@musnows/scriverse 0.3.4 → 0.3.6
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/dist/ai.js +223 -15
- package/dist/ai.js.map +1 -1
- package/dist/app.js +129 -9
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +162 -0
- package/dist/attachment-storage.js.map +1 -0
- package/dist/cli-contract.js +4 -4
- package/dist/cli-contract.js.map +1 -1
- package/dist/database.js +219 -1
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +331 -32
- package/dist/public/index.html +15 -15
- package/dist/public/markdown.js +16 -1
- package/dist/public/race-hierarchy.js +49 -0
- package/dist/public/relationship-graph.js +205 -31
- package/dist/public/styles.css +91 -4
- package/dist/server-runtime.js +1 -0
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +565 -21
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +7 -0
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/dist/public/index.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<script src="/theme-init.js?v=20260720-route-skeleton"></script>
|
|
10
10
|
<link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
|
|
11
11
|
<link rel="manifest" href="/site.webmanifest">
|
|
12
|
-
<link rel="stylesheet" href="/styles.css?v=
|
|
12
|
+
<link rel="stylesheet" href="/styles.css?v=20260721-release-0.3.6">
|
|
13
13
|
</head>
|
|
14
14
|
<body class="auth-pending">
|
|
15
15
|
<section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
|
|
@@ -103,19 +103,19 @@
|
|
|
103
103
|
<button id="new-chapter-button" class="secondary-button" type="button">新建章节</button>
|
|
104
104
|
</div>
|
|
105
105
|
<nav id="module-nav" class="module-nav" aria-label="作品模块">
|
|
106
|
-
<button type="button" data-module="editor" class="active">正文</button>
|
|
107
|
-
<button type="button" data-module="settings">设定库</button>
|
|
108
|
-
<button type="button" data-module="characters">角色</button>
|
|
109
|
-
<button type="button" data-module="organizations">组织</button>
|
|
110
|
-
<button type="button" data-module="timeline">时间轴</button>
|
|
111
|
-
<button type="button" data-module="relationships">关系</button>
|
|
112
|
-
<button type="button" data-module="outlines">大纲与伏笔</button>
|
|
113
|
-
<button class="ai-analysis-entry" type="button" data-module="tasks">AI 分析</button>
|
|
114
|
-
<button id="module-more-button" type="button" aria-expanded="false">更多</button>
|
|
115
|
-
<button class="module-nav-secondary hidden" type="button" data-module="races">种族</button>
|
|
116
|
-
<button class="module-nav-secondary hidden" type="button" data-module="reviews">审核</button>
|
|
117
|
-
<button class="module-nav-secondary hidden" type="button" data-module="ai-settings">AI 设置</button>
|
|
118
|
-
<button class="module-nav-secondary hidden" type="button" data-work-settings>作品设置</button>
|
|
106
|
+
<button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
|
|
107
|
+
<button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定库</button>
|
|
108
|
+
<button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
|
|
109
|
+
<button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
|
|
110
|
+
<button type="button" data-module="timeline"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>时间轴</button>
|
|
111
|
+
<button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
|
|
112
|
+
<button type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲与伏笔</span></button>
|
|
113
|
+
<button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
|
|
114
|
+
<button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
|
|
115
|
+
<button class="module-nav-secondary hidden" type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
|
|
116
|
+
<button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
|
|
117
|
+
<button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
|
|
118
|
+
<button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
|
|
119
119
|
</nav>
|
|
120
120
|
<div class="panel-heading">
|
|
121
121
|
<span>作品目录</span>
|
|
@@ -546,6 +546,6 @@
|
|
|
546
546
|
</dialog>
|
|
547
547
|
|
|
548
548
|
<div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
|
|
549
|
-
<script type="module" src="/app.js?v=
|
|
549
|
+
<script type="module" src="/app.js?v=20260721-release-0.3.6"></script>
|
|
550
550
|
</body>
|
|
551
551
|
</html>
|
package/dist/public/markdown.js
CHANGED
|
@@ -13,6 +13,15 @@ function safeLinkTarget(value) {
|
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function safeImageTarget(value) {
|
|
17
|
+
const target = String(value ?? "").trim();
|
|
18
|
+
const attachment = target.match(/^attachment:\/\/([A-Za-z0-9_-]{1,300})$/u);
|
|
19
|
+
if (attachment) return `/api/attachments/${encodeURIComponent(attachment[1])}/content`;
|
|
20
|
+
if (/^https:\/\//iu.test(target)) return target;
|
|
21
|
+
if (target.startsWith("/") && !target.startsWith("//")) return target;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
function renderInlineMarkdown(value) {
|
|
17
26
|
const tokens = [];
|
|
18
27
|
const preserve = (html) => {
|
|
@@ -22,6 +31,12 @@ function renderInlineMarkdown(value) {
|
|
|
22
31
|
};
|
|
23
32
|
let source = String(value ?? "");
|
|
24
33
|
source = source.replace(/`([^`\n]+)`/gu, (_match, code) => preserve(`<code>${escapeHtml(code)}</code>`));
|
|
34
|
+
source = source.replace(/!\[([^\]\n]*)\]\(([^)\s]+)\)/gu, (_match, label, src) => {
|
|
35
|
+
const target = safeImageTarget(src);
|
|
36
|
+
if (!target) return `[图片:${label || src}]`;
|
|
37
|
+
const caption = String(label ?? "").trim();
|
|
38
|
+
return preserve(`<span class="markdown-image"><img src="${escapeHtml(target)}" alt="${escapeHtml(caption)}" loading="lazy" decoding="async">${caption ? `<small>${escapeHtml(caption)}</small>` : ""}</span>`);
|
|
39
|
+
});
|
|
25
40
|
source = source.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/gu, (_match, label, href) => {
|
|
26
41
|
const target = safeLinkTarget(href);
|
|
27
42
|
if (!target) return `${label}(${href})`;
|
|
@@ -153,7 +168,7 @@ export function renderMarkdown(value) {
|
|
|
153
168
|
flushBlocks();
|
|
154
169
|
continue;
|
|
155
170
|
}
|
|
156
|
-
const heading = line.match(/^(#{1,
|
|
171
|
+
const heading = line.match(/^(#{1,6})\s+(.+)$/u);
|
|
157
172
|
if (heading) {
|
|
158
173
|
flushBlocks();
|
|
159
174
|
const level = heading[1].length;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function racePathLabel(race) {
|
|
2
|
+
const names = Array.isArray(race?.lineage)
|
|
3
|
+
? race.lineage.map((item) => String(item?.name ?? "").trim()).filter(Boolean)
|
|
4
|
+
: [];
|
|
5
|
+
return names.length ? names.join(" / ") : String(race?.name ?? "").trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function raceDescendantIds(races, raceId) {
|
|
9
|
+
const childrenByParent = new Map();
|
|
10
|
+
for (const race of races) {
|
|
11
|
+
const parentRaceId = race?.parentRaceId == null ? null : String(race.parentRaceId);
|
|
12
|
+
const children = childrenByParent.get(parentRaceId) ?? [];
|
|
13
|
+
children.push(race);
|
|
14
|
+
childrenByParent.set(parentRaceId, children);
|
|
15
|
+
}
|
|
16
|
+
const descendants = new Set();
|
|
17
|
+
const pending = [...(childrenByParent.get(String(raceId)) ?? [])];
|
|
18
|
+
while (pending.length) {
|
|
19
|
+
const race = pending.pop();
|
|
20
|
+
const id = String(race?.id ?? "");
|
|
21
|
+
if (!id || descendants.has(id)) continue;
|
|
22
|
+
descendants.add(id);
|
|
23
|
+
pending.push(...(childrenByParent.get(id) ?? []));
|
|
24
|
+
}
|
|
25
|
+
return descendants;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function eligibleRaceParents(races, currentRaceId = null) {
|
|
29
|
+
if (!currentRaceId) return [...races];
|
|
30
|
+
const excluded = raceDescendantIds(races, currentRaceId);
|
|
31
|
+
excluded.add(String(currentRaceId));
|
|
32
|
+
return races.filter((race) => !excluded.has(String(race?.id ?? "")));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildRaceForest(races) {
|
|
36
|
+
const nodes = new Map(races.map((race) => [String(race.id), { ...race, children: [] }]));
|
|
37
|
+
const roots = [];
|
|
38
|
+
for (const node of nodes.values()) {
|
|
39
|
+
const parent = node.parentRaceId ? nodes.get(String(node.parentRaceId)) : null;
|
|
40
|
+
if (parent && parent !== node) parent.children.push(node);
|
|
41
|
+
else roots.push(node);
|
|
42
|
+
}
|
|
43
|
+
const sort = (items) => {
|
|
44
|
+
items.sort((left, right) => String(left.name).localeCompare(String(right.name), "zh-CN"));
|
|
45
|
+
for (const item of items) sort(item.children);
|
|
46
|
+
return items;
|
|
47
|
+
};
|
|
48
|
+
return sort(roots);
|
|
49
|
+
}
|
|
@@ -21,12 +21,29 @@ const NETWORK_LAYOUTS = Object.freeze({
|
|
|
21
21
|
standard: Object.freeze({ width: 1200, height: 640, marginX: 48, marginY: 42, desiredEdgeLength: 196, repulsionStrength: 16800 }),
|
|
22
22
|
expanded: Object.freeze({ width: 1600, height: 900, marginX: 64, marginY: 56, desiredEdgeLength: 236, repulsionStrength: 22800 })
|
|
23
23
|
});
|
|
24
|
+
const RELATIONSHIP_EDGE_GAP = 24;
|
|
25
|
+
let relationshipRendererSequence = 0;
|
|
26
|
+
const GALAXY_CELESTIAL_PALETTES = Object.freeze([
|
|
27
|
+
Object.freeze({ key: "solar", hue: 42, saturation: 96, lightness: 68, color: "#ffc95f", core: "#fff8d4", rim: "#9f3c18", atmosphere: "rgba(255,184,72,.58)", ring: "rgba(255,222,151,.72)" }),
|
|
28
|
+
Object.freeze({ key: "azure", hue: 211, saturation: 94, lightness: 68, color: "#61b8ff", core: "#effaff", rim: "#173b85", atmosphere: "rgba(79,156,255,.56)", ring: "rgba(164,214,255,.68)" }),
|
|
29
|
+
Object.freeze({ key: "violet", hue: 263, saturation: 82, lightness: 72, color: "#b58cff", core: "#f7edff", rim: "#4d237c", atmosphere: "rgba(151,92,255,.54)", ring: "rgba(219,190,255,.7)" }),
|
|
30
|
+
Object.freeze({ key: "rose", hue: 342, saturation: 91, lightness: 70, color: "#ff739d", core: "#fff0f5", rim: "#7f1e42", atmosphere: "rgba(255,91,139,.52)", ring: "rgba(255,190,210,.68)" }),
|
|
31
|
+
Object.freeze({ key: "emerald", hue: 158, saturation: 67, lightness: 58, color: "#4ed49e", core: "#e7fff5", rim: "#145f4b", atmosphere: "rgba(50,211,154,.5)", ring: "rgba(166,244,214,.66)" }),
|
|
32
|
+
Object.freeze({ key: "ice", hue: 190, saturation: 88, lightness: 76, color: "#9beaff", core: "#f4fdff", rim: "#26627c", atmosphere: "rgba(116,224,255,.52)", ring: "rgba(206,246,255,.7)" }),
|
|
33
|
+
Object.freeze({ key: "copper", hue: 22, saturation: 74, lightness: 61, color: "#df8851", core: "#ffe8cf", rim: "#672b1c", atmosphere: "rgba(226,103,55,.48)", ring: "rgba(239,179,125,.66)" }),
|
|
34
|
+
Object.freeze({ key: "pearl", hue: 47, saturation: 31, lightness: 84, color: "#e7dfc7", core: "#ffffff", rim: "#6c6b78", atmosphere: "rgba(207,217,240,.46)", ring: "rgba(237,231,216,.72)" })
|
|
35
|
+
]);
|
|
36
|
+
const GALAXY_CELESTIAL_TYPES = Object.freeze({
|
|
37
|
+
core: Object.freeze(["star", "star", "gas-giant", "ringed"]),
|
|
38
|
+
active: Object.freeze(["gas-giant", "ringed", "ocean", "ice", "volcanic"]),
|
|
39
|
+
outer: Object.freeze(["rocky", "ocean", "ice", "volcanic", "dwarf", "ringed"])
|
|
40
|
+
});
|
|
24
41
|
export const GALAXY_ROTATION_RADIANS_PER_MS = 0.000012;
|
|
25
42
|
export const GALAXY_LAYOUT_CONFIG = Object.freeze({
|
|
26
|
-
minimumRadius:
|
|
27
|
-
radialSpan:
|
|
28
|
-
repulsionStrength:
|
|
29
|
-
desiredEdgeLength:
|
|
43
|
+
minimumRadius: 220,
|
|
44
|
+
radialSpan: 830,
|
|
45
|
+
repulsionStrength: 9200,
|
|
46
|
+
desiredEdgeLength: 285
|
|
30
47
|
});
|
|
31
48
|
|
|
32
49
|
export function formatRelationshipLabel(edge, separator = " · ") {
|
|
@@ -37,6 +54,17 @@ export function formatRelationshipLabel(edge, separator = " · ") {
|
|
|
37
54
|
return [subtype, ...keywords].filter(Boolean).join(separator) || "关系";
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
export function formatRelationshipStatusNote(edge) {
|
|
58
|
+
const statuses = [];
|
|
59
|
+
if (String(edge?.confirmationStatus ?? "pending") === "pending") statuses.push("待确认");
|
|
60
|
+
if (String(edge?.category ?? "") === "uncertain") statuses.push("关系类型未确定");
|
|
61
|
+
return statuses.length ? `(${statuses.join(" · ")})` : "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatRelationshipDetailLabel(edge) {
|
|
65
|
+
return `${formatRelationshipLabel(edge)}${formatRelationshipStatusNote(edge)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
export function groupRelationshipDetailsByCharacterName(graph, nodeId) {
|
|
41
69
|
const groups = new Map();
|
|
42
70
|
for (const edge of graph.edges) {
|
|
@@ -56,21 +84,90 @@ export function getRelationshipEdgeSelection(graph, edgeId) {
|
|
|
56
84
|
if (!edge) return null;
|
|
57
85
|
return {
|
|
58
86
|
edgeId: edge.id,
|
|
87
|
+
directed: edge.directed,
|
|
59
88
|
endpointIds: [edge.source, edge.target],
|
|
60
89
|
endpointNames: [graph.nodeById.get(edge.source)?.name ?? "未知角色", graph.nodeById.get(edge.target)?.name ?? "未知角色"],
|
|
61
90
|
label: formatRelationshipLabel(edge)
|
|
62
91
|
};
|
|
63
92
|
}
|
|
64
93
|
|
|
94
|
+
export function assignRelationshipEdgeCurves(edges, gap = RELATIONSHIP_EDGE_GAP) {
|
|
95
|
+
const groups = new Map();
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
const endpoints = [String(edge.source), String(edge.target)].sort((left, right) => left.localeCompare(right));
|
|
98
|
+
const key = endpoints.join("\u0000");
|
|
99
|
+
const group = groups.get(key) ?? [];
|
|
100
|
+
group.push(edge);
|
|
101
|
+
groups.set(key, group);
|
|
102
|
+
}
|
|
103
|
+
const offsets = new Map();
|
|
104
|
+
for (const group of groups.values()) {
|
|
105
|
+
const ordered = [...group].sort((left, right) => String(left.id).localeCompare(String(right.id)));
|
|
106
|
+
ordered.forEach((edge, index) => {
|
|
107
|
+
const canonicalOffset = (index - (ordered.length - 1) / 2) * gap;
|
|
108
|
+
const followsCanonicalDirection = String(edge.source).localeCompare(String(edge.target)) <= 0;
|
|
109
|
+
offsets.set(String(edge.id), canonicalOffset * (followsCanonicalDirection ? 1 : -1));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return offsets;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getRelationshipEdgeGeometry(from, to, sourceRadius = 0, targetRadius = 0, curveOffset = 0) {
|
|
116
|
+
const dx = Number(to?.x ?? 0) - Number(from?.x ?? 0);
|
|
117
|
+
const dy = Number(to?.y ?? 0) - Number(from?.y ?? 0);
|
|
118
|
+
const distance = Math.max(1, Math.hypot(dx, dy));
|
|
119
|
+
const normalX = -dy / distance;
|
|
120
|
+
const normalY = dx / distance;
|
|
121
|
+
const control = {
|
|
122
|
+
x: Number(from?.x ?? 0) + dx / 2 + normalX * curveOffset * 2,
|
|
123
|
+
y: Number(from?.y ?? 0) + dy / 2 + normalY * curveOffset * 2
|
|
124
|
+
};
|
|
125
|
+
const sourceTangentX = control.x - Number(from?.x ?? 0);
|
|
126
|
+
const sourceTangentY = control.y - Number(from?.y ?? 0);
|
|
127
|
+
const sourceTangentLength = Math.max(1, Math.hypot(sourceTangentX, sourceTangentY));
|
|
128
|
+
const targetTangentX = Number(to?.x ?? 0) - control.x;
|
|
129
|
+
const targetTangentY = Number(to?.y ?? 0) - control.y;
|
|
130
|
+
const targetTangentLength = Math.max(1, Math.hypot(targetTangentX, targetTangentY));
|
|
131
|
+
const startClearance = Math.max(0, Number(sourceRadius) || 0) + 2;
|
|
132
|
+
const endClearance = Math.max(0, Number(targetRadius) || 0) + 2;
|
|
133
|
+
const start = {
|
|
134
|
+
x: Number(from?.x ?? 0) + sourceTangentX / sourceTangentLength * startClearance,
|
|
135
|
+
y: Number(from?.y ?? 0) + sourceTangentY / sourceTangentLength * startClearance
|
|
136
|
+
};
|
|
137
|
+
const end = {
|
|
138
|
+
x: Number(to?.x ?? 0) - targetTangentX / targetTangentLength * endClearance,
|
|
139
|
+
y: Number(to?.y ?? 0) - targetTangentY / targetTangentLength * endClearance
|
|
140
|
+
};
|
|
141
|
+
const curved = Math.abs(curveOffset) > 0.01;
|
|
142
|
+
const labelPoint = curved
|
|
143
|
+
? {
|
|
144
|
+
x: (start.x + end.x) / 4 + control.x / 2,
|
|
145
|
+
y: (start.y + end.y) / 4 + control.y / 2
|
|
146
|
+
}
|
|
147
|
+
: { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
148
|
+
return {
|
|
149
|
+
path: curved
|
|
150
|
+
? `M ${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${control.x.toFixed(1)} ${control.y.toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}`
|
|
151
|
+
: `M ${start.x.toFixed(1)} ${start.y.toFixed(1)} L ${end.x.toFixed(1)} ${end.y.toFixed(1)}`,
|
|
152
|
+
labelX: labelPoint.x,
|
|
153
|
+
labelY: labelPoint.y,
|
|
154
|
+
angle: Math.atan2(end.y - start.y, end.x - start.x) * 180 / Math.PI
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
65
158
|
export function resolveRelationshipNodeGroup(node) {
|
|
66
159
|
const organizations = Array.isArray(node?.organizations) ? node.organizations : [];
|
|
67
160
|
const orgName = organizations
|
|
68
161
|
.map((item) => String(item?.name ?? item ?? "").trim())
|
|
69
162
|
.find(Boolean);
|
|
70
163
|
const species = String(node?.species ?? "").trim();
|
|
164
|
+
const rootSpecies = String(node?.rootSpecies ?? "").trim();
|
|
71
165
|
const identity = String(node?.identity ?? "").trim();
|
|
72
166
|
if (orgName) return { type: "organization", key: `org:${orgName}`, label: orgName };
|
|
73
|
-
if (
|
|
167
|
+
if (rootSpecies || species) {
|
|
168
|
+
const groupSpecies = rootSpecies || species;
|
|
169
|
+
return { type: "species", key: `species:${groupSpecies}`, label: groupSpecies };
|
|
170
|
+
}
|
|
74
171
|
if (identity) return { type: "identity", key: `identity:${identity}`, label: identity };
|
|
75
172
|
return { type: "default", key: "default", label: "未分组" };
|
|
76
173
|
}
|
|
@@ -100,6 +197,15 @@ function hashString(value) {
|
|
|
100
197
|
return hash >>> 0;
|
|
101
198
|
}
|
|
102
199
|
|
|
200
|
+
function mixHash(value) {
|
|
201
|
+
let mixed = Number(value) >>> 0;
|
|
202
|
+
mixed ^= mixed >>> 16;
|
|
203
|
+
mixed = Math.imul(mixed, 0x7feb352d);
|
|
204
|
+
mixed ^= mixed >>> 15;
|
|
205
|
+
mixed = Math.imul(mixed, 0x846ca68b);
|
|
206
|
+
return (mixed ^ (mixed >>> 16)) >>> 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
103
209
|
function seededRandom(seed) {
|
|
104
210
|
let value = seed || 1;
|
|
105
211
|
return () => {
|
|
@@ -126,6 +232,7 @@ export function buildRelationshipGraph(characters, relationships) {
|
|
|
126
232
|
name: String(character.name),
|
|
127
233
|
aliases: Array.isArray(character.aliases) ? character.aliases : [],
|
|
128
234
|
species: String(character.species ?? ""),
|
|
235
|
+
rootSpecies: String(character.race?.lineage?.[0]?.name ?? character.species ?? ""),
|
|
129
236
|
identity: String(character.attributes?.identity ?? ""),
|
|
130
237
|
organizations,
|
|
131
238
|
locked: Array.isArray(character.lockedFields) && character.lockedFields.length > 0,
|
|
@@ -673,15 +780,41 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
|
|
|
673
780
|
svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
|
|
674
781
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
675
782
|
svg.setAttribute("aria-label", "人物关系连线");
|
|
783
|
+
const arrowMarkerId = `relationship-edge-arrow-${++relationshipRendererSequence}`;
|
|
784
|
+
const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
|
785
|
+
const arrowMarker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
|
|
786
|
+
arrowMarker.id = arrowMarkerId;
|
|
787
|
+
arrowMarker.setAttribute("viewBox", "0 0 8 8");
|
|
788
|
+
arrowMarker.setAttribute("markerWidth", "8");
|
|
789
|
+
arrowMarker.setAttribute("markerHeight", "8");
|
|
790
|
+
arrowMarker.setAttribute("refX", "7");
|
|
791
|
+
arrowMarker.setAttribute("refY", "4");
|
|
792
|
+
arrowMarker.setAttribute("markerUnits", "userSpaceOnUse");
|
|
793
|
+
arrowMarker.setAttribute("orient", "auto");
|
|
794
|
+
const arrowHead = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
795
|
+
arrowHead.classList.add("relationship-edge-arrow");
|
|
796
|
+
arrowHead.setAttribute("d", "M 0 0 L 8 4 L 0 8 Z");
|
|
797
|
+
arrowHead.setAttribute("fill", "context-stroke");
|
|
798
|
+
arrowMarker.append(arrowHead);
|
|
799
|
+
definitions.append(arrowMarker);
|
|
800
|
+
svg.append(definitions);
|
|
676
801
|
const edgeElements = [];
|
|
677
802
|
const edgeElementsByNode = new Map(graph.nodes.map((node) => [node.id, []]));
|
|
803
|
+
const edgeCurveOffsets = assignRelationshipEdgeCurves(graph.edges);
|
|
804
|
+
const nodeVisualRadii = new Map(graph.nodes.map((node) => [node.id, Math.max(4, Number(node.nodeSize) / 2 || 4)]));
|
|
678
805
|
const updateEdgeGeometry = ({ edge, hitPath, path }, { includeHit = true } = {}) => {
|
|
679
806
|
const from = positions.get(edge.source);
|
|
680
807
|
const to = positions.get(edge.target);
|
|
681
808
|
if (!from || !to) return;
|
|
682
|
-
const geometry =
|
|
683
|
-
|
|
684
|
-
|
|
809
|
+
const geometry = getRelationshipEdgeGeometry(
|
|
810
|
+
from,
|
|
811
|
+
to,
|
|
812
|
+
nodeVisualRadii.get(edge.source),
|
|
813
|
+
nodeVisualRadii.get(edge.target),
|
|
814
|
+
edgeCurveOffsets.get(edge.id)
|
|
815
|
+
);
|
|
816
|
+
path.setAttribute("d", geometry.path);
|
|
817
|
+
if (includeHit) hitPath.setAttribute("d", geometry.path);
|
|
685
818
|
};
|
|
686
819
|
const updateLabelGeometry = (edge) => {
|
|
687
820
|
const from = positions.get(edge.source);
|
|
@@ -690,9 +823,16 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
|
|
|
690
823
|
const dx = to.x - from.x;
|
|
691
824
|
const dy = to.y - from.y;
|
|
692
825
|
const distance = Math.max(1, Math.hypot(dx, dy));
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
826
|
+
const geometry = getRelationshipEdgeGeometry(
|
|
827
|
+
from,
|
|
828
|
+
to,
|
|
829
|
+
nodeVisualRadii.get(edge.source),
|
|
830
|
+
nodeVisualRadii.get(edge.target),
|
|
831
|
+
edgeCurveOffsets.get(edge.id)
|
|
832
|
+
);
|
|
833
|
+
const middleX = geometry.labelX;
|
|
834
|
+
const middleY = geometry.labelY - 4;
|
|
835
|
+
let angle = geometry.angle;
|
|
696
836
|
if (angle > 90 || angle < -90) angle += 180;
|
|
697
837
|
const fullLabel = label.dataset.fullLabel || label.textContent || "";
|
|
698
838
|
// 按连线长度截断,短边只显示极短摘要,完整内容在底部详情
|
|
@@ -719,6 +859,13 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
|
|
|
719
859
|
path.style.setProperty("--edge-opacity", "0.24");
|
|
720
860
|
path.style.setProperty("--edge-width", "1");
|
|
721
861
|
if (edge.confirmationStatus === "pending") path.classList.add("is-pending");
|
|
862
|
+
if (edge.directed) {
|
|
863
|
+
path.classList.add("is-directed");
|
|
864
|
+
path.setAttribute("marker-end", `url(#${arrowMarkerId})`);
|
|
865
|
+
}
|
|
866
|
+
const sourceName = graph.nodeById.get(edge.source)?.name ?? "未知角色";
|
|
867
|
+
const targetName = graph.nodeById.get(edge.target)?.name ?? "未知角色";
|
|
868
|
+
hitPath.setAttribute("aria-label", `选择 ${sourceName}${edge.directed ? " 指向 " : " 与 "}${targetName} 的关系:${formatRelationshipLabel(edge)}`);
|
|
722
869
|
svg.append(hitPath, path);
|
|
723
870
|
const edgeElement = { edge, hitPath, path };
|
|
724
871
|
edgeElements.push(edgeElement);
|
|
@@ -1023,11 +1170,12 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
|
|
|
1023
1170
|
label.dataset.fullLabel = fullLabel;
|
|
1024
1171
|
label.classList.remove("hidden");
|
|
1025
1172
|
updateLabelGeometry(edgeElement.edge);
|
|
1026
|
-
|
|
1173
|
+
const direction = selection.directed ? "→" : "↔";
|
|
1174
|
+
focusText.textContent = `关系:${selection.endpointNames[0]} ${direction} ${selection.endpointNames[1]}`;
|
|
1027
1175
|
const heading = document.createElement("b");
|
|
1028
|
-
heading.textContent = `${selection.endpointNames[0]}
|
|
1176
|
+
heading.textContent = `${selection.endpointNames[0]} ${direction} ${selection.endpointNames[1]}`;
|
|
1029
1177
|
const detailText = document.createElement("span");
|
|
1030
|
-
detailText.textContent =
|
|
1178
|
+
detailText.textContent = formatRelationshipDetailLabel(edgeElement.edge);
|
|
1031
1179
|
edgeDetail.replaceChildren(heading, detailText);
|
|
1032
1180
|
edgeDetail.classList.remove("hidden");
|
|
1033
1181
|
};
|
|
@@ -1348,17 +1496,19 @@ export function createGalaxyStarfield(seed, count = 3600) {
|
|
|
1348
1496
|
const stars = [];
|
|
1349
1497
|
const armCount = 4;
|
|
1350
1498
|
for (let index = 0; index < count; index += 1) {
|
|
1351
|
-
const radius = 55 + Math.pow(random(), 0.62) *
|
|
1499
|
+
const radius = 55 + Math.pow(random(), 0.62) * 1380;
|
|
1352
1500
|
const arm = index % armCount;
|
|
1353
1501
|
const armAngle = arm / armCount * Math.PI * 2;
|
|
1354
1502
|
const angle = armAngle + radius * 0.0065 + (random() - 0.5) * (0.42 + radius / 1100);
|
|
1355
1503
|
const thickness = 22 + radius * 0.105;
|
|
1504
|
+
const temperature = random();
|
|
1356
1505
|
stars.push({
|
|
1357
1506
|
x: Math.cos(angle) * radius + (random() - 0.5) * 62,
|
|
1358
1507
|
y: (random() + random() + random() - 1.5) * thickness,
|
|
1359
1508
|
z: Math.sin(angle) * radius + (random() - 0.5) * 62,
|
|
1360
1509
|
size: random() > 0.965 ? 1.7 + random() * 1.4 : 0.45 + random() * 0.85,
|
|
1361
|
-
brightness: 0.22 + random() * 0.78
|
|
1510
|
+
brightness: 0.22 + random() * 0.78,
|
|
1511
|
+
color: temperature < 0.2 ? "255,218,176" : temperature > 0.78 ? "174,211,255" : "226,237,255"
|
|
1362
1512
|
});
|
|
1363
1513
|
}
|
|
1364
1514
|
return stars;
|
|
@@ -1404,22 +1554,37 @@ export function getGalaxyNodeAppearance(node, maxDegree) {
|
|
|
1404
1554
|
const weightedDegree = Math.max(0, Number(node?.weightedDegree) || 0);
|
|
1405
1555
|
const confidenceBoost = clamp(weightedDegree / Math.max(1, degree) / 1.35, 0, 1);
|
|
1406
1556
|
const intensity = clamp(normalizedDegree * 0.8 + confidenceBoost * 0.2, 0, 1);
|
|
1407
|
-
const hue = Math.round(218 - intensity * 166);
|
|
1408
|
-
const saturation = Math.round(58 + intensity * 35);
|
|
1409
|
-
const lightness = Math.round(47 + intensity * 27);
|
|
1410
1557
|
const brightness = (0.7 + intensity * 0.68).toFixed(3);
|
|
1411
1558
|
const glow = (0.26 + intensity * 0.74).toFixed(3);
|
|
1412
1559
|
const tier = intensity >= 0.7 ? "core" : intensity >= 0.34 ? "active" : "outer";
|
|
1560
|
+
const appearanceSeed = mixHash(hashString([
|
|
1561
|
+
String(node?.id ?? ""),
|
|
1562
|
+
String(node?.name ?? ""),
|
|
1563
|
+
String(node?.groupKey ?? ""),
|
|
1564
|
+
String(node?.species ?? ""),
|
|
1565
|
+
String(node?.identity ?? "")
|
|
1566
|
+
].join("|")));
|
|
1567
|
+
const palette = GALAXY_CELESTIAL_PALETTES[appearanceSeed % GALAXY_CELESTIAL_PALETTES.length];
|
|
1568
|
+
const celestialTypes = GALAXY_CELESTIAL_TYPES[tier];
|
|
1569
|
+
const celestialType = celestialTypes[Math.floor(appearanceSeed / GALAXY_CELESTIAL_PALETTES.length) % celestialTypes.length];
|
|
1570
|
+
const sizeScale = ({ star: 1.18, "gas-giant": 1.12, ringed: 1.08, ocean: 1, ice: 0.96, volcanic: 1.02, rocky: 0.92, dwarf: 0.76 })[celestialType] ?? 1;
|
|
1413
1571
|
return {
|
|
1414
1572
|
degree,
|
|
1415
1573
|
intensity,
|
|
1416
|
-
hue,
|
|
1417
|
-
saturation,
|
|
1418
|
-
lightness,
|
|
1574
|
+
hue: palette.hue,
|
|
1575
|
+
saturation: palette.saturation,
|
|
1576
|
+
lightness: palette.lightness,
|
|
1419
1577
|
brightness,
|
|
1420
1578
|
glow,
|
|
1421
1579
|
tier,
|
|
1422
|
-
|
|
1580
|
+
palette: palette.key,
|
|
1581
|
+
celestialType,
|
|
1582
|
+
sizeScale,
|
|
1583
|
+
color: palette.color,
|
|
1584
|
+
coreColor: palette.core,
|
|
1585
|
+
rimColor: palette.rim,
|
|
1586
|
+
atmosphereColor: palette.atmosphere,
|
|
1587
|
+
ringColor: palette.ring
|
|
1423
1588
|
};
|
|
1424
1589
|
}
|
|
1425
1590
|
|
|
@@ -1427,6 +1592,10 @@ export function getGalaxyNodeMarkerCenterOffset(nodeSize) {
|
|
|
1427
1592
|
return 8 + Math.max(0, Number(nodeSize) || 0) / 2;
|
|
1428
1593
|
}
|
|
1429
1594
|
|
|
1595
|
+
export function getGalaxyNodeDepthOpacity(depth) {
|
|
1596
|
+
return clamp(1.28 - Math.max(0, Number(depth) || 0) / 4800, 0.72, 1);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1430
1599
|
export function distanceToGalaxyEdge(point, from, to) {
|
|
1431
1600
|
const deltaX = to.x - from.x;
|
|
1432
1601
|
const deltaY = to.y - from.y;
|
|
@@ -1457,7 +1626,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
|
|
|
1457
1626
|
const layout = layoutGalaxy(graph, seed);
|
|
1458
1627
|
const stars = createGalaxyStarfield(`${seed}|stars`);
|
|
1459
1628
|
const initialNodePositions = new Map(layout.nodes.map((node) => [node.id, { x: node.x, y: node.y, z: node.z }]));
|
|
1460
|
-
const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance:
|
|
1629
|
+
const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance: 1560, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
|
|
1461
1630
|
const camera = { ...initialCamera };
|
|
1462
1631
|
const nodeElements = new Map();
|
|
1463
1632
|
const cleanups = [];
|
|
@@ -1556,7 +1725,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
|
|
|
1556
1725
|
const radius = star.size * perspective;
|
|
1557
1726
|
const twinkle = 0.82 + Math.sin(index * 12.9898 + camera.yaw * 5) * 0.18;
|
|
1558
1727
|
const alpha = clamp(star.brightness * twinkle * perspective, 0.08, 0.92);
|
|
1559
|
-
context.fillStyle = `rgba(
|
|
1728
|
+
context.fillStyle = `rgba(${star.color},${alpha})`;
|
|
1560
1729
|
context.beginPath();
|
|
1561
1730
|
context.arc(point.x, point.y, Math.max(0.28, radius), 0, Math.PI * 2);
|
|
1562
1731
|
context.fill();
|
|
@@ -1662,7 +1831,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
|
|
|
1662
1831
|
element.style.transformOrigin = `50% ${markerCenterOffset}px`;
|
|
1663
1832
|
element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -${markerCenterOffset}px) scale(${perspective * selectedScale})`;
|
|
1664
1833
|
element.style.zIndex = String(10000 - Math.round(point.depth));
|
|
1665
|
-
element.style.setProperty("--depth-opacity", String(
|
|
1834
|
+
element.style.setProperty("--depth-opacity", String(getGalaxyNodeDepthOpacity(point.depth)));
|
|
1666
1835
|
element.dataset.worldX = node.x.toFixed(2);
|
|
1667
1836
|
element.dataset.worldY = node.y.toFixed(2);
|
|
1668
1837
|
element.dataset.worldZ = node.z.toFixed(2);
|
|
@@ -1773,7 +1942,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
|
|
|
1773
1942
|
category.className = edge.category;
|
|
1774
1943
|
return category;
|
|
1775
1944
|
});
|
|
1776
|
-
const labels = [...new Set(group.edges.map((edge) =>
|
|
1945
|
+
const labels = [...new Set(group.edges.map((edge) => formatRelationshipDetailLabel(edge)))];
|
|
1777
1946
|
item.append(...categories, document.createTextNode(`${group.name} · ${labels.join(";")}`));
|
|
1778
1947
|
list.append(item);
|
|
1779
1948
|
}
|
|
@@ -1786,11 +1955,11 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
|
|
|
1786
1955
|
detail.classList.remove("hidden");
|
|
1787
1956
|
detail.replaceChildren();
|
|
1788
1957
|
const heading = document.createElement("strong");
|
|
1789
|
-
heading.textContent = selection.endpointNames.join(" ↔ ");
|
|
1958
|
+
heading.textContent = selection.endpointNames.join(selection.directed ? " → " : " ↔ ");
|
|
1790
1959
|
const category = document.createElement("small");
|
|
1791
1960
|
category.textContent = RELATION_STYLE[edge.category].label;
|
|
1792
1961
|
const description = document.createElement("p");
|
|
1793
|
-
description.textContent =
|
|
1962
|
+
description.textContent = formatRelationshipDetailLabel(edge);
|
|
1794
1963
|
detail.append(heading, category, description);
|
|
1795
1964
|
shell.dataset.selectedEdgeSource = selection.endpointIds[0];
|
|
1796
1965
|
shell.dataset.selectedEdgeTarget = selection.endpointIds[1];
|
|
@@ -1807,17 +1976,22 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
|
|
|
1807
1976
|
button.className = "galaxy-node";
|
|
1808
1977
|
button.dataset.galaxyNode = node.id;
|
|
1809
1978
|
button.dataset.relationshipTier = appearance.tier;
|
|
1810
|
-
|
|
1979
|
+
button.dataset.celestialType = appearance.celestialType;
|
|
1980
|
+
button.dataset.celestialPalette = appearance.palette;
|
|
1981
|
+
const nodeSize = clamp((10 + Math.sqrt(node.degree / maxDegree) * 28) * appearance.sizeScale, 8, 48);
|
|
1811
1982
|
button.style.setProperty("--node-size", `${nodeSize}px`);
|
|
1812
1983
|
button.dataset.nodeSize = nodeSize.toFixed(3);
|
|
1813
1984
|
button.style.setProperty("--node-color", appearance.color);
|
|
1985
|
+
button.style.setProperty("--node-core", appearance.coreColor);
|
|
1986
|
+
button.style.setProperty("--node-rim", appearance.rimColor);
|
|
1987
|
+
button.style.setProperty("--node-atmosphere", appearance.atmosphereColor);
|
|
1988
|
+
button.style.setProperty("--node-ring", appearance.ringColor);
|
|
1814
1989
|
button.style.setProperty("--node-brightness", appearance.brightness);
|
|
1815
1990
|
button.style.setProperty("--node-glow", appearance.glow);
|
|
1816
1991
|
const marker = document.createElement("i");
|
|
1817
1992
|
const label = document.createElement("span");
|
|
1818
1993
|
label.textContent = node.name;
|
|
1819
1994
|
button.append(marker, label);
|
|
1820
|
-
button.title = `${node.degree} 条关系 · ${appearance.tier === "core" ? "核心高亮" : appearance.tier === "active" ? "活跃连接" : "外围连接"}`;
|
|
1821
1995
|
button.setAttribute("aria-label", `${node.name},${node.degree} 条关系,${appearance.tier === "core" ? "核心高亮" : appearance.tier === "active" ? "活跃连接" : "外围连接"}${node.aliases.length ? `,别名 ${node.aliases.join("、")}` : ""}`);
|
|
1822
1996
|
button.setAttribute("aria-grabbed", "false");
|
|
1823
1997
|
let nodeDrag = null;
|