Files
TrulyMEM-TrueHumanMEM-local/entry/src/main/resources/rawfile/graph.html

764 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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