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 Explorer</title> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
font-family: 'Arial', sans-serif; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
min-height: 100vh; | |
padding: 20px; | |
} | |
.container { | |
max-width: 1400px; | |
margin: 0 auto; | |
background: white; | |
border-radius: 15px; | |
box-shadow: 0 20px 40px rgba(0,0,0,0.1); | |
overflow: hidden; | |
} | |
.header { | |
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); | |
color: white; | |
padding: 30px; | |
text-align: center; | |
} | |
.header h1 { | |
font-size: 2.5em; | |
margin-bottom: 10px; | |
text-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
} | |
.header p { | |
opacity: 0.9; | |
font-size: 1.1em; | |
} | |
.controls { | |
padding: 30px; | |
background: #f8f9fa; | |
border-bottom: 1px solid #e9ecef; | |
} | |
.file-input-wrapper { | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
gap: 15px; | |
} | |
.file-input { | |
display: none; | |
} | |
.file-input-button { | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
color: white; | |
padding: 15px 30px; | |
border: none; | |
border-radius: 50px; | |
font-size: 1.1em; | |
cursor: pointer; | |
transition: transform 0.2s, box-shadow 0.2s; | |
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); | |
position: relative; | |
overflow: hidden; | |
} | |
.file-input-button:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); | |
} | |
.demo-button { | |
background: linear-gradient(135deg, #00b894 0%, #00a085 100%); | |
margin-left: 15px; | |
} | |
.button-group { | |
display: flex; | |
flex-wrap: wrap; | |
gap: 15px; | |
justify-content: center; | |
} | |
.stats { | |
display: flex; | |
justify-content: center; | |
gap: 30px; | |
margin-top: 20px; | |
flex-wrap: wrap; | |
} | |
.stat-item { | |
background: white; | |
padding: 15px 25px; | |
border-radius: 10px; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
text-align: center; | |
} | |
.stat-number { | |
font-size: 1.5em; | |
font-weight: bold; | |
color: #667eea; | |
} | |
.stat-label { | |
color: #666; | |
font-size: 0.9em; | |
} | |
.visualization-area { | |
padding: 30px; | |
min-height: 600px; | |
} | |
.treemap-container { | |
margin-bottom: 40px; | |
border-radius: 10px; | |
overflow: hidden; | |
box-shadow: 0 5px 15px rgba(0,0,0,0.1); | |
} | |
.treemap-header { | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
color: white; | |
padding: 15px 20px; | |
font-weight: bold; | |
font-size: 1.1em; | |
} | |
.treemap { | |
position: relative; | |
background: #f8f9fa; | |
min-height: 400px; | |
border: 1px solid #e9ecef; | |
} | |
.treemap-node { | |
position: absolute; | |
border: 1px solid #fff; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
font-size: 12px; | |
font-weight: 500; | |
color: #333; | |
overflow: hidden; | |
} | |
.treemap-node:hover { | |
border-color: #667eea; | |
border-width: 2px; | |
transform: scale(1.02); | |
z-index: 100; | |
box-shadow: 0 5px 15px rgba(0,0,0,0.3); | |
} | |
.treemap-node.file { | |
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%); | |
color: white; | |
} | |
.treemap-node.folder { | |
background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%); | |
color: white; | |
} | |
.tooltip { | |
position: absolute; | |
background: rgba(0, 0, 0, 0.9); | |
color: white; | |
padding: 10px 15px; | |
border-radius: 5px; | |
font-size: 12px; | |
pointer-events: none; | |
z-index: 1000; | |
opacity: 0; | |
transition: opacity 0.3s; | |
max-width: 250px; | |
line-height: 1.4; | |
} | |
.tooltip.visible { | |
opacity: 1; | |
} | |
.loading { | |
text-align: center; | |
padding: 60px; | |
color: #666; | |
} | |
.loading-spinner { | |
width: 50px; | |
height: 50px; | |
border: 3px solid #f3f3f3; | |
border-top: 3px solid #667eea; | |
border-radius: 50%; | |
animation: spin 1s linear infinite; | |
margin: 0 auto 20px; | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(360deg); } | |
} | |
.breadcrumb { | |
padding: 10px 20px; | |
background: #e9ecef; | |
font-size: 14px; | |
color: #666; | |
} | |
@media (max-width: 768px) { | |
.header h1 { | |
font-size: 1.8em; | |
} | |
.stats { | |
gap: 15px; | |
} | |
.stat-item { | |
padding: 10px 15px; | |
font-size: 0.9em; | |
} | |
.visualization-area { | |
padding: 15px; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="header"> | |
<h1>🗂️ Squarified Treemap Explorer</h1> | |
<p>Visualize hierarchical file structures using advanced treemap algorithms</p> | |
</div> | |
<div class="controls"> | |
<div class="file-input-wrapper"> | |
<input type="file" id="folderInput" class="file-input" webkitdirectory multiple> | |
<div class="button-group"> | |
<button class="file-input-button" onclick="selectFolder()"> | |
📁 Select Folder to Explore | |
</button> | |
<button class="file-input-button demo-button" onclick="generateDemoData()"> | |
🎮 Try Demo Data | |
</button> | |
</div> | |
<div class="stats" id="stats" style="display: none;"> | |
<div class="stat-item"> | |
<div class="stat-number" id="totalFiles">0</div> | |
<div class="stat-label">Files</div> | |
</div> | |
<div class="stat-item"> | |
<div class="stat-number" id="totalFolders">0</div> | |
<div class="stat-label">Folders</div> | |
</div> | |
<div class="stat-item"> | |
<div class="stat-number" id="totalSize">0 MB</div> | |
<div class="stat-label">Total Size</div> | |
</div> | |
<div class="stat-item"> | |
<div class="stat-number" id="maxDepth">0</div> | |
<div class="stat-label">Max Depth</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<div class="visualization-area" id="visualizationArea"> | |
<div style="text-align: center; padding: 60px; color: #999;"> | |
<h3>🎯 Ready to Explore</h3> | |
<p>Select a folder above to visualize its structure, or try the demo data to see how it works!</p> | |
</div> | |
</div> | |
</div> | |
<div class="tooltip" id="tooltip"></div> | |
<script> | |
class SquarifiedTreemapExplorer { | |
constructor() { | |
this.tooltip = document.getElementById('tooltip'); | |
this.visualizationArea = document.getElementById('visualizationArea'); | |
this.folderInput = document.getElementById('folderInput'); | |
this.fileData = null; | |
this.setupEventListeners(); | |
} | |
setupEventListeners() { | |
document.addEventListener('mousemove', (e) => { | |
this.tooltip.style.left = e.pageX + 10 + 'px'; | |
this.tooltip.style.top = e.pageY + 10 + 'px'; | |
}); | |
// Listen for file input changes | |
this.folderInput.addEventListener('change', (e) => { | |
if (e.target.files.length > 0) { | |
this.processFiles(e.target.files); | |
} | |
}); | |
} | |
async selectFolder() { | |
try { | |
// Try modern File System Access API first | |
if ('showDirectoryPicker' in window && window.location.protocol === 'https:') { | |
const directoryHandle = await window.showDirectoryPicker(); | |
await this.processDirectory(directoryHandle); | |
} else { | |
// Fall back to file input | |
this.folderInput.click(); | |
} | |
} catch (error) { | |
if (error.name !== 'AbortError') { | |
console.log('File System Access API not available, using fallback'); | |
this.folderInput.click(); | |
} | |
} | |
} | |
async processFiles(files) { | |
this.showLoading(); | |
try { | |
const fileTree = this.buildFileTreeFromFiles(files); | |
this.fileData = fileTree; | |
this.updateStats(fileTree); | |
this.generateTreemaps(fileTree); | |
} catch (error) { | |
console.error('Error processing files:', error); | |
this.showError('Error processing file structure.'); | |
} | |
} | |
buildFileTreeFromFiles(files) { | |
const root = { | |
name: 'Selected Folder', | |
path: '', | |
type: 'directory', | |
size: 0, | |
children: [] | |
}; | |
const pathMap = new Map(); | |
pathMap.set('', root); | |
// Sort files by path to ensure directories are created before their contents | |
const sortedFiles = Array.from(files).sort((a, b) => a.webkitRelativePath.localeCompare(b.webkitRelativePath)); | |
for (const file of sortedFiles) { | |
const pathParts = file.webkitRelativePath.split('/'); | |
let currentPath = ''; | |
// Create directory structure | |
for (let i = 0; i < pathParts.length - 1; i++) { | |
const parentPath = currentPath; | |
currentPath = currentPath ? `${currentPath}/${pathParts[i]}` : pathParts[i]; | |
if (!pathMap.has(currentPath)) { | |
const dirNode = { | |
name: pathParts[i], | |
path: currentPath, | |
type: 'directory', | |
size: 0, | |
children: [] | |
}; | |
pathMap.set(currentPath, dirNode); | |
pathMap.get(parentPath).children.push(dirNode); | |
} | |
} | |
// Add file | |
const fileName = pathParts[pathParts.length - 1]; | |
const filePath = file.webkitRelativePath; | |
const parentPath = pathParts.slice(0, -1).join('/'); | |
const fileNode = { | |
name: fileName, | |
path: filePath, | |
type: 'file', | |
size: file.size, | |
lastModified: file.lastModified | |
}; | |
pathMap.get(parentPath).children.push(fileNode); | |
} | |
// Calculate directory sizes and sort children | |
this.calculateDirectorySizes(root); | |
this.sortChildrenBySize(root); | |
return root; | |
} | |
calculateDirectorySizes(node) { | |
if (node.type === 'file') { | |
return node.size; | |
} | |
let totalSize = 0; | |
for (const child of node.children || []) { | |
totalSize += this.calculateDirectorySizes(child); | |
} | |
node.size = totalSize; | |
return totalSize; | |
} | |
sortChildrenBySize(node) { | |
if (node.children) { | |
node.children.sort((a, b) => b.size - a.size); | |
node.children.forEach(child => this.sortChildrenBySize(child)); | |
} | |
} | |
generateDemoData() { | |
const demoData = { | |
name: 'Demo Project', | |
path: '', | |
type: 'directory', | |
size: 45320000, | |
children: [ | |
{ | |
name: 'src', | |
path: 'src', | |
type: 'directory', | |
size: 28500000, | |
children: [ | |
{ name: 'main.js', path: 'src/main.js', type: 'file', size: 15200000 }, | |
{ name: 'utils.js', path: 'src/utils.js', type: 'file', size: 8500000 }, | |
{ name: 'config.js', path: 'src/config.js', type: 'file', size: 3200000 }, | |
{ name: 'helpers.js', path: 'src/helpers.js', type: 'file', size: 1600000 } | |
] | |
}, | |
{ | |
name: 'assets', | |
path: 'assets', | |
type: 'directory', | |
size: 12400000, | |
children: [ | |
{ name: 'logo.png', path: 'assets/logo.png', type: 'file', size: 5600000 }, | |
{ name: 'background.jpg', path: 'assets/background.jpg', type: 'file', size: 4200000 }, | |
{ name: 'icons.svg', path: 'assets/icons.svg', type: 'file', size: 2600000 } | |
] | |
}, | |
{ | |
name: 'docs', | |
path: 'docs', | |
type: 'directory', | |
size: 2800000, | |
children: [ | |
{ name: 'README.md', path: 'docs/README.md', type: 'file', size: 1200000 }, | |
{ name: 'API.md', path: 'docs/API.md', type: 'file', size: 900000 }, | |
{ name: 'CHANGELOG.md', path: 'docs/CHANGELOG.md', type: 'file', size: 700000 } | |
] | |
}, | |
{ | |
name: 'tests', | |
path: 'tests', | |
type: 'directory', | |
size: 1320000, | |
children: [ | |
{ name: 'main.test.js', path: 'tests/main.test.js', type: 'file', size: 680000 }, | |
{ name: 'utils.test.js', path: 'tests/utils.test.js', type: 'file', size: 440000 }, | |
{ name: 'config.test.js', path: 'tests/config.test.js', type: 'file', size: 200000 } | |
] | |
}, | |
{ name: 'package.json', path: 'package.json', type: 'file', size: 180000 }, | |
{ name: 'webpack.config.js', path: 'webpack.config.js', type: 'file', size: 120000 } | |
] | |
}; | |
this.fileData = demoData; | |
this.updateStats(demoData); | |
this.generateTreemaps(demoData); | |
} | |
async processDirectory(directoryHandle) { | |
this.showLoading(); | |
try { | |
const fileTree = await this.buildFileTree(directoryHandle); | |
this.fileData = fileTree; | |
this.updateStats(fileTree); | |
this.generateTreemaps(fileTree); | |
} catch (error) { | |
console.error('Error processing directory:', error); | |
this.showError('Error processing directory structure.'); | |
} | |
} | |
async buildFileTree(directoryHandle, path = '') { | |
const node = { | |
name: directoryHandle.name || 'Root', | |
path: path, | |
type: 'directory', | |
size: 0, | |
children: [] | |
}; | |
for await (const [name, handle] of directoryHandle.entries()) { | |
try { | |
const childPath = path ? `${path}/${name}` : name; | |
if (handle.kind === 'file') { | |
const file = await handle.getFile(); | |
node.children.push({ | |
name: name, | |
path: childPath, | |
type: 'file', | |
size: file.size, | |
lastModified: file.lastModified | |
}); | |
node.size += file.size; | |
} else if (handle.kind === 'directory') { | |
const subDir = await this.buildFileTree(handle, childPath); | |
node.children.push(subDir); | |
node.size += subDir.size; | |
} | |
} catch (error) { | |
console.warn(`Skipping ${name}:`, error); | |
} | |
} | |
// Sort children by size (descending) for better treemap layout | |
node.children.sort((a, b) => b.size - a.size); | |
return node; | |
} | |
updateStats(fileTree) { | |
const stats = this.calculateStats(fileTree); | |
document.getElementById('totalFiles').textContent = stats.files.toLocaleString(); | |
document.getElementById('totalFolders').textContent = stats.folders.toLocaleString(); | |
document.getElementById('totalSize').textContent = this.formatFileSize(stats.size); | |
document.getElementById('maxDepth').textContent = stats.depth; | |
document.getElementById('stats').style.display = 'flex'; | |
} | |
calculateStats(node, depth = 0) { | |
let stats = { | |
files: node.type === 'file' ? 1 : 0, | |
folders: node.type === 'directory' ? 1 : 0, | |
size: node.size || 0, | |
depth: depth | |
}; | |
if (node.children) { | |
for (const child of node.children) { | |
const childStats = this.calculateStats(child, depth + 1); | |
stats.files += childStats.files; | |
stats.folders += childStats.folders; | |
stats.size += childStats.size; | |
stats.depth = Math.max(stats.depth, childStats.depth); | |
} | |
} | |
return stats; | |
} | |
generateTreemaps(fileTree) { | |
this.visualizationArea.innerHTML = ''; | |
// Create main treemap | |
this.createTreemapContainer(fileTree, 'Root Directory', 0); | |
// Create treemaps for major subdirectories | |
if (fileTree.children) { | |
const majorFolders = fileTree.children | |
.filter(child => child.type === 'directory' && child.children && child.children.length > 0) | |
.slice(0, 5); // Show top 5 subdirectories | |
majorFolders.forEach((folder, index) => { | |
this.createTreemapContainer(folder, folder.name, index + 1); | |
}); | |
} | |
} | |
createTreemapContainer(data, title, level) { | |
const container = document.createElement('div'); | |
container.className = 'treemap-container'; | |
const header = document.createElement('div'); | |
header.className = 'treemap-header'; | |
header.textContent = `${title} (${this.formatFileSize(data.size)})`; | |
const breadcrumb = document.createElement('div'); | |
breadcrumb.className = 'breadcrumb'; | |
breadcrumb.textContent = data.path || '/'; | |
const treemap = document.createElement('div'); | |
treemap.className = 'treemap'; | |
treemap.style.height = level === 0 ? '500px' : '400px'; | |
container.appendChild(header); | |
container.appendChild(breadcrumb); | |
container.appendChild(treemap); | |
this.visualizationArea.appendChild(container); | |
// Generate squarified treemap layout | |
this.renderSquarifiedTreemap(treemap, data); | |
} | |
renderSquarifiedTreemap(container, data) { | |
if (!data.children || data.children.length === 0) return; | |
const rect = container.getBoundingClientRect(); | |
const width = rect.width || 800; | |
const height = rect.height || 400; | |
const totalSize = data.size; | |
const children = data.children.filter(child => child.size > 0); | |
if (children.length === 0) return; | |
// Scale areas to fit container | |
const scaledChildren = children.map(child => ({ | |
...child, | |
scaledSize: (child.size / totalSize) * (width * height) | |
})); | |
const layout = this.squarify(scaledChildren, [], width, { x: 0, y: 0, width, height }); | |
this.renderLayout(container, layout); | |
} | |
squarify(children, row, w, container) { | |
if (children.length === 0) { | |
if (row.length > 0) { | |
return this.layoutRow(row, container); | |
} | |
return []; | |
} | |
const c = children[0]; | |
const newRow = [...row, c]; | |
if (row.length === 0 || this.worst(newRow, w) <= this.worst(row, w)) { | |
return this.squarify(children.slice(1), newRow, w, container); | |
} else { | |
const rowLayout = this.layoutRow(row, container); | |
const remaining = this.shrinkContainer(container, row, w); | |
const restLayout = this.squarify(children, [], this.getShortSide(remaining), remaining); | |
return [...rowLayout, ...restLayout]; | |
} | |
} | |
worst(row, w) { | |
if (row.length === 0) return Infinity; | |
const areas = row.map(r => r.scaledSize); | |
const sum = areas.reduce((a, b) => a + b, 0); | |
const max = Math.max(...areas); | |
const min = Math.min(...areas); | |
const term1 = (w * w * max) / (sum * sum); | |
const term2 = (sum * sum) / (w * w * min); | |
return Math.max(term1, term2); | |
} | |
layoutRow(row, container) { | |
if (row.length === 0) return []; | |
const sum = row.reduce((acc, r) => acc + r.scaledSize, 0); | |
const isVertical = container.width >= container.height; | |
let layouts = []; | |
let offset = 0; | |
for (const item of row) { | |
let rect; | |
if (isVertical) { | |
const height = container.height; | |
const width = (item.scaledSize / sum) * (sum / height); | |
rect = { | |
x: container.x + offset, | |
y: container.y, | |
width: width, | |
height: height, | |
data: item | |
}; | |
offset += width; | |
} else { | |
const width = container.width; | |
const height = (item.scaledSize / sum) * (sum / width); | |
rect = { | |
x: container.x, | |
y: container.y + offset, | |
width: width, | |
height: height, | |
data: item | |
}; | |
offset += height; | |
} | |
layouts.push(rect); | |
} | |
return layouts; | |
} | |
shrinkContainer(container, row, w) { | |
const sum = row.reduce((acc, r) => acc + r.scaledSize, 0); | |
const isVertical = container.width >= container.height; | |
if (isVertical) { | |
const usedWidth = sum / container.height; | |
return { | |
x: container.x + usedWidth, | |
y: container.y, | |
width: container.width - usedWidth, | |
height: container.height | |
}; | |
} else { | |
const usedHeight = sum / container.width; | |
return { | |
x: container.x, | |
y: container.y + usedHeight, | |
width: container.width, | |
height: container.height - usedHeight | |
}; | |
} | |
} | |
getShortSide(container) { | |
return Math.min(container.width, container.height); | |
} | |
renderLayout(container, layout) { | |
container.innerHTML = ''; | |
layout.forEach(rect => { | |
const element = document.createElement('div'); | |
element.className = `treemap-node ${rect.data.type}`; | |
element.style.left = `${rect.x}px`; | |
element.style.top = `${rect.y}px`; | |
element.style.width = `${rect.width}px`; | |
element.style.height = `${rect.height}px`; | |
// Show name only if rectangle is large enough | |
if (rect.width > 60 && rect.height > 20) { | |
element.textContent = rect.data.name; | |
} | |
this.addTooltip(element, rect.data); | |
container.appendChild(element); | |
}); | |
} | |
addTooltip(element, data) { | |
element.addEventListener('mouseenter', () => { | |
const tooltipContent = this.createTooltipContent(data); | |
this.tooltip.innerHTML = tooltipContent; | |
this.tooltip.classList.add('visible'); | |
}); | |
element.addEventListener('mouseleave', () => { | |
this.tooltip.classList.remove('visible'); | |
}); | |
} | |
createTooltipContent(data) { | |
let content = `<strong>${data.name}</strong><br>`; | |
content += `Type: ${data.type}<br>`; | |
content += `Size: ${this.formatFileSize(data.size)}<br>`; | |
content += `Path: ${data.path}<br>`; | |
if (data.type === 'file' && data.lastModified) { | |
content += `Modified: ${new Date(data.lastModified).toLocaleDateString()}<br>`; | |
} | |
if (data.children) { | |
content += `Items: ${data.children.length}`; | |
} | |
return content; | |
} | |
formatFileSize(bytes) { | |
if (bytes === 0) return '0 B'; | |
const k = 1024; | |
const sizes = ['B', '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]; | |
} | |
showLoading() { | |
this.visualizationArea.innerHTML = ` | |
<div class="loading"> | |
<div class="loading-spinner"></div> | |
<h3>📊 Processing Directory Structure</h3> | |
<p>Analyzing files and building treemap visualization...</p> | |
</div> | |
`; | |
} | |
showError(message) { | |
this.visualizationArea.innerHTML = ` | |
<div style="text-align: center; padding: 60px; color: #e74c3c;"> | |
<h3>❌ Error</h3> | |
<p>${message}</p> | |
</div> | |
`; | |
} | |
} | |
// Initialize the application | |
const app = new SquarifiedTreemapExplorer(); | |
// Global functions for button clicks | |
function selectFolder() { | |
app.selectFolder(); | |
} | |
function generateDemoData() { | |
app.generateDemoData(); | |
} | |
</script> | |
</body> | |
</html> |