円錐形のフォルダビューワ「Folder Viewer 3D」
[ 操作説明 ]
・上キーで下の階層に進む。
・下キーで上の階層に戻る。
・左右のキーで移動先を選ぶ。
・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>

コメント