|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<title>Storage Treemap Visualization</title> |
|
<script src="https://d3js.org/d3.v7.min.js"></script> |
|
<style> |
|
html, body { |
|
height: 100%; |
|
margin: 0; |
|
padding: 0; |
|
font-family: Arial, sans-serif; |
|
overflow: hidden; |
|
} |
|
body { |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
#header { |
|
padding: 10px 20px; |
|
background: #f5f5f5; |
|
border-bottom: 1px solid #ddd; |
|
} |
|
#title-row { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 10px; |
|
} |
|
h1 { |
|
margin: 0; |
|
font-size: 24px; |
|
} |
|
#file-controls { |
|
display: flex; |
|
align-items: center; |
|
gap: 10px; |
|
} |
|
#controls { |
|
margin: 0; |
|
} |
|
#treemap { |
|
flex: 1; |
|
position: relative; |
|
width: 100%; |
|
} |
|
.node { |
|
position: absolute; |
|
overflow: hidden; |
|
cursor: pointer; |
|
line-height: 1.2; |
|
font-size: 10px; |
|
} |
|
.node-label { |
|
padding: 4px; |
|
color: white; |
|
text-shadow: 0 0 3px rgba(0,0,0,0.8); |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
} |
|
#tooltip { |
|
position: absolute; |
|
padding: 10px; |
|
background: rgba(0,0,0,0.9); |
|
color: white; |
|
border-radius: 5px; |
|
pointer-events: none; |
|
display: none; |
|
z-index: 1000; |
|
} |
|
input[type="file"] { margin-right: 10px; } |
|
#cached-info { |
|
display: inline-block; |
|
margin-left: 10px; |
|
color: #666; |
|
font-size: 12px; |
|
} |
|
#clear-cache { |
|
margin-left: 5px; |
|
font-size: 11px; |
|
cursor: pointer; |
|
color: #0066cc; |
|
text-decoration: underline; |
|
} |
|
#depth-control { |
|
display: inline-flex; |
|
align-items: center; |
|
margin-left: 10px; |
|
} |
|
#maxDepth { |
|
width: 150px; |
|
margin: 0 8px; |
|
} |
|
#depth-value { |
|
min-width: 20px; |
|
font-weight: bold; |
|
} |
|
#breadcrumb { |
|
padding: 5px 20px; |
|
background: #fff; |
|
border-bottom: 1px solid #ddd; |
|
font-size: 14px; |
|
min-height: 20px; |
|
} |
|
#breadcrumb span { |
|
color: #0066cc; |
|
cursor: pointer; |
|
text-decoration: underline; |
|
} |
|
#breadcrumb span:hover { |
|
color: #0044aa; |
|
} |
|
#breadcrumb .separator { |
|
color: #666; |
|
margin: 0 5px; |
|
text-decoration: none; |
|
cursor: default; |
|
} |
|
#breadcrumb .current { |
|
color: #333; |
|
text-decoration: none; |
|
cursor: default; |
|
font-weight: bold; |
|
} |
|
.node.folder { |
|
cursor: pointer; |
|
} |
|
.node.folder:hover { |
|
opacity: 0.8; |
|
} |
|
#welcome { |
|
position: absolute; |
|
top: 50%; |
|
left: 50%; |
|
transform: translate(-50%, -50%); |
|
text-align: center; |
|
padding: 40px; |
|
background: #f9f9f9; |
|
border-radius: 10px; |
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1); |
|
} |
|
#welcome h2 { |
|
margin: 0 0 20px 0; |
|
color: #333; |
|
} |
|
#welcome p { |
|
margin: 0 0 30px 0; |
|
color: #666; |
|
} |
|
#welcome input[type="file"] { |
|
padding: 10px 20px; |
|
font-size: 16px; |
|
cursor: pointer; |
|
} |
|
.has-data #welcome { |
|
display: none; |
|
} |
|
.has-data #header, |
|
.has-data #breadcrumb, |
|
.has-data #treemap { |
|
display: block; |
|
} |
|
body:not(.has-data) #header, |
|
body:not(.has-data) #breadcrumb, |
|
body:not(.has-data) #treemap { |
|
display: none; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id="welcome"> |
|
<h2>Storage Treemap Visualization</h2> |
|
<p>Select a CSV file to visualize storage data</p> |
|
<input type="file" id="welcomeFileInput" accept=".csv"> |
|
</div> |
|
|
|
<div id="header"> |
|
<div id="title-row"> |
|
<h1>Storage Treemap Visualization</h1> |
|
<div id="file-controls"> |
|
<input type="file" id="csvFile" accept=".csv"> |
|
<span id="cached-info" style="display: none;"> |
|
Using cached: <span id="cached-filename"></span> |
|
<span id="clear-cache">[clear]</span> |
|
</span> |
|
</div> |
|
</div> |
|
<div id="controls"> |
|
<label>Size by: <select id="sizeBy"> |
|
<option value="storage" selected>Total Storage</option> |
|
<option value="count">File Count</option> |
|
</select></label> |
|
<label style="margin-left: 10px;">Color by: <select id="colorBy"> |
|
<option value="size">Total Storage</option> |
|
<option value="count" selected>File Count</option> |
|
</select></label> |
|
<span id="depth-control"> |
|
<label>Max Depth:</label> |
|
<input type="range" id="maxDepth" min="1" max="10" value="10"> |
|
<span id="depth-value">10</span> |
|
</span> |
|
</div> |
|
</div> |
|
<div id="breadcrumb"></div> |
|
<div id="treemap"></div> |
|
<div id="tooltip"></div> |
|
|
|
<script> |
|
let currentData = null; // Store current data for resize events |
|
let originalData = null; // Store original unfiltered data |
|
let maxDataDepth = 10; // Store the maximum depth found in the data |
|
let navigationPath = []; // Store navigation path for breadcrumb |
|
|
|
// Check for cached data on page load |
|
window.addEventListener('DOMContentLoaded', function() { |
|
const cachedCSV = localStorage.getItem('treemap_csv_data'); |
|
const cachedFilename = localStorage.getItem('treemap_csv_filename'); |
|
|
|
if (cachedCSV && cachedFilename) { |
|
// Show cached info |
|
document.body.classList.add('has-data'); |
|
document.getElementById('cached-info').style.display = 'inline-block'; |
|
document.getElementById('cached-filename').textContent = cachedFilename; |
|
|
|
// Process the cached data |
|
const csv = d3.csvParse(cachedCSV); |
|
processData(csv); |
|
} |
|
}); |
|
|
|
// Clear cache handler |
|
document.getElementById('clear-cache').addEventListener('click', function() { |
|
localStorage.removeItem('treemap_csv_data'); |
|
localStorage.removeItem('treemap_csv_filename'); |
|
document.getElementById('cached-info').style.display = 'none'; |
|
// Clear the visualization |
|
document.getElementById('treemap').innerHTML = ''; |
|
document.getElementById('breadcrumb').innerHTML = ''; |
|
currentData = null; |
|
originalData = null; |
|
navigationPath = []; |
|
// Show welcome screen again |
|
document.body.classList.remove('has-data'); |
|
}); |
|
|
|
// Handle file input from welcome screen |
|
document.getElementById('welcomeFileInput').addEventListener('change', handleFileSelect); |
|
document.getElementById('csvFile').addEventListener('change', handleFileSelect); |
|
|
|
function handleFileSelect(e) { |
|
const file = e.target.files[0]; |
|
if (file) { |
|
const reader = new FileReader(); |
|
reader.onload = function(e) { |
|
const csvContent = e.target.result; |
|
|
|
// Store in localStorage |
|
try { |
|
localStorage.setItem('treemap_csv_data', csvContent); |
|
localStorage.setItem('treemap_csv_filename', file.name); |
|
|
|
// Update UI |
|
document.body.classList.add('has-data'); |
|
document.getElementById('cached-info').style.display = 'inline-block'; |
|
document.getElementById('cached-filename').textContent = file.name; |
|
} catch (e) { |
|
console.warn('Could not cache file (localStorage full or disabled):', e); |
|
document.body.classList.add('has-data'); |
|
} |
|
|
|
const csv = d3.csvParse(csvContent); |
|
processData(csv); |
|
|
|
// Sync file inputs |
|
if (e.target.id === 'welcomeFileInput') { |
|
document.getElementById('csvFile').files = e.target.files; |
|
} else { |
|
document.getElementById('welcomeFileInput').files = e.target.files; |
|
} |
|
}; |
|
reader.readAsText(file); |
|
} |
|
} |
|
|
|
function processData(csv) { |
|
// Build hierarchy from CSV data |
|
const root = {name: "root", children: []}; |
|
const nodeMap = {}; |
|
maxDataDepth = 0; // Reset max depth |
|
|
|
// Process each row |
|
csv.forEach(row => { |
|
const path = row.Folder || ""; |
|
if (!path) return; |
|
|
|
const parts = path.split('/').filter(p => p); |
|
const fileCount = +row.File_Count || 0; |
|
const totalStorage = +row.Total_Storage || 0; |
|
|
|
// Track maximum depth |
|
maxDataDepth = Math.max(maxDataDepth, parts.length); |
|
|
|
// Build the tree structure |
|
let currentPath = ""; |
|
let parent = root; |
|
|
|
parts.forEach((part, index) => { |
|
currentPath = currentPath ? `${currentPath}/${part}` : part; |
|
|
|
if (!nodeMap[currentPath]) { |
|
const node = { |
|
name: part, |
|
path: currentPath, |
|
depth: index + 1 // Add depth tracking |
|
}; |
|
|
|
// If this is a leaf node (last part), add the values |
|
if (index === parts.length - 1) { |
|
node.fileCount = fileCount; |
|
node.totalStorage = totalStorage; |
|
// Store both metrics, we'll decide which to use for value later |
|
node.storageValue = totalStorage; |
|
node.countValue = fileCount; |
|
} else { |
|
node.children = []; |
|
} |
|
|
|
nodeMap[currentPath] = node; |
|
|
|
if (!parent.children) { |
|
parent.children = []; |
|
} |
|
parent.children.push(node); |
|
} |
|
|
|
parent = nodeMap[currentPath]; |
|
}); |
|
}); |
|
|
|
// Update slider max value and reset to max |
|
const slider = document.getElementById('maxDepth'); |
|
slider.max = maxDataDepth; |
|
slider.value = maxDataDepth; |
|
document.getElementById('depth-value').textContent = maxDataDepth; |
|
|
|
// Remove empty branches and calculate parent values |
|
function cleanAndSum(node) { |
|
if (node.children) { |
|
node.children = node.children.filter(child => { |
|
const hasValue = cleanAndSum(child); |
|
return hasValue; |
|
}); |
|
|
|
if (node.children.length === 0) { |
|
delete node.children; |
|
return false; |
|
} |
|
|
|
// Sum up children values for parents |
|
let totalStorage = 0; |
|
let fileCount = 0; |
|
node.children.forEach(child => { |
|
totalStorage += child.totalStorage || 0; |
|
fileCount += child.fileCount || 0; |
|
}); |
|
node.totalStorage = totalStorage; |
|
node.fileCount = fileCount; |
|
node.storageValue = totalStorage; |
|
node.countValue = fileCount; |
|
return totalStorage > 0 || fileCount > 0; |
|
} |
|
return (node.storageValue || 0) > 0 || (node.countValue || 0) > 0; |
|
} |
|
|
|
cleanAndSum(root); |
|
originalData = root; // Store original data |
|
applyDepthFilter(); |
|
} |
|
|
|
function applyDepthFilter() { |
|
if (!originalData) return; |
|
|
|
const maxDepth = parseInt(document.getElementById('maxDepth').value); |
|
|
|
// Start from the current navigation point |
|
let startNode = originalData; |
|
for (const pathItem of navigationPath) { |
|
startNode = pathItem.node; |
|
} |
|
|
|
// Deep clone the original data |
|
function cloneWithDepth(node, currentDepth = 0) { |
|
const clone = { |
|
name: node.name, |
|
path: node.path, |
|
depth: node.depth, |
|
fileCount: node.fileCount || 0, |
|
totalStorage: node.totalStorage || 0, |
|
storageValue: node.storageValue || 0, |
|
countValue: node.countValue || 0 |
|
}; |
|
|
|
// If we're at max depth, collapse children into this node |
|
if (currentDepth >= maxDepth) { |
|
// Sum all descendant values |
|
function sumDescendants(n) { |
|
let storage = n.storageValue || n.totalStorage || 0; |
|
let files = n.countValue || n.fileCount || 0; |
|
if (n.children) { |
|
n.children.forEach(child => { |
|
const childSums = sumDescendants(child); |
|
storage += childSums.storage; |
|
files += childSums.files; |
|
}); |
|
} |
|
return { storage: storage, files: files }; |
|
} |
|
|
|
const sums = sumDescendants(node); |
|
clone.storageValue = sums.storage; |
|
clone.totalStorage = sums.storage; |
|
clone.countValue = sums.files; |
|
clone.fileCount = sums.files; |
|
// Don't include children |
|
} else if (node.children) { |
|
clone.children = node.children.map(child => cloneWithDepth(child, currentDepth + 1)); |
|
} |
|
|
|
return clone; |
|
} |
|
|
|
currentData = cloneWithDepth(startNode); |
|
drawTreemap(currentData); |
|
updateBreadcrumb(); |
|
} |
|
|
|
function navigateToFolder(node) { |
|
if (!node.children || node.children.length === 0) return; |
|
|
|
// Add to navigation path |
|
navigationPath.push({ |
|
name: node.name, |
|
path: node.path, |
|
node: node |
|
}); |
|
|
|
// Redraw from this node |
|
applyDepthFilter(); |
|
} |
|
|
|
function navigateToBreadcrumb(index) { |
|
// Trim navigation path to the selected index |
|
navigationPath = navigationPath.slice(0, index); |
|
applyDepthFilter(); |
|
} |
|
|
|
function updateBreadcrumb() { |
|
const breadcrumb = document.getElementById('breadcrumb'); |
|
if (navigationPath.length === 0) { |
|
breadcrumb.innerHTML = ''; |
|
return; |
|
} |
|
|
|
let html = '<span onclick="navigateToBreadcrumb(0)">Root</span>'; |
|
navigationPath.forEach((item, index) => { |
|
html += '<span class="separator">›</span>'; |
|
if (index === navigationPath.length - 1) { |
|
html += `<span class="current">${item.name}</span>`; |
|
} else { |
|
html += `<span onclick="navigateToBreadcrumb(${index + 1})">${item.name}</span>`; |
|
} |
|
}); |
|
breadcrumb.innerHTML = html; |
|
} |
|
|
|
function drawTreemap(data) { |
|
const container = document.getElementById('treemap'); |
|
const width = container.offsetWidth; |
|
const height = container.offsetHeight; |
|
|
|
// Clear previous content |
|
container.innerHTML = ''; |
|
|
|
// Don't draw if container has no size |
|
if (width === 0 || height === 0) return; |
|
|
|
// Get the size metric |
|
const sizeBy = document.getElementById('sizeBy').value; |
|
|
|
// Create hierarchy with selected size metric |
|
const hierarchy = d3.hierarchy(data) |
|
.sum(d => { |
|
if (sizeBy === 'storage') { |
|
return d.storageValue || 0; |
|
} else { |
|
return d.countValue || 0; |
|
} |
|
}) |
|
.sort((a, b) => b.value - a.value); |
|
|
|
// Create treemap layout |
|
const treemap = d3.treemap() |
|
.size([width, height]) |
|
.paddingOuter(3) |
|
.paddingTop(19) |
|
.paddingInner(1) |
|
.round(true); |
|
|
|
// Generate the treemap |
|
const root = treemap(hierarchy); |
|
|
|
// Color scales |
|
const colorBy = document.getElementById('colorBy').value; |
|
const leaves = root.leaves(); |
|
|
|
const colorScale = d3.scaleSequential() |
|
.domain([0, d3.max(leaves, d => colorBy === 'size' ? d.data.totalStorage : d.data.fileCount)]) |
|
.interpolator(d3.interpolateCool); |
|
|
|
// Create the treemap cells |
|
const cell = d3.select(container) |
|
.selectAll('.node') |
|
.data(root.descendants()) |
|
.enter() |
|
.append('div') |
|
.attr('class', d => d.children ? 'node folder' : 'node') |
|
.style('left', d => `${d.x0}px`) |
|
.style('top', d => `${d.y0}px`) |
|
.style('width', d => `${d.x1 - d.x0}px`) |
|
.style('height', d => `${d.y1 - d.y0}px`) |
|
.style('background', d => { |
|
if (d.children) { |
|
return '#ddd'; // Parent nodes are light gray |
|
} else { |
|
const value = colorBy === 'size' ? d.data.totalStorage : d.data.fileCount; |
|
return colorScale(value); |
|
} |
|
}) |
|
.style('border', d => d.children ? '2px solid #999' : '1px solid white') |
|
.on('click', function(event, d) { |
|
if (d.children) { |
|
event.stopPropagation(); |
|
navigateToFolder(d.data); |
|
} |
|
}) |
|
.on('mouseover', function(event, d) { |
|
const tooltip = document.getElementById('tooltip'); |
|
tooltip.style.display = 'block'; |
|
let tooltipText = `<strong>${d.data.path || d.data.name}</strong><br> |
|
Files: ${(d.data.fileCount || 0).toLocaleString()}<br> |
|
Size: ${formatBytes(d.data.totalStorage || d.value || 0)}`; |
|
if (d.children) { |
|
tooltipText += '<br><em>Click to explore this folder</em>'; |
|
} |
|
tooltip.innerHTML = tooltipText; |
|
tooltip.style.left = `${event.pageX + 10}px`; |
|
tooltip.style.top = `${event.pageY - 30}px`; |
|
}) |
|
.on('mouseout', function() { |
|
document.getElementById('tooltip').style.display = 'none'; |
|
}); |
|
|
|
// Add labels |
|
cell.append('div') |
|
.attr('class', 'node-label') |
|
.text(d => { |
|
const width = d.x1 - d.x0; |
|
const height = d.y1 - d.y0; |
|
// Only show label if there's enough space |
|
if (width > 40 && height > 20) { |
|
return d.data.name; |
|
} |
|
return ''; |
|
}) |
|
.style('font-size', d => { |
|
const width = d.x1 - d.x0; |
|
if (width > 100) return '12px'; |
|
if (width > 50) return '10px'; |
|
return '9px'; |
|
}); |
|
} |
|
|
|
function formatBytes(bytes) { |
|
if (bytes === 0) return '0 Bytes'; |
|
const k = 1024; |
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; |
|
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; |
|
} |
|
|
|
// Handle window resize |
|
let resizeTimer; |
|
window.addEventListener('resize', function() { |
|
clearTimeout(resizeTimer); |
|
resizeTimer = setTimeout(function() { |
|
if (currentData) { |
|
drawTreemap(currentData); |
|
} |
|
}, 250); // Debounce resize events |
|
}); |
|
|
|
// Handle color change |
|
document.getElementById('colorBy').addEventListener('change', function() { |
|
if (currentData) { |
|
drawTreemap(currentData); |
|
} |
|
}); |
|
|
|
// Handle size change |
|
document.getElementById('sizeBy').addEventListener('change', function() { |
|
if (currentData) { |
|
drawTreemap(currentData); |
|
} |
|
}); |
|
|
|
// Handle depth change with slider |
|
document.getElementById('maxDepth').addEventListener('input', function(e) { |
|
document.getElementById('depth-value').textContent = e.target.value; |
|
applyDepthFilter(); |
|
}); |
|
</script> |
|
</body> |
|
</html> |