@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.
@@ -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=20260720-captcha-lazy-load">
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=20260720-captcha-lazy-load"></script>
549
+ <script type="module" src="/app.js?v=20260721-release-0.3.6"></script>
550
550
  </body>
551
551
  </html>
@@ -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,4})\s+(.+)$/u);
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: 165,
27
- radialSpan: 690,
28
- repulsionStrength: 5200,
29
- desiredEdgeLength: 210
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 (species) return { type: "species", key: `species:${species}`, label: species };
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 = `M ${from.x.toFixed(1)} ${from.y.toFixed(1)} L ${to.x.toFixed(1)} ${to.y.toFixed(1)}`;
683
- path.setAttribute("d", geometry);
684
- if (includeHit) hitPath.setAttribute("d", geometry);
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 middleX = (from.x + to.x) / 2;
694
- const middleY = (from.y + to.y) / 2 - 4;
695
- let angle = Math.atan2(dy, dx) * 180 / Math.PI;
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
- focusText.textContent = `关系:${selection.endpointNames[0]} ${selection.endpointNames[1]}`;
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]}与${selection.endpointNames[1]}`;
1176
+ heading.textContent = `${selection.endpointNames[0]} ${direction} ${selection.endpointNames[1]}`;
1029
1177
  const detailText = document.createElement("span");
1030
- detailText.textContent = selection.label;
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) * 1120;
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
- color: `hsl(${hue} ${saturation}% ${lightness}%)`
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: 1420, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
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(216,235,255,${alpha})`;
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(clamp(1.45 - point.depth / 2300, 0.38, 1)));
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) => formatRelationshipLabel(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 = selection.label;
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
- const nodeSize = 10 + Math.sqrt(node.degree / maxDegree) * 28;
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;