円錐形のフォルダビューワ「Folder Viewer 3D」


画像
バージョン1.1の画面


画像
バージョン1.2の画面

[ 操作説明 ]

・上キーで下の階層に進む。
・下キーで上の階層に戻る。
・左右のキーで移動先を選ぶ。
・homeキーでルートフォルダに戻る。
・Enterキーで選択中のファイルを開く。
・escキーで閉じる。

・ダウンロードされる方はこちら。↓

・バージョン1.1のソースコードはこちら。↓

<!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>


・バージョン1.2のソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Cyber Stream Explorer 1.2</title>
    <style>
        :root {
            --main: #ffcc00;   /* Amber */
            --accent: #00ffff; /* Cyan */
            --danger: #ff4444; /* Red */
            --bg: #010101;     /* Black */
            --glass: rgba(10, 15, 20, 0.9);
        }
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', sans-serif; user-select: none; color: #fff; }
        
        /* UI Layer */
        #ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
        
        /* Error Log */
        #error-log {
            position: absolute; bottom: 10px; left: 10px; color: #ff0000; font-family: monospace; font-size: 12px; 
            background: rgba(0,0,0,0.8); padding: 5px; display: none; pointer-events: auto; z-index: 9999;
        }

        /* Header */
        #header { position: absolute; top: 20px; left: 20px; pointer-events: auto; display: flex; flex-direction: column; gap: 10px; }
        button.sys-btn {
            background: rgba(50, 40, 0, 0.6); border: 1px solid var(--main); color: var(--main);
            padding: 10px 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.2); backdrop-filter: blur(4px); transition: 0.2s;
        }
        button.sys-btn:hover { background: rgba(255, 200, 0, 0.3); box-shadow: 0 0 25px rgba(255, 200, 0, 0.6); color: #fff; }

        #path-display {
            color: var(--accent); font-family: monospace; font-size: 14px; font-weight: bold;
            text-shadow: 0 0 5px var(--accent); background: rgba(0,0,0,0.8); padding: 8px 12px; border-left: 4px solid var(--accent);
        }

        /* Active File Info (Top Left) */
        #active-info {
            position: absolute; top: 100px; left: 20px; width: 300px;
            background: rgba(5, 10, 20, 0.85); border: 1px solid var(--main);
            padding: 15px; display: none; pointer-events: none;
            box-shadow: 0 0 20px rgba(255, 200, 0, 0.1);
        }
        #active-info h3 { margin: 0 0 10px 0; color: var(--main); font-size: 14px; letter-spacing: 2px; border-bottom: 1px solid #444; padding-bottom: 5px; }
        #active-info p { margin: 3px 0; font-size: 12px; color: #ccc; font-family: monospace; }
        #active-info .val { color: #fff; font-weight: bold; margin-left: 5px; }

        /* Instructions */
        .instructions { 
            position: absolute; top: 20px; right: 20px; text-align: right;
            color: rgba(200, 220, 255, 0.8); font-size: 13px; line-height: 1.6; 
            background: var(--glass); padding: 15px; border-right: 4px solid var(--main);
            pointer-events: auto; box-shadow: 0 0 20px rgba(0,0,0,0.5);
        }
        .key { color: #000; border: 1px solid #fff; padding: 1px 5px; border-radius: 3px; background: #fff; font-family: monospace; font-weight: bold; margin: 0 2px;}

        /* Loader */
        #loading {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.95); z-index: 9000; display: none;
            justify-content: center; align-items: center; flex-direction: column;
            color: var(--main); font-size: 24px; font-weight: bold; letter-spacing: 4px;
        }
        .blink { animation: blink 1s infinite; }
        @keyframes blink { 50% { opacity: 0.3; } }

        /* Stock Dock */
        #stock {
            position: absolute; bottom: 0; left: 0; width: 100%; height: 160px;
            background: linear-gradient(to top, rgba(10,5,0,0.95), transparent);
            border-top: 2px solid var(--main); display: flex; align-items: center; 
            padding: 0 40px; gap: 20px; z-index: 40; transition: bottom 0.3s cubic-bezier(0.23, 1, 0.32, 1); 
            overflow-x: auto; pointer-events: auto;
        }
        #stock.closed { bottom: -160px; }
        
        #stock-tabs {
            position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%);
            display: flex; gap: 4px; z-index: 41; transition: bottom 0.3s cubic-bezier(0.23, 1, 0.32, 1); pointer-events: auto;
        }
        #stock.closed + #stock-tabs { bottom: 0; }

        .tab-btn {
            background: rgba(20, 10, 0, 0.9); color: #888; padding: 6px 20px; 
            font-size: 12px; cursor: pointer; border-top: 2px solid #553300; 
            min-width: 100px; text-align: center; transition: 0.2s; font-weight: bold;
            clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%);
        }
        .tab-btn:hover { color: #fff; background: rgba(50, 20, 0, 0.9); }
        .tab-btn.active { background: var(--main); color: #000; border-top-color: #fff; }

        .stock-item {
            width: 100px; height: 120px; background: rgba(0,0,0,0.5); border: 1px solid var(--main);
            display: flex; flex-direction: column; align-items: center; cursor: grab;
            color: var(--main); flex-shrink: 0; position: relative; transition: 0.2s;
            user-select: none;
        }
        .stock-item:hover { background: rgba(255,170,0,0.2); box-shadow: 0 0 15px rgba(255,170,0,0.4); transform: translateY(-5px); }
        .stock-item:active { cursor: grabbing; }
        .stock-thumb { width: 90px; height: 80px; margin-top: 5px; display: flex; justify-content: center; align-items: center; overflow: hidden; background: #000; }
        .stock-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }
        .stock-name { font-size: 11px; margin-top: 5px; width: 90px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

        /* Viewer Overlay */
        #viewer-overlay {
            display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0, 0, 0, 0.95); z-index: 100;
            justify-content: center; align-items: center; flex-direction: column;
            backdrop-filter: blur(10px); opacity: 0; transition: opacity 0.3s;
            pointer-events: auto;
        }
        #viewer-overlay.active { opacity: 1; }
        #viewer-win {
            width: 90%; height: 85%; border: 2px solid var(--main);
            box-shadow: 0 0 50px rgba(255, 200, 0, 0.2); background: #050505;
            display: flex; flex-direction: column;
        }
        #viewer-head {
            padding: 10px 20px; background: rgba(255, 200, 0, 0.1); border-bottom: 1px solid var(--main);
            color: var(--main); font-weight: bold; display: flex; justify-content: space-between; align-items: center; letter-spacing: 2px;
        }
        #viewer-body { flex: 1; overflow: hidden; position: relative; display:flex; justify-content:center; align-items:center; }
        #viewer-body img, #viewer-body video { max-width: 100%; max-height: 100%; object-fit: contain; }
        .editor { width: 100%; height: 100%; background: #000; color: #0f0; border: none; font-family: monospace; padding: 20px; resize: none; outline: none; font-size: 14px; }
        .win-btn { background: transparent; border: 1px solid var(--main); color: var(--main); padding: 5px 20px; cursor: pointer; margin-left: 10px; transition: 0.2s; font-weight: bold; }
        .win-btn:hover { background: var(--main); color: #000; }

        /* Dialogs */
        #dialog-overlay {
            display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.8); z-index: 500; justify-content: center; align-items: center; pointer-events: auto;
        }
        #dialog-box {
            background: #080808; border: 1px solid var(--accent); padding: 30px; width: 450px;
            box-shadow: 0 0 40px rgba(0, 255, 255, 0.2); text-align: center;
        }
        #dialog-title { color: var(--accent); font-weight: bold; margin-bottom: 20px; display: block; letter-spacing: 2px; font-size: 18px; }
        #dialog-input {
            width: 100%; background: #111; border: 1px solid #333; color: #fff; padding: 12px;
            font-size: 18px; outline: none; margin-bottom: 25px; text-align: center; font-family: monospace;
        }
        #dialog-input:focus { border-color: var(--accent); box-shadow: 0 0 10px rgba(0,255,255,0.3); }

        /* Context Menu */
        .ctx-menu {
            display: none; position: absolute; z-index: 200;
            background: rgba(5, 10, 15, 0.98); border: 1px solid var(--main);
            box-shadow: 0 0 20px rgba(255, 200, 0, 0.3); min-width: 200px;
            pointer-events: auto; transform-origin: top left;
            animation: menuOpen 0.15s cubic-bezier(0.19, 1, 0.22, 1);
        }
        @keyframes menuOpen { from { opacity: 0; transform: scaleY(0.8); } to { opacity: 1; transform: scaleY(1); } }
        .ctx-item {
            padding: 12px 20px; color: #ddd; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.1);
            font-size: 14px; display: flex; justify-content: space-between; transition: 0.1s;
        }
        .ctx-item:last-child { border-bottom: none; }
        .ctx-item:hover { background: var(--main); color: #000; padding-left: 25px; }
        .ctx-key { opacity: 0.6; font-size: 11px; }

        /* Drag Ghost */
        #drag-ghost {
            position: absolute; pointer-events: none; z-index: 9999; 
            padding: 10px 20px; background: rgba(0,0,0,0.8); border: 1px solid var(--main); color: var(--main);
            font-weight: bold; border-radius: 5px; display: none;
        }

    </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">
        <div id="error-log"></div>
        <div id="header">
            <button id="btn-init" class="sys-btn">INITIALIZE SYSTEM</button>
            <div id="path-display">SYSTEM STANDBY</div>
        </div>

        <div id="active-info">
            <h3>SELECTED FILE</h3>
            <p>NAME:<span id="ai-name" class="val">-</span></p>
            <p>TYPE:<span id="ai-type" class="val">-</span></p>
            <p>SIZE:<span id="ai-size" class="val">-</span></p>
            <p>DATE:<span id="ai-date" class="val">-</span></p>
        </div>

        <div class="instructions">
            <b>CONTROLS:</b><br>
            <span class="key">←</span> <span class="key">→</span> : Rotate & Select<br>
            <span class="key">↑</span> / <span class="key">Ent</span> : Dive / Open<br>
            <span class="key">↓</span> : Ascend<br>
            <span class="key">Home</span> : Root<br>
            <span class="key">R-Click</span> : Menu<br>
            <span class="key">Drag</span> : Move to Stock
        </div>

        <div id="stock" class="closed"></div>
        <div id="stock-tabs"></div>

        <div id="dialog-overlay">
            <div id="dialog-box">
                <span id="dialog-title">INPUT</span>
                <input type="text" id="dialog-input" spellcheck="false" autocomplete="off">
                <div>
                    <button id="dialog-ok" class="sys-btn" style="border-color:var(--accent); color:var(--accent); width:120px;">OK</button>
                    <button id="dialog-cancel" class="sys-btn" style="border-color:#ff5555; color:#ff5555; width:120px;">CANCEL</button>
                </div>
            </div>
        </div>
        
        <div id="drag-ghost">MOVING...</div>
    </div>

    <div id="viewer-overlay">
        <div id="viewer-win">
            <div id="viewer-head">
                <span id="viewer-title">FILE</span>
                <div>
                    <button id="viewer-save" class="win-btn" style="display:none;">SAVE</button>
                    <button id="viewer-close" class="win-btn">CLOSE</button>
                </div>
            </div>
            <div id="viewer-body"></div>
        </div>
    </div>

    <div id="ctx-menu" class="ctx-menu">
        <div class="ctx-item" id="cm-open">Open <span class="ctx-key">Ent</span></div>
        <div class="ctx-item" id="cm-copy">Copy <span class="ctx-key">Ctrl+C</span></div>
        <div class="ctx-item" id="cm-paste">Paste <span class="ctx-key">Ctrl+V</span></div>
        <div class="ctx-item" id="cm-rename">Rename <span class="ctx-key">F2</span></div>
        <div class="ctx-item" id="cm-new">New Folder</div>
        <div class="ctx-item" id="cm-stock" style="color:var(--accent);">Add to Stock</div>
        <div class="ctx-item" id="cm-del" style="color:#ff5555;">Delete <span class="ctx-key">Del</span></div>
    </div>

    <div id="ctx-tab" class="ctx-menu">
        <div class="ctx-item" id="cm-tab-hide" style="color:var(--main);">Hide Stock</div>
        <div class="ctx-item" id="cm-tab-cancel" style="color:#ff5555;">Cancel Tab</div>
    </div>

    <div id="loading"><div class="blink">SCANNING DATA STRUCTURE...</div></div>

    <script type="module">
        window.onerror = function(msg, url, line) {
            const log = document.getElementById('error-log');
            log.style.display = 'block';
            log.innerText = `ERR: ${msg}\nL${line}`;
            return false;
        };

        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 = {
            layerDepth: 700,
            coneSlope: 0.7,
            baseRadius: 150,
            
            // Camera
            camHeight: 250,
            camDist: 550,       
            camLookAhead: 750,  

            nodeWidth: 200,
            // Gap adjustment: Node + Gap. 2.5 gives roughly 1 folder width gap
            spacingMult: 2.5, 
            
            colMain: 0xffcc00,
            colFile: 0x00aaff,
            colLine: 0xffaa00,
            
            // Visuals
            laserThickness: 4.0, 
            bloomStr: 2.8, bloomRad: 0.8, bloomThres: 0.1
        };

        const state = {
            rootNode: null, activeNode: null, targetChildIdx: 0,
            visibleNodes: [], stockList: [], activeStockIdx: -1, clipboard: null,
            focusPointZ: 0, targetFocusZ: 0, currentRotation: 0, targetRotation: 0,
            mouse: new THREE.Vector2(), raycaster: new THREE.Raycaster(), 
            ctxTarget: null, tabCtxIdx: -1,
            dragging: null
        };

        // --- 3D Scene ---
        const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x020202, 0.0005);
        const camera = new THREE.PerspectiveCamera(55, window.innerWidth/window.innerHeight, 1, 60000);
        const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: "high-performance" });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
        document.body.appendChild(renderer.domElement);

        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(scene, camera));
        const bloom = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), CONFIG.bloomStr, CONFIG.bloomRad, CONFIG.bloomThres);
        composer.addPass(bloom);

        const world = new THREE.Group(); scene.add(world);
        const grid = new THREE.GridHelper(80000, 400, 0x332200, 0x080500); grid.position.y = -2000; scene.add(grid);

        // Stream Lines
        const streamGroup = new THREE.Group(); scene.add(streamGroup);
        const streamGeo = new THREE.BufferGeometry();
        const streamPos = [];
        for(let i=0; i<300; i++) {
            const x=(Math.random()-0.5)*6000, y=(Math.random()-0.5)*6000, z=(Math.random()-0.5)*12000;
            streamPos.push(x,y,z, x,y,z-1500);
        }
        streamGeo.setAttribute('position', new THREE.Float32BufferAttribute(streamPos, 3));
        const streamMat = new THREE.LineBasicMaterial({ color: 0x00ffff, transparent: true, opacity: 0 }); 
        const streamLines = new THREE.LineSegments(streamGeo, streamMat);
        streamGroup.add(streamLines);

        // --- Cursors (Thick Glow from Ver 1.1) ---
        // Active Selection (Yellow)
        const cursorMesh = new THREE.Mesh(
            new THREE.TorusGeometry(150, 5, 16, 100), 
            new THREE.MeshBasicMaterial({ color: CONFIG.colMain, transparent:true, opacity:1.0 }) 
        );
        scene.add(cursorMesh); cursorMesh.visible = false;

        // Target Selection (Red)
        const targetMesh = new THREE.Mesh(
            new THREE.TorusGeometry(110, 5, 16, 60), 
            new THREE.MeshBasicMaterial({ color: 0xff3300, transparent:true, opacity:1.0 })
        );
        scene.add(targetMesh); targetMesh.visible = false;

        // Info Mesh for Target (HUD like exp_3d)
        const infoMesh = new THREE.Mesh(new THREE.PlaneGeometry(280, 160), new THREE.MeshBasicMaterial({ transparent:true, opacity:0, depthTest:false, side: THREE.DoubleSide }));
        const infoCvs = document.createElement('canvas'); infoCvs.width=512; infoCvs.height=300;
        const infoCtx = infoCvs.getContext('2d'); infoMesh.material.map = new THREE.CanvasTexture(infoCvs);
        infoMesh.renderOrder = 9999; scene.add(infoMesh); infoMesh.visible = false;

        // Arrow Particles
        let arrowTexture;
        function createArrowTexture() {
            const c=document.createElement('canvas'); c.width=64; c.height=64; const x=c.getContext('2d');
            x.fillStyle='#ffaa00'; x.beginPath(); x.moveTo(32,10); x.lineTo(54,54); x.lineTo(10,54); x.closePath(); x.fill();
            arrowTexture = new THREE.CanvasTexture(c);
        }
        createArrowTexture();
        const particles = [];
        function spawnArrow(startPos, endPos) {
            const m = new THREE.SpriteMaterial({ map: arrowTexture, color: 0xffffff, transparent: true, opacity: 1.0, depthTest: false });
            const s = new THREE.Sprite(m); s.scale.set(30, 30, 1);
            s.position.copy(startPos); scene.add(s);
            particles.push({ mesh: s, start: startPos.clone(), end: endPos.clone(), progress: 0, speed: 0.02+Math.random()*0.015 });
        }

        function init() {
            try {
                window.addEventListener('resize', onResize);
                const btn = document.getElementById('btn-init'); if(btn) btn.onclick = selectRoot;
                document.addEventListener('keydown', onKey);
                document.addEventListener('contextmenu', onContextMenu);
                window.addEventListener('click', e => { 
                    if(!e.target.closest('.ctx-menu')) document.querySelectorAll('.ctx-menu').forEach(m => m.style.display='none'); 
                });
                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mousedown', onMouseDown);
                window.addEventListener('mouseup', onMouseUp);
                document.addEventListener('dblclick', onMouseDoubleClick);
                
                // Stock Drag (Drop into 3D)
                const container = document.body;
                container.ondragover = e => e.preventDefault();
                container.ondrop = async e => { 
                    e.preventDefault(); 
                    const idx = e.dataTransfer.getData('stockIndex'); 
                    if(idx) moveFromStock(parseInt(idx)); 
                };

                bindMenu(); bindDialog(); bindViewer();
                animate();
            } catch(e){ console.error(e); }
        }

        async function selectRoot() {
            try {
                const h = await window.showDirectoryPicker();
                showLoad(true);
                state.rootNode = await scanNode(h, null, 0);
                state.activeNode = state.rootNode;
                state.targetChildIdx = 0;
                state.focusPointZ = state.rootNode.z;
                rebuildWorld();
                showLoad(false);
                updatePath();
            } catch(e) { console.error(e); showLoad(false); }
        }

        async function scanNode(handle, parent, depth) {
            const node = {
                handle: handle, name: handle.name, kind: handle.kind,
                parent: parent, depth: depth, children: [],
                angle: 0, z: -depth * CONFIG.layerDepth,
                obj: null, visualLine: null,
                thumbTex: null, 
                sizeStr: '', dateStr: '',
                currentScale: 1.0, currentLift: 0.0
            };
            
            // Get Info (Async)
            if(handle.kind === 'file') {
                handle.getFile().then(f => {
                    node.sizeStr = (f.size/1024).toFixed(1) + ' KB';
                    node.dateStr = new Date(f.lastModified).toLocaleDateString();
                    
                    const fType = f.type.toLowerCase();
                    if(fType.startsWith('image/')) {
                         if(fType === 'image/bmp' && window.createImageBitmap) {
                            createImageBitmap(f).then(bmp => {
                                node.thumbTex = new THREE.CanvasTexture(bmp);
                                if(node.obj) updateNodeTexture(node);
                            });
                        } else {
                            const url = URL.createObjectURL(f);
                            new THREE.TextureLoader().load(url, (t) => {
                                node.thumbTex = t; 
                                node.thumbTex.colorSpace = THREE.SRGBColorSpace;
                                if(node.obj) updateNodeTexture(node);
                                URL.revokeObjectURL(url);
                            });
                        }
                    } else if(fType.startsWith('video/')) {
                        const v = document.createElement('video'); v.src = URL.createObjectURL(f); 
                        v.muted = true; v.currentTime = 1.0; 
                        v.onloadeddata = () => { v.currentTime = Math.min(1.0, v.duration * 0.1); };
                        v.onseeked = () => {
                            const c = document.createElement('canvas'); c.width=320; c.height=180;
                            c.getContext('2d').drawImage(v,0,0,320,180);
                            node.thumbTex = new THREE.CanvasTexture(c);
                            node.thumbTex.colorSpace = THREE.SRGBColorSpace;
                            if(node.obj) updateNodeTexture(node);
                        };
                    }
                }).catch(()=>{});
            } else {
                node.sizeStr = '<DIR>';
            }

            if (handle.kind === 'directory') {
                try {
                    const entries = [];
                    for await (const e of handle.values()) entries.push(e);
                    entries.sort((a,b) => (a.kind===b.kind ? a.name.localeCompare(b.name) : (a.kind==='directory'?-1:1)));
                    for (const e of entries) {
                        const child = {
                            handle: e, name: e.name, kind: e.kind,
                            parent: node, depth: depth+1, children: [],
                            angle: 0, z: -(depth+1) * CONFIG.layerDepth,
                            obj: null, currentScale: 1.0, currentLift: 0.0
                        };
                        node.children.push(child);
                    }
                } catch(e){ console.warn(e); }
            }
            return node;
        }

        function updateNodeTexture(node) {
            if(node.obj && node.kind === 'file' && node.thumbTex) {
                const bg = node.obj.children.find(c => c.isSprite && c !== node.visualText);
                if(bg) bg.material.map = node.thumbTex;
            }
        }

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

        function rebuildWorld() {
            while(world.children.length) {
                const c = world.children[0]; world.remove(c);
                if(c.geometry) c.geometry.dispose();
            }
            state.visibleNodes = [];

            if(!state.activeNode) return;

            // 1. Ancestor Path
            let p = state.activeNode;
            while(p) {
                createNodeVisual(p, true); 
                p = p.parent;
            }

            // 2. Children (Cone Surface)
            const children = state.activeNode.children;
            if (children.length > 0) {
                const childZ = state.activeNode.z - CONFIG.layerDepth;
                const radius = getConeRadius(childZ);
                const circumference = 2 * Math.PI * radius;
                // Gap adjustment: Ensure item width includes gap
                const effectiveItemWidth = CONFIG.nodeWidth * CONFIG.spacingMult; 
                let effRadius = radius;
                if (children.length * effectiveItemWidth > circumference) effRadius = (children.length * effectiveItemWidth) / (2 * Math.PI);

                const step = (Math.PI * 2) / Math.max(children.length, 1);
                children.forEach((child, i) => {
                    child.angle = i * step;
                    child.z = childZ;
                    createNodeVisual(child, false, effRadius);
                });
            }

            // 3. Connections
            const target = state.activeNode.children[state.targetChildIdx];
            if(state.activeNode.parent && state.activeNode.parent.obj && state.activeNode.obj) {
                createConnection(state.activeNode.parent, state.activeNode, true);
            }
            children.forEach(c => {
                if(c.obj && state.activeNode.obj) {
                    const isTarget = (c === target);
                    createConnection(state.activeNode, c, isTarget); 
                }
            });

            rotateToTarget();
        }

        function createNodeVisual(node, isCenter, overrideRadius) {
            if (node.obj && node.obj.parent === world) return; 

            const grp = new THREE.Group();
            let x=0, y=0;
            if (!isCenter) {
                const r = overrideRadius || getConeRadius(node.z);
                x = Math.cos(node.angle) * r;
                y = Math.sin(node.angle) * r;
            }
            grp.position.set(x, y, node.z);
            
            // Text Label
            const w = CONFIG.nodeWidth; const h = 60; const scale = 6;
            const cvs = document.createElement('canvas'); cvs.width=w*scale; cvs.height=h*scale;
            const ctx = cvs.getContext('2d');
            const fs = 22 * scale;
            ctx.font = `bold ${fs}px "Segoe UI", Arial`; ctx.textAlign='center'; ctx.textBaseline='middle';
            const tm = ctx.measureText(node.name); const maxW = cvs.width * 0.9;
            ctx.save(); ctx.translate(cvs.width/2, cvs.height/2);
            if(tm.width > maxW) ctx.scale(maxW/tm.width, 1);
            ctx.strokeStyle = '#000000'; ctx.lineWidth = 8 * (scale/4); ctx.lineJoin='round'; ctx.strokeText(node.name, 0, 0);
            ctx.fillStyle = '#cccccc'; ctx.fillText(node.name, 0, 0);
            ctx.restore();
            const tex = new THREE.CanvasTexture(cvs); 
            tex.minFilter = THREE.LinearFilter; tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
            const textSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }));
            textSprite.scale.set(w, h, 1);
            node.visualText = textSprite; 
            grp.add(textSprite);

            // Object Body
            if (node.kind === 'directory') {
                const geo = new THREE.BoxGeometry(w, w, w);
                const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: CONFIG.colMain }));
                grp.add(edges); grp.userData.rotMesh = edges;
                const hit = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ visible: false }));
                grp.add(hit); hit.userData.node = node; state.visibleNodes.push(hit);
                textSprite.position.set(0,0,0);
            } else {
                const pCvs = document.createElement('canvas'); pCvs.width=w*2; pCvs.height=h*2;
                const pCtx = pCvs.getContext('2d');
                pCtx.fillStyle='rgba(0, 30, 60, 0.8)'; pCtx.fillRect(0,0,pCvs.width,pCvs.height);
                pCtx.strokeStyle= CONFIG.colFile; pCtx.lineWidth=6; pCtx.strokeRect(0,0,pCvs.width,pCvs.height);
                
                const map = node.thumbTex ? node.thumbTex : new THREE.CanvasTexture(pCvs);
                map.colorSpace = THREE.SRGBColorSpace;
                const bg = new THREE.Sprite(new THREE.SpriteMaterial({ map: map, transparent: true }));
                bg.scale.set(w, h, 1); bg.position.z = -1; 
                grp.add(bg);
                bg.userData.node = node; state.visibleNodes.push(bg);
            }

            world.add(grp);
            node.obj = grp;
        }

        function createConnection(pNode, cNode, isBold) {
            if(isBold) {
                const path = new THREE.LineCurve3(pNode.obj.position, cNode.obj.position);
                const tubGeo = new THREE.TubeGeometry(path, 1, 6, 8, false); 
                const tubMat = new THREE.MeshBasicMaterial({ color: CONFIG.colLine, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending });
                const mesh = new THREE.Mesh(tubGeo, tubMat);
                world.add(mesh);
                cNode.visualLine = mesh;
                cNode.isTube = true;
            } else {
                const geo = new THREE.BufferGeometry().setFromPoints([pNode.obj.position, cNode.obj.position]);
                const mat = new THREE.LineBasicMaterial({ color: CONFIG.colLine, transparent: true, opacity: 0.1 });
                const line = new THREE.Line(geo, mat);
                world.add(line);
                cNode.visualLine = line;
                cNode.isTube = false;
            }
        }

        function rotateToTarget() {
            if (!state.activeNode.children.length) return;
            const target = state.activeNode.children[state.targetChildIdx];
            const dest = (Math.PI / 2) - target.angle;
            let delta = dest - state.currentRotation;
            while (delta <= -Math.PI) delta += Math.PI*2; while (delta > Math.PI) delta -= Math.PI*2;
            new TWEEN.Tween({r:state.currentRotation}).to({r:state.currentRotation+delta}, 500).easing(TWEEN.Easing.Cubic.Out).onUpdate(o=>state.currentRotation=o.r).start();
            updateConnectionStyles();
        }

        function updateConnectionStyles() {
            const children = state.activeNode.children;
            const target = children[state.targetChildIdx];
            children.forEach(c => {
                if(c.visualLine) {
                    world.remove(c.visualLine);
                    if(c.visualLine.geometry) c.visualLine.geometry.dispose();
                }
                if(c.obj && state.activeNode.obj) {
                    createConnection(state.activeNode, c, (c === target));
                }
            });
        }

        async function diveInto(node) {
            if (node.kind === 'file') { openViewer(node.handle); return; }
            if (node.children.length === 0) {
                const temp = await scanNode(node.handle, node.parent, node.depth);
                node.children = temp.children;
            }
            triggerStreamEffect(true);
            new TWEEN.Tween(state).to({ focusPointZ: node.z }, 800).easing(TWEEN.Easing.Quartic.InOut).onComplete(() => {
                triggerStreamEffect(false); state.activeNode = node; state.targetChildIdx = 0; rebuildWorld(); updatePath();
            }).start();
        }

        function moveUp() {
            if (!state.activeNode.parent) return;
            const parent = state.activeNode.parent;
            const idx = parent.children.findIndex(c => c.name === state.activeNode.name);
            triggerStreamEffect(true);
            new TWEEN.Tween(state).to({ focusPointZ: parent.z }, 800).easing(TWEEN.Easing.Quartic.InOut).onComplete(() => {
                triggerStreamEffect(false); state.activeNode = parent; state.targetChildIdx = idx >= 0 ? idx : 0; rebuildWorld(); updatePath();
            }).start();
        }

        function triggerStreamEffect(active) { new TWEEN.Tween(streamGroup.children[0].material).to({ opacity: active ? 0.6 : 0 }, 300).start(); }

        function updateCamera() {
            const currentZ = state.focusPointZ;
            const lookAtZ = currentZ - CONFIG.camLookAhead;
            const r = getConeRadius(currentZ);
            const camY = r + CONFIG.camHeight;
            const camZ = currentZ + CONFIG.camDist;
            streamGroup.position.z = currentZ;
            camera.position.set(0, camY, camZ);
            camera.lookAt(0, getConeRadius(lookAtZ), lookAtZ);
        }

        function updateVisuals() {
            world.rotation.z = state.currentRotation;
            state.visibleNodes.forEach(obj => {
                const node = obj.userData.node;
                let tScale = 1.0; let tLift = 0.0;
                
                const isTarget = (state.activeNode && state.activeNode.children[state.targetChildIdx] === node);
                const isActive = (node === state.activeNode);

                if (isActive) { tScale = 2.5; tLift = 120; }
                else if (isTarget) { tScale = 2.0; tLift = 80; }
                
                node.currentScale += (tScale - node.currentScale) * 0.1;
                node.currentLift += (tLift - node.currentLift) * 0.1;
                node.obj.scale.set(node.currentScale, node.currentScale, node.currentScale);
                
                if (!isActive) {
                    const baseR = getConeRadius(node.z);
                    const r = baseR + node.currentLift;
                    node.obj.position.x = Math.cos(node.angle) * r;
                    node.obj.position.y = Math.sin(node.angle) * r;
                }

                if (node.obj.userData.rotMesh) {
                    node.obj.userData.rotMesh.rotation.x += 0.01; node.obj.userData.rotMesh.rotation.y += 0.02;
                }
                
                if (node.visualLine && node.parent && node.parent.obj) {
                    const pPos = node.parent.obj.position;
                    const cPos = node.obj.position;
                    if(node.isTube) {
                        node.visualLine.geometry.dispose();
                        const path = new THREE.LineCurve3(pPos, cPos);
                        node.visualLine.geometry = new THREE.TubeGeometry(path, 1, 6, 8, false);
                    } else {
                        const pos = node.visualLine.geometry.attributes.position.array;
                        pos[0]=pPos.x; pos[1]=pPos.y; pos[2]=pPos.z;
                        pos[3]=cPos.x; pos[4]=cPos.y; pos[5]=cPos.z;
                        node.visualLine.geometry.attributes.position.needsUpdate = true;
                    }
                }
            });
        }

        // --- HUDs ---
        function updateActiveInfo() {
            const div = document.getElementById('active-info');
            const n = state.activeNode;
            if(n && n.kind === 'file') {
                div.style.display = 'block';
                document.getElementById('ai-name').innerText = n.name;
                document.getElementById('ai-type').innerText = n.kind.toUpperCase();
                document.getElementById('ai-size').innerText = n.sizeStr;
                document.getElementById('ai-date').innerText = n.dateStr;
            } else {
                div.style.display = 'none';
            }
        }

        function updateTargetHUD(target) {
            if(!target || !target.obj || target.kind !== 'file') { infoMesh.visible = false; return; }
            infoMesh.visible = true;
            
            const p = new THREE.Vector3(); target.obj.getWorldPosition(p);
            infoMesh.position.copy(p);
            infoMesh.position.y += 80; infoMesh.position.x += 30;
            infoMesh.lookAt(camera.position);
            
            infoCtx.clearRect(0,0,512,300);
            infoCtx.fillStyle = "rgba(5, 10, 15, 0.9)"; infoCtx.fillRect(0,0,512,300);
            infoCtx.fillStyle = "rgba(0, 255, 255, 0.05)";
            for(let i=0; i<300; i+=4) infoCtx.fillRect(0,i,512,2);
            infoCtx.strokeStyle = "#00ffff"; infoCtx.lineWidth=4; infoCtx.strokeRect(0,0,512,300);
            
            infoCtx.fillStyle = "rgba(0, 255, 255, 0.2)"; infoCtx.fillRect(0,0,512,50);
            infoCtx.font = "bold 28px 'Segoe UI', monospace"; infoCtx.fillStyle = "#00ffff";
            infoCtx.textBaseline = 'middle'; infoCtx.fillText("TARGET INFO", 20, 25);
            
            infoCtx.font = "bold 32px 'Segoe UI'"; infoCtx.fillStyle = "#ffffff";
            let name = target.name;
            if(name.length > 25) name = name.substring(0,24)+'...';
            infoCtx.fillText(name, 20, 100);
            
            infoCtx.font = "24px 'Courier New'"; infoCtx.fillStyle = "#aaaaaa";
            infoCtx.fillText(`SIZE: ${target.sizeStr || '-'}`, 20, 150);
            infoCtx.fillText(`DATE: ${target.dateStr || '-'}`, 20, 190);
            
            infoMesh.material.map.needsUpdate = true;
        }

        function updateCursors() {
            if (state.activeNode && state.activeNode.obj) {
                cursorMesh.visible = true;
                const p = new THREE.Vector3(); state.activeNode.obj.getWorldPosition(p);
                cursorMesh.position.lerp(p, 0.1); cursorMesh.lookAt(camera.position);
            } else cursorMesh.visible = false;

            if (state.activeNode && state.activeNode.children[state.targetChildIdx]) {
                const t = state.activeNode.children[state.targetChildIdx];
                if (t.obj) {
                    targetMesh.visible = true;
                    const p = new THREE.Vector3(); t.obj.getWorldPosition(p);
                    targetMesh.position.lerp(p, 0.2); targetMesh.lookAt(camera.position); 
                    targetMesh.rotation.z += 0.02;
                    updateTargetHUD(t); 
                } else { targetMesh.visible = false; infoMesh.visible = false; }
            } else { targetMesh.visible = false; infoMesh.visible = false; }
        }

        function updateParticles() {
            if (state.activeNode && state.activeNode.children.length > 0) {
                const target = state.activeNode.children[state.targetChildIdx];
                if (target && target.obj && state.activeNode.obj) {
                    const s = new THREE.Vector3(); state.activeNode.obj.getWorldPosition(s);
                    const e = new THREE.Vector3(); target.obj.getWorldPosition(e);
                    if (Math.random() < 0.4) spawnArrow(s, e);
                }
            }
            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;
                }
            }
        }

        // --- Interaction ---
        function onKey(e) {
            if(document.getElementById('dialog-overlay').style.display==='flex' || document.getElementById('viewer-overlay').style.display==='flex') {
                if(e.key==='Escape') { document.getElementById('viewer-overlay').classList.remove('active'); setTimeout(()=>{document.getElementById('viewer-overlay').style.display='none'},300); } return;
            }
            if(!state.activeNode) return;
            switch(e.key) {
                case 'ArrowLeft': e.preventDefault(); changeTarget(1); break;
                case 'ArrowRight': e.preventDefault(); changeTarget(-1); break;
                case 'ArrowUp': case 'Enter': e.preventDefault(); if(state.activeNode.children[state.targetChildIdx]) diveInto(state.activeNode.children[state.targetChildIdx]); break;
                case 'ArrowDown': e.preventDefault(); moveUp(); break;
                case 'Home': e.preventDefault(); if(state.rootNode) { state.activeNode=state.rootNode; state.targetChildIdx=0; state.focusPointZ=state.rootNode.z; rebuildWorld(); updatePath(); } break;
            }
        }
        function changeTarget(dir) {
            const len = state.activeNode.children.length; if(len === 0) return;
            state.targetChildIdx = (state.targetChildIdx + dir + len) % len;
            rotateToTarget();
        }
        
        function onMouseMove(e) {
            state.mouse.x = (e.clientX/window.innerWidth)*2-1; state.mouse.y = -(e.clientY/window.innerHeight)*2+1;
            
            // Drag Logic
            if(state.dragging) {
                const ghost = document.getElementById('drag-ghost');
                ghost.style.display = 'block';
                ghost.style.left = (e.clientX + 10) + 'px';
                ghost.style.top = (e.clientY + 10) + 'px';
                return;
            }

            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            document.body.style.cursor = hits.length > 0 ? 'pointer' : 'default';
        }

        function onMouseDown(e) {
            if(e.button !== 0) return;
            if(e.target.closest('#stock') || e.target.closest('#stock-tabs') || e.target.closest('.ctx-menu')) return;
            
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            if(hits.length > 0) {
                const node = hits[0].object.userData.node;
                const idx = state.activeNode.children.indexOf(node);
                if(idx >= 0) { 
                    state.targetChildIdx = idx; rotateToTarget(); 
                    // Start dragging check
                    state.dragging = { node: node, startX: e.clientX, startY: e.clientY };
                }
                else if (node === state.activeNode.parent) moveUp();
            }
        }

        async function onMouseUp(e) {
            if(state.dragging) {
                const ghost = document.getElementById('drag-ghost');
                ghost.style.display = 'none';
                
                // Drop into Stock?
                if(e.clientY > window.innerHeight - 160) { // Approx stock height
                    await moveToStock(state.dragging.node.handle);
                }
                state.dragging = null;
            }
        }

        function onMouseDoubleClick(e) {
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            if(hits.length > 0) diveInto(hits[0].object.userData.node);
        }

        function onContextMenu(e) {
            e.preventDefault();
            // Tab Context
            if(e.target.closest('.tab-btn')) {
                state.tabCtxIdx = parseInt(e.target.closest('.tab-btn').dataset.idx);
                const m = document.getElementById('ctx-tab');
                m.style.display='block'; m.style.left=e.clientX+'px'; m.style.top=e.clientY+'px';
                return;
            }

            // Node Context
            state.raycaster.setFromCamera(state.mouse, camera);
            const hits = state.raycaster.intersectObjects(state.visibleNodes);
            if(hits.length > 0) {
                state.ctxTarget = hits[0].object.userData.node;
                const idx = state.activeNode.children.indexOf(state.ctxTarget);
                if(idx>=0) { state.targetChildIdx=idx; rotateToTarget(); }
                const m = document.getElementById('ctx-menu');
                m.style.display='block'; m.style.left=e.clientX+'px'; m.style.top=e.clientY+'px';
                document.getElementById('cm-stock').style.display = state.ctxTarget.kind==='directory'?'block':'none';
            } else document.querySelectorAll('.ctx-menu').forEach(m => m.style.display='none');
        }

        // --- Stock Logic ---
        async function moveToStock(handle) {
            if(state.activeStockIdx < 0) { alert("No active Stock Tab!"); return; }
            const dest = state.stockList[state.activeStockIdx].handle;
            if(handle.move) {
                try {
                    await handle.move(dest);
                    // Refresh View
                    const t = await scanNode(state.activeNode.handle, state.activeNode.parent, state.activeNode.depth); 
                    state.activeNode.children = t.children; rebuildWorld();
                    renderStock();
                } catch(e) { alert("Move failed: " + e.message); }
            }
        }

        async function moveFromStock(stockItemIdx) {
            if(state.activeStockIdx < 0) return;
            const currentStock = state.stockList[state.activeStockIdx];
            // Need to get entries again to find the item? Or assume index matches render
            // Re-fetch to be safe
            let entries = [];
            for await (const e of currentStock.handle.values()) entries.push(e);
            if(entries[stockItemIdx]) {
                try {
                    if(entries[stockItemIdx].move) {
                        await entries[stockItemIdx].move(state.activeNode.handle);
                        // Refresh
                        const t = await scanNode(state.activeNode.handle, state.activeNode.parent, state.activeNode.depth); 
                        state.activeNode.children = t.children; rebuildWorld();
                        renderStock();
                    }
                } catch(e) { alert("Retrieve failed: " + e.message); }
            }
        }

        function addToStock(node){
            if(state.stockList.find(s=>s.name===node.name)) return; 
            state.stockList.push({name:node.name, handle:node.handle}); 
            state.activeStockIdx = state.stockList.length-1; 
            renderStock(); 
            document.getElementById('stock').classList.remove('closed');
        }

        async function renderStock(){
            const t=document.getElementById('stock-tabs'); const d=document.getElementById('stock'); 
            t.innerHTML=''; d.innerHTML=''; 
            
            if(state.stockList.length===0){ d.classList.add('closed'); return; } 
            
            state.stockList.forEach((s,i)=>{
                const b=document.createElement('div'); 
                b.className='tab-btn'+(i===state.activeStockIdx?' active':''); 
                b.innerText=s.name; b.dataset.idx=i;
                b.onclick=()=>{
                    state.activeStockIdx=i; renderStock(); 
                    document.getElementById('stock').classList.remove('closed');
                }; 
                t.appendChild(b);
            }); 
            
            if(state.activeStockIdx >= 0) {
                const cur=state.stockList[state.activeStockIdx]; 
                try{
                    let idx = 0;
                    for await(const e of cur.handle.values()){
                        const el=document.createElement('div'); el.className='stock-item'; 
                        el.draggable = true;
                        
                        // Drag Start for Stock Item
                        const myIdx = idx; // capture closure
                        el.ondragstart = ev => {
                            ev.dataTransfer.setData('stockIndex', myIdx);
                        };

                        let iconHtml = `<div class="stock-thumb" style="font-size:30px; color:#555;">${e.kind==='directory'?'📁':'📄'}</div>`;
                        if(e.kind === 'file' && e.name.match(/\.(jpg|png|webp|gif)$/i)) {
                            e.getFile().then(f=>{
                                const u=URL.createObjectURL(f);
                                el.querySelector('.stock-thumb').innerHTML=`<img src="${u}">`;
                            });
                        }
                        el.innerHTML=`${iconHtml}<div class="stock-name">${e.name}</div>`; 
                        d.appendChild(el);
                        idx++;
                    }
                } catch(e){ d.innerHTML='ERR'; }
            }
        }

        // --- Bindings ---
        function bindMenu() {
            const hide=()=>document.querySelectorAll('.ctx-menu').forEach(m=>m.style.display='none');
            document.getElementById('cm-open').onclick=()=>{if(state.ctxTarget)diveInto(state.ctxTarget);hide();};
            document.getElementById('cm-copy').onclick=()=>{if(state.ctxTarget)state.clipboard=state.ctxTarget.handle;hide();};
            document.getElementById('cm-paste').onclick=async()=>{if(state.clipboard&&state.activeNode){try{if(state.clipboard.kind==='file'){const f=await state.clipboard.getFile();const h=await state.activeNode.handle.getFileHandle(state.clipboard.name,{create:true});const w=await h.createWritable();await w.write(f);await w.close(); const t=await scanNode(state.activeNode.handle,state.activeNode.parent,state.activeNode.depth); state.activeNode.children=t.children; rebuildWorld();}}catch(e){alert(e);}}hide();};
            document.getElementById('cm-rename').onclick=async()=>{if(state.ctxTarget&&state.ctxTarget.handle.move){const n=await showDialog("RENAME",state.ctxTarget.name);if(n){await state.ctxTarget.handle.move(n);state.ctxTarget.name=n;rebuildWorld();}}hide();};
            document.getElementById('cm-new').onclick=async()=>{const n=await showDialog("NEW FOLDER","New Folder");if(n){await state.activeNode.handle.getDirectoryHandle(n,{create:true});const t=await scanNode(state.activeNode.handle,state.activeNode.parent,state.activeNode.depth);state.activeNode.children=t.children;rebuildWorld();}hide();};
            document.getElementById('cm-del').onclick=async()=>{if(state.ctxTarget&&confirm("Delete?")){await state.activeNode.handle.removeEntry(state.ctxTarget.name,{recursive:true});const t=await scanNode(state.activeNode.handle,state.activeNode.parent,state.activeNode.depth);state.activeNode.children=t.children;rebuildWorld();}hide();};
            document.getElementById('cm-stock').onclick=()=>{if(state.ctxTarget.kind==='directory')addToStock(state.ctxTarget);hide();};
            
            // Tab Menu
            document.getElementById('cm-tab-hide').onclick = () => { document.getElementById('stock').classList.add('closed'); hide(); };
            document.getElementById('cm-tab-cancel').onclick = () => {
                if(state.tabCtxIdx >= 0) {
                    state.stockList.splice(state.tabCtxIdx, 1);
                    if(state.activeStockIdx >= state.stockList.length) state.activeStockIdx = state.stockList.length - 1;
                    renderStock();
                }
                hide();
            };
        }

        let dRes=null; function showDialog(t,v){return new Promise(r=>{document.getElementById('dialog-overlay').style.display='flex';document.getElementById('dialog-title').innerText=t;document.getElementById('dialog-input').value=v;document.getElementById('dialog-input').focus();dRes=r;});}
        function bindDialog(){document.getElementById('dialog-ok').onclick=()=>{document.getElementById('dialog-overlay').style.display='none';if(dRes)dRes(document.getElementById('dialog-input').value);};document.getElementById('dialog-cancel').onclick=()=>{document.getElementById('dialog-overlay').style.display='none';if(dRes)dRes(null);};}
        async function openViewer(h){const v=document.getElementById('viewer-overlay'),b=document.getElementById('viewer-body'); v.style.display='flex'; setTimeout(()=>v.classList.add('active'),10); document.getElementById('viewer-title').innerText=h.name; b.innerHTML='LOADING...'; try{const f=await h.getFile(),u=URL.createObjectURL(f); b.innerHTML=''; if(f.type.startsWith('image'))b.innerHTML=`<img src="${u}">`; else if(f.type.startsWith('video'))b.innerHTML=`<video src="${u}" controls autoplay></video>`; else if(f.type.startsWith('text')||h.name.match(/\.(txt|js|json|html|css|md)$/)){const t=await f.text(); const ta=document.createElement('textarea'); ta.className='editor'; ta.value=t; b.appendChild(ta); document.getElementById('viewer-save').style.display='block'; document.getElementById('viewer-save').onclick=async()=>{const w=await h.createWritable();await w.write(ta.value);await w.close();alert("SAVED");}; return;} else b.innerText="BINARY FILE"; document.getElementById('viewer-save').style.display='none';}catch(e){b.innerText="ERROR";}}
        function bindViewer(){document.getElementById('viewer-close').onclick=()=>{document.getElementById('viewer-overlay').classList.remove('active');setTimeout(()=>document.getElementById('viewer-overlay').style.display='none',300);};}
        function showLoad(b){document.getElementById('loading').style.display=b?'flex':'none';}
        function updatePath(){let p=state.activeNode,s=p.name;while(p.parent){p=p.parent;s=p.name+' / '+s;}document.getElementById('path-display').innerText=s.toUpperCase(); updateActiveInfo();}
        function onResize(){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();
            updateCamera(); updateVisuals(); updateCursors(); updateParticles();
            composer.render();
        }

        init();
    </script>
</body>
</html>

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

コメント

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