円錐形のフォルダビューワver2


画像
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Cyber Stream Navigation 1.1</title>
    <style>
        body { margin: 0; overflow: hidden; background-color: #050500; 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(50, 40, 0, 0.6);
            border: 1px solid #ffcc00;
            color: #ffcc00;
            padding: 12px 24px;
            font-size: 14px;
            font-weight: bold;
            cursor: pointer;
            text-transform: uppercase;
            letter-spacing: 2px;
            box-shadow: 0 0 15px rgba(255, 200, 0, 0.3);
            backdrop-filter: blur(4px);
            transition: all 0.2s;
        }
        button:hover { background: rgba(255, 200, 0, 0.2); box-shadow: 0 0 25px rgba(255, 200, 0, 0.8); }
        #loading { display: none; color: #ffaa00; margin-top: 15px; font-weight: bold; text-shadow: 0 0 10px orange; }
        .instructions { 
            color: rgba(255, 240, 200, 0.8); 
            font-size: 13px; 
            margin-top: 15px; 
            line-height: 1.6; 
            background: rgba(20, 15, 0, 0.8); 
            padding: 15px; 
            border-radius: 4px; 
            border-left: 3px solid #ffcc00;
        }
        .key { color: #fff; border: 1px solid #555; padding: 2px 4px; border-radius: 3px; background: #222; }
    </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">Initialize System</button>
        <div id="loading">System Scanning...</div>
        <div class="instructions">
            <b>KEYBOARD NAV:</b><br>
            <span class="key">↑</span> / <span class="key">PgUp</span> : Dive to Target<br>
            <span class="key">↓</span> / <span class="key">PgDn</span> : Ascend Parent<br>
            <span class="key">←</span> <span class="key">→</span> : Switch Target (Rotate)<br>
            <span class="key">Home</span> : Return to Root<br>
            <br>
            <b>MOUSE:</b><br>
            CLICK: Select / Expand<br>
            DRAG: Move & Rotate
        </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: 450,
            baseRadius: 120,
            nodeVisualWidth: 80,    
            spacingMultiplier: 2.8,
            
            // 色設定
            colorMain: 0xffcc00,
            colorDim: 0x443300,
            colorTarget: 0xff3300, 
            
            // レーザー設定
            laserThickness: 3.0,
            laserColorDefault: 0x664400,
            laserColorActive: 0xffaa00,
            
            // Bloom設定
            bloomStrength: 2.5,     
            bloomRadius: 0.6,
            bloomThreshold: 0.15,    

            // カメラ & ポップアップ設定
            camHoverHeight: 250,
            camFollowDist: 550,
            camLookAhead: 750,
            
            // ポップアップ強調
            scaleSelected: 2.5,  // 選択中の拡大率
            liftSelected: 60,    // 選択中の浮き上がり量
            scaleTarget: 2.0,    // ターゲットの拡大率
            liftTarget: 30       // ターゲットの浮き上がり量
        };

        // --- グローバル変数 ---
        let scene, camera, renderer, composer;
        let worldContainer = new THREE.Group(); 
        let cursorMesh;     
        let targetMesh;     
        
        let fullTreeData = null;
        let visibleNodes = [];   
        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 selectedNodeData = null; 
        let targetChildIndex = 0;    

        // ドラッグ制御
        let isDragging = false;
        let dragAxis = null;
        let lastMouse = { x: 0, y: 0 };

        // パーティクル
        let particles = [];
        let arrowTexture;

        init();
        animate();

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

            camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 1, 40000);
            
            renderer = new THREE.WebGLRenderer({ antialias: false });
            renderer.setSize(window.innerWidth, window.innerHeight);
            renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
            document.body.appendChild(renderer.domElement);

            // Bloom
            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(50000, 400, 0x332200, 0x110d00);
            gridHelper.position.y = -1500;
            scene.add(gridHelper);

            scene.add(worldContainer);
            
            createCursors();
            createArrowTexture();

            // イベント
            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);
            document.addEventListener('dblclick', onMouseDoubleClick);
            document.addEventListener('keydown', onKeyDown);
        }

        // --- アセット生成 ---
        function createCursors() {
            const geo1 = new THREE.TorusGeometry(60, 2, 16, 100);
            const mat1 = new THREE.MeshBasicMaterial({ color: CONFIG.colorMain, transparent: true, opacity: 0.9 });
            cursorMesh = new THREE.Mesh(geo1, mat1);
            cursorMesh.visible = false;
            scene.add(cursorMesh);

            const geo2 = new THREE.TorusGeometry(30, 2, 8, 50); 
            const mat2 = new THREE.MeshBasicMaterial({ color: CONFIG.colorTarget, transparent: true, opacity: 0.8 });
            targetMesh = new THREE.Mesh(geo2, mat2);
            targetMesh.visible = false;
            scene.add(targetMesh); 
        }

        function createArrowTexture() {
            const canvas = document.createElement('canvas');
            canvas.width = 64;
            canvas.height = 64;
            const ctx = canvas.getContext('2d');
            ctx.fillStyle = '#ffaa00';
            ctx.beginPath();
            ctx.moveTo(32, 10);
            ctx.lineTo(54, 54);
            ctx.lineTo(10, 54);
            ctx.closePath();
            ctx.fill();
            arrowTexture = new THREE.CanvasTexture(canvas);
        }

        // --- パーティクルシステム ---
        function spawnArrow(startPos, endPos) {
            const material = new THREE.SpriteMaterial({ 
                map: arrowTexture, 
                color: 0xffffff, 
                transparent: true, 
                opacity: 1.0,
                depthTest: false 
            });
            const sprite = new THREE.Sprite(material);
            sprite.scale.set(15, 15, 1);
            
            const p = {
                mesh: sprite,
                start: startPos.clone(),
                end: endPos.clone(),
                progress: 0,
                speed: 0.03 + Math.random() * 0.01 
            };
            
            sprite.position.copy(startPos);
            scene.add(sprite); 
            particles.push(p);
        }

        function updateParticles() {
            if (selectedNodeData && selectedNodeData.children && selectedNodeData.children.length > 0) {
                const targetNode = selectedNodeData.children[targetChildIndex];
                if (selectedNodeData.isExpanded && targetNode && targetNode.visualObj && selectedNodeData.visualObj) {
                    const startPos = new THREE.Vector3();
                    const endPos = new THREE.Vector3();
                    // updateVisualsで位置が変わっているので、現在のワールド座標を取得
                    selectedNodeData.visualObj.getWorldPosition(startPos);
                    targetNode.visualObj.getWorldPosition(endPos);

                    if (Math.random() < 0.25) spawnArrow(startPos, endPos);
                }
            }

            for (let i = particles.length - 1; i >= 0; i--) {
                const p = particles[i];
                p.progress += p.speed;
                if (p.progress >= 1.0) {
                    scene.remove(p.mesh);
                    particles.splice(i, 1);
                } else {
                    p.mesh.position.lerpVectors(p.start, p.end, p.progress);
                    if (p.progress > 0.8) p.mesh.material.opacity = (1.0 - p.progress) * 5;
                }
            }
        }

        // --- キーボード操作ロジック ---
        function onKeyDown(e) {
            if (!selectedNodeData) return;

            switch(e.key) {
                case 'ArrowUp':
                case 'PageUp':
                    e.preventDefault();
                    navigateDownToChild();
                    break;
                case 'ArrowDown':
                case 'PageDown':
                    e.preventDefault();
                    navigateUpToParent();
                    break;
                case 'ArrowLeft':
                    e.preventDefault();
                    changeTarget(1); // 左キーで右回り(次へ)
                    break;
                case 'ArrowRight':
                    e.preventDefault();
                    changeTarget(-1); // 右キーで左回り(戻る)
                    break;
                case 'Home':
                    e.preventDefault();
                    navigateHome();
                    break;
            }
        }

        function navigateHome() {
            if (fullTreeData) {
                selectNode(fullTreeData);
                targetChildIndex = 0;
            }
        }

        function navigateDownToChild() {
            if (!selectedNodeData.children || selectedNodeData.children.length === 0) return;
            if (!selectedNodeData.isExpanded) {
                selectedNodeData.isExpanded = true;
                refreshTree();
                // 展開直後は移動しない(ユーザーに状況を見せるため)
                return;
            }
            const nextNode = selectedNodeData.children[targetChildIndex];
            if (nextNode) {
                selectNode(nextNode);
                targetChildIndex = 0; 
            }
        }

        function navigateUpToParent() {
            if (selectedNodeData.parent) {
                const current = selectedNodeData;
                const parent = selectedNodeData.parent;
                const myIndex = parent.children.indexOf(current);
                selectNode(parent);
                targetChildIndex = Math.max(0, myIndex); 
            }
        }

        function changeTarget(dir) {
            if (!selectedNodeData.children || selectedNodeData.children.length === 0) return;
            const len = selectedNodeData.children.length;
            targetChildIndex = (targetChildIndex + dir + len) % len;
            
            // ★重要: ターゲットを変えたら、そのターゲットが見えるように回転を更新する
            updateRotationToFaceTarget();
        }

        function updateRotationToFaceTarget() {
            // 現在の選択ノードの、ターゲット子ノードを取得
            if (selectedNodeData && selectedNodeData.children && selectedNodeData.children.length > 0) {
                const targetNode = selectedNodeData.children[targetChildIndex];
                // 親の角度ではなく、ターゲット子ノードの角度を正面(PI/2)に持ってくる
                if (targetNode) {
                    const targetRot = (Math.PI / 2) - targetNode.angle;
                    
                    // 回転アニメーション
                    new TWEEN.Tween({ rot: targetRotation })
                        .to({ rot: targetRot }, 500) // 素早く移動
                        .easing(TWEEN.Easing.Cubic.Out)
                        .onUpdate((obj) => { targetRotation = obj.rot; })
                        .start();
                }
            }
        }

        function selectNode(node) {
            selectedNodeData = node;
            
            // 親選択時は展開状態を維持しつつ、必要なら展開
            if(node.children && node.children.length > 0 && !node.isExpanded) {
                node.isExpanded = true; 
                refreshTree();
            } else {
                updateHighlights(); 
            }
            
            // カメラと回転を更新
            focusNode(node);
        }

        // --- フォルダ処理 ---
        async function handleFolderSelect() {
            try {
                const dirHandle = await window.showDirectoryPicker();
                document.getElementById('loading').style.display = 'block';
                fullTreeData = await scanDirectory(dirHandle);
                fullTreeData.isExpanded = true; 
                selectNode(fullTreeData);
                refreshTree();
                document.getElementById('loading').style.display = 'none';
            } catch (err) {
                console.error(err);
                document.getElementById('loading').innerText = "Aborted";
            }
        }

        async function scanDirectory(handle, depth = 0, parent = null) {
            const node = {
                name: handle.name,
                kind: handle.kind,
                children: [],
                depth: depth,
                parent: parent,
                isExpanded: false, 
                requiredArc: 0,
                angle: 0, x: 0, y: 0, z: 0,
                visualObj: null,
                visualText: null,
                visualLaser: null,
                // アニメーション用
                currentScale: 1.0,
                currentLift: 0.0
            };
            if (depth > 15) 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 refreshTree() {
            while(worldContainer.children.length > 0){ 
                worldContainer.remove(worldContainer.children[0]); 
            }
            visibleNodes = []; 
            adjustConeSize(fullTreeData);
            calculateLayout(fullTreeData);
            renderTree(fullTreeData);
            updateHighlights();
        }

        function adjustConeSize(rootNode) {
            const countsPerDepth = {};
            function countVisible(node) {
                if(!countsPerDepth[node.depth]) countsPerDepth[node.depth] = 0;
                countsPerDepth[node.depth]++;
                if(node.isExpanded && node.children) {
                    node.children.forEach(countVisible);
                }
            }
            countVisible(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);

            if (busiestDepth > 0) {
                let slope = (requiredRadius - CONFIG.baseRadius) / (busiestDepth * CONFIG.layerDepth);
                if (slope < 0.25) slope = 0.25;
                calculatedSlope = slope;
            } else {
                calculatedSlope = 0.6;
            }
        }

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

        function calculateLayout(node) {
            function calcRequiredArc(n) {
                const selfArc = CONFIG.nodeVisualWidth * CONFIG.spacingMultiplier;
                if (!n.isExpanded || !n.children || n.children.length === 0) {
                    n.requiredArc = selfArc;
                } else {
                    let childrenSum = 0;
                    n.children.forEach(child => {
                        calcRequiredArc(child);
                        childrenSum += child.requiredArc;
                    });
                    n.requiredArc = Math.max(selfArc, childrenSum);
                }
            }
            calcRequiredArc(node);

            function assignPositions(n, startAngle, angleRange) {
                n.z = -n.depth * CONFIG.layerDepth;
                const radius = getConeRadius(n.z);
                const myAngle = startAngle + (angleRange / 2);
                n.angle = myAngle;
                n.x = Math.cos(myAngle) * radius;
                n.y = Math.sin(myAngle) * radius;

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

                const totalChildrenArc = n.children.reduce((sum, c) => sum + c.requiredArc, 0);
                let currentStart = startAngle;
                n.children.forEach(child => {
                    const ratio = child.requiredArc / totalChildrenArc;
                    const childAngleRange = angleRange * ratio;
                    assignPositions(child, currentStart, childAngleRange);
                    currentStart += childAngleRange;
                });
            }

            node.z = 0; node.x = 0; node.y = 0; node.angle = -Math.PI/2;
            if(node.isExpanded && node.children.length > 0) {
                const totalArc = node.children.reduce((sum,c)=>sum+c.requiredArc, 0);
                let currentStart = 0;
                node.children.forEach(child => {
                    const ratio = child.requiredArc / totalArc;
                    const range = Math.PI * 2 * ratio;
                    assignPositions(child, currentStart, range);
                    currentStart += range;
                });
            }
        }

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

        function createNodeVisual(node) {
            const hasChildren = node.children && node.children.length > 0;
            const group = new THREE.Group();
            
            // 位置はupdateVisualsで動的に制御するため、初期値セット
            group.position.set(node.x, node.y, node.z);
            
            // --- テキスト生成 ---
            const w = CONFIG.nodeVisualWidth;
            const h = 40;
            const scale = 6; 
            
            const canvas = document.createElement('canvas');
            canvas.width = w * scale;
            canvas.height = h * scale;
            const ctx = canvas.getContext('2d');

            const fontSize = 14 * scale;
            ctx.font = `bold ${fontSize}px "Segoe UI", Arial`;
            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.strokeStyle = '#000000';
            ctx.lineWidth = 5 * (scale / 2); 
            ctx.strokeText(node.name, 0, 0);
            
            ctx.fillStyle = '#ffffff';
            ctx.fillText(node.name, 0, 0);
            
            ctx.restore();

            const texture = new THREE.CanvasTexture(canvas);
            texture.minFilter = THREE.LinearFilter;
            texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

            const textMat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false });
            const textSprite = new THREE.Sprite(textMat);
            textSprite.scale.set(w, h, 1);
            
            node.visualText = textSprite;

            let mainMesh;
            if (hasChildren) {
                const boxGeo = new THREE.BoxGeometry(w, w, w);
                const edges = new THREE.EdgesGeometry(boxGeo);
                const lineMat = new THREE.LineBasicMaterial({ color: CONFIG.colorMain });
                const cube = new THREE.LineSegments(edges, lineMat);
                
                textSprite.position.set(0, 0, 0); 
                group.add(cube);
                group.add(textSprite);
                
                group.userData.isCube = true;
                group.userData.cubeMesh = cube;

                const hitBox = new THREE.Mesh(boxGeo, new THREE.MeshBasicMaterial({ visible: false }));
                group.add(hitBox);
                hitBox.userData.node = node;
                visibleNodes.push(hitBox); 
                mainMesh = group;

            } else {
                const plateCanvas = document.createElement('canvas');
                plateCanvas.width = w * 2;
                plateCanvas.height = h * 2;
                const pCtx = plateCanvas.getContext('2d');
                pCtx.fillStyle = 'rgba(20, 15, 0, 0.8)'; 
                pCtx.fillRect(0,0,plateCanvas.width, plateCanvas.height);
                pCtx.strokeStyle = '#aa8800'; 
                pCtx.lineWidth = 4;
                pCtx.strokeRect(0,0,plateCanvas.width, plateCanvas.height);
                
                const plateTex = new THREE.CanvasTexture(plateCanvas);
                const plateMat = new THREE.SpriteMaterial({ map: plateTex, transparent: true });
                const bgSprite = new THREE.Sprite(plateMat);
                bgSprite.scale.set(w, h, 1);
                
                group.add(bgSprite);
                textSprite.position.z = 1;
                group.add(textSprite);

                mainMesh = bgSprite;
                bgSprite.userData.node = node;
                visibleNodes.push(bgSprite);
            }

            worldContainer.add(group);
            node.visualObj = group;

            if (node.parent) {
                createLaserConnection(node.parent, node);
            }
        }

        function createLaserConnection(pNode, cNode) {
            // パスは動的に更新したほうが綺麗だが、チューブジオメトリの再構築は重い。
            // ここでは初期位置でラインを引くが、
            // ノードが浮き上がるとき(ポップアップ)に線が追従しないと不自然になる。
            // なので、Line (BufferGeometry) を使い、毎フレーム更新する方式に変更する。
            
            // 初期バッファ
            const geometry = new THREE.BufferGeometry();
            const positions = new Float32Array(2 * 3); // 2 points * 3 coords
            geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

            const material = new THREE.LineBasicMaterial({
                color: CONFIG.laserColorDefault,
                transparent: true,
                opacity: 0.4,
                linewidth: 2 // WebGLでは効かないことが多いが指定
            });

            // 線を太く光らせたいならLineLoopやTubeだが、動的更新はコスト高。
            // Bloomが強いのでLineでも十分光るはず。
            // もしTubeにするなら、MeshのScaleやRotationではなく、パス自体を更新する必要がある。
            // ここではパフォーマンスと追従性を取ってLineにする。
            
            const line = new THREE.Line(geometry, material);
            worldContainer.add(line);
            cNode.visualLaser = line;
        }

        // --- Visual Loop (アニメーション & ポップアップ) ---
        function updateVisuals() {
            visibleNodes.forEach(obj => {
                const node = obj.userData.node;
                
                // --- 目標スケールとリフト量(浮き上がり)の計算 ---
                let targetScale = 1.0;
                let targetLift = 0.0;

                // 優先順位1: 選択中
                if (node === selectedNodeData) {
                    targetScale = CONFIG.scaleSelected;
                    targetLift = CONFIG.liftSelected;
                } 
                // 優先順位2: ターゲット中の子供
                else if (selectedNodeData && selectedNodeData.isExpanded && selectedNodeData.children) {
                    const targetChild = selectedNodeData.children[targetChildIndex];
                    if (targetChild === node) {
                        targetScale = CONFIG.scaleTarget;
                        targetLift = CONFIG.liftTarget;
                    }
                }

                // 線形補間でスムーズに変化
                node.currentScale += (targetScale - node.currentScale) * 0.1;
                node.currentLift += (targetLift - node.currentLift) * 0.1;

                // スケール適用 (Group全体)
                node.visualObj.scale.set(node.currentScale, node.currentScale, node.currentScale);

                // 位置再計算 (Lift適用)
                // 円錐表面半径 + Lift
                const baseR = getConeRadius(node.z);
                const finalR = baseR + node.currentLift;
                
                const newX = Math.cos(node.angle) * finalR;
                const newY = Math.sin(node.angle) * finalR;
                
                node.visualObj.position.set(newX, newY, node.z);

                // キューブ回転
                if(obj.userData.isCube) {
                    obj.userData.cubeMesh.rotation.x += 0.01;
                    obj.userData.cubeMesh.rotation.y += 0.02;
                }
            });

            // レーザー(ライン)の端点更新
            visibleNodes.forEach(obj => {
                const node = obj.userData.node;
                if (node.parent && node.visualLaser && node.parent.visualObj) {
                    const positions = node.visualLaser.geometry.attributes.position.array;
                    
                    // 親の位置
                    positions[0] = node.parent.visualObj.position.x;
                    positions[1] = node.parent.visualObj.position.y;
                    positions[2] = node.parent.visualObj.position.z;
                    
                    // 子の位置
                    positions[3] = node.visualObj.position.x;
                    positions[4] = node.visualObj.position.y;
                    positions[5] = node.visualObj.position.z;
                    
                    node.visualLaser.geometry.attributes.position.needsUpdate = true;
                }
            });
        }

        // --- ハイライト ---
        function updateHighlights() {
            visibleNodes.forEach(obj => {
                const node = obj.userData.node;
                if(node.visualLaser) {
                    node.visualLaser.material.color.setHex(CONFIG.laserColorDefault);
                    node.visualLaser.material.opacity = 0.3;
                    // 太く見せるためにBloom閾値にかかる色にする
                }
            });

            if (!selectedNodeData) return;

            // パスハイライト
            let cursor = selectedNodeData;
            while(cursor) {
                if (cursor.visualLaser) {
                    cursor.visualLaser.material.color.setHex(CONFIG.laserColorActive);
                    cursor.visualLaser.material.opacity = 1.0; 
                }
                cursor = cursor.parent;
            }
        }

        function updateCursors() {
            if (selectedNodeData && selectedNodeData.visualObj) {
                cursorMesh.visible = true;
                const targetPos = new THREE.Vector3();
                // visualObjはGroupなのでWorldPositionを取る
                selectedNodeData.visualObj.getWorldPosition(targetPos);

                new TWEEN.Tween(cursorMesh.position)
                    .to({ x: targetPos.x, y: targetPos.y, z: targetPos.z }, 200)
                    .easing(TWEEN.Easing.Cubic.Out)
                    .start();
            } else {
                cursorMesh.visible = false;
            }

            if (selectedNodeData && selectedNodeData.children && selectedNodeData.children.length > 0) {
                const targetNode = selectedNodeData.children[targetChildIndex];
                if (targetNode && targetNode.visualObj && selectedNodeData.isExpanded) {
                    targetMesh.visible = true;
                    const tPos = new THREE.Vector3();
                    targetNode.visualObj.getWorldPosition(tPos);

                    new TWEEN.Tween(targetMesh.position)
                        .to({ x: tPos.x, y: tPos.y, z: tPos.z }, 100)
                        .start();
                } else {
                    targetMesh.visible = false;
                }
            } else {
                targetMesh.visible = false;
            }
        }

        // --- マウス操作 ---
        let clickTimeout = null;
        function onMouseClick(e) {
            if (isDragging) return;
            if (clickTimeout !== null) {
                clearTimeout(clickTimeout);
                clickTimeout = null;
                return;
            }
            clickTimeout = setTimeout(() => {
                const node = getIntersectedNode();
                if (node) {
                    // クリックしたノードがターゲット子ならダイブ、そうでなければ選択
                    selectNode(node);
                    // 選択後、親のリスト内での自分のインデックスをターゲットにする
                    if (node.parent) {
                        const idx = node.parent.children.indexOf(node);
                        // 親を選択状態にはしないが、データ整合性のため
                    }
                    targetChildIndex = 0; // リセット
                }
                clickTimeout = null;
            }, 250);
        }

        function onMouseDoubleClick(e) {
            if (clickTimeout !== null) {
                clearTimeout(clickTimeout);
                clickTimeout = null;
            }
            const node = getIntersectedNode();
            if (node) {
                if (node.children && node.children.length > 0) {
                    node.isExpanded = !node.isExpanded;
                    refreshTree();
                }
                selectNode(node);
            }
        }

        function getIntersectedNode() {
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(visibleNodes);
            if (intersects.length > 0) return intersects[0].object.userData.node;
            return null;
        }

        function onMouseDown(e) {
            isDragging = true;
            dragAxis = null; 
            lastMouse.x = e.clientX;
            lastMouse.y = e.clientY;
        }
        function onMouseUp() { setTimeout(() => { isDragging = false; }, 50); }
        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;
                if (!dragAxis) {
                    dragAxis = (Math.abs(dx) > Math.abs(dy)) ? 'x' : 'y';
                }
                if (dragAxis === 'y') {
                    targetFocusZ += dy * 4.0; 
                    if(targetFocusZ > 0) targetFocusZ = 0;
                } else if (dragAxis === 'x') {
                    targetRotation += dx * 0.005;
                }
                lastMouse.x = e.clientX;
                lastMouse.y = e.clientY;
            }
        }

        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 focusNode(node) {
            const targetZ = node.z + CONFIG.camLookAhead; 
            
            // ノードの角度を正面(PI/2)にする
            const targetRot = (Math.PI / 2) - node.angle;

            new TWEEN.Tween({ z: targetFocusZ, rot: targetRotation })
                .to({ z: targetZ, rot: targetRot }, 800)
                .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;
            
            // 毎フレームビジュアル更新(ポップアップ、回転、位置)
            updateVisuals();

            // カーソル常時更新
            updateCursors();
            if (cursorMesh.visible) {
                const s = 1.0 + Math.sin(Date.now() * 0.005) * 0.1;
                cursorMesh.scale.set(s, s, s);
                const worldCameraPos = new THREE.Vector3();
                camera.getWorldPosition(worldCameraPos);
                cursorMesh.lookAt(worldCameraPos);
            }
            if (targetMesh.visible) {
                const worldCameraPos = new THREE.Vector3();
                camera.getWorldPosition(worldCameraPos);
                targetMesh.lookAt(worldCameraPos);
                targetMesh.rotateZ(Date.now() * 0.005);
            }

            updateParticles();
            updateCamera();
            composer.render();
        }
    </script>
</body>
</html>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
円錐形のフォルダビューワver2|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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