diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e4efc3e00..7a1677355 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-24.04 if: ${{ always() }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Update ubuntu run: sudo apt-get update - name: Install software-properties-common diff --git a/web/package.json.in b/web/package.json.in index 95606423d..ee361824c 100644 --- a/web/package.json.in +++ b/web/package.json.in @@ -18,7 +18,7 @@ "sanitize-html": "^2.11.0" }, "scripts": { - "test": "mocha" + "test": "mocha test/**/*.test.js" }, "repository": { "type": "git", diff --git a/web/static/components/provenance/assets/arrow-markers.js b/web/static/components/provenance/assets/arrow-markers.js index 762eb31c9..c5b868ca1 100644 --- a/web/static/components/provenance/assets/arrow-markers.js +++ b/web/static/components/provenance/assets/arrow-markers.js @@ -29,7 +29,7 @@ function defineArrowMarkerComp(a_svg) { } // New version marker at 'end' -function defineArrowMarkerNewVer(a_svg, a_name) { +function defineArrowMarkerNewVer(a_svg) { a_svg .append("defs") .append("marker") diff --git a/web/static/components/provenance/customization_modal.js b/web/static/components/provenance/customization_modal.js new file mode 100644 index 000000000..0a5b731cf --- /dev/null +++ b/web/static/components/provenance/customization_modal.js @@ -0,0 +1,237 @@ +import { DEFAULTS } from "./state.js"; + +function showCustomizationModal(node, x, y, currentCustomizationNode) { + const modal = document.getElementById("customization-modal"); + if (!modal) return; + + // Set the current node being customized + currentCustomizationNode = node; + + // Save original values for reverting if cancelled + if (window.saveOriginalValues) { + window.saveOriginalValues(node); + } + + const nodeColorInput = document.getElementById("node-color-input"); + // Helper function to convert RGB to hex format + const rgbToHex = (rgb) => { + return "#" + rgb.map((x) => parseInt(x).toString(16).padStart(2, "0")).join(""); + }; + + // Helper function to get the default color from CSS + const getDefaultColor = (node) => { + const nodeElement = d3.select(`[id="${node.id}"] circle.obj`).node(); + if (!nodeElement) { + return DEFAULTS.NODE_COLOR; + } + + const computedStyle = window.getComputedStyle(nodeElement); + const fillColor = computedStyle.fill; + + if (fillColor && fillColor !== "none") { + if (fillColor.startsWith("rgb")) { + const rgb = fillColor.match(/\d+/g); + return rgb && rgb.length === 3 ? rgbToHex(rgb) : DEFAULTS.NODE_COLOR; + } + return fillColor; + } + + return DEFAULTS.NODE_COLOR; + }; + + // Get the actual current node color + nodeColorInput.value = node.nodeColor || getDefaultColor(node); + + const labelSizeSlider = document.getElementById("label-size-slider"); + const labelSizeValue = labelSizeSlider.nextElementSibling; + labelSizeSlider.value = node.labelSize || DEFAULTS.LABEL_SIZE; + labelSizeValue.textContent = `${labelSizeSlider.value}px`; + + const labelColorInput = document.getElementById("label-color-input"); + labelColorInput.value = node.labelColor || DEFAULTS.LABEL_COLOR; + + const anchorCheckbox = document.getElementById("anchor-checkbox"); + anchorCheckbox.checked = node.anchored || false; + + // Position and show modal + modal.style.left = `${x}px`; + modal.style.top = `${y}px`; + modal.style.display = "block"; + + // Return the current node for reference + return currentCustomizationNode; +} + +/** + * Makes a modal element draggable by its header + * Optimized to only attach document listeners during drag operations + * @param {HTMLElement} modal - The modal element to make draggable + */ +function makeModalDraggable(modal) { + let offsetX, offsetY; + const header = modal.querySelector(".modal-header") || modal; + + // Handle mouse movement during drag + function handleMouseMove(e) { + modal.style.left = `${e.clientX - offsetX}px`; + modal.style.top = `${e.clientY - offsetY}px`; + } + + // Handle end of drag operation + function handleMouseUp() { + // Remove event listeners when dragging ends to improve performance + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + } + + // Start dragging when mousedown on header + header.addEventListener("mousedown", function (e) { + // Calculate initial offset + offsetX = e.clientX - modal.offsetLeft; + offsetY = e.clientY - modal.offsetTop; + + // Add event listeners for dragging only when needed + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + e.preventDefault(); + }); +} + +function createCustomizationModal() { + // Remove existing modal if it exists + const existingModal = document.getElementById("customization-modal"); + if (existingModal) { + document.body.removeChild(existingModal); + } + + const modal = document.createElement("div"); + modal.id = "customization-modal"; + modal.className = "customization-modal"; + modal.style.display = "none"; + + // Add a draggable header + const modalHeader = document.createElement("div"); + modalHeader.className = "modal-header"; + + // Title in the draggable header + const title = document.createElement("h3"); + title.textContent = "Customize Node & Label"; + modalHeader.appendChild(title); + modal.appendChild(modalHeader); + + // Node customization section + const nodeSection = document.createElement("div"); + nodeSection.className = "section"; + + // Node color picker + const nodeColorLabel = document.createElement("label"); + nodeColorLabel.textContent = "Node Color"; + nodeSection.appendChild(nodeColorLabel); + + const nodeColorRow = document.createElement("div"); + nodeColorRow.className = "control-row"; + + const nodeColorInput = document.createElement("input"); + nodeColorInput.type = "color"; + nodeColorInput.id = "node-color-input"; + nodeColorInput.value = DEFAULTS.NODE_COLOR; + + nodeColorRow.appendChild(nodeColorInput); + nodeSection.appendChild(nodeColorRow); + + modal.appendChild(nodeSection); + + // Label customization section + const labelSection = document.createElement("div"); + labelSection.className = "section"; + + const labelSizeLabel = document.createElement("label"); + labelSizeLabel.textContent = "Label Size"; + labelSection.appendChild(labelSizeLabel); + + const labelSizeRow = document.createElement("div"); + labelSizeRow.className = "control-row"; + + const labelSizeSlider = document.createElement("input"); + labelSizeSlider.type = "range"; + labelSizeSlider.min = "8"; + labelSizeSlider.max = "24"; + labelSizeSlider.value = DEFAULTS.LABEL_SIZE; + labelSizeSlider.id = "label-size-slider"; + + const labelSizeValue = document.createElement("span"); + labelSizeValue.className = "value"; + labelSizeValue.textContent = `${DEFAULTS.LABEL_SIZE}px`; + + labelSizeRow.appendChild(labelSizeSlider); + labelSizeRow.appendChild(labelSizeValue); + labelSection.appendChild(labelSizeRow); + + // Label color picker + const labelColorLabel = document.createElement("label"); + labelColorLabel.textContent = "Label Color"; + labelSection.appendChild(labelColorLabel); + + const labelColorRow = document.createElement("div"); + labelColorRow.className = "control-row"; + + const labelColorInput = document.createElement("input"); + labelColorInput.type = "color"; + labelColorInput.id = "label-color-input"; + labelColorInput.value = DEFAULTS.LABEL_COLOR; // Default text color + + labelColorRow.appendChild(labelColorInput); + labelSection.appendChild(labelColorRow); + + modal.appendChild(labelSection); + + // Anchor controls + const anchorSection = document.createElement("div"); + anchorSection.className = "section"; + + const anchorRow = document.createElement("div"); + anchorRow.className = "control-row checkbox-row"; + + const anchorCheckbox = document.createElement("input"); + anchorCheckbox.type = "checkbox"; + anchorCheckbox.id = "anchor-checkbox"; + + const anchorLabel = document.createElement("label"); + anchorLabel.htmlFor = "anchor-checkbox"; + anchorLabel.textContent = "Anchor Node"; + anchorLabel.classList.add("inline-label"); + + anchorRow.appendChild(anchorCheckbox); + anchorRow.appendChild(anchorLabel); + anchorSection.appendChild(anchorRow); + + modal.appendChild(anchorSection); + + // Buttons + const buttonsDiv = document.createElement("div"); + buttonsDiv.className = "buttons"; + + const applyButton = document.createElement("button"); + applyButton.textContent = "Apply"; + applyButton.className = "primary"; + applyButton.id = "apply-customization"; + + const closeButton = document.createElement("button"); + closeButton.textContent = "Close"; + closeButton.id = "close-customization"; + + buttonsDiv.appendChild(closeButton); + buttonsDiv.appendChild(applyButton); + + modal.appendChild(buttonsDiv); + + document.body.appendChild(modal); + + // Make the modal draggable + makeModalDraggable(modal); + + return modal; +} + +export { showCustomizationModal, createCustomizationModal }; diff --git a/web/static/components/provenance/panel_graph.js b/web/static/components/provenance/panel_graph.js index b785401e8..8933d7e58 100644 --- a/web/static/components/provenance/panel_graph.js +++ b/web/static/components/provenance/panel_graph.js @@ -7,84 +7,124 @@ import { defineArrowMarkerDeriv, defineArrowMarkerNewVer, } from "./assets/arrow-markers.js"; +import { DEFAULTS, GraphState, ThemeObserver } from "./state.js"; +import { + createCustomizationModal, + showCustomizationModal as showModal, +} from "./customization_modal.js"; +import { + createNode, + graphCountConnected, + graphPrune, + graphPruneCalc, + graphPruneReset, + isLeafNode, + makeLabel, +} from "./utils.js"; + +// Dynamically load the styles CSS +(function loadStyles() { + if (!document.getElementById("graph-styles-css")) { + const link = document.createElement("link"); + link.id = "graph-styles-css"; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = "./styles/graph_styles.css"; + document.head.appendChild(link); + } + // Add custom styles for the customization modal + if (!document.getElementById("customization-modal-css")) { + const link = document.createElement("link"); + link.id = "customization-modal-css"; + link.rel = "stylesheet"; + link.type = "text/css"; + link.href = "./styles/customization_modal.css"; + document.head.appendChild(link); + } + + // Apply initial theme class from settings if available + if (window.settings && window.settings.theme) { + document.body.classList.add("theme-" + window.settings.theme); + } +})(); export function newGraphPanel(a_id, a_frame, a_parent) { return new GraphPanel(a_id, a_frame, a_parent); } -function makeLabel(node, item) { - //console.log("makeLabel",node,item); - if (item.alias) { - node.label = item.alias; - } else node.label = item.id; - - node.label += util.generateNoteSpan(item, true); -} - function GraphPanel(a_id, a_frame, a_parent) { - //var graph_div = $(a_id,a_frame); - var inst = this; - var node_data = []; - var link_data = []; - var graph_center_x = 200; - var nodes_grp = null; - var nodes = null; - var links_grp = null; - var links = null; - var svg = null; - var simulation = null; - var sel_node = null; - var focus_node_id, + let inst = this; + let node_data = []; + let link_data = []; + let graph_center_x = 200; + let nodes_grp = null; + let nodes = null; + let links_grp = null; + let links = null; + let svg = null; + let simulation = null; + let sel_node = null; + let focus_node_id, sel_node_id, - r = 10; + r = DEFAULTS.NODE_SIZE; + + // Customization modal for node/label editing + let customizationModal = null; + let currentCustomizationNode = null; + + // State management using observer pattern + let graphStateManager = new GraphState(); + + // Register theme observer to manage theme changes + const themeObserver = new ThemeObserver(); + graphStateManager.addObserver(themeObserver); + + // Set initial theme from settings if available + if (window.settings && window.settings.theme) { + graphStateManager.setTheme(window.settings.theme); + + // Hook into the global theme setting to keep in sync + if (typeof window.settings.setTheme === "function") { + const originalSetTheme = window.settings.setTheme; + window.settings.setTheme = function (theme) { + // Call original function + originalSetTheme(theme); + // Update our graph state + graphStateManager.setTheme(theme); + }; + } + } this.load = function (a_id, a_sel_node_id) { focus_node_id = a_id; sel_node_id = a_sel_node_id ? a_sel_node_id : a_id; sel_node = null; - //console.log("owner:",a_owner); api.dataGetDepGraph(a_id, function (a_data) { - var item, i, j, dep, node; - link_data = []; + let new_node_data = []; + let id_map = {}; - var new_node_data = []; - var id, - id_map = {}; - - for (i in a_data.item) { - item = a_data.item[i]; - //console.log("node:",item); - node = { - id: item.id, - doi: item.doi, - size: item.size, - notes: item.notes, - inhErr: item.inhErr, - locked: item.locked, - links: [], - }; - - makeLabel(node, item); + // Create nodes using factory pattern + let id; + for (let i in a_data.item) { + const item = a_data.item[i]; + const node = createNode(item); - if (item.gen != undefined) { - node.row = item.gen; - node.col = 0; - } - - if (item.id == a_id) { + if (item.id === a_id) { node.comp = true; } - if (item.id == sel_node_id) { + if (item.id === sel_node_id) { sel_node = node; } id_map[node.id] = new_node_data.length; new_node_data.push(node); - for (j in item.dep) { - dep = item.dep[j]; + + // Create links + for (let j in item.dep) { + const dep = item.dep[j]; id = item.id + "-" + dep.id; link_data.push({ source: item.id, @@ -95,23 +135,39 @@ function GraphPanel(a_id, a_frame, a_parent) { } } - for (i in link_data) { - dep = link_data[i]; - - node = new_node_data[id_map[dep.source]]; - node.links.push(dep); - node = new_node_data[id_map[dep.target]]; - node.links.push(dep); + // Connect links to nodes + for (let i in link_data) { + const dep = link_data[i]; + const sourceNode = new_node_data[id_map[dep.source]]; + sourceNode.links.push(dep); + const targetNode = new_node_data[id_map[dep.target]]; + targetNode.links.push(dep); } // Copy any existing position data to new nodes - var node2; - for (i in node_data) { - node = node_data[i]; - if (id_map[node.id] != undefined) { - node2 = new_node_data[id_map[node.id]]; + for (let i in node_data) { + const node = node_data[i]; + if (id_map[node.id] !== undefined) { + const node2 = new_node_data[id_map[node.id]]; + + // Copy position node2.x = node.x; node2.y = node.y; + + // Copy anchor state + if (node.anchored) { + node2.anchored = true; + node2.fx = node.x; + node2.fy = node.y; + } + + // Copy customizations + if (node.nodeSize) node2.nodeSize = node.nodeSize; + if (node.nodeColor) node2.nodeColor = node.nodeColor; + if (node.labelSize) node2.labelSize = node.labelSize; + if (node.labelColor) node2.labelColor = node.labelColor; + if (node.labelOffsetX !== undefined) node2.labelOffsetX = node.labelOffsetX; + if (node.labelOffsetY !== undefined) node2.labelOffsetY = node.labelOffsetY; } } @@ -126,30 +182,224 @@ function GraphPanel(a_id, a_frame, a_parent) { } } + // Initialize customization modal if not already created + if (!customizationModal) { + customizationModal = createCustomizationModal(); + setupCustomizationModalEvents(); + } + renderGraph(); + // Initialize graph controls + inst.addGraphControls(); panel_info.showSelectedInfo(sel_node_id, inst.checkGraphUpdate); a_parent.updateBtnState(); }); }; + // Set up event handlers for the customization modal + function setupCustomizationModalEvents() { + const modal = document.getElementById("customization-modal"); + if (!modal) return; + + // Store original and temporary changes + let originalValues = {}; + let tempChanges = {}; + + // Function to save original values when the modal opens + window.saveOriginalValues = function (node) { + if (!node) return; + + // Save all original values that can be modified + originalValues = { + nodeColor: node.nodeColor, + labelSize: node.labelSize, + labelColor: node.labelColor, + anchored: node.anchored, + fx: node.fx, + fy: node.fy, + }; + }; + + // Function to restore original values when cancelling + function restoreOriginalValues() { + if (!currentCustomizationNode) return; + + // Restore all original values + if (originalValues.nodeColor !== undefined) { + currentCustomizationNode.nodeColor = originalValues.nodeColor; + } else { + delete currentCustomizationNode.nodeColor; + } + + if (originalValues.labelSize !== undefined) { + currentCustomizationNode.labelSize = originalValues.labelSize; + } else { + delete currentCustomizationNode.labelSize; + } + + if (originalValues.labelColor !== undefined) { + currentCustomizationNode.labelColor = originalValues.labelColor; + } else { + delete currentCustomizationNode.labelColor; + } + + // Render to show original values + renderGraph(); + } + + // Node color input + const nodeColorInput = document.getElementById("node-color-input"); + nodeColorInput.addEventListener("input", function () { + if (currentCustomizationNode) { + // Store in temp changes and apply for preview only + tempChanges.nodeColor = this.value; + currentCustomizationNode.nodeColor = this.value; + renderGraph(); + } + }); + + // Label size slider + const labelSizeSlider = document.getElementById("label-size-slider"); + const labelSizeValue = labelSizeSlider.nextElementSibling; + + labelSizeSlider.addEventListener("input", function () { + labelSizeValue.textContent = `${this.value}px`; + if (currentCustomizationNode) { + // Store in temp changes and apply for preview only + tempChanges.labelSize = parseInt(this.value); + currentCustomizationNode.labelSize = parseInt(this.value); + renderGraph(); + } + }); + + // Label color input + const labelColorInput = document.getElementById("label-color-input"); + labelColorInput.addEventListener("input", function () { + if (currentCustomizationNode) { + // Store in temp changes and apply for preview only + tempChanges.labelColor = this.value; + currentCustomizationNode.labelColor = this.value; + renderGraph(); + } + }); + + // Anchor checkbox - store change but don't apply until "Apply" button is clicked + const anchorCheckbox = document.getElementById("anchor-checkbox"); + anchorCheckbox.addEventListener("change", function () { + if (currentCustomizationNode) { + // Store the anchoring state in temporary changes + tempChanges.anchorChecked = this.checked; + tempChanges.nodeX = currentCustomizationNode.x; + tempChanges.nodeY = currentCustomizationNode.y; + + // Just preview the change without actually fixing the position + const previewClass = document.querySelector(".anchor-preview"); + if (previewClass) { + previewClass.style.display = this.checked ? "block" : "none"; + } + } + }); + + // Close button - discard changes + const closeButton = document.getElementById("close-customization"); + closeButton.addEventListener("click", function () { + // Revert any preview changes back to original values + restoreOriginalValues(); + + // Hide anchor preview if showing + const previewClass = document.querySelector(".anchor-preview"); + if (previewClass) { + previewClass.style.display = "none"; + } + + // Clear temporary changes + tempChanges = {}; + modal.style.display = "none"; + currentCustomizationNode = null; + }); + + // Apply button - commit all changes + const applyButton = document.getElementById("apply-customization"); + applyButton.addEventListener("click", function () { + if (!currentCustomizationNode) { + modal.style.display = "none"; + return; + } + + // Apply all changes permanently + + // Node color change + if (tempChanges.hasOwnProperty("nodeColor")) { + currentCustomizationNode.nodeColor = tempChanges.nodeColor; + } + + // Label size change + if (tempChanges.hasOwnProperty("labelSize")) { + currentCustomizationNode.labelSize = tempChanges.labelSize; + } + + // Label color change + if (tempChanges.hasOwnProperty("labelColor")) { + currentCustomizationNode.labelColor = tempChanges.labelColor; + } + + // Apply the anchoring change if it was made + if (tempChanges.hasOwnProperty("anchorChecked")) { + currentCustomizationNode.anchored = tempChanges.anchorChecked; + + if (tempChanges.anchorChecked) { + // Fix the node position + currentCustomizationNode.fx = tempChanges.nodeX; + currentCustomizationNode.fy = tempChanges.nodeY; + } else { + // Release the fixed position + delete currentCustomizationNode.fx; + delete currentCustomizationNode.fy; + } + } + + // Save changes to persistent state if state manager exists + if (graphStateManager) { + graphStateManager.saveState(node_data); + } + + // Update the visualization + renderGraph(); + + // Clear temporary changes + tempChanges = {}; + modal.style.display = "none"; + currentCustomizationNode = null; + }); + + // Close modal when clicking outside + document.addEventListener("click", function (e) { + if ( + modal.style.display === "block" && + !modal.contains(e.target) && + !e.target.closest(".node") + ) { + // Handle the same way as clicking Close button + closeButton.click(); + } + }); + } + this.checkGraphUpdate = function (a_data, a_source) { console.log("graph check updates", a_data, a_source); console.log("sel node", sel_node); - // source is sel_node_id, so check sel_node - if (a_data.size != sel_node.size) { + if (a_data.size !== sel_node.size) { console.log("size diff, update!"); model.update([a_data]); } }; - // TODO Why are IDs separate from data? - this.update = function (a_ids, a_data) { // Only updates locked and alias of impacted nodes - var ids = Array.isArray(a_ids) ? a_ids : [a_ids]; - var data = Array.isArray(a_data) ? a_data : [a_data]; - var i, + let ids = Array.isArray(a_ids) ? a_ids : [a_ids]; + let data = Array.isArray(a_data) ? a_data : [a_data]; + let i, node, item, render = false; @@ -187,7 +437,7 @@ function GraphPanel(a_id, a_frame, a_parent) { }; this.getSelectedNodes = function () { - var sel = []; + let sel = []; if (sel_node) { sel.push({ key: sel_node.id, @@ -202,9 +452,115 @@ function GraphPanel(a_id, a_frame, a_parent) { if (focus_node_id) return focus_node_id; }; - // NOTE: D3 changes link source and target IDs strings (in link_data) to node references (in node_data) when renderGraph runs + // Add UI buttons for saving and loading graph state + this.addGraphControls = function (containerSelector) { + const controlsId = "graph-controls"; + const helpTipId = "graph-help-tooltip"; + let controlsDiv = document.getElementById(controlsId); + let helpTip = document.getElementById(helpTipId); + + // Create container for graph controls if it doesn't exist + if (!controlsDiv) { + controlsDiv = document.createElement("div"); + controlsDiv.id = controlsId; + controlsDiv.className = "graph-controls"; + + // "Show Help" button + const showHelpButton = document.createElement("button"); + showHelpButton.textContent = "Show Help"; + showHelpButton.id = "show-graph-help-button"; + showHelpButton.addEventListener("click", function () { + const tipElement = document.getElementById(helpTipId); + if (tipElement) { + tipElement.classList.add("visible"); + } + }); + controlsDiv.appendChild(showHelpButton); + } + + // Create help tooltip if it doesn't exist + if (!helpTip) { + helpTip = document.createElement("div"); + helpTip.id = helpTipId; + helpTip.className = "graph-tooltip"; + + const closeButton = document.createElement("span"); + closeButton.innerHTML = "×"; // X button + closeButton.className = "graph-tooltip-close"; + closeButton.addEventListener("click", function () { + const tipElement = document.getElementById(helpTipId); + if (tipElement) { + tipElement.classList.remove("visible"); + } + }); + helpTip.appendChild(closeButton); + + const helpText = document.createElement("div"); + helpText.innerHTML = + "Graph Controls:
" + + "• Drag nodes to move them
" + + "• Shift+Drag From node to anchor nodes
" + + "• Alt+Drag From node to move label
" + + "• Right-click for customization options
" + + "• Double-click to toggle anchor"; + helpTip.appendChild(helpText); + helpTip.classList.add("visible"); // Initially visible + } + + // Add controls and tooltip to the graph container's parent + let graphContainerParent; + if (containerSelector) { + graphContainerParent = document.querySelector(containerSelector); + } else { + const selector = a_id.startsWith("#") ? a_id : "#" + a_id; + const svgElement = document.querySelector(selector); + if (svgElement && svgElement.parentElement) { + graphContainerParent = svgElement.parentElement; + } + } + + if (graphContainerParent) { + graphContainerParent.style.position = "relative"; // Important for absolute positioning of tooltip + if (!document.getElementById(controlsId) && controlsDiv) { + // Append only if not already there + graphContainerParent.appendChild(controlsDiv); + } + if (!document.getElementById(helpTipId) && helpTip) { + // Append only if not already there + graphContainerParent.appendChild(helpTip); + } + } else { + console.error( + "Graph container parent not found for controls:", + containerSelector || a_id, + ); + } + }; + + function selNode(d, parentNode) { + if (sel_node !== d) { + d3.select(".highlight").attr("class", "select hidden"); + d3.select(parentNode).select(".select").attr("class", "select highlight"); + sel_node = d; + sel_node_id = d.id; + panel_info.showSelectedInfo(d.id, inst.checkGraphUpdate); + a_parent.updateBtnState(); + } + } + + /** + * Renders the graph using D3.js force layout + * + * IMPORTANT: D3.js force layout automatically converts link source and target properties. + * Before rendering: source and target are string IDs + * After rendering: source and target become references to the actual node objects + * + * This transformation happens as part of D3's internal processing and is essential + * for the force-directed layout to work correctly. However, it means the structure + * of link objects changes during the application lifecycle. + */ function renderGraph() { - var g; + let g; links = links_grp.selectAll("line").data(link_data, function (d) { return d.id; @@ -214,7 +570,6 @@ function GraphPanel(a_id, a_frame, a_parent) { .enter() .append("line") .attr("marker-start", function (d) { - //console.log("link enter 1"); switch (d.ty) { case 0: return "url(#arrow-derivation)"; @@ -226,15 +581,8 @@ function GraphPanel(a_id, a_frame, a_parent) { return ""; } }) - /*.attr('marker-end',function(d){ - //console.log("link enter 1"); - switch ( d.ty ){ - case 2: return 'url(#arrow-new-version)'; - default: return ''; - } - })*/ + .attr("class", function (d) { - //console.log("link enter 2"); switch (d.ty) { case 0: return "link derivation"; @@ -255,14 +603,11 @@ function GraphPanel(a_id, a_frame, a_parent) { // Update nodes.select("circle.obj").attr("class", function (d) { - var res = "obj "; - - //console.log("upd node", d ); + let res = "obj "; - if (d.id == focus_node_id) res += "main"; - else if (d.row != undefined) res += "prov"; + if (d.id === focus_node_id) res += "main"; + else if (d.row !== undefined) res += "prov"; else { - //console.log("upd other node", d ); res += "other"; } @@ -288,8 +633,7 @@ function GraphPanel(a_id, a_frame, a_parent) { }); nodes.selectAll(".node > circle.select").attr("class", function (d) { - if (d.id == sel_node_id) { - //sel_node = d; + if (d.id === sel_node_id) { return "select highlight"; } else return "select hidden"; }); @@ -301,13 +645,15 @@ function GraphPanel(a_id, a_frame, a_parent) { .call(d3.drag().on("start", dragStarted).on("drag", dragged).on("end", dragEnded)); g.append("circle") - .attr("r", r) + .attr("r", function (d) { + return d.nodeSize || r; + }) .attr("class", function (d) { - var res = "obj "; + let res = "obj "; //console.log("node enter 1"); - if (d.id == focus_node_id) res += "main"; - else if (d.row != undefined) res += "prov"; + if (d.id === focus_node_id) res += "main"; + else if (d.row !== undefined) res += "prov"; else { res += "other"; //console.log("new other node", d ); @@ -318,52 +664,56 @@ function GraphPanel(a_id, a_frame, a_parent) { return res; }) + .style("fill", function (d) { + return d.nodeColor || null; // Use CSS default if not specified + }) .on("mouseover", function (d) { - //console.log("mouse over"); + const nodeSize = d.nodeSize || r; d3.select(this) .transition() .duration(150) - .attr("r", r * 1.5); + .attr("r", nodeSize * 1.5); }) .on("mouseout", function (d) { - d3.select(this).transition().duration(500).attr("r", r); + const nodeSize = d.nodeSize || r; + d3.select(this).transition().duration(500).attr("r", nodeSize); }) - .on("dblclick", function (d, i) { - //console.log("dbl click"); - if (d.comp) inst.collapseNode(); - else inst.expandNode(); + .on("click", function (d) { + // Select the node when clicked + selNode(d, this.parentNode); d3.event.stopPropagation(); }) - .on("click", function (d, i) { - if (sel_node != d) { - d3.select(".highlight").attr("class", "select hidden"); - d3.select(this.parentNode).select(".select").attr("class", "select highlight"); - sel_node = d; - sel_node_id = d.id; - panel_info.showSelectedInfo(d.id, inst.checkGraphUpdate); - a_parent.updateBtnState(); - } - - if (d3.event.ctrlKey) { - if (d.comp) inst.collapseNode(); - else inst.expandNode(); - } + .on("contextmenu", function (d, i) { + // Prevent default context menu + d3.event.preventDefault(); + + // Select the node if not already selected + selNode(d, this.parentNode); + + // Show customization modal at mouse position + currentCustomizationNode = showModal( + d, + d3.event.pageX, + d3.event.pageY, + currentCustomizationNode, + renderGraph, + ); d3.event.stopPropagation(); }); g.append("circle") - .attr("r", r * 1.5) + .attr("r", function (d) { + // Scale the selection circle based on the node size + return (d.nodeSize || r) * 1.5; + }) .attr("class", function (d) { - //console.log("node enter 3"); - - if (d.id == sel_node_id) { - //sel_node = d; + if (d.id === sel_node_id) { return "select highlight"; } else return "select hidden"; }); - var n2 = g.filter(function (d) { + let n2 = g.filter(function (d) { return d.size; }); n2.append("line") @@ -373,8 +723,6 @@ function GraphPanel(a_id, a_frame, a_parent) { .attr("x2", r * 0.5) .attr("y2", -r * 0.3) .attr("class", "data"); - //.attr("stroke-width", 1 ) - //.attr("stroke", "white" ); n2.append("line") .attr("pointer-events", "none") @@ -383,8 +731,6 @@ function GraphPanel(a_id, a_frame, a_parent) { .attr("x2", r * 0.5) .attr("y2", 0) .attr("class", "data"); - //.attr("stroke-width", 1 ) - //.attr("stroke", "white" ); n2.append("line") .attr("pointer-events", "none") @@ -393,8 +739,6 @@ function GraphPanel(a_id, a_frame, a_parent) { .attr("x2", r * 0.5) .attr("y2", r * 0.3) .attr("class", "data"); - //.attr("stroke-width", 1 ) - //.attr("stroke", "white" ); g.append("text") .attr("class", "label") @@ -405,7 +749,64 @@ function GraphPanel(a_id, a_frame, a_parent) { if (d.locked) return r + 12; else return r; }) - .attr("y", -r); + .attr("y", -r) + .style("font-size", function (d) { + return (d.labelSize || DEFAULTS.LABEL_SIZE) + "px"; + }) + .style("fill", function (d) { + return d.labelColor || DEFAULTS.LABEL_COLOR; + }) + .on("contextmenu", function (d, i) { + // Prevent default context menu + d3.event.preventDefault(); + + // Select the node if not already selected + selNode(d, this.parentNode); + + // Show customization modal at mouse position + currentCustomizationNode = showModal( + d, + d3.event.pageX, + d3.event.pageY, + currentCustomizationNode, + renderGraph, + ); + + d3.event.stopPropagation(); + }) + // Make labels draggable independently with Alt key + .call( + d3 + .drag() + .on("start", function (d) { + if (!d3.event.active) simulation.alphaTarget(0.3).restart(); + d.draggingLabel = true; + d.labelDragStartX = d3.event.x; + d.labelDragStartY = d3.event.y; + d3.event.sourceEvent.stopPropagation(); + }) + .on("drag", function (d) { + if (!d.labelOffsetX) d.labelOffsetX = 0; + if (!d.labelOffsetY) d.labelOffsetY = 0; + + // Update the label offset based on drag movement + d.labelOffsetX += d3.event.x - d.labelDragStartX; + d.labelOffsetY += d3.event.y - d.labelDragStartY; + + // Update the start position for the next drag event + d.labelDragStartX = d3.event.x; + d.labelDragStartY = d3.event.y; + + // Update the visualization + simTick(); + d3.event.sourceEvent.stopPropagation(); + }) + .on("end", function (d) { + if (!d3.event.active) simulation.alphaTarget(0); + d.draggingLabel = false; + d3.event.sourceEvent.stopPropagation(); + }), + ); g.append("text") .attr("class", "locked") @@ -421,12 +822,11 @@ function GraphPanel(a_id, a_frame, a_parent) { nodes = nodes_grp.selectAll("g"); if (simulation) { - //console.log("restart sim"); simulation.nodes(node_data).force("link").links(link_data); simulation.alpha(1).restart(); } else { - var linkForce = d3 + let linkForce = d3 .forceLink(link_data) .strength(function (d) { switch (d.ty) { @@ -451,23 +851,30 @@ function GraphPanel(a_id, a_frame, a_parent) { "row", d3 .forceY(function (d, i) { - return d.row != undefined ? 75 + d.row * 75 : 0; + return d.row !== undefined ? 75 + d.row * 75 : 0; }) .strength(function (d) { - return d.row != undefined ? 0.05 : 0; + return d.row !== undefined ? 0.05 : 0; }), ) .force( "col", d3 .forceX(function (d, i) { - return d.col != undefined ? graph_center_x : 0; + return d.col !== undefined ? graph_center_x : 0; }) .strength(function (d) { - return d.col != undefined ? 0.05 : 0; + return d.col !== undefined ? 0.05 : 0; }), ) .force("link", linkForce) + .force( + "collide", + d3.forceCollide().radius(function (d) { + // Base radius plus some extra space based on label length + return r * 1.5 + (d.label ? d.label.length * 0.8 : 0); + }), + ) .on("tick", simTick); } } @@ -475,40 +882,131 @@ function GraphPanel(a_id, a_frame, a_parent) { function dragStarted(d) { //console.log("drag start",d.id); if (!d3.event.active) simulation.alphaTarget(0.3).restart(); - d.fx = d3.event.x; - d.fy = d3.event.y; - d3.event.sourceEvent.stopPropagation(); + + // If node was previously anchored, maintain that state + if (d.anchored) { + // Node is already anchored, just update position + d.fx = d3.event.x; + d.fy = d3.event.y; + } else { + // Normal behavior + d.fx = d3.event.x; + d.fy = d3.event.y; + } + + // Check if Alt key is pressed to drag the label independently + if (d3.event.sourceEvent && d3.event.sourceEvent.altKey) { + d.draggingLabel = true; + d.labelDragStartX = d3.event.x; + d.labelDragStartY = d3.event.y; + + // Initialize label offsets if they don't exist + if (d.labelOffsetX === undefined) d.labelOffsetX = 0; + if (d.labelOffsetY === undefined) d.labelOffsetY = 0; + } + + if (d3.event.sourceEvent) d3.event.sourceEvent.stopPropagation(); } function dragged(d) { - //console.log("drag",d3.event.x,d3.event.y); - d.fx = d3.event.x; - d.fy = d3.event.y; + if (d.draggingLabel) { + // Dragging the label independently + // Update the label offset based on drag movement + d.labelOffsetX += d3.event.x - d.labelDragStartX; + d.labelOffsetY += d3.event.y - d.labelDragStartY; + + // Update the start position for the next drag event + d.labelDragStartX = d3.event.x; + d.labelDragStartY = d3.event.y; + } else { + // Normal node dragging + d.fx = d3.event.x; + d.fy = d3.event.y; + } + simTick(); - d3.event.sourceEvent.stopPropagation(); + if (d3.event.sourceEvent) d3.event.sourceEvent.stopPropagation(); } function dragEnded(d) { //console.log("drag end",d.id); if (!d3.event.active) simulation.alphaTarget(0); - d.x = d.fx; - d.y = d.fy; - delete d.fx; - delete d.fy; + + if (d.draggingLabel) { + // End of label dragging + d.draggingLabel = false; + // Label position is already updated in the dragged function + } else { + // Check if shift key is pressed to anchor the node + if (d3.event.sourceEvent && d3.event.sourceEvent.shiftKey) { + // Keep the fixed position (anchored) + d.anchored = true; + // Visual feedback for anchored state + d3.select(d3.event.sourceEvent.target.parentNode) + .select("circle.obj") + .classed("anchored", true); + } else if (!d.anchored) { + // Normal behavior - release the node if it's not anchored + d.x = d.fx; + d.y = d.fy; + delete d.fx; + delete d.fy; + } + } + + // Double-click to toggle anchor state + if (d3.event.sourceEvent && d3.event.sourceEvent.detail === 2 && !d.draggingLabel) { + // Prevent the double-click from triggering zoom + d3.event.sourceEvent.preventDefault(); + d3.event.sourceEvent.stopPropagation(); + + d.anchored = !d.anchored; + if (d.anchored) { + d.fx = d.x; + d.fy = d.y; + // Visual feedback for anchored state + d3.select(d3.event.sourceEvent.target.parentNode) + .select("circle.obj") + .classed("anchored", true); + } else { + delete d.fx; + delete d.fy; + // Remove visual feedback for anchored state + d3.select(d3.event.sourceEvent.target.parentNode) + .select("circle.obj") + .classed("anchored", false); + } + + // We're using double-click for anchoring, so don't also use it for expand/collapse + return; + } + //console.log("at:",d); - d3.event.sourceEvent.stopPropagation(); + if (d3.event.sourceEvent) d3.event.sourceEvent.stopPropagation(); } + /** + * Finds a node by its ID in the node_data array + * + * @param {string} a_id - The ID of the node to find + * @returns {object|undefined} - The node object if found, undefined otherwise + */ function findNode(a_id) { - for (var i in node_data) { - if (node_data[i].id == a_id) return node_data[i]; - } + return node_data.find((node) => node.id === a_id); } + /** + * Finds a link by its ID in the link_data array + * + * IMPORTANT: Link objects have a dynamic structure that changes during the D3 force layout process: + * This transformation happens automatically when D3.js processes the links for + * force-directed layout, as noted in the comment above renderGraph(). + * + * @param {string} a_id - The ID of the link to find + * @returns {object|undefined} - The link object if found, undefined otherwise + */ function findLink(a_id) { - for (var i in link_data) { - if (link_data[i].id == a_id) return link_data[i]; - } + return link_data.find((link) => link.id === a_id); } this.expandNode = function () { @@ -516,24 +1014,23 @@ function GraphPanel(a_id, a_frame, a_parent) { api.dataView(sel_node_id, function (data) { console.log("expand node data:", data); if (data) { - var rec = data; + let rec = data; sel_node.comp = true; - var dep, new_node, link, i, id; + let dep, new_node, link, i, id; for (i in rec.deps) { dep = rec.deps[i]; - //console.log("dep:",dep); - if (dep.dir == "DEP_IN") id = dep.id + "-" + rec.id; + if (dep.dir === "DEP_IN") id = dep.id + "-" + rec.id; else id = rec.id + "-" + dep.id; link = findLink(id); if (link) continue; link = { id: id, ty: model.DepTypeFromString[dep.type] }; - if (dep.dir == "DEP_IN") { + if (dep.dir === "DEP_IN") { link.source = dep.id; link.target = rec.id; } else { @@ -545,6 +1042,7 @@ function GraphPanel(a_id, a_frame, a_parent) { new_node = findNode(dep.id); if (!new_node) { + // Create the new node new_node = { id: dep.id, notes: dep.notes, @@ -566,76 +1064,110 @@ function GraphPanel(a_id, a_frame, a_parent) { } }; + /** + * Collapses the selected node by removing its connected nodes + * while maintaining the overall graph structure + * + * The collapse operation works by: + * 1. Identifying nodes connected to the selected node + * 2. Marking nodes for pruning that aren't needed for the graph structure + * 3. Removing those nodes and their links from the visualization + * 4. Updating the graph to maintain the core relationships + */ this.collapseNode = function () { - //console.log("collapse node"); - if (sel_node) { - var i, + // Only collapse nodes that are marked as composite and selected + if (sel_node && sel_node.comp) { + let i, link, dest, loc_trim = []; + // Mark node as no longer a composite sel_node.comp = false; + // Process each link connected to the selected node for (i = sel_node.links.length - 1; i >= 0; i--) { link = sel_node.links[i]; - //console.log("lev 0 link:",link); - dest = link.source != sel_node ? link.source : link.target; + // Get the node at the other end of the link + dest = link.source !== sel_node ? link.source : link.target; + + // Calculate which nodes should be pruned (removed) based on connectivity graphPruneCalc(dest, [sel_node.id], sel_node); - if (!dest.prune && dest.row == undefined) { - graphPruneReset(-1); - link.prune += 1; - //graphPrune(); + // If the destination node shouldn't be pruned and isn't a root node + if (!dest.prune && dest.row === undefined) { + // Reset prune flags and mark this link for pruning + graphPruneReset(link_data, node_data); + link.prune = true; + loc_trim.push(link); } + // Handle nodes marked for pruning if (dest.prune) { - //console.log("PRUNE ALL"); - graphPrune(); - } else if (dest.row == undefined) { - //console.log("PRUNE LOCAL EDGE ONLY"); - graphPruneReset(); + // Remove this node and its links from the visualization + graphPrune(link_data, node_data); + } else if (dest.row === undefined) { + // For non-root nodes that aren't being pruned, + // reset flags and track the link for potential pruning + graphPruneReset(link_data, node_data); loc_trim.push(link); - //link.prune = true; - //graphPrune(); } else { - //console.log("PRUNE NONE"); - graphPruneReset(); + // For root nodes, just reset prune flags + graphPruneReset(link_data, node_data); } } - if (loc_trim.length < sel_node.links.length) { + // If we have links to prune, mark them and remove them + if (loc_trim.length > 0) { for (i in loc_trim) { loc_trim[i].prune = true; } - graphPrune(); + graphPrune(link_data, node_data); } - //graphPruneReset(); - + // Update the visualization with the changes renderGraph(); } }; + /** + * Hides the selected node by removing it from the visualization + * + * The hide operation only works on leaf nodes (nodes with only one connection) + * to preserve the overall graph structure. Hide differs from collapse in that: + * - Hide: Completely removes a node (only works on leaf nodes) + * - Collapse: Simplifies a part of the graph while maintaining relationships + */ this.hideNode = function () { - if (sel_node && sel_node.id != focus_node_id && node_data.length > 1) { - sel_node.prune = true; - // Check for disconnection of the graph - console.log("hide", sel_node.id); - var start = - sel_node.links[0].source == sel_node - ? sel_node.links[0].target - : sel_node.links[0].source; - console.log("start", start); - if (graphCountConnected(start, []) == node_data.length - 1) { - for (var i in sel_node.links) { - console.log("prune", i, sel_node.links[i]); - sel_node.links[i].prune = true; - } - graphPrune(); + if (sel_node && sel_node.id !== focus_node_id && node_data.length > 1) { + if (isLeafNode(sel_node)) { + // Additional check to verify removing this node won't disconnect the graph + // Get the node at the other end of the single connection + const connectedNode = + sel_node.links[0].source === sel_node + ? sel_node.links[0].target + : sel_node.links[0].source; + + // Ensure all other nodes can still be reached after removal + if (graphCountConnected(connectedNode, [sel_node.id]) === node_data.length - 1) { + sel_node.prune = true; + + for (let i in sel_node.links) { + sel_node.links[i].prune = true; + } - sel_node = node_data[0]; - sel_node_id = sel_node.id; - renderGraph(); + // Must use full link_data array (not just sel_node.links) so that + // all connections are properly removed from the visualization + graphPrune(link_data, node_data); + + sel_node = node_data[0]; + sel_node_id = sel_node.id; + renderGraph(); + } else { + // We should never really reach here since we can't hide a leaf-node + sel_node.prune = false; + util.setStatusText("Cannot hide this node as it would disconnect the graph"); + } } else { sel_node.prune = false; util.setStatusText("Cannot hide non-leaf nodes (try collapsing)"); @@ -647,9 +1179,7 @@ function GraphPanel(a_id, a_frame, a_parent) { // Called automatically from API module when data records are impacted by edits or annotations this.updateData = function (a_data) { - //console.log( "graph updating:", a_data ); - - var j, + let j, node, item, l, @@ -658,36 +1188,28 @@ function GraphPanel(a_id, a_frame, a_parent) { dep_cnt, render = false; - //if ( focus_node_id ) - // inst.load( focus_node_id, sel_node_id ); - // Scan updates for dependency changes that impact current graph, // if found, reload entire graph from DB // If not reloading, scan for changes to title, annotations, status... - for (var i in a_data) { + for (let i in a_data) { item = a_data[i]; - //console.log("examine:",i,item); node = findNode(i); if (node) { if (item.depsAvail) { - //console.log("deps avail on existing node:",node.links); // See if deps have changed l1 = {}; for (j in node.links) { l = node.links[j]; - if (l.source.id == i) l1[l.target.id] = l.ty; + if (l.source.id === i) l1[l.target.id] = l.ty; } - //console.log("l1:",l1); same = true; dep_cnt = 0; for (j in item.dep) { l = item.dep[j]; - //console.log("chk dep",l); - if (l.dir == "DEP_OUT") { - if (l1[l.id] != model.DepTypeFromString[l.type]) { - //console.log("type mismatch",l.id,l1[l.id],l.type); + if (l.dir === "DEP_OUT") { + if (l1[l.id] !== model.DepTypeFromString[l.type]) { same = false; break; } @@ -695,14 +1217,12 @@ function GraphPanel(a_id, a_frame, a_parent) { } } - if (same && Object.keys(l1).length != dep_cnt) { - //console.log("len mismatch", Object.keys( l1 ).length, dep_cnt); + if (same && Object.keys(l1).length !== dep_cnt) { same = false; } if (!same) { // Reload graph - //console.log("must reload graph (diff deps)"); inst.load(focus_node_id, sel_node_id); return; } @@ -712,14 +1232,13 @@ function GraphPanel(a_id, a_frame, a_parent) { node.locked = item.locked; node.notes = item.notes; node.size = item.size; - //console.log("updating:", node); makeLabel(node, item); - if (node == sel_node) panel_info.showSelectedInfo(sel_node_id); + if (node === sel_node) panel_info.showSelectedInfo(sel_node_id); } else if (item.depsAvail) { // See if this node might need to be added to graph for (j in item.dep) { l = item.dep[j]; - if (l.dir == "DEP_OUT" && findNode(l.id)) { + if (l.dir === "DEP_OUT" && findNode(l.id)) { //console.log("must reload graph (new ext deps)"); inst.load(focus_node_id, sel_node_id); return; @@ -734,77 +1253,65 @@ function GraphPanel(a_id, a_frame, a_parent) { } }; - function graphCountConnected(a_node, a_visited, a_from) { - var count = 0; - - if (a_visited.indexOf(a_node.id) < 0 && !a_node.prune) { - a_visited.push(a_node.id); - count++; - var link, dest; - for (var i in a_node.links) { - link = a_node.links[i]; - if (link != a_from) { - dest = link.source == a_node ? link.target : link.source; - count += graphCountConnected(dest, a_visited, link); - } - } - } - - return count; - } + function simTick() { + // Update node positions + nodes.attr("transform", function (d) { + return "translate(" + d.x + "," + d.y + ")"; + }); - function graphPrune() { - var i, j, item; - - for (i = link_data.length - 1; i >= 0; i--) { - item = link_data[i]; - if (item.prune) { - //console.log("pruning link:",item); - if (!item.source.prune) { - item.source.comp = false; - j = item.source.links.indexOf(item); - if (j != -1) { - item.source.links.splice(j, 1); - } else { - console.log("BAD INDEX IN SOURCE LINKS!"); - } - } - if (!item.target.prune) { - item.target.comp = false; - j = item.target.links.indexOf(item); - if (j != -1) { - item.target.links.splice(j, 1); - } else { - console.log("BAD INDEX IN TARGET LINKS!"); - } - } - link_data.splice(i, 1); - } - } + // Update label positions with offsets if they exist + nodes + .selectAll("text.label") + .attr("x", function (d) { + // Base position + let baseX = d.locked ? r + 12 : r; + // Apply offset if it exists + return baseX + (d.labelOffsetX || 0); + }) + .attr("y", function (d) { + // Apply offset if it exists + return -r + (d.labelOffsetY || 0); + }) + // Apply custom label styles + .style("font-size", function (d) { + return (d.labelSize || DEFAULTS.LABEL_SIZE) + "px"; + }) + .style("fill", function (d) { + return d.labelColor || DEFAULTS.LABEL_COLOR; + }); - for (i = node_data.length - 1; i >= 0; i--) { - item = node_data[i]; - if (item.prune) { - //console.log("pruning node:",item); - node_data.splice(i, 1); - } - } - } + // Update node styles and anchored status + nodes + .selectAll("circle.obj") + .attr("r", function (d) { + return d.nodeSize || r; + }) + .style("fill", function (d) { + return d.nodeColor || null; // Use CSS default if not specified + }) + .classed("anchored", function (d) { + return d.anchored === true; + }); - function graphPruneReset() { - var i; - for (i in node_data) { - node_data[i].prune = false; - } - for (i in link_data) { - link_data[i].prune = false; - } - } + // Update selection highlight circles to match node size + nodes.selectAll("circle.select").attr("r", function (d) { + return (d.nodeSize || r) * 1.5; + }); - function simTick() { - //console.log("tick"); - nodes.attr("transform", function (d) { - return "translate(" + d.x + "," + d.y + ")"; + // Add a visual indicator for anchored nodes + nodes.selectAll("circle.obj").each(function (d) { + // Remove any existing anchor indicator + d3.select(this.parentNode).selectAll(".anchor-indicator").remove(); + + // Add anchor indicator for anchored nodes + if (d.anchored) { + d3.select(this.parentNode) + .append("circle") + .attr("class", "anchor-indicator") + .attr("r", 3) + .attr("cx", 0) + .attr("cy", 0); + } }); links @@ -823,17 +1330,19 @@ function GraphPanel(a_id, a_frame, a_parent) { } // Graph Init - var zoom = d3.zoom(); + let zoom = d3 + .zoom() + .on("zoom", function () { + svg.attr("transform", d3.event.transform); + }) + .filter(function () { + // Disable zoom on double-click to prevent conflicts with node anchoring + return !d3.event.button && d3.event.detail < 2; + }); // TODO Select in our frame only - svg = d3 - .select(a_id) - .call( - zoom.on("zoom", function () { - svg.attr("transform", d3.event.transform); - }), - ) - .append("g"); + svg = d3.select(a_id).call(zoom).append("g"); + // TODO add deselect selected node highlight on double-click defineArrowMarkerDeriv(svg); defineArrowMarkerComp(svg); @@ -847,36 +1356,3 @@ function GraphPanel(a_id, a_frame, a_parent) { return this; } - -// Depth-first-search to required nodes, mark for pruning -function graphPruneCalc(a_node, a_visited, a_source) { - if (a_visited.indexOf(a_node.id) < 0) { - a_visited.push(a_node.id); - - if (a_node.row != undefined) { - return false; - } - - var i, - prune, - dest, - link, - keep = false; - - for (i in a_node.links) { - link = a_node.links[i]; - dest = link.source != a_node ? link.source : link.target; - if (dest != a_source) { - prune = graphPruneCalc(dest, a_visited, a_node); - keep |= !prune; - } - } - - if (!keep) { - a_node.prune = true; - for (i in a_node.links) a_node.links[i].prune = true; - } - } - - return a_node.prune; -} diff --git a/web/static/components/provenance/state.js b/web/static/components/provenance/state.js new file mode 100644 index 000000000..28ecad276 --- /dev/null +++ b/web/static/components/provenance/state.js @@ -0,0 +1,206 @@ +const DEFAULTS = { + NODE_SIZE: 10, + NODE_COLOR: "#6baed6", // Use CSS default + LABEL_SIZE: 14, + LABEL_COLOR: "#333333", // Use CSS default + THEME: "light", // Default theme +}; + +/** + * GraphState - Manages the state of the provenance graph visualization using the Observer pattern + * + * The Observer pattern is used here to: + * 1. Persist user customizations of the graph layout (node positions, anchoring) + * 2. Maintain visual styling choices (colors, sizes) between sessions + * 3. Enable real-time updates when customizations are made in one view (e.g., modal dialog) + * to be reflected in another view (the graph itself) + * + * Potential future uses: + * - Synchronizing graph changes with underlying data records + * - Broadcasting updates when relationship data changes + * - Enabling multiple views of the same graph data to stay in sync + * - Supporting undo/redo functionality by tracking state changes + * + * How to use: + * - Components that need to observe state changes should implement an update(state) method + * - Then register with graphStateManager.addObserver(observerComponent) + * - When state changes occur, all observers will be notified + */ +class GraphState { + constructor() { + this.observers = []; + this.state = { + nodePositions: {}, // Store node positions + nodeStyles: {}, // Store node customizations + labelOffsets: {}, // Store label offsets + labelStyles: {}, // Store label customizations + theme: DEFAULTS.THEME, // Current theme + }; + + // Load saved state from localStorage if available + try { + const savedState = localStorage.getItem("datafed-graph-state"); + + // Try to detect system theme from settings if available + if (window.settings && window.settings.theme) { + this.state.theme = window.settings.theme; + } + + // Hook into settings.setTheme to keep our state in sync + if (window.settings && typeof window.settings.setTheme === "function") { + const originalSetTheme = window.settings.setTheme; + const graphState = this; + + window.settings.setTheme = function (theme) { + // Call original function + originalSetTheme(theme); + // Update our state and notify observers + graphState.setTheme(theme); + }; + } + if (savedState) { + this.state = JSON.parse(savedState); + } + } catch (e) { + console.error("Failed to load graph state:", e); + } + } + + addObserver(observer) { + this.observers.push(observer); + } + + notifyObservers() { + this.observers.forEach((observer) => observer.update(this.state)); + } + + /** + * Sets the current theme and notifies observers + * @param {string} theme - The theme to set ('light' or 'dark') + * @returns {boolean} - Success status of the save operation + */ + setTheme(theme) { + if (theme === "light" || theme === "dark") { + this.state.theme = theme; + this.notifyObservers(); + + // Update body class for CSS variables + document.body.classList.remove("theme-light", "theme-dark"); + document.body.classList.add("theme-" + theme); + + return true; + } + return false; + } + + /** + * Gets the current theme + * @returns {string} - The current theme ('light' or 'dark') + */ + getTheme() { + return this.state.theme; + } + + /** + * Saves the current state of all nodes to localStorage + * Only saves non-default values to reduce storage footprint + * @param {Array} nodeData - Array of node objects with current state + * @returns {boolean} - Success status of the save operation + */ + saveState(nodeData) { + // Save current theme and preserve it + const currentTheme = this.state.theme; + + // Reset the state + this.state = { + nodePositions: {}, + nodeStyles: {}, + labelOffsets: {}, + labelStyles: {}, + theme: currentTheme, // Preserve theme + }; + + // Save state for each node, only storing non-default and non-zero values + nodeData.forEach((node) => { + // Only save position if node is anchored or has a position + if (node.anchored || (node.x !== undefined && node.y !== undefined)) { + const positionData = {}; + + // Only add properties that are different from defaults + if (node.x !== undefined) positionData.x = node.x; + if (node.y !== undefined) positionData.y = node.y; + if (node.anchored) positionData.anchored = true; + + // Only save if we have actual data to store + if (Object.keys(positionData).length > 0) { + this.state.nodePositions[node.id] = positionData; + } + } + + // Save node style customizations - only non-default values + const nodeStyle = {}; + if (node.nodeSize && node.nodeSize !== DEFAULTS.NODE_SIZE) { + nodeStyle.size = node.nodeSize; + } + if (node.nodeColor && node.nodeColor !== DEFAULTS.NODE_COLOR) { + nodeStyle.color = node.nodeColor; + } + if (Object.keys(nodeStyle).length > 0) { + this.state.nodeStyles[node.id] = nodeStyle; + } + + // Save label offsets - only if they exist and aren't zero + if (node.labelOffsetX || node.labelOffsetY) { + const offsetData = {}; + if (node.labelOffsetX) offsetData.x = node.labelOffsetX; + if (node.labelOffsetY) offsetData.y = node.labelOffsetY; + + if (Object.keys(offsetData).length > 0) { + this.state.labelOffsets[node.id] = offsetData; + } + } + + // Save label style customizations - only non-default values + const labelStyle = {}; + if (node.labelSize && node.labelSize !== DEFAULTS.LABEL_SIZE) { + labelStyle.size = node.labelSize; + } + if (node.labelColor && node.labelColor !== DEFAULTS.LABEL_COLOR) { + labelStyle.color = node.labelColor; + } + if (Object.keys(labelStyle).length > 0) { + this.state.labelStyles[node.id] = labelStyle; + } + }); + + // Store in localStorage + try { + localStorage.setItem("datafed-graph-state", JSON.stringify(this.state)); + this.notifyObservers(); + return true; + } catch (e) { + console.error("Failed to save graph state:", e); + return false; + } + } +} + +/** + * ThemeObserver - Observer class for theme changes + * Uses the Observer pattern to respond to theme changes + */ +class ThemeObserver { + /** + * Called by GraphState when state changes + * @param {object} state - The updated state object + */ + update(state) { + if (state && state.theme) { + // Apply theme class to body + document.body.classList.remove("theme-light", "theme-dark"); + document.body.classList.add("theme-" + state.theme); + } + } +} + +export { GraphState, DEFAULTS, ThemeObserver }; diff --git a/web/static/components/provenance/styles/customization_modal.css b/web/static/components/provenance/styles/customization_modal.css new file mode 100644 index 000000000..0b6daf2b1 --- /dev/null +++ b/web/static/components/provenance/styles/customization_modal.css @@ -0,0 +1,151 @@ +/* Import theme variables */ +@import url('./theme-variables.css'); + +.customization-modal { + position: absolute; + background-color: var(--modal-bg-color); + border: 1px solid var(--modal-border-color); + border-radius: 8px; + padding: 0; /* Remove padding to control it better in children */ + box-shadow: 0 0 10px rgba(0,0,0,0.5); + color: var(--modal-text-color); + width: 260px; + z-index: 1000; + font-family: sans-serif; +} + +.modal-header { + cursor: move; + padding: 5px; + margin-bottom: 10px; + background-color: var(--modal-header-bg); + border-bottom: 1px solid var(--modal-header-border); + border-radius: 8px 8px 0 0; +} + +.modal-header h3 { + margin: 0; + padding: 5px; + color: var(--modal-header-text); +} + +.customization-modal .section { + margin-bottom: 15px; + padding: 0 15px; +} + +.customization-modal label { + display: block; + margin-bottom: 5px; + color: var(--modal-label-color); +} + +.customization-modal .control-row { + display: flex; + align-items: center; + justify-content: space-between; /* Distribute space evenly */ + margin-bottom: 8px; + width: 100%; /* Ensure it takes full width */ +} + +.customization-modal input[type="color"] { + width: 40px; + height: 30px; + border: 1px solid var(--modal-input-border); + border-radius: 3px; + background: none; + cursor: pointer; + margin-right: auto; /* Push to the left */ + padding: 2px; +} + +.customization-modal input[type="range"] { + flex: 1; + margin-right: 10px; + height: 5px; + -webkit-appearance: none; + background: var(--modal-slider-bg); + border-radius: 5px; + outline: none; +} + +/* Browser-specific thumb styling */ +.customization-modal input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 15px; + height: 15px; + border-radius: 50%; + background: var(--modal-slider-thumb); + cursor: pointer; +} + +.customization-modal input[type="range"]::-moz-range-thumb { + width: 15px; + height: 15px; + border-radius: 50%; + background: var(--modal-slider-thumb); + cursor: pointer; + border: none; +} + +.customization-modal input[type="range"]::-ms-thumb { + width: 15px; + height: 15px; + border-radius: 50%; + background: var(--modal-slider-thumb); + cursor: pointer; +} + +.customization-modal .value { + min-width: 40px; + text-align: right; + color: var(--modal-value-color); + padding-left: 5px; +} + +.customization-modal .buttons { + display: flex; + justify-content: flex-end; + margin-top: 15px; + padding: 10px 15px 15px; + border-top: 1px solid var(--modal-section-border); +} + +.customization-modal button { + padding: 6px 12px; + margin-left: 8px; + background-color: var(--modal-button-bg); + color: var(--modal-button-text); + border: 1px solid var(--modal-button-border); + border-radius: 3px; + cursor: pointer; + min-width: 70px; + font-size: 13px; +} + +.customization-modal button:hover { + background-color: var(--modal-button-hover-bg); +} + +.customization-modal button.primary { + background-color: var(--modal-primary-button-bg); + border-color: var(--modal-primary-button-bg); +} + +.customization-modal button.primary:hover { + background-color: var(--modal-primary-button-hover-bg); +} + +.inline-label { + display: inline; + margin-left: 5px; +} + +/* Checkbox specific styles */ +.customization-modal .checkbox-row { + justify-content: flex-start; /* Align items to the left */ +} + +.customization-modal .checkbox-row input[type="checkbox"] { + margin-right: 8px; +} \ No newline at end of file diff --git a/web/static/components/provenance/styles/graph_styles.css b/web/static/components/provenance/styles/graph_styles.css new file mode 100644 index 000000000..07fd7c87f --- /dev/null +++ b/web/static/components/provenance/styles/graph_styles.css @@ -0,0 +1,245 @@ +/* Graph visualization styles */ +@import url('./theme-variables.css'); + +/* Graph controls */ +.graph-controls { + position: absolute; + top: 10px; + right: 10px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 5px; +} + +.graph-controls button { + background-color: var(--controls-button-bg); + border: 1px solid var(--controls-button-border); + border-radius: 3px; + padding: 5px 10px; + cursor: pointer; + font-size: 12px; + color: var(--controls-button-text); +} + +.graph-controls button:hover { + background-color: var(--controls-button-hover-bg); +} + +/* Graph tooltip */ +.graph-tooltip { + position: absolute; + bottom: 10px; + left: 10px; + background: var(--tooltip-bg); + color: var(--tooltip-text); + border: 1px solid var(--tooltip-border); + padding: 5px 10px; + border-radius: 3px; + font-size: 12px; + pointer-events: none; + z-index: 1000; + max-width: 300px; + padding: 15px; + display: none; /* Hidden by default */ +} + +.graph-tooltip.visible { + display: block; +} + +.graph-tooltip-close { + position: absolute; + top: 5px; + right: 10px; + cursor: pointer; + pointer-events: all; + font-size: 20px; + color: var(--tooltip-close); +} + +/* Graph container */ +.graph-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background-color: var(--graph-bg); +} + +/* Node styles */ +.node { + cursor: pointer; +} + +.node circle.obj { + stroke-width: 1.5px; + transition: r 0.3s, fill 0.3s, stroke-width 0.3s; +} + +.node circle.obj.main { + fill: #ff7f0e; +} + +.node circle.obj.prov { + fill: #1f77b4; +} + +.node circle.obj.other { + fill: #2ca02c; +} + +.node circle.obj.comp { + stroke: #000; +} + +.node circle.obj.part { + stroke: #666; +} + +/* Anchored nodes */ +.node circle.obj.anchored, +.nodes .anchored { + stroke: #ffcc00; + stroke-width: 2px; + stroke-dasharray: 5, 3; +} + +.node circle.select { + fill: none; + stroke: #ff0000; + stroke-width: 2px; + pointer-events: none; +} + +/* Highlighting selected nodes */ +.node circle.select.highlight { + stroke-dasharray: 5, 5; + animation: dash 1s linear infinite; +} + +.node circle.select.hidden { + display: none; +} + +@keyframes dash { + to { + stroke-dashoffset: -10; + } +} + +.node text.label { + font-family: sans-serif; + pointer-events: none; + text-anchor: start; + dominant-baseline: central; + font-weight: bold; + transition: font-size 0.3s, fill 0.3s; +} + +.node text.locked { + font-family: sans-serif; + text-anchor: middle; + dominant-baseline: central; + font-size: 10px; + fill: #d62728; +} + +/* Node tooltip */ +.node-tooltip { + position: absolute; + background: var(--node-tooltip-bg); + border: 1px solid var(--node-tooltip-border); + border-radius: 4px; + padding: 10px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + pointer-events: none; + z-index: 1000; + max-width: 300px; + font-size: 12px; + color: var(--node-tooltip-text); +} + +.node-tooltip h4 { + margin: 0 0 5px 0; + font-size: 14px; + border-bottom: 1px solid var(--node-tooltip-heading-border); + padding-bottom: 3px; +} + +.node-tooltip p { + margin: 5px 0; +} + +.node-tooltip .metadata { + margin-top: 5px; + font-style: italic; + color: var(--node-tooltip-meta); +} + +/* Graph context menu */ +.node-context-menu { + position: absolute; + background: var(--context-menu-bg); + border: 1px solid var(--context-menu-border); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + padding: 5px 0; + z-index: 1000; + color: var(--context-menu-text); +} + +.node-context-menu ul { + list-style: none; + margin: 0; + padding: 0; +} + +.node-context-menu li { + padding: 8px 15px; + cursor: pointer; +} + +.node-context-menu li:hover { + background: var(--context-menu-item-hover); +} + +.node-context-menu li.separator { + height: 1px; + background: var(--context-menu-separator); + padding: 0; + margin: 5px 0; +} + +/* Loading indicator */ +.graph-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255, 255, 255, 0.8); + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + text-align: center; +} + +.graph-loading .spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 2s linear infinite; + margin: 0 auto 10px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/web/static/components/provenance/styles/theme-variables.css b/web/static/components/provenance/styles/theme-variables.css new file mode 100644 index 000000000..3a9abebee --- /dev/null +++ b/web/static/components/provenance/styles/theme-variables.css @@ -0,0 +1,104 @@ +/* Theme variables for the provenance visualization components + These variables will be overridden by the theme-specific CSS */ + +:root { + /* Base theme variables with dark theme defaults */ + --modal-bg-color: #222; + --modal-border-color: #444; + --modal-text-color: #fff; + --modal-header-bg: #333; + --modal-header-border: #444; + --modal-header-text: #fff; + --modal-label-color: #ccc; + --modal-input-border: #444; + --modal-slider-bg: #444; + --modal-slider-thumb: #2a7dbb; + --modal-value-color: #ccc; + --modal-button-bg: #333; + --modal-button-hover-bg: #444; + --modal-button-border: #555; + --modal-button-text: #fff; + --modal-primary-button-bg: #2a7dbb; + --modal-primary-button-hover-bg: #3a8dcb; + --modal-section-border: #333; + + /* Graph controls */ + --controls-button-bg: #333; + --controls-button-border: #444; + --controls-button-text: #ddd; + --controls-button-hover-bg: #444; + + /* Graph tooltip */ + --tooltip-bg: rgba(0, 0, 0, 0.8); + --tooltip-border: #444; + --tooltip-text: #fff; + --tooltip-close: #ccc; + + /* Node context menu */ + --context-menu-bg: #222; + --context-menu-border: #444; + --context-menu-item-hover: #333; + --context-menu-text: #eee; + --context-menu-separator: #444; + + /* Node tooltip */ + --node-tooltip-bg: #222; + --node-tooltip-border: #444; + --node-tooltip-heading-border: #444; + --node-tooltip-text: #eee; + --node-tooltip-meta: #aaa; + + /* Graph container */ + --graph-bg: #1e1e1e; +} + +/* Light theme overrides */ +body.theme-light { + --modal-bg-color: #fff; + --modal-border-color: #ccc; + --modal-text-color: #333; + --modal-header-bg: #f5f5f5; + --modal-header-border: #ddd; + --modal-header-text: #333; + --modal-label-color: #555; + --modal-input-border: #ccc; + --modal-slider-bg: #e0e0e0; + --modal-slider-thumb: #4a98db; + --modal-value-color: #555; + --modal-button-bg: #f5f5f5; + --modal-button-hover-bg: #e9e9e9; + --modal-button-border: #ccc; + --modal-button-text: #333; + --modal-primary-button-bg: #4a98db; + --modal-primary-button-hover-bg: #3a8dcb; + --modal-section-border: #eee; + + /* Graph controls */ + --controls-button-bg: #ffffff; + --controls-button-border: #cccccc; + --controls-button-text: #333333; + --controls-button-hover-bg: #f5f5f5; + + /* Graph tooltip */ + --tooltip-bg: rgba(255, 255, 255, 0.95); + --tooltip-border: #ccc; + --tooltip-text: #333; + --tooltip-close: #666; + + /* Node context menu */ + --context-menu-bg: #ffffff; + --context-menu-border: #ccc; + --context-menu-item-hover: #f5f5f5; + --context-menu-text: #333; + --context-menu-separator: #e5e5e5; + + /* Node tooltip */ + --node-tooltip-bg: #ffffff; + --node-tooltip-border: #ccc; + --node-tooltip-heading-border: #eee; + --node-tooltip-text: #333; + --node-tooltip-meta: #666; + + /* Graph container */ + --graph-bg: #f9f9f9; +} \ No newline at end of file diff --git a/web/static/components/provenance/utils.js b/web/static/components/provenance/utils.js new file mode 100644 index 000000000..abe2a6fbd --- /dev/null +++ b/web/static/components/provenance/utils.js @@ -0,0 +1,294 @@ +import * as util from "../../util.js"; +import { DEFAULTS } from "./state.js"; + +/** + * @typedef {object} NodeLink + * @property {string} id - Unique identifier for the link + * @property {Node|string} source - Source node or node ID + * @property {Node|string} target - Target node or node ID + * @property {number} ty - Type of dependency relationship (0=derivation, 1=component, 2=new-version) + * @property {boolean} [prune] - Flag indicating if this link should be pruned (removed) + */ + +/** + * @typedef {object} DataItem + * @property {string} id - Unique identifier for the data item + * @property {string} [doi] - Digital Object Identifier, if available + * @property {number} [size] - Size of the data item + * @property {string} [notes] - Notes or annotations attached to the item + * @property {boolean} [inhErr] - Flag indicating if the item has inherited errors + * @property {boolean} [locked] - Flag indicating if the item is locked + * @property {string} [alias] - Display name/alias for the item + * @property {number} [gen] - Generation number (for hierarchical layout) + * @property {Array} [dep] - Dependencies for the item + * @property {Array} [deps] - Extended dependency information for the item + */ + +/** + * @typedef {object} Node + * @property {string} id - Unique identifier for the node + * @property {string} [doi] - Digital Object Identifier, if available + * @property {number} [size] - Size of data associated with this node + * @property {string} [notes] - Notes or annotations attached to the node + * @property {boolean} [inhErr] - Flag indicating if the node has inherited errors + * @property {boolean} [locked] - Flag indicating if the node is locked + * @property {Array} links - Links connected to this node + * @property {string} label - Display label for the node + * @property {number} [row] - Row position in hierarchical layout + * @property {number} [col] - Column position in hierarchical layout + * @property {boolean} [comp] - Flag indicating if this is a composite node + * @property {boolean} [prune] - Flag indicating if this node should be pruned (removed) + * + * // Position and physics properties + * @property {number} x - X-coordinate position + * @property {number} y - Y-coordinate position + * @property {number} [fx] - Fixed X-coordinate (when node is anchored) + * @property {number} [fy] - Fixed Y-coordinate (when node is anchored) + * @property {boolean} [anchored] - Flag indicating if the node position is fixed/anchored + * + * // Visual customization properties + * @property {number} [nodeSize] - Custom size of the node + * @property {string} [nodeColor] - Custom color of the node + * @property {number} [labelSize] - Custom size of the node label + * @property {string} [labelColor] - Custom color of the node label + * @property {number} [labelOffsetX] - X-offset for the label position + * @property {number} [labelOffsetY] - Y-offset for the label position + * + * // Interaction state + * @property {boolean} [draggingLabel] - Flag indicating if the label is being dragged + * @property {number} [labelDragStartX] - Starting X position for label dragging + * @property {number} [labelDragStartY] - Starting Y position for label dragging + */ + +/** + * Factory function to create a new node from a data item + * @param {DataItem} item - The data item to create a node from + * @returns {Node} - A new node object with properties from the item + */ +function createNode(item) { + const node = { + id: item.id, + doi: item.doi, + size: item.size, + notes: item.notes, + inhErr: item.inhErr, + locked: item.locked, + links: [], + nodeSize: DEFAULTS.NODE_SIZE, + labelSize: DEFAULTS.LABEL_SIZE, + }; + + makeLabel(node, item); + + if (item.gen !== undefined) { + node.row = item.gen; + node.col = 0; + } + + return node; +} + +/** + * Creates a label for a node based on its properties + * @param {Node} node - The node object to create a label for + * @param {DataItem} item - The data item containing information for the label + */ +function makeLabel(node, item) { + if (item.alias) { + node.label = item.alias; + } else node.label = item.id; + + node.label += util.generateNoteSpan(item, true); +} + +/** + * Counts the number of connected nodes in the graph that can be reached from a starting node + * + * Critical for the "hide" operation to ensure removing a node won't disconnect the graph. + * Used to verify that all nodes (except the one being hidden) remain connected + * after removing a particular node. + * + * @param {Node} a_node - The starting node to begin the traversal from + * @param {Array} a_visited - Array of already visited node IDs (to exclude from count) + * @param {NodeLink} [a_from] - The link that led to this node (to avoid backtracking) + * @returns {number} - Count of connected nodes reachable from the starting node + */ +function graphCountConnected(a_node, a_visited, a_from) { + let count = 0; + + // Only count unvisited, non-pruned nodes + if (a_visited.indexOf(a_node.id) < 0 && !a_node.prune) { + a_visited.push(a_node.id); + count++; + let link, dest; + + // Recursively count connected nodes + for (let i in a_node.links) { + link = a_node.links[i]; + if (link !== a_from) { + dest = link.source === a_node ? link.target : link.source; + count += graphCountConnected(dest, a_visited, link); + } + } + } + + return count; +} + +/** + * Calculates which nodes should be pruned using depth-first search + * This function is used as part of the "collapse" functionality in the graph, + * which removes less important nodes while maintaining key relationships. + * + * @param {Node} a_node - The node to evaluate for pruning + * @param {Array} a_visited - Array of already visited node IDs + * @param {Node} a_source - The source node that led to this node + * @returns {boolean} - Whether the node should be pruned + */ +function graphPruneCalc(a_node, a_visited, a_source) { + if (a_visited.indexOf(a_node.id) < 0) { + a_visited.push(a_node.id); + + // Don't prune root nodes (those with row defined) + if (a_node.row !== undefined) { + return false; + } + + let i, + prune, + dest, + link, + keep = false; + + // Check all connected nodes + for (i in a_node.links) { + link = a_node.links[i]; + dest = link.source !== a_node ? link.source : link.target; + if (dest !== a_source) { + prune = graphPruneCalc(dest, a_visited, a_node); + // If any connected node should be kept, this node should be kept too + keep |= !prune; + } + } + + // If nothing connected to this node should be kept, mark for pruning + if (!keep) { + a_node.prune = true; + for (i in a_node.links) a_node.links[i].prune = true; + } + } + + return a_node.prune; +} + +/** + * Detaches a link from a given node. + * This involves marking the node as not a composition ('comp = false') + * and removing the link from the node's 'links' array. + * + * @param {object} node - The node object (e.g., link.source or link.target) + * from which the link will be detached. Expected to have 'prune', 'comp', + * and 'links' properties. + * @param {object} linkToDetach - The link object to detach from the node. + * @param {string} nodeName - A string identifier for the node (e.g., "source" or "target") + * used for logging purposes. + */ +function detachLinkFromNode(node, linkToDetach, nodeName) { + // Check if the node exists and is not marked for pruning + if (node && !node.prune) { + node.comp = false; // Mark node as not a composition (part of detachment logic) + + // Find the index of the link in the node's links array + const linkIndex = node.links.indexOf(linkToDetach); + + if (linkIndex !== -1) { + // If found, remove the link from the array + node.links.splice(linkIndex, 1); + } else { + // If not found, log an error message + console.log( + `BAD INDEX IN ${nodeName.toUpperCase()} LINKS! Link not found when trying to detach.`, + ); + } + } +} + +/** + * Removes links and nodes that are marked for pruning using a more functional approach + * + * This function is used for both the "collapse" and "hide" functionality: + * - Collapse: Removes less important nodes while maintaining the overall graph structure + * - Hide: Removes leaf nodes that the user wants to hide from the visualization + * + * @param {Array} link_data - Array of link objects + * @param {Array} node_data - Array of node objects + * @returns {void} - Modifies the arrays in place + */ +function graphPrune(link_data, node_data) { + // First, process any link cleanup for nodes that will remain + // This needs to be done before filtering to maintain references + const prunedLinks = link_data.filter((item) => item.prune); + + // For each pruned link, update the references in its connected nodes + // (but only for nodes that aren't being pruned themselves) + prunedLinks.forEach((link) => { + // Handle source node if it exists and won't be pruned + detachLinkFromNode(link.source, link, "source"); + // Handle target node if it exists and won't be pruned + detachLinkFromNode(link.target, link, "target"); + }); + + // Filter out pruned links + nodes from the main array + const filteredLinks = link_data.filter((item) => !item.prune); + const filteredNodes = node_data.filter((item) => !item.prune); + // Clear the array + node_data.length = 0; + link_data.length = 0; + // Refill with filtered items + link_data.push(...filteredLinks); + node_data.push(...filteredNodes); +} + +/** + * Resets all prune flags in the graph + * Used during graph manipulation to clear previous pruning states + * before calculating new ones during collapse and hide operations + * + * @param {Array} link_data - Array of link objects + * @param {Array} node_data - Array of node objects + */ +function graphPruneReset(link_data, node_data) { + node_data.forEach((node) => { + node.prune = false; + }); + link_data.forEach((link) => { + link.prune = false; + }); +} + +/** + * Checks if a node is a leaf node (has only one connection) + * Used as an initial quick check for the hide functionality - only leaf nodes can be hidden + * to preserve the graph's connectivity and structure + * + * Note: This is a necessary but not sufficient condition for safe removal. + * After confirming a node is a leaf, we still need to use graphCountConnected + * to verify that removing it won't disconnect the graph. + * + * @param {Node} node - The node to check + * @returns {boolean} - True if this is a leaf node, false otherwise + */ +function isLeafNode(node) { + // A leaf node only has one connection to the rest of the graph + return node.links.length === 1; +} + +export { + createNode, + makeLabel, + graphPruneCalc, + graphPrune, + graphPruneReset, + graphCountConnected, + isLeafNode, +}; diff --git a/web/static/jquery-ui-dark/datafed.css b/web/static/jquery-ui-dark/datafed.css index 7dd7b4df3..255c074f0 100644 --- a/web/static/jquery-ui-dark/datafed.css +++ b/web/static/jquery-ui-dark/datafed.css @@ -195,6 +195,25 @@ span.fancytree-node.fancytree-folder.fancytree-expanded > span.fancytree-expande stroke: #777; } +.nodes .anchored { + stroke-dasharray: 5, 3; + stroke-width: 3px; +} + +/* Tooltip for graph interaction help */ +.graph-tooltip { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 5px 10px; + border-radius: 3px; + font-size: 12px; + pointer-events: none; + z-index: 1000; +} + .node .highlight { stroke: #0f0 !important; stroke-width: 2px !important; diff --git a/web/static/jquery-ui-light/datafed.css b/web/static/jquery-ui-light/datafed.css index a83e08b0a..4e90c365b 100644 --- a/web/static/jquery-ui-light/datafed.css +++ b/web/static/jquery-ui-light/datafed.css @@ -201,6 +201,26 @@ ul.tagit { stroke: #888; } +.nodes .anchored { + stroke-dasharray: 5, 3; + stroke-width: 3px; +} + +/* Tooltip for graph interaction help */ +.graph-tooltip { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(255, 255, 255, 0.9); + color: #000; + padding: 5px 10px; + border-radius: 3px; + font-size: 12px; + pointer-events: none; + z-index: 1000; + border: 1px solid #ccc; +} + .node .highlight { stroke: #088 !important; stroke-width: 2px !important; @@ -460,4 +480,4 @@ span.fancytree-node.fancytree-folder.fancytree-expanded > span.fancytree-icon:af .data-tree-creator-self { color: #0040FF; -} +} \ No newline at end of file diff --git a/web/test/components/provenance/state.test.js b/web/test/components/provenance/state.test.js new file mode 100644 index 000000000..668829cbb --- /dev/null +++ b/web/test/components/provenance/state.test.js @@ -0,0 +1,199 @@ +import { expect } from "chai"; +import { DEFAULTS, GraphState } from "../../../static/components/provenance/state.js"; + +describe("state", function () { + describe("DEFAULTS", function () { + it("should have the expected default values", function () { + expect(DEFAULTS).to.be.an("object"); + expect(DEFAULTS.NODE_SIZE).to.equal(10); + expect(DEFAULTS.NODE_COLOR).to.equal("#6baed6"); + expect(DEFAULTS.LABEL_SIZE).to.equal(14); + expect(DEFAULTS.LABEL_COLOR).to.be.equal("#333333"); + }); + }); + + describe("GraphState", function () { + let graphState; + + beforeEach(function () { + graphState = new GraphState(); + + global.localStorage = { + items: {}, + getItem: function (key) { + return this.items[key] || null; + }, + setItem: function (key, value) { + this.items[key] = value; + }, + }; + + global.console.error = function () {}; + }); + + afterEach(function () { + // Clean up + delete global.localStorage; + }); + + it("should initialize with empty state and observers", function () { + expect(graphState.observers).to.be.an("array").that.is.empty; + expect(graphState.state).to.be.an("object"); + expect(graphState.state.nodePositions).to.be.an("object").that.is.empty; + expect(graphState.state.nodeStyles).to.be.an("object").that.is.empty; + expect(graphState.state.labelOffsets).to.be.an("object").that.is.empty; + expect(graphState.state.labelStyles).to.be.an("object").that.is.empty; + }); + + it("should add observers correctly", function () { + const observer = { update: () => {} }; + graphState.addObserver(observer); + + expect(graphState.observers).to.have.lengthOf(1); + expect(graphState.observers[0]).to.equal(observer); + }); + + it("should notify observers when state changes", function () { + let notified = false; + const observer = { + update: function (state) { + notified = true; + expect(state).to.equal(graphState.state); + }, + }; + + graphState.addObserver(observer); + graphState.notifyObservers(); + + expect(notified).to.be.true; + }); + + it("should save node positions correctly", function () { + const nodeData = [{ id: "node1", x: 100, y: 200, anchored: true }]; + + graphState.saveState(nodeData); + + expect(graphState.state.nodePositions.node1).to.deep.equal({ + x: 100, + y: 200, + anchored: true, + }); + }); + + it("should save node styles when they differ from defaults", function () { + const nodeData = [ + { + id: "node1", + nodeSize: 20, + nodeColor: "red", + }, + ]; + + graphState.saveState(nodeData); + + expect(graphState.state.nodeStyles.node1).to.deep.equal({ + size: 20, + color: "red", + }); + }); + + it("should not save node styles when they match defaults", function () { + const nodeData = [ + { + id: "node1", + nodeSize: DEFAULTS.NODE_SIZE, + nodeColor: DEFAULTS.NODE_COLOR, + }, + ]; + + graphState.saveState(nodeData); + + expect(graphState.state.nodeStyles) + .to.be.an("object") + .to.deep.equal({ + [nodeData[0].id]: { + size: nodeData[0].nodeSize, + color: nodeData[0].nodeColor, + }, + }); + }); + + it("should save label offsets correctly", function () { + const nodeData = [{ id: "node1", labelOffsetX: 5, labelOffsetY: 10 }]; + + graphState.saveState(nodeData); + + expect(graphState.state.labelOffsets.node1).to.deep.equal({ + x: 5, + y: 10, + }); + }); + + it("should save label styles when they differ from defaults", function () { + const nodeData = [ + { + id: "node1", + labelSize: 20, + labelColor: "blue", + }, + ]; + + graphState.saveState(nodeData); + + expect(graphState.state.labelStyles.node1).to.deep.equal({ + size: 20, + color: "blue", + }); + }); + + it("should not save label styles when they match defaults", function () { + const nodeData = [ + { + id: "node1", + labelSize: DEFAULTS.LABEL_SIZE, + labelColor: DEFAULTS.LABEL_COLOR, + }, + ]; + + graphState.saveState(nodeData); + + expect(graphState.state.labelStyles) + .to.be.an("object") + .to.deep.equal({ + [nodeData[0].id]: { + size: nodeData[0].labelSize, + color: nodeData[0].labelColor, + }, + }); + }); + + it("should store state in localStorage", function () { + const nodeData = [{ id: "node1", x: 100, y: 200 }]; + + graphState.saveState(nodeData); + + const storedState = JSON.parse(localStorage.getItem("datafed-graph-state")); + expect(storedState).to.deep.equal(graphState.state); + }); + + it("should return true when state is saved successfully", function () { + const nodeData = [{ id: "node1", x: 100, y: 200 }]; + + const result = graphState.saveState(nodeData); + + expect(result).to.be.true; + }); + + it("should handle errors when saving state", function () { + global.localStorage.setItem = function () { + throw new Error("Storage error"); + }; + + const nodeData = [{ id: "node1", x: 100, y: 200 }]; + + const result = graphState.saveState(nodeData); + + expect(result).to.be.false; + }); + }); +}); diff --git a/web/test/components/provenance/utils.test.js b/web/test/components/provenance/utils.test.js new file mode 100644 index 000000000..77750ee71 --- /dev/null +++ b/web/test/components/provenance/utils.test.js @@ -0,0 +1,364 @@ +import { expect } from "chai"; +import { + createNode, + makeLabel, + graphPruneCalc, + graphPrune, + graphPruneReset, + graphCountConnected, +} from "../../../static/components/provenance/utils.js"; +import { DEFAULTS } from "../../../static/components/provenance/state.js"; + +describe("utils", function () { + describe("createNode", function () { + it("should create a node with correct properties", function () { + const item = { + id: "test-id", + doi: "test-doi", + size: 100, + notes: "test notes", + inhErr: false, + locked: true, + alias: "test-alias", + }; + + const node = createNode(item); + + expect(node).to.be.an("object"); + expect(node.id).to.equal(item.id); + expect(node.doi).to.equal(item.doi); + expect(node.size).to.equal(item.size); + expect(node.notes).to.equal(item.notes); + expect(node.inhErr).to.equal(item.inhErr); + expect(node.locked).to.equal(item.locked); + expect(node.links).to.be.an("array").that.is.empty; + expect(node.nodeSize).to.equal(DEFAULTS.NODE_SIZE); + expect(node.labelSize).to.equal(DEFAULTS.LABEL_SIZE); + expect(node.label).to.include(item.alias); + }); + + it("should set row and col when gen is provided", function () { + const item = { + id: "test-id", + gen: 3, + }; + + const node = createNode(item); + + expect(node.row).to.equal(3); + expect(node.col).to.equal(0); + }); + + it("should use id as label when alias is not provided", function () { + const item = { + id: "test-id", + }; + + const node = createNode(item); + + expect(node.label).to.include(item.id); + }); + }); + + describe("makeLabel", function () { + it("should use alias when available", function () { + const node = {}; + const item = { + id: "test-id", + alias: "test-alias", + }; + + makeLabel(node, item); + + expect(node.label).to.include(item.alias); + }); + + it("should use id when alias is not available", function () { + const node = {}; + const item = { + id: "test-id", + }; + + makeLabel(node, item); + + expect(node.label).to.include(item.id); + }); + }); + + describe("graphCountConnected", function () { + it("should count connected nodes correctly", function () { + const node1 = { id: "node1", links: [], prune: false }; + const node2 = { id: "node2", links: [], prune: false }; + const node3 = { id: "node3", links: [], prune: false }; + + const link1 = { source: node1, target: node2 }; + const link2 = { source: node2, target: node3 }; + + node1.links.push(link1); + node2.links.push(link1, link2); + node3.links.push(link2); + + const visited = []; + const count = graphCountConnected(node1, visited, null); + + expect(count).to.equal(3); + expect(visited).to.include.members(["node1", "node2", "node3"]); + }); + + it("should not count pruned nodes", function () { + const node1 = { id: "node1", links: [], prune: false }; + const node2 = { id: "node2", links: [], prune: true }; + + const link = { source: node1, target: node2 }; + + node1.links.push(link); + node2.links.push(link); + + const visited = []; + const count = graphCountConnected(node1, visited, null); + + expect(count).to.equal(1); + expect(visited).to.include("node1"); + expect(visited).not.to.include("node2"); // Still visited, but not counted + }); + + it("should not revisit already visited nodes", function () { + const node1 = { id: "node1", links: [], prune: false }; + const node2 = { id: "node2", links: [], prune: false }; + + const link = { source: node1, target: node2 }; + + node1.links.push(link); + node2.links.push(link); + + const visited = ["node2"]; // Pre-visit node2 + const count = graphCountConnected(node1, visited, null); + + expect(count).to.equal(1); // Only node1 is counted + expect(visited).to.include.members(["node1", "node2"]); + }); + }); + + describe("graphPruneCalc", function () { + it("should mark nodes for pruning correctly", function () { + // Create a graph where some nodes should be pruned + const rootNode = { id: "root", links: [], row: 0 }; // Has row, should not be pruned + const node1 = { id: "node1", links: [] }; // No connections to required nodes, should be pruned + const node2 = { id: "node2", links: [] }; // Connected to root, should not be pruned + + const link1 = { source: rootNode, target: node2 }; + + rootNode.links.push(link1); + node2.links.push(link1); + + const visited = []; + + // Node1 should be marked for pruning + graphPruneCalc(node1, visited, null); + expect(node1.prune).to.be.true; + + // Reset visited array + visited.length = 0; + + // Node2 should not be marked for pruning because it's connected to rootNode + graphPruneCalc(node2, visited, null); + expect(node2.prune).to.be.undefined; + }); + + it("should return early if node has row defined", function () { + const node = { id: "node", links: [], row: 0 }; + const visited = []; + + const result = graphPruneCalc(node, visited, null); + + expect(result).to.be.false; + expect(node.prune).to.be.undefined; + }); + + it("should not revisit already visited nodes", function () { + const node = { id: "node", links: [] }; + const visited = ["node"]; // Pre-visit the node + + const result = graphPruneCalc(node, visited, null); + + expect(result).to.be.undefined; // No return value when node is already visited + expect(node.prune).to.be.undefined; // Node is not modified + }); + }); + + describe("graphPrune", function () { + it("should remove pruned nodes and links", function () { + // Create nodes and links with some marked for pruning + const node1 = { id: "node1", links: [], prune: true }; + const node2 = { id: "node2", links: [], prune: false }; + const node3 = { id: "node3", links: [], prune: true }; + + const link1 = { source: node1, target: node2, prune: true }; + const link2 = { source: node2, target: node3, prune: true }; + + node1.links.push(link1); + node2.links.push(link1, link2); + node3.links.push(link2); + + const nodeData = [node1, node2, node3]; + const linkData = [link1, link2]; + + graphPrune(linkData, nodeData); + + // Check that pruned nodes and links are removed + expect(nodeData).to.have.lengthOf(1); + expect(nodeData[0].id).to.equal("node2"); + expect(linkData).to.have.lengthOf(0); + + // Check that links are removed from remaining nodes + expect(node2.links).to.have.lengthOf(0); + expect(node2.comp).to.be.false; + }); + + it("should handle edge cases with missing source or target", function () { + const node = { id: "node", links: [], prune: false }; + const link = { source: node, target: null, prune: true }; + + node.links.push(link); + + const nodeData = [node]; + const linkData = [link]; + + graphPrune(linkData, nodeData); + + expect(nodeData).to.have.lengthOf(1); + expect(linkData).to.have.lengthOf(0); + expect(node.links).to.have.lengthOf(0); + }); + + it("should correctly prune a link with a missing target and update the source node's comp status", function () { + const nodeS = { id: "nodeS", links: [], prune: false, comp: true }; + const link = { source: nodeS, target: null, prune: true }; + nodeS.links.push(link); + + const nodeData = [nodeS]; + const linkData = [link]; + + graphPrune(linkData, nodeData); + + expect(nodeData).to.have.lengthOf(1); + expect(nodeData[0]).to.equal(nodeS); + expect(linkData).to.be.empty; + expect(nodeS.links).to.be.empty; + expect(nodeS.comp).to.be.false; + }); + + it("should correctly prune a link with a missing source and update the target node's comp status", function () { + const nodeT = { id: "nodeT", links: [], prune: false, comp: true }; + const link = { source: null, target: nodeT, prune: true }; + nodeT.links.push(link); + + const nodeData = [nodeT]; + const linkData = [link]; + + graphPrune(linkData, nodeData); + + expect(nodeData).to.have.lengthOf(1); + expect(nodeData[0]).to.equal(nodeT); + expect(linkData).to.be.empty; + expect(nodeT.links).to.be.empty; + expect(nodeT.comp).to.be.false; + }); + + it("should not modify data if no nodes or links are marked for pruning", function () { + const node1 = { id: "node1", links: [], prune: false, comp: true }; + const node2 = { id: "node2", links: [], prune: false, comp: true }; + const link1 = { source: node1, target: node2, prune: false }; + node1.links.push(link1); + node2.links.push(link1); + + const nodeData = [node1, node2]; + const linkData = [link1]; + + const originalNodeData = [...nodeData]; + const originalLinkData = [...linkData]; + const originalNode1Links = [...node1.links]; + const originalNode2Links = [...node2.links]; + const originalNode1Comp = node1.comp; + const originalNode2Comp = node2.comp; + + graphPrune(linkData, nodeData); + + expect(nodeData).to.deep.equal(originalNodeData); + expect(linkData).to.deep.equal(originalLinkData); + expect(node1.links).to.deep.equal(originalNode1Links); + expect(node2.links).to.deep.equal(originalNode2Links); + expect(node1.comp).to.equal(originalNode1Comp); + expect(node2.comp).to.equal(originalNode2Comp); + }); + + it("should remove only pruned links and update 'comp' status and 'links' array of non-pruned nodes", function () { + const node1 = { id: "node1", links: [], prune: false, comp: true }; + const node2 = { id: "node2", links: [], prune: false, comp: true }; + const link1 = { source: node1, target: node2, prune: true }; + node1.links.push(link1); + node2.links.push(link1); + + const nodeData = [node1, node2]; + const linkData = [link1]; + + graphPrune(linkData, nodeData); + + expect(nodeData).to.have.lengthOf(2); + expect(nodeData).to.include.members([node1, node2]); + expect(linkData).to.be.empty; + expect(node1.links).to.be.empty; + expect(node1.comp).to.be.false; + expect(node2.links).to.be.empty; + expect(node2.comp).to.be.false; + }); + + it("should remove all nodes and links if all are marked for pruning", function () { + const node1 = { id: "node1", links: [], prune: true }; + const node2 = { id: "node2", links: [], prune: true }; + const link1 = { source: node1, target: node2, prune: true }; + // Simulate links being added to nodes, though they might be removed by graphPruneCalc setting prune on links + if (node1.links) node1.links.push(link1); + else node1.links = [link1]; + if (node2.links) node2.links.push(link1); + else node2.links = [link1]; + + const nodeData = [node1, node2]; + const linkData = [link1]; + + graphPrune(linkData, nodeData); + + expect(nodeData).to.be.empty; + expect(linkData).to.be.empty; + }); + }); + + describe("graphPruneReset", function () { + it("should reset prune flags on all nodes", function () { + const node1 = { id: "node1", prune: true }; + const node2 = { id: "node2", prune: true }; + + const nodeData = [node1, node2]; + + graphPruneReset(nodeData); + + expect(node1.prune).to.be.false; + expect(node2.prune).to.be.false; + }); + + it("should reset prune flags on all links when provided", function () { + const node1 = { id: "node1", prune: true }; + const link1 = { id: "link1", prune: true }; + const link2 = { id: "link2", prune: true }; + + const nodeData = [node1]; + const linkData = [link1, link2]; + + graphPruneReset(nodeData, linkData); + + expect(node1.prune).to.be.false; + expect(link1.prune).to.be.false; + expect(link2.prune).to.be.false; + }); + }); +}); diff --git a/web/views/main.ect b/web/views/main.ect index d33343329..0368e5d79 100644 --- a/web/views/main.ect +++ b/web/views/main.ect @@ -51,6 +51,8 @@ + +