764 lines
39 KiB
HTML
764 lines
39 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>记忆星图 - TrulyMEM</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: 'Courier New', monospace; background: #0a0a1a; color: #ffffff; overflow: hidden; width: 100vw; height: 100vh; }
|
||
#canvas-container { width: 100%; height: 100%; position: relative; }
|
||
canvas { display: block; }
|
||
#stats { position: absolute; top: 20px; left: 20px; background: rgba(10, 10, 26, 0.8); padding: 15px 20px; border-radius: 8px; border: 1px solid rgba(100, 100, 255, 0.3); font-size: 14px; z-index: 100; backdrop-filter: blur(10px); }
|
||
#stats h3 { margin-bottom: 8px; color: #4488ff; font-size: 16px; }
|
||
#stats p { margin: 4px 0; color: #aaaacc; }
|
||
#stats span { color: #ffffff; font-weight: bold; }
|
||
#node-info { position: absolute; top: 20px; right: 20px; background: rgba(10, 10, 26, 0.9); padding: 15px 20px; border-radius: 8px; border: 1px solid rgba(100, 100, 255, 0.3); font-size: 14px; z-index: 100; display: none; backdrop-filter: blur(10px); max-width: 300px; }
|
||
#node-info h3 { color: #44ff88; margin-bottom: 8px; font-size: 16px; }
|
||
#node-info p { margin: 4px 0; color: #aaaacc; }
|
||
#node-info .label { color: #8888aa; }
|
||
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 20px; color: #4488ff; z-index: 200; }
|
||
#nav { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); display: flex; gap: 20px; z-index: 100; }
|
||
|
||
/* 搜索框 */
|
||
#search-box { position: absolute; top: 80px; left: 20px; z-index: 100; }
|
||
#search-input { background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #ffffff; padding: 8px 12px; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 14px; width: 200px; outline: none; backdrop-filter: blur(10px); }
|
||
#search-input::placeholder { color: #666688; }
|
||
|
||
/* 类型过滤按钮 */
|
||
#type-filter { position: absolute; top: 120px; left: 20px; display: flex; gap: 8px; z-index: 100; flex-wrap: wrap; }
|
||
.type-btn { background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #aaaacc; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-family: 'Courier New', monospace; font-size: 12px; backdrop-filter: blur(10px); transition: all 0.3s; }
|
||
.type-btn.active { background: rgba(68, 136, 255, 0.3); border-color: #4488ff; color: #ffffff; }
|
||
|
||
/* 缩放控制按钮 */
|
||
#zoom-controls { position: absolute; bottom: 100px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 100; }
|
||
.zoom-btn { width: 40px; height: 40px; border-radius: 50%; background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #ffffff; font-size: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(10px); font-family: 'Courier New', monospace; }
|
||
|
||
/* 边标签 */
|
||
#edge-label { position: absolute; display: none; background: rgba(10, 10, 26, 0.9); color: #44ff88; padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 150; border: 1px solid rgba(68, 255, 136, 0.3); }
|
||
.nav-btn { background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #aaaacc; padding: 12px 24px; border-radius: 8px; cursor: pointer; font-family: 'Courier New', monospace; font-size: 14px; backdrop-filter: blur(10px); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="canvas-container">
|
||
<div id="loading">正在加载星图数据...</div>
|
||
<div id="stats">
|
||
<h3>🌌 记忆星图</h3>
|
||
<p>节点: <span id="node-count">0</span></p>
|
||
<p>边: <span id="edge-count">0</span></p>
|
||
<p>状态: <span id="status">初始化中...</span></p>
|
||
</div>
|
||
<div id="node-info">
|
||
<h3 id="info-name"></h3>
|
||
<p><span class="label">类型:</span> <span id="info-type"></span></p>
|
||
<p><span class="label">提及次数:</span> <span id="info-mentions"></span></p>
|
||
<p><span class="label">连接数:</span> <span id="info-links"></span></p>
|
||
</div>
|
||
<div id="nav">
|
||
<button class="nav-btn active">🌌 星图</button>
|
||
</div>
|
||
<div id="search-box">
|
||
<input type="text" id="search-input" placeholder="搜索节点...">
|
||
</div>
|
||
<div id="type-filter">
|
||
<button class="type-btn active" data-type="全部">全部</button>
|
||
<button class="type-btn" data-type="Person">Person</button>
|
||
<button class="type-btn" data-type="Task">Task</button>
|
||
<button class="type-btn" data-type="AI">AI</button>
|
||
<button class="type-btn" data-type="Concept">Concept</button>
|
||
<button class="type-btn" data-type="Object">Object</button>
|
||
</div>
|
||
<div id="zoom-controls">
|
||
<button class="zoom-btn" id="zoom-in">+</button>
|
||
<button class="zoom-btn" id="zoom-out">-</button>
|
||
<button class="zoom-btn" id="zoom-reset">R</button>
|
||
</div>
|
||
<div id="edge-label"></div>
|
||
</div>
|
||
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||
<script>
|
||
let scene, camera, renderer, controls;
|
||
let nodes = [], edges = [];
|
||
let nodeMeshes = [], edgeLines = [];
|
||
let starField, nebulaParticles;
|
||
let raycaster, mouse;
|
||
let hoveredNode = null, selectedNode = null;
|
||
let animationId;
|
||
let highlightPulse = 0;
|
||
let searchTerm = '';
|
||
let activeTypeFilter = '全部';
|
||
let isDragging = false;
|
||
let dragNode = null;
|
||
let originalPhysicsState = true;
|
||
let edgeLabelEl = null;
|
||
let nodePositions = {}; // 存储节点位置用于拖拽
|
||
|
||
const typeColors = { 'person': 0x4488ff, 'task': 0xff8844, 'ai': 0xaa44ff, 'concept': 0x44ff88, 'object': 0xff4444 };
|
||
const defaultColor = 0xcccccc;
|
||
const edgeColors = { '喜欢': 0xff6b6b, '学习': 0x4ecdc4, '属于': 0x45b7d1, '相关': 0x96ceb4, '使用': 0xfeca57, '创建': 0xff9ff3 };
|
||
const defaultEdgeColor = 0x444466;
|
||
|
||
function init() {
|
||
scene = new THREE.Scene();
|
||
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.015);
|
||
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
|
||
camera.position.set(0, 30, 60);
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
renderer.setPixelRatio(window.devicePixelRatio);
|
||
renderer.setClearColor(0x0a0a1a, 1);
|
||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.05;
|
||
const ambientLight = new THREE.AmbientLight(0x444466, 0.6);
|
||
scene.add(ambientLight);
|
||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||
directionalLight.position.set(50, 100, 50);
|
||
scene.add(directionalLight);
|
||
raycaster = new THREE.Raycaster();
|
||
mouse = new THREE.Vector2();
|
||
createStarField();
|
||
createNebula();
|
||
window.addEventListener('resize', onWindowResize);
|
||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||
renderer.domElement.addEventListener('click', onMouseClick);
|
||
window.addEventListener('message', (event) => {
|
||
if (event.data.type === 'graph_data') {
|
||
window.__graphData = event.data.payload;
|
||
loadGraphData();
|
||
}
|
||
if (event.data.type === 'search_nodes') {
|
||
searchTerm = event.data.query || '';
|
||
document.getElementById('search-input').value = searchTerm;
|
||
applyFilters();
|
||
}
|
||
if (event.data.type === 'node_detail') {
|
||
showNodeDetailPanel(event.data.detail);
|
||
}
|
||
if (event.data.type === 'highlight_node') {
|
||
highlightNodeById(event.data.nodeId);
|
||
}
|
||
});
|
||
// 搜索输入框事件
|
||
const searchInput = document.getElementById('search-input');
|
||
searchInput.addEventListener('input', (e) => {
|
||
searchTerm = e.target.value.toLowerCase();
|
||
applyFilters();
|
||
// 通知 ArkTS
|
||
try {
|
||
if (window.nativeBridge && window.nativeBridge.onSearch) {
|
||
window.nativeBridge.onSearch(searchTerm);
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
// 类型过滤按钮事件
|
||
document.querySelectorAll('.type-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
activeTypeFilter = btn.dataset.type;
|
||
applyFilters();
|
||
});
|
||
});
|
||
// 缩放控制按钮
|
||
document.getElementById('zoom-in').addEventListener('click', () => {
|
||
camera.position.multiplyScalar(0.8);
|
||
controls.update();
|
||
});
|
||
document.getElementById('zoom-out').addEventListener('click', () => {
|
||
camera.position.multiplyScalar(1.2);
|
||
controls.update();
|
||
});
|
||
document.getElementById('zoom-reset').addEventListener('click', () => {
|
||
if (Object.keys(nodePositions).length > 0) {
|
||
const allPositions = Object.values(nodePositions);
|
||
let maxDist = 0;
|
||
allPositions.forEach(pos => { maxDist = Math.max(maxDist, Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z)); });
|
||
camera.position.set(maxDist * 2.2, maxDist * 1.5, maxDist * 2.2);
|
||
controls.target.set(0, 0, 0);
|
||
controls.update();
|
||
}
|
||
});
|
||
// 边标签元素
|
||
edgeLabelEl = document.getElementById('edge-label');
|
||
// 鼠标移动检测边悬停
|
||
renderer.domElement.addEventListener('mousemove', onEdgeHoverCheck);
|
||
// 通知 ArkTS 请求图数据
|
||
try {
|
||
if (window.nativeBridge && window.nativeBridge.requestGraphData) {
|
||
window.nativeBridge.requestGraphData();
|
||
}
|
||
} catch(e) {}
|
||
// 触摸事件支持
|
||
initTouchEvents();
|
||
// 初始化拖拽功能
|
||
initDragFunctionality();
|
||
animate();
|
||
}
|
||
|
||
function createStarField() {
|
||
const starCount = 3000;
|
||
const positions = new Float32Array(starCount * 3);
|
||
const colors = new Float32Array(starCount * 3);
|
||
for (let i = 0; i < starCount; i++) {
|
||
const i3 = i * 3;
|
||
const radius = 400 + Math.random() * 600;
|
||
const theta = Math.random() * Math.PI * 2;
|
||
const phi = Math.acos(2 * Math.random() - 1);
|
||
positions[i3] = radius * Math.sin(phi) * Math.cos(theta);
|
||
positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
|
||
positions[i3 + 2] = radius * Math.cos(phi);
|
||
const colorChoice = Math.random();
|
||
if (colorChoice < 0.7) {
|
||
colors[i3] = 0.8 + Math.random() * 0.2;
|
||
colors[i3 + 1] = 0.8 + Math.random() * 0.2;
|
||
colors[i3 + 2] = 1.0;
|
||
} else {
|
||
colors[i3] = 1.0;
|
||
colors[i3 + 1] = 0.9 + Math.random() * 0.1;
|
||
colors[i3 + 2] = 0.8 + Math.random() * 0.2;
|
||
}
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({ size: 1.5, vertexColors: true, transparent: true, opacity: 0.8, sizeAttenuation: true });
|
||
starField = new THREE.Points(geometry, material);
|
||
scene.add(starField);
|
||
}
|
||
|
||
function createNebula() {
|
||
const nebulaCount = 500;
|
||
const positions = new Float32Array(nebulaCount * 3);
|
||
const colors = new Float32Array(nebulaCount * 3);
|
||
for (let i = 0; i < nebulaCount; i++) {
|
||
const i3 = i * 3;
|
||
positions[i3] = (Math.random() - 0.5) * 800;
|
||
positions[i3 + 1] = (Math.random() - 0.5) * 800;
|
||
positions[i3 + 2] = (Math.random() - 0.5) * 800;
|
||
const colorChoice = Math.random();
|
||
if (colorChoice < 0.33) {
|
||
colors[i3] = 0.5 + Math.random() * 0.3; colors[i3 + 1] = 0.2 + Math.random() * 0.2; colors[i3 + 2] = 0.7 + Math.random() * 0.3;
|
||
} else if (colorChoice < 0.66) {
|
||
colors[i3] = 0.2 + Math.random() * 0.2; colors[i3 + 1] = 0.3 + Math.random() * 0.3; colors[i3 + 2] = 0.8 + Math.random() * 0.2;
|
||
} else {
|
||
colors[i3] = 0.7 + Math.random() * 0.3; colors[i3 + 1] = 0.2 + Math.random() * 0.2; colors[i3 + 2] = 0.5 + Math.random() * 0.3;
|
||
}
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({ size: 8, vertexColors: true, transparent: true, opacity: 0.15, sizeAttenuation: true, blending: THREE.AdditiveBlending });
|
||
nebulaParticles = new THREE.Points(geometry, material);
|
||
scene.add(nebulaParticles);
|
||
}
|
||
|
||
window.loadGraphData = function(data) {
|
||
if (data && data.nodes && data.edges) {
|
||
nodes = data.nodes.map(n => ({ id: n.id, name: n.label || n.name, type: n.type, mention_count: n.mentions || 1 }));
|
||
edges = data.edges.map(e => ({ id: e.id, source: e.from || e.source, target: e.to || e.target, relation_type: e.label || e.relation }));
|
||
document.getElementById('node-count').textContent = nodes.length;
|
||
document.getElementById('edge-count').textContent = edges.length;
|
||
document.getElementById('status').textContent = '就绪';
|
||
createGraphVisualization();
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function createGraphVisualization() {
|
||
nodeMeshes.forEach(mesh => scene.remove(mesh));
|
||
edgeLines.forEach(line => scene.remove(line));
|
||
nodeMeshes = [];
|
||
edgeLines = [];
|
||
if (nodes.length === 0) return;
|
||
const nodeDegrees = {};
|
||
nodes.forEach(n => nodeDegrees[n.id] = 0);
|
||
edges.forEach(e => {
|
||
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
|
||
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
|
||
});
|
||
const positions = {};
|
||
nodePositions = positions; // 存储供拖拽使用
|
||
const maxDegree = Math.max(...Object.values(nodeDegrees), 1);
|
||
nodes.forEach((node, i) => {
|
||
const angle = (i / nodes.length) * Math.PI * 2;
|
||
const radius = 15 + (nodeDegrees[node.id] / maxDegree) * 35;
|
||
positions[node.id] = { x: radius * Math.cos(angle), y: (Math.random() - 0.5) * 10, z: radius * Math.sin(angle) };
|
||
});
|
||
for (let iter = 0; iter < 200; iter++) {
|
||
Object.keys(positions).forEach(id1 => {
|
||
Object.keys(positions).forEach(id2 => {
|
||
if (id1 >= id2) return;
|
||
const pos1 = positions[id1], pos2 = positions[id2];
|
||
const dx = pos1.x - pos2.x, dy = pos1.y - pos2.y, dz = pos1.z - pos2.z;
|
||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||
if (dist < 20) { // 增加排斥距离
|
||
const force = 0.3 / (dist * dist); // 增加排斥力系数(平方反比)
|
||
pos1.x += (dx/dist)*force; pos1.y += (dy/dist)*force; pos1.z += (dz/dist)*force;
|
||
pos2.x -= (dx/dist)*force; pos2.y -= (dy/dist)*force; pos2.z -= (dz/dist)*force;
|
||
}
|
||
});
|
||
});
|
||
edges.forEach(edge => {
|
||
const pos1 = positions[edge.source], pos2 = positions[edge.target];
|
||
if (!pos1 || !pos2) return;
|
||
const dx = pos2.x - pos1.x, dy = pos2.y - pos1.y, dz = pos2.z - pos1.z;
|
||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||
if (dist > 15) {
|
||
const force = 0.08; // 增加吸引力
|
||
pos1.x += (dx/dist)*force; pos1.y += (dy/dist)*force; pos1.z += (dz/dist)*force;
|
||
pos2.x -= (dx/dist)*force; pos2.y -= (dy/dist)*force; pos2.z -= (dz/dist)*force;
|
||
}
|
||
});
|
||
}
|
||
nodes.forEach(node => {
|
||
const pos = positions[node.id];
|
||
if (!pos) return;
|
||
const radius = 0.4 + Math.min(node.mention_count * 0.15, 1.5);
|
||
const color = typeColors[node.type] || defaultColor;
|
||
const geometry = new THREE.SphereGeometry(radius, 16, 12);
|
||
const material = new THREE.MeshPhongMaterial({ color: color, emissive: color, emissiveIntensity: 0.5 + Math.min(node.mention_count * 0.05, 0.3), shininess: 30, transparent: true, opacity: 0 });
|
||
const sphere = new THREE.Mesh(geometry, material);
|
||
sphere.position.set(pos.x, pos.y, pos.z);
|
||
sphere.userData = { nodeId: node.id, nodeData: node };
|
||
scene.add(sphere);
|
||
nodeMeshes.push(sphere);
|
||
// 淡入动画
|
||
fadeInObject(sphere, 500);
|
||
});
|
||
edges.forEach(edge => {
|
||
const pos1 = positions[edge.source], pos2 = positions[edge.target];
|
||
if (!pos1 || !pos2) return;
|
||
const color = edgeColors[edge.relation_type] || defaultEdgeColor;
|
||
const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(pos1.x, pos1.y, pos1.z), new THREE.Vector3(pos2.x, pos2.y, pos2.z)]);
|
||
const material = new THREE.LineBasicMaterial({ color: color, transparent: true, opacity: 0, linewidth: 1 });
|
||
const line = new THREE.Line(geometry, material);
|
||
line.userData = { edgeId: edge.id, edgeData: edge };
|
||
scene.add(line);
|
||
edgeLines.push(line);
|
||
// 淡入动画
|
||
fadeInLine(line, 500);
|
||
});
|
||
const allPositions = Object.values(positions);
|
||
if (allPositions.length > 0) {
|
||
let maxDist = 0;
|
||
allPositions.forEach(pos => { maxDist = Math.max(maxDist, Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z)); });
|
||
camera.position.set(maxDist * 2.2, maxDist * 1.5, maxDist * 2.2);
|
||
controls.target.set(0, 0, 0);
|
||
}
|
||
}
|
||
|
||
function onMouseMove(event) {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||
if (intersects.length > 0) {
|
||
const node = intersects[0].object;
|
||
if (hoveredNode !== node) {
|
||
if (hoveredNode) hoveredNode.scale.set(1, 1, 1);
|
||
hoveredNode = node;
|
||
node.scale.set(1.2, 1.2, 1.2);
|
||
showNodeInfo(node.userData.nodeData);
|
||
}
|
||
} else {
|
||
if (hoveredNode) { hoveredNode.scale.set(1, 1, 1); hoveredNode = null; hideNodeInfo(); }
|
||
}
|
||
}
|
||
|
||
function onMouseClick(event) {
|
||
if (isDragging) return;
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||
if (intersects.length > 0) {
|
||
const node = intersects[0].object;
|
||
if (selectedNode === node) {
|
||
selectedNode = null;
|
||
document.getElementById('node-info').style.display = 'none';
|
||
resetHighlight();
|
||
} else {
|
||
selectedNode = node;
|
||
showNodeInfo(node.userData.nodeData, true);
|
||
highlightNodeConnections(node.userData.nodeData);
|
||
// 通知 ArkTS 节点被点击
|
||
try {
|
||
if (window.nativeBridge && window.nativeBridge.onNodeClick) {
|
||
window.nativeBridge.onNodeClick(node.userData.nodeData.id, node.userData.nodeData.name);
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
} else {
|
||
// 点击空白处恢复
|
||
selectedNode = null;
|
||
document.getElementById('node-info').style.display = 'none';
|
||
resetHighlight();
|
||
}
|
||
}
|
||
|
||
function showNodeInfo(nodeData, isClick = false) {
|
||
document.getElementById('info-name').textContent = nodeData.name;
|
||
document.getElementById('info-type').textContent = nodeData.type;
|
||
document.getElementById('info-mentions').textContent = nodeData.mention_count;
|
||
const linkCount = edges.filter(e => e.source === nodeData.id || e.target === nodeData.id).length;
|
||
document.getElementById('info-links').textContent = linkCount;
|
||
if (isClick) document.getElementById('node-info').style.display = 'block';
|
||
}
|
||
|
||
function hideNodeInfo() { if (!selectedNode) document.getElementById('node-info').style.display = 'none'; }
|
||
|
||
// 淡入动画
|
||
function fadeInObject(obj, duration) {
|
||
const startOpacity = 0;
|
||
const endOpacity = obj.material.opacity !== undefined ? (obj.material.transparent ? obj.material.opacity : 1) : 1;
|
||
obj.material.opacity = startOpacity;
|
||
obj.material.transparent = true;
|
||
const startTime = Date.now();
|
||
function animateFade() {
|
||
const elapsed = Date.now() - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
obj.material.opacity = startOpacity + (endOpacity - startOpacity) * progress;
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animateFade);
|
||
} else {
|
||
obj.material.transparent = endOpacity < 1;
|
||
}
|
||
}
|
||
animateFade();
|
||
}
|
||
|
||
function fadeInLine(line, duration) {
|
||
const startOpacity = 0;
|
||
const endOpacity = 0.4;
|
||
line.material.opacity = startOpacity;
|
||
const startTime = Date.now();
|
||
function animateFade() {
|
||
const elapsed = Date.now() - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
line.material.opacity = startOpacity + (endOpacity - startOpacity) * progress;
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animateFade);
|
||
}
|
||
}
|
||
animateFade();
|
||
}
|
||
|
||
// 通过节点ID高亮节点(供ArkTS调用)
|
||
function highlightNodeById(nodeId) {
|
||
const mesh = nodeMeshes.find(m => m.userData.nodeData.id === nodeId);
|
||
if (mesh) {
|
||
selectedNode = mesh;
|
||
showNodeInfo(mesh.userData.nodeData, true);
|
||
highlightNodeConnections(mesh.userData.nodeData);
|
||
}
|
||
}
|
||
|
||
// 显示节点详情面板(供ArkTS调用)
|
||
function showNodeDetailPanel(detail) {
|
||
// 更新node-info面板显示详细信息
|
||
document.getElementById('info-name').textContent = detail.name;
|
||
document.getElementById('info-type').textContent = detail.type;
|
||
document.getElementById('info-mentions').textContent = detail.mention_count;
|
||
document.getElementById('info-links').textContent = detail.connection_count;
|
||
document.getElementById('node-info').style.display = 'block';
|
||
|
||
// 如果有连接信息,添加到面板
|
||
let detailHtml = `<h3>${detail.name}</h3>`;
|
||
detailHtml += `<p><span class="label">类型:</span> ${detail.type}</p>`;
|
||
detailHtml += `<p><span class="label">提及次数:</span> ${detail.mention_count}</p>`;
|
||
detailHtml += `<p><span class="label">连接数:</span> ${detail.connection_count}</p>`;
|
||
if (detail.connections && detail.connections.length > 0) {
|
||
detailHtml += `<p><span class="label">连接关系:</span></p><ul style="margin-left: 15px; font-size: 12px;">`;
|
||
detail.connections.forEach(conn => {
|
||
detailHtml += `<li>${conn.type}: ${conn.target_name}</li>`;
|
||
});
|
||
detailHtml += `</ul>`;
|
||
}
|
||
document.getElementById('node-info').innerHTML = detailHtml;
|
||
}
|
||
|
||
// 节点拖拽功能
|
||
function initDragFunctionality() {
|
||
let dragStartPos = { x: 0, y: 0 };
|
||
|
||
renderer.domElement.addEventListener('mousedown', (event) => {
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||
if (intersects.length > 0) {
|
||
isDragging = false;
|
||
dragNode = intersects[0].object;
|
||
dragStartPos = { x: event.clientX, y: event.clientY };
|
||
originalPhysicsState = true; // 暂停物理模拟
|
||
}
|
||
});
|
||
|
||
renderer.domElement.addEventListener('mousemove', (event) => {
|
||
if (dragNode) {
|
||
const dx = event.clientX - dragStartPos.x;
|
||
const dy = event.clientY - dragStartPos.y;
|
||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||
isDragging = true;
|
||
}
|
||
if (isDragging) {
|
||
// 将屏幕坐标转换为3D空间
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||
const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||
const vector = new THREE.Vector3(mouseX, mouseY, 0.5);
|
||
vector.unproject(camera);
|
||
const dir = vector.sub(camera.position).normalize();
|
||
const distance = -camera.position.z / dir.z;
|
||
const newPos = camera.position.clone().add(dir.multiplyScalar(distance));
|
||
dragNode.position.copy(newPos);
|
||
// 更新存储的位置
|
||
if (nodePositions[dragNode.userData.nodeId]) {
|
||
nodePositions[dragNode.userData.nodeId] = { x: newPos.x, y: newPos.y, z: newPos.z };
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
renderer.domElement.addEventListener('mouseup', () => {
|
||
if (dragNode) {
|
||
// 恢复物理模拟
|
||
dragNode = null;
|
||
isDragging = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Touch事件支持
|
||
function initTouchEvents() {
|
||
let touchStart = null;
|
||
let touchStartDistance = 0;
|
||
let touchStartPos = { x: 0, y: 0 };
|
||
let isTouchDrag = false;
|
||
|
||
renderer.domElement.addEventListener('touchstart', (event) => {
|
||
event.preventDefault();
|
||
if (event.touches.length === 1) {
|
||
const touch = event.touches[0];
|
||
touchStartPos = { x: touch.clientX, y: touch.clientY };
|
||
isTouchDrag = false;
|
||
|
||
// 模拟鼠标事件用于射线检测
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
|
||
mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
|
||
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||
if (intersects.length > 0) {
|
||
const node = intersects[0].object;
|
||
if (selectedNode === node) {
|
||
selectedNode = null;
|
||
document.getElementById('node-info').style.display = 'none';
|
||
resetHighlight();
|
||
} else {
|
||
selectedNode = node;
|
||
showNodeInfo(node.userData.nodeData, true);
|
||
highlightNodeConnections(node.userData.nodeData);
|
||
try {
|
||
if (window.nativeBridge && window.nativeBridge.onNodeClick) {
|
||
window.nativeBridge.onNodeClick(node.userData.nodeData.id, node.userData.nodeData.name);
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
}
|
||
} else if (event.touches.length === 2) {
|
||
// 双指缩放
|
||
const dx = event.touches[0].clientX - event.touches[1].clientX;
|
||
const dy = event.touches[0].clientY - event.touches[1].clientY;
|
||
touchStartDistance = Math.sqrt(dx*dx + dy*dy);
|
||
}
|
||
}, { passive: false });
|
||
|
||
renderer.domElement.addEventListener('touchmove', (event) => {
|
||
event.preventDefault();
|
||
if (event.touches.length === 1 && controls) {
|
||
const touch = event.touches[0];
|
||
const dx = touch.clientX - touchStartPos.x;
|
||
const dy = touch.clientY - touchStartPos.y;
|
||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||
isTouchDrag = true;
|
||
}
|
||
// 模拟OrbitControls的鼠标移动
|
||
if (isTouchDrag) {
|
||
const rotateSpeed = 0.005;
|
||
controls.rotateLeft(-dx * rotateSpeed);
|
||
controls.rotateUp(-dy * rotateSpeed);
|
||
controls.update();
|
||
touchStartPos = { x: touch.clientX, y: touch.clientY };
|
||
}
|
||
} else if (event.touches.length === 2 && controls) {
|
||
// 双指缩放
|
||
const dx = event.touches[0].clientX - event.touches[1].clientX;
|
||
const dy = event.touches[0].clientY - event.touches[1].clientY;
|
||
const distance = Math.sqrt(dx*dx + dy*dy);
|
||
const scale = touchStartDistance / distance;
|
||
camera.position.multiplyScalar(scale);
|
||
controls.update();
|
||
touchStartDistance = distance;
|
||
}
|
||
}, { passive: false });
|
||
|
||
renderer.domElement.addEventListener('touchend', (event) => {
|
||
if (event.touches.length === 0) {
|
||
isTouchDrag = false;
|
||
}
|
||
}, { passive: false });
|
||
}
|
||
|
||
// 搜索和类型过滤
|
||
function applyFilters() {
|
||
nodeMeshes.forEach(mesh => {
|
||
const nodeData = mesh.userData.nodeData;
|
||
const nameMatch = nodeData.name.toLowerCase().includes(searchTerm);
|
||
const typeMatch = activeTypeFilter === '全部' || nodeData.type === activeTypeFilter;
|
||
|
||
if (nameMatch && typeMatch) {
|
||
mesh.material.transparent = false;
|
||
mesh.material.opacity = 1;
|
||
mesh.scale.setScalar(1);
|
||
} else {
|
||
mesh.material.transparent = true;
|
||
mesh.material.opacity = 0.2;
|
||
mesh.scale.setScalar(0.8);
|
||
}
|
||
});
|
||
// 同时过滤边
|
||
edgeLines.forEach(line => {
|
||
const edgeData = line.userData.edgeData;
|
||
const sourceNode = nodes.find(n => n.id === edgeData.source);
|
||
const targetNode = nodes.find(n => n.id === edgeData.target);
|
||
const sourceMatch = sourceNode && sourceNode.name.toLowerCase().includes(searchTerm) && (activeTypeFilter === '全部' || sourceNode.type === activeTypeFilter);
|
||
const targetMatch = targetNode && targetNode.name.toLowerCase().includes(searchTerm) && (activeTypeFilter === '全部' || targetNode.type === activeTypeFilter);
|
||
|
||
if ((sourceMatch || targetMatch) && searchTerm === '' && activeTypeFilter === '全部') {
|
||
line.material.transparent = true;
|
||
line.material.opacity = 0.4;
|
||
} else if (sourceMatch || targetMatch) {
|
||
line.material.transparent = true;
|
||
line.material.opacity = 0.6;
|
||
} else {
|
||
line.material.transparent = true;
|
||
line.material.opacity = 0.1;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 连接高亮
|
||
function highlightNodeConnections(nodeData) {
|
||
const connectedNodeIds = new Set();
|
||
const connectedEdgeIds = new Set();
|
||
|
||
// 找出所有连接的节点和边
|
||
edges.forEach(edge => {
|
||
if (edge.source === nodeData.id || edge.target === nodeData.id) {
|
||
connectedNodeIds.add(edge.source);
|
||
connectedNodeIds.add(edge.target);
|
||
connectedEdgeIds.add(edge.id);
|
||
}
|
||
});
|
||
|
||
nodeMeshes.forEach(mesh => {
|
||
const meshNodeId = mesh.userData.nodeData.id;
|
||
if (meshNodeId === nodeData.id) {
|
||
// 选中的节点
|
||
mesh.material.emissiveIntensity = 1.0;
|
||
mesh.scale.setScalar(1.5);
|
||
} else if (connectedNodeIds.has(meshNodeId)) {
|
||
// 直接连接的节点
|
||
mesh.material.emissiveIntensity = 0.8;
|
||
mesh.scale.setScalar(1.3);
|
||
mesh.material.transparent = false;
|
||
mesh.material.opacity = 1;
|
||
} else {
|
||
// 无关系的节点
|
||
mesh.material.transparent = true;
|
||
mesh.material.opacity = 0.15;
|
||
mesh.material.emissiveIntensity = 0.2;
|
||
mesh.scale.setScalar(0.9);
|
||
}
|
||
});
|
||
|
||
edgeLines.forEach(line => {
|
||
if (connectedEdgeIds.has(line.userData.edgeData.id)) {
|
||
line.material.transparent = true;
|
||
line.material.opacity = 0.8;
|
||
line.material.linewidth = 2;
|
||
} else {
|
||
line.material.transparent = true;
|
||
line.material.opacity = 0.1;
|
||
line.material.linewidth = 1;
|
||
}
|
||
});
|
||
}
|
||
|
||
function resetHighlight() {
|
||
nodeMeshes.forEach(mesh => {
|
||
const nodeData = mesh.userData.nodeData;
|
||
const nameMatch = nodeData.name.toLowerCase().includes(searchTerm);
|
||
const typeMatch = activeTypeFilter === '全部' || nodeData.type === activeTypeFilter;
|
||
|
||
mesh.material.emissiveIntensity = 0.5 + Math.min(nodeData.mention_count * 0.05, 0.3);
|
||
if (nameMatch && typeMatch) {
|
||
mesh.material.transparent = false;
|
||
mesh.material.opacity = 1;
|
||
mesh.scale.setScalar(1);
|
||
} else {
|
||
mesh.material.transparent = true;
|
||
mesh.material.opacity = 0.2;
|
||
mesh.scale.setScalar(0.8);
|
||
}
|
||
});
|
||
edgeLines.forEach(line => {
|
||
line.material.transparent = true;
|
||
line.material.opacity = 0.4;
|
||
line.material.linewidth = 1;
|
||
});
|
||
}
|
||
|
||
// 边标签显示
|
||
function onEdgeHoverCheck(event) {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(mouse, camera);
|
||
|
||
// 检查边悬停
|
||
const edgeIntersects = raycaster.intersectObjects(edgeLines);
|
||
if (edgeIntersects.length > 0) {
|
||
const edge = edgeIntersects[0].object;
|
||
const edgeData = edge.userData.edgeData;
|
||
edgeLabelEl.textContent = edgeData.relation_type || '关系';
|
||
edgeLabelEl.style.display = 'block';
|
||
edgeLabelEl.style.left = (event.clientX - rect.left + 10) + 'px';
|
||
edgeLabelEl.style.top = (event.clientY - rect.top - 10) + 'px';
|
||
} else {
|
||
edgeLabelEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function onWindowResize() {
|
||
camera.aspect = window.innerWidth / window.innerHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||
}
|
||
|
||
function animate() {
|
||
animationId = requestAnimationFrame(animate);
|
||
const time = Date.now() * 0.001;
|
||
highlightPulse = (highlightPulse + 0.02) % (Math.PI * 2);
|
||
controls.update();
|
||
if (starField) starField.rotation.y += 0.0001;
|
||
if (hoveredNode) { const pulse = 1 + Math.sin(highlightPulse * 3) * 0.05; hoveredNode.scale.set(pulse * 1.2, pulse * 1.2, pulse * 1.2); }
|
||
renderer.render(scene, camera);
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html> |