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


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Neon Cone Tree v3</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #000205; 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, 100, 100, 0.2);
            border: 1px solid #00aaaa;
            color: #00ffff;
            padding: 12px 24px;
            font-size: 14px;
            font-weight: bold;
            cursor: pointer;
            text-transform: uppercase;
            letter-spacing: 2px;
            backdrop-filter: blur(4px);
            transition: all 0.3s;
        }
        button:hover { background: rgba(0, 255, 255, 0.3); border-color: #00ffff; box-shadow: 0 0 20px rgba(0, 255, 255, 0.5); }
        #loading { display: none; color: #00ffff; margin-top: 15px; font-weight: bold; text-shadow: 0 0 5px #00ffff; }
        .instructions { 
            color: rgba(200,220,255,0.7); 
            font-size: 13px; 
            margin-top: 15px; 
            line-height: 1.6; 
            background: rgba(0,0,0,0.8); 
            padding: 15px; 
            border-radius: 4px; 
            border-left: 3px solid #00aaaa;
        }
    </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 Root Folder</button>
        <div id="loading">Calculating Topology...</div>
        <div class="instructions">
            <b>NAVIGATION:</b><br>
            DRAG UP/DOWN: Glide Forward/Backward<br>
            DRAG LEFT/RIGHT: Rotate Cone<br>
            CLICK: <b>Center Folder</b>
        </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';

        // --- 設定 (Configuration) ---
        const CONFIG = {
            layerDepth: 400,        // 階層間の距離 (Z)
            baseRadius: 150,        // 最小半径
            
            // レイアウト設定
            nodeVisualWidth: 80,    // フォルダの見た目の幅
            spacingMultiplier: 2.2, // 隣との間隔係数 (2.2 = フォルダ2個分以上のスペースを確保)
            
            // 色設定(Bloom制御用)
            // 明るい色(RGBが1.0に近い)ほど光り、暗い色は光らない
            colorLine: 0x00ffff,      // 線:最高輝度のシアン(強く光る)
            colorBoxBg: 'rgba(0, 10, 20, 0.85)', // 箱背景:暗い(光らない)
            colorBoxBorder: '#004455', // 箱枠:暗めの青緑(ほんのり光る程度)
            colorText: '#aaaaaa',      // 文字:グレー(あまり光らせない、読みやすさ重視)

            // Bloom設定
            bloomStrength: 2.5,     // 発光強度
            bloomThreshold: 0.6,    // 閾値(これより明るい色だけ光る -> 線だけ光るように調整)
            bloomRadius: 0.8,

            // カメラ
            camHoverHeight: 180,
            camFollowDist: 400,
            camLookAhead: 600
        };

        // --- グローバル変数 ---
        let scene, camera, renderer, composer;
        let worldContainer = new THREE.Group(); 
        let nodes = []; 
        let raycaster = new THREE.Raycaster();
        let mouse = new THREE.Vector2();
        
        // レイアウト計算後の円錐パラメータ
        let calculatedSlope = 0.5; // 動的に変わる
        
        // ナビゲーション
        let focusPointZ = 0; 
        let targetFocusZ = 0;
        let currentRotation = 0;
        let targetRotation = 0;
        let isDragging = false;
        let lastMouse = { x: 0, y: 0 };

        init();
        animate();

        function init() {
            scene = new THREE.Scene();
            scene.fog = new THREE.FogExp2(0x000205, 0.0005);

            camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 30000);
            
            renderer = new THREE.WebGLRenderer({ antialias: false }); // Bloom使うならfalse推奨
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
            // 色空間の設定(発光を綺麗に見せるため)
            // renderer.toneMapping = THREE.ReinhardToneMapping;
            document.body.appendChild(renderer.domElement);

            // --- Bloom Effect ---
            const renderScene = new RenderPass(scene, camera);
            const bloomPass = new UnrealBloomPass(
                new THREE.Vector2(window.innerWidth, window.innerHeight), 
                CONFIG.bloomStrength, 
                CONFIG.bloomRadius, 
                CONFIG.bloomThreshold
            );
            
            composer = new EffectComposer(renderer);
            composer.addPass(renderScene);
            composer.addPass(bloomPass);

            // 背景演出
            const gridHelper = new THREE.GridHelper(40000, 400, 0x112233, 0x050510);
            gridHelper.position.y = -1200;
            scene.add(gridHelper);

            scene.add(worldContainer);

            // イベント
            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(worldContainer.children.length > 0){ 
                    worldContainer.remove(worldContainer.children[0]); 
                }
                nodes = [];
                focusPointZ = 0;
                targetFocusZ = 0;
                targetRotation = 0;
                currentRotation = 0;
                worldContainer.rotation.z = 0;

                // 1. データスキャン
                const treeRoot = await scanDirectory(dirHandle);

                // 2. 円錐サイズの自動計算(重なり防止の肝)
                adjustConeSize(treeRoot);

                // 3. 配置計算
                calculateLayout(treeRoot);

                // 4. 描画
                renderTree(treeRoot);

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

        async function scanDirectory(handle, depth = 0, parent = null) {
            const node = {
                name: handle.name,
                kind: handle.kind,
                children: [],
                depth: depth,
                parent: parent,
                // レイアウト用プレースホルダ
                requiredArc: 0,
                angle: 0,
                x: 0, y: 0, z: 0
            };
            
            if (depth > 8) return node; // 深さ制限

            if (handle.kind === 'directory') {
                for await (const entry of handle.values()) {
                    node.children.push(await scanDirectory(entry, depth + 1, node));
                }
            }
            return node;
        }

        // ★重要: 混雑状況に合わせて円錐を太くする処理
        function adjustConeSize(rootNode) {
            // 各階層にノードが何個あるか数える
            const countsPerDepth = {};
            let maxDepth = 0;

            function count(node) {
                if(!countsPerDepth[node.depth]) countsPerDepth[node.depth] = 0;
                countsPerDepth[node.depth]++;
                if(node.depth > maxDepth) maxDepth = node.depth;
                node.children.forEach(count);
            }
            count(rootNode);

            // 最も混んでいる階層を探す
            let maxNodesInLayer = 0;
            let busiestDepth = 0;
            for(const d in countsPerDepth) {
                if(countsPerDepth[d] > maxNodesInLayer) {
                    maxNodesInLayer = countsPerDepth[d];
                    busiestDepth = parseInt(d);
                }
            }

            // 必要な円周長 = (ノード数) * (幅 * スペース係数)
            // これにより「隣と十分空く」ことが保証される
            const requiredCircumference = maxNodesInLayer * (CONFIG.nodeVisualWidth * CONFIG.spacingMultiplier);
            const requiredRadius = requiredCircumference / (2 * Math.PI);

            // その深度(busiestDepth)で requiredRadius になるような slope を計算
            // radius = base + depth * slope
            // slope = (req - base) / depth
            // ※ depth=0の場合はbaseRadiusのみで決まるので除外
            
            if (busiestDepth > 0) {
                let slope = (requiredRadius - CONFIG.baseRadius) / (busiestDepth * CONFIG.layerDepth);
                if (slope < 0.2) slope = 0.2; // 最低限の広がりは維持
                calculatedSlope = slope;
            } else {
                calculatedSlope = 0.4;
            }
            
            console.log(`Auto-adjusted Cone Slope: ${calculatedSlope}, Max Nodes: ${maxNodesInLayer} at depth ${busiestDepth}`);
        }

        function getConeRadius(z) {
            return CONFIG.baseRadius + Math.abs(z) * calculatedSlope;
        }

        function calculateLayout(rootNode) {
            // 1. 重み計算(必要アーク長)
            function calcRequiredArc(node) {
                // 物理幅 = ノード幅 * 係数
                const selfArc = CONFIG.nodeVisualWidth * CONFIG.spacingMultiplier;
                
                if (!node.children || node.children.length === 0) {
                    node.requiredArc = selfArc;
                } else {
                    let childrenSum = 0;
                    node.children.forEach(child => {
                        calcRequiredArc(child);
                        childrenSum += child.requiredArc;
                    });
                    // 自分自身を表示する幅と、子孫を表示する幅、大きい方を取る
                    node.requiredArc = Math.max(selfArc, childrenSum);
                }
            }
            calcRequiredArc(rootNode);

            // 2. 座標割り当て
            function assignPositions(node, startAngle, angleRange) {
                node.z = -node.depth * CONFIG.layerDepth;
                const radius = getConeRadius(node.z);

                const myAngle = startAngle + (angleRange / 2);
                node.angle = myAngle;
                
                // 極座標 -> 直交座標
                node.x = Math.cos(myAngle) * radius;
                node.y = Math.sin(myAngle) * radius;

                if (!node.children || node.children.length === 0) return;

                // 子ノードへ角度を分配
                const totalChildrenArc = node.children.reduce((sum, c) => sum + c.requiredArc, 0);
                let currentStart = startAngle;

                node.children.forEach(child => {
                    // 親の角度範囲を、子の必要アーク長の比率で分配
                    const ratio = child.requiredArc / totalChildrenArc;
                    const childAngleRange = angleRange * ratio;
                    assignPositions(child, currentStart, childAngleRange);
                    currentStart += childAngleRange;
                });
            }

            // ルート配置 (2PI全周を使う)
            rootNode.z = 0;
            rootNode.x = 0;
            rootNode.y = 0;
            rootNode.angle = -Math.PI/2;

            if(rootNode.children.length > 0) {
                const totalArc = rootNode.children.reduce((sum,c)=>sum+c.requiredArc, 0);
                let currentStart = 0;
                rootNode.children.forEach(child => {
                    const ratio = child.requiredArc / totalArc;
                    const range = Math.PI * 2 * ratio;
                    assignPositions(child, currentStart, range);
                    currentStart += range;
                });
            }
        }

        // --- 描画 (Visuals) ---

        function renderTree(node) {
            createNodeVisual(node);
            if(node.children) node.children.forEach(renderTree);
        }

        function createNodeVisual(node) {
            // --- Canvas Texture (フォルダ描画) ---
            const w = CONFIG.nodeVisualWidth;
            const h = 40; // 高さは固定
            
            const canvas = document.createElement('canvas');
            const scale = 2; // 画質
            canvas.width = w * scale;
            canvas.height = h * scale;
            const ctx = canvas.getContext('2d');

            // 1. 背景 (暗くする -> 光らない)
            ctx.fillStyle = CONFIG.colorBoxBg; 
            ctx.fillRect(0,0,canvas.width, canvas.height);
            
            // 2. 枠線 (暗めの色 -> ほんのり光るか光らないか)
            ctx.strokeStyle = CONFIG.colorBoxBorder; 
            ctx.lineWidth = 4;
            ctx.strokeRect(0,0,canvas.width, canvas.height);

            // 3. テキスト (グレー -> 眩しくならない)
            const fontSize = 14 * scale;
            ctx.font = `bold ${fontSize}px "Segoe UI", Arial`;
            ctx.fillStyle = CONFIG.colorText;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            
            // 文字幅調整
            const textMetrics = ctx.measureText(node.name);
            const maxWidth = canvas.width * 0.9;
            ctx.save();
            ctx.translate(canvas.width/2, canvas.height/2);
            if (textMetrics.width > maxWidth) ctx.scale(maxWidth / textMetrics.width, 1);
            ctx.fillText(node.name, 0, 0);
            ctx.restore();

            const texture = new THREE.CanvasTexture(canvas);
            texture.minFilter = THREE.LinearFilter;
            
            const material = new THREE.SpriteMaterial({ 
                map: texture, 
                transparent: true,
                depthWrite: false
            });
            const sprite = new THREE.Sprite(material);
            sprite.position.set(node.x, node.y, node.z);
            sprite.scale.set(w, h, 1);
            
            sprite.userData = { node: node };
            worldContainer.add(sprite);
            nodes.push(sprite);

            // --- Line Connection (線描画) ---
            if (node.parent) {
                createConnection(node.parent, node);
            }
        }

        function createConnection(pNode, cNode) {
            const geometry = new THREE.BufferGeometry().setFromPoints([
                new THREE.Vector3(pNode.x, pNode.y, pNode.z),
                new THREE.Vector3(cNode.x, cNode.y, cNode.z)
            ]);
            
            // ★重要: 線だけ高輝度カラーにする
            // BloomのThreshold(0.6)を超えさせるため、明るい色を指定
            const material = new THREE.LineBasicMaterial({ 
                color: CONFIG.colorLine, // 0x00ffff (Cyan)
                transparent: true, 
                opacity: 0.9 
            });
            
            const line = new THREE.Line(geometry, material);
            worldContainer.add(line);
        }

        // --- Camera & Interaction ---

        function updateCamera() {
            // スクロールに合わせたカメラ位置(表面滑空)
            const camZ = focusPointZ + CONFIG.camFollowDist;
            const lookZ = focusPointZ - CONFIG.camLookAhead;
            const camRadius = getConeRadius(camZ);
            const lookRadius = getConeRadius(lookZ);

            camera.position.set(0, camRadius + CONFIG.camHoverHeight, camZ);
            camera.lookAt(0, lookRadius, lookZ);
        }

        function onMouseDown(e) {
            isDragging = true;
            lastMouse.x = e.clientX;
            lastMouse.y = e.clientY;
        }
        function onMouseUp() { isDragging = false; }
        function onMouseMove(e) {
            mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;

            if (isDragging) {
                const dx = e.clientX - lastMouse.x;
                const dy = e.clientY - lastMouse.y;
                
                targetFocusZ += dy * 3.0; 
                if(targetFocusZ > 0) targetFocusZ = 0;

                targetRotation += dx * 0.005;

                lastMouse.x = e.clientX;
                lastMouse.y = e.clientY;
            }
        }

        function onMouseClick(e) {
            if (isDragging) return;
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(nodes);
            if (intersects.length > 0) focusNode(intersects[0].object);
        }

        function focusNode(sprite) {
            const node = sprite.userData.node;
            const targetZ = node.z + CONFIG.camLookAhead; 
            const targetRot = (Math.PI / 2) - node.angle;

            new TWEEN.Tween({ z: targetFocusZ, rot: targetRotation })
                .to({ z: targetZ, rot: targetRot }, 1000)
                .easing(TWEEN.Easing.Cubic.Out)
                .onUpdate((obj) => {
                    targetFocusZ = obj.z;
                    targetRotation = obj.rot;
                })
                .start();
        }

        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();

            focusPointZ += (targetFocusZ - focusPointZ) * 0.1;
            currentRotation += (targetRotation - currentRotation) * 0.1;

            worldContainer.rotation.z = currentRotation;
            updateCamera();

            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