knowledge_graph_3d.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>申勤物业元知识图谱 3D</title>
  7. <style>
  8. :root {
  9. --bg: #f4f7fb;
  10. --panel: #ffffff;
  11. --border: #d9e1ec;
  12. --primary: #2f5597;
  13. --text: #22303f;
  14. --muted: #7a8794;
  15. }
  16. * { box-sizing: border-box; }
  17. body {
  18. margin: 0;
  19. font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
  20. background: var(--bg);
  21. color: var(--text);
  22. height: 100vh;
  23. display: flex;
  24. flex-direction: column;
  25. overflow: hidden;
  26. }
  27. header {
  28. height: 58px;
  29. padding: 0 18px;
  30. display: flex;
  31. align-items: center;
  32. gap: 10px;
  33. border-bottom: 1px solid var(--border);
  34. background: #fff;
  35. }
  36. header h1 {
  37. margin: 0;
  38. font-size: 18px;
  39. color: var(--primary);
  40. }
  41. .spacer { flex: 1; }
  42. button {
  43. border: 1px solid var(--border);
  44. background: #fff;
  45. border-radius: 9px;
  46. padding: 8px 12px;
  47. cursor: pointer;
  48. font-size: 13px;
  49. font-family: inherit;
  50. }
  51. button:hover { background: #eef3fb; }
  52. .workspace {
  53. flex: 1;
  54. position: relative;
  55. overflow: hidden;
  56. }
  57. #graph3D {
  58. position: absolute;
  59. inset: 0;
  60. background:
  61. radial-gradient(circle at 50% 42%, rgba(255, 255, 255, 0.88) 0%, rgba(239, 245, 252, 0.86) 45%, rgba(222, 232, 245, 0.92) 100%),
  62. radial-gradient(circle at 16% 18%, rgba(47, 85, 151, 0.16), transparent 32%),
  63. radial-gradient(circle at 82% 16%, rgba(14, 116, 144, 0.14), transparent 28%),
  64. radial-gradient(circle at 72% 84%, rgba(124, 58, 237, 0.12), transparent 34%),
  65. radial-gradient(circle at 25% 78%, rgba(217, 119, 6, 0.08), transparent 30%),
  66. linear-gradient(rgba(47, 85, 151, 0.045) 1px, transparent 1px),
  67. linear-gradient(90deg, rgba(47, 85, 151, 0.045) 1px, transparent 1px);
  68. background-size: auto, auto, auto, auto, auto, 42px 42px, 42px 42px;
  69. }
  70. #graph3D canvas {
  71. display: block;
  72. cursor: grab;
  73. }
  74. #graph3D canvas:active {
  75. cursor: grabbing;
  76. }
  77. #nodePanel {
  78. position: absolute;
  79. top: 16px;
  80. right: 16px;
  81. width: 330px;
  82. max-height: calc(100% - 32px);
  83. overflow: auto;
  84. background: var(--panel);
  85. border: 1px solid var(--border);
  86. border-radius: 14px;
  87. box-shadow: 0 14px 36px rgba(35, 48, 63, 0.16);
  88. padding: 16px;
  89. display: none;
  90. }
  91. #nodePanel.show { display: block; }
  92. #nodePanel h2 { margin: 0 0 10px; font-size: 20px; color: var(--primary); }
  93. .meta-list {
  94. display: grid;
  95. grid-template-columns: 96px 1fr;
  96. row-gap: 8px;
  97. column-gap: 8px;
  98. font-size: 13px;
  99. }
  100. .meta-list dt { color: var(--muted); }
  101. .meta-list dd {
  102. margin: 0;
  103. word-break: break-word;
  104. }
  105. .attrs-title {
  106. margin: 16px 0 8px;
  107. font-size: 14px;
  108. color: var(--primary);
  109. }
  110. .attrs {
  111. display: flex;
  112. flex-wrap: wrap;
  113. gap: 7px;
  114. margin-top: 0;
  115. }
  116. .attr-tag {
  117. background: #eef3fb;
  118. color: var(--primary);
  119. border: 1px solid #ccdbf5;
  120. border-radius: 999px;
  121. padding: 5px 9px;
  122. font-size: 12px;
  123. }
  124. #errorOverlay {
  125. position: absolute;
  126. inset: 0;
  127. display: none;
  128. align-items: center;
  129. justify-content: center;
  130. color: #c0392b;
  131. background: rgba(244, 247, 251, 0.94);
  132. font-size: 15px;
  133. text-align: center;
  134. padding: 24px;
  135. }
  136. #errorOverlay.show { display: flex; }
  137. .chat-dock {
  138. position: absolute;
  139. right: 18px;
  140. bottom: 18px;
  141. width: 430px;
  142. height: 620px;
  143. max-width: calc(100% - 36px);
  144. max-height: calc(100% - 36px);
  145. display: flex;
  146. flex-direction: column;
  147. background: #ffffff;
  148. border: 1px solid var(--border);
  149. border-radius: 16px;
  150. box-shadow: 0 18px 50px rgba(35, 48, 63, 0.18);
  151. overflow: hidden;
  152. }
  153. .chat-resize-handle {
  154. position: absolute;
  155. z-index: 6;
  156. touch-action: none;
  157. user-select: none;
  158. -webkit-user-select: none;
  159. }
  160. .chat-resize-handle.nw { top: 0; left: 0; width: 16px; height: 16px; cursor: nwse-resize; }
  161. .chat-resize-handle.n { top: 0; left: 20px; right: 20px; height: 8px; cursor: ns-resize; }
  162. .chat-resize-handle.ne { top: 0; right: 0; width: 16px; height: 16px; cursor: nesw-resize; }
  163. .chat-resize-handle.e { top: 20px; right: 0; bottom: 20px; width: 8px; cursor: ew-resize; }
  164. .chat-resize-handle.se { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; }
  165. .chat-resize-handle.s { bottom: 0; left: 20px; right: 20px; height: 8px; cursor: ns-resize; }
  166. .chat-resize-handle.sw { bottom: 0; left: 0; width: 16px; height: 16px; cursor: nesw-resize; }
  167. .chat-resize-handle.w { top: 20px; left: 0; bottom: 20px; width: 8px; cursor: ew-resize; }
  168. .chat-dock.collapsed {
  169. display: none;
  170. }
  171. .chat-fab {
  172. position: absolute;
  173. right: 18px;
  174. bottom: 18px;
  175. width: 58px;
  176. height: 58px;
  177. border-radius: 50%;
  178. border: 2px solid var(--primary);
  179. padding: 0;
  180. overflow: hidden;
  181. cursor: pointer;
  182. box-shadow: 0 10px 24px rgba(35, 48, 63, 0.22);
  183. z-index: 7;
  184. display: none;
  185. }
  186. .chat-fab.show {
  187. display: block;
  188. }
  189. .chat-fab img {
  190. width: 100%;
  191. height: 100%;
  192. object-fit: cover;
  193. display: block;
  194. }
  195. .chat-dock.collapsed .chat-resize-handle {
  196. display: none;
  197. }
  198. .chat-dock-header {
  199. height: 42px;
  200. padding: 0 24px;
  201. display: flex;
  202. align-items: center;
  203. gap: 8px;
  204. background: var(--primary);
  205. color: #fff;
  206. font-size: 14px;
  207. cursor: move;
  208. user-select: none;
  209. -webkit-user-select: none;
  210. touch-action: none;
  211. }
  212. .chat-dock-header .spacer { flex: 1; }
  213. .chat-toggle {
  214. background: rgba(255, 255, 255, 0.15);
  215. color: #fff;
  216. border: 1px solid rgba(255, 255, 255, 0.28);
  217. padding: 5px 9px;
  218. border-radius: 8px;
  219. cursor: pointer;
  220. }
  221. #chatFrame {
  222. flex: 1;
  223. border: 0;
  224. display: block;
  225. }
  226. .chat-dock.collapsed #chatFrame {
  227. display: none;
  228. }
  229. </style>
  230. </head>
  231. <body>
  232. <header>
  233. <h1>申勤物业元知识图谱 3D</h1>
  234. <div class="spacer"></div>
  235. <button id="resetBtn">重置视角</button>
  236. <button onclick="location.href='index.html'">返回聊天</button>
  237. </header>
  238. <div class="workspace">
  239. <div id="graph3D"></div>
  240. <div id="errorOverlay"></div>
  241. <aside id="nodePanel">
  242. <h2 id="panelTitle"></h2>
  243. <dl class="meta-list" id="panelMeta"></dl>
  244. <h3 class="attrs-title">属性</h3>
  245. <div class="attrs" id="panelAttrs"></div>
  246. </aside>
  247. <button id="chatFab" class="chat-fab" title="展开聊天窗口">
  248. <img src="微信图片_20260817170012_14_2.png" alt="打开聊天" />
  249. </button>
  250. <div id="chatDock" class="chat-dock">
  251. <div class="chat-resize-handle nw" data-resize="nw" title="缩放"></div>
  252. <div class="chat-resize-handle n" data-resize="n" title="上下缩放"></div>
  253. <div class="chat-resize-handle ne" data-resize="ne" title="缩放"></div>
  254. <div class="chat-resize-handle e" data-resize="e" title="左右缩放"></div>
  255. <div class="chat-resize-handle se" data-resize="se" title="缩放"></div>
  256. <div class="chat-resize-handle s" data-resize="s" title="上下缩放"></div>
  257. <div class="chat-resize-handle sw" data-resize="sw" title="缩放"></div>
  258. <div class="chat-resize-handle w" data-resize="w" title="左右缩放"></div>
  259. <div class="chat-dock-header">
  260. <span>申勤物业知识助手</span>
  261. <div class="spacer"></div>
  262. <button id="chatToggle" class="chat-toggle">收起</button>
  263. </div>
  264. <iframe id="chatFrame" src="index.html" title="申勤物业知识助手"></iframe>
  265. </div>
  266. </div>
  267. <script src="vendor/three.min.js"></script>
  268. <script src="vendor/OrbitControls.js"></script>
  269. <script>
  270. const $ = (id) => document.getElementById(id);
  271. const container = $("graph3D");
  272. const nodePanel = $("nodePanel");
  273. const panelTitle = $("panelTitle");
  274. const panelMeta = $("panelMeta");
  275. const panelAttrs = $("panelAttrs");
  276. const chatDock = $("chatDock");
  277. const chatToggle = $("chatToggle");
  278. const errorOverlay = $("errorOverlay");
  279. const chatResizeHandles = Array.from(document.querySelectorAll(".chat-resize-handle"));
  280. const chatHeader = document.querySelector(".chat-dock-header");
  281. const chatFab = $("chatFab");
  282. let resizeState = null;
  283. let chatDragState = null;
  284. const departmentColors = {
  285. "市场部": "#d97706",
  286. "人事部": "#7c3aed",
  287. "运营部": "#2f855a",
  288. "财务部": "#c0392b",
  289. "采购部": "#0e7490",
  290. };
  291. const fallbackColors = ["#4c78a8", "#b45309", "#6d28d9", "#15803d", "#be123c", "#0369a1", "#b45309"];
  292. function apiBase() {
  293. if (location.protocol === "http:" || location.protocol === "https:") {
  294. return location.origin;
  295. }
  296. return "http://127.0.0.1:8000";
  297. }
  298. function showError(message) {
  299. errorOverlay.textContent = message;
  300. errorOverlay.classList.add("show");
  301. }
  302. function colorForNode(node) {
  303. const department = Array.isArray(node.department) && node.department.length
  304. ? node.department[0]
  305. : "未分类";
  306. const known = departmentColors[department];
  307. if (known) return known;
  308. let hash = 0;
  309. const key = String(node.id || node.name || "");
  310. for (let i = 0; i < key.length; i += 1) {
  311. hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
  312. }
  313. return fallbackColors[hash % fallbackColors.length];
  314. }
  315. function hexToNumber(hex) {
  316. return parseInt(hex.slice(1), 16);
  317. }
  318. function showNode(node) {
  319. panelTitle.textContent = node.name;
  320. panelMeta.innerHTML = "";
  321. const attributes = node.attributes || [];
  322. const meta = [
  323. ["所属部门", (node.department || []).join("、")],
  324. ["是否中心节点", node.is_hub ? "是" : "否"],
  325. ["是否启用", node.active ? "是" : "否"],
  326. ["是否建议节点", node.suggested ? "是" : "否"],
  327. ["属性数量", String(attributes.length)],
  328. ];
  329. const description = (node.description || "").trim();
  330. const attributesText = attributes.join(" / ");
  331. if (description && description.replace(/\s+/g, "") !== attributesText.replace(/\s+/g, "")) {
  332. meta.push(["说明", description]);
  333. }
  334. meta.forEach(([k, v]) => {
  335. const dt = document.createElement("dt");
  336. dt.textContent = k;
  337. const dd = document.createElement("dd");
  338. dd.textContent = v;
  339. panelMeta.appendChild(dt);
  340. panelMeta.appendChild(dd);
  341. });
  342. panelAttrs.innerHTML = "";
  343. if (!attributes.length) {
  344. const empty = document.createElement("span");
  345. empty.className = "attr-tag";
  346. empty.textContent = "暂无属性";
  347. panelAttrs.appendChild(empty);
  348. } else {
  349. attributes.forEach((attr) => {
  350. const tag = document.createElement("span");
  351. tag.className = "attr-tag";
  352. tag.textContent = attr;
  353. tag.title = attr;
  354. panelAttrs.appendChild(tag);
  355. });
  356. }
  357. nodePanel.classList.add("show");
  358. }
  359. function init() {
  360. if (typeof THREE === "undefined" || typeof THREE.OrbitControls === "undefined") {
  361. showError("3D 渲染库加载失败,请确认 html/vendor 目录下的 three.min.js 和 OrbitControls.js 存在。");
  362. return;
  363. }
  364. const scene = new THREE.Scene();
  365. const camera = new THREE.PerspectiveCamera(
  366. 50,
  367. container.clientWidth / Math.max(container.clientHeight, 1),
  368. 0.1,
  369. 5000,
  370. );
  371. camera.position.set(0, 180, 780);
  372. const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  373. renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  374. renderer.setSize(container.clientWidth, container.clientHeight);
  375. renderer.setClearColor(0xf4f7fb, 0);
  376. container.appendChild(renderer.domElement);
  377. const controls = new THREE.OrbitControls(camera, renderer.domElement);
  378. controls.enableDamping = true;
  379. controls.dampingFactor = 0.08;
  380. controls.minDistance = 240;
  381. controls.maxDistance = 1500;
  382. controls.autoRotate = true;
  383. controls.autoRotateSpeed = 0.75;
  384. controls.target.set(0, 0, 0);
  385. const ambient = new THREE.AmbientLight(0xffffff, 0.78);
  386. scene.add(ambient);
  387. const keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
  388. keyLight.position.set(360, 520, 520);
  389. scene.add(keyLight);
  390. const fillLight = new THREE.DirectionalLight(0xffffff, 0.35);
  391. fillLight.position.set(-420, -180, -360);
  392. scene.add(fillLight);
  393. const graphGroup = new THREE.Group();
  394. scene.add(graphGroup);
  395. const nodeMeshes = new Map();
  396. const edgeItems = [];
  397. function buildLayout(nodes) {
  398. const positions = new Map();
  399. const n = nodes.length || 1;
  400. const R = 290;
  401. const goldenAngle = Math.PI * (3 - Math.sqrt(5));
  402. nodes.forEach((node, i) => {
  403. let y;
  404. let radiusAtY;
  405. if (n === 1) {
  406. y = 0;
  407. radiusAtY = 1;
  408. } else {
  409. y = 1 - (i / (n - 1)) * 2;
  410. radiusAtY = Math.sqrt(Math.max(0, 1 - y * y));
  411. }
  412. const theta = goldenAngle * i;
  413. positions.set(node.id, new THREE.Vector3(
  414. Math.cos(theta) * radiusAtY * R,
  415. y * R * 0.72,
  416. Math.sin(theta) * radiusAtY * R,
  417. ));
  418. });
  419. return positions;
  420. }
  421. function makeTextSprite(text, options) {
  422. const opts = options || {};
  423. const width = opts.width || 256;
  424. const height = opts.height || 64;
  425. const canvas = document.createElement("canvas");
  426. canvas.width = width;
  427. canvas.height = height;
  428. const ctx = canvas.getContext("2d");
  429. ctx.clearRect(0, 0, width, height);
  430. ctx.font = `${opts.weight || 600} ${opts.fontSize || 28}px "Microsoft YaHei", "PingFang SC", sans-serif`;
  431. ctx.textAlign = "center";
  432. ctx.textBaseline = "middle";
  433. ctx.lineWidth = opts.strokeWidth || 6;
  434. ctx.strokeStyle = opts.stroke || "rgba(255, 255, 255, 0.95)";
  435. ctx.strokeText(text, width / 2, height / 2);
  436. ctx.fillStyle = opts.color || "#1f2a3a";
  437. ctx.fillText(text, width / 2, height / 2);
  438. const texture = new THREE.CanvasTexture(canvas);
  439. texture.minFilter = THREE.LinearFilter;
  440. const material = new THREE.SpriteMaterial({
  441. map: texture,
  442. depthTest: false,
  443. depthWrite: false,
  444. transparent: true,
  445. });
  446. const sprite = new THREE.Sprite(material);
  447. sprite.scale.set(opts.scaleX || 120, opts.scaleY || 30, 1);
  448. return sprite;
  449. }
  450. function selfLoopPoints(position, offset) {
  451. const points = [];
  452. const steps = 72;
  453. for (let i = 0; i <= steps; i += 1) {
  454. const angle = (Math.PI * 2 * i) / steps;
  455. points.push(new THREE.Vector3(
  456. position.x + Math.cos(angle) * 22,
  457. position.y + offset,
  458. position.z + Math.sin(angle) * 22,
  459. ));
  460. }
  461. return points;
  462. }
  463. function buildGraph(data) {
  464. const nodes = data?.nodes || [];
  465. const relations = data?.relations || [];
  466. const positions = buildLayout(nodes);
  467. const edgeMaterial = new THREE.LineBasicMaterial({
  468. color: 0x8fa3c0,
  469. transparent: true,
  470. opacity: 0.52,
  471. });
  472. const selfLoopCounts = {};
  473. relations.forEach((rel) => {
  474. const a = positions.get(rel.source);
  475. const b = positions.get(rel.target);
  476. if (!a || !b) return;
  477. if (rel.source === rel.target) {
  478. const count = selfLoopCounts[rel.source] || 0;
  479. selfLoopCounts[rel.source] = count + 1;
  480. const offset = 34 + count * 22;
  481. const geometry = new THREE.BufferGeometry().setFromPoints(selfLoopPoints(a, offset));
  482. const line = new THREE.Line(geometry, edgeMaterial);
  483. graphGroup.add(line);
  484. const label = makeTextSprite(rel.type, {
  485. fontSize: 20,
  486. color: "#1f2a3a",
  487. stroke: "rgba(244, 247, 251, 0.95)",
  488. strokeWidth: 5,
  489. scaleX: 82,
  490. scaleY: 20,
  491. });
  492. label.position.set(a.x, a.y + offset + 12, a.z);
  493. graphGroup.add(label);
  494. edgeItems.push({ line, label, a, b, self: true, offset });
  495. } else {
  496. const geometry = new THREE.BufferGeometry().setFromPoints([a, b]);
  497. const line = new THREE.Line(geometry, edgeMaterial);
  498. graphGroup.add(line);
  499. const mid = new THREE.Vector3().addVectors(a, b).multiplyScalar(0.5);
  500. const label = makeTextSprite(rel.type, {
  501. fontSize: 20,
  502. color: "#1f2a3a",
  503. stroke: "rgba(244, 247, 251, 0.95)",
  504. strokeWidth: 5,
  505. scaleX: 82,
  506. scaleY: 20,
  507. });
  508. label.position.copy(mid);
  509. graphGroup.add(label);
  510. edgeItems.push({ line, label, a, b, self: false, offset: 0 });
  511. }
  512. });
  513. const sphereGeometry = new THREE.SphereGeometry(1, 40, 28);
  514. nodes.forEach((node) => {
  515. const p = positions.get(node.id);
  516. if (!p) return;
  517. const radius = node.is_hub ? 22 : 14;
  518. const material = new THREE.MeshPhongMaterial({
  519. color: hexToNumber(colorForNode(node)),
  520. emissive: new THREE.Color(hexToNumber(colorForNode(node))).multiplyScalar(0.16),
  521. shininess: 28,
  522. specular: 0x556677,
  523. });
  524. const mesh = new THREE.Mesh(sphereGeometry, material);
  525. mesh.position.copy(p);
  526. mesh.scale.setScalar(radius);
  527. mesh.userData = { node };
  528. graphGroup.add(mesh);
  529. nodeMeshes.set(node.id, mesh);
  530. const label = makeTextSprite(node.name, {
  531. fontSize: 28,
  532. color: "#1f2a3a",
  533. stroke: "rgba(255, 255, 255, 0.95)",
  534. strokeWidth: 6,
  535. scaleX: 132,
  536. scaleY: 34,
  537. });
  538. label.position.set(p.x, p.y + radius + 18, p.z);
  539. graphGroup.add(label);
  540. });
  541. }
  542. const raycaster = new THREE.Raycaster();
  543. const pointer = new THREE.Vector2();
  544. let pointerDown = null;
  545. function pickNode(event) {
  546. const rect = renderer.domElement.getBoundingClientRect();
  547. pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  548. pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  549. raycaster.setFromCamera(pointer, camera);
  550. const hits = raycaster.intersectObjects(Array.from(nodeMeshes.values()), false);
  551. if (hits.length) {
  552. showNode(hits[0].object.userData.node);
  553. }
  554. }
  555. renderer.domElement.addEventListener("pointerdown", (event) => {
  556. pointerDown = { x: event.clientX, y: event.clientY };
  557. });
  558. renderer.domElement.addEventListener("pointerup", (event) => {
  559. if (!pointerDown) return;
  560. const moved = Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y);
  561. pointerDown = null;
  562. if (moved < 6) pickNode(event);
  563. });
  564. renderer.domElement.addEventListener("pointermove", (event) => {
  565. const rect = renderer.domElement.getBoundingClientRect();
  566. pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  567. pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  568. raycaster.setFromCamera(pointer, camera);
  569. const hits = raycaster.intersectObjects(Array.from(nodeMeshes.values()), false);
  570. renderer.domElement.style.cursor = hits.length ? "pointer" : "grab";
  571. });
  572. $("resetBtn").addEventListener("click", () => {
  573. camera.position.set(0, 180, 780);
  574. controls.target.set(0, 0, 0);
  575. controls.update();
  576. });
  577. window.addEventListener("resize", () => {
  578. const width = container.clientWidth;
  579. const height = container.clientHeight;
  580. camera.aspect = width / Math.max(height, 1);
  581. camera.updateProjectionMatrix();
  582. renderer.setSize(width, height);
  583. });
  584. async function load() {
  585. const res = await fetch(`${apiBase()}/output/meta_graph_schema_display.json`);
  586. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  587. const data = await res.json();
  588. buildGraph(data);
  589. }
  590. load().catch((err) => {
  591. showError(`图谱加载失败:${err.message}`);
  592. });
  593. function animate() {
  594. requestAnimationFrame(animate);
  595. controls.update();
  596. renderer.render(scene, camera);
  597. }
  598. animate();
  599. }
  600. function ensureDockPosition() {
  601. const workspace = chatDock.parentElement;
  602. const dockRect = chatDock.getBoundingClientRect();
  603. const workspaceRect = workspace.getBoundingClientRect();
  604. const left = dockRect.left - workspaceRect.left;
  605. const top = dockRect.top - workspaceRect.top;
  606. setDockPosition(left, top);
  607. return { left, top };
  608. }
  609. function endChatResize(e) {
  610. if (!resizeState) return;
  611. const handle = resizeState.handle;
  612. resizeState = null;
  613. if (e && handle && handle.hasPointerCapture && handle.hasPointerCapture(e.pointerId)) {
  614. handle.releasePointerCapture(e.pointerId);
  615. }
  616. }
  617. chatResizeHandles.forEach((handle) => {
  618. handle.addEventListener("pointerdown", (e) => {
  619. if (chatDock.classList.contains("collapsed")) return;
  620. const position = ensureDockPosition();
  621. resizeState = {
  622. handle,
  623. direction: handle.getAttribute("data-resize") || "",
  624. pointerId: e.pointerId,
  625. startX: e.clientX,
  626. startY: e.clientY,
  627. startLeft: position.left,
  628. startTop: position.top,
  629. startWidth: chatDock.offsetWidth,
  630. startHeight: chatDock.offsetHeight,
  631. };
  632. handle.setPointerCapture(e.pointerId);
  633. if (e.cancelable) e.preventDefault();
  634. });
  635. handle.addEventListener("pointermove", (e) => {
  636. if (!resizeState || resizeState.handle !== handle || e.pointerId !== resizeState.pointerId) return;
  637. if (e.cancelable) e.preventDefault();
  638. const workspace = chatDock.parentElement;
  639. const direction = resizeState.direction;
  640. const minWidth = 280;
  641. const minHeight = 320;
  642. let left = resizeState.startLeft;
  643. let top = resizeState.startTop;
  644. let width = resizeState.startWidth;
  645. let height = resizeState.startHeight;
  646. const dx = e.clientX - resizeState.startX;
  647. const dy = e.clientY - resizeState.startY;
  648. if (direction.includes("e")) {
  649. width = Math.min(workspace.clientWidth - resizeState.startLeft, Math.max(minWidth, resizeState.startWidth + dx));
  650. }
  651. if (direction.includes("w")) {
  652. width = Math.min(resizeState.startLeft + resizeState.startWidth, Math.max(minWidth, resizeState.startWidth - dx));
  653. left = resizeState.startLeft + resizeState.startWidth - width;
  654. }
  655. if (direction.includes("s")) {
  656. height = Math.min(workspace.clientHeight - resizeState.startTop, Math.max(minHeight, resizeState.startHeight + dy));
  657. }
  658. if (direction.includes("n")) {
  659. height = Math.min(resizeState.startTop + resizeState.startHeight, Math.max(minHeight, resizeState.startHeight - dy));
  660. top = resizeState.startTop + resizeState.startHeight - height;
  661. }
  662. chatDock.style.left = `${left}px`;
  663. chatDock.style.top = `${top}px`;
  664. chatDock.style.width = `${width}px`;
  665. chatDock.style.height = `${height}px`;
  666. });
  667. handle.addEventListener("pointerup", endChatResize);
  668. handle.addEventListener("pointercancel", endChatResize);
  669. handle.addEventListener("lostpointercapture", endChatResize);
  670. });
  671. function setDockPosition(left, top) {
  672. chatDock.style.left = `${left}px`;
  673. chatDock.style.top = `${top}px`;
  674. chatDock.style.right = "auto";
  675. chatDock.style.bottom = "auto";
  676. }
  677. function endChatDrag(e) {
  678. if (!chatDragState) return;
  679. chatDragState = null;
  680. if (e && chatHeader.hasPointerCapture && chatHeader.hasPointerCapture(e.pointerId)) {
  681. chatHeader.releasePointerCapture(e.pointerId);
  682. }
  683. }
  684. chatHeader.addEventListener("pointerdown", (e) => {
  685. if (e.target.closest("button")) return;
  686. const workspace = chatDock.parentElement;
  687. const dockRect = chatDock.getBoundingClientRect();
  688. const workspaceRect = workspace.getBoundingClientRect();
  689. const left = dockRect.left - workspaceRect.left;
  690. const top = dockRect.top - workspaceRect.top;
  691. setDockPosition(left, top);
  692. chatDragState = {
  693. pointerId: e.pointerId,
  694. startX: e.clientX,
  695. startY: e.clientY,
  696. originLeft: left,
  697. originTop: top,
  698. };
  699. chatHeader.setPointerCapture(e.pointerId);
  700. if (e.cancelable) e.preventDefault();
  701. });
  702. chatHeader.addEventListener("pointermove", (e) => {
  703. if (!chatDragState || e.pointerId !== chatDragState.pointerId) return;
  704. if (e.cancelable) e.preventDefault();
  705. const workspace = chatDock.parentElement;
  706. const maxLeft = Math.max(0, workspace.clientWidth - chatDock.offsetWidth);
  707. const maxTop = Math.max(0, workspace.clientHeight - chatDock.offsetHeight);
  708. const left = Math.min(maxLeft, Math.max(0, chatDragState.originLeft + (e.clientX - chatDragState.startX)));
  709. const top = Math.min(maxTop, Math.max(0, chatDragState.originTop + (e.clientY - chatDragState.startY)));
  710. chatDock.style.left = `${left}px`;
  711. chatDock.style.top = `${top}px`;
  712. });
  713. chatHeader.addEventListener("pointerup", endChatDrag);
  714. chatHeader.addEventListener("pointercancel", endChatDrag);
  715. chatHeader.addEventListener("lostpointercapture", endChatDrag);
  716. function setChatCollapsed(collapsed) {
  717. chatDock.classList.toggle("collapsed", collapsed);
  718. chatFab.classList.toggle("show", collapsed);
  719. chatToggle.textContent = collapsed ? "展开" : "收起";
  720. }
  721. chatToggle.addEventListener("click", () => {
  722. setChatCollapsed(!chatDock.classList.contains("collapsed"));
  723. });
  724. chatFab.addEventListener("click", () => {
  725. setChatCollapsed(false);
  726. });
  727. init();
  728. </script>
  729. </body>
  730. </html>