local-knowledge-graph 1.10.0 → 1.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-knowledge-graph",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "本地知识图谱整合器:3D可视化 + AI对话建图 + 智能检索 + Git回溯 + RDF合规,全流程本地运行",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -256,6 +256,138 @@ function makeLabelSprite(text, cssColor, fontSize) {
256
256
  return sprite;
257
257
  }
258
258
 
259
+ /* ---- 平行边弧形分离 ---- */
260
+ // 同对节点多条关系时线弯曲错开:offset 为弧的偏移强度(0=直线),标签置于各弧顶
261
+ const ARC_SEGMENTS = 16;
262
+ function arcOffsetVec(dir, arc) {
263
+ // 弧偏移方向:取与边垂直的平面,按边序号均匀转开角度;强度=边长*比例,保证不同长度边分离度一致
264
+ const up = Math.abs(dir.y) > 0.92 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
265
+ const u = new THREE.Vector3().crossVectors(dir, up).normalize();
266
+ const v = new THREE.Vector3().crossVectors(dir, u).normalize();
267
+ const ang = (arc.idx / arc.total) * Math.PI * 2 + 0.6; // 固定相位避免与常用方向重合
268
+ const strength = 0.14; // 弧顶偏移 = 边长 * strength
269
+ return u.multiplyScalar(Math.cos(ang)).add(v.multiplyScalar(Math.sin(ang))).multiplyScalar(dir.length() * strength);
270
+ }
271
+
272
+ // 曲线上参数 t∈[0,1] 的点:a→b 直线叠加抛物弧偏移(中点最大,两端为0)
273
+ function arcPoint(out, a, b, dir, off, t) {
274
+ const sag = 4 * t * (1 - t);
275
+ out.set(
276
+ a.x + dir.x * t + off.x * sag,
277
+ a.y + dir.y * t + off.y * sag,
278
+ a.z + dir.z * t + off.z * sag
279
+ );
280
+ return out;
281
+ }
282
+
283
+ function makeRelLine(pa, pb, arc, color, dashed, opacity, dashSize, gapSize) {
284
+ const pts = [];
285
+ if (arc) {
286
+ const dir = pb.clone().sub(pa);
287
+ const off = arcOffsetVec(dir, arc);
288
+ for (let i = 0; i <= ARC_SEGMENTS; i++) pts.push(arcPoint(new THREE.Vector3(), pa, pb, dir, off, i / ARC_SEGMENTS).clone());
289
+ } else {
290
+ pts.push(pa.clone(), pb.clone());
291
+ }
292
+ const geo = new THREE.BufferGeometry().setFromPoints(pts);
293
+ const mat = dashed
294
+ ? new THREE.LineDashedMaterial({ color, dashSize, gapSize, transparent: true, opacity })
295
+ : new THREE.LineBasicMaterial({ color, transparent: true, opacity });
296
+ const line = new THREE.Line(geo, mat);
297
+ if (dashed) line.computeLineDistances();
298
+ return line;
299
+ }
300
+
301
+ // 每帧根据节点最新位置刷新弧线几何(标签为HTML层,弧顶点存 l.arcTop 供屏幕投影)
302
+ const _arcDir = new THREE.Vector3(), _arcOff = new THREE.Vector3(), _arcTmp = new THREE.Vector3();
303
+ function updateRelLine(l) {
304
+ if (!l.arc) {
305
+ const posAttr = l.line.geometry.attributes.position;
306
+ posAttr.setXYZ(0, l.a.pos.x, l.a.pos.y, l.a.pos.z);
307
+ posAttr.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
308
+ posAttr.needsUpdate = true;
309
+ if (l.dashed) l.line.computeLineDistances();
310
+ l.arcTop = (l.arcTop || new THREE.Vector3()).copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
311
+ return;
312
+ }
313
+ _arcDir.subVectors(l.b.pos, l.a.pos);
314
+ _arcOff.copy(arcOffsetVec(_arcDir, l.arc));
315
+ const posAttr = l.line.geometry.attributes.position;
316
+ for (let i = 0; i <= ARC_SEGMENTS; i++) {
317
+ arcPoint(_arcTmp, l.a.pos, l.b.pos, _arcDir, _arcOff, i / ARC_SEGMENTS);
318
+ posAttr.setXYZ(i, _arcTmp.x, _arcTmp.y, _arcTmp.z);
319
+ }
320
+ posAttr.needsUpdate = true;
321
+ if (l.dashed) l.line.computeLineDistances();
322
+ arcPoint(_arcTmp, l.a.pos, l.b.pos, _arcDir, _arcOff, 0.5);
323
+ l.arcTop = (l.arcTop || new THREE.Vector3()).copy(_arcTmp);
324
+ }
325
+
326
+ /* ---- 关系标签:HTML层 + 屏幕空间防重叠 ---- */
327
+ const relLabelEls = new Map(); // relationId -> div
328
+ function rebuildLinkLabelEls() {
329
+ const layer = $('link-labels');
330
+ layer.innerHTML = '';
331
+ relLabelEls.clear();
332
+ for (const l of simLinks) {
333
+ const el = document.createElement('div');
334
+ el.className = 'rel-label';
335
+ const name = l.inferredName || (state.relations.find((x) => x.id === l.id) || {}).name || '';
336
+ el.textContent = name;
337
+ el.style.color = l.colorCss || '#ccc';
338
+ el.style.borderColor = (l.colorCss || '#ccc') + '55';
339
+ el.onclick = () => {
340
+ state.selected = { type: 'relation', id: l.id };
341
+ renderInfoCard();
342
+ };
343
+ layer.appendChild(el);
344
+ relLabelEls.set(l.id, el);
345
+ l.labelEl = el;
346
+ }
347
+ measureLinkLabels();
348
+ }
349
+
350
+ // 一次性测量标签尺寸(投影定位需要 w/h;避免每帧读 offsetWidth 强制回流)
351
+ function measureLinkLabels() {
352
+ for (const el of relLabelEls.values()) {
353
+ el._w = el.offsetWidth;
354
+ el._h = el.offsetHeight;
355
+ }
356
+ }
357
+ window.addEventListener('resize', () => setTimeout(measureLinkLabels, 60));
358
+
359
+ const _projV = new THREE.Vector3();
360
+ function updateLinkLabels() {
361
+ const layer = $('link-labels');
362
+ if (!layer || !simLinks.length) return;
363
+ const W = renderer.domElement.clientWidth, H = renderer.domElement.clientHeight;
364
+ const items = [];
365
+ for (const l of simLinks) {
366
+ const el = l.labelEl;
367
+ if (!el) continue;
368
+ _projV.copy(l.arcTop || l.a.pos).project(camera);
369
+ if (_projV.z > 1 || _projV.z < -1) { el.style.display = 'none'; continue; }
370
+ const x = (_projV.x + 1) / 2 * W, y = (1 - _projV.y) / 2 * H;
371
+ if (x < -80 || x > W + 80 || y < -40 || y > H + 40) { el.style.display = 'none'; continue; }
372
+ el.style.display = 'block';
373
+ items.push({ el, x, y, w: el._w || 40, h: el._h || 18 });
374
+ }
375
+ // 屏幕空间防重叠:按 y 排序,与已放置矩形相交的标签向下推开,直到无碰撞
376
+ items.sort((p, q) => p.y - q.y);
377
+ const placed = [];
378
+ for (const it of items) {
379
+ let ny = it.y, guard = 0;
380
+ while (guard++ < 60) {
381
+ const hit = placed.find((p) => Math.abs(ny - p.y) < (it.h + p.h) / 2 + 2 && Math.abs(it.x - p.x) < (it.w + p.w) / 2 + 4);
382
+ if (!hit) break;
383
+ ny = hit.y + (hit.h + it.h) / 2 + 2;
384
+ }
385
+ it.y = ny;
386
+ placed.push(it);
387
+ it.el.style.transform = `translate(${(it.x - it.w / 2).toFixed(1)}px, ${(it.y - it.h / 2).toFixed(1)}px)`;
388
+ }
389
+ }
390
+
259
391
  function buildNodeMesh(category) {
260
392
  const st = ENTITY_STYLE[category] || { color: 0xaaaaaa };
261
393
  let mesh;
@@ -338,6 +470,21 @@ function rebuildGraph() {
338
470
  simNodes.push({ id: e.id, pos: mesh.position.clone(), vel: new THREE.Vector3(), mesh, label, radius: 10, sizeScale: ENTITY_STYLE[e.category] ? (ENTITY_STYLE[e.category].size || 1) : 1 });
339
471
  });
340
472
 
473
+ // 同节点对的平行边统一编号:线作弧形分离,标签各置弧顶,避免文字与线完全重叠
474
+ const pairCount = new Map(); // 'a|b'(无向) -> 同对节点边的总数
475
+ for (const r of rels) {
476
+ const k = r.source_id < r.target_id ? `${r.source_id}|${r.target_id}` : `${r.target_id}|${r.source_id}`;
477
+ pairCount.set(k, (pairCount.get(k) || 0) + 1);
478
+ }
479
+ const pairSeen = new Map();
480
+ const arcOf = (r) => {
481
+ const k = r.source_id < r.target_id ? `${r.source_id}|${r.target_id}` : `${r.target_id}|${r.source_id}`;
482
+ const n = pairCount.get(k) || 1;
483
+ const i = pairSeen.get(k) || 0;
484
+ pairSeen.set(k, i + 1);
485
+ return n > 1 ? { idx: i, total: n } : null;
486
+ };
487
+
341
488
  rels.forEach((r) => {
342
489
  const a = simNodes.find((n) => n.id === r.source_id);
343
490
  const b = simNodes.find((n) => n.id === r.target_id);
@@ -347,21 +494,12 @@ function rebuildGraph() {
347
494
  const baseOp = st.opacity === undefined ? 0.9 : st.opacity;
348
495
  const op = conf === '存疑' ? Math.min(baseOp, 0.35) : baseOp; // 存疑降不透明度
349
496
  const dashed = st.dashed || conf === '推测'; // 推测强制虚线
350
- const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
351
- const mat = dashed
352
- ? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize || 6, gapSize: st.gapSize || 4, transparent: true, opacity: op })
353
- : new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
354
- const line = new THREE.Line(geo, mat);
497
+ const arc = arcOf(r);
498
+ const line = makeRelLine(a.pos, b.pos, arc, st.color, dashed, op, st.dashSize || 6, st.gapSize || 4);
355
499
  line.userData.relationId = r.id;
356
500
  line.userData.baseOpacity = op;
357
501
  linkGroup.add(line);
358
- const mid = a.pos.clone().add(b.pos).multiplyScalar(0.5);
359
- // 线标注显示具体关系名(如"父子"),线型/颜色仍由大类规定
360
- const lbl = makeLabelSprite(r.name, st.css, 24);
361
- lbl.userData.text = r.name;
362
- lbl.position.copy(mid);
363
- labelGroup.add(lbl);
364
- simLinks.push({ id: r.id, a, b, line, label: lbl, dashed, confidence: conf });
502
+ simLinks.push({ id: r.id, a, b, line, dashed, confidence: conf, arc, colorCss: st.css });
365
503
  });
366
504
 
367
505
  // 推理关系叠加:虚化虚线 + "(推)"标注,负数id与库中显式关系区分;仅显示两端均在当前视图的边
@@ -372,18 +510,18 @@ function rebuildGraph() {
372
510
  const a = simNodes.find((n) => n.id === ir.source_id);
373
511
  const b = simNodes.find((n) => n.id === ir.target_id);
374
512
  if (!a || !b) return;
375
- const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
376
- const mat = new THREE.LineDashedMaterial({ color: 0xc792ea, dashSize: 3, gapSize: 5, transparent: true, opacity: 0.35 });
377
- const line = new THREE.Line(geo, mat);
378
- line.computeLineDistances();
513
+ // 推理边并入平行边分组:同对节点的显式边与推理边互不错开编号,仅继续排号
514
+ const k = ir.source_id < ir.target_id ? `${ir.source_id}|${ir.target_id}` : `${ir.target_id}|${ir.source_id}`;
515
+ const n = (pairCount.get(k) || 0) + 1;
516
+ pairCount.set(k, n);
517
+ const arc = n > 1 ? { idx: n - 1, total: n } : null;
518
+ const line = makeRelLine(a.pos, b.pos, arc, 0xc792ea, true, 0.35, 3, 5);
379
519
  line.userData.relationId = -(idx + 1);
380
520
  linkGroup.add(line);
381
- const lbl = makeLabelSprite(ir.name + '(推)', '#c792ea', 22);
382
- lbl.position.copy(a.pos.clone().add(b.pos).multiplyScalar(0.5));
383
- labelGroup.add(lbl);
384
- simLinks.push({ id: line.userData.relationId, a, b, line, label: lbl, dashed: true });
521
+ simLinks.push({ id: line.userData.relationId, a, b, line, dashed: true, arc, colorCss: '#c792ea', inferredName: ir.name + '(推)' });
385
522
  });
386
523
  }
524
+ rebuildLinkLabelEls();
387
525
 
388
526
  simBudget = 420;
389
527
  settleCount = 0;
@@ -429,12 +567,7 @@ function simStep() {
429
567
  }
430
568
  settleCount++;
431
569
  for (const l of simLinks) {
432
- const posAttr = l.line.geometry.attributes.position;
433
- posAttr.setXYZ(0, l.a.pos.x, l.a.pos.y, l.a.pos.z);
434
- posAttr.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
435
- posAttr.needsUpdate = true;
436
- if (l.dashed) l.line.computeLineDistances();
437
- l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
570
+ updateRelLine(l);
438
571
  }
439
572
  for (const nd of simNodes) {
440
573
  nd.mesh.position.copy(nd.pos);
@@ -466,6 +599,7 @@ function animate() {
466
599
  if (camFly.t >= 1) camFly = null;
467
600
  }
468
601
  controls.update();
602
+ updateLinkLabels();
469
603
  renderer.render(scene, camera);
470
604
  }
471
605
  animate();
@@ -1439,7 +1573,7 @@ function applyCanvasHi(nodes, rels) {
1439
1573
  const base = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
1440
1574
  const on = rels.has(l.id);
1441
1575
  l.line.material.opacity = on ? Math.max(base, 0.95) : 0.05;
1442
- if (l.label) l.label.material.opacity = on ? 1 : 0.06;
1576
+ if (l.labelEl) l.labelEl.style.opacity = on ? 1 : 0.06;
1443
1577
  }
1444
1578
  }
1445
1579
 
@@ -1452,7 +1586,7 @@ function clearCanvasHi() {
1452
1586
  }
1453
1587
  for (const l of simLinks) {
1454
1588
  l.line.material.opacity = l.line.userData.baseOpacity === undefined ? 0.9 : l.line.userData.baseOpacity;
1455
- if (l.label) l.label.material.opacity = 1;
1589
+ if (l.labelEl) l.labelEl.style.opacity = 1;
1456
1590
  }
1457
1591
  }
1458
1592
 
@@ -1745,8 +1879,11 @@ function applyStyleLight(dirty) {
1745
1879
  : new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: op });
1746
1880
  if (st.dashed) l.line.computeLineDistances();
1747
1881
  l.dashed = st.dashed;
1748
- const fresh = relabel(l.label, st.css, 24);
1749
- if (fresh) l.label = fresh;
1882
+ // 标签为HTML层:直接换色
1883
+ if (l.labelEl) {
1884
+ l.labelEl.style.color = st.css;
1885
+ l.labelEl.style.borderColor = st.css + '55';
1886
+ }
1750
1887
  }
1751
1888
  }
1752
1889
  }
package/public/index.html CHANGED
@@ -231,6 +231,7 @@
231
231
  </div>
232
232
 
233
233
  <div id="canvas-wrap">
234
+ <div id="link-labels"></div>
234
235
  <div class="legend" id="legend"></div>
235
236
  <button id="m-legend" class="m-btn">图例</button>
236
237
  <div id="ego-bar">
package/public/style.css CHANGED
@@ -98,6 +98,16 @@
98
98
 
99
99
  #canvas-wrap { flex: 1; position: relative; min-width: 0; background: radial-gradient(ellipse at center, #0d1424 0%, #070a12 100%); }
100
100
  #canvas-wrap canvas { display: block; touch-action: none; }
101
+ /* 关系标签层:屏幕空间定位,防重叠 */
102
+ #link-labels { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
103
+ .rel-label {
104
+ position: absolute; left: 0; top: 0; pointer-events: auto; cursor: pointer;
105
+ font-size: 11px; line-height: 1; padding: 3px 7px; border-radius: 9px; white-space: nowrap;
106
+ background: rgba(9,13,24,.78); border: 1px solid transparent; user-select: none;
107
+ will-change: transform; text-shadow: none;
108
+ }
109
+ .rel-label:hover { filter: brightness(1.25); }
110
+ html[data-theme="light"] .rel-label { background: rgba(255,255,255,.86); }
101
111
  .legend {
102
112
  position: absolute; left: 12px; bottom: 12px; background: rgba(13,20,36,.85);
103
113
  border: 1px solid #1e2a44; border-radius: 8px; padding: 9px 12px; font-size: 11px; color: #a9bbd8;