diff --git a/html/table.html b/html/table.html index aa1a8cf..e9b54f7 100644 --- a/html/table.html +++ b/html/table.html @@ -2,6 +2,15 @@ + + ArborView @@ -223,7 +254,9 @@ const DATA = __DEADFOOD__; const DEBUG = false; const TREE_OFFSET = 100; + const TREE_LEFT_MARGIN_VIEWBOX_OFFSET = 30; //Space to accommodate near the root inner node labels var METDATA_TABLE_NON_FULL_SCREEEN_HEIGTH = null; //before changing to full screen mode save the current size of the metadata table + var LAST_METADATA_COLUMN_SELECTED = null; //last metadata column selected for legend rendering. useful for subtree legend generation var NEWICK = null; var tree_root = null; var METADATA = null; @@ -233,8 +266,8 @@ var ORIGINAL_DATA = null; var ORIGINAL_VIEW_BOX = null; var ORIGINAL_WIDTH_SVG = 0; - var LAST_MOVE_X = 0; - var LAST_MOVE_Y = 0; + var LAST_SCROLL_X = 0; // Store last scroll x-position making legend position updates smarter + var LAST_SCROLL_Y = 0; // Store last scroll y-position making legend position updates smarter var SHOW_BRANCH_LENGTHS = true; var SHOW_METADATA_IN_NAME = false; var LINE_THICKNESS = 2.0; @@ -273,6 +306,37 @@ // These are messy due to the way in which they were created, and there has not been time // to refactor them yet sadly. + // ===== CHANGE: Font Size to Average Width Mapping ===== + // This table defines average character width multipliers for different font sizes in a typical sans-serif font (like Arial or Helvetica) + // E.g. Each character averages 0.62 × 12px = 7.44px wide + // We need to do approximate max label width calculation as DOM is not rendered at this stage preventing usage of bbox() + const FONT_METRICS = { + 8: 0.52, // avg width multiplier for 8px font + 9: 0.55, + 10: 0.58, + 11: 0.60, + 12: 0.62, // default size + 14: 0.65, + 16: 0.68, + 18: 0.70 + }; + + /** + * Estimates the pixel width of text based on font size and average character width metrics. + * Uses predefined font metrics for accurate estimation without DOM measurement. + * + * @function estimateTextWidth + * @param {string} text - The text string to measure + * @param {number} fontSize - Font size in pixels (must match FONT_METRICS keys for best accuracy) + * @returns {number} Estimated width in pixels + * + **/ + function estimateTextWidth(text, fontSize) { + const AVG_CHAR_WIDTH = FONT_METRICS[fontSize]; + return text.length * AVG_CHAR_WIDTH * fontSize; + } + + function addDisplayDistance(d, rd){ let next_rd = null; @@ -405,7 +469,10 @@ * scale bar is slightly difficult to add, as the svg viewport dat only shows up AFTER rendering */ - // --- Input Validation --- + const padding = { + top: -2, // Gap above the scale bar text + } + // --- Input Validation --- try { if (!svg || typeof svg.append !== 'function' || svg.empty()) { throw new Error("Invalid 'svg' parameter: expected a D3 selection."); @@ -426,7 +493,10 @@ console.error("drawScale() failed due to invalid input:", error.message); return; // Exit early to avoid rendering with bad input } - const scaleBarGroup = svg.append("g").attr("id", "scale-bar-group") //group all svg path elements under single group + const scaleBarGroup = svg.append("g") + .attr("id", "scale-bar-group") //group all svg path elements under single group + .attr("transform", `translate(0, ${padding.top})`); + enableVerticalElementDrag(scaleBarGroup); //enables vertical drag of the scale bar let scale_bar_menu = `
@@ -452,35 +522,6 @@ .attr("fill-opacity", 1) .attr("class", "scale-bar") - // ===== CHANGE: Font Size to Average Width Mapping ===== - // This table defines average character width multipliers for different font sizes in a typical sans-serif font (like Arial or Helvetica) - // E.g. Each character averages 0.62 × 12px = 7.44px wide - // We need to do approximate max label width calculation as DOM is not rendered at this stage preventing usage of bbox() - const FONT_METRICS = { - 8: 0.52, // avg width multiplier for 8px font - 9: 0.55, - 10: 0.58, - 11: 0.60, - 12: 0.62, // default size - 14: 0.65, - 16: 0.68, - 18: 0.70 - }; - - /** - * Estimates the pixel width of text based on font size and average character width metrics. - * Uses predefined font metrics for accurate estimation without DOM measurement. - * - * @function estimateTextWidth - * @param {string} text - The text string to measure - * @param {number} fontSize - Font size in pixels (must match FONT_METRICS keys for best accuracy) - * @returns {number} Estimated width in pixels - * - **/ - function estimateTextWidth(text, fontSize) { - const AVG_CHAR_WIDTH = FONT_METRICS[fontSize] || FONT_METRICS[12]; - return text.length * AVG_CHAR_WIDTH * fontSize; - } // Estimate maximum label width (using your font size 12px) const maxLabel = max_length.toFixed(DECIMAL_PLACES); const longestLabelWidth = estimateTextWidth(maxLabel, fontSize); @@ -628,7 +669,7 @@ SHOW_BRANCH_LENGTHS = false; var ele = document.getElementById("TreeData"); var ele_style = window.getComputedStyle(ele); - const width = parseInt(ele_style.width)-TREE_OFFSET; + const width = parseInt(ele_style.width)-TREE_OFFSET+TREE_LEFT_MARGIN_VIEWBOX_OFFSET; //var width = window.innerWidth; const marginTop = 40; @@ -722,7 +763,8 @@ const transition = svg.transition("tree-layout") .duration(duration) .attr("height", height) - .attr("viewBox", [0, left.x - marginTop, width, height]) + .attr("viewBox", [-TREE_LEFT_MARGIN_VIEWBOX_OFFSET, left.x - marginTop, + width, height]) .tween("resize", window.ResizeObserver ? null : () => () => svg.dispatch("toggle")) .on("end", () => { svg.dispatch("layout-complete");// This tells the rest of the app "The tree is done rendering!" @@ -880,7 +922,7 @@ // Made with lots of help from: https://observablehq.com/d/6c52fee38fb28b2f var ele = document.getElementById("TreeData"); var ele_style = window.getComputedStyle(ele); - const width = parseInt(ele_style.width)-TREE_OFFSET; + const width = parseInt(ele_style.width)-TREE_OFFSET+TREE_LEFT_MARGIN_VIEWBOX_OFFSET; const marginTop = 40; const marginRight = 10; const marginBottom = 30; // updated for viewing @@ -1007,7 +1049,8 @@ const transition = svg.transition("tree-layout") .duration(duration) .attr("height", height) - .attr("viewBox", [0, left.x - marginTop, width, height]) + .attr("viewBox", [-TREE_LEFT_MARGIN_VIEWBOX_OFFSET, left.x - marginTop, + width, height]) .tween("resize", window.ResizeObserver ? null : () => () => svg.dispatch("toggle")) .on("end", () => { svg.dispatch("layout-complete");// This tells the rest of the app "The tree is done rendering!" @@ -1192,21 +1235,27 @@ // Made with lots of help from: https://observablehq.com/d/6c52fee38fb28b2f var ele = document.getElementById("TreeData"); var ele_style = window.getComputedStyle(ele); + const root = d3.hierarchy(data); - const width = parseInt(ele_style.width)-TREE_OFFSET; - const height = width; + const maxLabelCharCount = d3.max(root.leaves(), d => {return d.data.name.length;}); + const labelBuffer = maxLabelCharCount * 12; //12px per character + + const width = parseInt(ele_style.width)-TREE_OFFSET + labelBuffer; + const height = width; //this plot will have square dimensions with circle inside const outerRadius = width / 2; const innerRadius = outerRadius; const decimal_places = 4; const sc_to_radians = Math.PI / 180; - const root = d3.hierarchy(data); + root.sort((a, b) => b.height - a.height || d3.ascending(a.id, b.id)); addTreeDisplayDistance(root) const dx = 20; const dy = outerRadius; - + + + function linkStep(startAngle, startRadius, endAngle, endRadius) { const c0 = Math.cos(startAngle = (startAngle - 90) / 180 * Math.PI); const s0 = Math.sin(startAngle); @@ -2116,6 +2165,13 @@ // Create a subtree from the inner node selected in a tree let CustomSubTree = (event, data) => { console.debug("Subtree is being rendered") + const zoomSlider = document.querySelector('#zoom_slider'); + const zoomValueLabel = document.querySelector('#zoom_slider_value'); + zoomSlider.value = 1; + zoomValueLabel.textContent = "1"; + + const isLegendActive = !$('#colour-legend').hasClass('d-none'); + console.debug("Legend is active:", isLegendActive); $("#legend_toggle").bootstrapToggle('off') SelectedNodes.drawSelectedNodes(); $('#colour-legend').empty() @@ -2131,7 +2187,7 @@ console.debug(`New head node of the tree with ${totalNodes} nodes at distance ${copy_head_node.max_length} and ultrametric=${TREE_ULTRAMETRIC_P}`); //if a tree is non-ultrametric then create a subtree with subtracted distances from the head node selected as root now - console.debug(copy_head_node) + if (!TREE_ULTRAMETRIC_P){ const distanceToSubtract = copy_head_node.max_length; const subtractDistance = (node, amount) => { @@ -2160,8 +2216,15 @@ let renderedTreePadding = svg_chart.style.paddingLeft || 0 svg_chart.style.paddingLeft = `${renderedTreePadding + TREE_OFFSET}px` + $("#TreeData").append(svg_chart); + + if (isLegendActive && LAST_METADATA_COLUMN_SELECTED !== null) { + // Regenerate the legend for the subtree using last selected field + colour_by_element(LAST_METADATA_COLUMN_SELECTED); + } + $(".leaf-node").each((i, elm) => { // Maintain colour of already selected nodes let [parent, circle, text] = SelectedNodes.getNodeData(elm); @@ -2230,6 +2293,7 @@ let switchTreeType = (eve) => { TREE_VAL = eve.id + document.getElementById("dropdown_tree_types").setAttribute("data-current-layout", TREE_VAL); $('#colour-legend').empty() $('#colour-legend').addClass('d-none') $('#TreeSVG').remove(); @@ -2293,7 +2357,7 @@ } maxIDLength = Math.max(key.length, maxIDLength); const selectedValues = selected_indexes.map((i) => { - let row_label = String(row[i] || "NA").trim(); + let row_label = String(row[i] ?? "").trim(); // ?? preserves '0', empty strings, and false. It only replaces null/undefined. /** @remarks fromCodePoint retrieves the the horizontal unicode elipse so that the charactar is rendered nicely. */ @@ -2451,6 +2515,8 @@ //icon.id="tree_fullscreen_btn" //icon.onclick="tree_full_screen_mode()" + $('#metadata_length').addClass('p-1'); + let html=document.createElement("div"); html.className = "paginate_button displayAll" html.id = "paginate_button_all" @@ -2459,15 +2525,6 @@ document.querySelector("#metadata_paginate").appendChild(html) } - let maximize_button_html=document.createElement("i"); - maximize_button_html.style = "position:absolute; top:0; right: 0; cursor: pointer; color:blue; margin-right:1px" - maximize_button_html.className="d-flex fs-5 bi-arrow-up-right-square" - maximize_button_html.id = "metadata_fullscreen_btn" - maximize_button_html.setAttribute("onclick", "metadata_full_screen_mode()") - if(document.querySelector("#metadata_fullscreen_btn") === null){ - document.querySelector("#metadata_wrapper").appendChild(maximize_button_html) - } - $('.paginate_button.displayAll:not(.disabled)', this.api().table().container()) .on('click', function(){ @@ -2522,14 +2579,37 @@ }); $('#metadata_filter').append(clearBtn); + + let maximize_button_html=document.createElement("i"); + maximize_button_html.style = "cursor: pointer; color:blue; margin-right:1px" + maximize_button_html.className="d-inline-flex p-1 fs-5 bi-arrow-up-right-square" + maximize_button_html.id = "metadata_fullscreen_btn" + maximize_button_html.setAttribute("onclick", "metadata_full_screen_mode()") + if(document.querySelector("#metadata_fullscreen_btn") === null){ + document.querySelector("#metadata_filter").appendChild(maximize_button_html); + } // TODO initializing table from json object would minimize copies - // Saving data as a map, to allow for updating of metadata + + /** + * INITIALIZE ORIGINAL_DATA MAP - Saving data as a map, to allow for updating of metadata + * * We iterate through the DataTables API to build a global Map. + * Instead of using .data(), we access the DOM nodes directly to extract + * .textContent. This prevents symbols like '&' from being stored as '&'. + */ ORIGINAL_DATA = new Map(); - data_table.rows().data().each((value, index) => { - ORIGINAL_DATA.set(value[ID_FIELD], value) - }); + data_table.rows().every(function (rowIdx, tableLoop, rowLoop) { + // Get the actual row DOM element for this row + const rowNode = this.node(); + + // Extract the textContent from each cell + const rawValues = Array.from(rowNode.cells).map(td => td.textContent.trim()); + // Store raw metadata array as a Map based on first column treated as sampleID + // Assuming ID_FIELD is the index of your ID column + ORIGINAL_DATA.set(rawValues[ID_FIELD], rawValues); + }); + // TODO This can likely be initialized in the above loop pushing to value for(const [key, value] of ORIGINAL_DATA.entries()){ @@ -2545,7 +2625,8 @@ const treeData = document.querySelector('#TreeData'); const menuButtons = document.querySelector('#tree_menu_buttons'); const selectedNodes = document.querySelector('#SelectedNodes'); - const metadataTable = document.querySelector('#ClusterInfo') + const metadataTable = document.querySelector('#ClusterInfo'); + if (!treeData || !menuButtons || !selectedNodes) return; const availableHeight = Math.abs(treeData.offsetHeight - menuButtons.clientHeight-12); @@ -2594,7 +2675,7 @@ table_body.push("\n"); } - parsed_data = null; // mark old data for GC + parsed_data = null; // mark old data for GC and cleanup memory $(table_id).html("" + table_head + "" + "" + table_body.join() + ""); // ? A bit odd we are making the table with HTML then converting to jquery datatable... CreateDataTable(); @@ -2625,17 +2706,26 @@ let node_leg = $("#colour-legend") node_leg.empty() - node_leg.append(`
`) + //Add the drag handle + node_leg.append(`
`) + + - for(const item of array_tuples){ //[field_value, hex_colour] + const columnName = TABLE_HEADERS[column_index] || `Unlabeled (column #${Number(column_index)+1})` //in case no column name is provided + node_leg.append(`
+ ${columnName} +
`); + + for(const item of array_tuples.filter(tuple => tuple[0] !== '')){ //[field_value, hex_colour] let row_legend_node = $(`
`) let field_value=item[0] - if(field_value === ''){field_value="Not defined"} + let count = (value2samples[field_value] || []).length; + //if(field_value === ''){field_value="Not defined"} row_legend_node.append(` \n - ${field_value}`) + ${field_value} (${count})`) node_leg.append(row_legend_node) } document.querySelector('#colour-legend').classList.remove("d-none"); @@ -2694,15 +2784,30 @@ }; let SubsetTable = () => { + let totalSamples = data_table.rows().count();; let filtered = data_table.data().filter((value, idx) => { return SelectedNodes.nodes.has(value[ID_FIELD]); }); + let remainingSamples = filtered.length; + let filteredOutSamplesCount = totalSamples - remainingSamples; data_table.clear(); data_table.rows.add(filtered).draw(); $("td").addClass("editable"); $("td:not(:first-child)").attr("contenteditable", true); + Swal.fire({ + title: 'Metadata Table Subsetted based on Selected Nodes', + html: `${remainingSamples} samples kept out of ${totalSamples}.
` + + `${filteredOutSamplesCount} samples were filtered out.`, + icon: 'success', + timer: 5000, + timerProgressBar: true, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + }; @@ -2795,8 +2900,11 @@ const svg = d3.select("#TreeSVG") const treeContainer = svg.select('g[cursor="pointer"][pointer-events="all"]') + const treeDataContainer = document.querySelector('#TreeData'); const svgAttrWidth = svg.attr("width") const oldWidth = treeContainer.node().getBBox().width + const currentLayout = document.getElementById("dropdown_tree_types").getAttribute("data-current-layout"); + const isRadial = currentLayout === "Radial Dendrogram" if (treeContainer.empty()) { @@ -2821,6 +2929,7 @@ svg.selectAll("g.leaf-node").each(function () { const g = d3.select(this); const nodeId = this.id; + if (isEmpty) { newLabel = `${nodeId}`; } else { @@ -2832,6 +2941,7 @@ trimEnd() would work, but if we had more options for justifying text in the future in may create some problems. */ + newLabel = hasValue ? `${joinedFields}` : nodeId; } g.selectAll("text") @@ -2839,30 +2949,58 @@ // Only update text elements that start with the nodeId return d3.select(this).text().startsWith(nodeId); }) + .attr("xml:space", "preserve") // Prevents SVG from collapsing your padding spaces .text(newLabel).attr("class", "leaf-node-label"); }); if (missingMetadataNodes.size > 0) { console.warn(`Metadata missing for ${missingMetadataNodes.size} nodes:`, Array.from(missingMetadataNodes)); } - - - - const newWidth = treeContainer.node().getBBox().width - const newHeight = treeContainer.node().getBBox().height + const bbox = treeContainer.node().getBBox(); + const newWidth = bbox.width+50 // add some padding as calculates the coordinates of the paths and shapes, but it does not include the width of the strokes + //const newHeight = treeContainer.node().getBBox().height+20 // add some padding as calculates the coordinates of the paths and shapes, but it does not include the width of the strokes + const viewBoxAttr = svg.attr("viewBox") if (!viewBoxAttr) { console.warn("viewBox not yet initialized by chart function."); return; } - const [minX, minY, width, height] = viewBoxAttr.split(/[\s,]+/).map(Number) + let [minX, minY, width, height] = viewBoxAttr.split(/[\s,]+/).map(Number) // Update the width of the svg element + //treeDataContainer.scrollLeft = newWidth; + if (isRadial) { + const fontSize=12; + let longestLabel = ""; + // Prioritize the Metadata Map that is available upon node text addition from metadata + if (MetaDataMap.size > 0) { + MetaDataMap.forEach((label) => { + if (label && label.length > longestLabel.length) { + longestLabel = label; + } + }); + } else { + // Fallback: If no metadata is loaded, check the Node IDs in the DOM + svg.selectAll("g.leaf-node").each(function() { + const nodeId = this.id; + if (nodeId.length > longestLabel.length) { + longestLabel = nodeId; + } + }); + } + maxLabelPixels = estimateTextWidth(longestLabel, fontSize); + height = newWidth; + minY = -maxLabelPixels-5; // Add padding to account for label height + minX = minY; //left margin also needs to be adjusted + } + + svg.attr("width", newWidth) - .attr("height", newHeight) - .attr("viewBox", `${minX} ${minY} ${newWidth} ${newHeight}`) + .attr("height", height) + .attr("viewBox", `${minX} ${minY} ${newWidth} ${height}`) svg.style.overflow = "scroll"; - + const maxScrollLeft = newWidth - treeDataContainer.clientWidth; + treeDataContainer.scrollLeft = maxScrollLeft; //scroll to the right margin } @@ -3050,6 +3188,7 @@ let colour_by_element = (ele) => { + LAST_METADATA_COLUMN_SELECTED = ele; // Create a legend for the column selected if(tree_root === null){ // TODO add pop up @@ -3058,6 +3197,7 @@ } let col_index = ele.id; + $("#legend_toggle").bootstrapToggle('on') let unq_data = new Set(); @@ -3066,7 +3206,7 @@ }) - // Need to create groupings of the each set of ID's belonging to each value grouping + // Need to create GLOBAL TREE groupings of the each set of ID's belonging to each value grouping let break_down_values = Array.from(unq_data); break_down_values.sort() @@ -3131,35 +3271,45 @@ //colour each leaf nodes with the selected colour let colour_idx = 0; colour_legend.length = 0; // clear out old data, so new legend created each time + for (const nodeId of SelectedNodes.nodes) { console.debug(nodeId); // This will log each node ID in the Set } + // This local object will hold only the nodes currently in the DOM + // Relevant when generating the legend for subtrees + let visible_breakdown = []; for(const item in break_down_values){ + let nodes_found_for_this_category = []; //checks if node exists in DOM now + if (item === "" || item === null) continue; //Skip empty field values (leaves them uncoloured/hidden from legend) + break_down_values[item].forEach((x, i) => { - - try{ - let current_item = document.querySelector(`[id='${x}']`); - let [elem, circle, text] = SelectedNodes.getNodeData(current_item); - if(SelectedNodes.nodes.has(x)){ - circle.dataset.oldfill = colours[colour_idx]; - }else{ - if (circle.style.fill !== "") { - circle.dataset.oldfill = circle.style.fill; - }else{ - circle.dataset.oldfill = SelectedNodes.getDefaultColour(); - } + + //check if the node exists in the DOM (e.g. subtree case) + if(document.getElementById(x)){ + + try{ + let current_item = document.querySelector(`[id='${x}']`); + let [elem, circle, text] = SelectedNodes.getNodeData(current_item); + nodes_found_for_this_category.push(x); + circle.dataset.oldfill = circle.style.fill || SelectedNodes.getDefaultColour(); circle.style.fill = colours[colour_idx]; + + }catch(error){ + console.error("Could not find node ", x, " in DOM"); + console.error(error); } - - }catch(error){ - console.error("Could not find ", x, " In DOM"); - console.error(error); } }); - colour_legend.push([item, colours[colour_idx]]) - colour_idx++; + + + if (nodes_found_for_this_category.length > 0) { + colour_legend.push([item, colours[colour_idx]]) + visible_breakdown[item] = nodes_found_for_this_category; + colour_idx++; + } } - CreateNodeLegend(colour_legend, col_index, break_down_values); + + CreateNodeLegend(colour_legend, col_index, visible_breakdown); }; @@ -3338,6 +3488,7 @@ } METADATA = d3.tsvParse(DATA, blankQuitter); TABLE_HEADERS = METADATA.columns; + CreateTable(METADATA, "#metadata", TABLE_HEADERS); METADATA = null; initialize_legend_menu(); @@ -3362,6 +3513,7 @@ } METADATA = d3.tsvParse(metadata, blankQuitter); TABLE_HEADERS = METADATA.columns; + CreateTable(METADATA, "#metadata", TABLE_HEADERS); METADATA = null; initialize_legend_menu(); @@ -3441,14 +3593,30 @@ }); + + zoom_tree = function(){ const zoom_times = parseFloat(document.querySelector('#zoom_slider').value) const tree_data_elm = document.querySelector('#TreeData') const tree_svg = document.querySelector('#TreeSVG') document.querySelector('#zoom_slider_value').textContent = zoom_times - const new_width = ORIGINAL_WIDTH_SVG * zoom_times //use global value as getBBox() at high zooms gives wrong values - const prev_width = parseFloat(document.querySelector('#TreeSVG').style.width) + + const content_bbox = tree_svg.getBBox(); //content bounding box of the Tree SVG + + // Define the base coordinate width (unzoomed) including your offset + const base_coordinate_width = Math.max(ORIGINAL_WIDTH_SVG + TREE_LEFT_MARGIN_VIEWBOX_OFFSET + 20, + content_bbox.width + content_bbox.x + 50); //20 for right margin padding + const current_vb = tree_svg.viewBox.baseVal; //current viewBox values + const base_coordinate_height = current_vb.height; //the unzoomed height of the tree. + + //Calculate new physical dimensions of width and height as both must be scaled for zoom to work + const new_width = base_coordinate_width * zoom_times + const new_height = base_coordinate_height * zoom_times + + //Get the previous physical width before zooming + const prev_width = parseFloat(document.querySelector('#TreeSVG').style.width) || base_coordinate_width; + const prev_height = parseFloat(document.querySelector('#TreeSVG').style.height) || base_coordinate_height; //Previous x and y scroll positions before applying a zoom const prev_x_scroll_pos = tree_data_elm.scrollLeft @@ -3457,31 +3625,38 @@ //document.querySelector('#TreeSVG').style.width = new_width ; //modify width of SVG tree // svg width was not being properly set on firfox document.querySelector('#TreeSVG').setAttribute("width", `${new_width}`); //modify width of SVG tree + document.querySelector('#TreeSVG').setAttribute("height", `${new_height}`); //modify height of SVG tree + + // This is what makes the tree actually look bigger inside the new width/height + tree_svg.setAttribute("viewBox", `${-TREE_LEFT_MARGIN_VIEWBOX_OFFSET} ${current_vb.y} ${base_coordinate_width} ${base_coordinate_height}`); + + // Calculate the scale change factor for width and height + const scaleChangeX = new_width / prev_width; + const scaleChangeY = new_height / prev_height; + + // Focus on the center of the current view for a smoother experience + // Formula: (Current center point * scale) - half of the window + const view_w = tree_data_elm.clientWidth; + const view_h = tree_data_elm.clientHeight; + + // Calculate the physical "Boundaries" of the new scaled content. + const maxPossibleX = new_width - view_w; + const maxPossibleY = new_height - view_h; + + // Calculate the theoretical scroll target that does not care if the result is possible given screen dims + let targetX = (prev_x_scroll_pos + view_w / 2) * scaleChangeX - (view_w / 2); + let targetY = (prev_y_scroll_pos + view_h / 2) * scaleChangeY - (view_h / 2); + + // This prevents the browser from fighting your scrollTo command + zoom_x_translated = Math.round(Math.max(0, Math.min(targetX, maxPossibleX))); + zoom_y_translated = Math.round(Math.max(0, Math.min(targetY, maxPossibleY))); + + //Apply the final, safe coordinates preventing x-axis sliding effect on zoom out + document.querySelector('#TreeData').scrollTo(zoom_x_translated, zoom_y_translated); - //store new translated x and y coordinates after zooming - let zoom_x_translated = 0 - let zoom_y_translated = 0 - const scaleChange = new_width/prev_width //sometimes the specified zoom factor is not exact, use this value instead - - //keeping zoom centered on an element - if(new_width > prev_width){ - //get previous scroll position which is the left most x-coordinate position of a view - //translate that coordiante to a new zoomed state - //add 1/4 view field length scaled to a new zoomed state coordinate - zoom_x_translated = prev_x_scroll_pos*scaleChange + (tree_data_elm.clientWidth/4)*(scaleChange) - zoom_y_translated = prev_y_scroll_pos*scaleChange + (tree_data_elm.clientHeight/4)*(scaleChange) - - }else{ - //Zooming out - //Scale current scroll position (previous scroll + 1/4 view field distance) to a new coordinate zoomed out state - //Substract the 1/4 offeset/margin that is already in the needed zoomed out state (no adjustments) - zoom_x_translated = prev_x_scroll_pos*scaleChange - (tree_data_elm.clientWidth/4) - zoom_y_translated = prev_y_scroll_pos*scaleChange - (tree_data_elm.clientHeight/4) - - } - document.querySelector('#TreeData').scrollTo(zoom_x_translated,zoom_y_translated) - } + } + scroll_into_view_treenode = function(id){ let selectedNode = document.querySelector(`[id='${id}']`) if(selectedNode !== null){ @@ -3576,9 +3751,21 @@ scroll_legend_into_view = function(node,full_screen_btn, node_TreeData){ + if(node !== null){ - node.style.left=`${node_TreeData.scrollLeft}px` - node.style.top=`${node_TreeData.scrollTop}px` + const x = Math.round(node_TreeData.scrollLeft+10); + const y = Math.round(node_TreeData.scrollTop+10); + + // Skip if the movement is less than 5px (small) + if (Math.abs(x - LAST_SCROLL_X) < 5 && Math.abs(y - LAST_SCROLL_Y) < 2) { + return; + } + + LAST_SCROLL_X = x; + LAST_SCROLL_Y = y; + + node.style.left=`${x}px` + node.style.top=`${y}px` full_screen_btn.style.right = `-${node_TreeData.scrollLeft}px` full_screen_btn.style.top = `${node_TreeData.scrollTop}px` @@ -3593,25 +3780,54 @@ } let select_deselect_nodes_by_field_value = (legend_node, column_index) => { - + const categoryName = legend_node.getAttribute('data-fieldvalue'); + const columnName = data_table.column(column_index).header().innerText.trim(); //find indices of filtered data in a metadata table - let indexes = data_table.rows( (idx, data, node) => { - if(data[column_index] === `${legend_node.textContent}` ){ - return true - }else{ - return false - } - } ).indexes() + let getIndexes = () => { + return data_table.rows((idx, data, node) => { + return data[column_index] === `${legend_node.getAttribute('data-fieldvalue')}`; + }).indexes(); + }; + + let indexes = getIndexes(); + let selected = legend_node.classList.contains('fw-bold') ? true : false + + + let selected_node_ids = data_table.cells(indexes,0).data(); + + if (selected_node_ids.length === 0) { + + Swal.fire({ + title: "Selected Nodes Not Found in Metadata", + html: `The selected category ${categoryName} in in column ${columnName} contains samples are currently hidden.

Would you like to clear ALL metadata filters to select these nodes?`, + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Yes, clear filters" + }).then((result) => { + if (result.isConfirmed) { + data_table.clear(); + data_table.rows.add(Array.from(ORIGINAL_DATA.values())).draw(); + $("td").addClass("editable"); + $("td:not(:first-child)").attr("contenteditable", true); + + select_deselect_nodes_by_field_value(legend_node, column_index); + } + }); + return; + } + if(!selected){ legend_node.classList.add('fw-bold'); }else{ legend_node.classList.remove('fw-bold'); } + - let selected_node_ids = data_table.cells(indexes,0).data(); for(var i = 0; i < selected_node_ids.length; i++) { let tree_node = document.querySelector( `[id='${selected_node_ids[i]}']`) let [ele, circle, text] = SelectedNodes.getNodeData(tree_node); @@ -3626,8 +3842,13 @@ } } // Bring the terminal node into frame - terminal_node = document.querySelector(`[id=${selected_node_ids[selected_node_ids.length-1]}`); - terminal_node.scrollIntoView({block: "center", inline: "center"}) + let lastId = selected_node_ids[selected_node_ids.length-1]; + terminal_node = document.querySelector(`[id=${lastId}]`); + if (terminal_node) { + terminal_node.scrollIntoView({block: "center", inline: "center"}) + }else{ + console.warn(`Node with ID ${lastId} not found in DOM. Cannot scroll.`); + } SelectedNodes.drawSelectedNodes(); } @@ -3714,7 +3935,7 @@ const xmlns = "http://www.w3.org/2000/xmlns/"; const xlinkns = "http://www.w3.org/1999/xlink"; const svgns = "http://www.w3.org/2000/svg"; - svg = svg.cloneNode(true); + //svg = svg.cloneNode(true); const lgd = document.getElementById("colour-legend"); const legend_width = lgd.clientWidth; @@ -3746,17 +3967,47 @@ tree_svg.style.marginLeft = legend_width + offset_safety_factor; const serializer = new window.XMLSerializer; - const xml_string = serializer.serializeToString(svg); + const xml_string = serializer.serializeToString(tree_svg); //we export tree_svg and not svg as pure svg does not support html elements such as legend return [new Blob([xml_string], {type: "image/svg+xml"}), xml_string]; } - export_tree_to_svg = function(){ + + + export_tree_to_svg = function(event){ + if (event) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + + const versionMeta = document.querySelector('meta[name="version"]'); + const versionNumber = versionMeta.getAttribute('content'); + const date = new Date(); let downloadLink = document.createElement("a"); downloadLink.download = 'tree_snapshot_'+date.getDate()+'-'+ (date.getMonth()+1)+'-'+date.getFullYear()+'_'+date.getHours()+'h.svg'; let svg = document.getElementById("TreeData").cloneNode(true) + let svgElement = svg.querySelector("#TreeSVG"); + const currentViewBox = svgElement.getAttribute("viewBox"); + let [x, y, width, height] = currentViewBox.split(/[\s,]+/).map(Number); + svgElement.setAttribute("viewBox", `${x} ${y-20} ${width} ${height}`); + svgElement.style.fontFamily = "monospace"; + + const versionText = document.createElementNS("http://www.w3.org/2000/svg", "text"); + versionText.textContent = `v${versionNumber}`; + + versionText.setAttribute("x", x + width - 10); // 10px from right edge + versionText.setAttribute("y", y - 5); + versionText.setAttribute("fill", "#999999"); + versionText.setAttribute("font-size", "14px"); + versionText.setAttribute("font-family", "monospace"); + versionText.setAttribute("text-anchor", "end"); // Right-aligned sticking to right margin + + svgElement.appendChild(versionText); + + //remove duplicated leaf nodes text duplication for more accurate searches svg.querySelectorAll('g .tree-node').forEach(node => { text_nodes_leafs = node.querySelectorAll('text[class=""]') @@ -3764,23 +4015,35 @@ text_nodes_leafs[0].remove() } }) + svg.querySelector("#TreeSVG").style.overflow = "visible" const blob = serialize2svg(svg)[0]; downloadLink.href = window.URL.createObjectURL(blob); + console.log(window.URL.createObjectURL(blob)) downloadLink.click(); //Trigger a click on the element downloadLink.remove(); } + /*Adapted from https://gist.github.com/tatsuyasusukida/1261585e3422da5645a1cbb9cf8813d6 and https://zooper.pages.dev/articles/how-to-convert-a-svg-to-png-using-canvas*/ - export_tree_to_png = function(){ + export_tree_to_png = function(event){ + if (event) event.preventDefault(); let svg = document.getElementById("TreeSVG").cloneNode(true); + let legendOrigin = document.getElementById("colour-legend"); + let hasLegend = legendOrigin && !legendOrigin.classList.contains('d-none'); + svg.style.fontFamily = "monospace"; + + let legendWidth = hasLegend ? legendOrigin.offsetWidth : 0; + let legendHeight = hasLegend ? legendOrigin.offsetHeight : 0; let {x, y, width, height} = svg.viewBox.baseVal; let svg_width = width; let svg_height = height; + let modified_width = svg_width + (svg_width * 0.15); - let modified_height = svg_height + (svg_height * 0.15); + let modified_height = svg_height// + (svg_height * 0.15); + // Change the width of the svg element allowing for more of the viewbox to show up svg.setAttribute("width", modified_width) @@ -3800,14 +4063,59 @@ .attr("id", "translation_element") .attr("height", modified_height) .attr("width", modified_width) - .append(() => { - return svg - }) + .attr("viewBox", `${x} ${y} ${modified_width} ${modified_height}`) + // .append(() => { + // return svg + //}) + if (hasLegend) { //add legend if it exists to svg + let legendClone = legendOrigin.cloneNode(true); + const activeStyles = window.getComputedStyle(legendOrigin); + legendClone.style.cssText = activeStyles.cssText; + + //replace bootstrap d-flex with classical flex + legendClone.querySelectorAll('.d-flex').forEach(el => { + el.style.display = "flex"; + el.style.alignItems = "center"; + el.style.padding = "4px"; + }); + + legendClone.querySelectorAll('.text-center').forEach(el => { + el.style.textAlign = "center"; + el.style.display = "block"; // Ensure it takes full width to allow centering + el.style.width = "100%"; + }); + + legendClone.querySelectorAll('.d-flex').forEach(el => el.style.display = "flex"); + + // 3. Replace inputs with solid squares + legendClone.querySelectorAll('input').forEach(el => { + let square = document.createElement('div'); + square.style.cssText = "width:20px; height:20px; border:1px solid black; display:inline-block; flex-shrink:0;"; + square.style.backgroundColor = el.value; + el.parentNode.replaceChild(square, el); + }); + const fObject = wrapping_svg.append("foreignObject") + .attr("x", 30) // Anchored to the top-left of the viewport + .attr("y", 35) + .attr("width", legendOrigin.offsetWidth) // Fixed width for the legend area + .attr("height", legendOrigin.scrollHeight); + + // Add XHTML wrapper with minimal Bootstrap-mimicking styles + const div = fObject.append("xhtml:div") + .attr("xmlns", "http://www.w3.org/1999/xhtml") + .attr("style", "background-color: white; border: 1px solid #ccc;"); + div.append(() => legendClone);; + + } + + wrapping_svg.append('g') + .attr("id", "translation_element") + .append(() => svg); //extract SVG element const svgData = new XMLSerializer().serializeToString(document.getElementById("temporary_svg")); - + //create canvas to the size of the SVG let canvas = document.createElement('canvas') canvas.width = modified_width; @@ -3819,12 +4127,21 @@ img.onload = function () { //must be inside this image onload event function due to async image load nature //render SVG image onto canvas element + const versionMeta = document.querySelector('meta[name="version"]'); + const versionNumber = versionMeta.getAttribute('content'); + let ctx = canvas.getContext('2d'); ctx.fillStyle = "white"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, modified_width, modified_height); + ctx.font = "14px monospace"; + ctx.fillStyle = "#999999"; // Subtle grey + ctx.textAlign = "right"; // Align text to the right edge + ctx.textBaseline = "top"; // Align text to the top edge + ctx.fillText(`v${versionNumber}`, canvas.width - 10, 5); + //create PNG image from the canvas let pngUrl = canvas.toDataURL('image/png').replace('image/png', 'octet/stream'); //create download link @@ -3868,8 +4185,6 @@ document.addEventListener("DOMContentLoaded",()=>{ - let button = document.querySelector('#tree_fullscreen_btn') - /** * Handles dynamic layout adjustments when the #ClusterInfo section is shown or hidden. * @@ -3894,9 +4209,12 @@ document.querySelector('#ClusterInfo').addEventListener('hidden.bs.collapse', function () { let heightMetaDiv = document.querySelector('#ClusterInfo>div').style.height let heightTreeDataDiv = document.querySelector('#TreeData').style.height + let resizerHeight = document.querySelector('#resizer-bar').offsetHeight let unitMatchTree = heightTreeDataDiv.match(/.?(\w{1,2})$/) let unitMatchMeta = heightMetaDiv.match(/.?(\w{1,2})$/) let unitTree = unitMatchTree ? unitMatchTree[1] : null; + let viewportHeight = window.visualViewport.height; + let maxAllowedHeight = window.innerHeight - resizerHeight - 10; /*let unitMeta = unitMatchMeta ? unitMatchMeta[1] : null; // Calculate the equivalent pixel value from vh @@ -3905,11 +4223,15 @@ heightMetaDiv = (parseFloat(heightMetaDiv) / 100) * viewportHeight; unitMeta="px" }*/ - - if(unitTree !== null){ - document.querySelector('#TreeData').style.height= parseFloat(heightTreeDataDiv) + parseFloat(heightMetaDiv)+"px" - document.querySelector('#control_panel').style.height = parseFloat(heightTreeDataDiv) + parseFloat(heightMetaDiv)+"px" + + if(unitTree !== null){ + let calculatedHeight = parseFloat(heightTreeDataDiv) + parseFloat(heightMetaDiv) + let finalHeight = Math.min(calculatedHeight, maxAllowedHeight); + + document.querySelector('#TreeData').style.height= finalHeight +"px" + document.querySelector('#control_panel').style.height = finalHeight +"px" }else{ + document.querySelector('#TreeData').style.height=`${window.visualViewport.height-10}px` document.querySelector('#control_panel').style.height=`${window.visualViewport.height-10}px` } @@ -3952,11 +4274,16 @@ const controlPanel = document.querySelector('#control_panel'); const clusterInfo = document.querySelector('#ClusterInfo'); const ORIGINAL_CONTROL_PANEL_HEIGHT = controlPanel.getBoundingClientRect().height + const versionMeta = document.querySelector('meta[name="version"]'); + + const versionNumber = versionMeta.getAttribute('content'); + const watermark = document.getElementById('version-watermark'); + watermark.textContent = `v${versionNumber}`; - function adjustClusterInfoPanelHeight(controlPanel, clusterInfo) { + function adjustClusterInfoPanelHeight(controlPanel, clusterInfo) { + const resizerHeight = document.querySelector('#resizer-bar').offsetHeight; // Calculate the desired height for the cluster info panel. - let newHeight = window.innerHeight - ORIGINAL_CONTROL_PANEL_HEIGHT; - + let newHeight = window.innerHeight - ORIGINAL_CONTROL_PANEL_HEIGHT - resizerHeight - 4; // Define the minimum height as 10% of the viewport. const minHeightPercentage = 0.10; const tenPercentViewportHeight = window.innerHeight * minHeightPercentage; @@ -3967,14 +4294,32 @@ } // Set the height of the cluster info panel's inner div. - clusterInfo.querySelector("div").style.height = `${newHeight}px`; + clusterInfo.querySelector("div").style.height = `${Math.floor(newHeight)}px`; } adjustClusterInfoPanelHeight(controlPanel, clusterInfo) const ORIGINAL_METADATA_HIGHT = clusterInfo.querySelector("div").style.height - + // Ths listener detecta when fullscreen mode ends and restores the layout dimensions to the original values captured on load. This is necessary as exiting fullscreen can cause the layout to resize in unexpected ways, and we want to ensure a consistent user experience by reverting to the known "safe" dimensions. + document.addEventListener('fullscreenchange', () => { + // If document.fullscreenElement is null, we have just EXITED fullscreen + if (!document.fullscreenElement) { + console.debug("Exited fullscreen: Restoring layout dimensions."); + + // Force the layout back to the known "safe" dimensions captured on initial load + if(document.querySelector('#control_panel')){ + document.querySelector('#control_panel').style.height = `${ORIGINAL_CONTROL_PANEL_HEIGHT}px`; + } + if(document.querySelector('#TreeData')){ + document.querySelector('#TreeData').style.height = `${ORIGINAL_CONTROL_PANEL_HEIGHT}px`; + } + if(document.querySelector('#ClusterInfo>div')){ + document.querySelector('#ClusterInfo>div').style.height = ORIGINAL_METADATA_HIGHT; + } + adjustSelectedNodesHeight(); + } + }); if (controlPanel && clusterInfo) { controlPanel.addEventListener('mouseenter', () => { @@ -4073,24 +4418,36 @@ vertical_div_resize = function(event){ event.preventDefault() let cursor_pos_init = event.y + const clusterInfo = document.querySelector('#ClusterInfo'); metadata_panel = document.querySelector('#ClusterInfo>div') treeData_panel = document.querySelector('#TreeData') control_panel = document.querySelector('#control_panel') + let metadata_panel_height_init = metadata_panel.getBoundingClientRect().height let treeData_panel_height_init = treeData_panel.getBoundingClientRect().height let control_panel_height_init = control_panel.getBoundingClientRect().height document.addEventListener('mousemove', resize, false) document.addEventListener('mouseup', stop_resize, false) + + //Only uncollapse if it was hidden and do not use bsCollapse.show(), the animation will conflict with the mouse move. + if (!clusterInfo.classList.contains('show')) { + clusterInfo.classList.add('show'); // Force the class on immediately with no animation + metadata_panel.style.height = '0px'; // Ensure element starts at 0 height for correct math + } + function resize(event){ event.stopPropagation(); const dy = event.y - cursor_pos_init metadata_panel.style.height = metadata_panel_height_init - dy+'px' - treeData_panel.style.height = treeData_panel_height_init + dy+'px' - control_panel.style.height = control_panel_height_init + dy+'px' + treeData_panel.style.height = treeData_panel_height_init + dy +'px' + control_panel.style.height = control_panel_height_init + dy +'px' } function stop_resize(event){ document.removeEventListener('mousemove', resize) document.querySelector('#SelectedNodes').style.Height = `${document.querySelector('#TreeData').offsetHeight - document.querySelector('#tree_menu_buttons').offsetHeight}px` + + + } } @@ -4125,6 +4482,12 @@ dragDivByMouse = function(event) { const legend_div = document.querySelector('#colour-legend'); const control_panel_div = document.querySelector("#control_panel"); + const treeDataContainer = document.querySelector('#TreeData'); + + //Current SVG TreeData scroll positions to adjust for scrolling during drag + const scrollY = treeDataContainer.scrollTop; + const scrollX = treeDataContainer.scrollLeft; + // Get bounding box of the legend const rect = legend_div.getBoundingClientRect(); @@ -4133,6 +4496,9 @@ const grabOffsetX = event.clientX - rect.left; const grabOffsetY = event.clientY - rect.top; + + const tree = document.querySelector('#TreeData'); + // Store control panel width let controlPanelOffset = control_panel_div.offsetWidth; // Override offset if document is in fullscreen mode @@ -4147,8 +4513,8 @@ e.preventDefault(); // Calculate new position, accounting for grab offset and control panel width - const newLeft = e.clientX - grabOffsetX - controlPanelOffset; - const newTop = e.clientY - grabOffsetY; + const newLeft = e.clientX - grabOffsetX - controlPanelOffset + scrollX; + const newTop = e.clientY - grabOffsetY + scrollY; legend_div.style.left = newLeft + "px"; legend_div.style.top = newTop + "px"; @@ -4170,7 +4536,7 @@ -
+
@@ -4196,12 +4562,12 @@ title="Copy selected node IDs to your clipboard to paste into another application and optionally generate a text file" onclick="copy_ids_to_clipboard()" name="CopyIDs" id="copy-ids-button"> IDs - -
@@ -4210,12 +4576,12 @@ Export Meta Table
- +