From 1cf6c85db2a251a2ba13f19803b0d89c04566ae4 Mon Sep 17 00:00:00 2001 From: Elizabeth Lawrence Date: Tue, 18 Mar 2025 15:45:48 -0400 Subject: [PATCH 1/5] add zoom and drag functionality replaces the shifting of the svg box laterally also added functionality to change size based on screen size - some issues still with the zoom not being very smooth and deselecting links not also deselecting nodes. some issues with zoom in the multi select mode too --- script.js | 389 ++++++++++++++++++++++++++++++++++++++++++----------- styles.css | 35 +++-- 2 files changed, 335 insertions(+), 89 deletions(-) diff --git a/script.js b/script.js index a63f61e..2b7dc2e 100644 --- a/script.js +++ b/script.js @@ -64,23 +64,93 @@ const questions = [ { text: "Did your data collection methods work as expected?", tags: ["Data Collection", "Evaluation"] }, ]; -const svgWidth = 800; // Set the width of the SVG box -const svgHeight = 600; // Set the height of the SVG box -const radius = 200; // Circle radius -const centerX = svgWidth / 2; -const centerY = svgHeight / 2; - -// Assign fixed positions for nodes -data.nodes.forEach((node, i) => { - const angle = (i / data.nodes.length) * 2 * Math.PI - Math.PI / 2; // Start at top - node.x = centerX + radius * Math.cos(angle); - node.y = centerY + radius * Math.sin(angle); +/////////////// Set up the space for the spiderweb ////////////////////// +// const svgWidth = 800; // Set the width of the SVG box +// const svgHeight = 600; // Set the height of the SVG box +// const radius = 200; // Circle radius +// const centerX = svgWidth / 2; +// const centerY = svgHeight / 2; +const container = document.getElementById('spiderweb'); +const svg = d3.select('#spiderweb').append('svg'); + +// Function to update SVG dimensions +function updateSvgDimensions() { + const svgWidth = container.offsetWidth; + const svgHeight = container.offsetHeight || (svgWidth * 0.8); // Fallback height + console.log(`Container width: ${svgWidth}, height: ${svgHeight}`); + + svg.attr('width', svgWidth).attr('height', svgHeight); +} + +// Initialize the dimensions +updateSvgDimensions(); + +// Use ResizeObserver to listen for container size changes +const resizeObserver = new ResizeObserver(() => { + updateSvgDimensions(); }); +// Observe the container +resizeObserver.observe(container); + +let svgWidth = container.offsetWidth; +let svgHeight = container.offsetHeight || (svgWidth * 0.8); +// let svgWidth = window.innerWidth * 0.5; // % of viewport width +// let svgHeight = window.innerHeight * 0.5; // % of viewport height +let radius = Math.min(svgWidth, svgHeight) * 0.35; // Adjust radius based on dimensions +let centerX = svgWidth / 2; +let centerY = svgHeight / 2; + + +// Assign positions for nodes +function updateNodePositions() { + centerX = svgWidth / 2; + centerY = svgHeight / 2; + radius = Math.min(svgWidth, svgHeight) * 0.35; + + data.nodes.forEach((node, i) => { + const angle = (i / data.nodes.length) * 2 * Math.PI - Math.PI / 2; + node.x = centerX + radius * Math.cos(angle); + node.y = centerY + radius * Math.sin(angle); + }); + + // Update node and link positions + node + .attr("cx", d => d.x) + .attr("cy", d => d.y); + + link + .attr("x1", d => data.nodes.find(node => node.id === d.source).x) + .attr("y1", d => data.nodes.find(node => node.id === d.source).y) + .attr("x2", d => data.nodes.find(node => node.id === d.target).x) + .attr("y2", d => data.nodes.find(node => node.id === d.target).y); + + svgGroup.selectAll(".node-label") + .attr("x", d => d.x + (22 * Math.cos(Math.atan2(d.y - centerY, d.x - centerX)))) + .attr("y", d => d.y + (22 * Math.sin(Math.atan2(d.y - centerY, d.x - centerX)))) + .attr("text-anchor", d => { + const angle = Math.atan2(d.y - centerY, d.x - centerX); + return Math.cos(angle) > 0 ? "start" : "end"; // Correct side for horizontal alignment + }) + .attr("alignment-baseline", d => { + const angle = Math.atan2(d.y - centerY, d.x - centerX); + return Math.sin(angle) > 0 ? "hanging" : "alphabetic"; // Correct vertical alignment + }) + .text(d => d.name|| d.id);; +} + +// data.nodes.forEach((node, i) => { +// const angle = (i / data.nodes.length) * 2 * Math.PI - Math.PI / 2; // Start at top +// node.x = centerX + radius * Math.cos(angle); +// node.y = centerY + radius * Math.sin(angle); +// }); + // Create the SVG canvas -const svg = d3.select("#spiderweb").append("svg") - .attr("width", svgWidth) - .attr("height", svgHeight); +// const svg = d3.select("#spiderweb").append("svg") +// .attr("width", svgWidth) +// .attr("height", svgHeight) +// .attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`) +// .attr("preserveAspectRatio", "xMidYMid meet"); // Tooltip const tooltip = d3.select("#tooltip"); @@ -92,15 +162,102 @@ document.getElementById("toggle-selection-mode").addEventListener("change", (eve document.getElementById("toggle-label").textContent = multiSelectMode ? "Multi-Select" : "Single Select"; }); -// Deselect all button -document.getElementById("deselect-all").addEventListener("click", () => { - d3.selectAll(".node").classed("selected", false); - d3.selectAll(".link").classed("selected", false); - hideInfoPanel(); -}); + +/////////////// Set up functions for zoom etc ////////////////////// +// Add zoom and pan behavior +const zoom = d3.zoom() + .scaleExtent([0.5, 5]) // Define the zoom scale range + .on("zoom", (event) => { + svgGroup.attr("transform", event.transform); // Apply zoom and pan + }); + +svg.call(zoom); + +// Function to reset zoom to default position +function updateZoomForSelectedItems() { + // Get selected nodes and links + const selectedNodes = d3.selectAll(".node.selected").data(); + const selectedLinks = d3.selectAll(".link.selected").data(); + + if (selectedNodes.length === 0 && selectedLinks.length === 0) return; + + // Calculate the bounding box of all selected nodes and links + const allSelectedElements = [...selectedNodes, ...selectedLinks.flatMap(link => [link.source, link.target])]; + const xValues = allSelectedElements.map(e => e.x); + const yValues = allSelectedElements.map(e => e.y); + + // Compute the center of the selected elements + const centerX = d3.mean(xValues); + const centerY = d3.mean(yValues); + + // Compute the scale based on the bounding box of the selected elements + const xExtent = d3.extent(xValues); + const yExtent = d3.extent(yValues); + const width = xExtent[1] - xExtent[0]; + const height = yExtent[1] - yExtent[0]; + const padding = 50; + + const scale = Math.min( + (svgWidth - 2 * padding) / width, + (svgHeight - 2 * padding) / height + ); + + // Apply the zoom transformation to the center of the selected elements + const transform = d3.zoomIdentity + .translate(svgWidth / 2, svgHeight / 2) + .scale(scale) + .translate(-centerX, -centerY); + + svg.transition() + .duration(750) + .call(zoom.transform, transform); +} + + +function resetZoom() { + const transform = d3.zoomIdentity + .translate(svgWidth / 2, svgHeight / 2) // Center the zoom to the center of the SVG + .scale(1) // Set the zoom scale to 1 (default zoom level) + .translate(-centerX, -centerY); // Adjust for the center of the graph (optional) + + svg.transition() + .duration(750) + .call(zoom.transform, transform); // Apply the zoom transform with the reset state +} + +let svgMoved = false; // Flag to ensure the SVG moves only once +// Function to handle the SVG transformation +function adjustSvg() { + const svgContainer = d3.select("#spiderweb").node(); // Get the SVG container + const svgBoundingRect = svgContainer.getBoundingClientRect(); // Get dimensions and position + + // Set the info panel width as 50% of the screen width + const infoPanelWidth = window.innerWidth * 0.5; + + // Calculate translation to shift the SVG left of the info panel + //const translateX = -(infoPanelWidth / 2.5); // Shift left by half the info panel width + //const translateY = 0; // No vertical translation + + // Define a dynamic scale factor + //const scale = 0.70; // Shrinks to % of its size + + // Ensure it doesn't move off-screen + const maxTranslateX = -(svgBoundingRect.width * (1 - scale)) / 2; + const finalTranslateX = Math.max(translateX, maxTranslateX); // Ensure it stays within bounds + + // Apply the transformation + d3.select("svg") + .transition() + .duration(300) // Smooth transition for resizing + .attr("transform", `translate(${finalTranslateX}, ${translateY}) scale(${scale})`); +} + +////////////////////////// Set up the nodes and links of the spiderweb ////////////////////// +// Create a group to hold nodes and links +const svgGroup = svg.append("g"); // Add links -const link = svg.selectAll(".link") +const link = svgGroup.selectAll(".link") .data(data.links) .enter() .append("line") @@ -122,83 +279,131 @@ const link = svg.selectAll(".link") return targetNode.y; }) .on("click", (event, d) => { - // Add selected class to the clicked link - svg.transition().duration(900) - .attr("transform", "translate(-400, 0) scale(1)"); - - const sourceNode = data.nodes.find(node => node.id === d.source); - const targetNode = data.nodes.find(node => node.id === d.target); - - if (multiSelectMode) { - // Toggle the selected class on the clicked link - const link = d3.select(event.currentTarget); - const isLinkSelected = link.classed("selected"); - link.classed("selected", !isLinkSelected); - - // Re-evaluate node selection based on remaining selected links - d3.selectAll(".node").classed("selected", node => { - // Check if the node is part of any currently selected links - const isConnectedToSelectedLink = d3 - .selectAll(".link.selected") - .data() - .some(link => link.source === node.id || link.target === node.id); - - return isConnectedToSelectedLink; - }); - } else { - // Single-select mode: clear all and highlight the current link and nodes - d3.selectAll(".node").classed("selected", false); - d3.selectAll(".link").classed("selected", false); + // Add selected class to the clicked link + const sourceNode = data.nodes.find(node => node.id === d.source); + const targetNode = data.nodes.find(node => node.id === d.target); - d3.selectAll(".node").classed("selected", node => { - return node.id === sourceNode.id || node.id === targetNode.id; - }); - d3.select(event.currentTarget).classed("selected", true); + const isSelected = d3.select(event.currentTarget).classed("selected"); + + if (multiSelectMode) { + // Toggle the selected class on the clicked link + const link = d3.select(event.currentTarget); + const isLinkSelected = link.classed("selected"); + link.classed("selected", !isLinkSelected); + + // Re-evaluate node selection based on remaining selected links + d3.selectAll(".node").classed("selected", node => { + // Check if the node is part of any currently selected links + const isConnectedToSelectedLink = d3 + .selectAll(".link.selected") + .data() + .some(link => link.source === node.id || link.target === node.id); + + return isConnectedToSelectedLink; + updateZoomForSelectedItems(); + }); + } else { + if (isSelected) { + // If the link is already selected, deselect it and reset zoom + d3.select(event.currentTarget).classed("selected", false); + resetZoom(); + } else { + // Zoom into the link if it's not selected + const transform = d3.zoomIdentity + .translate(svgWidth / 2, svgHeight / 2) + .scale(2) + .translate(-d.x, -d.y); + + svg.transition() + .duration(750) + .call(zoom.transform, transform); + // Single-select mode: clear all and highlight the current link and nodes + d3.selectAll(".node").classed("selected", false); + d3.selectAll(".link").classed("selected", false); + + d3.selectAll(".node").classed("selected", node => { + return node.id === sourceNode.id || node.id === targetNode.id; + }); + d3.select(event.currentTarget).classed("selected", true); + } } - // Update the info panel with the current selection - updateInfoPanel(); -}) - .on("mouseover", (event, d) => { - tooltip.style("left", `${event.pageX + 10}px`) - .style("top", `${event.pageY + 10}px`) - .style("display", "inline-block") - .html(`Connection: ${d.source} ↔ ${d.target}`) ; + // Update the info panel with the current selection + updateInfoPanel(); + + if (!svgMoved) { + adjustSvg(); // Perform initial adjustment + svgMoved = true; // Set the flag so this block doesn't execute again + } }) - .on("mouseout", () => { - tooltip.style("display", "none"); - }); + .on("mouseover", (event, d) => { + tooltip.style("left", `${event.pageX + 20}px`) + .style("top", `${event.pageY + 20}px`) + .style("display", "inline-block") + .html(`Connection: ${d.source} ↔ ${d.target}`) ; + }) + .on("mouseout", () => { + tooltip.style("display", "none"); + }); // Add nodes -const node = svg.selectAll(".node") +const node = svgGroup.selectAll(".node") .data(data.nodes) .enter() .append("circle") .attr("class", "node") - .attr("r", 10) + .attr("r", 15) .attr("cx", d => d.x) .attr("cy", d => d.y) .attr("fill", "#1f78b4") .on("click", (event, d) => { - svg.transition().duration(900) - .attr("transform", "translate(-400, 0) scale(1)"); const node = d3.select(event.currentTarget); const isSelected = node.classed("selected"); if (multiSelectMode) { // Toggle selected class on the clicked node node.classed("selected", !isSelected); + updateZoomForSelectedItems(); } else { - // Deselect all nodes and links, then select the clicked node - d3.selectAll(".node").classed("selected", false); - d3.selectAll(".link").classed("selected", false); - node.classed("selected", true); + if (isSelected) { + // If the node is already selected, deselect it and reset zoom + node.classed("selected", false); + resetZoom(); + } else { + // Zoom into the node if it's not selected + const transform = d3.zoomIdentity + .translate(svgWidth / 2, svgHeight / 2) + .scale(2) + .translate(-d.x, -d.y); + + svg.transition() + .duration(750) + .call(zoom.transform, transform); + // Deselect all nodes and links, then select the clicked node + d3.selectAll(".node").classed("selected", false); + d3.selectAll(".link").classed("selected", false); + node.classed("selected", true); } + } // Show info panel with questions related to the selected nodes and links updateInfoPanel(); + if (!svgMoved) { + adjustSvg(); // Perform initial adjustment + svgMoved = true; // Set the flag so this block doesn't execute again + } }); + +// Reset zoom and deselect all nodes and links +document.getElementById("deselect-all").addEventListener("click", () => { + d3.selectAll(".node").classed("selected", false); + d3.selectAll(".link").classed("selected", false); + hideInfoPanel(); + resetZoom(); + svgMoved = false; +}); + // Handle showing the info panel function showInfoPanel(content) { d3.select("#info-panel") @@ -215,33 +420,38 @@ function hideInfoPanel() { d3.select("#info-panel") .style("display", "none"); + resetZoom(); + // Reset the spiderweb scale - svg.transition().duration(900) - .attr("transform", "scale(1) translate(0, 0)"); - + // svg.transition().duration(300) + // .attr("transform", "scale(1) translate(0, 0)"); + // Remove selected class from all nodes and links d3.selectAll(".node").classed("selected", false); d3.selectAll(".link").classed("selected", false); + + // Reset svgMoved flag + svgMoved = false; } // Add event listener to close button document.getElementById("close-info-panel").addEventListener("click", hideInfoPanel); // Add labels -svg.selectAll(".node-label") +svgGroup.selectAll(".node-label") .data(data.nodes) .enter() .append("text") .attr("class", "node-label") - .attr("x", d => d.x + (15 * Math.cos(Math.atan2(d.y - centerY, d.x - centerX)))) // Offset text outward - .attr("y", d => d.y + (15 * Math.sin(Math.atan2(d.y - centerY, d.x - centerX)))) // Offset text outward + .attr("x", d => d.x + (22 * Math.cos(Math.atan2(d.y - centerY, d.x - centerX)))) // Offset text outward + .attr("y", d => d.y + (22 * Math.sin(Math.atan2(d.y - centerY, d.x - centerX)))) // Offset text outward .attr("text-anchor", d => { const angle = Math.atan2(d.y - centerY, d.x - centerX); - return Math.cos(angle) > 0 ? "start" : "end"; // Align text based on direction + return Math.cos(angle) > 0 ? "start" : "end"; // Correct side for horizontal alignment }) .attr("alignment-baseline", d => { const angle = Math.atan2(d.y - centerY, d.x - centerX); - return Math.sin(angle) > 0 ? "hanging" : "alphabetic"; // Adjust vertical alignment + return Math.sin(angle) > 0 ? "hanging" : "alphabetic"; // Correct vertical alignment }) .text(d => d.name || d.id); @@ -266,5 +476,26 @@ function updateInfoPanel() { } } +// Update dimensions on window resize +window.addEventListener("resize", () => { + svgWidth = window.innerWidth * 0.9; + svgHeight = window.innerHeight * 0.9; + + svg.attr("width", svgWidth) + .attr("height", svgHeight) + .attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`); + + updateSvgDimensions(); + updateNodePositions(); + + if (svgMoved) { + adjustSvg(); // Recalculate transformation on resize + } +}); + + +// Initial positioning +updateNodePositions(); + console.log(data.nodes); console.log(data.links); diff --git a/styles.css b/styles.css index 348cf50..04d1d7c 100644 --- a/styles.css +++ b/styles.css @@ -26,23 +26,28 @@ p { display: flex; justify-content: center; align-items: flex-start; - height: 600px; - position: relative; + height: 60vh; + padding: 10px; + margin: 0 auto; + max-width: 100vh; + box-sizing: border-box; } -/* SVG container styles */ svg { display: block; margin: 0 auto; background-color: #eaf6ff; /* Optional: subtle background for the SVG */ border: 1px solid #002366; border-radius: 10px; + box-sizing: border-box; } #spiderweb { flex: 2; display: flex; justify-content: center; align-items: center; + width: 80%; height: 100%; + margin: 0; } #info-panel { @@ -52,12 +57,12 @@ svg { border: 3px solid #002366; display: none; /* Initially hidden */ overflow-y: auto; - height: 100%; - width: 50%; + height: 60%; + max-height: 90vh; + width: 40vw; box-sizing: border-box; /* Include padding and border in the element's total width and height */ position: absolute; - right: 0; - top: 0; + right: 0; } #close-info-panel { display: block; @@ -96,24 +101,34 @@ svg { stroke: #a16305; stroke-width: 1.75px; } +.node:hover { + fill: #ff9900; /* Change to a different color when selected */ + stroke: #a16305; + stroke-width: 1.75px; +} /* Text labels for nodes */ .node-label { - font-size: 17px; /* Adjust the font size */ + font-size: 20px; /* Adjust the font size */ fill: #002366; /* Dark blue for text */ font-family: 'Montserrat', sans-serif; /* Same font as the rest of the page */ pointer-events: none; /* Prevent text from capturing mouse events */ font-weight: bold; + white-space: pre-wrap; + word-wrap: break-word; + text-align: center; + width: 100px; } /* Link styles */ .link { stroke: #cccccc; - stroke-width: 3px; + stroke-width: 4px; transition: stroke 0.2s, stroke-width 0.2s; } .link:hover { stroke: #002366; /* Dark blue on hover */ - stroke-width: 3px; /* Increase thickness on hover */ + stroke-width: 5px; /* Increase thickness on hover */ + cursor: pointer; } .link.selected { From 8783104ade623527649b349d5d1265e0df2a63b1 Mon Sep 17 00:00:00 2001 From: Elizabeth Lawrence Date: Thu, 10 Apr 2025 11:51:38 -0400 Subject: [PATCH 2/5] add hover over description & add some resources * added hover over text and also included the description in the infobox * updated Review to learn to all be Explore & Review to Learn --- index.html | 4 ++- resources.html | 12 ++++++++- script.js | 67 ++++++++++++++++++++++++++++++++++---------------- styles.css | 13 ++++++++++ 4 files changed, 73 insertions(+), 23 deletions(-) diff --git a/index.html b/index.html index fd1a6db..0dc632f 100644 --- a/index.html +++ b/index.html @@ -23,10 +23,11 @@

Introduction to the Blueprint

+

Note: this tool is currently in early development. Questions included are not final as this is still an early draft.


Each component in the ocean observing value chain is interlinked and important. Explore the interactive Blueprint below to gain insights into how these components work together!

Use the Single or Multi-Select mode to focus on individual components or explore multiple connections at once.


-

This tool is currently under development.

+

Provide feedback on the Blueprint by filling in this survey


@@ -40,6 +41,7 @@

Introduction to the Blueprint

+
diff --git a/resources.html b/resources.html index 5a481a6..99b9a07 100644 --- a/resources.html +++ b/resources.html @@ -24,7 +24,17 @@

Resources

Here you will find a list of resources that are relevant to each of the components of Blueprint.



-

Under development: content to come!

+

Under development: more content to come!


+

Resources include:

+ +