| <!-- | |
| Copyright 2026 The Fuchsia Authors. All rights reserved. | |
| Use of this source code is governed by a BSD-style license that can be | |
| found in the LICENSE file. | |
| --> | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Testing Metadata Visualizer</title> | |
| <style> | |
| :root { | |
| --bg-color: #0d1117; | |
| --card-bg: rgba(22, 27, 34, 0.8); | |
| --card-border: rgba(48, 54, 61, 0.5); | |
| --text-primary: #c9d1d9; | |
| --text-secondary: #8b949e; | |
| --accent-primary: #58a6ff; | |
| --accent-secondary: #bc85ff; | |
| --font-family: | |
| "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, | |
| Arial, sans-serif; | |
| --shadow-primary: 0 8px 32px 0 rgba(0, 0, 0, 0.37); | |
| } | |
| :root.light { | |
| --bg-color: #f6f8fa; | |
| --card-bg: rgba(255, 255, 255, 0.9); | |
| --card-border: rgba(216, 222, 228, 0.8); | |
| --text-primary: #24292f; | |
| --text-secondary: #57606a; | |
| --accent-primary: #0969da; | |
| --accent-secondary: #8250df; | |
| --shadow-primary: 0 4px 12px rgba(0, 0, 0, 0.1); | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--bg-color); | |
| color: var(--text-primary); | |
| margin: 0; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| box-sizing: border-box; | |
| transition: | |
| background-color 0.3s, | |
| color 0.3s; | |
| } | |
| header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding-bottom: 20px; | |
| border-bottom: 1px solid var(--card-border); | |
| margin-bottom: 20px; | |
| flex-shrink: 0; | |
| } | |
| h1 { | |
| font-size: 1.5rem; | |
| margin: 0; | |
| background: linear-gradient( | |
| 135deg, | |
| var(--accent-primary), | |
| var(--accent-secondary) | |
| ); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| .controls { | |
| display: flex; | |
| gap: 15px; | |
| align-items: center; | |
| } | |
| select, | |
| input { | |
| background-color: var(--card-bg); | |
| border: 1px solid var(--card-border); | |
| color: var(--text-primary); | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| font-family: var(--font-family); | |
| font-size: 0.9rem; | |
| outline: none; | |
| transition: | |
| border-color 0.2s, | |
| box-shadow 0.2s; | |
| } | |
| select:focus, | |
| input:focus { | |
| border-color: var(--accent-primary); | |
| box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.3); | |
| } | |
| .breadcrumbs { | |
| display: flex; | |
| gap: 5px; | |
| align-items: center; | |
| font-size: 0.9rem; | |
| color: var(--text-secondary); | |
| margin-bottom: 15px; | |
| flex-wrap: wrap; | |
| } | |
| .breadcrumbs span { | |
| cursor: pointer; | |
| color: var(--accent-primary); | |
| } | |
| .breadcrumbs span:hover { | |
| text-decoration: underline; | |
| } | |
| .breadcrumbs .current { | |
| color: var(--text-primary); | |
| cursor: default; | |
| } | |
| .breadcrumbs .current:hover { | |
| text-decoration: none; | |
| } | |
| main { | |
| flex-grow: 1; | |
| display: flex; | |
| flex-direction: row; | |
| gap: 20px; | |
| overflow: hidden; | |
| background: var(--card-bg); | |
| border: 1px solid var(--card-border); | |
| border-radius: 12px; | |
| backdrop-filter: blur(8px); | |
| box-shadow: var(--shadow-primary); | |
| padding: 20px; | |
| box-sizing: border-box; | |
| } | |
| .sidebar { | |
| flex: 0 0 320px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 20px; | |
| overflow-y: auto; | |
| border-right: 1px solid var(--card-border); | |
| padding-right: 20px; | |
| box-sizing: border-box; | |
| } | |
| .sidebar h2 { | |
| font-size: 1.1rem; | |
| margin: 0; | |
| color: var(--accent-primary); | |
| } | |
| .sidebar-section { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .sidebar-section-title { | |
| font-size: 0.9rem; | |
| font-weight: 600; | |
| color: var(--text-secondary); | |
| margin-top: 20px; | |
| margin-bottom: 5px; | |
| border-bottom: 1px solid var(--card-border); | |
| padding-bottom: 3px; | |
| } | |
| .sidebar-item { | |
| font-size: 0.85rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .sidebar-item .chip { | |
| max-width: 180px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| margin: 0; | |
| } | |
| .tree-container { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 15px; | |
| overflow-y: auto; | |
| padding-left: 10px; | |
| } | |
| .box { | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--card-border); | |
| border-radius: 8px; | |
| padding: 15px; | |
| box-sizing: border-box; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| transition: | |
| transform 0.2s, | |
| background-color 0.2s, | |
| border-color 0.2s; | |
| cursor: pointer; | |
| width: 100%; | |
| max-width: 800px; | |
| border-left: 4px solid transparent; | |
| } | |
| .box:hover { | |
| transform: translateY(-2px); | |
| background: rgba(255, 255, 255, 0.05); | |
| border-color: var(--accent-primary); | |
| } | |
| .box-title { | |
| font-weight: 600; | |
| font-size: 1rem; | |
| color: var(--text-primary); | |
| word-break: break-all; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .box-title .icon { | |
| font-size: 1.2rem; | |
| color: var(--text-secondary); | |
| } | |
| .box-meta { | |
| font-size: 0.85rem; | |
| color: var(--text-secondary); | |
| display: flex; | |
| flex-direction: row; | |
| gap: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .box-meta-section { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| flex: 1; | |
| min-width: 150px; | |
| } | |
| .box-meta-section span.label { | |
| font-weight: 600; | |
| font-size: 0.8rem; | |
| color: var(--text-secondary); | |
| } | |
| .chip { | |
| display: inline-block; | |
| padding: 3px 8px; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| width: fit-content; | |
| margin-right: 5px; | |
| margin-bottom: 5px; | |
| border: 1px solid transparent; | |
| } | |
| .chip .count { | |
| border-radius: 3px; | |
| padding: 1px 4px; | |
| margin-left: 5px; | |
| font-size: 0.7rem; | |
| } | |
| .theme-toggle { | |
| cursor: pointer; | |
| background: none; | |
| border: none; | |
| color: var(--text-primary); | |
| font-size: 1.2rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 8px; | |
| border-radius: 50%; | |
| transition: background-color 0.2s; | |
| } | |
| .theme-toggle:hover { | |
| background-color: rgba(255, 255, 255, 0.1); | |
| } | |
| /* Loading and Empty State */ | |
| .empty-state { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100%; | |
| color: var(--text-secondary); | |
| gap: 15px; | |
| } | |
| .empty-state h2 { | |
| margin: 0; | |
| color: var(--text-primary); | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: var(--card-border); | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--text-secondary); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>Test Coverage Visualizer</h1> | |
| <div class="controls"> | |
| <button | |
| class="theme-toggle" | |
| onclick="toggleTheme()" | |
| title="Toggle Theme" | |
| > | |
| 🌓 | |
| </button> | |
| </div> | |
| </header> | |
| <div id="breadcrumbs" class="breadcrumbs"></div> | |
| <main> | |
| <div id="sidebar" class="sidebar"> | |
| <h2>Summary</h2> | |
| <div id="sidebar-content" class="sidebar-section"> | |
| <!-- Recursive summary will be inserted here --> | |
| </div> | |
| </div> | |
| <div id="graph-container" class="tree-container"> | |
| <div class="empty-state"> | |
| <h2>No Data Loaded</h2> | |
| <p>Ensure a valid testing_metadata.json is loaded.</p> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| let metadata = {}; | |
| let originalData = {}; | |
| let currentPath = ""; | |
| // Theme Management | |
| function toggleTheme() { | |
| const isLight = document.documentElement.classList.toggle("light"); | |
| localStorage.setItem("theme", isLight ? "light" : "dark"); | |
| renderGraph(currentPath, false); // Re-render to update colors for light mode | |
| } | |
| (function () { | |
| const savedTheme = localStorage.getItem("theme"); | |
| if (savedTheme === "light") { | |
| document.documentElement.classList.add("light"); | |
| } | |
| })(); | |
| // Load Data from URL | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const filesParam = urlParams.get("files"); | |
| const pathParam = urlParams.get("path") || ""; | |
| if (filesParam) { | |
| loadData(filesParam.split(",")[0].trim(), pathParam); | |
| } | |
| // Handle back/forward buttons | |
| window.addEventListener("popstate", (event) => { | |
| const params = new URLSearchParams(window.location.search); | |
| const path = params.get("path") || ""; | |
| renderGraph(path, false); // Don't pushState again | |
| }); | |
| }); | |
| function loadData(url, initialPath = "") { | |
| fetch(url) | |
| .then((response) => { | |
| if (!response.ok) | |
| throw new Error(`HTTP error! status: ${response.status}`); | |
| return response.json(); | |
| }) | |
| .then((data) => { | |
| originalData = data; | |
| metadata = data.metadata || {}; | |
| renderGraph(initialPath, false); // Initial render, don't pushState | |
| }) | |
| .catch((error) => { | |
| console.error("Failed to load data:", error); | |
| showEmptyState(`Failed to load data: ${error.message}`); | |
| }); | |
| } | |
| function showEmptyState(message) { | |
| const container = document.getElementById("graph-container"); | |
| container.innerHTML = ` | |
| <div class="empty-state"> | |
| <h2>Error</h2> | |
| <p>${message}</p> | |
| </div> | |
| `; | |
| } | |
| function updateBreadcrumbs(path) { | |
| const container = document.getElementById("breadcrumbs"); | |
| container.innerHTML = ""; | |
| const spanAll = document.createElement("span"); | |
| spanAll.textContent = "All Directories"; | |
| spanAll.onclick = () => renderGraph(""); | |
| container.appendChild(spanAll); | |
| if (!path) { | |
| spanAll.className = "current"; | |
| return; | |
| } | |
| const segments = path.split("/"); | |
| let accumulatedPath = ""; | |
| segments.forEach((segment, index) => { | |
| const sep = document.createTextNode(" / "); | |
| container.appendChild(sep); | |
| accumulatedPath += (accumulatedPath ? "/" : "") + segment; | |
| const span = document.createElement("span"); | |
| span.textContent = segment; | |
| if (index === segments.length - 1) { | |
| span.className = "current"; | |
| } else { | |
| const targetPath = accumulatedPath; | |
| span.onclick = () => renderGraph(targetPath); | |
| } | |
| container.appendChild(span); | |
| }); | |
| } | |
| // Generate consistent HSL color based on string and theme | |
| function getCategoryColor(text) { | |
| if ( | |
| !text || | |
| text === "None" || | |
| text.toLowerCase() === "uncategorized" | |
| ) { | |
| const isLight = document.documentElement.classList.contains("light"); | |
| return { | |
| bg: isLight ? "#e4e6eb" : "#2a261a", | |
| text: "var(--text-secondary)", | |
| border: "var(--card-border)", | |
| }; | |
| } | |
| let hash = 0; | |
| for (let i = 0; i < text.length; i++) { | |
| hash = text.charCodeAt(i) + ((hash << 5) - hash); | |
| } | |
| const hue = Math.abs(hash % 360); | |
| const sat = 70 + Math.abs((hash >> 8) % 30); // 70% to 100% | |
| const isLight = document.documentElement.classList.contains("light"); | |
| if (isLight) { | |
| const lightBg = 80 + Math.abs((hash >> 16) % 15); | |
| const lightText = 15 + Math.abs((hash >> 24) % 15); | |
| return { | |
| bg: `hsl(${hue}, ${sat}%, ${lightBg}%)`, | |
| text: `hsl(${hue}, ${sat}%, ${lightText}%)`, | |
| border: `hsl(${hue}, ${sat}%, 65%)`, | |
| }; | |
| } else { | |
| const darkBg = 8 + Math.abs((hash >> 16) % 8); | |
| const darkText = 70 + Math.abs((hash >> 24) % 15); | |
| return { | |
| bg: `hsl(${hue}, ${sat}%, ${darkBg}%)`, | |
| text: `hsl(${hue}, ${sat}%, ${darkText}%)`, | |
| border: `hsl(${hue}, ${sat}%, 35%)`, | |
| }; | |
| } | |
| } | |
| function renderGraph(filterPath, updateHistory = true) { | |
| // Before pushing a new state, save the scroll position of the CURRENT view. | |
| if (updateHistory && history.state) { | |
| const oldContainer = document.getElementById("graph-container"); | |
| const oldScroll = oldContainer ? oldContainer.scrollTop : 0; | |
| history.replaceState({ ...history.state, scrollTop: oldScroll }, ""); | |
| } | |
| currentPath = filterPath; | |
| updateBreadcrumbs(filterPath); | |
| // Update URL query string | |
| if (updateHistory) { | |
| const url = new URL(window.location); | |
| url.searchParams.set("path", filterPath); | |
| history.pushState({ path: filterPath, scrollTop: 0 }, "", url); | |
| } | |
| const container = document.getElementById("graph-container"); | |
| container.innerHTML = ""; | |
| // Restore scroll position if available in history state | |
| const state = history.state; | |
| let targetScroll = 0; | |
| if ( | |
| state && | |
| state.path === filterPath && | |
| state.scrollTop !== undefined | |
| ) { | |
| targetScroll = state.scrollTop; | |
| } | |
| const sidebar = document.getElementById("sidebar"); | |
| if (sidebar) sidebar.scrollTop = 0; // Always reset sidebar to top | |
| const keys = Object.keys(metadata); | |
| // 1. Calculate recursive summary for sidebar | |
| const recursiveCategories = new Map(); | |
| const recursiveSubcats = new Map(); | |
| const recursiveTags = new Map(); | |
| let totalDescendants = 0; | |
| keys.forEach((key) => { | |
| if ( | |
| filterPath === "" || | |
| key === filterPath || | |
| key.startsWith(filterPath + "/") | |
| ) { | |
| totalDescendants++; | |
| const cov = metadata[key].coverage || {}; | |
| const cat = cov.category || "None"; | |
| recursiveCategories.set( | |
| cat, | |
| (recursiveCategories.get(cat) || 0) + 1, | |
| ); | |
| const subcat = cov.subcategory || "None"; | |
| recursiveSubcats.set( | |
| subcat, | |
| (recursiveSubcats.get(subcat) || 0) + 1, | |
| ); | |
| const tags = Array.isArray(cov.tags) ? cov.tags : []; | |
| tags.forEach((tag) => { | |
| recursiveTags.set(tag, (recursiveTags.get(tag) || 0) + 1); | |
| }); | |
| } | |
| }); | |
| // Render sidebar | |
| const sidebarContent = document.getElementById("sidebar-content"); | |
| const sortedRecCats = Array.from(recursiveCategories.entries()).sort( | |
| (a, b) => b[1] - a[1], | |
| ); | |
| const sortedRecSubcats = Array.from(recursiveSubcats.entries()).sort( | |
| (a, b) => b[1] - a[1], | |
| ); | |
| const sortedRecTags = Array.from(recursiveTags.entries()).sort( | |
| (a, b) => b[1] - a[1], | |
| ); | |
| document.querySelector(".sidebar h2").textContent = filterPath | |
| ? `Summary: ${filterPath}` | |
| : "Summary: All Root"; | |
| sidebarContent.innerHTML = ` | |
| <div class="sidebar-item" style="font-weight: 600; color: var(--text-primary);"> | |
| <span>Total Directories</span> | |
| <span>${totalDescendants}</span> | |
| </div> | |
| <div class="sidebar-section-title" style="margin-top: 5px;">Categories</div> | |
| ${ | |
| sortedRecCats | |
| .slice(0, 5) | |
| .map(([cat, count]) => { | |
| const colors = getCategoryColor(cat); | |
| return ` | |
| <div class="sidebar-item"> | |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}">${cat}</span> | |
| <span>${count}</span> | |
| </div> | |
| `; | |
| }) | |
| .join("") || '<div class="sidebar-item">None</div>' | |
| } | |
| <div class="sidebar-section-title">Subcategories</div> | |
| ${ | |
| sortedRecSubcats | |
| .slice(0, 5) | |
| .map(([subcat, count]) => { | |
| const colors = getCategoryColor(subcat); | |
| return ` | |
| <div class="sidebar-item"> | |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}">${subcat}</span> | |
| <span>${count}</span> | |
| </div> | |
| `; | |
| }) | |
| .join("") || '<div class="sidebar-item">None</div>' | |
| } | |
| <div class="sidebar-section-title">Tags</div> | |
| ${ | |
| sortedRecTags | |
| .slice(0, 10) | |
| .map(([tag, count]) => { | |
| const colors = getCategoryColor(tag); | |
| return ` | |
| <div class="sidebar-item"> | |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}">${tag}</span> | |
| <span>${count}</span> | |
| </div> | |
| `; | |
| }) | |
| .join("") || '<div class="sidebar-item">None</div>' | |
| } | |
| `; | |
| // 2. Find immediate children of currentPath for the main view | |
| const childrenMap = new Map(); // segment -> { count, categories: Map, subcategories: Map, tags: Map, isLeaf, data } | |
| keys.forEach((key) => { | |
| if (key === filterPath) { | |
| const cov = metadata[key].coverage || {}; | |
| const cat = cov.category || "None"; | |
| const subcat = cov.subcategory || "None"; | |
| const tags = Array.isArray(cov.tags) ? cov.tags : []; | |
| const catMap = new Map([[cat, 1]]); | |
| const subcatMap = new Map([[subcat, 1]]); | |
| const tagsMap = new Map(); | |
| tags.forEach((t) => tagsMap.set(t, 1)); | |
| childrenMap.set(".", { | |
| count: 1, | |
| categories: catMap, | |
| subcategories: subcatMap, | |
| tags: tagsMap, | |
| isLeaf: true, | |
| data: metadata[key], | |
| }); | |
| return; | |
| } | |
| if (filterPath === "" || key.startsWith(filterPath + "/")) { | |
| const relativePart = | |
| filterPath === "" ? key : key.slice(filterPath.length + 1); | |
| const segments = relativePart.split("/"); | |
| const immediateSegment = segments[0]; | |
| if (!childrenMap.has(immediateSegment)) { | |
| childrenMap.set(immediateSegment, { | |
| count: 0, | |
| categories: new Map(), | |
| subcategories: new Map(), | |
| tags: new Map(), | |
| isLeaf: segments.length === 1, | |
| data: null, | |
| }); | |
| } | |
| const entry = childrenMap.get(immediateSegment); | |
| entry.count++; | |
| const cov = metadata[key].coverage || {}; | |
| const cat = cov.category || "None"; | |
| entry.categories.set(cat, (entry.categories.get(cat) || 0) + 1); | |
| const subcat = cov.subcategory || "None"; | |
| entry.subcategories.set( | |
| subcat, | |
| (entry.subcategories.get(subcat) || 0) + 1, | |
| ); | |
| const tags = Array.isArray(cov.tags) ? cov.tags : []; | |
| tags.forEach((tag) => { | |
| entry.tags.set(tag, (entry.tags.get(tag) || 0) + 1); | |
| }); | |
| if (segments.length === 1) { | |
| entry.isLeaf = true; | |
| entry.data = metadata[key]; | |
| } | |
| } | |
| }); | |
| if (childrenMap.size === 0) { | |
| container.innerHTML = ` | |
| <div class="empty-state"> | |
| <h2>No Data Found</h2> | |
| <p>No directories match the filter.</p> | |
| </div> | |
| `; | |
| return; | |
| } | |
| // Sort so current dir '.' is first, then branches, then leaves | |
| const sortedEntries = Array.from(childrenMap.entries()).sort((a, b) => { | |
| if (a[0] === ".") return -1; | |
| if (b[0] === ".") return 1; | |
| return a[0].localeCompare(b[0]); | |
| }); | |
| sortedEntries.forEach(([segment, info]) => { | |
| const box = document.createElement("div"); | |
| box.className = "box"; | |
| const titlePath = segment === "." ? filterPath || "/" : segment; | |
| box.classList.add("directory"); | |
| // Sort and get dominant color from category | |
| const sortedCats = Array.from(info.categories.entries()).sort( | |
| (a, b) => b[1] - a[1], | |
| ); | |
| const dominantCat = sortedCats[0]?.[0] || "None"; | |
| const dominantColors = getCategoryColor(dominantCat); | |
| box.style.borderLeftColor = dominantColors.border; | |
| // Sort subcats and tags | |
| const sortedSubcats = Array.from(info.subcategories.entries()).sort( | |
| (a, b) => b[1] - a[1], | |
| ); | |
| const sortedTags = Array.from(info.tags.entries()).sort( | |
| (a, b) => b[1] - a[1], | |
| ); | |
| box.innerHTML = ` | |
| <div class="box-title" title="${titlePath}"> | |
| <span>${titlePath}</span> | |
| <span class="icon">📁</span> | |
| </div> | |
| <div class="box-size">${info.count} ${info.count === 1 ? "directory" : "directories"}</div> | |
| <div class="box-meta"> | |
| <div class="box-meta-section"> | |
| <span class="label">Categories</span> | |
| <div> | |
| ${ | |
| sortedCats | |
| .map(([cat, count]) => { | |
| const colors = getCategoryColor(cat); | |
| return ` | |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}"> | |
| ${cat} <span class="count" style="background: ${colors.text}; color: var(--bg-color)">${count}</span> | |
| </span> | |
| `; | |
| }) | |
| .join("") || "None" | |
| } | |
| </div> | |
| </div> | |
| <div class="box-meta-section"> | |
| <span class="label">Subcategories</span> | |
| <div> | |
| ${ | |
| sortedSubcats | |
| .map(([subcat, count]) => { | |
| const colors = getCategoryColor(subcat); | |
| return ` | |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}"> | |
| ${subcat} <span class="count" style="background: ${colors.text}; color: var(--bg-color)">${count}</span> | |
| </span> | |
| `; | |
| }) | |
| .join("") || "None" | |
| } | |
| </div> | |
| </div> | |
| <div class="box-meta-section"> | |
| <span class="label">Tags</span> | |
| <div> | |
| ${ | |
| sortedTags | |
| .map(([tag, count]) => { | |
| const colors = getCategoryColor(tag); | |
| return ` | |
| <span class="chip" style="background: ${colors.bg}; color: ${colors.text}; border-color: ${colors.border}"> | |
| ${tag} <span class="count" style="background: ${colors.text}; color: var(--bg-color)">${count}</span> | |
| </span> | |
| `; | |
| }) | |
| .join("") || "None" | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| box.onclick = () => { | |
| if (segment === ".") return; | |
| const nextPath = filterPath ? `${filterPath}/${segment}` : segment; | |
| renderGraph(nextPath); | |
| }; | |
| container.appendChild(box); | |
| }); | |
| // Apply restored scroll target after DOM construction | |
| container.scrollTop = targetScroll; | |
| } | |
| </script> | |
| </body> | |
| </html> |