...ing logging 4.0

はてなブログに移行しました。D言語の話とかいろいろ。

Nested Folder Treemap by ChatGPT

ChatGPTにフォルダツリーマップを表示するPSスクリプトを書いてもらった。

  • ファイル、フォルダとも、1MB以上の要素だけ表示して、小さすぎるファイルは集計から除く(境界値を10MBにしていたけど期待どおりにならなかったので変更)。
  • フォルダ階層をクリックしてフォルダを潜っていく機能あり。
  • 実行するとフォルダの探索結果をHTMLに全部書き込むので、8階層までしか探索しないようにしてある(5階層だと期待どおりにならなかったので変更)。
  • 再帰処理をやめてループ処理にした(ネストが深すぎて実行時エラーになったので)。
  • 巡回したフォルダを記憶して何度も巡回しないようにした(無限ループしたので)。
  • カレントフォルダのサブサブサブフォルダまで描画する。
  • カレントフォルダのフルパスをテキストボックスで表示。クリックでクリップボードコピー機能付き。
  • 1つ上のフォルダに戻るボタンを追加。ルートフォルダでは無効化してある。
  • 遷移できるフォルダだけ拡大アニメーションと指カーソル表示にする。

自分で書いたら何日かかるか分からないが、こうも簡単にできてしまうとは・・・すごい!

Generate-FolderTreemapHTML.ps1

param(
    [string]$Path = ".",
    [string]$OutputHtml = "nested_treemap.html"
)

function Get-FolderSizeJson {
    param (
        [string]$BasePath,
        [int]$DepthLimit = 8
    )

    $stack = New-Object System.Collections.Stack
    $visited = @{ }

    $root = [PSCustomObject]@{
        name = Split-Path $BasePath -Leaf
        fullPath = (Resolve-Path $BasePath).Path
        type = "folder"
        children = @()
        size = 0
        depth = 0
    }

    $stack.Push($root)

    while ($stack.Count -gt 0) {
        $node = $stack.Pop()

        if (-not $node.fullPath) { continue }
        if ($visited.ContainsKey($node.fullPath)) { continue }
        $visited[$node.fullPath] = $true

        if ($node.depth -ge $DepthLimit) {
            continue
        }

        $children = @()
        $files = Get-ChildItem -Path $node.fullPath -File -ErrorAction SilentlyContinue
        $totalSize = 0
        foreach ($file in $files) {
            if ($file.Length -ge 1MB) {
                $children += [PSCustomObject]@{
                    name = $file.Name
                    size = $file.Length
                    type = "file"
                }
                $totalSize += $file.Length
            } else {
                $totalSize += $file.Length
            }
        }

        $subfolders = Get-ChildItem -Path $node.fullPath -Directory -ErrorAction SilentlyContinue
        foreach ($subfolder in $subfolders) {
            $childNode = [PSCustomObject]@{
                name = $subfolder.Name
                fullPath = $subfolder.FullName
                type = "folder"
                children = @()
                size = 0
                depth = $node.depth + 1
            }
            $children += $childNode
            $stack.Push($childNode)
        }

        $node.children = $children
        $node.size = $totalSize + ($children | Measure-Object -Property size -Sum).Sum
    }

    function FilterSmallNodes([PSCustomObject]$node) {
        if ($node.type -eq "file") {
            return ($node.size -ge 1MB)
        }
        if ($node.children) {
            $node.children = $node.children | Where-Object { FilterSmallNodes $_ }
            $node.size = ($node.children | Measure-Object -Property size -Sum).Sum
        }
        return ($node.size -ge 1MB)
    }

    if (-not (FilterSmallNodes $root)) {
        return $null
    }

    function ConvertTo-JsonCustom($obj) {
        return $obj | ConvertTo-Json -Depth 100 -Compress
    }

    return ConvertTo-JsonCustom $root
}

$json = Get-FolderSizeJson -BasePath $Path

if (-not $json) {
    Write-Warning "指定フォルダに1MB以上のフォルダ/ファイルがありません。"
    exit
}

$utf8Bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$base64 = [Convert]::ToBase64String($utf8Bytes)

$html = @"
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>Nested Folder Treemap</title>
<style>
    body { margin: 0; font-family: sans-serif; }
    h2 { margin: 10px; }
    #breadcrumb {
        margin: 10px;
        font-size: 14px;
        user-select: none;
    }
    #breadcrumb span {
        cursor: pointer;
        color: #337ab7;
        margin-right: 5px;
    }
    #breadcrumb span:hover {
        text-decoration: underline;
    }
    #chart {
        position: relative;
        width: 100vw;
        height: 80vh;
        overflow: auto;
        padding: 10px;
        box-sizing: border-box;
    }
    .node {
        position: absolute;
        border: 2px solid white;
        box-sizing: border-box;
        border-radius: 4px;
        color: white;
        font-size: 12px;
        overflow: hidden;
        transition: transform 0.2s;
        cursor: default;
        display: flex;
        flex-direction: column;
        justify-content: flex-start;
        padding: 4px;
    }
    .node.folder {
        background-color: #f39c12;
    }
    .node.file {
        background-color: #2980b9;
    }
    .node.enlarge:hover {
        transform: scale(1.05);
        z-index: 10;
        cursor: pointer;
    }
    .label {
        font-weight: bold;
        font-size: 11px;
        color: white;
        text-shadow: 0 0 3px rgba(0,0,0,0.7);
        word-break: break-word;
        z-index: 1000;
        position: relative;
        user-select: none;
    }
    .label.folder-label {
        position: absolute;
        top: 4px;
        left: 4px;
        background: rgba(0,0,0,0.3);
        padding: 2px 4px;
        border-radius: 3px;
        user-select: none;
    }
    #backButton {
        margin: 10px 5px 10px 10px;
        padding: 6px 12px;
        font-size: 14px;
        user-select: none;
    }
    #folderPath {
        vertical-align: middle;
        margin-left: 10px;
        font-size: 13px;
        /* 横幅をウィンドウ幅からボタン分を引いた幅に調整 */
        width: calc(100vw - 150px);
        max-width: 80vw;
        user-select: all;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        padding: 6px 8px;
        box-sizing: border-box;
    }
    .node.file {
        cursor: default !important;
    }
    /* コピー完了ツールチップ */
    #copyTooltip {
        position: fixed;
        background: #444;
        color: white;
        padding: 5px 10px;
        border-radius: 4px;
        font-size: 14px;
        pointer-events: none;
        opacity: 0;
        transition: opacity 0.3s ease;
        z-index: 10000;
        user-select: none;
    }
</style>
</head>
<body>
<h2>Nested Folder Treemap for '$Path'</h2>
<div id='breadcrumb'></div>
<button id='backButton'>1つ上の階層へ</button>
<input type='text' id='folderPath' readonly />
<div id='chart'></div>
<div id='copyTooltip'>コピーしました</div>

<script>
const base64 = '$base64';

function base64ToUtf8(str) {
    const bytes = Uint8Array.from(atob(str), c => c.charCodeAt(0));
    return new TextDecoder('utf-8').decode(bytes);
}

const jsonStr = base64ToUtf8(base64);
const data = JSON.parse(jsonStr);

function assignParents(node, parent = null) {
    node._parent = parent;
    if (node.children) {
        if (!Array.isArray(node.children)) {
            node.children = [node.children];
        }
        node.children.forEach(child => assignParents(child, node));
    }
}
assignParents(data);

let currentNode = data;
let backButton = document.getElementById('backButton');
let folderPathInput = document.getElementById('folderPath');
let copyTooltip = document.getElementById('copyTooltip');
let tooltipTimeout = null;
let tooltipFadeInterval = null;

function updateBreadcrumb() {
    const breadcrumb = document.getElementById('breadcrumb');
    breadcrumb.innerHTML = '';
    let path = [];
    let temp = currentNode;
    while (temp) {
        path.unshift(temp);
        temp = temp._parent;
    }
    path.forEach((node, i) => {
        const span = document.createElement('span');
        span.textContent = node.name || 'Root';
        span.addEventListener('click', () => {
            currentNode = node;
            createTreemap(document.getElementById('chart'), currentNode, currentNode.size);
            updateBreadcrumb();
            updateBackButton();
            updateFolderPath();
        });
        breadcrumb.appendChild(span);
        if (i < path.length - 1) breadcrumb.appendChild(document.createTextNode(' > '));
    });
}

function updateBackButton() {
    backButton.disabled = !currentNode._parent;
}

function updateFolderPath() {
    folderPathInput.value = currentNode.fullPath || '';
}

function createTreemap(container, node, totalSize, scale = 1, level = 0) {
    container.innerHTML = '';
    const sorted = node.children?.slice().sort((a, b) => b.size - a.size) || [];
    const containerRect = container.getBoundingClientRect();
    const maxW = containerRect.width;
    const maxH = containerRect.height;
    const area = maxW * maxH * 0.90;
    const ratio = area / totalSize;
    let x = 0, y = 0, rowH = 0;

    sorted.forEach(child => {
        const w = Math.max(80, Math.sqrt((child.size || 1) * ratio * scale));
        const h = w * 0.75;
        if (x + w > maxW) {
            x = 0;
            y += rowH + 10;
            rowH = 0;
        }

        const div = document.createElement('div');
        div.className = 'node ' + (child.type === 'file' ? 'file' : 'folder');
        div.style.width = w + 'px';
        div.style.height = h + 'px';
        div.style.left = x + 'px';
        div.style.top = (y + 20) + 'px';

        // サブフォルダのみenlargeを付与(見た目向上)
        if (child.type === 'folder' && child.children && child.children.length > 0) {
            div.classList.add('enlarge');
            div.style.cursor = 'pointer';
        } else if (child.type === 'folder') {
            div.style.cursor = 'default';
        }

        const label = document.createElement('div');
        label.className = 'label';
        if (child.type === 'folder') {
            label.classList.add('folder-label');
        }
        label.textContent = child.name + ' (' + (child.size / 1024 / 1024).toFixed(1) + ' MB)';
        div.appendChild(label);
        container.appendChild(div);

        // すべての階層でサブツリーマップをネスト表示
        if (child.type === 'folder' && child.children?.length && level < 2) {
            const subContainer = document.createElement('div');
            subContainer.style.position = 'relative';
            subContainer.style.width = '100%';
            subContainer.style.height = '100%';
            subContainer.style.padding = '4px';
            div.appendChild(subContainer);
            setTimeout(() => createTreemap(subContainer, child, child.size, 0.85, level + 1), 0);
        }

        div.addEventListener('click', e => {
            e.stopPropagation();
            if (child.type === 'folder' && child.children?.length) {
                currentNode = child;
                createTreemap(document.getElementById('chart'), currentNode, currentNode.size);
                updateBreadcrumb();
                updateBackButton();
                updateFolderPath();
            }
        });

        x += w + 10;
        if (h > rowH) rowH = h;
    });
}

backButton.addEventListener('click', () => {
    if (currentNode._parent) {
        currentNode = currentNode._parent;
        createTreemap(document.getElementById('chart'), currentNode, currentNode.size);
        updateBreadcrumb();
        updateBackButton();
        updateFolderPath();
    }
});

folderPathInput.addEventListener('click', async (e) => {
    folderPathInput.select();
    try {
        await navigator.clipboard.writeText(folderPathInput.value);
        // コピー完了後も選択状態を維持
        folderPathInput.select();
        showCopyTooltip(e.pageX, e.pageY);
    } catch {
        alert('クリップボードへのコピーに失敗しました。');
    }
});

// コピー完了ツールチップの管理
function showCopyTooltip(x, y) {
    if (tooltipTimeout) clearTimeout(tooltipTimeout);
    if (tooltipFadeInterval) clearInterval(tooltipFadeInterval);

    copyTooltip.style.opacity = 1;
    copyTooltip.style.left = (x + 10) + 'px';  // マウスカーソルの右に10pxずらす
    copyTooltip.style.top = (y + 10) + 'px';   // マウスカーソルの下に10pxずらす
    copyTooltip.style.pointerEvents = 'none';

    let elapsed = 0;
    tooltipFadeInterval = setInterval(() => {
        elapsed += 100;
        let opacity = 1 - (elapsed / 3000);
        if (opacity <= 0) {
            opacity = 0;
            clearInterval(tooltipFadeInterval);
            copyTooltip.style.opacity = 0;
        } else {
            copyTooltip.style.opacity = opacity;
        }
    }, 100);

    tooltipTimeout = setTimeout(() => {
        clearInterval(tooltipFadeInterval);
        copyTooltip.style.opacity = 0;
    }, 3000);
}

updateBreadcrumb();
updateBackButton();
updateFolderPath();
createTreemap(document.getElementById('chart'), currentNode, currentNode.size);
</script>
</body>
</html>
"@

Set-Content -Path $OutputHtml -Value $html -Encoding utf8
Write-Host "Treemap HTML file saved as '$OutputHtml'"
Start-Process $OutputHtml

使い方

パスを指定して実行すると、そのパスをルートとしたツリーマップがHTMLファイルとして出力され、デフォルトブラウザで表示まで行われます。

パスを指定しなければカレントディレクトリがルートになります。

> .\Generate-FolderTreemapHTML.ps1 -Path "C:\Users\haru\Dropbox\

これでファイル整理がはかどる。

ダウンロード

github.com