円錐形の3Dツリービュー


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Cone Tree Visualizer</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #000; font-family: 'Segoe UI', sans-serif; }
        #ui-layer { position: absolute; top: 20px; left: 20px; z-index: 10; pointer-events: none; }
        button {
            pointer-events: auto;
            background: rgba(0, 255, 255, 0.1);
            border: 1px solid #00ffff;
            color: #00ffff;
            padding: 10px 20px;
            font-size: 14px;
            cursor: pointer;
            text-transform: uppercase;
            letter-spacing: 2px;
            transition: all 0.3s;
            box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
        }
        button:hover { background: rgba(0, 255, 255, 0.3); box-shadow: 0 0 20px rgba(0, 255, 255, 0.6); }
        #loading { display: none; color: #00ffff; margin-top: 10px; text-shadow: 0 0 5px #00ffff; }
        .instructions { color: rgba(255,255,255,0.5); font-size: 12px; margin-top: 10px; line-height: 1.5; }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>
    <div id="ui-layer">
        <button id="selectFolderBtn">Select Folder</button>
        <div id="loading">Scanning Directory...</div>
        <div class="instructions">
            DRAG UP/DOWN: Move In/Out<br>
            DRAG LEFT/RIGHT: Rotate Cone<br>
            CLICK: Focus Node
        </div>
    </div>

    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import TWEEN from 'three/addons/libs/tween.module.js';

        // --- 設定 ---
        const CONFIG = {
            layerHeight: 300,    // 階層間の奥行き距離
            baseRadius: 100,      // 階層ごとの広がりの係数 (円錐の広がり具合)
            nodeColor: 0x00ffff,
            lineColor: 0x0088ff,
            bloomStrength: 1.5,
            bloomRadius: 0.4,
            bloomThreshold: 0,
            textScale: 30
        };

        // --- グローバル変数 ---
        let scene, camera, renderer, composer;
        let rootNodeVisuals = new THREE.Group();
        let nodes = []; // Raycasting用
        let raycaster = new THREE.Raycaster();
        let mouse = new THREE.Vector2();
        
        // ナビゲーション状態
        let cameraZ = 200;
        let coneRotation = 0;
        let isDragging = false;
        let previousMousePosition = { x: 0, y: 0 };
        let targetCameraZ = 200;
        let targetRotation = 0;

        init();
        animate();

        function init() {
            // シーン設定
            scene = new THREE.Scene();
            scene.fog = new THREE.FogExp2(0x000000, 0.0008); // 奥を少し暗くする

            // カメラ
            camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 10000);
            camera.position.set(0, 0, cameraZ);
            camera.lookAt(0, 0, -1000);

            // レンダラー
            renderer = new THREE.WebGLRenderer({ antialias: false }); // Bloom使うときはAntialiasオフ推奨
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(window.devicePixelRatio);
            document.body.appendChild(renderer.domElement);

            // ポストプロセス (Bloom効果 - 光る表現)
            const renderScene = new RenderPass(scene, camera);
            const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
            bloomPass.threshold = CONFIG.bloomThreshold;
            bloomPass.strength = CONFIG.bloomStrength;
            bloomPass.radius = CONFIG.bloomRadius;

            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            scene.add(rootNodeVisuals);

            // イベントリスナー
            window.addEventListener('resize', onWindowResize);
            document.getElementById('selectFolderBtn').addEventListener('click', handleFolderSelect);
            
            // マウス操作
            document.addEventListener('mousedown', onMouseDown);
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
            document.addEventListener('click', onMouseClick);
        }

        // --- フォルダ読み込み処理 ---
        async function handleFolderSelect() {
            try {
                const dirHandle = await window.showDirectoryPicker();
                document.getElementById('loading').style.display = 'block';
                
                // 既存のツリーを削除
                while(rootNodeVisuals.children.length > 0){ 
                    rootNodeVisuals.remove(rootNodeVisuals.children[0]); 
                }
                nodes = [];
                targetCameraZ = 200;
                targetRotation = 0;
                camera.position.z = 200;
                rootNodeVisuals.rotation.z = 0;

                // ディレクトリ構造を解析
                const treeData = await scanDirectory(dirHandle);
                
                // レイアウト計算と3Dオブジェクト生成
                buildConeTree(treeData);

                document.getElementById('loading').style.display = 'none';
            } catch (err) {
                console.error(err);
                document.getElementById('loading').innerText = "Canceled or Error";
            }
        }

        async function scanDirectory(handle, depth = 0) {
            const node = {
                name: handle.name,
                kind: handle.kind,
                children: [],
                depth: depth
            };

            // 深さ制限(ブラウザクラッシュ防止)
            if (depth > 5) return node; 

            if (handle.kind === 'directory') {
                for await (const entry of handle.values()) {
                    // フォルダのみを可視化する場合はここを調整
                    node.children.push(await scanDirectory(entry, depth + 1));
                }
            }
            return node;
        }

        // --- レイアウトと可視化 ---
        
        // ツリーの各ノードに必要な「重み(子孫の数)」を計算して角度割り当てに使う
        function calculateSubtreeWeight(node) {
            if (!node.children || node.children.length === 0) {
                node.weight = 1;
            } else {
                node.weight = node.children.reduce((sum, child) => sum + calculateSubtreeWeight(child), 0);
            }
            return node.weight;
        }

        function buildConeTree(rootData) {
            calculateSubtreeWeight(rootData);
            
            // ルートノード配置
            createNodeVisual(rootData, 0, 0, 0, 0); // x, y, z, angle
            
            // 再帰的に子ノードを配置
            // 円錐の頂点(0,0,0)から、Zマイナス方向へ広がる
            layoutChildren(rootData, 0, 0, Math.PI * 2, 0);
        }

        function layoutChildren(parentNode, parentX, parentY, angleRange, startAngle) {
            if (!parentNode.children || parentNode.children.length === 0) return;

            const currentDepth = parentNode.depth + 1;
            const z = -currentDepth * CONFIG.layerHeight; // 奥へ
            const radius = currentDepth * CONFIG.baseRadius; // 円錐状に広がる

            let currentStartAngle = startAngle;

            parentNode.children.forEach(child => {
                // 子孫の数に応じて角度の領分を決める(重なり防止)
                const childAngleRange = (child.weight / parentNode.weight) * angleRange;
                const midAngle = currentStartAngle + (childAngleRange / 2);

                const x = Math.cos(midAngle) * radius;
                const y = Math.sin(midAngle) * radius;

                // ノード作成
                const childVisual = createNodeVisual(child, x, y, z, midAngle);
                
                // 親子を結ぶライン作成
                createConnection(parentX, parentY, -(currentDepth -1) * CONFIG.layerHeight, x, y, z);

                // 再帰
                layoutChildren(child, x, y, childAngleRange, currentStartAngle);

                currentStartAngle += childAngleRange;
            });
        }

        function createNodeVisual(node, x, y, z, angle) {
            // スプライト(テキストラベル)
            const canvas = document.createElement('canvas');
            const ctx = canvas.getContext('2d');
            const fontSize = 40;
            ctx.font = `Bold ${fontSize}px Arial`;
            const textWidth = ctx.measureText(node.name).width;
            canvas.width = textWidth + 20;
            canvas.height = fontSize + 20;
            
            // 光る文字
            ctx.shadowColor = "#00ffff";
            ctx.shadowBlur = 10;
            ctx.fillStyle = node.kind === 'directory' ? "#ffffff" : "#aaaaaa";
            ctx.font = `Bold ${fontSize}px Arial`;
            ctx.fillText(node.name, 10, fontSize);

            const texture = new THREE.CanvasTexture(canvas);
            const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
            const sprite = new THREE.Sprite(material);
            
            sprite.position.set(x, y, z);
            sprite.scale.set(canvas.width / 2, canvas.height / 2, 1);
            
            // データを持たせる
            sprite.userData = { 
                node: node, 
                targetZ: -z + 200, // このノードを見るときのカメラZ位置
                angle: angle 
            };
            
            rootNodeVisuals.add(sprite);
            nodes.push(sprite);
            return sprite;
        }

        function createConnection(x1, y1, z1, x2, y2, z2) {
            const geometry = new THREE.BufferGeometry().setFromPoints([
                new THREE.Vector3(x1, y1, z1),
                new THREE.Vector3(x2, y2, z2)
            ]);
            const material = new THREE.LineBasicMaterial({ 
                color: CONFIG.lineColor, 
                transparent: true, 
                opacity: 0.6 
            });
            const line = new THREE.Line(geometry, material);
            rootNodeVisuals.add(line);
        }

        // --- 操作・インタラクション ---

        function onMouseDown(event) {
            isDragging = true;
            previousMousePosition = { x: event.clientX, y: event.clientY };
        }

        function onMouseUp() { isDragging = false; }

        function onMouseMove(event) {
            // マウス位置更新(Raycaster用)
            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

            if (isDragging) {
                const deltaX = event.clientX - previousMousePosition.x;
                const deltaY = event.clientY - previousMousePosition.y;

                // 上下ドラッグ:視点を奥/手前へ (Z移動)
                // 上(マイナス)へドラッグ -> 奥へ進む -> カメラのZを減らす
                targetCameraZ += deltaY * 2; 

                // 左右ドラッグ:円錐を回転
                targetRotation += deltaX * 0.005;

                previousMousePosition = { x: event.clientX, y: event.clientY };
            }
        }

        function onMouseClick(event) {
            if(isDragging) return; // ドラッグ終わりならクリック扱いしない

            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(nodes);

            if (intersects.length > 0) {
                const target = intersects[0].object;
                focusNode(target);
            }
        }

        function focusNode(sprite) {
            // クリックしたフォルダを正面(上)に持ってくる
            // 現在の回転角度を考慮して、ターゲットの角度が -PI/2 (画面上部) になるように回す
            // Three.jsの座標系ではY軸上がPI/2、右が0。
            // 視覚的に「上」に来るように回転角度を調整
            
            const nodeAngle = sprite.userData.angle;
            // 目標: rotation.z + nodeAngle = -Math.PI / 2 (画面上部)
            const targetRot = -nodeAngle - (Math.PI / 2); 
            
            // 最短回転方向を計算
            let currentRot = rootNodeVisuals.rotation.z;
            // 角度の正規化などはTweenでスムーズに動くので簡易的に設定
            
            new TWEEN.Tween(rootNodeVisuals.rotation)
                .to({ z: targetRot }, 1000)
                .easing(TWEEN.Easing.Exponential.Out)
                .start();

            // カメラの深さを合わせる
            // targetCameraZ = sprite.userData.targetZ; // その階層へ飛ぶ場合
            targetRotation = targetRot; // 手動操作のターゲットも更新
        }

        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
        }

        function animate() {
            requestAnimationFrame(animate);

            TWEEN.update();

            // カメラのスムーズな移動
            camera.position.z += (targetCameraZ - camera.position.z) * 0.1;
            
            // 回転のスムーズな移動(クリックアニメーション中は上書きしないように制御してもよいが、今回は共存させる)
            if (!TWEEN.getAll().length) {
                rootNodeVisuals.rotation.z += (targetRotation - rootNodeVisuals.rotation.z) * 0.1;
            } else {
                // Tween中はtargetRotationを同期させてドラッグ再開時にガクつかないようにする
                targetRotation = rootNodeVisuals.rotation.z;
            }

            // フォグの濃度調整(奥に行くと手前が消える演出など入れたければここ)
            
            composer.render();
        }
    </script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
円錐形の3Dツリービュー|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1