|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Squarified Treemap - Folder Explorer</title> |
|
<style> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
|
margin: 0; |
|
padding: 2rem; |
|
background-color: #f4f4f9; |
|
color: #333; |
|
text-align: center; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
justify-content: flex-start; |
|
min-height: 100vh; |
|
} |
|
|
|
h1 { |
|
color: #2c3e50; |
|
} |
|
|
|
p { |
|
color: #555; |
|
margin-bottom: 2rem; |
|
max-width: 600px; |
|
} |
|
|
|
|
|
.folder-picker-label { |
|
display: inline-block; |
|
padding: 12px 24px; |
|
background-color: #3498db; |
|
color: white; |
|
border-radius: 8px; |
|
cursor: pointer; |
|
font-weight: bold; |
|
transition: background-color 0.3s ease, transform 0.2s ease; |
|
} |
|
|
|
.folder-picker-label:hover { |
|
background-color: #2980b9; |
|
transform: translateY(-2px); |
|
} |
|
|
|
#folder-picker { |
|
display: none; |
|
} |
|
|
|
|
|
#treemap-container { |
|
position: relative; |
|
width: 90vw; |
|
max-width: 1200px; |
|
height: 75vh; |
|
margin: 2rem auto; |
|
border: 1px solid #ccc; |
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1); |
|
background-color: #fff; |
|
border-radius: 8px; |
|
overflow: hidden; |
|
} |
|
|
|
|
|
.node-group { |
|
position: absolute; |
|
box-sizing: border-box; |
|
overflow: hidden; |
|
} |
|
|
|
|
|
.internal { |
|
border: 1px solid #aaa; |
|
} |
|
|
|
|
|
.leaf { |
|
background-clip: padding-box; |
|
box-shadow: inset 0px 0px 0px 1px rgba(255, 255, 255, 0.8); |
|
transition: filter 0.2s ease-in-out; |
|
display: flex; |
|
align-items: flex-start; |
|
justify-content: flex-start; |
|
} |
|
|
|
.leaf:hover { |
|
filter: brightness(1.15); |
|
z-index: 10; |
|
} |
|
|
|
|
|
.node-label { |
|
display: block; |
|
padding: 2px 5px; |
|
color: #fff; |
|
background-color: rgba(0,0,0,0.4); |
|
font-size: 12px; |
|
font-weight: bold; |
|
white-space: nowrap; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
text-decoration: none; |
|
cursor: pointer; |
|
} |
|
.node-label:hover { |
|
background-color: rgba(0,0,0,0.6); |
|
} |
|
|
|
|
|
.leaf-label { |
|
padding: 3px; |
|
color: rgba(255, 255, 255, 0.95); |
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.7); |
|
text-align: left; |
|
font-size: 11px; |
|
white-space: normal; |
|
word-break: break-word; |
|
pointer-events: none; |
|
} |
|
|
|
|
|
#tooltip { |
|
position: fixed; |
|
background-color: rgba(0, 0, 0, 0.85); |
|
color: white; |
|
padding: 8px 12px; |
|
border-radius: 4px; |
|
pointer-events: none; |
|
opacity: 0; |
|
transition: opacity 0.2s; |
|
font-size: 14px; |
|
z-index: 1001; |
|
transform: translate(15px, 10px); |
|
} |
|
|
|
|
|
#context-menu { |
|
position: fixed; |
|
display: none; |
|
background-color: #ecf0f1; |
|
border: 1px solid #bdc3c7; |
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2); |
|
border-radius: 5px; |
|
padding: 5px 0; |
|
z-index: 1000; |
|
} |
|
|
|
#context-menu button { |
|
display: block; |
|
width: 100%; |
|
padding: 8px 20px; |
|
border: none; |
|
background: none; |
|
text-align: left; |
|
cursor: pointer; |
|
font-size: 14px; |
|
} |
|
|
|
#context-menu button:hover { |
|
background-color: #3498db; |
|
color: white; |
|
} |
|
|
|
|
|
.modal-overlay { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
background: rgba(0,0,0,0.5); |
|
display: none; |
|
align-items: center; |
|
justify-content: center; |
|
z-index: 2000; |
|
} |
|
.modal-content { |
|
background: white; |
|
padding: 20px; |
|
border-radius: 8px; |
|
text-align: center; |
|
box-shadow: 0 5px 15px rgba(0,0,0,0.3); |
|
} |
|
.modal-content p { |
|
margin-bottom: 20px; |
|
} |
|
.modal-content button { |
|
padding: 10px 20px; |
|
border: none; |
|
border-radius: 5px; |
|
cursor: pointer; |
|
margin: 0 10px; |
|
} |
|
#modal-confirm { |
|
background-color: #e74c3c; |
|
color: white; |
|
} |
|
#modal-cancel { |
|
background-color: #bdc3c7; |
|
} |
|
|
|
</style> |
|
</head> |
|
<body> |
|
|
|
<h1>Squarified Treemap Folder Explorer 🗺️</h1> |
|
<p>Select a folder to visualize its contents. Hover over files or directories for info. Right-click on a file for options.</p> |
|
|
|
<label for="folder-picker" class="folder-picker-label">Choose a Folder</label> |
|
<input type="file" id="folder-picker" webkitdirectory directory multiple /> |
|
|
|
<div id="treemap-container"></div> |
|
<div id="tooltip"></div> |
|
|
|
|
|
<div id="context-menu"> |
|
<button id="menu-copy-path">Copy Path</button> |
|
<button id="menu-delete">Delete from View</button> |
|
</div> |
|
|
|
|
|
<div id="delete-modal" class="modal-overlay"> |
|
<div class="modal-content"> |
|
<p>This will only remove the item from the visualization.<br>It <strong>will not</strong> be deleted from your computer. Do you want to continue?</p> |
|
<button id="modal-confirm">Yes, Remove</button> |
|
<button id="modal-cancel">Cancel</button> |
|
</div> |
|
</div> |
|
|
|
|
|
|
|
<script src="https://d3js.org/d3.v7.min.js"></script> |
|
|
|
|
|
<script> |
|
document.addEventListener('DOMContentLoaded', () => { |
|
const folderPicker = document.getElementById('folder-picker'); |
|
const treemapContainer = document.getElementById('treemap-container'); |
|
const tooltip = document.getElementById('tooltip'); |
|
const contextMenu = document.getElementById('context-menu'); |
|
const deleteModal = document.getElementById('delete-modal'); |
|
|
|
let currentFileTree = null; |
|
let nodeToDelete = null; |
|
|
|
|
|
|
|
|
|
function handleFileSelect(event) { |
|
const files = event.target.files; |
|
if (files.length === 0) { |
|
treemapContainer.innerHTML = '<p style="padding: 2rem;">No files selected or folder is empty.</p>'; |
|
return; |
|
} |
|
currentFileTree = buildFileTree(files); |
|
renderTreemap(currentFileTree); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
function buildFileTree(files) { |
|
const root = { name: "root", path: "", children: [] }; |
|
for (const file of files) { |
|
if (file.size === 0) continue; |
|
const pathParts = file.webkitRelativePath.split('/'); |
|
let currentNode = root; |
|
let currentPath = ''; |
|
|
|
for (let i = 0; i < pathParts.length; i++) { |
|
const part = pathParts[i]; |
|
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part; |
|
|
|
if (i === pathParts.length - 1) { |
|
currentNode.children.push({ name: part, value: file.size, path: currentPath }); |
|
} else { |
|
let dirNode = currentNode.children.find(child => child.name === part && child.children); |
|
if (!dirNode) { |
|
dirNode = { name: part, children: [], path: currentPath }; |
|
currentNode.children.push(dirNode); |
|
} |
|
currentNode = dirNode; |
|
} |
|
} |
|
} |
|
return root; |
|
} |
|
|
|
|
|
|
|
|
|
function renderTreemap(data) { |
|
treemapContainer.innerHTML = ''; |
|
const width = treemapContainer.clientWidth; |
|
const height = treemapContainer.clientHeight; |
|
|
|
const root = d3.hierarchy(data).sum(d => d.value).sort((a, b) => b.value - a.value); |
|
|
|
const treemapLayout = d3.treemap() |
|
.size([width, height]) |
|
.paddingInner(1) |
|
.paddingOuter(3) |
|
.paddingTop(20) |
|
.tile(d3.treemapSquarify); |
|
|
|
treemapLayout(root); |
|
const color = d3.scaleOrdinal(d3.schemeCategory10); |
|
|
|
const node = d3.select('#treemap-container') |
|
.selectAll('div') |
|
.data(root.descendants()) |
|
.join('div') |
|
.attr('class', d => `node-group ${d.children ? 'internal' : 'leaf'}`) |
|
.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`); |
|
|
|
|
|
const leaves = node.filter(d => !d.children); |
|
|
|
leaves.style('background-color', d => { |
|
let ancestor = d; |
|
while (ancestor.depth > 1) { ancestor = ancestor.parent; } |
|
return color(ancestor.data.name); |
|
}) |
|
.on('contextmenu', (event, d) => { |
|
event.preventDefault(); |
|
event.stopPropagation(); |
|
nodeToDelete = d; |
|
contextMenu.style.display = 'block'; |
|
contextMenu.style.left = `${event.clientX}px`; |
|
contextMenu.style.top = `${event.clientY}px`; |
|
}); |
|
|
|
|
|
leaves.append('div') |
|
.attr('class', 'leaf-label') |
|
.text(d => d.data.name); |
|
|
|
|
|
leaves.on('mouseenter', (event, d) => { |
|
tooltip.style.opacity = 1; |
|
tooltip.innerHTML = ` |
|
<strong>File:</strong> ${d.data.name}<br> |
|
<strong>Path:</strong> ${d.data.path}<br> |
|
<strong>Size:</strong> ${formatBytes(d.value)} |
|
`; |
|
}) |
|
.on('mousemove', (event) => { |
|
tooltip.style.left = `${event.clientX}px`; |
|
tooltip.style.top = `${event.clientY}px`; |
|
}) |
|
.on('mouseleave', () => { |
|
tooltip.style.opacity = 0; |
|
}); |
|
|
|
|
|
const directories = node.filter(d => d.children); |
|
|
|
|
|
directories.append('a') |
|
.attr('class', 'node-label') |
|
.attr('href', d => `file:///${d.data.path ? d.data.path.replace(/\//g, '\\') : ''}`) |
|
.on('click', event => event.preventDefault()) |
|
.text(d => d.data.name); |
|
|
|
|
|
directories.on('mouseenter', (event, d) => { |
|
tooltip.style.opacity = 1; |
|
tooltip.innerHTML = ` |
|
<strong>Directory:</strong> ${d.data.path}<br> |
|
<strong>Total Size:</strong> ${formatBytes(d.value)} |
|
`; |
|
}) |
|
.on('mousemove', (event) => { |
|
tooltip.style.left = `${event.clientX}px`; |
|
tooltip.style.top = `${event.clientY}px`; |
|
}) |
|
.on('mouseleave', () => { |
|
tooltip.style.opacity = 0; |
|
}); |
|
} |
|
|
|
|
|
folderPicker.addEventListener('change', handleFileSelect); |
|
|
|
|
|
window.addEventListener('click', () => { |
|
contextMenu.style.display = 'none'; |
|
}); |
|
|
|
|
|
document.getElementById('menu-copy-path').addEventListener('click', () => { |
|
if (nodeToDelete && navigator.clipboard) { |
|
navigator.clipboard.writeText(nodeToDelete.data.path).catch(err => console.error('Failed to copy path: ', err)); |
|
} |
|
}); |
|
|
|
document.getElementById('menu-delete').addEventListener('click', () => { |
|
if (nodeToDelete) { |
|
deleteModal.style.display = 'flex'; |
|
} |
|
}); |
|
|
|
|
|
document.getElementById('modal-cancel').addEventListener('click', () => { |
|
deleteModal.style.display = 'none'; |
|
nodeToDelete = null; |
|
}); |
|
|
|
document.getElementById('modal-confirm').addEventListener('click', () => { |
|
if (nodeToDelete && nodeToDelete.parent) { |
|
|
|
const children = nodeToDelete.parent.data.children; |
|
const index = children.findIndex(child => child.path === nodeToDelete.data.path); |
|
if (index > -1) { |
|
children.splice(index, 1); |
|
} |
|
renderTreemap(currentFileTree); |
|
} |
|
deleteModal.style.display = 'none'; |
|
nodeToDelete = null; |
|
}); |
|
|
|
|
|
window.addEventListener('resize', () => { |
|
if (currentFileTree) { |
|
renderTreemap(currentFileTree); |
|
} |
|
}); |
|
|
|
|
|
|
|
|
|
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]}`; |
|
} |
|
}); |
|
</script> |
|
|
|
</body> |
|
</html> |
|
|