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 +1 -1
- package/public/app.js +167 -30
- package/public/index.html +1 -0
- package/public/style.css +10 -0
package/package.json
CHANGED
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
|
|
351
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
const
|
|
377
|
-
const
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1749
|
-
if (
|
|
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
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;
|