Spaces:
Running
Running
| <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> | |