From 2cad91a63b7b276012454c7a377d750022c83323 Mon Sep 17 00:00:00 2001 From: s0lray Date: Wed, 25 Mar 2026 14:34:59 -0400 Subject: [PATCH 1/2] Search: reveal node in tree instead of linking to URL (THE-87) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a search result now collapses the full tree, expands all ancestor branches of the matched node, highlights the node with an accent-colored circle and bold text, and pans the viewport to it. A small ↗ icon remains in each result for users who want to open the tool's website directly. Co-Authored-By: Paperclip --- public/css/arf.css | 31 +++++++++++++--- public/js/arf.js | 92 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/public/css/arf.css b/public/css/arf.css index 983a0e2..a8d0459 100644 --- a/public/css/arf.css +++ b/public/css/arf.css @@ -211,20 +211,39 @@ a { padding: 8px 12px; border-bottom: 1px solid var(--color-link); font-size: 13px; + cursor: pointer; } .search-result-item:last-child { border-bottom: none; } -.search-result-item a { - font-weight: bold; - color: var(--color-node-stroke); - text-decoration: none; +.search-result-item:hover { + background: var(--color-bg); } -.search-result-item a:hover { - text-decoration: underline; +.search-result-header { + display: flex; + align-items: center; + gap: 6px; +} + +.search-result-name { + font-weight: bold; + color: var(--color-node-stroke); +} + +.search-result-ext { + font-size: 11px; + color: var(--color-text-secondary); + text-decoration: none; + flex-shrink: 0; + line-height: 1; +} + +.search-result-ext:hover { + color: var(--color-node-stroke); + text-decoration: none; } .search-result-path { diff --git a/public/js/arf.js b/public/js/arf.js index f65cd92..eac2f99 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -4,7 +4,8 @@ var margin = [20, 120, 20, 140], i = 0, duration = 1250, root, - allSearchNodes = []; + allSearchNodes = [], + searchMatches = []; var tree = d3.tree() .size([height, width]); @@ -100,6 +101,7 @@ function update(source) { nodeEnter.append("circle") .attr("r", 1e-6) .style("fill", function(d) { + if (d._highlighted) return getCSSVar("--color-accent"); return d._children ? getCSSVar("--color-node-fill-branch") : getCSSVar("--color-node-fill-leaf"); }); @@ -138,13 +140,16 @@ function update(source) { .attr("transform", function(d) { return "translate(" + d.y + "," + d.x + ")"; }); nodeUpdate.select("circle") - .attr("r", 6) + .attr("r", function(d) { return d._highlighted ? 9 : 6; }) .style("fill", function(d) { + if (d._highlighted) return getCSSVar("--color-accent"); return d._children ? getCSSVar("--color-node-fill-branch") : getCSSVar("--color-node-fill-leaf"); - }); + }) + .style("stroke-width", function(d) { return d._highlighted ? "2.5px" : "1.5px"; }); nodeUpdate.select("text") .style("fill-opacity", 1) + .style("font-weight", function(d) { return d._highlighted ? "bold" : "normal"; }) .style("fill", function(d) { return d.data.free ? getCSSVar("--color-text-primary") : getCSSVar("--color-text-secondary"); }); @@ -229,20 +234,49 @@ function initSearch() { var query = input.value.trim(); searchDebounceTimer = setTimeout(function() { doSearch(query); }, 200); }); + + var results = document.getElementById("search-results"); + if (results) { + results.addEventListener("click", function(e) { + var item = e.target.closest(".search-result-item"); + if (!item) return; + // If the click was on the external link, let it navigate normally + if (e.target.closest(".search-result-ext")) return; + var idx = parseInt(item.getAttribute("data-node-idx"), 10); + if (!isNaN(idx) && searchMatches[idx]) { + revealNode(searchMatches[idx]); + } + }); + results.addEventListener("keydown", function(e) { + if (e.key !== "Enter") return; + var item = e.target.closest(".search-result-item"); + if (!item) return; + if (e.target.closest(".search-result-ext")) return; + var idx = parseInt(item.getAttribute("data-node-idx"), 10); + if (!isNaN(idx) && searchMatches[idx]) { + revealNode(searchMatches[idx]); + } + }); + } } function doSearch(query) { var results = document.getElementById("search-results"); if (!results) return; + // Clear highlights whenever the query changes + root.descendants().forEach(function(n) { n._highlighted = false; }); + if (!query) { + searchMatches = []; results.innerHTML = ""; results.classList.remove("visible"); + update(root); return; } var lower = query.toLowerCase(); - var matches = allSearchNodes.filter(function(d) { + searchMatches = allSearchNodes.filter(function(d) { var name = (d.data.name || "").toLowerCase(); var desc = (d.data.description || "").toLowerCase(); return name.indexOf(lower) !== -1 || desc.indexOf(lower) !== -1; @@ -250,19 +284,22 @@ function doSearch(query) { results.classList.add("visible"); - if (matches.length === 0) { + if (searchMatches.length === 0) { results.innerHTML = '
No results found for \u201c' + escapeHtml(query) + '\u201d
'; return; } - var html = matches.map(function(d) { + var html = searchMatches.map(function(d, idx) { var path = d.ancestors().reverse().slice(1, -1).map(function(a) { return escapeHtml(a.data.name); }).join(" \u203a "); - var name = escapeHtml(d.data.name); + var name = escapeHtml(parseName(d.data.name).cleanName); var url = safeUrl(d.data.url); - return '
' + - '' + name + '' + + return '
' + + '
' + + '' + name + '' + + (url !== '#' ? '\u2197' : '') + + '
' + (path ? '
' + path + '
' : '') + '
'; }).join(""); @@ -270,6 +307,43 @@ function doSearch(query) { results.innerHTML = html; } +// Reveal a node in the tree: collapse everything, expand ancestors, highlight and pan to it. +function revealNode(d) { + // Collapse entire tree + root.descendants().forEach(function(n) { + n._highlighted = false; + if (n.children) { + n._children = n.children; + n.children = null; + } + }); + + // Expand all ancestors from root down to d's parent + d.ancestors().forEach(function(ancestor) { + if (ancestor._children) { + ancestor.children = ancestor._children; + ancestor._children = null; + } + }); + + // Highlight the target node + d._highlighted = true; + + // Re-render and pan + update(root); + zoomToNode(d); + + // Close the search dropdown + var results = document.getElementById("search-results"); + if (results) { + results.innerHTML = ""; + results.classList.remove("visible"); + } + var input = document.getElementById("search-input"); + if (input) input.value = ""; + searchMatches = []; +} + function escapeHtml(str) { return (str || "").replace(/&/g, "&").replace(//g, ">"); } From e16fa431b62a14234c2664e3282f1a3e8258476c Mon Sep 17 00:00:00 2001 From: s0lray Date: Wed, 25 Mar 2026 15:26:09 -0400 Subject: [PATCH 2/2] fix(zoom): center node on visible viewport, not SVG viewBox midpoint (THE-87) zoomToNode was using svgH/2 (y=400 in viewBox space) as the pan target. Because the SVG sits below the header, the true viewport center is at (window.innerHeight/2 - rect.top) / svgScale not svgH/2. The discrepancy equals roughly the header height, which is why the zoomed node consistently appeared too low. Co-Authored-By: Paperclip --- public/js/arf.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/public/js/arf.js b/public/js/arf.js index eac2f99..d3a9f04 100644 --- a/public/js/arf.js +++ b/public/js/arf.js @@ -217,8 +217,13 @@ function toggle(d) { // Auto-pan viewport to center on a node after expand/click. function zoomToNode(d) { var currentK = d3.zoomTransform(svgEl.node()).k; - var tx = svgW / 2 - margin[3] - d.y * currentK; - var ty = svgH / 2 - margin[0] - d.x * currentK; + var rect = svgEl.node().getBoundingClientRect(); + var svgScale = rect.width / svgW; + // Use the visible viewport center (accounts for header/nav above the SVG). + var vpCenterX = rect.width / 2 / svgScale; + var vpCenterY = (window.innerHeight / 2 - rect.top) / svgScale; + var tx = vpCenterX - margin[3] - d.y * currentK; + var ty = vpCenterY - margin[0] - d.x * currentK; svgEl.transition().duration(duration) .call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(currentK)); }