knowledge_graph_3d.html 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  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. <link rel="stylesheet" href="data_quality.css?v=20260904">
  231. </head>
  232. <body>
  233. <header>
  234. <h1>申勤物业元知识图谱 3D</h1>
  235. <div class="spacer"></div>
  236. <button id="resetBtn">重置视角</button>
  237. <button onclick="location.href='index.html'">返回聊天</button>
  238. </header>
  239. <div class="workspace">
  240. <div id="graph3D"></div>
  241. <div id="errorOverlay"></div>
  242. <aside id="nodePanel">
  243. <h2 id="panelTitle"></h2>
  244. <dl class="meta-list" id="panelMeta"></dl>
  245. <h3 class="attrs-title">属性</h3>
  246. <div class="attrs" id="panelAttrs"></div>
  247. </aside>
  248. <button id="chatFab" class="chat-fab" title="展开聊天窗口">
  249. <img src="微信图片_20260817170012_14_2.png" alt="打开聊天" />
  250. </button>
  251. <div id="chatDock" class="chat-dock">
  252. <div class="chat-resize-handle nw" data-resize="nw" title="缩放"></div>
  253. <div class="chat-resize-handle n" data-resize="n" title="上下缩放"></div>
  254. <div class="chat-resize-handle ne" data-resize="ne" title="缩放"></div>
  255. <div class="chat-resize-handle e" data-resize="e" title="左右缩放"></div>
  256. <div class="chat-resize-handle se" data-resize="se" title="缩放"></div>
  257. <div class="chat-resize-handle s" data-resize="s" title="上下缩放"></div>
  258. <div class="chat-resize-handle sw" data-resize="sw" title="缩放"></div>
  259. <div class="chat-resize-handle w" data-resize="w" title="左右缩放"></div>
  260. <div class="chat-dock-header">
  261. <span>申勤物业知识助手</span>
  262. <div class="spacer"></div>
  263. <button id="chatToggle" class="chat-toggle">收起</button>
  264. </div>
  265. <iframe id="chatFrame" src="index.html?v=20260907-cache" title="申勤物业知识助手"></iframe>
  266. </div>
  267. </div>
  268. <script src="vendor/three.min.js"></script>
  269. <script src="vendor/OrbitControls.js"></script>
  270. <script>
  271. const $ = (id) => document.getElementById(id);
  272. const container = $("graph3D");
  273. const nodePanel = $("nodePanel");
  274. const panelTitle = $("panelTitle");
  275. const panelMeta = $("panelMeta");
  276. const panelAttrs = $("panelAttrs");
  277. const chatDock = $("chatDock");
  278. const chatToggle = $("chatToggle");
  279. const errorOverlay = $("errorOverlay");
  280. const chatResizeHandles = Array.from(document.querySelectorAll(".chat-resize-handle"));
  281. const chatHeader = document.querySelector(".chat-dock-header");
  282. const chatFab = $("chatFab");
  283. let resizeState = null;
  284. let chatDragState = null;
  285. const departmentColors = {
  286. "市场部": "#d97706",
  287. "人事部": "#7c3aed",
  288. "运营部": "#2f855a",
  289. "财务部": "#c0392b",
  290. "采购部": "#0e7490",
  291. };
  292. const fallbackColors = ["#4c78a8", "#b45309", "#6d28d9", "#15803d", "#be123c", "#0369a1", "#b45309"];
  293. function apiBase() {
  294. if (location.protocol === "http:" || location.protocol === "https:") {
  295. return location.origin;
  296. }
  297. return "http://127.0.0.1:8000";
  298. }
  299. function showError(message) {
  300. errorOverlay.textContent = message;
  301. errorOverlay.classList.add("show");
  302. }
  303. function colorForNode(node) {
  304. const department = Array.isArray(node.department) && node.department.length
  305. ? node.department[0]
  306. : "未分类";
  307. const known = departmentColors[department];
  308. if (known) return known;
  309. let hash = 0;
  310. const key = String(node.id || node.name || "");
  311. for (let i = 0; i < key.length; i += 1) {
  312. hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
  313. }
  314. return fallbackColors[hash % fallbackColors.length];
  315. }
  316. function hexToNumber(hex) {
  317. return parseInt(hex.slice(1), 16);
  318. }
  319. function showNode(node) {
  320. panelTitle.textContent = node.name;
  321. panelMeta.innerHTML = "";
  322. const attributes = node.attributes || [];
  323. const meta = [
  324. ["所属部门", (node.department || []).join("、")],
  325. ["是否中心节点", node.is_hub ? "是" : "否"],
  326. ["是否启用", node.active ? "是" : "否"],
  327. ["是否建议节点", node.suggested ? "是" : "否"],
  328. ["属性数量", String(attributes.length)],
  329. ];
  330. const description = (node.description || "").trim();
  331. const attributesText = attributes.join(" / ");
  332. if (description && description.replace(/\s+/g, "") !== attributesText.replace(/\s+/g, "")) {
  333. meta.push(["说明", description]);
  334. }
  335. meta.forEach(([k, v]) => {
  336. const dt = document.createElement("dt");
  337. dt.textContent = k;
  338. const dd = document.createElement("dd");
  339. dd.textContent = v;
  340. panelMeta.appendChild(dt);
  341. panelMeta.appendChild(dd);
  342. });
  343. panelAttrs.innerHTML = "";
  344. if (!attributes.length) {
  345. const empty = document.createElement("span");
  346. empty.className = "attr-tag";
  347. empty.textContent = "暂无属性";
  348. panelAttrs.appendChild(empty);
  349. } else {
  350. attributes.forEach((attr) => {
  351. const tag = document.createElement("span");
  352. tag.className = "attr-tag";
  353. tag.textContent = attr;
  354. tag.title = attr;
  355. panelAttrs.appendChild(tag);
  356. });
  357. }
  358. nodePanel.classList.add("show");
  359. }
  360. function init() {
  361. if (typeof THREE === "undefined" || typeof THREE.OrbitControls === "undefined") {
  362. showError("3D 渲染库加载失败,请确认 html/vendor 目录下的 three.min.js 和 OrbitControls.js 存在。");
  363. return;
  364. }
  365. const scene = new THREE.Scene();
  366. const camera = new THREE.PerspectiveCamera(
  367. 50,
  368. container.clientWidth / Math.max(container.clientHeight, 1),
  369. 0.1,
  370. 5000,
  371. );
  372. camera.position.set(0, 180, 780);
  373. const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  374. renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  375. renderer.setSize(container.clientWidth, container.clientHeight);
  376. renderer.setClearColor(0xf4f7fb, 0);
  377. container.appendChild(renderer.domElement);
  378. const controls = new THREE.OrbitControls(camera, renderer.domElement);
  379. controls.enableDamping = true;
  380. controls.dampingFactor = 0.08;
  381. controls.minDistance = 240;
  382. controls.maxDistance = 1500;
  383. controls.autoRotate = true;
  384. controls.autoRotateSpeed = 0.75;
  385. controls.target.set(0, 0, 0);
  386. const ambient = new THREE.AmbientLight(0xffffff, 0.78);
  387. scene.add(ambient);
  388. const keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
  389. keyLight.position.set(360, 520, 520);
  390. scene.add(keyLight);
  391. const fillLight = new THREE.DirectionalLight(0xffffff, 0.35);
  392. fillLight.position.set(-420, -180, -360);
  393. scene.add(fillLight);
  394. const graphGroup = new THREE.Group();
  395. scene.add(graphGroup);
  396. const nodeMeshes = new Map();
  397. const edgeItems = [];
  398. function buildLayout(nodes) {
  399. const positions = new Map();
  400. const n = nodes.length || 1;
  401. const R = 290;
  402. const goldenAngle = Math.PI * (3 - Math.sqrt(5));
  403. nodes.forEach((node, i) => {
  404. let y;
  405. let radiusAtY;
  406. if (n === 1) {
  407. y = 0;
  408. radiusAtY = 1;
  409. } else {
  410. y = 1 - (i / (n - 1)) * 2;
  411. radiusAtY = Math.sqrt(Math.max(0, 1 - y * y));
  412. }
  413. const theta = goldenAngle * i;
  414. positions.set(node.id, new THREE.Vector3(
  415. Math.cos(theta) * radiusAtY * R,
  416. y * R * 0.72,
  417. Math.sin(theta) * radiusAtY * R,
  418. ));
  419. });
  420. return positions;
  421. }
  422. function makeTextSprite(text, options) {
  423. const opts = options || {};
  424. const width = opts.width || 256;
  425. const height = opts.height || 64;
  426. const canvas = document.createElement("canvas");
  427. canvas.width = width;
  428. canvas.height = height;
  429. const ctx = canvas.getContext("2d");
  430. ctx.clearRect(0, 0, width, height);
  431. ctx.font = `${opts.weight || 600} ${opts.fontSize || 28}px "Microsoft YaHei", "PingFang SC", sans-serif`;
  432. ctx.textAlign = "center";
  433. ctx.textBaseline = "middle";
  434. ctx.lineWidth = opts.strokeWidth || 6;
  435. ctx.strokeStyle = opts.stroke || "rgba(255, 255, 255, 0.95)";
  436. ctx.strokeText(text, width / 2, height / 2);
  437. ctx.fillStyle = opts.color || "#1f2a3a";
  438. ctx.fillText(text, width / 2, height / 2);
  439. const texture = new THREE.CanvasTexture(canvas);
  440. texture.minFilter = THREE.LinearFilter;
  441. const material = new THREE.SpriteMaterial({
  442. map: texture,
  443. depthTest: false,
  444. depthWrite: false,
  445. transparent: true,
  446. });
  447. const sprite = new THREE.Sprite(material);
  448. sprite.scale.set(opts.scaleX || 120, opts.scaleY || 30, 1);
  449. return sprite;
  450. }
  451. function selfLoopPoints(position, offset) {
  452. const points = [];
  453. const steps = 72;
  454. for (let i = 0; i <= steps; i += 1) {
  455. const angle = (Math.PI * 2 * i) / steps;
  456. points.push(new THREE.Vector3(
  457. position.x + Math.cos(angle) * 22,
  458. position.y + offset,
  459. position.z + Math.sin(angle) * 22,
  460. ));
  461. }
  462. return points;
  463. }
  464. function buildGraph(data) {
  465. const nodes = data?.nodes || [];
  466. const relations = data?.relations || [];
  467. const positions = buildLayout(nodes);
  468. const edgeMaterial = new THREE.LineBasicMaterial({
  469. color: 0x8fa3c0,
  470. transparent: true,
  471. opacity: 0.52,
  472. });
  473. const selfLoopCounts = {};
  474. relations.forEach((rel) => {
  475. const a = positions.get(rel.source);
  476. const b = positions.get(rel.target);
  477. if (!a || !b) return;
  478. if (rel.source === rel.target) {
  479. const count = selfLoopCounts[rel.source] || 0;
  480. selfLoopCounts[rel.source] = count + 1;
  481. const offset = 34 + count * 22;
  482. const geometry = new THREE.BufferGeometry().setFromPoints(selfLoopPoints(a, offset));
  483. const line = new THREE.Line(geometry, edgeMaterial);
  484. graphGroup.add(line);
  485. const label = makeTextSprite(rel.type, {
  486. fontSize: 20,
  487. color: "#1f2a3a",
  488. stroke: "rgba(244, 247, 251, 0.95)",
  489. strokeWidth: 5,
  490. scaleX: 82,
  491. scaleY: 20,
  492. });
  493. label.position.set(a.x, a.y + offset + 12, a.z);
  494. graphGroup.add(label);
  495. edgeItems.push({ line, label, a, b, self: true, offset });
  496. } else {
  497. const geometry = new THREE.BufferGeometry().setFromPoints([a, b]);
  498. const line = new THREE.Line(geometry, edgeMaterial);
  499. graphGroup.add(line);
  500. const mid = new THREE.Vector3().addVectors(a, b).multiplyScalar(0.5);
  501. const label = makeTextSprite(rel.type, {
  502. fontSize: 20,
  503. color: "#1f2a3a",
  504. stroke: "rgba(244, 247, 251, 0.95)",
  505. strokeWidth: 5,
  506. scaleX: 82,
  507. scaleY: 20,
  508. });
  509. label.position.copy(mid);
  510. graphGroup.add(label);
  511. edgeItems.push({ line, label, a, b, self: false, offset: 0 });
  512. }
  513. });
  514. const sphereGeometry = new THREE.SphereGeometry(1, 40, 28);
  515. nodes.forEach((node) => {
  516. const p = positions.get(node.id);
  517. if (!p) return;
  518. const radius = node.is_hub ? 22 : 14;
  519. const material = new THREE.MeshPhongMaterial({
  520. color: hexToNumber(colorForNode(node)),
  521. emissive: new THREE.Color(hexToNumber(colorForNode(node))).multiplyScalar(0.16),
  522. shininess: 28,
  523. specular: 0x556677,
  524. });
  525. const mesh = new THREE.Mesh(sphereGeometry, material);
  526. mesh.position.copy(p);
  527. mesh.scale.setScalar(radius);
  528. mesh.userData = { node };
  529. graphGroup.add(mesh);
  530. nodeMeshes.set(node.id, mesh);
  531. const label = makeTextSprite(node.name, {
  532. fontSize: 28,
  533. color: "#1f2a3a",
  534. stroke: "rgba(255, 255, 255, 0.95)",
  535. strokeWidth: 6,
  536. scaleX: 132,
  537. scaleY: 34,
  538. });
  539. label.position.set(p.x, p.y + radius + 18, p.z);
  540. graphGroup.add(label);
  541. });
  542. }
  543. const raycaster = new THREE.Raycaster();
  544. const pointer = new THREE.Vector2();
  545. let pointerDown = null;
  546. function pickNode(event) {
  547. const rect = renderer.domElement.getBoundingClientRect();
  548. pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  549. pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  550. raycaster.setFromCamera(pointer, camera);
  551. const hits = raycaster.intersectObjects(Array.from(nodeMeshes.values()), false);
  552. if (hits.length) {
  553. showNode(hits[0].object.userData.node);
  554. }
  555. }
  556. renderer.domElement.addEventListener("pointerdown", (event) => {
  557. pointerDown = { x: event.clientX, y: event.clientY };
  558. });
  559. renderer.domElement.addEventListener("pointerup", (event) => {
  560. if (!pointerDown) return;
  561. const moved = Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y);
  562. pointerDown = null;
  563. if (moved < 6) pickNode(event);
  564. });
  565. renderer.domElement.addEventListener("pointermove", (event) => {
  566. const rect = renderer.domElement.getBoundingClientRect();
  567. pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  568. pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  569. raycaster.setFromCamera(pointer, camera);
  570. const hits = raycaster.intersectObjects(Array.from(nodeMeshes.values()), false);
  571. renderer.domElement.style.cursor = hits.length ? "pointer" : "grab";
  572. });
  573. $("resetBtn").addEventListener("click", () => {
  574. camera.position.set(0, 180, 780);
  575. controls.target.set(0, 0, 0);
  576. controls.update();
  577. });
  578. window.addEventListener("resize", () => {
  579. const width = container.clientWidth;
  580. const height = container.clientHeight;
  581. camera.aspect = width / Math.max(height, 1);
  582. camera.updateProjectionMatrix();
  583. renderer.setSize(width, height);
  584. });
  585. async function load() {
  586. const res = await fetch(`${apiBase()}/output/meta_graph_schema_display.json`, { cache: "no-store" });
  587. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  588. const data = await res.json();
  589. buildGraph(data);
  590. const displayedSchemaVersion = data?.meta?.build_id;
  591. setInterval(async () => {
  592. try {
  593. const response = await fetch(`${apiBase()}/api/runtime`, { cache: "no-store" });
  594. if (!response.ok) return;
  595. const current = await response.json();
  596. if (current.schema_version && current.schema_version !== displayedSchemaVersion) {
  597. window.location.reload();
  598. }
  599. } catch (_) { /* Keep the currently displayed graph during brief service outages. */ }
  600. }, 10000);
  601. }
  602. load().catch((err) => {
  603. showError(`图谱加载失败:${err.message}`);
  604. });
  605. function animate() {
  606. requestAnimationFrame(animate);
  607. controls.update();
  608. renderer.render(scene, camera);
  609. }
  610. animate();
  611. }
  612. function ensureDockPosition() {
  613. const workspace = chatDock.parentElement;
  614. const dockRect = chatDock.getBoundingClientRect();
  615. const workspaceRect = workspace.getBoundingClientRect();
  616. const left = dockRect.left - workspaceRect.left;
  617. const top = dockRect.top - workspaceRect.top;
  618. setDockPosition(left, top);
  619. return { left, top };
  620. }
  621. function endChatResize(e) {
  622. if (!resizeState) return;
  623. const handle = resizeState.handle;
  624. resizeState = null;
  625. if (e && handle && handle.hasPointerCapture && handle.hasPointerCapture(e.pointerId)) {
  626. handle.releasePointerCapture(e.pointerId);
  627. }
  628. }
  629. chatResizeHandles.forEach((handle) => {
  630. handle.addEventListener("pointerdown", (e) => {
  631. if (chatDock.classList.contains("collapsed")) return;
  632. const position = ensureDockPosition();
  633. resizeState = {
  634. handle,
  635. direction: handle.getAttribute("data-resize") || "",
  636. pointerId: e.pointerId,
  637. startX: e.clientX,
  638. startY: e.clientY,
  639. startLeft: position.left,
  640. startTop: position.top,
  641. startWidth: chatDock.offsetWidth,
  642. startHeight: chatDock.offsetHeight,
  643. };
  644. handle.setPointerCapture(e.pointerId);
  645. if (e.cancelable) e.preventDefault();
  646. });
  647. handle.addEventListener("pointermove", (e) => {
  648. if (!resizeState || resizeState.handle !== handle || e.pointerId !== resizeState.pointerId) return;
  649. if (e.cancelable) e.preventDefault();
  650. const workspace = chatDock.parentElement;
  651. const direction = resizeState.direction;
  652. const minWidth = 280;
  653. const minHeight = 320;
  654. let left = resizeState.startLeft;
  655. let top = resizeState.startTop;
  656. let width = resizeState.startWidth;
  657. let height = resizeState.startHeight;
  658. const dx = e.clientX - resizeState.startX;
  659. const dy = e.clientY - resizeState.startY;
  660. if (direction.includes("e")) {
  661. width = Math.min(workspace.clientWidth - resizeState.startLeft, Math.max(minWidth, resizeState.startWidth + dx));
  662. }
  663. if (direction.includes("w")) {
  664. width = Math.min(resizeState.startLeft + resizeState.startWidth, Math.max(minWidth, resizeState.startWidth - dx));
  665. left = resizeState.startLeft + resizeState.startWidth - width;
  666. }
  667. if (direction.includes("s")) {
  668. height = Math.min(workspace.clientHeight - resizeState.startTop, Math.max(minHeight, resizeState.startHeight + dy));
  669. }
  670. if (direction.includes("n")) {
  671. height = Math.min(resizeState.startTop + resizeState.startHeight, Math.max(minHeight, resizeState.startHeight - dy));
  672. top = resizeState.startTop + resizeState.startHeight - height;
  673. }
  674. chatDock.style.left = `${left}px`;
  675. chatDock.style.top = `${top}px`;
  676. chatDock.style.width = `${width}px`;
  677. chatDock.style.height = `${height}px`;
  678. });
  679. handle.addEventListener("pointerup", endChatResize);
  680. handle.addEventListener("pointercancel", endChatResize);
  681. handle.addEventListener("lostpointercapture", endChatResize);
  682. });
  683. function setDockPosition(left, top) {
  684. chatDock.style.left = `${left}px`;
  685. chatDock.style.top = `${top}px`;
  686. chatDock.style.right = "auto";
  687. chatDock.style.bottom = "auto";
  688. }
  689. function endChatDrag(e) {
  690. if (!chatDragState) return;
  691. chatDragState = null;
  692. if (e && chatHeader.hasPointerCapture && chatHeader.hasPointerCapture(e.pointerId)) {
  693. chatHeader.releasePointerCapture(e.pointerId);
  694. }
  695. }
  696. chatHeader.addEventListener("pointerdown", (e) => {
  697. if (e.target.closest("button")) return;
  698. const workspace = chatDock.parentElement;
  699. const dockRect = chatDock.getBoundingClientRect();
  700. const workspaceRect = workspace.getBoundingClientRect();
  701. const left = dockRect.left - workspaceRect.left;
  702. const top = dockRect.top - workspaceRect.top;
  703. setDockPosition(left, top);
  704. chatDragState = {
  705. pointerId: e.pointerId,
  706. startX: e.clientX,
  707. startY: e.clientY,
  708. originLeft: left,
  709. originTop: top,
  710. };
  711. chatHeader.setPointerCapture(e.pointerId);
  712. if (e.cancelable) e.preventDefault();
  713. });
  714. chatHeader.addEventListener("pointermove", (e) => {
  715. if (!chatDragState || e.pointerId !== chatDragState.pointerId) return;
  716. if (e.cancelable) e.preventDefault();
  717. const workspace = chatDock.parentElement;
  718. const maxLeft = Math.max(0, workspace.clientWidth - chatDock.offsetWidth);
  719. const maxTop = Math.max(0, workspace.clientHeight - chatDock.offsetHeight);
  720. const left = Math.min(maxLeft, Math.max(0, chatDragState.originLeft + (e.clientX - chatDragState.startX)));
  721. const top = Math.min(maxTop, Math.max(0, chatDragState.originTop + (e.clientY - chatDragState.startY)));
  722. chatDock.style.left = `${left}px`;
  723. chatDock.style.top = `${top}px`;
  724. });
  725. chatHeader.addEventListener("pointerup", endChatDrag);
  726. chatHeader.addEventListener("pointercancel", endChatDrag);
  727. chatHeader.addEventListener("lostpointercapture", endChatDrag);
  728. function setChatCollapsed(collapsed) {
  729. chatDock.classList.toggle("collapsed", collapsed);
  730. chatFab.classList.toggle("show", collapsed);
  731. chatToggle.textContent = collapsed ? "展开" : "收起";
  732. }
  733. chatToggle.addEventListener("click", () => {
  734. setChatCollapsed(!chatDock.classList.contains("collapsed"));
  735. });
  736. chatFab.addEventListener("click", () => {
  737. setChatCollapsed(false);
  738. });
  739. init();
  740. </script>
  741. <script src="data_quality.js?v=20260904"></script>
  742. </body>
  743. </html>