blob: 94368689bb122588639e024b3905f2d532280035 [file]
<!--
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>