local-knowledge-graph 1.5.0
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/.opencode/skill/kg-triples/SKILL.md +55 -0
- package/HELP.md +112 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/cli.js +86 -0
- package/lib/agent.js +211 -0
- package/lib/ask.js +290 -0
- package/lib/db.js +613 -0
- package/lib/embeddings.js +166 -0
- package/lib/git.js +175 -0
- package/lib/importer.js +54 -0
- package/lib/inference.js +123 -0
- package/lib/paths.js +53 -0
- package/lib/rdf.js +64 -0
- package/lib/updater.js +144 -0
- package/lib/validator.js +146 -0
- package/lib/vectors.js +68 -0
- package/lib/viewer.js +33 -0
- package/lib/viewer_template.html +283 -0
- package/mcp/server.js +262 -0
- package/package.json +44 -0
- package/public/app.js +1761 -0
- package/public/index.html +238 -0
- package/public/style.css +374 -0
- package/public/vendor/OrbitControls.js +1045 -0
- package/public/vendor/three.min.js +6 -0
- package/server.js +625 -0
- package/tools/ego.js +122 -0
- package/tools/search.js +101 -0
package/lib/updater.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 版本与更新(双模式):
|
|
4
|
+
// - 开发模式(程序目录是Git仓库):git fetch 对比 origin/master,pull --ff-only 更新
|
|
5
|
+
// - npm 模式(全局安装):npm view 对比注册表版本,npm i -g <pkg>@latest 更新
|
|
6
|
+
// 数据目录独立于程序目录,两种模式的升级都不影响用户数据。
|
|
7
|
+
|
|
8
|
+
const { execFileSync } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { APP_ROOT, PKG_NAME, isDevRepo } = require('./paths');
|
|
12
|
+
|
|
13
|
+
const GIT_TIMEOUT = 20000;
|
|
14
|
+
|
|
15
|
+
function git(args, timeout = GIT_TIMEOUT) {
|
|
16
|
+
return execFileSync('git', args, { cwd: APP_ROOT, timeout, encoding: 'utf8' }).trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function npm(args, timeout = 30000) {
|
|
20
|
+
const bin = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
21
|
+
return execFileSync(bin, args, { cwd: APP_ROOT, timeout, encoding: 'utf8' }).trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shortErr(e) {
|
|
25
|
+
return String(e.message || e).split('\n')[0].slice(0, 180);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function currentVersion() {
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(path.join(APP_ROOT, 'package.json'), 'utf8')).version; }
|
|
30
|
+
catch (_) { return 'unknown'; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compareVersions(a, b) {
|
|
34
|
+
const pa = String(a).split('.').map(Number);
|
|
35
|
+
const pb = String(b).split('.').map(Number);
|
|
36
|
+
for (let i = 0; i < 3; i++) {
|
|
37
|
+
const x = pa[i] || 0;
|
|
38
|
+
const y = pb[i] || 0;
|
|
39
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function repoSlug() {
|
|
45
|
+
try {
|
|
46
|
+
const url = git(['remote', 'get-url', 'origin']);
|
|
47
|
+
const m = url.match(/github\.com[:/](.+?)(?:\.git)?\/?$/);
|
|
48
|
+
if (m) return m[1];
|
|
49
|
+
} catch (_) { /* fallback */ }
|
|
50
|
+
return 'iamsamyiok/local-knowledge-graph';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 最新 Release 信息(尽力而为,失败不影响检查结果)
|
|
54
|
+
async function fetchLatestRelease() {
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(`https://api.github.com/repos/${repoSlug()}/releases/latest`, {
|
|
57
|
+
headers: { 'User-Agent': 'local-knowledge-graph-updater', Accept: 'application/vnd.github+json' },
|
|
58
|
+
signal: AbortSignal.timeout(8000),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) return null;
|
|
61
|
+
const j = await res.json();
|
|
62
|
+
return { tag: j.tag_name, name: j.name, url: j.html_url, published_at: j.published_at };
|
|
63
|
+
} catch (_) { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------- npm 模式 ----------
|
|
67
|
+
function checkSyncNpm() {
|
|
68
|
+
let registryVersion;
|
|
69
|
+
try { registryVersion = npm(['view', PKG_NAME, 'version'], 30000); }
|
|
70
|
+
catch (e) { return { ok: false, error: '无法连接 npm 注册表:' + shortErr(e) }; }
|
|
71
|
+
const cur = currentVersion();
|
|
72
|
+
const cmp = compareVersions(cur, registryVersion);
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
mode: 'npm',
|
|
76
|
+
current_version: cur,
|
|
77
|
+
registry_version: registryVersion,
|
|
78
|
+
behind: cmp < 0 ? 1 : 0,
|
|
79
|
+
ahead: cmp > 0 ? 1 : 0,
|
|
80
|
+
up_to_date: cmp >= 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function applyUpdateNpm() {
|
|
85
|
+
const c = checkSyncNpm();
|
|
86
|
+
if (!c.ok) return c;
|
|
87
|
+
if (c.up_to_date) return { ok: true, up_to_date: true, version: currentVersion() };
|
|
88
|
+
try { npm(['install', '-g', `${PKG_NAME}@${c.registry_version}`], 300000); }
|
|
89
|
+
catch (e) { return { ok: false, error: 'npm 全局更新失败:' + shortErr(e) }; }
|
|
90
|
+
return { ok: true, up_to_date: false, version: c.registry_version, from_version: c.current_version };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------- 开发模式(git) ----------
|
|
94
|
+
function checkSyncGit() {
|
|
95
|
+
const info = { ok: true, mode: 'git', current_version: currentVersion(), up_to_date: true, behind: 0, ahead: 0 };
|
|
96
|
+
let localSha;
|
|
97
|
+
try { localSha = git(['rev-parse', 'HEAD']); }
|
|
98
|
+
catch (e) { return { ok: false, error: '当前目录不是Git仓库,无法检查更新:' + shortErr(e) }; }
|
|
99
|
+
try { git(['fetch', 'origin', 'master'], 30000); }
|
|
100
|
+
catch (e) { return { ok: false, error: '无法连接 GitHub:' + shortErr(e) }; }
|
|
101
|
+
let remoteSha;
|
|
102
|
+
try { remoteSha = git(['rev-parse', 'FETCH_HEAD']); }
|
|
103
|
+
catch (e) { return { ok: false, error: '读取远端版本失败:' + shortErr(e) }; }
|
|
104
|
+
info.local_sha = localSha.slice(0, 7);
|
|
105
|
+
info.remote_sha = remoteSha.slice(0, 7);
|
|
106
|
+
if (remoteSha !== localSha) {
|
|
107
|
+
try { info.behind = Number(git(['rev-list', '--count', `HEAD..${remoteSha}`])) || 0; } catch (_) { info.behind = 0; }
|
|
108
|
+
try { info.ahead = Number(git(['rev-list', '--count', `${remoteSha}..HEAD`])) || 0; } catch (_) { info.ahead = 0; }
|
|
109
|
+
info.up_to_date = info.behind === 0 && info.ahead === 0;
|
|
110
|
+
if (info.behind === 0 && info.ahead > 0) info.note = `本地领先远端 ${info.ahead} 个提交`;
|
|
111
|
+
}
|
|
112
|
+
return info;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyUpdateGit() {
|
|
116
|
+
const c = checkSyncGit();
|
|
117
|
+
if (!c.ok) return c;
|
|
118
|
+
if (c.up_to_date) return { ok: true, up_to_date: true, version: currentVersion() };
|
|
119
|
+
if (c.ahead > 0) return { ok: false, error: `本地存在 ${c.ahead} 个未发布提交,无法快进更新;请在项目目录手动处理(git stash 或推送后重试)` };
|
|
120
|
+
try { git(['pull', '--ff-only', 'origin', 'master'], 120000); }
|
|
121
|
+
catch (e) { return { ok: false, error: '更新失败:' + shortErr(e) }; }
|
|
122
|
+
return { ok: true, up_to_date: false, version: currentVersion(), from_sha: c.local_sha, to_sha: c.remote_sha };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- 对外接口 ----------
|
|
126
|
+
function updateMode() {
|
|
127
|
+
return isDevRepo() ? 'git' : 'npm';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function checkSync() {
|
|
131
|
+
return updateMode() === 'git' ? checkSyncGit() : checkSyncNpm();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function checkUpdate() {
|
|
135
|
+
const info = checkSync();
|
|
136
|
+
if (info.ok) info.latest_release = await fetchLatestRelease();
|
|
137
|
+
return info;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyUpdate() {
|
|
141
|
+
return updateMode() === 'git' ? applyUpdateGit() : applyUpdateNpm();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { currentVersion, compareVersions, checkUpdate, applyUpdate, checkSync, updateMode, repoSlug };
|
package/lib/validator.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// RDF规范校验器:所有手工与OpenCode操作共用同一套校验,标准完全一致
|
|
4
|
+
|
|
5
|
+
const ENTITY_CATEGORIES = ['物理实体', '抽象实体', '数值实体', '时间实体'];
|
|
6
|
+
const RELATION_CATEGORIES = ['空间', '互动', '归属', '时间', '属性'];
|
|
7
|
+
const SOURCES = ['手工', 'OpenCode', '系统'];
|
|
8
|
+
const RDF_TYPE_MAP = {
|
|
9
|
+
'物理实体': 'PhysicalEntity',
|
|
10
|
+
'抽象实体': 'AbstractEntity',
|
|
11
|
+
'数值实体': 'NumericEntity',
|
|
12
|
+
'时间实体': 'TemporalEntity',
|
|
13
|
+
};
|
|
14
|
+
const RDF_RELATION_TYPE_MAP = {
|
|
15
|
+
'空间': 'SpatialRelation',
|
|
16
|
+
'互动': 'InteractionRelation',
|
|
17
|
+
'归属': 'BelongingRelation',
|
|
18
|
+
'时间': 'TemporalRelation',
|
|
19
|
+
'属性': 'AttributeRelation',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function isPrimitive(v) {
|
|
23
|
+
return v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 实体校验:三元组结构 (subject=实体, predicate=属性键, object=原子值)
|
|
27
|
+
// 属性必须是扁平JSON对象,键为非空字符串,值为原始类型;禁止嵌套与冗余字段
|
|
28
|
+
function validateEntityInput(input) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
if (!input || typeof input !== 'object') return { ok: false, errors: ['输入必须为JSON对象'], value: null };
|
|
31
|
+
|
|
32
|
+
const name = input.name;
|
|
33
|
+
if (typeof name !== 'string' || name.trim().length === 0) errors.push('实体名称必须为非空字符串');
|
|
34
|
+
if (typeof name === 'string' && name.length > 200) errors.push('实体名称长度不得超过200字符');
|
|
35
|
+
|
|
36
|
+
const category = input.category;
|
|
37
|
+
if (!ENTITY_CATEGORIES.includes(category)) {
|
|
38
|
+
errors.push(`实体大类必须为以下4类之一: ${ENTITY_CATEGORIES.join(' / ')}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let attributes = input.attributes === undefined ? {} : input.attributes;
|
|
42
|
+
if (attributes === null || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
|
43
|
+
errors.push('属性(attributes)必须为JSON对象');
|
|
44
|
+
attributes = null;
|
|
45
|
+
} else {
|
|
46
|
+
for (const [k, v] of Object.entries(attributes)) {
|
|
47
|
+
if (typeof k !== 'string' || k.trim().length === 0) errors.push(`属性键"${k}"必须为非空字符串`);
|
|
48
|
+
if (!isPrimitive(v)) {
|
|
49
|
+
errors.push(`属性"${k}"的值必须为字符串/数值/布尔/空值(扁平三元组结构),禁止嵌套对象或数组`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (Object.keys(attributes).length > 100) errors.push('属性键数量不得超过100');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (errors.length) return { ok: false, errors, value: null };
|
|
56
|
+
return { ok: true, errors: [], value: { name: name.trim(), category, attributes } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateEntityPatch(patch, existing) {
|
|
60
|
+
const errors = [];
|
|
61
|
+
const merged = {
|
|
62
|
+
name: existing.name,
|
|
63
|
+
category: existing.category,
|
|
64
|
+
attributes: JSON.parse(existing.attributes || '{}'),
|
|
65
|
+
};
|
|
66
|
+
if (patch.name !== undefined) {
|
|
67
|
+
if (typeof patch.name !== 'string' || patch.name.trim().length === 0) errors.push('实体名称必须为非空字符串');
|
|
68
|
+
else merged.name = patch.name.trim();
|
|
69
|
+
}
|
|
70
|
+
if (patch.category !== undefined) {
|
|
71
|
+
if (!ENTITY_CATEGORIES.includes(patch.category)) errors.push(`实体大类必须为: ${ENTITY_CATEGORIES.join(' / ')}`);
|
|
72
|
+
else merged.category = patch.category;
|
|
73
|
+
}
|
|
74
|
+
if (patch.attributes !== undefined) {
|
|
75
|
+
const check = validateEntityInput({ name: merged.name, category: merged.category, attributes: patch.attributes });
|
|
76
|
+
if (!check.ok) errors.push(...check.errors);
|
|
77
|
+
else merged.attributes = check.value.attributes;
|
|
78
|
+
}
|
|
79
|
+
if (errors.length) return { ok: false, errors, value: null };
|
|
80
|
+
return { ok: true, errors: [], value: merged };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 关系校验:三元组结构 (subject=起点实体, predicate=关系, object=终点实体)
|
|
84
|
+
function validateRelationInput(input, db) {
|
|
85
|
+
const errors = [];
|
|
86
|
+
if (!input || typeof input !== 'object') return { ok: false, errors: ['输入必须为JSON对象'], value: null };
|
|
87
|
+
|
|
88
|
+
const sid = Number(input.source_id);
|
|
89
|
+
const tid = Number(input.target_id);
|
|
90
|
+
if (!Number.isInteger(sid) || sid <= 0) errors.push('起点实体id必须为正整数');
|
|
91
|
+
if (!Number.isInteger(tid) || tid <= 0) errors.push('终点实体id必须为正整数');
|
|
92
|
+
if (errors.length === 0) {
|
|
93
|
+
const s = db.prepare('SELECT id FROM entities WHERE id = ?').get(sid);
|
|
94
|
+
const t = db.prepare('SELECT id FROM entities WHERE id = ?').get(tid);
|
|
95
|
+
if (!s) errors.push(`起点实体id=${sid} 不存在`);
|
|
96
|
+
if (!t) errors.push(`终点实体id=${tid} 不存在`);
|
|
97
|
+
if (sid === tid) errors.push('起点与终点不能为同一实体');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const name = input.name;
|
|
101
|
+
if (typeof name !== 'string' || name.trim().length === 0) errors.push('关系名称必须为非空字符串');
|
|
102
|
+
if (typeof name === 'string' && name.length > 200) errors.push('关系名称长度不得超过200字符');
|
|
103
|
+
|
|
104
|
+
const category = input.category;
|
|
105
|
+
if (!RELATION_CATEGORIES.includes(category)) {
|
|
106
|
+
errors.push(`关系大类必须为以下5类之一: ${RELATION_CATEGORIES.join(' / ')}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (errors.length) return { ok: false, errors, value: null };
|
|
110
|
+
return { ok: true, errors: [], value: { source_id: sid, target_id: tid, name: name.trim(), category } };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateRelationPatch(patch, existing, db) {
|
|
114
|
+
const errors = [];
|
|
115
|
+
const merged = {
|
|
116
|
+
source_id: existing.source_id,
|
|
117
|
+
target_id: existing.target_id,
|
|
118
|
+
name: existing.name,
|
|
119
|
+
category: existing.category,
|
|
120
|
+
};
|
|
121
|
+
if (patch.source_id !== undefined) merged.source_id = patch.source_id;
|
|
122
|
+
if (patch.target_id !== undefined) merged.target_id = patch.target_id;
|
|
123
|
+
if (patch.name !== undefined) {
|
|
124
|
+
if (typeof patch.name !== 'string' || patch.name.trim().length === 0) errors.push('关系名称必须为非空字符串');
|
|
125
|
+
else merged.name = patch.name.trim();
|
|
126
|
+
}
|
|
127
|
+
if (patch.category !== undefined) {
|
|
128
|
+
if (!RELATION_CATEGORIES.includes(patch.category)) errors.push(`关系大类必须为: ${RELATION_CATEGORIES.join(' / ')}`);
|
|
129
|
+
else merged.category = patch.category;
|
|
130
|
+
}
|
|
131
|
+
const check = validateRelationInput(merged, db);
|
|
132
|
+
if (errors.length || !check.ok) return { ok: false, errors: [...errors, ...check.errors], value: null };
|
|
133
|
+
return check;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
ENTITY_CATEGORIES,
|
|
138
|
+
RELATION_CATEGORIES,
|
|
139
|
+
SOURCES,
|
|
140
|
+
RDF_TYPE_MAP,
|
|
141
|
+
RDF_RELATION_TYPE_MAP,
|
|
142
|
+
validateEntityInput,
|
|
143
|
+
validateEntityPatch,
|
|
144
|
+
validateRelationInput,
|
|
145
|
+
validateRelationPatch,
|
|
146
|
+
};
|
package/lib/vectors.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 实体向量库:独立 data/vectors.db,不参与图谱保存点/回溯(向量可随时重建,避免二进制BLOB撑爆git差量)。
|
|
4
|
+
// 遵循与主库相同的 KG_DATA_DIR/KG_DB_PATH 环境变量约定,测试可完全隔离。
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
9
|
+
|
|
10
|
+
const { DATA_DIR } = require('./paths');
|
|
11
|
+
const VDB_PATH = process.env.KG_VDB_PATH || path.join(DATA_DIR, 'vectors.db');
|
|
12
|
+
|
|
13
|
+
let vdb = null;
|
|
14
|
+
|
|
15
|
+
function open() {
|
|
16
|
+
if (vdb) return vdb;
|
|
17
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
18
|
+
vdb = new DatabaseSync(VDB_PATH);
|
|
19
|
+
vdb.exec('PRAGMA journal_mode = DELETE; PRAGMA busy_timeout = 4000;');
|
|
20
|
+
vdb.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS entity_embeddings (
|
|
22
|
+
entity_id INTEGER PRIMARY KEY,
|
|
23
|
+
text TEXT NOT NULL,
|
|
24
|
+
vector BLOB NOT NULL,
|
|
25
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
26
|
+
);
|
|
27
|
+
`);
|
|
28
|
+
return vdb;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function close() {
|
|
32
|
+
try { if (vdb) vdb.close(); } catch (_) { /* 已关闭 */ }
|
|
33
|
+
vdb = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function count() {
|
|
37
|
+
return open().prepare('SELECT COUNT(*) AS c FROM entity_embeddings').get().c;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function get(entityId) {
|
|
41
|
+
return open().prepare('SELECT * FROM entity_embeddings WHERE entity_id = ?').get(entityId) || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function all() {
|
|
45
|
+
return open().prepare('SELECT entity_id, text, vector FROM entity_embeddings').all();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function upsert(entityId, text, vec) {
|
|
49
|
+
const f32 = Float32Array.from(vec);
|
|
50
|
+
open().prepare(
|
|
51
|
+
`INSERT INTO entity_embeddings(entity_id, text, vector, updated_at) VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
52
|
+
ON CONFLICT(entity_id) DO UPDATE SET text = excluded.text, vector = excluded.vector, updated_at = excluded.updated_at`
|
|
53
|
+
).run(entityId, text, Buffer.from(f32.buffer));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 实体已删除的向量行清理(主库删除实体后调用,保持两库引用一致)
|
|
57
|
+
function pruneOrphans(liveIds) {
|
|
58
|
+
const live = new Set(liveIds);
|
|
59
|
+
const rows = open().prepare('SELECT entity_id FROM entity_embeddings').all();
|
|
60
|
+
const del = open().prepare('DELETE FROM entity_embeddings WHERE entity_id = ?');
|
|
61
|
+
let pruned = 0;
|
|
62
|
+
for (const r of rows) {
|
|
63
|
+
if (!live.has(r.entity_id)) { del.run(r.entity_id); pruned++; }
|
|
64
|
+
}
|
|
65
|
+
return pruned;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { open, close, count, get, all, upsert, pruneOrphans, VDB_PATH };
|
package/lib/viewer.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 单文件只读图谱查看器生成器:
|
|
4
|
+
// 将图谱数据与 three.js 全部内联进一个 HTML 文件,离线可开、仅查看、涉及编辑。
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const VENDOR_DIR = path.join(__dirname, '..', 'public', 'vendor');
|
|
10
|
+
|
|
11
|
+
function escapeScript(src) {
|
|
12
|
+
// 防止内联代码中出现 </script> 提前闭合标签
|
|
13
|
+
return String(src).replace(/<\/script/gi, '<\\/script');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildViewerHtml(graph, opts = {}) {
|
|
17
|
+
const three = fs.readFileSync(path.join(VENDOR_DIR, 'three.min.js'), 'utf8');
|
|
18
|
+
const controls = fs.readFileSync(path.join(VENDOR_DIR, 'OrbitControls.js'), 'utf8');
|
|
19
|
+
const data = JSON.stringify({ entities: graph.entities, relations: graph.relations });
|
|
20
|
+
const exportedAt = new Date().toLocaleString('zh-CN');
|
|
21
|
+
const title = opts.title || '知识图谱只读查看器';
|
|
22
|
+
|
|
23
|
+
const tpl = fs.readFileSync(path.join(__dirname, 'viewer_template.html'), 'utf8');
|
|
24
|
+
return tpl
|
|
25
|
+
.replace('/*__TITLE__*/', escapeScript(title))
|
|
26
|
+
.replace('/*__EXPORTED_AT__*/', escapeScript(exportedAt))
|
|
27
|
+
.replace('/*__COUNTS__*/', `${graph.entities.length} 实体 · ${graph.relations.length} 关系`)
|
|
28
|
+
.replace('"__GRAPH_DATA__"', escapeScript(data))
|
|
29
|
+
.replace('/*__THREE_JS__*/', () => escapeScript(three))
|
|
30
|
+
.replace('/*__ORBIT_CONTROLS__*/', () => escapeScript(controls));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { buildViewerHtml };
|
|
@@ -0,0 +1,283 @@
|
|
|
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.0, maximum-scale=1.0, user-scalable=no">
|
|
6
|
+
<title>/*__TITLE__*/</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body { height: 100%; overflow: hidden; }
|
|
10
|
+
body { font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; background: #0b0f1a; color: #d7dee9; font-size: 13px; }
|
|
11
|
+
header {
|
|
12
|
+
height: 44px; display: flex; align-items: center; gap: 10px; padding: 0 14px;
|
|
13
|
+
background: #101728; border-bottom: 1px solid #1e2a44; flex-wrap: wrap;
|
|
14
|
+
}
|
|
15
|
+
header h1 { font-size: 14px; font-weight: 600; color: #7fd1ff; }
|
|
16
|
+
header .badge { font-size: 11px; color: #8fa3c0; border: 1px solid #2a3a5c; border-radius: 10px; padding: 2px 9px; }
|
|
17
|
+
header .ro { color: #ffb74d; border-color: #ffb74d66; }
|
|
18
|
+
header .spacer { flex: 1; }
|
|
19
|
+
header .date { font-size: 10.5px; color: #5c6f92; }
|
|
20
|
+
#wrap { position: relative; height: calc(100% - 44px); background: radial-gradient(ellipse at center, #0d1424 0%, #070a12 100%); }
|
|
21
|
+
#wrap canvas { display: block; touch-action: none; }
|
|
22
|
+
.legend {
|
|
23
|
+
position: absolute; left: 12px; bottom: 12px; background: rgba(13,20,36,.85);
|
|
24
|
+
border: 1px solid #1e2a44; border-radius: 8px; padding: 9px 12px; font-size: 11px; color: #a9bbd8;
|
|
25
|
+
pointer-events: none; line-height: 1.9;
|
|
26
|
+
}
|
|
27
|
+
.legend b { color: #d7dee9; }
|
|
28
|
+
.sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; vertical-align: -1px; }
|
|
29
|
+
.ln { display: inline-block; width: 22px; height: 0; border-top: 2.5px solid; margin-right: 6px; vertical-align: 3px; }
|
|
30
|
+
.ln.dash { border-top-style: dashed; }
|
|
31
|
+
#info-card {
|
|
32
|
+
position: absolute; top: 12px; right: 12px; width: 250px; max-height: 70%; overflow-y: auto; display: none;
|
|
33
|
+
background: rgba(13,20,36,.92); border: 1px solid #2a3a5c; border-radius: 8px; padding: 10px 12px;
|
|
34
|
+
}
|
|
35
|
+
#info-card h4 { color: #7fd1ff; font-size: 13px; margin-bottom: 6px; }
|
|
36
|
+
#info-card .kv { font-size: 11px; color: #a9bbd8; margin: 2px 0; word-break: break-all; }
|
|
37
|
+
#info-card .kv b { color: #d7dee9; }
|
|
38
|
+
.tag { display: inline-block; font-size: 10px; padding: 1px 7px; border-radius: 9px; border: 1px solid; margin-left: 4px; }
|
|
39
|
+
@media (max-width: 768px) {
|
|
40
|
+
#info-card { left: 8px; right: 8px; top: auto; bottom: 10px; width: auto; max-height: 40vh; }
|
|
41
|
+
header .date { display: none; }
|
|
42
|
+
}
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<header>
|
|
47
|
+
<h1>/*__TITLE__*/</h1>
|
|
48
|
+
<span class="badge">/*__COUNTS__*/</span>
|
|
49
|
+
<span class="badge ro">只读查看</span>
|
|
50
|
+
<div class="spacer"></div>
|
|
51
|
+
<span class="date">导出于 /*__EXPORTED_AT__*/</span>
|
|
52
|
+
</header>
|
|
53
|
+
<div id="wrap">
|
|
54
|
+
<div class="legend" id="legend"></div>
|
|
55
|
+
<div id="info-card"></div>
|
|
56
|
+
</div>
|
|
57
|
+
<script>/*__THREE_JS__*/</script>
|
|
58
|
+
<script>/*__ORBIT_CONTROLS__*/</script>
|
|
59
|
+
<script>
|
|
60
|
+
'use strict';
|
|
61
|
+
const GRAPH = "__GRAPH_DATA__";
|
|
62
|
+
|
|
63
|
+
const ENTITY_STYLE = {
|
|
64
|
+
'物理实体': { color: 0x4fc3f7, css: '#4fc3f7', shape: '实心球', size: 1 },
|
|
65
|
+
'抽象实体': { color: 0xba68c8, css: '#ba68c8', shape: '线框球', size: 1 },
|
|
66
|
+
'数值实体': { color: 0x81c784, css: '#81c784', shape: '立方体', size: 1 },
|
|
67
|
+
'时间实体': { color: 0xffb74d, css: '#ffb74d', shape: '圆环', size: 1 },
|
|
68
|
+
};
|
|
69
|
+
const RELATION_STYLE = {
|
|
70
|
+
'空间': { color: 0x4caf50, css: '#4caf50', dashed: false, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
71
|
+
'互动': { color: 0xf44336, css: '#f44336', dashed: true, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
72
|
+
'归属': { color: 0x2196f3, css: '#2196f3', dashed: false, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
73
|
+
'时间': { color: 0xffc107, css: '#ffc107', dashed: true, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
74
|
+
'属性': { color: 0x9c27b0, css: '#9c27b0', dashed: false, dashSize: 6, gapSize: 4, opacity: 0.9 },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const wrap = document.getElementById('wrap');
|
|
78
|
+
const esc = (s) => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
79
|
+
|
|
80
|
+
const scene = new THREE.Scene();
|
|
81
|
+
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 5000);
|
|
82
|
+
camera.position.set(0, 90, 260);
|
|
83
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
84
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
85
|
+
renderer.setClearColor(0x000000, 0);
|
|
86
|
+
wrap.appendChild(renderer.domElement);
|
|
87
|
+
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
88
|
+
controls.enableDamping = true;
|
|
89
|
+
controls.dampingFactor = 0.08;
|
|
90
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
|
|
91
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
92
|
+
dirLight.position.set(120, 200, 100);
|
|
93
|
+
scene.add(dirLight);
|
|
94
|
+
const grid = new THREE.GridHelper(480, 48, 0x1c2a47, 0x141e35);
|
|
95
|
+
grid.position.y = -60;
|
|
96
|
+
scene.add(grid);
|
|
97
|
+
|
|
98
|
+
const nodeGroup = new THREE.Group(), linkGroup = new THREE.Group(), labelGroup = new THREE.Group();
|
|
99
|
+
scene.add(nodeGroup, linkGroup, labelGroup);
|
|
100
|
+
|
|
101
|
+
const simNodes = [], simLinks = [];
|
|
102
|
+
const entityMap = new Map();
|
|
103
|
+
|
|
104
|
+
function makeLabelSprite(text, cssColor, fontSize) {
|
|
105
|
+
const canvas = document.createElement('canvas');
|
|
106
|
+
const ctx = canvas.getContext('2d');
|
|
107
|
+
const font = fontSize + 'px "PingFang SC","Microsoft YaHei",sans-serif';
|
|
108
|
+
ctx.font = font;
|
|
109
|
+
const w = Math.ceil(ctx.measureText(text).width) + 20;
|
|
110
|
+
canvas.width = w; canvas.height = fontSize + 16;
|
|
111
|
+
ctx.font = font;
|
|
112
|
+
ctx.fillStyle = 'rgba(9,13,24,0.78)';
|
|
113
|
+
const r = 8;
|
|
114
|
+
ctx.beginPath();
|
|
115
|
+
ctx.moveTo(r,0); ctx.lineTo(w-r,0); ctx.quadraticCurveTo(w,0,w,r);
|
|
116
|
+
ctx.lineTo(w,canvas.height-r); ctx.quadraticCurveTo(w,canvas.height,w-r,canvas.height);
|
|
117
|
+
ctx.lineTo(r,canvas.height); ctx.quadraticCurveTo(0,canvas.height,0,canvas.height-r);
|
|
118
|
+
ctx.lineTo(0,r); ctx.quadraticCurveTo(0,0,r,0);
|
|
119
|
+
ctx.fill();
|
|
120
|
+
ctx.strokeStyle = cssColor; ctx.globalAlpha = 0.55; ctx.stroke(); ctx.globalAlpha = 1;
|
|
121
|
+
ctx.fillStyle = cssColor; ctx.textBaseline = 'middle';
|
|
122
|
+
ctx.fillText(text, 10, canvas.height/2+1);
|
|
123
|
+
const tex = new THREE.CanvasTexture(canvas);
|
|
124
|
+
tex.minFilter = THREE.LinearFilter;
|
|
125
|
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
|
|
126
|
+
const s = 0.16;
|
|
127
|
+
sprite.scale.set(canvas.width*s, canvas.height*s, 1);
|
|
128
|
+
return sprite;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildNodeMesh(category) {
|
|
132
|
+
const st = ENTITY_STYLE[category] || { color: 0xaaaaaa, size: 1 };
|
|
133
|
+
let mesh;
|
|
134
|
+
if (category === '物理实体') {
|
|
135
|
+
mesh = new THREE.Mesh(new THREE.SphereGeometry(9, 26, 18), new THREE.MeshStandardMaterial({ color: st.color, roughness: 0.35, metalness: 0.15 }));
|
|
136
|
+
} else if (category === '抽象实体') {
|
|
137
|
+
mesh = new THREE.Mesh(new THREE.SphereGeometry(10, 18, 12), new THREE.MeshBasicMaterial({ color: st.color, wireframe: true }));
|
|
138
|
+
} else if (category === '数值实体') {
|
|
139
|
+
mesh = new THREE.Mesh(new THREE.BoxGeometry(13, 13, 13), new THREE.MeshStandardMaterial({ color: st.color, roughness: 0.4, metalness: 0.1 }));
|
|
140
|
+
} else {
|
|
141
|
+
mesh = new THREE.Mesh(new THREE.TorusGeometry(9.5, 3.4, 16, 42), new THREE.MeshStandardMaterial({ color: st.color, roughness: 0.35, metalness: 0.15 }));
|
|
142
|
+
}
|
|
143
|
+
mesh.scale.setScalar(st.size || 1);
|
|
144
|
+
return mesh;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
(function build() {
|
|
148
|
+
const N = GRAPH.entities.length;
|
|
149
|
+
GRAPH.entities.forEach((e, i) => {
|
|
150
|
+
const mesh = buildNodeMesh(e.category);
|
|
151
|
+
const k = i + 0.5;
|
|
152
|
+
const phi = Math.acos(1 - 2*k/Math.max(N,1));
|
|
153
|
+
const theta = Math.PI * (1 + Math.sqrt(5)) * k;
|
|
154
|
+
const R = 40 + 12*Math.sqrt(N);
|
|
155
|
+
mesh.position.set(R*Math.sin(phi)*Math.cos(theta), (R*0.6)*Math.cos(phi), R*Math.sin(phi)*Math.sin(theta));
|
|
156
|
+
mesh.userData.entityId = e.id;
|
|
157
|
+
nodeGroup.add(mesh);
|
|
158
|
+
let attrs = {};
|
|
159
|
+
try { attrs = JSON.parse(e.attributes || '{}'); } catch (_) {}
|
|
160
|
+
const label = makeLabelSprite(e.name, ENTITY_STYLE[e.category] ? ENTITY_STYLE[e.category].css : '#ccc', 34);
|
|
161
|
+
labelGroup.add(label);
|
|
162
|
+
entityMap.set(e.id, { entity: e, mesh, label, attrs });
|
|
163
|
+
simNodes.push({ id: e.id, pos: mesh.position.clone(), vel: new THREE.Vector3(), mesh, label, sizeScale: (ENTITY_STYLE[e.category]||{}).size || 1 });
|
|
164
|
+
});
|
|
165
|
+
GRAPH.relations.forEach((r) => {
|
|
166
|
+
const a = simNodes.find(n => n.id === r.source_id), b = simNodes.find(n => n.id === r.target_id);
|
|
167
|
+
if (!a || !b) return;
|
|
168
|
+
const st = RELATION_STYLE[r.category] || { color: 0x999999, dashed: false };
|
|
169
|
+
const geo = new THREE.BufferGeometry().setFromPoints([a.pos, b.pos]);
|
|
170
|
+
const mat = st.dashed
|
|
171
|
+
? new THREE.LineDashedMaterial({ color: st.color, dashSize: st.dashSize||6, gapSize: st.gapSize||4, transparent: true, opacity: st.opacity===undefined?0.9:st.opacity })
|
|
172
|
+
: new THREE.LineBasicMaterial({ color: st.color, transparent: true, opacity: st.opacity===undefined?0.9:st.opacity });
|
|
173
|
+
const line = new THREE.Line(geo, mat);
|
|
174
|
+
line.userData.relationId = r.id;
|
|
175
|
+
linkGroup.add(line);
|
|
176
|
+
const lbl = makeLabelSprite(r.name, st.css, 24);
|
|
177
|
+
lbl.position.copy(a.pos).add(b.pos).multiplyScalar(0.5);
|
|
178
|
+
labelGroup.add(lbl);
|
|
179
|
+
simLinks.push({ id: r.id, a, b, line, label: lbl, dashed: st.dashed });
|
|
180
|
+
});
|
|
181
|
+
})();
|
|
182
|
+
|
|
183
|
+
document.getElementById('legend').innerHTML =
|
|
184
|
+
'<b>实体样式</b><br>' + Object.keys(ENTITY_STYLE).map(c => '<span class="sw" style="background:'+ENTITY_STYLE[c].css+'"></span>'+c+' · '+ENTITY_STYLE[c].shape).join('<br>') +
|
|
185
|
+
'<br><b>关系线型</b><br>' + Object.keys(RELATION_STYLE).map(c => '<span class="ln '+(RELATION_STYLE[c].dashed?'dash':'')+'" style="border-color:'+RELATION_STYLE[c].css+'"></span>'+c+'关系').join('<br>');
|
|
186
|
+
|
|
187
|
+
let budget = 480;
|
|
188
|
+
function simStep() {
|
|
189
|
+
const REP = 2600, SPRING = 0.01, REST = 78, CENTER = 0.006, DAMP = 0.86;
|
|
190
|
+
for (let i = 0; i < simNodes.length; i++) for (let j = i+1; j < simNodes.length; j++) {
|
|
191
|
+
const A = simNodes[i], B = simNodes[j];
|
|
192
|
+
const dx = A.pos.x-B.pos.x, dy = A.pos.y-B.pos.y, dz = A.pos.z-B.pos.z;
|
|
193
|
+
let d2 = dx*dx+dy*dy+dz*dz; if (d2 < 4) d2 = 4;
|
|
194
|
+
const d = Math.sqrt(d2), f = REP/d2;
|
|
195
|
+
A.vel.x += dx/d*f; A.vel.y += dy/d*f; A.vel.z += dz/d*f;
|
|
196
|
+
B.vel.x -= dx/d*f; B.vel.y -= dy/d*f; B.vel.z -= dz/d*f;
|
|
197
|
+
}
|
|
198
|
+
for (const l of simLinks) {
|
|
199
|
+
const dx = l.b.pos.x-l.a.pos.x, dy = l.b.pos.y-l.a.pos.y, dz = l.b.pos.z-l.a.pos.z;
|
|
200
|
+
const d = Math.max(Math.sqrt(dx*dx+dy*dy+dz*dz), 0.01);
|
|
201
|
+
const f = SPRING*(d-REST);
|
|
202
|
+
l.a.vel.x += dx/d*f; l.a.vel.y += dy/d*f; l.a.vel.z += dz/d*f;
|
|
203
|
+
l.b.vel.x -= dx/d*f; l.b.vel.y -= dy/d*f; l.b.vel.z -= dz/d*f;
|
|
204
|
+
}
|
|
205
|
+
for (const nd of simNodes) { nd.vel.multiplyScalar(DAMP); nd.vel.addScaledVector(nd.pos, -CENTER); nd.pos.add(nd.vel); }
|
|
206
|
+
for (const l of simLinks) {
|
|
207
|
+
const pa = l.line.geometry.attributes.position;
|
|
208
|
+
pa.setXYZ(0, l.a.pos.x, l.a.pos.y, l.a.pos.z);
|
|
209
|
+
pa.setXYZ(1, l.b.pos.x, l.b.pos.y, l.b.pos.z);
|
|
210
|
+
pa.needsUpdate = true;
|
|
211
|
+
if (l.dashed) l.line.computeLineDistances();
|
|
212
|
+
l.label.position.copy(l.a.pos).add(l.b.pos).multiplyScalar(0.5);
|
|
213
|
+
}
|
|
214
|
+
for (const nd of simNodes) { nd.mesh.position.copy(nd.pos); nd.label.position.set(nd.pos.x, nd.pos.y+18, nd.pos.z); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const selected = { obj: null };
|
|
218
|
+
function animate() {
|
|
219
|
+
requestAnimationFrame(animate);
|
|
220
|
+
if (budget > 0) { simStep(); budget--; }
|
|
221
|
+
for (const nd of simNodes) nd.mesh.scale.setScalar(nd.sizeScale || 1);
|
|
222
|
+
if (selected.obj && selected.obj.type === 'entity') {
|
|
223
|
+
const nd = simNodes.find(n => n.id === selected.obj.id);
|
|
224
|
+
if (nd) nd.mesh.scale.setScalar((nd.sizeScale||1) * (1.25 + 0.08*Math.sin(Date.now()/300)));
|
|
225
|
+
}
|
|
226
|
+
controls.update();
|
|
227
|
+
renderer.render(scene, camera);
|
|
228
|
+
}
|
|
229
|
+
animate();
|
|
230
|
+
|
|
231
|
+
const raycaster = new THREE.Raycaster();
|
|
232
|
+
raycaster.params.Line = { threshold: 3 };
|
|
233
|
+
let downPos = null;
|
|
234
|
+
renderer.domElement.addEventListener('pointerdown', e => { downPos = {x:e.clientX, y:e.clientY}; });
|
|
235
|
+
renderer.domElement.addEventListener('pointerup', e => {
|
|
236
|
+
if (!downPos) return;
|
|
237
|
+
const moved = Math.hypot(e.clientX-downPos.x, e.clientY-downPos.y);
|
|
238
|
+
downPos = null;
|
|
239
|
+
if (moved > (e.pointerType === 'touch' ? 12 : 5)) return;
|
|
240
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
241
|
+
const mouse = new THREE.Vector2(((e.clientX-rect.left)/rect.width)*2-1, -((e.clientY-rect.top)/rect.height)*2+1);
|
|
242
|
+
raycaster.setFromCamera(mouse, camera);
|
|
243
|
+
const hits = raycaster.intersectObjects(nodeGroup.children, false);
|
|
244
|
+
if (hits.length) { selected.obj = { type: 'entity', id: hits[0].object.userData.entityId }; renderCard(); return; }
|
|
245
|
+
const lh = raycaster.intersectObjects(linkGroup.children, false);
|
|
246
|
+
if (lh.length) { selected.obj = { type: 'relation', id: lh[0].object.userData.relationId }; renderCard(); return; }
|
|
247
|
+
selected.obj = null; renderCard();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
function renderCard() {
|
|
251
|
+
const card = document.getElementById('info-card');
|
|
252
|
+
if (!selected.obj) { card.style.display = 'none'; return; }
|
|
253
|
+
if (selected.obj.type === 'entity') {
|
|
254
|
+
const m = entityMap.get(selected.obj.id);
|
|
255
|
+
if (!m) { card.style.display = 'none'; return; }
|
|
256
|
+
const e = m.entity;
|
|
257
|
+
let attrHtml = '';
|
|
258
|
+
for (const [k, v] of Object.entries(m.attrs)) attrHtml += '<div class="kv"><b>'+esc(k)+'</b>: '+esc(String(v))+'</div>';
|
|
259
|
+
const relCount = GRAPH.relations.filter(r => r.source_id === e.id || r.target_id === e.id).length;
|
|
260
|
+
card.innerHTML = '<h4>'+esc(e.name)+' <span class="tag" style="color:'+ENTITY_STYLE[e.category].css+';border-color:'+ENTITY_STYLE[e.category].css+'55">'+e.category+' · '+ENTITY_STYLE[e.category].shape+'</span></h4>' +
|
|
261
|
+
'<div class="kv">id: '+e.id+' 来源: '+esc(e.source)+'</div><div class="kv">关联关系: '+relCount+' 条</div>' + (attrHtml || '<div class="kv">(无属性)</div>');
|
|
262
|
+
card.style.display = 'block';
|
|
263
|
+
} else {
|
|
264
|
+
const r = GRAPH.relations.find(x => x.id === selected.obj.id);
|
|
265
|
+
if (!r) { card.style.display = 'none'; return; }
|
|
266
|
+
const s = entityMap.get(r.source_id), t = entityMap.get(r.target_id);
|
|
267
|
+
card.innerHTML = '<h4>'+esc(r.name)+' <span class="tag" style="color:'+RELATION_STYLE[r.category].css+';border-color:'+RELATION_STYLE[r.category].css+'55">'+r.category+'关系</span></h4>' +
|
|
268
|
+
'<div class="kv"><b>'+(s ? esc(s.entity.name) : '?')+'</b> --> <b>'+(t ? esc(t.entity.name) : '?')+'</b></div><div class="kv">id: '+r.id+' 来源: '+esc(r.source)+'</div>';
|
|
269
|
+
card.style.display = 'block';
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resize() {
|
|
274
|
+
const w = wrap.clientWidth, h = wrap.clientHeight;
|
|
275
|
+
camera.aspect = w/h;
|
|
276
|
+
camera.updateProjectionMatrix();
|
|
277
|
+
renderer.setSize(w, h);
|
|
278
|
+
}
|
|
279
|
+
window.addEventListener('resize', resize);
|
|
280
|
+
resize();
|
|
281
|
+
</script>
|
|
282
|
+
</body>
|
|
283
|
+
</html>
|