awacke1's picture
Update index.html
33bd719 verified
raw
history blame
17.3 kB
<!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>
/* General Styling */
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;
}
/* Input Button Styling */
.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; /* Hide the default file input */
}
/* Treemap Container */
#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; /* Ensures nodes don't spill out */
}
/* Styling for node groups (both leaves and internal directories) */
.node-group {
position: absolute;
box-sizing: border-box;
overflow: hidden;
}
/* Styling for internal nodes (directories) to give them a frame */
.internal {
border: 1px solid #aaa;
}
/* Styling for leaf nodes (files) */
.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; /* Use flexbox for alignment of label */
align-items: flex-start; /* Align text to top */
justify-content: flex-start; /* Align text to left */
}
.leaf:hover {
filter: brightness(1.15);
z-index: 10;
}
/* Directory labels (now as links) */
.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);
}
/* File labels */
.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; /* Smaller font */
white-space: normal; /* Allow wrapping */
word-break: break-word; /* Break long words */
pointer-events: none; /* Make sure label doesn't block mouse events on parent */
}
/* Tooltip for Hover-overs */
#tooltip {
position: fixed; /* Use fixed to position relative to viewport */
background-color: rgba(0, 0, 0, 0.85);
color: white;
padding: 8px 12px;
border-radius: 4px;
pointer-events: none; /* Allows mouse events to pass through to elements below */
opacity: 0;
transition: opacity 0.2s;
font-size: 14px;
z-index: 1001;
transform: translate(15px, 10px); /* Offset from cursor */
}
/* Custom Context Menu */
#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 for confirmation */
.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>
<!-- Custom Context Menu Structure -->
<div id="context-menu">
<button id="menu-copy-path">Copy Path</button>
<button id="menu-delete">Delete from View</button>
</div>
<!-- Modal Structure -->
<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>
<!-- D3.js Library for data visualization -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<!-- JS App Logic -->
<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; // To store the current data structure for modification
let nodeToDelete = null; // To store the node targeted for deletion
/**
* Processes the selected files and initiates the treemap rendering.
*/
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);
}
/**
* Builds a hierarchical tree structure from a flat FileList.
* This version adds a 'path' property to directories as well.
*/
function buildFileTree(files) {
const root = { name: "root", path: "", children: [] }; // FIX: Added path property to root
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];
// Reconstruct path at each level
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (i === pathParts.length - 1) { // It's a file
currentNode.children.push({ name: part, value: file.size, path: currentPath });
} else { // It's a directory
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;
}
/**
* Renders the squarified treemap using D3.js.
*/
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`);
// --- Handle Leaves (Files) ---
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(); // Stop event from bubbling to parent context menus
nodeToDelete = d;
contextMenu.style.display = 'block';
contextMenu.style.left = `${event.clientX}px`;
contextMenu.style.top = `${event.clientY}px`;
});
// Add labels to the leaf nodes
leaves.append('div')
.attr('class', 'leaf-label')
.text(d => d.data.name);
// Attach hover listeners to leaves
leaves.on('mouseenter', (event, d) => {
tooltip.style.opacity = 1; // FIX: Use direct style property
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`; // FIX: Use direct style property
tooltip.style.top = `${event.clientY}px`; // FIX: Use direct style property
})
.on('mouseleave', () => {
tooltip.style.opacity = 0; // FIX: Use direct style property
});
// --- Handle Directories ---
const directories = node.filter(d => d.children);
// Add labels to parent nodes (directories) as clickable links
directories.append('a')
.attr('class', 'node-label')
.attr('href', d => `file:///${d.data.path ? d.data.path.replace(/\//g, '\\') : ''}`) // FIX: Check if path exists
.on('click', event => event.preventDefault()) // Prevent default left-click behavior
.text(d => d.data.name);
// Attach hover listeners to directories
directories.on('mouseenter', (event, d) => {
tooltip.style.opacity = 1; // FIX: Use direct style property
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`; // FIX: Use direct style property
tooltip.style.top = `${event.clientY}px`; // FIX: Use direct style property
})
.on('mouseleave', () => {
tooltip.style.opacity = 0; // FIX: Use direct style property
});
}
// --- Event Listeners ---
folderPicker.addEventListener('change', handleFileSelect);
// Hide context menu on any click
window.addEventListener('click', () => {
contextMenu.style.display = 'none';
});
// Context menu actions
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';
}
});
// Modal actions
document.getElementById('modal-cancel').addEventListener('click', () => {
deleteModal.style.display = 'none';
nodeToDelete = null;
});
document.getElementById('modal-confirm').addEventListener('click', () => {
if (nodeToDelete && nodeToDelete.parent) {
// Find and remove the node from its parent's children array in the data
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); // Re-render with the modified data
}
deleteModal.style.display = 'none';
nodeToDelete = null;
});
// Resize handler
window.addEventListener('resize', () => {
if (currentFileTree) {
renderTreemap(currentFileTree);
}
});
/**
* Formats bytes into a human-readable string (KB, MB, GB).
*/
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>