temp savepoints. port succeeds. minor errors to be fixed.

This commit is contained in:
Kur0mi 2026-06-21 21:05:56 +02:00
parent e0c280a05c
commit a726ca20e1
55 changed files with 30746 additions and 9607 deletions

3
.gitignore vendored
View file

@ -2,3 +2,6 @@
node_modules/
data.json
*.map
dist/
dev-vault/
*.log

View file

@ -1,6 +1,9 @@
# Mini World Map
Mini World Map is an Obsidian plugin that visualizes your vault as a hierarchy-first world map, with Obsidian note links layered on top.
Mini World Map is an Obsidian plugin that visualizes your vault as a hierarchy-first world map, with Obsidian note links layered on top. It now has two rendering modes:
- **2D radial rings:** the default hierarchy-first Mini World Map view.
- **3D map:** a Galaxy-derived Three.js map for flying through the vault link graph.
It is designed for people who use folders as meaningful topic structure and links as cross-topic associations. The map helps you see both at once: where a note lives in the vault hierarchy, and which other notes or topics it connects to.
@ -18,6 +21,7 @@ The goal is to complement Obsidian's native Graph View and conventional mind-map
## Features
- **Two render modes:** switch between 2D radial rings and a 3D map from the view panel.
- **Atlas view:** browse folders and notes as a radial world map rooted at the whole vault or at any folder.
- **Focus view:** center the map around the active note and show its ancestors, siblings, outgoing links, and backlinks.
- **Concentric hierarchy rings:** render parent folders closer to the center and child folders or notes farther outward.
@ -32,9 +36,22 @@ The goal is to complement Obsidian's native Graph View and conventional mind-map
- **Canvas controls:** pan, zoom, drag nodes temporarily, reset the view, and switch between bounded and complete root detail.
- **Adaptive detail:** automatically adjusts depth, node budget, and link budget when the map is large.
- **Display controls:** configure atlas depth, node limit, link limit, outside-link detail, label visibility, spin speed, color scheme, and ignored folders.
- **Appearance modes:** follow Obsidian automatically, or force Mini World Map's Day or Night palette.
- **Appearance modes:** follow Obsidian/system by default, or force Mini World Map's Light or Night palette.
- **Panel language:** use English by default, with Chinese available for both 2D and 3D panels.
- **Local-only indexing:** builds the map from your local vault metadata without network services or telemetry.
## Development
Mini World Map is now built from TypeScript source.
```bash
npm install
npm test
npm run build
```
`npm run build` validates TypeScript and writes release assets. The checked-in `main.js` is the generated bundle; the previous legacy bundle is preserved at `legacy/main.legacy.js`.
## Usage
1. Enable Mini World Map in **Settings -> Community plugins**.
@ -54,7 +71,9 @@ Useful interactions:
Mini World Map includes settings for:
- Color scheme: Auto, Day, or Night.
- Language: English or Chinese.
- Color scheme: System, Light, or Night.
- Default render mode: 2D radial rings or 3D map.
- Default atlas depth.
- Default link overlay limit.
- Default render node limit.

99
esbuild.config.mjs Normal file
View file

@ -0,0 +1,99 @@
import esbuild from 'esbuild';
import process from 'process';
import fs from 'node:fs';
import path from 'node:path';
import { builtinModules } from 'node:module';
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === 'production';
// dev 构建直接输出到本地 dev vault 的插件目录(可用 MINI_WORLD_MAP_OUTDIR 覆盖);
// prod 输出到 ./dist 作为 release 资产。
const outDir = prod
? path.resolve('./dist')
: (process.env.MINI_WORLD_MAP_OUTDIR ??
path.resolve('./dev-vault/.obsidian/plugins/mini-world-map'));
fs.mkdirSync(outDir, { recursive: true });
// 'worker:' 前缀导入 → 独立打包成 IIFE 文本Blob URL Worker 用)
const inlineWorker = {
name: 'inline-worker',
setup(build) {
build.onResolve({ filter: /^worker:/ }, (args) => ({
path: path.resolve(path.dirname(args.importer), args.path.slice('worker:'.length)),
namespace: 'inline-worker',
}));
build.onLoad({ filter: /.*/, namespace: 'inline-worker' }, async (args) => {
const result = await esbuild.build({
entryPoints: [args.path],
bundle: true,
write: false,
format: 'iife',
target: 'es2021',
minify: prod,
});
return { contents: result.outputFiles[0].text, loader: 'text' };
});
},
};
const copyAssets = {
name: 'copy-assets',
setup(build) {
build.onEnd((result) => {
if (result.errors.length > 0) return;
fs.copyFileSync('manifest.json', path.join(outDir, 'manifest.json'));
fs.copyFileSync('styles.css', path.join(outDir, 'styles.css'));
if (!prod) {
// pjeby/hot-reload 监听该标记文件所在的插件目录
fs.writeFileSync(path.join(outDir, '.hotreload'), '');
}
});
},
};
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtinModules,
],
format: 'cjs',
target: 'es2021',
define: { __GALAXY_DEV__: prod ? 'false' : 'true' },
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: path.join(outDir, 'main.js'),
minify: prod,
plugins: [inlineWorker, copyAssets],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

35
eslint.config.mts Normal file
View file

@ -0,0 +1,35 @@
import tseslint from 'typescript-eslint';
import globals from 'globals';
import { globalIgnores } from 'eslint/config';
export default tseslint.config(
globalIgnores([
'node_modules',
'dist',
'dev-vault',
'esbuild.config.mjs',
'version-bump.mjs',
'versions.json',
'main.js',
'legacy',
'package.json',
'package-lock.json',
'tsconfig.json',
]),
{
languageOptions: {
globals: {
...globals.browser,
__GALAXY_DEV__: 'readonly',
},
parserOptions: {
projectService: {
allowDefaultProject: ['eslint.config.mts', 'manifest.json'],
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json'],
},
},
},
...tseslint.configs.recommended,
);

8957
legacy/main.legacy.js Normal file

File diff suppressed because it is too large Load diff

13025
main.js

File diff suppressed because one or more lines are too long

7032
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "mini-world-map",
"version": "0.1.3",
"description": "Hierarchy-first 2D radial rings and 3D map views for your Obsidian vault",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": [],
"license": "MIT",
"dependencies": {
"3d-force-graph": "1.80.0",
"d3-force-3d": "3.0.6",
"three": "0.184.0",
"three-spritetext": "1.10.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^22.15.17",
"@types/three": "^0.184.0",
"esbuild": "0.25.5",
"eslint": "^9.39.4",
"globals": "^17.6.0",
"jiti": "^2.6.1",
"obsidian": "latest",
"typescript": "^5.8.3",
"typescript-eslint": "^8.59.1",
"vitest": "^3.0.0"
}
}

83
src/bench/bench.ts Normal file
View file

@ -0,0 +1,83 @@
import type { App } from 'obsidian';
import { normalizePath } from 'obsidian';
import type { BenchResult, FrameStats } from '../types';
const BENCH_DIR = '_galaxy_bench';
/** 收集 durationMs 内的 rAF 帧间隔onFrame 可用于驱动脚本镜头 */
export function collectFrames(
durationMs: number,
onFrame?: (elapsedMs: number) => void,
): Promise<FrameStats> {
return new Promise((resolve) => {
const deltas: number[] = [];
let last = performance.now();
const start = last;
const tick = (now: number) => {
deltas.push(now - last);
last = now;
const elapsed = now - start;
onFrame?.(elapsed);
if (elapsed < durationMs) {
window.requestAnimationFrame(tick);
} else {
deltas.shift(); // 首帧间隔含启动开销,丢弃
const sorted = [...deltas].sort((a, b) => a - b);
const total = deltas.reduce((s, d) => s + d, 0);
const p95 = sorted[Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1)] ?? 0;
resolve({
frames: deltas.length,
avgFps: deltas.length > 0 ? 1000 / (total / deltas.length) : 0,
p95FrameMs: p95,
worstFrameMs: sorted[sorted.length - 1] ?? 0,
durationMs: total,
});
}
};
window.requestAnimationFrame(tick);
});
}
/** 观察主线程 longtask返回 stop() 取回结果 */
export function observeLongTasks(): { stop: () => { count: number; longestMs: number; totalMs: number } } {
let count = 0;
let longestMs = 0;
let totalMs = 0;
let observer: PerformanceObserver | null = null;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
count++;
totalMs += entry.duration;
if (entry.duration > longestMs) longestMs = entry.duration;
}
});
observer.observe({ entryTypes: ['longtask'] });
} catch {
// longtask observer 不可用时降级为零值(结果中可见)
}
return {
stop: () => {
observer?.disconnect();
return { count, longestMs, totalMs };
},
};
}
export function heapUsed(): number {
const perf = performance as unknown as { memory?: { usedJSHeapSize: number } };
return perf.memory?.usedJSHeapSize ?? -1;
}
export async function writeBenchResult(app: App, result: BenchResult): Promise<string> {
const adapter = app.vault.adapter;
const dir = normalizePath(BENCH_DIR);
if (!(await adapter.exists(dir))) await adapter.mkdir(dir);
const file = normalizePath(`${BENCH_DIR}/${result.scenario}-${Date.now()}.json`);
await adapter.write(file, JSON.stringify(result, null, 2));
return file;
}
export function sleep(ms: number): Promise<void> {
return new Promise((r) => window.setTimeout(r, ms));
}

34
src/constants.ts Normal file
View file

@ -0,0 +1,34 @@
export const VIEW_TYPE_MINI_WORLD_MAP = 'mini-world-map-view';
export const VIEW_TYPE_GALAXY = VIEW_TYPE_MINI_WORLD_MAP;
// 力学/辉光/外观的默认值与持久化见 src/settings.ts
// 节点尺寸世界单位2.2×(1+0.5√degree),上限 6 倍——枢纽不能吞掉画面
export const NODE_BASE_RADIUS = 2.2;
export const NODE_MAX_RADIUS = NODE_BASE_RADIUS * 6;
// NASA 配方bloom 初值会立刻被 settings 覆盖,见 GraphController.applySettings
export const BACKGROUND_COLOR = 0x000003;
export const BLOOM_DEFAULTS = { strength: 0.6, radius: 0.4, threshold: 0.18 };
export const LINK_OPACITY = 0.16;
// 镜头编排(数字来自视觉规格,实现者无需品味)
export const CRUISE = {
angularSpeed: 0.022, // rad/s
elevationDeg: 8,
elevationPeriodS: 90,
radiusBreath: 0.04,
radiusPeriodS: 60,
resumeDelayMs: 10_000,
rampUpMs: 2_000,
};
export const FLY_TO = {
distancePerRadius: 12,
minDistance: 40,
maxDistance: 140,
azimuthOffsetRad: (15 * Math.PI) / 180,
minMs: 800,
maxMs: 1800,
msPerWorldUnit: 0.45,
};
export const STARFIELD_ROTATION_RAD_PER_S = 0.0008;

109
src/data/GraphStore.ts Normal file
View file

@ -0,0 +1,109 @@
import type { App } from 'obsidian';
import { Component, debounce } from 'obsidian';
import type { GraphData } from '../types';
import { buildGraph } from './buildGraph';
import { seedPosition, seedRadius } from './seed';
/**
* metadataCache
* GraphData + id
*
*/
export class GraphStore extends Component {
data: GraphData = { nodes: [], links: [] };
/** x,y,z × nodes.length布局引擎原地写渲染器只读 */
positions = new Float32Array(0);
private includeUnresolved = false;
private includeOrphans = true;
private nodeCap: number | null = null;
private linkCap: number | null = null;
private onChanged: (() => void) | null = null;
constructor(private app: App) {
super();
}
/** dataChanged 在防抖重建完成后触发(调用方负责 reheat + 重建渲染缓冲) */
init(includeUnresolved: boolean, includeOrphans: boolean, onChanged: () => void): void {
this.includeUnresolved = includeUnresolved;
this.includeOrphans = includeOrphans;
this.onChanged = onChanged;
const rebuildSoon = debounce(() => this.rebuild(true), 800, true);
this.registerEvent(this.app.metadataCache.on('resolved', rebuildSoon));
this.registerEvent(this.app.vault.on('rename', rebuildSoon));
this.registerEvent(this.app.vault.on('delete', rebuildSoon));
}
async ensureCacheReady(): Promise<void> {
if (Object.keys(this.app.metadataCache.resolvedLinks).length > 0) return;
await new Promise<void>((resolve) => {
this.registerEvent(this.app.metadataCache.on('resolved', () => resolve()));
});
}
setIncludeUnresolved(v: boolean): void {
if (v === this.includeUnresolved) return;
this.includeUnresolved = v;
this.rebuild(true);
}
getIncludeUnresolved(): boolean {
return this.includeUnresolved;
}
/** 质量档位的节点/链接帽;变化时重建(保坐标) */
setCaps(nodeCap: number | null, linkCap: number | null): void {
if (nodeCap === this.nodeCap && linkCap === this.linkCap) return;
this.nodeCap = nodeCap;
this.linkCap = linkCap;
this.rebuild(true);
}
setIncludeOrphans(v: boolean): void {
if (v === this.includeOrphans) return;
this.includeOrphans = v;
this.rebuild(true);
}
/** preservePositions=false 用于基准(全新确定性种子 → 完整冷布局) */
rebuild(preservePositions: boolean): void {
const files = this.app.vault.getMarkdownFiles().map((f) => ({
path: f.path,
basename: f.basename,
size: f.stat.size,
}));
const next = buildGraph(files, this.app.metadataCache.resolvedLinks, this.app.metadataCache.unresolvedLinks, {
includeUnresolved: this.includeUnresolved,
includeOrphans: this.includeOrphans,
nodeCap: this.nodeCap,
linkCap: this.linkCap,
});
const oldIndexById = new Map<string, number>();
if (preservePositions) {
this.data.nodes.forEach((n, i) => oldIndexById.set(n.id, i));
}
const oldPositions = this.positions;
const radius = seedRadius(next.nodes.length);
const positions = new Float32Array(next.nodes.length * 3);
next.nodes.forEach((n, i) => {
const oi = oldIndexById.get(n.id);
if (oi !== undefined && oi * 3 + 2 < oldPositions.length) {
positions[i * 3] = oldPositions[oi * 3] ?? 0;
positions[i * 3 + 1] = oldPositions[oi * 3 + 1] ?? 0;
positions[i * 3 + 2] = oldPositions[oi * 3 + 2] ?? 0;
} else {
const [x, y, z] = seedPosition(n.id, radius);
positions[i * 3] = x;
positions[i * 3 + 1] = y;
positions[i * 3 + 2] = z;
}
});
this.data = next;
this.positions = positions;
this.onChanged?.();
}
}

151
src/data/buildGraph.ts Normal file
View file

@ -0,0 +1,151 @@
import type { GraphData, GraphLink, GraphNode } from '../types';
/** 输入用纯记录,不依赖 obsidian —— 可单测(设计要求) */
export interface FileRecord {
path: string;
basename: string;
size?: number; // 字节
}
export type LinkTable = Record<string, Record<string, number>>;
export interface BuildOptions {
includeUnresolved: boolean;
includeOrphans: boolean;
/** 移动档:按度数取 top N 节点保枢纽结构null=不限 */
nodeCap?: number | null;
/** 移动档:按 min(端点度数) 取 top N 边null=不限 */
linkCap?: number | null;
}
function topFolder(path: string): string {
const idx = path.indexOf('/');
return idx === -1 ? '' : path.slice(0, idx);
}
/**
* vault
* - files md
* - resolvedLinks (src,dst)
* - degree = +
*/
export function buildGraph(
files: FileRecord[],
resolvedLinks: LinkTable,
unresolvedLinks: LinkTable,
opts: BuildOptions,
): GraphData {
const nodes: GraphNode[] = [];
const indexById = new Map<string, number>();
for (const f of files) {
indexById.set(f.path, nodes.length);
nodes.push({
id: f.path,
name: f.basename,
folderTop: topFolder(f.path),
degree: 0,
inDegree: 0,
outDegree: 0,
fileSize: f.size ?? 0,
unresolved: false,
});
}
const links: GraphLink[] = [];
const addLink = (si: number, ti: number) => {
links.push({ source: si, target: ti });
const s = nodes[si];
const t = nodes[ti];
if (s) {
s.degree++;
s.outDegree++;
}
if (t) {
t.degree++;
t.inDegree++;
}
};
for (const src of Object.keys(resolvedLinks)) {
const si = indexById.get(src);
if (si === undefined) continue;
const targets = resolvedLinks[src] ?? {};
for (const dst of Object.keys(targets)) {
const ti = indexById.get(dst);
if (ti === undefined) continue;
addLink(si, ti);
}
}
if (opts.includeUnresolved) {
for (const src of Object.keys(unresolvedLinks)) {
const si = indexById.get(src);
if (si === undefined) continue;
const targets = unresolvedLinks[src] ?? {};
for (const name of Object.keys(targets)) {
const ghostId = `unresolved:${name}`;
let gi = indexById.get(ghostId);
if (gi === undefined) {
gi = nodes.length;
indexById.set(ghostId, gi);
nodes.push({
id: ghostId,
name,
folderTop: '__unresolved__',
degree: 0,
inDegree: 0,
outDegree: 0,
fileSize: 0,
unresolved: true,
});
}
addLink(si, gi);
}
}
}
let result: GraphData = { nodes, links };
if (!opts.includeOrphans) {
// 过滤孤儿degree 0被过滤节点必然无边只需重排索引
result = filterNodes(result, (n) => n.degree > 0);
}
const nodeCap = opts.nodeCap ?? null;
if (nodeCap !== null && result.nodes.length > nodeCap) {
// 度数榜 top N并列按原序——保住枢纽结构「仍像这座库」
const ranked = [...result.nodes.entries()].sort((a, b) => b[1].degree - a[1].degree || a[0] - b[0]);
const keepIdx = new Set(ranked.slice(0, nodeCap).map(([i]) => i));
result = filterNodes(result, (_n, i) => keepIdx.has(i));
}
const linkCap = opts.linkCap ?? null;
if (linkCap !== null && result.links.length > linkCap) {
const deg = (i: number) => result.nodes[i]?.degree ?? 0;
result = {
nodes: result.nodes,
links: [...result.links.entries()]
.sort((a, b) => Math.min(deg(b[1].source), deg(b[1].target)) - Math.min(deg(a[1].source), deg(a[1].target)) || a[0] - b[0])
.slice(0, linkCap)
.map(([, l]) => l),
};
}
return result;
}
function filterNodes(g: GraphData, keep: (n: GraphNode, i: number) => boolean): GraphData {
const remap = new Map<number, number>();
const kept: GraphNode[] = [];
g.nodes.forEach((n, i) => {
if (keep(n, i)) {
remap.set(i, kept.length);
kept.push(n);
}
});
const links: GraphLink[] = [];
for (const l of g.links) {
const s2 = remap.get(l.source);
const t2 = remap.get(l.target);
if (s2 !== undefined && t2 !== undefined) links.push({ source: s2, target: t2 });
}
return { nodes: kept, links };
}

29
src/data/seed.ts Normal file
View file

@ -0,0 +1,29 @@
// FNV-1a 确定性散列:种子布局(基准可复现)与调色板分配共用
export function hash32(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
export function unit(s: string): number {
return hash32(s) / 0xffffffff;
}
/** 按 id 确定性地撒进半径 radius 的球内,返回 [x,y,z] */
export function seedPosition(id: string, radius: number): [number, number, number] {
const u = unit(id);
const v = unit(id + ':v');
const w = unit(id + ':w');
const r = radius * Math.cbrt(u);
const theta = 2 * Math.PI * v;
const phi = Math.acos(2 * w - 1);
return [r * Math.sin(phi) * Math.cos(theta), r * Math.sin(phi) * Math.sin(theta), r * Math.cos(phi)];
}
/** 与节点规模匹配的种子球半径 */
export function seedRadius(nodeCount: number): number {
return 80 * Math.cbrt(Math.max(nodeCount, 1) / 1000);
}

309
src/i18n.ts Normal file
View file

@ -0,0 +1,309 @@
import type {
ColorScheme,
ExternalDetailMode,
HoverHighlightMode,
HoverTargetMode,
LabelVisibility,
Language,
ViewMode,
} from './settings';
export const LANGUAGE_OPTIONS: [Language, string][] = [
['en', 'English'],
['zh', '中文'],
];
const STRINGS: Record<string, Record<Language, string>> = {
'language': { en: 'Language', zh: '语言' },
'language.en': { en: 'English', zh: 'English' },
'language.zh': { en: 'Chinese', zh: '中文' },
'mode.radial2d': { en: '2D radial', zh: '2D 环形图' },
'mode.map3d': { en: '3D map', zh: '3D 地图' },
'stats.counts': { en: '{nodes} nodes / {links} links', zh: '{nodes} 个节点 / {links} 条链接' },
'stats.3d': {
en: '{fps} fps · {calls} calls · {nodes}n/{links}l · {state}',
zh: '{fps} fps · {calls} calls · {nodes} 节点/{links} 链接 · {state}',
},
'state.settled': { en: 'settled', zh: '已沉降' },
'state.layout': { en: 'layout', zh: '布局中' },
'tab.inspect': { en: 'Inspect', zh: '检查' },
'tab.pins': { en: 'Pins', zh: '固定' },
'tab.view': { en: 'View', zh: '视图' },
'tab.controls': { en: 'Controls', zh: '控制' },
'tab.defaults': { en: 'Defaults', zh: '默认' },
'tab.appearance': { en: 'Appearance', zh: '外观' },
'tab.physics': { en: 'Physics', zh: '力学' },
'tab.motion': { en: 'Motion', zh: '运动' },
'tab.advanced': { en: 'Advanced', zh: '高级' },
'common.search': { en: 'Search', zh: '搜索' },
'common.recenter': { en: 'Center', zh: '回中心' },
'common.fit': { en: 'Fit', zh: '适配' },
'common.rebuild': { en: 'Rebuild', zh: '重建' },
'common.pin': { en: 'Pin', zh: '固定' },
'common.pinCurrent': { en: 'Pin current', zh: '固定当前' },
'common.clear': { en: 'Clear', zh: '清空' },
'common.group': { en: 'Group', zh: '分组' },
'common.inspect': { en: 'Inspect', zh: '检查' },
'common.open': { en: 'Open', zh: '打开' },
'common.focus': { en: 'Focus', zh: '聚焦' },
'common.root': { en: 'Root', zh: '根节点' },
'common.source': { en: 'Source', zh: '来源' },
'common.target': { en: 'Target', zh: '目标' },
'common.none': { en: 'None', zh: '无' },
'common.default': { en: 'Default', zh: '默认' },
'common.resetDefaults': { en: 'Reset defaults', zh: '重置默认' },
'view.atlas': { en: 'Atlas', zh: '图谱' },
'view.focus': { en: 'Focus', zh: '聚焦' },
'view.vaultRoot': { en: 'Vault root', zh: '库根目录' },
'view.mode': { en: 'Map mode', zh: '地图模式' },
'view.theme': { en: 'Theme', zh: '主题' },
'theme.auto': { en: 'System', zh: '跟随系统' },
'theme.radialAuto': { en: 'System', zh: '跟随系统' },
'theme.day': { en: 'Light', zh: '浅色' },
'theme.night': { en: 'Dark', zh: '深色' },
'theme.deep': { en: 'Deep space', zh: '深空' },
'inspect.type': { en: 'Type', zh: '类型' },
'inspect.depth': { en: 'Depth', zh: '深度' },
'inspect.notes': { en: 'Notes', zh: '笔记' },
'inspect.out': { en: 'Out', zh: '出链' },
'inspect.in': { en: 'In', zh: '入链' },
'inspect.linkOverlay': { en: 'Note link', zh: '笔记链接' },
'inspect.weight': { en: 'Weight', zh: '权重' },
'inspect.raw': { en: 'Raw', zh: '原始' },
'inspect.unresolved': { en: 'Unresolved', zh: '未解析' },
'inspect.external': { en: 'Outside', zh: '外部' },
'inspect.outgoing': { en: 'Outgoing ({count})', zh: '出链({count}' },
'inspect.backlinks': { en: 'Backlinks ({count})', zh: '反向链接({count}' },
'pins.groupName': { en: 'Group name', zh: '分组名称' },
'pins.empty': { en: 'No pinned paths.', zh: '暂无固定路径。' },
'pins.already': { en: 'That path is already pinned.', zh: '这条路径已经固定。' },
'pins.selectFirst': { en: 'Select pinned paths to group.', zh: '先选择要分组的固定路径。' },
'pins.selectBeforePin': { en: 'Select or hover a path before pinning.', zh: '先选择或悬停一条路径再固定。' },
'control.depth': { en: 'Depth', zh: '深度' },
'control.nodes': { en: 'Nodes', zh: '节点' },
'control.noteLinks': { en: 'Note links', zh: '笔记链接' },
'control.showNoteLinks': { en: 'Show note links', zh: '显示笔记链接' },
'control.hover': { en: 'Hover', zh: '悬停' },
'control.hoverTargets': { en: 'Hover targets', zh: '悬停对象' },
'control.labels': { en: 'Labels', zh: '标签' },
'control.spin': { en: 'Ring spin', zh: '环形旋转' },
'control.outsideLinks': { en: 'Outside links', zh: '外部链接' },
'control.outsideDetail': { en: 'Outside detail', zh: '外部细节' },
'control.exactOutsideFiles': { en: 'Exact outside notes', zh: '精确外部笔记' },
'control.legend': { en: 'Legend', zh: '图例' },
'control.defaultDepth': { en: 'Default depth', zh: '默认深度' },
'control.defaultNodes': { en: 'Default nodes', zh: '默认节点数' },
'control.defaultNoteLinks': { en: 'Default note links', zh: '默认笔记链接' },
'control.unresolvedLinks': { en: 'Unresolved links', zh: '未解析链接' },
'control.ignoredFolders': { en: 'Ignored folders', zh: '忽略文件夹' },
'hover.none': { en: 'None', zh: '无' },
'hover.note-links': { en: 'Note links', zh: '笔记链接' },
'hover.hierarchy-parents': { en: 'Parents', zh: '父级' },
'hover.hierarchy-direct-children': { en: 'Direct children', zh: '直接子级' },
'hover.hierarchy-descendants': { en: 'All children', zh: '全部子级' },
'hover.hierarchy-parents-direct': { en: 'Parents + direct children', zh: '父级 + 直接子级' },
'hover.hierarchy-all': { en: 'Parents + all children', zh: '父级 + 全部子级' },
'hoverTarget.nodes': { en: 'Nodes only', zh: '仅节点' },
'hoverTarget.links': { en: 'Links only', zh: '仅链接' },
'hoverTarget.both': { en: 'Nodes + links', zh: '节点 + 链接' },
'labels.auto': { en: 'Auto', zh: '自动' },
'labels.hover': { en: 'Hover only', zh: '仅悬停' },
'outside.grouped': { en: 'Groups', zh: '分组' },
'outside.selected': { en: 'Selected', zh: '选中' },
'outside.exact': { en: 'Exact', zh: '精确' },
'legend.root': { en: 'Root', zh: '根节点' },
'legend.root.desc': { en: 'Current atlas root', zh: '当前图谱根节点' },
'legend.folder': { en: 'Folders', zh: '文件夹' },
'legend.folder.desc': { en: 'Folders without a same-name child note', zh: '没有同名子笔记的文件夹' },
'legend.folderMeta': { en: 'Folder notes', zh: '文件夹笔记' },
'legend.folderMeta.desc': { en: 'Folder merged with its same-name child note', zh: '与同名子笔记合并的文件夹' },
'legend.note': { en: 'Notes', zh: '笔记' },
'legend.note.desc': { en: 'Markdown notes', zh: 'Markdown 笔记' },
'legend.outsideGroup': { en: 'Outside groups', zh: '外部分组' },
'legend.outsideGroup.desc': { en: 'Grouped branches outside the root', zh: '根节点外的分组分支' },
'legend.outsideNote': { en: 'Outside notes', zh: '外部笔记' },
'legend.outsideNote.desc': { en: 'Exact linked notes outside the root', zh: '根节点外的精确链接笔记' },
'legend.unresolvedNote': { en: 'Unresolved notes', zh: '未解析笔记' },
'legend.unresolvedNote.desc': { en: 'Unresolved internal link targets', zh: '未解析的内部链接目标' },
'legend.hierarchy': { en: 'Hierarchy', zh: '层级' },
'legend.hierarchy.desc': { en: 'Parent-child hierarchy edges', zh: '父子层级边' },
'legend.noteLinks': { en: 'Note links', zh: '笔记链接' },
'legend.noteLinks.desc': { en: 'Internal markdown links', zh: '内部 Markdown 链接' },
'legend.outsideLinks': { en: 'Outside links', zh: '外部链接' },
'legend.outsideLinks.desc': { en: 'Links crossing the current root', zh: '跨出当前根节点的链接' },
'legend.unresolvedLinks': { en: 'Unresolved links', zh: '未解析链接' },
'legend.unresolvedLinks.desc': { en: 'Links involving unresolved targets', zh: '包含未解析目标的链接' },
'loading.radial': { en: 'Building map…', zh: '构建地图…' },
'loading.3d': { en: 'Building map…', zh: '构建地图…' },
'context.openNote': { en: 'Open note', zh: '打开笔记' },
'context.focusNote': { en: 'Focus note', zh: '聚焦笔记' },
'context.useAsRoot': { en: 'Use as atlas root', zh: '设为图谱根节点' },
'context.openRepresentative': { en: 'Open representative note', zh: '打开代表笔记' },
'context.pinPath': { en: 'Pin highlighted path', zh: '固定高亮路径' },
'3d.cruiseOn': { en: 'Cruise: on', zh: '巡航:开' },
'3d.cruiseOff': { en: 'Cruise: off', zh: '巡航:关' },
'3d.reveal': { en: 'Reveal', zh: '创世动画' },
'3d.glow': { en: 'Glow', zh: '辉光' },
'3d.glowStrength': { en: 'Strength', zh: '强度' },
'3d.glowRadius': { en: 'Radius', zh: '扩散' },
'3d.glowThreshold': { en: 'Threshold', zh: '阈值' },
'3d.repel': { en: 'Repel', zh: '斥力' },
'3d.linkDistance': { en: 'Link distance', zh: '链接距离' },
'3d.linkStrength': { en: 'Link strength', zh: '链接强度' },
'3d.centerPull': { en: 'Center pull', zh: '向心力' },
'3d.flatten': { en: 'Flatten', zh: '扁平度' },
'3d.nodeSize': { en: 'Node size', zh: '节点大小' },
'3d.linkOpacity': { en: 'Link opacity', zh: '链接透明度' },
'3d.twinkle': { en: 'Twinkle', zh: '星星眨眼' },
'3d.twinkleOff': { en: 'Off', zh: '关' },
'3d.size.degree': { en: 'Size: links', zh: '大小:链接数' },
'3d.size.fileSize': { en: 'Size: file size', zh: '大小:文档量' },
'3d.size.uniform': { en: 'Size: uniform', zh: '大小:一致' },
'3d.colorTheme': { en: 'Color theme…', zh: '配色主题…' },
'3d.importColors': { en: 'Import 2D colors', zh: '导入二维配色' },
'3d.shuffleColors': { en: 'Shuffle colors', zh: '配色洗牌' },
'3d.speed': { en: 'Speed', zh: '速度' },
'3d.unresolvedShow': { en: 'Unresolved: show', zh: '未解析:显示' },
'3d.unresolvedHide': { en: 'Unresolved: hide', zh: '未解析:隐藏' },
'3d.orphansShow': { en: 'Orphans: show', zh: '孤儿:显示' },
'3d.orphansHide': { en: 'Orphans: hide', zh: '孤儿:隐藏' },
'3d.quality.auto': { en: 'Quality: auto', zh: '画质:自动' },
'3d.quality.high': { en: 'Quality: high', zh: '画质:高' },
'3d.quality.low': { en: 'Quality: low', zh: '画质:低' },
'3d.quality.mobile': { en: 'Quality: mobile', zh: '画质:移动模拟' },
'3d.help.drag': { en: 'Left drag = orbit · wheel = zoom', zh: '左键拖 = 环绕 · 滚轮 = 缩放' },
'3d.help.pan': {
en: 'Right drag / Cmd or Shift + left drag = pan',
zh: '右键拖 / ⌘或⇧+左键拖 = 平移',
},
'3d.help.mac': { en: 'macOS treats Ctrl+click as right-click', zh: 'macOS 的 Ctrl+点击会被系统当右键' },
'3d.help.fly': { en: 'WASD = fly · Q/E = rise/fall · Shift = fast', zh: 'WASD = 平飞 · Q/E = 升降 · Shift = 加速' },
'3d.help.pick': { en: 'Click node = select and fly · ESC = clear', zh: '点击节点 = 选中飞行 · ESC = 取消' },
'3d.help.keys': { en: 'F = fly to selected · R = overview', zh: 'F = 飞向选中 · R = 回总览' },
'3d.help.slider': { en: 'Double-click a slider to reset it', zh: '双击滑杆 = 回默认值' },
'3d.revealWait': { en: 'The map is still settling. Try reveal after it settles.', zh: '星系还在成形中,沉降后再试。' },
'3d.workerFallback': {
en: 'Mini World Map 3D: background layout worker is unavailable; using the main thread.',
zh: 'Mini World Map 3D后台线程不可用已回退主线程布局。',
},
'3d.mobileCap': {
en: 'Mobile quality: showing the top {cap} linked nodes out of {total}.',
zh: '移动档:已显示链接最多的前 {cap} 个节点(共 {total})。',
},
'3d.performanceMode': {
en: 'Mini World Map 3D switched to performance mode. Change quality in Advanced.',
zh: 'Mini World Map 3D已自动切换到性能模式可在高级页改回。',
},
'3d.importMissing': { en: 'No 2D graph color groups found in graph.json.', zh: '未找到自带图谱的颜色分组graph.json。' },
'3d.importDone': { en: 'Imported {count} 2D color groups.', zh: '已导入 {count} 组 2D 图谱配色。' },
'3d.shuffleMissing': { en: 'Import 2D colors before shuffling.', zh: '先导入二维图谱配色,才能洗牌。' },
'3d.contextLost': { en: 'Rendering context lost. Click to rebuild.', zh: '渲染上下文丢失,点击重建。' },
'3d.searchPlaceholder': { en: 'Search notes, press Enter to fly…', zh: '搜索笔记,回车飞过去…' },
'3d.searchUnresolved': { en: 'Unresolved', zh: '未解析' },
'3d.searchLinks': { en: '{count} links', zh: '{count} 链接' },
'3d.card.unresolved': { en: 'Unresolved link (note does not exist)', zh: '未解析链接(笔记尚不存在)' },
'3d.card.root': { en: 'Vault root', zh: '根目录' },
'3d.card.stats': { en: '↩ {in} backlinks · → {out} outgoing', zh: '↩ {in} 反链 · → {out} 出链' },
'3d.card.modified': { en: ' · modified {date}', zh: ' · 改于 {date}' },
'3d.card.empty': { en: '(empty note)', zh: '(空笔记)' },
'3d.bench.wait': { en: '{scenario}: waiting for layout to settle…', zh: '{scenario}:等待布局沉降…' },
'3d.bench.orbit': { en: '{scenario}: 20s orbit FPS run…', zh: '{scenario}20s 环绕测帧率…' },
'3d.bench.done': { en: '{scenario} done: avg {fps} fps · {calls} calls', zh: '{scenario} 完成avg {fps} fps · {calls} calls' },
'3d.bench.s2Start': {
en: 'S2: cold layout started. The interface should remain responsive.',
zh: 'S2冷布局开始预算化 tick期间界面应保持可用…',
},
'3d.bench.s2Done': {
en: 'S2 done: settled in {seconds}s / {ticks} ticks, longest block {longest}ms',
zh: 'S2 完成:沉降 {seconds}s / {ticks} ticks最长阻塞 {longest}ms',
},
'style.galaxy': { en: 'Galaxy', zh: '银河' },
'style.nebula': { en: 'Nebula', zh: '星云' },
'style.minimal': { en: 'Minimal', zh: '极简' },
'style.fireworks': { en: 'Fireworks', zh: '烟火' },
'color.hubble': { en: 'Hubble deep field', zh: '哈勃深空' },
'color.tiktok': { en: 'Neon pop', zh: '抖音霓虹' },
'color.sunset': { en: 'Sunset film', zh: '落日胶片' },
'color.cyber': { en: 'Cyber city', zh: '赛博都市' },
'color.matrix': { en: 'Matrix', zh: '黑客帝国' },
'color.aurora': { en: 'Aurora', zh: '极光' },
'settings.title': { en: 'Mini World Map', zh: 'Mini World Map' },
'settings.defaultMode': { en: 'Default render mode', zh: '默认渲染模式' },
'settings.defaultModeDesc': {
en: '2D radial rings is the hierarchy-first map. 3D map uses the Galaxy renderer.',
zh: '2D 环形图以层级为主3D 地图使用 Galaxy 渲染器。',
},
'settings.languageDesc': { en: 'Language used by both 2D and 3D panels.', zh: '2D 与 3D 面板共同使用的语言。' },
'settings.depthDesc': {
en: 'How many hierarchy levels to render before deeper nodes are summarized by budgets.',
zh: '渲染多少层级后由预算汇总更深节点。',
},
'settings.nodeLimitDesc': { en: 'Maximum visible nodes in the 2D radial map.', zh: '2D 环形图最多显示的节点数。' },
'settings.linkLimitDesc': { en: 'Maximum aggregated note links to draw in 2D.', zh: '2D 中最多绘制的聚合笔记链接数。' },
'settings.showLinksDesc': {
en: 'Keeps note-link roads visible. Hover/link pinning still works when this is off.',
zh: '保持笔记链接可见;关闭后悬停与链接固定仍可用。',
},
'settings.hoverDesc': { en: 'Choose what 2D hover highlights.', zh: '选择 2D 悬停高亮的内容。' },
'settings.hoverTargetsDesc': { en: 'Choose whether nodes, note-link roads, or both react to hover and click.', zh: '选择节点、笔记链接道路或两者是否响应悬停和点击。' },
'settings.labelsDesc': { en: 'Auto shows important names by zoom; hover only keeps the map quieter.', zh: '自动按缩放显示重要名称;仅悬停会更安静。' },
'settings.spinDesc': { en: 'Optional radial ring motion in the 2D map.', zh: '2D 环形图中的可选环形运动。' },
'settings.unresolvedDesc': { en: 'Represent unresolved internal links as temporary nodes.', zh: '将未解析内部链接显示为临时节点。' },
'settings.ignoredDesc': { en: 'One folder path per line.', zh: '每行一个文件夹路径。' },
'notice.rebuilt': { en: 'Mini World Map rebuilt', zh: 'Mini World Map 已重建' },
'notice.openToBuild': { en: 'Open Mini World Map to build the index', zh: '打开 Mini World Map 后才能构建索引' },
'notice.switchTo3d': { en: 'Switch to 3D map to use fly-to search.', zh: '切换到 3D 地图后可使用飞行搜索。' },
};
export function t(language: Language, key: string, vars: Record<string, string | number> = {}): string {
const template = STRINGS[key]?.[language] ?? STRINGS[key]?.en ?? key;
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`));
}
export function viewModeLabel(language: Language, mode: ViewMode): string {
return t(language, `mode.${mode}`);
}
export function colorSchemeOptions(language: Language): [ColorScheme, string][] {
return [
['auto', t(language, 'theme.radialAuto')],
['day', t(language, 'theme.day')],
['night', t(language, 'theme.night')],
];
}
export function languageOptions(language: Language): [Language, string][] {
return LANGUAGE_OPTIONS.map(([value]) => [value, t(language, `language.${value}`)]);
}
export function hoverModeOptions(language: Language, values: readonly [HoverHighlightMode, string][]): [HoverHighlightMode, string][] {
return values.map(([value]) => [value, t(language, `hover.${value}`)]);
}
export function hoverTargetOptions(language: Language, values: readonly [HoverTargetMode, string][]): [HoverTargetMode, string][] {
return values.map(([value]) => [value, t(language, `hoverTarget.${value}`)]);
}
export function labelVisibilityOptions(language: Language, values: readonly [LabelVisibility, string][]): [LabelVisibility, string][] {
return values.map(([value]) => [value, t(language, `labels.${value}`)]);
}
export function outsideDetailOptions(language: Language): [ExternalDetailMode, string][] {
return [
['grouped', t(language, 'outside.grouped')],
['selected', t(language, 'outside.selected')],
['exact', t(language, 'outside.exact')],
];
}

View file

@ -0,0 +1,271 @@
import { MOUSE, PerspectiveCamera, Spherical, Vector3 } from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CRUISE, FLY_TO } from '../constants';
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
interface Tween {
t0: number;
durMs: number;
fromPos: Vector3;
toPos: Vector3;
fromTarget: Vector3;
toTarget: Vector3;
onDone?: () => void;
}
export interface CameraHooks {
/** F 键:飞向当前选中(无选中则忽略) */
onFlyToSelected: () => void;
/** R 键:回总览 */
onResetView: () => void;
}
const FLY_KEYS = new Set(['w', 'a', 's', 'd', 'q', 'e']);
/**
* G1 FPS / Google Earth
* - · / Ctrl()+ ·
* - FPS WASD + Q/E Shift ×3
* - / tweenF R
* tabindex Obsidian
*/
export class CameraDirector {
cruiseEnabled = true;
/** 巡航角速度倍率(面板「巡航速度」滑杆) */
cruiseSpeed = 1;
private controls: OrbitControls;
private tween: Tween | null = null;
private lastInputAt = 0;
private cruiseAnchor: Spherical | null = null;
private cruiseT = 0;
private cruiseDir = 1;
private pendingDensityDir: Vector3 | null = null;
private pressed = new Set<string>();
private shiftHeld = false;
private tmpOffset = new Vector3();
private tmpSph = new Spherical();
private tmpDir = new Vector3();
private tmpRight = new Vector3();
private disposeFns: (() => void)[] = [];
constructor(
private camera: PerspectiveCamera,
private dom: HTMLElement,
private hooks: CameraHooks,
) {
this.controls = new OrbitControls(camera, dom);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.lastInputAt = performance.now();
dom.tabIndex = 0; // 画布可聚焦 → 键盘飞行不影响 Obsidian 其他快捷键
this.bindPointer();
this.bindKeys();
}
get target(): Vector3 {
return this.controls.target;
}
private markInput(): void {
this.lastInputAt = performance.now();
this.cruiseAnchor = null;
this.tween = null; // 任何输入打断飞行(停在当前位置,不跳变)
}
private bindPointer(): void {
const onDown = (e: PointerEvent) => {
this.dom.focus();
// Google Earth 式平移:⌘/Shift/Ctrl + 左键拖macOS 的 Ctrl+点击被系统征用为右键,
// 所以 Mac 上主用 ⌘ 或 Shift右键拖原生就是平移
this.controls.mouseButtons.LEFT =
e.metaKey || e.shiftKey || e.ctrlKey ? MOUSE.PAN : MOUSE.ROTATE;
this.markInput();
};
const onInput = () => this.markInput();
this.dom.addEventListener('pointerdown', onDown, { capture: true });
this.dom.addEventListener('wheel', onInput, { passive: true });
this.dom.addEventListener('touchstart', onInput, { passive: true });
this.disposeFns.push(() => {
this.dom.removeEventListener('pointerdown', onDown, { capture: true });
this.dom.removeEventListener('wheel', onInput);
this.dom.removeEventListener('touchstart', onInput);
});
}
private bindKeys(): void {
const onKeyDown = (e: KeyboardEvent) => {
const k = e.key.toLowerCase();
this.shiftHeld = e.shiftKey;
if (FLY_KEYS.has(k)) {
this.pressed.add(k);
this.markInput();
e.preventDefault();
e.stopPropagation();
} else if (k === 'f') {
this.hooks.onFlyToSelected();
e.preventDefault();
} else if (k === 'r') {
this.hooks.onResetView();
e.preventDefault();
}
};
const onKeyUp = (e: KeyboardEvent) => {
this.shiftHeld = e.shiftKey;
this.pressed.delete(e.key.toLowerCase());
};
const onBlur = () => this.pressed.clear();
this.dom.addEventListener('keydown', onKeyDown);
this.dom.addEventListener('keyup', onKeyUp);
this.dom.addEventListener('blur', onBlur);
this.disposeFns.push(() => {
this.dom.removeEventListener('keydown', onKeyDown);
this.dom.removeEventListener('keyup', onKeyUp);
this.dom.removeEventListener('blur', onBlur);
});
}
/** WASD/QE相机与轨道目标同步平移飞完仍可正常环绕 */
private applyFly(deltaS: number): boolean {
if (this.pressed.size === 0) return false;
const dist = this.camera.position.distanceTo(this.controls.target);
const speed = Math.min(Math.max(dist * 0.8, 10), 600) * (this.shiftHeld ? 3 : 1);
const fwd = this.camera.getWorldDirection(this.tmpDir);
this.tmpRight.crossVectors(fwd, this.camera.up).normalize();
const move = new Vector3();
if (this.pressed.has('w')) move.add(fwd);
if (this.pressed.has('s')) move.sub(fwd);
if (this.pressed.has('d')) move.add(this.tmpRight);
if (this.pressed.has('a')) move.sub(this.tmpRight);
if (this.pressed.has('e')) move.y += 1;
if (this.pressed.has('q')) move.y -= 1;
if (move.lengthSq() < 1e-8) return false;
move.normalize().multiplyScalar(speed * deltaS);
this.camera.position.add(move);
this.controls.target.add(move);
this.lastInputAt = performance.now();
return true;
}
/** 初始机位:质心外 2.2×半径、仰角 +18° */
setInitialFraming(graphRadius: number): void {
this.camera.position.copy(this.framingPosition(graphRadius));
this.controls.target.set(0, 0, 0);
this.controls.update();
}
private framingPosition(graphRadius: number): Vector3 {
const d = graphRadius * 2.2;
const elev = (18 * Math.PI) / 180;
return new Vector3(d * Math.cos(elev), d * Math.sin(elev), d * 0.35);
}
/** R/回中心:平滑回总览 */
resetView(graphRadius: number, onDone?: () => void): void {
this.startTween(this.framingPosition(graphRadius), new Vector3(0, 0, 0), 1200, onDone);
}
/**
* 10s
* G2 5 4
*/
beginFocusOrbit(densityDir: Vector3 | null): void {
this.pendingDensityDir = densityDir;
this.cruiseAnchor = null;
this.lastInputAt = performance.now() - CRUISE.resumeDelayMs - 1;
}
flyTo(nodePos: Vector3, nodeRadius: number, onDone?: () => void): void {
const dist = Math.min(Math.max(nodeRadius * FLY_TO.distancePerRadius, FLY_TO.minDistance), FLY_TO.maxDistance);
// 保持当前视角方向但偏转 15° 方位角——到达时不正对节点,邻域可见
const dir = this.camera.position.clone().sub(nodePos);
if (dir.lengthSq() < 1e-6) dir.set(0, 0, 1);
this.tmpSph.setFromVector3(dir);
this.tmpSph.theta += FLY_TO.azimuthOffsetRad;
this.tmpSph.radius = dist;
const toPos = nodePos.clone().add(new Vector3().setFromSpherical(this.tmpSph));
const travel = this.camera.position.distanceTo(toPos);
const durMs = Math.min(Math.max(FLY_TO.minMs + FLY_TO.msPerWorldUnit * travel, FLY_TO.minMs), FLY_TO.maxMs);
this.startTween(toPos, nodePos.clone(), durMs, onDone);
}
private startTween(toPos: Vector3, toTarget: Vector3, durMs: number, onDone?: () => void): void {
this.tween = {
t0: performance.now(),
durMs,
fromPos: this.camera.position.clone(),
toPos,
fromTarget: this.controls.target.clone(),
toTarget,
};
if (onDone) this.tween.onDone = onDone;
}
/** 每帧驱动返回当前是否处于巡航中HUD 显示用) */
update(now: number, deltaS: number): boolean {
if (this.tween) {
const tw = this.tween;
const t = Math.min((now - tw.t0) / tw.durMs, 1);
const k = easeInOutCubic(t);
this.camera.position.lerpVectors(tw.fromPos, tw.toPos, k);
this.controls.target.lerpVectors(tw.fromTarget, tw.toTarget, k);
this.controls.update();
if (t >= 1) {
this.tween = null;
tw.onDone?.();
this.lastInputAt = now; // 到达后等满闲置时长再起巡航
}
return false;
}
const flying = this.applyFly(deltaS);
const idleMs = now - this.lastInputAt;
if (!flying && this.cruiseEnabled && idleMs > CRUISE.resumeDelayMs) {
// 0→满速渐起无顿挫
const ramp = Math.min((idleMs - CRUISE.resumeDelayMs) / CRUISE.rampUpMs, 1);
if (!this.cruiseAnchor) {
this.cruiseAnchor = new Spherical().setFromVector3(
this.tmpOffset.copy(this.camera.position).sub(this.controls.target),
);
this.cruiseT = 0;
this.cruiseDir = 1;
// 邻居密集方向 → 选择能更早扫过该侧的旋转方向
if (this.pendingDensityDir && this.pendingDensityDir.lengthSq() > 1e-6) {
const densityTheta = new Spherical().setFromVector3(this.pendingDensityDir).theta;
let delta = densityTheta - this.cruiseAnchor.theta;
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
this.cruiseDir = delta >= 0 ? 1 : -1;
}
this.pendingDensityDir = null;
}
this.cruiseT += deltaS * ramp;
const t = this.cruiseT;
const a = this.cruiseAnchor;
const elev = ((CRUISE.elevationDeg * Math.PI) / 180) * Math.sin((2 * Math.PI * t) / CRUISE.elevationPeriodS);
const breath = 1 + CRUISE.radiusBreath * Math.sin((2 * Math.PI * t) / CRUISE.radiusPeriodS);
this.tmpSph.radius = a.radius * breath;
this.tmpSph.theta = a.theta + this.cruiseDir * CRUISE.angularSpeed * this.cruiseSpeed * t;
this.tmpSph.phi = Math.min(Math.max(a.phi + elev, 0.05), Math.PI - 0.05);
this.camera.position.setFromSpherical(this.tmpSph).add(this.controls.target);
this.camera.lookAt(this.controls.target);
return true;
}
this.controls.update();
return false;
}
dispose(): void {
this.tween = null;
this.pressed.clear();
for (const fn of this.disposeFns) fn();
this.disposeFns = [];
this.controls.dispose();
}
}

View file

@ -0,0 +1,23 @@
import type { GraphData, LayoutParams } from '../types';
/**
*
* M1: 主线程 d3-force-3d tick
* M3: Web Worker positions transferable
*/
export interface LayoutEngine {
/** x,y,z × n引擎原地写入渲染器直接读 */
readonly positions: Float32Array;
/** 自 init 起累计 tick 数(基准用,替代运行时方法替换 hack */
readonly ticks: number;
/** initialAlpha: 1=完整冷布局;低值(如 0.06 暖启动 / 0.3 增量更新)= 温和整理 */
init(data: GraphData, positions: Float32Array, params: LayoutParams, initialAlpha?: number): void;
/** 跑一个 tick返回 false 表示已沉降 */
step(): boolean;
isSettled(): boolean;
/** 数据增量更新后低温重热 */
reheat(alpha?: number): void;
/** 实时调参(控制面板滑杆):更新力参数并重热,星系当场重排 */
updateParams(params: LayoutParams): void;
dispose(): void;
}

View file

@ -0,0 +1,109 @@
import { forceLink, forceManyBody, forceSimulation, forceX, forceY, forceZ } from 'd3-force-3d';
import type { SimLink, SimNode, Simulation } from 'd3-force-3d';
import type { GraphData, LayoutParams } from '../types';
import type { LayoutEngine } from './LayoutEngine';
interface LNode extends SimNode {
x: number;
y: number;
z: number;
}
export class MainThreadForceLayout implements LayoutEngine {
positions: Float32Array = new Float32Array(0);
private sim: Simulation<LNode> | null = null;
private simNodes: LNode[] = [];
private degrees: number[] = [];
private settled = true;
private _ticks = 0;
get ticks(): number {
return this._ticks;
}
init(data: GraphData, positions: Float32Array, params: LayoutParams, initialAlpha = 1): void {
this.dispose();
this.positions = positions;
this.degrees = data.nodes.map((n) => Math.max(n.degree, 1));
this.simNodes = data.nodes.map((_, i) => ({
x: positions[i * 3] ?? 0,
y: positions[i * 3 + 1] ?? 0,
z: positions[i * 3 + 2] ?? 0,
}));
const links: SimLink<LNode>[] = data.links.map((l) => ({ source: l.source, target: l.target }));
// forceSimulation 创建即自启动内部 timer——立刻 stop改由渲染循环逐帧驱动
this.sim = forceSimulation(this.simNodes, 3)
.alphaDecay(1 - Math.pow(0.001, 1 / 300))
.velocityDecay(params.velocityDecay)
.force('link', forceLink<LNode>(links).distance(params.linkDistance).strength(this.linkStrengthFn(params.linkStrength)))
.force('charge', forceManyBody<LNode>().strength(params.charge).distanceMax(800))
.force('x', forceX<LNode>(0).strength(params.centerPull))
.force('y', forceY<LNode>(0).strength(params.centerPull + params.flatten))
.force('z', forceZ<LNode>(0).strength(params.centerPull))
.stop();
this.sim.alpha(initialAlpha);
this._ticks = 0;
this.settled = false;
}
/** d3 默认 strength = 1/min(端点连接数),倍率叠加其上(保持枢纽不被拉爆的特性) */
private linkStrengthFn(mult: number): (link: SimLink<LNode>) => number {
const degrees = this.degrees;
return (link) => {
const s = typeof link.source === 'number' ? link.source : (link.source.index ?? 0);
const t = typeof link.target === 'number' ? link.target : (link.target.index ?? 0);
const base = 1 / Math.min(degrees[s] ?? 1, degrees[t] ?? 1);
return Math.min(base * mult, 1);
};
}
updateParams(params: LayoutParams): void {
const sim = this.sim;
if (!sim) return;
(sim.force('charge') as import('d3-force-3d').ManyBodyForce<LNode> | undefined)?.strength(params.charge);
const link = sim.force('link') as import('d3-force-3d').LinkForce<LNode> | undefined;
link?.distance(params.linkDistance);
link?.strength(this.linkStrengthFn(params.linkStrength));
(sim.force('x') as import('d3-force-3d').PositionForce<LNode> | undefined)?.strength(params.centerPull);
(sim.force('y') as import('d3-force-3d').PositionForce<LNode> | undefined)?.strength(params.centerPull + params.flatten);
(sim.force('z') as import('d3-force-3d').PositionForce<LNode> | undefined)?.strength(params.centerPull);
this.reheat(0.5);
}
step(): boolean {
const sim = this.sim;
if (!sim || this.settled) return false;
sim.tick();
this._ticks++;
const pos = this.positions;
const nodes = this.simNodes;
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (!n) continue;
pos[i * 3] = n.x;
pos[i * 3 + 1] = n.y;
pos[i * 3 + 2] = n.z;
}
if (sim.alpha() < sim.alphaMin()) this.settled = true;
return !this.settled;
}
isSettled(): boolean {
return this.settled;
}
reheat(alpha = 0.3): void {
if (!this.sim) return;
this.sim.alpha(Math.max(this.sim.alpha(), alpha));
this.settled = false;
}
dispose(): void {
this.sim?.stop();
this.sim = null;
this.simNodes = [];
this.settled = true;
}
}

View file

@ -0,0 +1,115 @@
import workerSource from 'worker:./forceWorker.ts';
import type { GraphData, LayoutParams } from '../types';
import type { LayoutEngine } from './LayoutEngine';
interface TickMsg {
type: 'tick';
buffer: ArrayBuffer;
alpha: number;
settled: boolean;
ticks: number;
}
/**
* Worker M3 d3-force-3d Blob URL Worker
* transferable 线 38KB memcpy
* 退 MainThreadForceLayout
*/
export class WorkerForceLayout implements LayoutEngine {
positions: Float32Array = new Float32Array(0);
private worker: Worker | null = null;
private url = '';
private dirty = false;
private settled = true;
private _ticks = 0;
get ticks(): number {
return this._ticks;
}
init(data: GraphData, positions: Float32Array, params: LayoutParams, initialAlpha = 1): void {
this.disposeWorker();
this.positions = positions;
this._ticks = 0;
this.dirty = false;
this.url = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' }));
this.worker = new Worker(this.url);
this.worker.onmessage = (e: MessageEvent) => {
const m = e.data as TickMsg;
if (m.type !== 'tick') return;
const incoming = new Float32Array(m.buffer);
this.positions.set(incoming.subarray(0, this.positions.length));
this._ticks = m.ticks;
this.settled = m.settled;
this.dirty = true;
// 归还 buffertransferable 乒乓)
this.worker?.postMessage({ type: 'buffer', buffer: m.buffer }, [m.buffer]);
};
const n = data.nodes.length;
const posCopy = new Float32Array(positions); // worker 持有自己的副本
const linkIdx = new Uint32Array(data.links.length * 2);
data.links.forEach((l, i) => {
linkIdx[i * 2] = l.source;
linkIdx[i * 2 + 1] = l.target;
});
const degrees = new Float32Array(n);
data.nodes.forEach((node, i) => (degrees[i] = Math.max(node.degree, 1)));
const bufA = new ArrayBuffer(n * 3 * 4);
const bufB = new ArrayBuffer(n * 3 * 4);
this.settled = initialAlpha < 0.001;
this.worker.postMessage(
{
type: 'init',
count: n,
positions: posCopy.buffer,
links: linkIdx.buffer,
degrees: degrees.buffer,
params,
initialAlpha,
bufA,
bufB,
},
[posCopy.buffer, linkIdx.buffer, degrees.buffer, bufA, bufB],
);
}
/** 返回「本帧坐标有更新」——调用方据此刷新渲染缓冲 */
step(): boolean {
const had = this.dirty;
this.dirty = false;
return had;
}
isSettled(): boolean {
return this.settled;
}
reheat(alpha = 0.3): void {
this.settled = false;
this.worker?.postMessage({ type: 'reheat', alpha });
}
updateParams(params: LayoutParams): void {
this.settled = false;
this.worker?.postMessage({ type: 'params', params });
}
private disposeWorker(): void {
this.worker?.terminate();
this.worker = null;
if (this.url) {
URL.revokeObjectURL(this.url);
this.url = '';
}
}
dispose(): void {
this.disposeWorker();
this.settled = true;
this.dirty = false;
}
}

166
src/layout/forceWorker.ts Normal file
View file

@ -0,0 +1,166 @@
import { forceLink, forceManyBody, forceSimulation, forceX, forceY, forceZ } from 'd3-force-3d';
import type { SimLink, SimNode, Simulation } from 'd3-force-3d';
import type { LayoutParams } from '../types';
/**
* WorkerM3d3-force-3d 线
* init// transferable tick 12ms
* transferable
*/
interface WNode extends SimNode {
x: number;
y: number;
z: number;
}
interface InitMsg {
type: 'init';
count: number;
positions: ArrayBuffer;
links: ArrayBuffer; // Uint32Array [s0,t0,s1,t1,...]
degrees: ArrayBuffer; // Float32Array
params: LayoutParams;
initialAlpha: number;
bufA: ArrayBuffer;
bufB: ArrayBuffer;
}
type InMsg =
| InitMsg
| { type: 'params'; params: LayoutParams }
| { type: 'reheat'; alpha: number }
| { type: 'buffer'; buffer: ArrayBuffer };
interface WorkerCtx {
onmessage: ((e: MessageEvent) => void) | null;
postMessage(msg: unknown, transfer?: Transferable[]): void;
}
const ctx = self as unknown as WorkerCtx;
let sim: Simulation<WNode> | null = null;
let nodes: WNode[] = [];
let degrees: Float32Array = new Float32Array(0);
let freeBuffers: ArrayBuffer[] = [];
let settled = true;
let tickCount = 0;
let needPost = false;
let scheduled = false;
function linkStrengthFn(mult: number): (link: SimLink<WNode>) => number {
return (link) => {
const s = typeof link.source === 'number' ? link.source : (link.source.index ?? 0);
const t = typeof link.target === 'number' ? link.target : (link.target.index ?? 0);
const base = 1 / Math.min(degrees[s] ?? 1, degrees[t] ?? 1);
return Math.min(base * mult, 1);
};
}
function applyParams(params: LayoutParams): void {
if (!sim) return;
(sim.force('charge') as import('d3-force-3d').ManyBodyForce<WNode> | undefined)?.strength(params.charge);
const link = sim.force('link') as import('d3-force-3d').LinkForce<WNode> | undefined;
link?.distance(params.linkDistance);
link?.strength(linkStrengthFn(params.linkStrength));
(sim.force('x') as import('d3-force-3d').PositionForce<WNode> | undefined)?.strength(params.centerPull);
(sim.force('y') as import('d3-force-3d').PositionForce<WNode> | undefined)?.strength(params.centerPull + params.flatten);
(sim.force('z') as import('d3-force-3d').PositionForce<WNode> | undefined)?.strength(params.centerPull);
}
function schedule(): void {
if (scheduled) return;
scheduled = true;
// Worker 作用域用 self没有 window宏任务 yield 让批次间能处理回传消息
self.setTimeout(run, 0);
}
function run(): void {
scheduled = false;
const s = sim;
if (!s || settled) return;
const t0 = Date.now();
// 批量 tick每批最多 12ms主线程按消费节奏取最新帧
while (Date.now() - t0 < 12) {
s.tick();
tickCount++;
if (s.alpha() < s.alphaMin()) {
settled = true;
break;
}
}
needPost = true;
post();
if (!settled) schedule();
}
function post(): void {
if (!needPost || !sim) return;
const buf = freeBuffers.pop();
if (!buf) return; // 等 buffer 归还时再补发
const arr = new Float32Array(buf);
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (!n) continue;
arr[i * 3] = n.x;
arr[i * 3 + 1] = n.y;
arr[i * 3 + 2] = n.z;
}
needPost = false;
ctx.postMessage({ type: 'tick', buffer: buf, alpha: sim.alpha(), settled, ticks: tickCount }, [buf]);
}
ctx.onmessage = (e: MessageEvent) => {
const msg = e.data as InMsg;
switch (msg.type) {
case 'init': {
sim?.stop();
const pos = new Float32Array(msg.positions);
degrees = new Float32Array(msg.degrees);
const linkIdx = new Uint32Array(msg.links);
nodes = [];
for (let i = 0; i < msg.count; i++) {
nodes.push({ x: pos[i * 3] ?? 0, y: pos[i * 3 + 1] ?? 0, z: pos[i * 3 + 2] ?? 0 });
}
const links: SimLink<WNode>[] = [];
for (let li = 0; li < linkIdx.length; li += 2) {
links.push({ source: linkIdx[li] ?? 0, target: linkIdx[li + 1] ?? 0 });
}
sim = forceSimulation(nodes, 3)
.alphaDecay(1 - Math.pow(0.001, 1 / 300))
.velocityDecay(msg.params.velocityDecay)
.force('link', forceLink<WNode>(links).distance(msg.params.linkDistance).strength(linkStrengthFn(msg.params.linkStrength)))
.force('charge', forceManyBody<WNode>().strength(msg.params.charge).distanceMax(800))
.force('x', forceX<WNode>(0).strength(msg.params.centerPull))
.force('y', forceY<WNode>(0).strength(msg.params.centerPull + msg.params.flatten))
.force('z', forceZ<WNode>(0).strength(msg.params.centerPull))
.stop();
sim.alpha(msg.initialAlpha);
freeBuffers = [msg.bufA, msg.bufB];
tickCount = 0;
settled = msg.initialAlpha < sim.alphaMin();
needPost = false;
if (!settled) schedule();
break;
}
case 'params':
applyParams(msg.params);
if (sim) {
sim.alpha(Math.max(sim.alpha(), 0.5));
settled = false;
schedule();
}
break;
case 'reheat':
if (sim) {
sim.alpha(Math.max(sim.alpha(), msg.alpha));
settled = false;
schedule();
}
break;
case 'buffer':
freeBuffers.push(msg.buffer);
post(); // 沉降最后一帧可能在等 buffer
break;
}
};

File diff suppressed because it is too large Load diff

281
src/main.ts Normal file
View file

@ -0,0 +1,281 @@
import { Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { VIEW_TYPE_MINI_WORLD_MAP } from './constants';
import type { Language, MiniWorldMapSettings, ViewMode } from './settings';
import {
DEFAULT_RADIAL_SETTINGS,
HOVER_HIGHLIGHT_MODE_OPTIONS,
HOVER_TARGET_MODE_OPTIONS,
LABEL_VISIBILITY_OPTIONS,
MAX_ATLAS_DEPTH,
MAX_LINK_LIMIT,
MAX_RENDER_NODE_LIMIT,
MAX_SWIRL_STRENGTH,
clampNumber,
mergeSettings,
normalizeHoverHighlightMode,
normalizeHoverTargetMode,
normalizeLabelVisibility,
normalizeLanguage,
normalizeViewMode,
} from './settings';
import { hoverModeOptions, hoverTargetOptions, labelVisibilityOptions, languageOptions, t, viewModeLabel } from './i18n';
import { MiniWorldMapView } from './view/GalaxyView';
import { Map3DController } from './view/Map3DController';
export default class MiniWorldMapPlugin extends Plugin {
settings: MiniWorldMapSettings = mergeSettings(null);
async onload(): Promise<void> {
this.settings = mergeSettings(await this.loadData());
this.registerView(VIEW_TYPE_MINI_WORLD_MAP, (leaf) => new MiniWorldMapView(leaf, this));
this.addRibbonIcon('network', 'Open Mini World Map', () => {
void this.activateView();
});
this.addCommand({
id: 'open-map',
name: 'Open Mini World Map',
callback: () => void this.activateView(),
});
this.addCommand({
id: 'toggle-render-mode',
name: 'Toggle Mini World Map render mode',
callback: () => {
this.setViewMode(this.settings.viewMode === 'radial2d' ? 'map3d' : 'radial2d');
void this.activateView();
},
});
this.addCommand({
id: 'rebuild-index',
name: 'Rebuild Mini World Map index',
callback: () => {
let rebuilt = false;
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_MINI_WORLD_MAP)) {
const view = leaf.view;
if (view instanceof MiniWorldMapView && 'rebuild' in (view.controller ?? {})) {
(view.controller as { rebuild?: (reason: string) => void }).rebuild?.('manual');
rebuilt = true;
}
}
new Notice(t(this.settings.language, rebuilt ? 'notice.rebuilt' : 'notice.openToBuild'));
},
});
this.addCommand({
id: 'search-3d',
name: 'Search 3D map node and fly',
callback: () => {
void this.activateView().then((view) => {
if (view?.controller instanceof Map3DController) view.controller.openSearch();
else new Notice(t(this.settings.language, 'notice.switchTo3d'));
});
},
});
this.addSettingTab(new MiniWorldMapSettingTab(this.app, this));
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
setViewMode(mode: ViewMode): void {
this.settings.viewMode = normalizeViewMode(mode);
void this.saveSettings();
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_MINI_WORLD_MAP)) {
const view = leaf.view;
if (view instanceof MiniWorldMapView) view.switchMode(this.settings.viewMode);
}
}
setLanguage(language: Language): void {
this.settings.language = normalizeLanguage(language);
void this.saveSettings();
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_MINI_WORLD_MAP)) {
const view = leaf.view;
if (view instanceof MiniWorldMapView) view.switchMode(this.settings.viewMode);
}
}
async activateView(): Promise<MiniWorldMapView | null> {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_MINI_WORLD_MAP)[0] ?? null;
if (!leaf) {
leaf = workspace.getLeaf(true);
await leaf.setViewState({ type: VIEW_TYPE_MINI_WORLD_MAP, active: true });
}
if (leaf.isDeferred) await leaf.loadIfDeferred();
await workspace.revealLeaf(leaf);
return leaf.view instanceof MiniWorldMapView ? leaf.view : null;
}
}
class MiniWorldMapSettingTab extends PluginSettingTab {
constructor(
app: import('obsidian').App,
private plugin: MiniWorldMapPlugin,
) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
const radial = this.plugin.settings.radial;
const language = this.plugin.settings.language;
containerEl.empty();
containerEl.createEl('h2', { text: t(language, 'settings.title') });
new Setting(containerEl)
.setName(t(language, 'language'))
.setDesc(t(language, 'settings.languageDesc'))
.addDropdown((dropdown) => {
for (const [value, label] of languageOptions(language)) dropdown.addOption(value, label);
dropdown.setValue(this.plugin.settings.language).onChange(async (value) => {
this.plugin.settings.language = normalizeLanguage(value);
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(t(language, 'settings.defaultMode'))
.setDesc(t(language, 'settings.defaultModeDesc'))
.addDropdown((dropdown) =>
dropdown
.addOption('radial2d', viewModeLabel(language, 'radial2d'))
.addOption('map3d', viewModeLabel(language, 'map3d'))
.setValue(this.plugin.settings.viewMode)
.onChange(async (value) => {
this.plugin.settings.viewMode = normalizeViewMode(value);
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.defaultDepth'))
.setDesc(t(language, 'settings.depthDesc'))
.addSlider((slider) =>
slider
.setLimits(1, 20, 1)
.setValue(radial.atlasDepth)
.setDynamicTooltip()
.onChange(async (value) => {
radial.atlasDepth = clampNumber(value, 1, MAX_ATLAS_DEPTH, DEFAULT_RADIAL_SETTINGS.atlasDepth);
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.defaultNodes'))
.setDesc(t(language, 'settings.nodeLimitDesc'))
.addText((text) =>
text
.setPlaceholder(String(DEFAULT_RADIAL_SETTINGS.renderNodeLimit))
.setValue(String(radial.renderNodeLimit))
.onChange(async (value) => {
radial.renderNodeLimit = clampNumber(value, 200, MAX_RENDER_NODE_LIMIT, DEFAULT_RADIAL_SETTINGS.renderNodeLimit);
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.defaultNoteLinks'))
.setDesc(t(language, 'settings.linkLimitDesc'))
.addText((text) =>
text
.setPlaceholder(String(DEFAULT_RADIAL_SETTINGS.linkLimit))
.setValue(String(radial.linkLimit))
.onChange(async (value) => {
radial.linkLimit = clampNumber(value, 0, MAX_LINK_LIMIT, DEFAULT_RADIAL_SETTINGS.linkLimit);
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.showNoteLinks'))
.setDesc(t(language, 'settings.showLinksDesc'))
.addToggle((toggle) =>
toggle.setValue(radial.showLinkOverlay && !radial.hiddenLegendItems.includes('link')).onChange(async (value) => {
radial.showLinkOverlay = value;
const hidden = new Set(radial.hiddenLegendItems);
if (value) hidden.delete('link');
else hidden.add('link');
radial.hiddenLegendItems = [...hidden];
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.hover'))
.setDesc(t(language, 'settings.hoverDesc'))
.addDropdown((dropdown) => {
for (const [value, label] of hoverModeOptions(language, HOVER_HIGHLIGHT_MODE_OPTIONS)) dropdown.addOption(value, label);
dropdown.setValue(radial.hoverHighlightMode).onChange(async (value) => {
radial.hoverHighlightMode = normalizeHoverHighlightMode(value);
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t(language, 'control.hoverTargets'))
.setDesc(t(language, 'settings.hoverTargetsDesc'))
.addDropdown((dropdown) => {
for (const [value, label] of hoverTargetOptions(language, HOVER_TARGET_MODE_OPTIONS)) dropdown.addOption(value, label);
dropdown.setValue(radial.hoverTargetMode).onChange(async (value) => {
radial.hoverTargetMode = normalizeHoverTargetMode(value);
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t(language, 'control.labels'))
.setDesc(t(language, 'settings.labelsDesc'))
.addDropdown((dropdown) => {
for (const [value, label] of labelVisibilityOptions(language, LABEL_VISIBILITY_OPTIONS)) dropdown.addOption(value, label);
dropdown.setValue(radial.labelVisibility).onChange(async (value) => {
radial.labelVisibility = normalizeLabelVisibility(value);
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName(t(language, 'control.spin'))
.setDesc(t(language, 'settings.spinDesc'))
.addSlider((slider) =>
slider
.setLimits(0, MAX_SWIRL_STRENGTH, 1)
.setValue(radial.swirlStrength)
.setDynamicTooltip()
.onChange(async (value) => {
radial.swirlStrength = clampNumber(value, 0, MAX_SWIRL_STRENGTH, DEFAULT_RADIAL_SETTINGS.swirlStrength);
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.unresolvedLinks'))
.setDesc(t(language, 'settings.unresolvedDesc'))
.addToggle((toggle) =>
toggle.setValue(radial.includeUnresolvedLinks).onChange(async (value) => {
radial.includeUnresolvedLinks = value;
await this.plugin.saveSettings();
}),
);
new Setting(containerEl)
.setName(t(language, 'control.ignoredFolders'))
.setDesc(t(language, 'settings.ignoredDesc'))
.addTextArea((text) =>
text.setValue(radial.ignoreFolders.join('\n')).onChange(async (value) => {
radial.ignoreFolders = value
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
await this.plugin.saveSettings();
}),
);
}
}

347
src/overlay/ControlPanel.ts Normal file
View file

@ -0,0 +1,347 @@
import type { GalaxySettings, Language, ViewMode } from '../settings';
import { DEFAULT_GALAXY_SETTINGS } from '../settings';
import type { StylePreset } from '../render/stylePresets';
import { STYLE_PRESETS } from '../render/stylePresets';
import type { ColorTheme } from '../render/colorThemes';
import { COLOR_THEMES } from '../render/colorThemes';
import { t, viewModeLabel } from '../i18n';
import { Slider } from './Slider';
type PanelPage = 'view' | 'appearance' | 'physics' | 'motion' | 'advanced';
export interface ControlPanelCallbacks {
onViewMode?: (mode: ViewMode) => void;
onBloom: () => void;
onPhysics: () => void;
onLook: () => void;
onCruise: (on: boolean) => void;
onCruiseSpeed: () => void;
onPreset: () => void;
onStylePreset: (p: StylePreset) => void;
onShowUnresolved: (on: boolean) => void;
onImportColors: () => void;
onShuffleColors: () => void;
onColorTheme: (t: ColorTheme) => void;
onRecenter: () => void;
onReveal: () => void;
onShowOrphans: (on: boolean) => void;
onSizeBy: () => void;
onQuality: () => void;
onSearch: () => void;
onReset: () => void;
runScenario: (s: 'S1' | 'S2' | 'S3') => void;
}
export class ControlPanel {
readonly statsEl: HTMLElement;
private root: HTMLElement;
private body: HTMLElement;
private page: PanelPage = 'view';
private sliders: Slider[] = [];
private cruiseBtn: HTMLButtonElement | null = null;
private presetBtn: HTMLButtonElement | null = null;
private unresolvedBtn: HTMLButtonElement | null = null;
private orphanBtn: HTMLButtonElement | null = null;
private sizeByBtn: HTMLButtonElement | null = null;
private qualityBtn: HTMLButtonElement | null = null;
private styleChips: HTMLButtonElement[] = [];
constructor(
parent: HTMLElement,
private settings: GalaxySettings,
private language: Language,
private cb: ControlPanelCallbacks,
private viewMode: ViewMode = 'map3d',
) {
this.root = parent.createDiv({ cls: 'galaxy-panel gx-theme-dark mwm-map-panel' });
const header = this.root.createDiv({ cls: 'galaxy-panel-header' });
this.statsEl = header.createDiv({ cls: 'galaxy-panel-stats', text: '…' });
const collapseBtn = header.createEl('button', { cls: 'galaxy-panel-collapse', text: '-' });
this.body = this.root.createDiv({ cls: 'galaxy-panel-body' });
collapseBtn.addEventListener('click', () => {
const hidden = this.body.hasClass('is-hidden');
this.body.toggleClass('is-hidden', !hidden);
collapseBtn.setText(hidden ? '-' : '+');
});
this.render();
}
private render(): void {
this.body.empty();
this.sliders = [];
this.styleChips = [];
this.cruiseBtn = null;
this.presetBtn = null;
this.unresolvedBtn = null;
this.orphanBtn = null;
this.sizeByBtn = null;
this.qualityBtn = null;
const modeSwitch = this.body.createDiv({ cls: 'mwm-mode-switch' });
modeSwitch.createDiv({ cls: 'mwm-mode-switch-label', text: this.tt('view.mode') });
const modeRow = modeSwitch.createDiv({ cls: 'galaxy-mode-row mwm-mode-row' });
this.modeButton(modeRow, 'radial2d');
this.modeButton(modeRow, 'map3d');
const settings = this.body.createDiv({ cls: 'mwm-panel-settings mwm-3d-settings' });
const tabs = settings.createDiv({ cls: 'mwm-panel-tabs' });
for (const [id, label] of [
['view', this.tt('tab.view')],
['appearance', this.tt('tab.appearance')],
['physics', this.tt('tab.physics')],
['motion', this.tt('tab.motion')],
['advanced', this.tt('tab.advanced')],
] as const) {
const button = tabs.createEl('button', { cls: this.page === id ? 'is-active' : '', text: label });
button.addEventListener('click', () => {
this.page = id;
this.render();
});
}
const pageEl = settings.createDiv({ cls: 'mwm-panel-page' });
if (this.page === 'appearance') this.renderAppearancePage(pageEl);
else if (this.page === 'physics') this.renderPhysicsPage(pageEl);
else if (this.page === 'motion') this.renderMotionPage(pageEl);
else if (this.page === 'advanced') this.renderAdvancedPage(pageEl);
else this.renderViewPage(pageEl);
}
private renderViewPage(parent: HTMLElement): void {
const row = parent.createDiv({ cls: 'galaxy-panel-row' });
const searchBtn = row.createEl('button', { text: this.tt('common.search') });
searchBtn.addEventListener('click', this.cb.onSearch);
const recenterBtn = row.createEl('button', { text: this.tt('common.recenter') });
recenterBtn.addEventListener('click', this.cb.onRecenter);
const revealBtn = row.createEl('button', { text: this.tt('3d.reveal') });
revealBtn.addEventListener('click', this.cb.onReveal);
}
private renderAppearancePage(parent: HTMLElement): void {
const s = this.settings;
const d = DEFAULT_GALAXY_SETTINGS;
const themeRow = parent.createDiv({ cls: 'galaxy-panel-row' });
this.presetBtn = themeRow.createEl('button', { text: this.presetLabel() });
this.presetBtn.addEventListener('click', () => {
s.preset = s.preset === 'deep-space' ? 'adaptive' : 'deep-space';
this.presetBtn?.setText(this.presetLabel());
this.cb.onPreset();
});
const chipRow = parent.createDiv({ cls: 'gx-chips' });
for (const preset of STYLE_PRESETS) {
const chip = chipRow.createEl('button', { cls: 'gx-chip', text: this.tt(`style.${preset.id}`) });
chip.addEventListener('click', () => {
this.cb.onStylePreset(preset);
this.refreshAll();
this.markActiveChip(preset.id);
});
chip.dataset['presetId'] = preset.id;
this.styleChips.push(chip);
}
this.sliders.push(
new Slider(parent, this.slider('3d.nodeSize', 0.3, 2.5, 0.05, d.look.nodeSize, () => s.look.nodeSize, (v) => (s.look.nodeSize = v), cbFmt('x'), this.cb.onLook)),
new Slider(parent, this.slider('3d.linkOpacity', 0, 0.6, 0.01, d.look.linkOpacity, () => s.look.linkOpacity, (v) => (s.look.linkOpacity = v), undefined, this.cb.onLook)),
new Slider(parent, this.slider('3d.twinkle', 0, 2, 0.1, d.look.twinkle, () => s.look.twinkle, (v) => (s.look.twinkle = v), (v) => (v < 0.05 ? this.tt('3d.twinkleOff') : v.toFixed(1)), this.cb.onLook)),
new Slider(parent, this.slider('3d.glowStrength', 0, 2.5, 0.05, d.bloom.strength, () => s.bloom.strength, (v) => (s.bloom.strength = v), undefined, this.cb.onBloom)),
new Slider(parent, this.slider('3d.glowRadius', 0, 1.2, 0.05, d.bloom.radius, () => s.bloom.radius, (v) => (s.bloom.radius = v), undefined, this.cb.onBloom)),
new Slider(parent, this.slider('3d.glowThreshold', 0, 1, 0.05, d.bloom.threshold, () => s.bloom.threshold, (v) => (s.bloom.threshold = v), undefined, this.cb.onBloom)),
);
const sizeRow = parent.createDiv({ cls: 'galaxy-panel-row' });
this.sizeByBtn = sizeRow.createEl('button', { text: this.sizeByLabel() });
this.sizeByBtn.addEventListener('click', () => {
const order: typeof s.look.sizeBy[] = ['degree', 'fileSize', 'uniform'];
s.look.sizeBy = order[(order.indexOf(s.look.sizeBy) + 1) % order.length] ?? 'degree';
this.sizeByBtn?.setText(this.sizeByLabel());
this.cb.onSizeBy();
});
const themeSel = parent.createEl('select', { cls: 'gx-theme-select' });
const customOpt = themeSel.createEl('option', { text: this.tt('3d.colorTheme'), value: '' });
customOpt.disabled = true;
for (const colorTheme of COLOR_THEMES) {
themeSel.createEl('option', { text: this.tt(`color.${colorTheme.id}`), value: colorTheme.id });
}
themeSel.value = COLOR_THEMES.some((theme) => theme.id === s.colorTheme) ? s.colorTheme : '';
if (!themeSel.value) customOpt.selected = true;
themeSel.addEventListener('change', () => {
const colorTheme = COLOR_THEMES.find((theme) => theme.id === themeSel.value);
if (colorTheme) this.cb.onColorTheme(colorTheme);
});
const colorRow = parent.createDiv({ cls: 'galaxy-panel-row' });
const importBtn = colorRow.createEl('button', { text: this.tt('3d.importColors') });
importBtn.addEventListener('click', () => {
this.cb.onImportColors();
customOpt.selected = true;
});
const shuffleBtn = colorRow.createEl('button', { text: this.tt('3d.shuffleColors') });
shuffleBtn.addEventListener('click', () => {
this.cb.onShuffleColors();
customOpt.selected = true;
});
}
private renderPhysicsPage(parent: HTMLElement): void {
const s = this.settings;
const d = DEFAULT_GALAXY_SETTINGS;
this.sliders.push(
new Slider(parent, this.slider('3d.repel', 20, 400, 5, d.physics.repel, () => s.physics.repel, (v) => (s.physics.repel = v), (v) => String(Math.round(v)), this.cb.onPhysics)),
new Slider(parent, this.slider('3d.linkDistance', 20, 200, 5, d.physics.linkDistance, () => s.physics.linkDistance, (v) => (s.physics.linkDistance = v), (v) => String(Math.round(v)), this.cb.onPhysics)),
new Slider(parent, this.slider('3d.linkStrength', 0.1, 2, 0.1, d.physics.linkStrength, () => s.physics.linkStrength, (v) => (s.physics.linkStrength = v), cbFmt('x1'), this.cb.onPhysics)),
new Slider(parent, this.slider('3d.centerPull', 0, 0.2, 0.005, d.physics.centerPull, () => s.physics.centerPull, (v) => (s.physics.centerPull = v), (v) => v.toFixed(3), this.cb.onPhysics)),
new Slider(parent, this.slider('3d.flatten', 0, 0.8, 0.02, d.physics.flatten, () => s.physics.flatten, (v) => (s.physics.flatten = v), undefined, this.cb.onPhysics)),
);
}
private renderMotionPage(parent: HTMLElement): void {
const s = this.settings;
const d = DEFAULT_GALAXY_SETTINGS;
const row = parent.createDiv({ cls: 'galaxy-panel-row' });
this.cruiseBtn = row.createEl('button', { text: this.cruiseLabel() });
this.cruiseBtn.addEventListener('click', () => {
s.cruise = !s.cruise;
this.cruiseBtn?.setText(this.cruiseLabel());
this.cb.onCruise(s.cruise);
});
this.sliders.push(
new Slider(parent, this.slider('3d.speed', 0.2, 3, 0.1, d.cruiseSpeed, () => s.cruiseSpeed, (v) => (s.cruiseSpeed = v), cbFmt('x1'), this.cb.onCruiseSpeed)),
);
const helpBody = parent.createDiv({ cls: 'galaxy-panel-help' });
for (const key of ['3d.help.drag', '3d.help.pan', '3d.help.mac', '3d.help.fly', '3d.help.pick', '3d.help.keys', '3d.help.slider']) {
helpBody.createDiv({ text: this.tt(key) });
}
}
private renderAdvancedPage(parent: HTMLElement): void {
const s = this.settings;
const advRow = parent.createDiv({ cls: 'galaxy-panel-row' });
this.unresolvedBtn = advRow.createEl('button', { text: this.unresolvedLabel() });
this.unresolvedBtn.addEventListener('click', () => {
s.showUnresolved = !s.showUnresolved;
this.unresolvedBtn?.setText(this.unresolvedLabel());
this.cb.onShowUnresolved(s.showUnresolved);
});
this.orphanBtn = advRow.createEl('button', { text: this.orphanLabel() });
this.orphanBtn.addEventListener('click', () => {
s.showOrphans = !s.showOrphans;
this.orphanBtn?.setText(this.orphanLabel());
this.cb.onShowOrphans(s.showOrphans);
});
const advRow2 = parent.createDiv({ cls: 'galaxy-panel-row' });
this.qualityBtn = advRow2.createEl('button', { text: this.qualityLabel() });
this.qualityBtn.addEventListener('click', () => {
const order: typeof s.qualityOverride[] = ['auto', 'high', 'low', 'mobile'];
s.qualityOverride = order[(order.indexOf(s.qualityOverride) + 1) % order.length] ?? 'auto';
this.qualityBtn?.setText(this.qualityLabel());
this.cb.onQuality();
});
const resetBtn = advRow2.createEl('button', { text: this.tt('common.resetDefaults') });
resetBtn.addEventListener('click', () => {
this.cb.onReset();
this.refreshAll();
});
if (__GALAXY_DEV__) {
const devRow = parent.createDiv({ cls: 'galaxy-panel-row' });
for (const sc of ['S1', 'S2', 'S3'] as const) {
const b = devRow.createEl('button', { text: sc });
b.addEventListener('click', () => this.cb.runScenario(sc));
}
}
}
private modeButton(parent: HTMLElement, mode: ViewMode): void {
const button = parent.createEl('button', { cls: this.viewMode === mode ? 'is-active' : '', text: viewModeLabel(this.language, mode) });
button.addEventListener('click', () => this.cb.onViewMode?.(mode));
}
private slider(
key: string,
min: number,
max: number,
step: number,
defaultValue: number,
get: () => number,
set: (v: number) => void,
fmt: ((v: number) => string) | undefined,
onInput: () => void,
): ConstructorParameters<typeof Slider>[1] {
return {
label: this.tt(key),
min,
max,
step,
defaultValue,
get,
set,
fmt,
onInput,
defaultLabel: this.tt('common.default'),
};
}
private presetLabel(): string {
return `${this.tt('view.theme')}: ${this.settings.preset === 'deep-space' ? this.tt('theme.deep') : this.tt('theme.auto')}`;
}
private cruiseLabel(): string {
return this.settings.cruise ? this.tt('3d.cruiseOn') : this.tt('3d.cruiseOff');
}
private unresolvedLabel(): string {
return this.settings.showUnresolved ? this.tt('3d.unresolvedShow') : this.tt('3d.unresolvedHide');
}
private orphanLabel(): string {
return this.settings.showOrphans ? this.tt('3d.orphansShow') : this.tt('3d.orphansHide');
}
private sizeByLabel(): string {
return this.tt(`3d.size.${this.settings.look.sizeBy}`);
}
private qualityLabel(): string {
return this.tt(`3d.quality.${this.settings.qualityOverride}`);
}
private markActiveChip(id: string): void {
for (const chip of this.styleChips) chip.toggleClass('is-active', chip.dataset['presetId'] === id);
}
refreshAll(): void {
for (const sl of this.sliders) sl.refresh();
this.cruiseBtn?.setText(this.cruiseLabel());
this.presetBtn?.setText(this.presetLabel());
this.unresolvedBtn?.setText(this.unresolvedLabel());
this.orphanBtn?.setText(this.orphanLabel());
this.sizeByBtn?.setText(this.sizeByLabel());
this.qualityBtn?.setText(this.qualityLabel());
}
setLanguage(language: Language): void {
this.language = language;
this.render();
}
setPanelTheme(cls: 'gx-theme-dark' | 'gx-theme-light'): void {
this.root.removeClass('gx-theme-dark');
this.root.removeClass('gx-theme-light');
this.root.addClass(cls);
}
dispose(): void {
this.root.remove();
this.sliders = [];
this.styleChips = [];
}
private tt(key: string, vars: Record<string, string | number> = {}): string {
return t(this.language, key, vars);
}
}
function cbFmt(kind: 'x' | 'x1'): (value: number) => string {
return (value) => (kind === 'x1' ? `${value.toFixed(1)}x` : `${value.toFixed(2)}x`);
}

View file

@ -0,0 +1,237 @@
import type { App } from 'obsidian';
import { TFile, getAllTags } from 'obsidian';
import type { Language } from '../settings';
import type { GraphData, GraphNode } from '../types';
import type { AggregateRenderer } from '../render/AggregateRenderer';
import { t } from '../i18n';
export interface OverlayCallbacks {
openNote: (id: string) => void;
focusNode: (index: number) => void;
}
/**
* DOM NASA
* 14 + hover 1 + 20 + 1 36
*/
export class OverlayManager {
private root: HTMLElement;
private hubEls: { index: number; el: HTMLElement }[] = [];
private neighborEls: { index: number; el: HTMLElement }[] = [];
private hoverEl: HTMLElement;
private hoverIndex = -1;
private card: HTMLElement;
private cardIndex = -1;
private data: GraphData = { nodes: [], links: [] };
private graphRadius = 200;
private snippetToken = 0;
private hubBudget = 14;
private neighborBudget = 20;
private mobileCard = false;
constructor(
parent: HTMLElement,
private app: App,
private renderer: AggregateRenderer,
private cb: OverlayCallbacks,
private language: Language = 'en',
) {
this.root = parent.createDiv({ cls: 'gx-overlay' });
this.hoverEl = this.root.createDiv({ cls: 'gx-label gx-label-hover' });
this.hoverEl.hide();
this.card = this.root.createDiv({ cls: 'gx-card' });
this.card.hide();
}
setLanguage(language: Language): void {
this.language = language;
if (this.cardIndex >= 0) {
const node = this.data.nodes[this.cardIndex];
if (node) this.buildCard(node, this.cardIndex);
}
}
/** 质量档位预算;卡片切底部抽屉模式(移动端) */
setBudgets(hub: number, neighbor: number, mobileCard: boolean): void {
this.hubBudget = hub;
this.neighborBudget = neighbor;
this.mobileCard = mobileCard;
this.setData(this.data, this.graphRadius);
}
setData(data: GraphData, graphRadius: number): void {
this.data = data;
this.graphRadius = graphRadius;
for (const h of this.hubEls) h.el.remove();
this.hubEls = [...data.nodes.entries()]
.filter(([, n]) => !n.unresolved)
.sort((a, b) => b[1].degree - a[1].degree)
.slice(0, this.hubBudget)
.map(([index, n]) => ({
index,
el: this.root.createDiv({ cls: 'gx-label gx-label-hub', text: n.name }),
}));
// 数据重建后旧索引失效,清掉依赖索引的状态
this.setHover(-1);
this.setSelection(-1, new Set());
}
setHover(index: number): void {
this.hoverIndex = index;
if (index < 0) {
this.hoverEl.hide();
return;
}
const node = this.data.nodes[index];
if (!node) return;
this.hoverEl.setText(node.name);
this.hoverEl.show();
}
/**
* M4.1 .mobile-navbar
* navbar //
* navbar 0
*/
private refreshBottomInset(): void {
let inset = 0;
const navbar = activeDocument.querySelector('.mobile-navbar');
if (navbar) {
const nb = navbar.getBoundingClientRect();
const ce = this.root.getBoundingClientRect();
inset = Math.max(0, Math.round(ce.bottom - nb.top));
}
this.root.setCssProps({ '--gx-bottom-inset': `${inset}px` });
}
/** 选中:邻居标签 + 卡片index<0 清空 */
setSelection(index: number, neighbors: Set<number>): void {
for (const e of this.neighborEls) e.el.remove();
this.neighborEls = [];
this.cardIndex = index;
if (index < 0) {
this.card.hide();
return;
}
const byDegree = [...neighbors]
.filter((i) => i !== index)
.sort((a, b) => (this.data.nodes[b]?.degree ?? 0) - (this.data.nodes[a]?.degree ?? 0))
.slice(0, this.neighborBudget);
this.neighborEls = byDegree.map((i) => ({
index: i,
el: this.root.createDiv({ cls: 'gx-label gx-label-neighbor', text: this.data.nodes[i]?.name ?? '' }),
}));
const node = this.data.nodes[index];
if (node) {
if (this.mobileCard) {
this.refreshBottomInset();
// 移除桌面定位残留的内联 transform → CSS 底部抽屉定位靠选择器特异性接管(免 !important
this.card.style.removeProperty('transform');
}
this.buildCard(node, index);
}
}
private buildCard(node: GraphNode, index: number): void {
this.card.empty();
this.card.show();
this.card.createDiv({ cls: 'gx-card-title', text: node.name });
const meta = this.card.createDiv({ cls: 'gx-card-meta' });
const dot = meta.createSpan({ cls: 'gx-card-dot' });
dot.style.background = this.renderer.nodeColorHex(index);
meta.createSpan({
text: node.unresolved ? this.tt('3d.card.unresolved') : node.id.includes('/') ? node.id.slice(0, node.id.lastIndexOf('/')) : this.tt('3d.card.root'),
});
const file = node.unresolved ? null : this.app.vault.getAbstractFileByPath(node.id);
const tfile = file instanceof TFile ? file : null;
if (tfile) {
const cache = this.app.metadataCache.getFileCache(tfile);
const tags = cache ? (getAllTags(cache) ?? []) : [];
if (tags.length > 0) {
const tagRow = this.card.createDiv({ cls: 'gx-card-tags' });
for (const t of tags.slice(0, 5)) tagRow.createSpan({ cls: 'gx-card-tag', text: t });
}
}
const stats = this.card.createDiv({ cls: 'gx-card-stats' });
stats.setText(
this.tt('3d.card.stats', { in: node.inDegree, out: node.outDegree }) +
(tfile ? this.tt('3d.card.modified', { date: new Date(tfile.stat.mtime).toLocaleDateString(this.language === 'zh' ? 'zh-CN' : 'en-US') }) : ''),
);
if (tfile) {
const snippetEl = this.card.createDiv({ cls: 'gx-card-snippet', text: '…' });
const token = ++this.snippetToken;
void this.app.vault.cachedRead(tfile).then((text) => {
if (token !== this.snippetToken) return; // 已切换选中,丢弃过期结果
snippetEl.setText(stripMarkdown(text).slice(0, 120) || this.tt('3d.card.empty'));
});
}
const actions = this.card.createDiv({ cls: 'gx-card-actions' });
if (!node.unresolved) {
const openBtn = actions.createEl('button', { text: this.tt('context.openNote') });
openBtn.addEventListener('click', () => this.cb.openNote(node.id));
}
const focusBtn = actions.createEl('button', { text: this.tt('common.focus') });
focusBtn.addEventListener('click', () => this.cb.focusNode(index));
}
/** 每帧投影所有被追踪节点translate3d 定位GPU 合成,无重排) */
update(w: number, h: number): void {
const far = this.graphRadius * 2.6;
const near = this.graphRadius * 1.2;
for (const { index, el } of this.hubEls) {
const p = this.renderer.projectNode(index, w, h);
if (p.behind || p.x < 0 || p.x > w || p.y < 0 || p.y > h) {
el.setCssProps({ opacity: '0' });
continue;
}
const dist = this.renderer.cameraDistanceTo(index);
const a = Math.min(Math.max((far - dist) / (far - near), 0), 1);
el.style.opacity = a.toFixed(2);
el.style.transform = `translate3d(${p.x.toFixed(1)}px, ${(p.y - 14).toFixed(1)}px, 0)`;
}
for (const { index, el } of this.neighborEls) {
const p = this.renderer.projectNode(index, w, h);
el.style.opacity = p.behind ? '0' : '0.85';
if (!p.behind) el.style.transform = `translate3d(${p.x.toFixed(1)}px, ${(p.y - 12).toFixed(1)}px, 0)`;
}
if (this.hoverIndex >= 0) {
const p = this.renderer.projectNode(this.hoverIndex, w, h);
if (!p.behind) this.hoverEl.style.transform = `translate3d(${p.x.toFixed(1)}px, ${(p.y - 18).toFixed(1)}px, 0)`;
}
if (this.cardIndex >= 0 && !this.mobileCard) {
const p = this.renderer.projectNode(this.cardIndex, w, h);
if (!p.behind) {
const flip = p.x + 296 > w;
const x = flip ? p.x - 296 : p.x + 16;
const y = Math.min(Math.max(p.y - 40, 12), Math.max(h - this.card.clientHeight - 12, 12));
this.card.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
}
}
}
dispose(): void {
this.root.remove();
this.hubEls = [];
this.neighborEls = [];
}
private tt(key: string, vars: Record<string, string | number> = {}): string {
return t(this.language, key, vars);
}
}
function stripMarkdown(text: string): string {
return text
.replace(/^---\n[\s\S]*?\n---\n?/, '') // frontmatter
.replace(/!?\[\[([^\]|]+)(\|[^\]]+)?\]\]/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#*`>~_]|---/g, '')
.replace(/\s+/g, ' ')
.trim();
}

110
src/overlay/Slider.ts Normal file
View file

@ -0,0 +1,110 @@
export interface SliderSpec {
label: string;
min: number;
max: number;
step: number;
defaultValue: number;
get: () => number;
set: (v: number) => void;
fmt?: (v: number) => string;
onInput: () => void;
defaultLabel?: string;
}
/**
* Lightroom G1 +
* - [min,default] [default,max] 线
* - =
* - min/max
*/
export class Slider {
private thumbEl: HTMLElement;
private fillEl: HTMLElement;
private valueEl: HTMLElement;
private trackEl: HTMLElement;
constructor(
parent: HTMLElement,
private spec: SliderSpec,
) {
const root = parent.createDiv({ cls: 'gx-slider' });
const head = root.createDiv({ cls: 'gx-slider-head' });
head.createSpan({ cls: 'gx-slider-label', text: spec.label });
this.valueEl = head.createSpan({ cls: 'gx-slider-value' });
this.trackEl = root.createDiv({ cls: 'gx-slider-track' });
this.trackEl.createDiv({ cls: 'gx-slider-rail' });
this.fillEl = this.trackEl.createDiv({ cls: 'gx-slider-fill' });
this.trackEl.createDiv({
cls: 'gx-slider-notch',
attr: { title: `${spec.defaultLabel ?? 'Default'} ${this.fmt(spec.defaultValue)}` },
});
this.thumbEl = this.trackEl.createDiv({ cls: 'gx-slider-thumb' });
const bounds = root.createDiv({ cls: 'gx-slider-bounds' });
bounds.createSpan({ text: this.fmt(spec.min) });
bounds.createSpan({ text: this.fmt(spec.max) });
this.bindDrag();
this.trackEl.addEventListener('dblclick', () => {
spec.set(spec.defaultValue);
this.refresh();
spec.onInput();
});
this.refresh();
}
private fmt(v: number): string {
return this.spec.fmt ? this.spec.fmt(v) : v.toFixed(2);
}
/** 位置(0..1) → 值:默认值锚定在 0.5 */
private posToValue(p: number): number {
const { min, max, defaultValue: d, step } = this.spec;
const raw = p <= 0.5 ? min + (d - min) * (p / 0.5) : d + (max - d) * ((p - 0.5) / 0.5);
const stepped = Math.round(raw / step) * step;
return Math.min(Math.max(stepped, min), max);
}
private valueToPos(v: number): number {
const { min, max, defaultValue: d } = this.spec;
if (v <= d) return d === min ? 0 : (0.5 * (v - min)) / (d - min);
return max === d ? 1 : 0.5 + (0.5 * (v - d)) / (max - d);
}
private bindDrag(): void {
const onPointer = (e: PointerEvent) => {
const rect = this.trackEl.getBoundingClientRect();
const p = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
this.spec.set(this.posToValue(p));
this.refresh();
this.spec.onInput();
};
this.trackEl.addEventListener('pointerdown', (e) => {
e.preventDefault();
this.trackEl.setPointerCapture(e.pointerId);
onPointer(e);
const move = (ev: PointerEvent) => onPointer(ev);
const up = (ev: PointerEvent) => {
this.trackEl.releasePointerCapture(ev.pointerId);
this.trackEl.removeEventListener('pointermove', move);
this.trackEl.removeEventListener('pointerup', up);
};
this.trackEl.addEventListener('pointermove', move);
this.trackEl.addEventListener('pointerup', up);
});
}
refresh(): void {
const v = this.spec.get();
const p = this.valueToPos(v);
this.thumbEl.style.left = `${(p * 100).toFixed(2)}%`;
const from = Math.min(p, 0.5);
const width = Math.abs(p - 0.5);
this.fillEl.style.left = `${(from * 100).toFixed(2)}%`;
this.fillEl.style.width = `${(width * 100).toFixed(2)}%`;
this.valueEl.setText(this.fmt(v));
this.valueEl.toggleClass('is-default', Math.abs(v - this.spec.defaultValue) < this.spec.step / 2);
}
}

54
src/quality/tiers.ts Normal file
View file

@ -0,0 +1,54 @@
/**
* M4
* Platform.isMobile
* auto = high + FPS
*/
export type TierId = 'high' | 'low' | 'mobile';
export interface QualityTier {
id: TierId;
pixelRatioCap: number;
bloomAllowed: boolean; // mobile 关 bloomshader 热核保住 80% 观感,省掉全部后期开销
starScale: number; // 星空点数缩放
nodeCap: number | null; // 按度数排序取 top N实测 top1500 保 94% 链接质量)
linkCap: number | null; // 按 min(端点度数) 取 top N
hubLabels: number;
neighborLabels: number;
hoverThrottleMs: number | null; // null = 仅 tap触屏无 hover
}
export const TIERS: Record<TierId, QualityTier> = {
high: {
id: 'high',
pixelRatioCap: 2,
bloomAllowed: true,
starScale: 1,
nodeCap: null,
linkCap: null,
hubLabels: 14,
neighborLabels: 20,
hoverThrottleMs: 30,
},
low: {
id: 'low',
pixelRatioCap: 1,
bloomAllowed: true,
starScale: 0.4,
nodeCap: null,
linkCap: null,
hubLabels: 8,
neighborLabels: 12,
hoverThrottleMs: 80,
},
mobile: {
id: 'mobile',
pixelRatioCap: 1.5,
bloomAllowed: false,
starScale: 0.32,
nodeCap: 1500,
linkCap: 12_000,
hubLabels: 6,
neighborLabels: 8,
hoverThrottleMs: null,
},
};

View file

@ -0,0 +1,672 @@
import {
ACESFilmicToneMapping,
BufferAttribute,
BufferGeometry,
Color,
Group,
LineBasicMaterial,
LineSegments,
NoToneMapping,
PerspectiveCamera,
Points,
PointsMaterial,
Scene,
ShaderMaterial,
Vector2,
Vector3,
WebGLRenderer,
} from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import type { GraphData } from '../types';
import { BLOOM_DEFAULTS, NODE_BASE_RADIUS, NODE_MAX_RADIUS, STARFIELD_ROTATION_RAD_PER_S } from '../constants';
import { NODE_FRAGMENT_SHADER, NODE_VERTEX_SHADER } from './shaders';
import { linkColor, fallbackColorFn } from './palette';
import type { NodeColorFn } from './palette';
import { buildStarfield, disposeStarfield, Twinkler } from './starfield';
import type { VisualTokens } from './presets';
import type { QualityTier } from '../quality/tiers';
import { DEEP_SPACE } from './presets';
const FOCUS_DIM = 0.12;
const FOCUS_FADE_S = 0.28;
/**
* 1×Points 1×LineSegments 3×Points
* 1×LineSegments <10 draw call
* / VisualTokens WebGL
*/
export class AggregateRenderer {
readonly camera: PerspectiveCamera;
readonly renderer: WebGLRenderer;
private scene = new Scene();
private composer: EffectComposer;
private bloomPass: UnrealBloomPass;
private outputPass: OutputPass;
private renderPass: RenderPass;
private nodePoints: Points | null = null;
private nodeMaterial: ShaderMaterial | null = null;
private nodeGeometry: BufferGeometry | null = null;
private linkSegments: LineSegments | null = null;
private linkGeometry: BufferGeometry | null = null;
private linkMaterial: LineBasicMaterial | null = null;
private selSegments: LineSegments | null = null;
private selGeometry: BufferGeometry | null = null;
private selMaterial: LineBasicMaterial | null = null;
private selLinkIdx: number[] = [];
private starfield: Group;
private twinkler: Twinkler;
twinkleFreq = 0.5;
private motes: Points | null = null;
private reveal: { t0: number; durMs: number; maxR: number } | null = null;
private revealBuf: Float32Array = new Float32Array(0);
private data: GraphData = { nodes: [], links: [] };
private positions: Float32Array = new Float32Array(0);
private sizes: Float32Array = new Float32Array(0);
private dimCurrent: Float32Array = new Float32Array(0);
private dimTarget: Float32Array = new Float32Array(0);
private dimAnimating = false;
private colorFn: NodeColorFn = fallbackColorFn;
private tokens: VisualTokens = DEEP_SPACE;
private tierBloomAllowed = true;
private lastW = 2;
private lastH = 2;
private baseLinkOpacity = 0.16;
private focusActive = false;
private graphRadiusEstimate: number;
private projVec = new Vector3();
private pixelScale = 1;
private nodeScale = 1;
constructor(container: HTMLElement, graphRadiusEstimate: number) {
this.graphRadiusEstimate = graphRadiusEstimate;
this.renderer = new WebGLRenderer({ antialias: false, alpha: false });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.toneMapping = ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.05;
this.renderer.info.autoReset = false;
container.appendChild(this.renderer.domElement);
this.scene.background = new Color(this.tokens.background);
this.camera = new PerspectiveCamera(60, 1, 0.5, 50_000);
const sf = buildStarfield(graphRadiusEstimate * 6.5);
this.starfield = sf.group;
this.twinkler = sf.twinkler;
this.scene.add(this.starfield);
this.composer = new EffectComposer(this.renderer);
this.renderPass = new RenderPass(this.scene, this.camera);
this.bloomPass = new UnrealBloomPass(
new Vector2(2, 2),
BLOOM_DEFAULTS.strength,
BLOOM_DEFAULTS.radius,
BLOOM_DEFAULTS.threshold,
);
this.outputPass = new OutputPass();
this.composer.addPass(this.renderPass);
this.composer.addPass(this.bloomPass);
this.composer.addPass(this.outputPass);
}
// ---------- 数据与颜色 ----------
setColorFn(fn: NodeColorFn): void {
this.colorFn = fn;
}
setData(data: GraphData, positions: Float32Array): void {
this.data = data;
this.positions = positions;
this.disposeGraphObjects();
const n = data.nodes.length;
const m = data.links.length;
// —— 节点 ——
const nodePos = new Float32Array(n * 3);
nodePos.set(positions.subarray(0, n * 3));
const ghost = new Float32Array(n);
this.sizes = new Float32Array(n);
this.dimCurrent = new Float32Array(n).fill(1);
this.dimTarget = new Float32Array(n).fill(1);
for (let i = 0; i < n; i++) {
const node = data.nodes[i];
if (!node) continue;
ghost[i] = node.unresolved ? 1 : 0;
this.sizes[i] = this.computeSize(node);
}
this.nodeGeometry = new BufferGeometry();
this.nodeGeometry.setAttribute('position', new BufferAttribute(nodePos, 3));
this.nodeGeometry.setAttribute('color', new BufferAttribute(new Float32Array(n * 3), 3));
this.nodeGeometry.setAttribute('aSize', new BufferAttribute(this.sizes, 1));
this.nodeGeometry.setAttribute('aGhost', new BufferAttribute(ghost, 1));
this.nodeGeometry.setAttribute('aDim', new BufferAttribute(this.dimCurrent, 1));
this.nodeMaterial = new ShaderMaterial({
vertexShader: NODE_VERTEX_SHADER,
fragmentShader: NODE_FRAGMENT_SHADER,
vertexColors: true,
transparent: true,
depthWrite: false,
uniforms: {
uPixelScale: { value: this.pixelScale },
uSizeMul: { value: this.nodeScale },
uLightMode: { value: this.tokens.lightMode ? 1 : 0 },
uMaxPoint: { value: 110 * this.renderer.getPixelRatio() },
},
});
this.nodePoints = new Points(this.nodeGeometry, this.nodeMaterial);
this.nodePoints.renderOrder = 1; // 节点永远盖住链接网
this.nodePoints.frustumCulled = false;
this.scene.add(this.nodePoints);
// —— 链接 ——
this.linkGeometry = new BufferGeometry();
this.linkGeometry.setAttribute('position', new BufferAttribute(new Float32Array(m * 2 * 3), 3));
this.linkGeometry.setAttribute('color', new BufferAttribute(new Float32Array(m * 2 * 3), 3));
this.linkMaterial = new LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: this.effectiveLinkOpacity(),
depthWrite: false,
});
this.linkSegments = new LineSegments(this.linkGeometry, this.linkMaterial);
this.linkSegments.renderOrder = 0;
this.linkSegments.frustumCulled = false;
this.scene.add(this.linkSegments);
this.recolor();
this.updatePositions();
this.setSelectedLinks(this.selLinkIdx); // 数据重建后恢复高亮层
}
private sizeMode: 'degree' | 'fileSize' | 'uniform' = 'degree';
private computeSize(node: import('../types').GraphNode): number {
switch (this.sizeMode) {
case 'fileSize':
// 中位笔记 ~2KB立方根压缩长尾巨型文档不吞画面
return Math.min(Math.max(NODE_BASE_RADIUS * (0.7 + 1.1 * Math.cbrt(node.fileSize / 4096)), 1.6), NODE_MAX_RADIUS);
case 'uniform':
return NODE_BASE_RADIUS * 1.3;
default:
return Math.min(NODE_BASE_RADIUS * (1 + 0.5 * Math.sqrt(node.degree)), NODE_MAX_RADIUS);
}
}
setSizeMode(mode: 'degree' | 'fileSize' | 'uniform'): void {
this.sizeMode = mode;
if (!this.nodeGeometry) return;
for (let i = 0; i < this.data.nodes.length; i++) {
const node = this.data.nodes[i];
if (node) this.sizes[i] = this.computeSize(node);
}
(this.nodeGeometry.getAttribute('aSize') as BufferAttribute).needsUpdate = true;
}
/**
* G2.5
* / +
*/
playReveal(durMs = 2600): void {
const n = this.data.nodes.length;
if (n === 0) return;
let maxR = 1;
for (let i = 0; i < n; i++) {
const r = Math.hypot(this.positions[i * 3] ?? 0, this.positions[i * 3 + 1] ?? 0, this.positions[i * 3 + 2] ?? 0);
if (r > maxR) maxR = r;
}
if (this.revealBuf.length < n * 3) this.revealBuf = new Float32Array(n * 3);
this.reveal = { t0: performance.now(), durMs, maxR };
}
private stepReveal(now: number): void {
if (!this.reveal || !this.nodeGeometry || !this.linkGeometry) return;
const { t0, durMs, maxR } = this.reveal;
const p = (now - t0) / durMs;
if (p >= 1) {
this.reveal = null;
this.updatePositions();
if (this.linkMaterial) this.linkMaterial.opacity = this.effectiveLinkOpacity();
return;
}
const n = this.data.nodes.length;
const buf = this.revealBuf;
const pos = this.positions;
for (let i = 0; i < n; i++) {
const x = pos[i * 3] ?? 0;
const y = pos[i * 3 + 1] ?? 0;
const z = pos[i * 3 + 2] ?? 0;
const delay = (Math.hypot(x, y, z) / maxR) * 0.55; // 内圈先亮,波次向外
const local = Math.min(Math.max((p - delay) / 0.45, 0), 1);
const k = 1 - Math.pow(1 - local, 3); // easeOutCubic
buf[i * 3] = x * k;
buf[i * 3 + 1] = y * k;
buf[i * 3 + 2] = z * k;
}
const nodeAttr = this.nodeGeometry.getAttribute('position') as BufferAttribute;
(nodeAttr.array as Float32Array).set(buf.subarray(0, n * 3));
nodeAttr.needsUpdate = true;
const linkAttr = this.linkGeometry.getAttribute('position') as BufferAttribute;
const arr = linkAttr.array as Float32Array;
const links = this.data.links;
for (let li = 0; li < links.length; li++) {
const l = links[li];
if (!l) continue;
const sI = l.source * 3;
const tI = l.target * 3;
const o = li * 6;
arr[o] = buf[sI] ?? 0;
arr[o + 1] = buf[sI + 1] ?? 0;
arr[o + 2] = buf[sI + 2] ?? 0;
arr[o + 3] = buf[tI] ?? 0;
arr[o + 4] = buf[tI + 1] ?? 0;
arr[o + 5] = buf[tI + 2] ?? 0;
}
linkAttr.needsUpdate = true;
if (this.linkMaterial) this.linkMaterial.opacity = this.effectiveLinkOpacity() * Math.min(p * 1.6, 1);
}
get revealing(): boolean {
return this.reveal !== null;
}
/** 配色/视觉方向变化时重算颜色(不动坐标) */
recolor(): void {
if (!this.nodeGeometry || !this.linkGeometry) return;
const n = this.data.nodes.length;
const nodeColAttr = this.nodeGeometry.getAttribute('color') as BufferAttribute;
const nodeCol = nodeColAttr.array as Float32Array;
const resolved: Color[] = new Array<Color>(n);
const hsl = { h: 0, s: 0, l: 0 };
for (let i = 0; i < n; i++) {
const node = this.data.nodes[i];
if (!node) continue;
let c = this.colorFn(node).clone();
if (this.tokens.nodeLightness !== null) {
c.getHSL(hsl);
c = c.setHSL(hsl.h, hsl.s * 0.95, this.tokens.nodeLightness);
}
resolved[i] = c;
nodeCol[i * 3] = c.r;
nodeCol[i * 3 + 1] = c.g;
nodeCol[i * 3 + 2] = c.b;
}
nodeColAttr.needsUpdate = true;
const linkColAttr = this.linkGeometry.getAttribute('color') as BufferAttribute;
const linkCol = linkColAttr.array as Float32Array;
const ink = this.tokens.linkInk ? new Color(this.tokens.linkInk) : null;
const fallback = new Color('#7a87a8');
for (let li = 0; li < this.data.links.length; li++) {
const l = this.data.links[li];
if (!l) continue;
const c = ink ?? linkColor(resolved[l.source] ?? fallback, resolved[l.target] ?? fallback);
for (const v of [0, 1]) {
linkCol[(li * 2 + v) * 3] = c.r;
linkCol[(li * 2 + v) * 3 + 1] = c.g;
linkCol[(li * 2 + v) * 3 + 2] = c.b;
}
}
linkColAttr.needsUpdate = true;
}
/** 布局热时每帧调用:节点直拷,链接按索引 gather */
updatePositions(): void {
if (!this.nodeGeometry || !this.linkGeometry) return;
const n = this.data.nodes.length;
const nodeAttr = this.nodeGeometry.getAttribute('position') as BufferAttribute;
(nodeAttr.array as Float32Array).set(this.positions.subarray(0, n * 3));
nodeAttr.needsUpdate = true;
const linkAttr = this.linkGeometry.getAttribute('position') as BufferAttribute;
const arr = linkAttr.array as Float32Array;
const pos = this.positions;
const links = this.data.links;
for (let li = 0; li < links.length; li++) {
const l = links[li];
if (!l) continue;
const s = l.source * 3;
const t = l.target * 3;
const o = li * 6;
arr[o] = pos[s] ?? 0;
arr[o + 1] = pos[s + 1] ?? 0;
arr[o + 2] = pos[s + 2] ?? 0;
arr[o + 3] = pos[t] ?? 0;
arr[o + 4] = pos[t + 1] ?? 0;
arr[o + 5] = pos[t + 2] ?? 0;
}
linkAttr.needsUpdate = true;
this.updateSelPositions();
}
// ---------- 聚焦与选中高亮 ----------
/** 聚焦模式非邻居淡出280ms 缓动CPU 插值 3k floats 可忽略) */
setFocus(selected: number, neighbors: Set<number> | null): void {
const n = this.data.nodes.length;
this.focusActive = neighbors !== null;
for (let i = 0; i < n; i++) {
this.dimTarget[i] = !neighbors || neighbors.has(i) || i === selected ? 1 : FOCUS_DIM;
}
this.dimAnimating = true;
if (this.linkMaterial) this.linkMaterial.opacity = this.effectiveLinkOpacity();
}
/** 选中节点自身的链接 → 独立高亮层(全饱和、盖在最上) */
setSelectedLinks(linkIndices: number[]): void {
this.selLinkIdx = linkIndices;
if (this.selSegments) {
this.scene.remove(this.selSegments);
this.selGeometry?.dispose();
this.selMaterial?.dispose();
this.selSegments = null;
this.selGeometry = null;
this.selMaterial = null;
}
if (linkIndices.length === 0) return;
const m = linkIndices.length;
const pos = new Float32Array(m * 6);
const col = new Float32Array(m * 6);
const hsl = { h: 0, s: 0, l: 0 };
for (let k = 0; k < m; k++) {
const l = this.data.links[linkIndices[k] ?? -1];
if (!l) continue;
const sNode = this.data.nodes[l.source];
const c = (sNode ? this.colorFn(sNode).clone() : new Color('#9aa4b2'));
c.getHSL(hsl);
c.setHSL(hsl.h, Math.min(hsl.s * 1.2, 1), this.tokens.lightMode ? 0.42 : 0.62);
for (const v of [0, 1]) {
col[k * 6 + v * 3] = c.r;
col[k * 6 + v * 3 + 1] = c.g;
col[k * 6 + v * 3 + 2] = c.b;
}
}
this.selGeometry = new BufferGeometry();
this.selGeometry.setAttribute('position', new BufferAttribute(pos, 3));
this.selGeometry.setAttribute('color', new BufferAttribute(col, 3));
this.selMaterial = new LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.85,
depthWrite: false,
});
this.selSegments = new LineSegments(this.selGeometry, this.selMaterial);
this.selSegments.renderOrder = 2;
this.selSegments.frustumCulled = false;
this.scene.add(this.selSegments);
this.updateSelPositions();
}
private updateSelPositions(): void {
if (!this.selGeometry || this.selLinkIdx.length === 0) return;
const attr = this.selGeometry.getAttribute('position') as BufferAttribute;
const arr = attr.array as Float32Array;
const pos = this.positions;
for (let k = 0; k < this.selLinkIdx.length; k++) {
const l = this.data.links[this.selLinkIdx[k] ?? -1];
if (!l) continue;
const s = l.source * 3;
const t = l.target * 3;
arr[k * 6] = pos[s] ?? 0;
arr[k * 6 + 1] = pos[s + 1] ?? 0;
arr[k * 6 + 2] = pos[s + 2] ?? 0;
arr[k * 6 + 3] = pos[t] ?? 0;
arr[k * 6 + 4] = pos[t + 1] ?? 0;
arr[k * 6 + 5] = pos[t + 2] ?? 0;
}
attr.needsUpdate = true;
}
private effectiveLinkOpacity(): number {
const base = this.baseLinkOpacity * this.tokens.linkOpacityScale;
return this.focusActive ? base * 0.25 : base;
}
// ---------- 视觉方向 ----------
applyTokens(tokens: VisualTokens, bloomStrengthFromSettings: number): void {
this.tokens = tokens;
this.scene.background = new Color(tokens.background);
this.starfield.visible = tokens.starfield;
this.renderer.toneMapping = tokens.lightMode ? NoToneMapping : ACESFilmicToneMapping;
if (this.nodeMaterial) {
this.nodeMaterial.uniforms['uLightMode']!.value = tokens.lightMode ? 1 : 0;
}
this.bloomPass.enabled = tokens.bloomEnabled && this.tierBloomAllowed && bloomStrengthFromSettings > 0.001;
if (tokens.motes && !this.motes) this.buildMotes();
if (this.motes) this.motes.visible = tokens.motes;
if (this.linkMaterial) this.linkMaterial.opacity = this.effectiveLinkOpacity();
this.recolor();
this.setSelectedLinks(this.selLinkIdx);
}
get currentTokens(): VisualTokens {
return this.tokens;
}
/** 晨昼模式的尘埃微粒600 点、近大远小、缓慢漂移 */
private buildMotes(): void {
const count = 600;
const pos = new Float32Array(count * 3);
const R = this.graphRadiusEstimate * 2.2;
for (let i = 0; i < count; i++) {
pos[i * 3] = (Math.random() * 2 - 1) * R;
pos[i * 3 + 1] = (Math.random() * 2 - 1) * R;
pos[i * 3 + 2] = (Math.random() * 2 - 1) * R;
}
const geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(pos, 3));
const mat = new PointsMaterial({
color: new Color('#d8d4cb'),
size: 1.6,
sizeAttenuation: true,
transparent: true,
opacity: 0.5,
depthWrite: false,
});
this.motes = new Points(geo, mat);
this.motes.renderOrder = -1;
this.scene.add(this.motes);
}
// ---------- 渲染循环 ----------
render(deltaS: number): void {
this.starfield.rotation.y += STARFIELD_ROTATION_RAD_PER_S * deltaS;
if (this.starfield.visible) this.twinkler.update(deltaS, this.twinkleFreq);
if (this.motes?.visible) this.motes.rotation.y -= STARFIELD_ROTATION_RAD_PER_S * 2 * deltaS;
if (this.dimAnimating) this.stepDim(deltaS);
if (this.reveal) this.stepReveal(performance.now());
this.renderer.info.reset();
this.composer.render();
}
private stepDim(deltaS: number): void {
const k = Math.min(deltaS / FOCUS_FADE_S, 1);
let active = false;
for (let i = 0; i < this.dimCurrent.length; i++) {
const cur = this.dimCurrent[i] ?? 1;
const tgt = this.dimTarget[i] ?? 1;
const next = cur + (tgt - cur) * k;
this.dimCurrent[i] = Math.abs(next - tgt) < 0.005 ? tgt : next;
if (this.dimCurrent[i] !== tgt) active = true;
}
this.dimAnimating = active;
if (this.nodeGeometry) {
(this.nodeGeometry.getAttribute('aDim') as BufferAttribute).needsUpdate = true;
}
}
get drawCalls(): number {
return this.renderer.info.render.calls;
}
// ---------- 参数 ----------
setBloomParams(p: { strength: number; radius: number; threshold: number }): void {
this.bloomPass.strength = p.strength;
this.bloomPass.radius = p.radius;
this.bloomPass.threshold = p.threshold;
this.bloomPass.enabled = this.tokens.bloomEnabled && this.tierBloomAllowed && p.strength > 0.001;
}
getBloomStrength(): number {
return this.bloomPass.enabled ? this.bloomPass.strength : 0;
}
setBloomStrength(v: number): void {
this.bloomPass.strength = v;
this.bloomPass.enabled = this.tokens.bloomEnabled && this.tierBloomAllowed && v > 0.001;
}
/** 质量档位M4pixelRatio / bloom 门控 / 星空密度,全部免重建即时生效 */
applyTier(tier: QualityTier, bloomStrengthFromSettings: number): void {
this.tierBloomAllowed = tier.bloomAllowed;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.pixelRatioCap));
this.bloomPass.enabled = this.tokens.bloomEnabled && this.tierBloomAllowed && bloomStrengthFromSettings > 0.001;
// 星空按档位密度重建(一次性,毫秒级)
const visible = this.starfield.visible;
const rotation = this.starfield.rotation.y;
disposeStarfield(this.starfield);
this.scene.remove(this.starfield);
const sf = buildStarfield(this.graphRadiusEstimate * 6.5, tier.starScale);
this.starfield = sf.group;
this.twinkler = sf.twinkler;
this.starfield.visible = visible;
this.starfield.rotation.y = rotation;
this.scene.add(this.starfield);
this.resize(this.lastW, this.lastH); // pixelRatio 变化 → 重算 uPixelScale/uMaxPoint 与缓冲尺寸
const u = this.nodeMaterial?.uniforms['uMaxPoint'];
if (u) u.value = 110 * this.renderer.getPixelRatio();
}
setLinkOpacity(v: number): void {
this.baseLinkOpacity = v;
if (this.linkMaterial) this.linkMaterial.opacity = this.effectiveLinkOpacity();
}
setNodeScale(v: number): void {
this.nodeScale = v;
const u = this.nodeMaterial?.uniforms['uSizeMul'];
if (u) u.value = v;
}
resize(w: number, h: number): void {
if (w < 2 || h < 2) return;
this.lastW = w;
this.lastH = h;
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.renderer.setSize(w, h);
this.composer.setSize(w, h);
this.bloomPass.resolution.set(w, h);
const physH = h * this.renderer.getPixelRatio();
this.pixelScale = physH / (2 * Math.tan(((this.camera.fov / 2) * Math.PI) / 180));
const u = this.nodeMaterial?.uniforms['uPixelScale'];
if (u) u.value = this.pixelScale;
}
// ---------- 拾取与投影 ----------
/** 投影到屏幕逻辑像素z>1 = 在镜头后 */
projectNode(i: number, w: number, h: number): { x: number; y: number; behind: boolean } {
this.projVec.set(this.positions[i * 3] ?? 0, this.positions[i * 3 + 1] ?? 0, this.positions[i * 3 + 2] ?? 0);
this.projVec.project(this.camera);
return {
x: ((this.projVec.x + 1) / 2) * w,
y: ((1 - this.projVec.y) / 2) * h,
behind: this.projVec.z > 1,
};
}
/** 屏幕空间最近邻拾取O(n) 仅在点击/节流 hover 时跑) */
pickNearest(px: number, py: number, w: number, h: number, maxPx: number): number {
let best = -1;
let bestDist = maxPx;
for (let i = 0; i < this.data.nodes.length; i++) {
const p = this.projectNode(i, w, h);
if (p.behind) continue;
const d = Math.hypot(p.x - px, p.y - py);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
nodeRadius(i: number): number {
return this.sizes[i] ?? NODE_BASE_RADIUS;
}
nodePosition(i: number, out: Vector3): Vector3 {
return out.set(this.positions[i * 3] ?? 0, this.positions[i * 3 + 1] ?? 0, this.positions[i * 3 + 2] ?? 0);
}
nodeColorHex(i: number): string {
const node = this.data.nodes[i];
return node ? `#${this.colorFn(node).getHexString()}` : '#9aa4b2';
}
cameraDistanceTo(i: number): number {
this.projVec.set(this.positions[i * 3] ?? 0, this.positions[i * 3 + 1] ?? 0, this.positions[i * 3 + 2] ?? 0);
return this.camera.position.distanceTo(this.projVec);
}
// ---------- 销毁合同 ----------
private disposeGraphObjects(): void {
if (this.nodePoints) this.scene.remove(this.nodePoints);
if (this.linkSegments) this.scene.remove(this.linkSegments);
if (this.selSegments) {
this.scene.remove(this.selSegments);
this.selGeometry?.dispose();
this.selMaterial?.dispose();
this.selSegments = null;
this.selGeometry = null;
this.selMaterial = null;
}
this.nodeGeometry?.dispose();
this.nodeMaterial?.dispose();
this.linkGeometry?.dispose();
this.linkMaterial?.dispose();
this.nodePoints = null;
this.linkSegments = null;
this.nodeGeometry = null;
this.linkGeometry = null;
this.nodeMaterial = null;
this.linkMaterial = null;
}
/** 销毁合同composer 目标 → 场景资源 → renderer → 强制丢上下文 */
dispose(): void {
this.disposeGraphObjects();
disposeStarfield(this.starfield);
this.scene.remove(this.starfield);
if (this.motes) {
this.motes.geometry.dispose();
(this.motes.material as PointsMaterial).dispose();
this.scene.remove(this.motes);
this.motes = null;
}
this.bloomPass.dispose();
this.outputPass.dispose();
this.renderPass.dispose();
this.composer.dispose();
this.renderer.dispose();
try {
this.renderer.forceContextLoss();
} catch {
// 上下文可能已丢失
}
this.renderer.domElement.remove();
}
}

View file

@ -0,0 +1,790 @@
import {
BufferAttribute,
BufferGeometry,
Color,
LineBasicMaterial,
LineSegments,
Mesh,
MeshBasicMaterial,
OrthographicCamera,
Points,
Scene,
ShaderMaterial,
Vector3,
WebGLRenderer,
} from 'three';
import type { LabelVisibility } from '../settings';
import type { RadialLayout, RadialPoint, RadialRoute } from '../layout/radial/layoutRadial';
import type { VisibleWorldGraph, WorldEdge, WorldNode } from '../world/types';
import { NODE_FRAGMENT_SHADER, NODE_VERTEX_SHADER } from './shaders';
export interface RadialActiveState {
hasActive: boolean;
dimOthers: boolean;
activeNodeId: string | null;
activeLinkId: string | null;
relatedNodes: Set<string>;
highlightedEdges: Set<string>;
labelNodes: Set<string>;
pinnedNodeIds: Set<string>;
}
interface EdgeVisual {
key: string;
edge: WorldEdge;
points: { x: number; y: number }[];
}
export type RadialResolvedScheme = 'day' | 'night';
interface RadialPalette {
bg: string;
ring: string;
tree: string;
link: string;
external: string;
externalLink: string;
unresolved: string;
focus: string;
folder: string;
folderMeta: string;
note: string;
root: string;
ringOpacity: number;
treeOpacity: number;
linkOpacity: number;
externalLinkOpacity: number;
highlightOpacity: number;
nodeScale: number;
maxLabels: number;
}
const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
day: {
bg: '#f7f8fb',
ring: '#94a3b8',
tree: '#5b677a',
link: '#525e70',
external: '#5b5fe0',
externalLink: '#cb7622',
unresolved: '#dc2626',
focus: '#5368ee',
folder: '#3f4a5a',
folderMeta: '#4f5ee8',
note: '#536073',
root: '#4f5ee8',
ringOpacity: 0.16,
treeOpacity: 0.17,
linkOpacity: 0.06,
externalLinkOpacity: 0.08,
highlightOpacity: 0.9,
nodeScale: 0.19,
maxLabels: 140,
},
night: {
bg: '#0f1117',
ring: '#818cf8',
tree: '#818cf8',
link: '#94a3b8',
external: '#c4b5fd',
externalLink: '#fb923c',
unresolved: '#fb7185',
focus: '#aab6ff',
folder: '#d7dae2',
folderMeta: '#f59e0b',
note: '#a1a8b5',
root: '#7c9cff',
ringOpacity: 0.15,
treeOpacity: 0.18,
linkOpacity: 0.065,
externalLinkOpacity: 0.09,
highlightOpacity: 0.98,
nodeScale: 0.2,
maxLabels: 140,
},
};
export class RadialRenderer {
readonly renderer: WebGLRenderer;
readonly camera: OrthographicCamera;
readonly domElement: HTMLCanvasElement;
private scene = new Scene();
private labelRoot: HTMLElement;
private ringSegments: Mesh | null = null;
private hierarchySegments: LineSegments | null = null;
private linkSegments: LineSegments | null = null;
private highlightSegments: Mesh | null = null;
private nodePoints: Points | null = null;
private nodeGeometry: BufferGeometry | null = null;
private nodeMaterial: ShaderMaterial | null = null;
private nodeIds: string[] = [];
private edgeVisuals = new Map<string, EdgeVisual>();
private graph: VisibleWorldGraph | null = null;
private layout: RadialLayout | null = null;
private active: RadialActiveState = emptyActiveState();
private width = 1;
private height = 1;
private centerX = 0;
private centerY = 0;
private zoom = 1;
private scheme: RadialResolvedScheme = 'night';
private revealFrame = 0;
private revealOverlay: HTMLElement | null = null;
constructor(private container: HTMLElement) {
this.renderer = new WebGLRenderer({ antialias: true, alpha: false });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setClearColor(this.palette().bg, 1);
this.domElement = this.renderer.domElement;
this.domElement.classList.add('mwm-radial-canvas');
this.domElement.tabIndex = 0;
container.appendChild(this.domElement);
this.labelRoot = container.createDiv({ cls: 'mwm-radial-labels' });
this.camera = new OrthographicCamera(-1, 1, 1, -1, 0.1, 5000);
this.camera.position.set(0, 0, 1000);
this.camera.lookAt(0, 0, 0);
}
setTheme(scheme: RadialResolvedScheme): boolean {
if (this.scheme === scheme) return false;
this.scheme = scheme;
this.renderer.setClearColor(this.palette().bg, 1);
if (this.nodeMaterial) {
const lightMode = this.nodeMaterial.uniforms['uLightMode'];
const pixelScale = this.nodeMaterial.uniforms['uPixelScale'];
if (lightMode) lightMode.value = scheme === 'day' ? 1 : 0;
if (pixelScale) pixelScale.value = 1000 * Math.min(window.devicePixelRatio || 1, 2);
this.updateNodeScale();
}
this.render();
return true;
}
setData(graph: VisibleWorldGraph, layout: RadialLayout, labelVisibility: LabelVisibility): void {
this.graph = graph;
this.layout = layout;
this.edgeVisuals.clear();
this.disposeObjects();
this.buildRings();
this.buildEdges(graph.hierarchyEdges, layout, 'hierarchy');
this.buildEdges(graph.linkEdges, layout, 'links');
for (const edge of graph.hoverLinkEdges) {
if (this.edgeVisuals.has(edge.id)) continue;
const visual = this.edgeVisual(edge, layout);
if (visual) this.edgeVisuals.set(visual.key, visual);
}
this.buildNodes(graph, layout);
this.setActive(this.active, labelVisibility);
this.render();
}
resize(width: number, height: number): void {
this.width = Math.max(1, Math.floor(width));
this.height = Math.max(1, Math.floor(height));
this.renderer.setSize(this.width, this.height, false);
this.camera.left = -this.width / 2;
this.camera.right = this.width / 2;
this.camera.top = this.height / 2;
this.camera.bottom = -this.height / 2;
this.applyCamera();
this.updateLabels();
}
setView(centerX: number, centerY: number, zoom: number): void {
this.centerX = Number.isFinite(centerX) ? centerX : 0;
this.centerY = Number.isFinite(centerY) ? centerY : 0;
this.zoom = Math.min(Math.max(Number.isFinite(zoom) ? zoom : 1, 0.003), 6);
this.updateNodeScale();
this.applyCamera();
const rebuiltHighlights = this.active.highlightedEdges.size > 0;
if (rebuiltHighlights) this.rebuildHighlights();
this.updateLabels();
if (rebuiltHighlights) this.render();
}
getView(): { centerX: number; centerY: number; zoom: number } {
return { centerX: this.centerX, centerY: this.centerY, zoom: this.zoom };
}
fitToLayout(): void {
if (!this.layout) return;
const padding = 160;
const w = Math.max(1, this.layout.bounds.maxX - this.layout.bounds.minX + padding * 2);
const h = Math.max(1, this.layout.bounds.maxY - this.layout.bounds.minY + padding * 2);
const zoom = Math.min(this.width / w, this.height / h, 1.2) * 0.92;
this.setView((this.layout.bounds.minX + this.layout.bounds.maxX) / 2, (this.layout.bounds.minY + this.layout.bounds.maxY) / 2, zoom);
}
playRevealFromRoot(rootId: string, text: string, durMs = 1200): void {
if (!this.layout || this.width < 2 || this.height < 2) return;
cancelAnimationFrame(this.revealFrame);
this.revealOverlay?.remove();
const root = this.layout.positions.get(rootId) ?? { x: 0, y: 0 };
const screen = this.worldToScreen(root.x, root.y);
const maxRadius = Math.hypot(Math.max(screen.x, this.width - screen.x), Math.max(screen.y, this.height - screen.y)) + 120;
const start = performance.now();
this.container.style.setProperty('--mwm-reveal-x', `${screen.x.toFixed(1)}px`);
this.container.style.setProperty('--mwm-reveal-y', `${screen.y.toFixed(1)}px`);
this.container.style.setProperty('--mwm-reveal-radius', '0px');
this.container.addClass('is-radial-revealing');
this.revealOverlay = this.container.createDiv({ cls: 'mwm-radial-loading gx-mask-text', text });
const step = (now: number) => {
const p = Math.min(Math.max((now - start) / durMs, 0), 1);
const eased = 1 - Math.pow(1 - p, 3);
this.container.style.setProperty('--mwm-reveal-radius', `${(maxRadius * eased).toFixed(1)}px`);
if (p < 1) {
this.revealFrame = window.requestAnimationFrame(step);
return;
}
this.container.removeClass('is-radial-revealing');
this.revealOverlay?.addClass('is-fading');
window.setTimeout(() => {
this.revealOverlay?.remove();
this.revealOverlay = null;
}, 220);
};
this.revealFrame = window.requestAnimationFrame(step);
}
setActive(active: RadialActiveState, labelVisibility: LabelVisibility): void {
this.active = active;
this.updateNodeDim();
this.rebuildHighlights();
this.updateLabels(labelVisibility);
this.render();
}
render(): void {
this.renderer.render(this.scene, this.camera);
}
worldToScreen(x: number, y: number): { x: number; y: number } {
const projected = new Vector3(x, y, 0).project(this.camera);
return {
x: ((projected.x + 1) / 2) * this.width,
y: ((1 - projected.y) / 2) * this.height,
};
}
screenToWorld(x: number, y: number): { x: number; y: number } {
return {
x: this.centerX + (x - this.width / 2) / this.zoom,
y: this.centerY - (y - this.height / 2) / this.zoom,
};
}
hitTest(
screenX: number,
screenY: number,
includeLinks: boolean,
includeNodes = true,
): { nodeId: string | null; edge: WorldEdge | null } {
if (!this.graph || !this.layout) return { nodeId: null, edge: null };
const world = this.screenToWorld(screenX, screenY);
let bestNode: { id: string; distance: number } | null = null;
if (includeNodes) {
for (const node of this.graph.nodes) {
const point = this.layout.positions.get(node.id);
if (!point) continue;
const distance = Math.hypot(world.x - point.x, world.y - point.y);
const visualRadius = Math.max(4, point.nodeRadius * this.palette().nodeScale * nodeScreenScale(this.zoom) * 0.55);
const radius = Math.max(point.nodeRadius * 0.36, visualRadius / Math.max(0.003, this.zoom)) + Math.max(5, 6 / this.zoom);
if (distance <= radius && (!bestNode || distance < bestNode.distance)) bestNode = { id: node.id, distance };
}
}
if (bestNode) return { nodeId: bestNode.id, edge: null };
if (!includeLinks) return { nodeId: null, edge: null };
let bestEdge: { edge: WorldEdge; distance: number } | null = null;
for (const visual of this.edgeVisuals.values()) {
if (visual.edge.type === 'hierarchy' || visual.edge.type === 'external-hierarchy') continue;
const distance = distanceToPolyline(world, visual.points);
if (distance <= Math.max(10, 8 / this.zoom) && (!bestEdge || distance < bestEdge.distance)) {
bestEdge = { edge: visual.edge, distance };
}
}
return { nodeId: null, edge: bestEdge?.edge ?? null };
}
nodePoint(nodeId: string): RadialPoint | null {
return this.layout?.positions.get(nodeId) ?? null;
}
dispose(): void {
cancelAnimationFrame(this.revealFrame);
this.disposeObjects();
this.renderer.dispose();
this.revealOverlay?.remove();
this.domElement.remove();
this.labelRoot.remove();
}
private palette(): RadialPalette {
return PALETTES[this.scheme];
}
private applyCamera(): void {
this.camera.position.set(this.centerX, this.centerY, 1000);
this.camera.zoom = this.zoom;
this.camera.updateProjectionMatrix();
this.render();
}
private buildRings(): void {
this.ringSegments = null;
}
private buildEdges(edges: WorldEdge[], layout: RadialLayout, kind: 'hierarchy' | 'links'): void {
const positions: number[] = [];
const colors: number[] = [];
const palette = this.palette();
for (const edge of edges) {
const visual = this.edgeVisual(edge, layout);
if (!visual) continue;
this.edgeVisuals.set(visual.key, visual);
const color = edgeColor(edge, kind, palette);
for (let i = 0; i < visual.points.length - 1; i++) {
const a = visual.points[i];
const b = visual.points[i + 1];
if (!a || !b) continue;
positions.push(a.x, a.y, kind === 'hierarchy' ? -1 : -2);
positions.push(b.x, b.y, kind === 'hierarchy' ? -1 : -2);
pushColor(colors, color, kind === 'hierarchy' ? 0.46 : edge.externalCount ? 0.22 : 0.16);
pushColor(colors, color, kind === 'hierarchy' ? 0.46 : edge.externalCount ? 0.22 : 0.16);
}
}
const line = new LineSegments(
makeLineGeometry(positions, colors),
new LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: kind === 'hierarchy' ? palette.treeOpacity : edges.some((edge) => edge.externalCount) ? palette.externalLinkOpacity : palette.linkOpacity,
depthWrite: false,
}),
);
if (kind === 'hierarchy') this.hierarchySegments = line;
else this.linkSegments = line;
this.scene.add(line);
}
private buildNodes(graph: VisibleWorldGraph, layout: RadialLayout): void {
const palette = this.palette();
const positions = new Float32Array(graph.nodes.length * 3);
const colors = new Float32Array(graph.nodes.length * 3);
const sizes = new Float32Array(graph.nodes.length);
const ghost = new Float32Array(graph.nodes.length);
const dim = new Float32Array(graph.nodes.length).fill(1);
this.nodeIds = graph.nodes.map((node) => node.id);
graph.nodes.forEach((node, index) => {
const point = layout.positions.get(node.id);
const color = nodeColor(node, graph.rootId, palette);
positions[index * 3] = point?.x ?? 0;
positions[index * 3 + 1] = point?.y ?? 0;
positions[index * 3 + 2] = 1;
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
sizes[index] = Math.max(2.1, (point?.nodeRadius ?? 8) * palette.nodeScale);
ghost[index] = node.type === 'unresolved' || node.type === 'external' || node.externalProxy ? 1 : 0;
});
this.nodeGeometry = new BufferGeometry();
this.nodeGeometry.setAttribute('position', new BufferAttribute(positions, 3));
this.nodeGeometry.setAttribute('color', new BufferAttribute(colors, 3));
this.nodeGeometry.setAttribute('aSize', new BufferAttribute(sizes, 1));
this.nodeGeometry.setAttribute('aGhost', new BufferAttribute(ghost, 1));
this.nodeGeometry.setAttribute('aDim', new BufferAttribute(dim, 1));
this.nodeMaterial = new ShaderMaterial({
vertexShader: NODE_VERTEX_SHADER,
fragmentShader: NODE_FRAGMENT_SHADER,
vertexColors: true,
transparent: true,
depthWrite: false,
uniforms: {
uPixelScale: { value: 1000 * Math.min(window.devicePixelRatio || 1, 2) },
uSizeMul: { value: nodeScreenScale(this.zoom) },
uLightMode: { value: this.scheme === 'day' ? 1 : 0 },
uMaxPoint: { value: 58 * Math.min(window.devicePixelRatio || 1, 2) },
},
});
this.nodePoints = new Points(this.nodeGeometry, this.nodeMaterial);
this.nodePoints.frustumCulled = false;
this.scene.add(this.nodePoints);
}
private updateNodeDim(): void {
if (!this.nodeGeometry) return;
const attr = this.nodeGeometry.getAttribute('aDim') as BufferAttribute;
const dim = attr.array as Float32Array;
for (let i = 0; i < this.nodeIds.length; i++) {
const id = this.nodeIds[i] ?? '';
const focused =
id === this.active.activeNodeId || this.active.pinnedNodeIds.has(id) || id === this.graph?.focusId;
const related = this.active.relatedNodes.has(id);
dim[i] = this.active.dimOthers && !focused && !related ? 0.23 : focused ? 1.12 : related ? 0.95 : 0.82;
}
attr.needsUpdate = true;
}
private updateNodeScale(): void {
if (!this.nodeMaterial) return;
const sizeMul = this.nodeMaterial.uniforms['uSizeMul'];
const maxPoint = this.nodeMaterial.uniforms['uMaxPoint'];
if (sizeMul) sizeMul.value = nodeScreenScale(this.zoom);
if (maxPoint) maxPoint.value = 58 * Math.min(window.devicePixelRatio || 1, 2);
}
private rebuildHighlights(): void {
if (this.highlightSegments) {
this.scene.remove(this.highlightSegments);
this.highlightSegments.geometry.dispose();
if (Array.isArray(this.highlightSegments.material)) this.highlightSegments.material.forEach((m) => m.dispose());
else this.highlightSegments.material.dispose();
this.highlightSegments = null;
}
const positions: number[] = [];
const colors: number[] = [];
const palette = this.palette();
const color = new Color(palette.focus);
const zoom = Math.max(0.003, this.zoom || 1);
for (const key of this.active.highlightedEdges) {
const visual = this.edgeVisuals.get(key);
if (!visual) continue;
for (let i = 0; i < visual.points.length - 1; i++) {
const a = visual.points[i];
const b = visual.points[i + 1];
if (!a || !b) continue;
pushSegmentBand(positions, colors, color, a, b, highlightWidthPx(visual.edge) / zoom, 2);
}
}
this.highlightSegments = new Mesh(
makeMeshGeometry(positions, colors),
new MeshBasicMaterial({
vertexColors: true,
transparent: true,
opacity: palette.highlightOpacity,
depthWrite: false,
depthTest: false,
}),
);
this.scene.add(this.highlightSegments);
}
private edgeVisual(edge: WorldEdge, layout: RadialLayout): EdgeVisual | null {
const source = layout.positions.get(edge.source);
const target = layout.positions.get(edge.target);
if (!source || !target) return null;
const route = layout.routes.get(edge.id);
return {
key: edge.id,
edge,
points: routePoints(source, target, route),
};
}
private updateLabels(labelVisibility: LabelVisibility = 'auto'): void {
if (!this.graph || !this.layout) return;
this.labelRoot.empty();
const directIds = new Set<string>([
...this.active.labelNodes,
...this.active.pinnedNodeIds,
this.active.activeNodeId ?? '',
this.graph.rootId,
this.graph.focusId ?? '',
]);
directIds.delete('');
const ranked = this.graph.nodes
.map((node) => {
const point = this.layout?.positions.get(node.id) ?? null;
return point ? { node, point, score: labelScore(node, point, this.graph!) } : null;
})
.filter((item): item is { node: WorldNode; point: RadialPoint; score: number } => Boolean(item))
.sort((a, b) => b.score - a.score);
const denominator = Math.max(1, ranked.length - 1);
const autoBudget =
labelVisibility === 'auto' && this.zoom >= 0.08
? Math.round(Math.min(this.palette().maxLabels, Math.max(0, (this.zoom - 0.08) * 84 + Math.sqrt(this.zoom) * 18)))
: 0;
let autoShown = 0;
for (let rank = 0; rank < ranked.length; rank++) {
const item = ranked[rank]!;
const { node, point } = item;
const direct = directIds.has(node.id);
const strength = direct
? 1
: labelVisibility === 'auto'
? zoomLabelStrength(node, point, this.zoom, rank, denominator, this.graph)
: 0;
if (!direct) {
if (strength <= 0.08 || autoShown >= autoBudget) continue;
autoShown++;
}
const screen = this.worldToScreen(point.x, point.y);
if (screen.x < -160 || screen.y < -80 || screen.x > this.width + 160 || screen.y > this.height + 120) continue;
const label = this.labelRoot.createDiv({ cls: node.id === this.graph.rootId ? 'mwm-radial-label is-root' : 'mwm-radial-label' });
label.setText(node.title);
const scale = labelScreenScale(this.zoom) * (node.id === this.graph.rootId ? 1.18 : point.nodeRadius >= 24 ? 1.1 : point.nodeRadius >= 15 ? 1.04 : 1);
const fontSize = Math.max(node.id === this.graph.rootId ? 12 : 9.5, 12 * scale);
label.style.fontSize = `${fontSize.toFixed(2)}px`;
label.style.maxWidth = `${Math.round((node.id === this.graph.rootId ? 190 : node.type === 'folder' ? 156 : point.nodeRadius >= 18 ? 146 : 124) * scale)}px`;
label.style.opacity = String(direct ? 0.96 : Math.min(0.9, 0.22 + strength * 0.68));
const visualNodeRadius = Math.max(5, point.nodeRadius * this.palette().nodeScale * nodeScreenScale(this.zoom) * 0.62);
const labelOffset = Math.max(9, visualNodeRadius + 6 * scale);
label.style.transform = `translate3d(${screen.x.toFixed(1)}px, ${(screen.y + labelOffset).toFixed(1)}px, 0)`;
if (this.active.dimOthers && !directIds.has(node.id) && !this.active.relatedNodes.has(node.id)) {
label.style.opacity = String(Math.min(Number(label.style.opacity) || 1, 0.34));
label.addClass('is-dim');
}
}
}
private disposeObjects(): void {
for (const obj of [this.ringSegments, this.hierarchySegments, this.linkSegments, this.highlightSegments, this.nodePoints]) {
if (!obj) continue;
this.scene.remove(obj);
if ('geometry' in obj) obj.geometry.dispose();
const material = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const item of material) item.dispose();
}
this.ringSegments = null;
this.hierarchySegments = null;
this.linkSegments = null;
this.highlightSegments = null;
this.nodePoints = null;
this.nodeGeometry = null;
this.nodeMaterial = null;
this.labelRoot.empty();
}
}
export function emptyActiveState(): RadialActiveState {
return {
hasActive: false,
dimOthers: false,
activeNodeId: null,
activeLinkId: null,
relatedNodes: new Set(),
highlightedEdges: new Set(),
labelNodes: new Set(),
pinnedNodeIds: new Set(),
};
}
function makeLineGeometry(positions: number[], colors: number[]): BufferGeometry {
const geometry = new BufferGeometry();
geometry.setAttribute('position', new BufferAttribute(new Float32Array(positions), 3));
geometry.setAttribute('color', new BufferAttribute(new Float32Array(colors), 3));
return geometry;
}
function makeMeshGeometry(positions: number[], colors: number[]): BufferGeometry {
const geometry = makeLineGeometry(positions, colors);
geometry.computeBoundingSphere();
return geometry;
}
function pushSegmentBand(
positions: number[],
colors: number[],
color: Color,
a: { x: number; y: number },
b: { x: number; y: number },
width: number,
z: number,
): void {
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
if (!Number.isFinite(length) || length <= 0.001) return;
const half = Math.max(0.1, width * 0.5);
const nx = (-dy / length) * half;
const ny = (dx / length) * half;
const vertices = [
[a.x + nx, a.y + ny, z],
[a.x - nx, a.y - ny, z],
[b.x - nx, b.y - ny, z],
[a.x + nx, a.y + ny, z],
[b.x - nx, b.y - ny, z],
[b.x + nx, b.y + ny, z],
];
for (const vertex of vertices) {
positions.push(vertex[0]!, vertex[1]!, vertex[2]!);
pushColor(colors, color, 1);
}
}
function pushColor(colors: number[], color: Color, alpha: number): void {
void alpha;
colors.push(color.r, color.g, color.b);
}
function nodeColor(node: WorldNode, rootId: string, palette: RadialPalette): Color {
if (node.id === rootId) return new Color(palette.root);
if (node.type === 'unresolved') return new Color(palette.unresolved);
if (node.type === 'external' || node.externalProxy) return new Color(palette.external);
if (node.type === 'folder' && node.representativeFile) return new Color(palette.folderMeta);
if (node.type === 'folder') return new Color(palette.folder);
return new Color(palette.note);
}
function edgeColor(edge: WorldEdge, kind: 'hierarchy' | 'links', palette: RadialPalette): Color {
if (edge.unresolvedCount) return new Color(palette.unresolved);
if (edge.externalCount) return new Color(kind === 'links' ? palette.externalLink : palette.external);
return new Color(kind === 'hierarchy' ? palette.tree : palette.link);
}
function routePoints(source: RadialPoint, target: RadialPoint, route: RadialRoute | undefined): { x: number; y: number }[] {
if (!route) return [source, target];
if (route.kind === 'outer') {
const points: { x: number; y: number }[] = [source];
const start = route.sourceAngle;
let end = route.endAngle ?? route.targetAngle;
let delta = end - start;
if (Math.abs(delta) > Math.PI) delta += delta > 0 ? -Math.PI * 2 : Math.PI * 2;
end = start + delta;
const steps = Math.max(8, Math.ceil(Math.abs(delta) / 0.18));
for (let i = 0; i <= steps; i++) {
const angle = start + (delta * i) / steps;
points.push({
x: route.centerX + Math.cos(angle) * route.radius,
y: route.centerY + Math.sin(angle) * route.radius,
});
}
points.push(target);
return points;
}
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
const pull = route.curveStrength ?? 0.16;
const ctrl = { x: midX - midX * pull, y: midY - midY * pull };
const points: { x: number; y: number }[] = [];
for (let i = 0; i <= 12; i++) {
const t = i / 12;
const a = (1 - t) * (1 - t);
const b = 2 * (1 - t) * t;
const c = t * t;
points.push({ x: a * source.x + b * ctrl.x + c * target.x, y: a * source.y + b * ctrl.y + c * target.y });
}
return points;
}
function distanceToPolyline(point: { x: number; y: number }, points: { x: number; y: number }[]): number {
let best = Infinity;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
if (!a || !b) continue;
best = Math.min(best, distanceToSegment(point, a, b));
}
return best;
}
function distanceToSegment(point: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = dx * dx + dy * dy;
if (len <= 1e-6) return Math.hypot(point.x - a.x, point.y - a.y);
const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / len));
return Math.hypot(point.x - (a.x + dx * t), point.y - (a.y + dy * t));
}
function highlightWidthPx(edge: WorldEdge): number {
if (edge.type === 'hierarchy' || edge.type === 'external-hierarchy') return 3.1;
if (edge.externalCount) return 3.6;
return 4.1;
}
function labelScore(node: WorldNode, point: RadialPoint, graph: VisibleWorldGraph): number {
const degree = Math.max(0, (node.linkCount || 0) + (node.backlinkCount || 0));
let score = Math.max(0, point.nodeRadius || 0) * 2.8 + Math.log1p(degree) * 13 + Math.sqrt(degree) * 1.4 - Math.min(34, (node.depth || 0) * 3.2);
if (node.id === graph.rootId) score += 10000;
if (node.id === graph.focusId) score += 9000;
if (node.type === 'folder') {
score += 32 + Math.log1p(node.noteCount || node.descendantCount || 0) * 8;
if (node.representativeFile) score += 8;
} else if (node.type === 'note') {
score += 10;
} else if (node.type === 'external' || node.externalProxy) {
score -= 10;
} else if (node.type === 'unresolved') {
score -= 22;
}
return score;
}
function zoomLabelStrength(
node: WorldNode,
point: RadialPoint,
zoom: number,
rank: number,
denominator: number,
graph: VisibleWorldGraph,
): number {
const clampedZoom = clampNumber(zoom, 0.003, 6);
const root = node.id === graph.rootId;
if (root) return 0.68 + smoothstep(0.04, 0.2, clampedZoom) * 0.32;
const degree = Math.max(0, (node.linkCount || 0) + (node.backlinkCount || 0));
const folder = node.type === 'folder';
const external = node.type === 'external' || node.externalProxy;
const unresolved = node.type === 'unresolved';
const sizeSignal = clampNumber((point.nodeRadius - 4) / 42, 0, 1);
const degreeSignal = clampNumber(Math.log1p(degree) / Math.log1p(80), 0, 1);
const salienceSignal = clampNumber(1 - rank / Math.max(1, denominator), 0, 1);
const nodeCount = Math.max(1, graph.nodes.length || 1);
const leading = rank < Math.max(10, Math.min(58, Math.ceil(nodeCount * 0.05)));
const secondary = rank < Math.max(28, Math.min(170, Math.ceil(nodeCount * 0.18)));
const tertiary = rank < Math.max(80, Math.min(520, Math.ceil(nodeCount * 0.38)));
let threshold = 1.24 - sizeSignal * 0.64 - degreeSignal * 0.22 - salienceSignal * 0.42;
if (leading) threshold -= 0.28;
else if (secondary) threshold -= 0.2;
else if (tertiary) threshold -= 0.08;
if (folder) threshold -= node.representativeFile ? 0.18 : 0.12;
else if (external) threshold -= 0.04;
if (unresolved) threshold += 0.18;
threshold = clampNumber(threshold, 0.16, 1.38);
const fade = smoothstep(threshold - 0.14, threshold + 0.1, clampedZoom);
const leadingFade = leading
? smoothstep(0.12, 0.38, clampedZoom)
: secondary
? smoothstep(0.22, 0.62, clampedZoom) * 0.9
: tertiary
? smoothstep(0.46, 0.96, clampedZoom) * 0.7
: 0;
const largeFade =
point.nodeRadius >= 24
? smoothstep(0.22, 0.56, clampedZoom) * 0.96
: point.nodeRadius >= 15
? smoothstep(0.42, 0.86, clampedZoom) * 0.78
: 0;
const smallFade = !unresolved ? smoothstep(0.86, 1.28, clampedZoom) * 0.82 : 0;
const closeFade = !unresolved ? smoothstep(1.12, 1.46, clampedZoom) * 0.98 : 0;
return clampNumber(Math.max(fade, leadingFade, largeFade, smallFade, closeFade), 0, 1);
}
function labelScreenScale(zoom: number): number {
const clampedZoom = clampNumber(zoom, 0.003, 6);
return 0.62 + smoothstep(0.06, 1.48, clampedZoom) * 0.88;
}
function nodeScreenScale(zoom: number): number {
const clampedZoom = clampNumber(zoom, 0.003, 6);
return (
0.7 +
smoothstep(0.07, 0.36, clampedZoom) * 0.36 +
smoothstep(0.36, 1.32, clampedZoom) * 0.82 +
smoothstep(1.32, 3.2, clampedZoom) * 0.72 +
smoothstep(3.2, 6, clampedZoom) * 0.28
);
}
function smoothstep(edge0: number, edge1: number, value: number): number {
if (edge0 === edge1) return value >= edge1 ? 1 : 0;
const t = clampNumber((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, value));
}

49
src/render/colorThemes.ts Normal file
View file

@ -0,0 +1,49 @@
/**
* G2.5
*
*
*/
export interface ColorTheme {
id: string;
name: string;
colors: string[];
}
export const COLOR_THEMES: ColorTheme[] = [
{
id: 'hubble',
name: '哈勃深空',
// 哈勃望远镜假彩色调色板:电离氧青、氢α金、硫离子锈红——天文摄影正统
colors: ['#46d4dc', '#ffc35c', '#d05a32', '#7fd0a0', '#e8d9a0', '#5a9bd8', '#d87fa8', '#9a7fe0', '#cfd8e8'],
},
{
id: 'tiktok',
name: '抖音霓虹',
// TikTok 青/红双主色 + 衍生明度级——黑底霓虹
colors: ['#25f4ee', '#fe2c55', '#ffffff', '#7ae8e2', '#ff7a9c', '#19b8b2', '#c2244a', '#a8f0ec', '#ffd0dc'],
},
{
id: 'sunset',
name: '落日胶片',
// Instagram 渐变:橙→品红→紫→蓝
colors: ['#f58529', '#dd2a7b', '#8134af', '#515bd4', '#feda77', '#e1306c', '#c13584', '#fd8d32', '#405de6'],
},
{
id: 'cyber',
name: '赛博都市',
// Cyberpunk 2077信号黄/电青/警告红
colors: ['#fcee0a', '#00f0ff', '#ff003c', '#9d00ff', '#00ff9f', '#ff6ec7', '#3df5ff', '#ffe600', '#c800ff'],
},
{
id: 'matrix',
name: '黑客帝国',
// Matrix 纯绿阶——单色也是品味
colors: ['#00ff41', '#33ff66', '#00cc34', '#66ff8c', '#00b32d', '#80ffa0', '#1aff4d', '#00e639', '#4dff79'],
},
{
id: 'aurora',
name: '极光',
// Spotify 绿 + 冰蓝 + 紫罗兰——高纬夜空
colors: ['#1db954', '#00d4ff', '#7f5fff', '#38f0c0', '#4fa8ff', '#9f7fff', '#22e6a8', '#66c2ff', '#b08fff'],
},
];

57
src/render/palette.ts Normal file
View file

@ -0,0 +1,57 @@
import { Color } from 'three';
import { hash32 } from '../data/seed';
import type { GraphNode } from '../types';
import type { ColorGroup } from '../settings/graphJsonImport';
export type NodeColorFn = (node: GraphNode) => Color;
/**
* 2D
* path: 前缀匹配 hash 退
*/
export function makeNodeColorFn(groups: ColorGroup[]): NodeColorFn {
const parsed = groups.map((g) => ({
prefix: g.query.startsWith('path:') ? g.query.slice(5).trim() : null,
raw: g.query,
color: new Color(g.color),
}));
return (node) => {
if (node.unresolved) return UNRESOLVED;
for (const g of parsed) {
if (g.prefix !== null ? node.id.startsWith(g.prefix) : node.id.includes(g.raw)) return g.color;
}
return folderColor(node.folderTop, false);
};
}
export const fallbackColorFn: NodeColorFn = (node) => folderColor(node.folderTop, node.unresolved);
// Obsidian 标准色族 hsl(h, 60%, 60%) 的色相轮(与 Rick 的 9 组配色同族);
// M2 接 graph.json 真实 colorGroups本表是无配置时的回退
const HUES = [0, 40, 80, 120, 160, 200, 240, 280, 320];
const NEUTRAL = new Color('#9aa4b2'); // 未分组
const UNRESOLVED = new Color('#7a8499'); // 幽灵
const cache = new Map<string, Color>();
export function folderColor(folderTop: string, unresolved: boolean): Color {
if (unresolved) return UNRESOLVED;
if (folderTop === '') return NEUTRAL;
let c = cache.get(folderTop);
if (!c) {
const hue = HUES[hash32(folderTop) % HUES.length] ?? 0;
c = new Color().setHSL(hue / 360, 0.6, 0.6);
cache.set(folderTop, c);
}
return c;
}
/** 链接色:端点色 50/50 混合 → 去饱和 60% + 压亮度NASA 细灰线,辉光由 bloom 给) */
export function linkColor(a: Color, b: Color): Color {
const c = a.clone().lerp(b, 0.5);
const hsl = { h: 0, s: 0, l: 0 };
c.getHSL(hsl);
c.setHSL(hsl.h, hsl.s * 0.4, Math.min(hsl.l, 0.35));
return c;
}

41
src/render/presets.ts Normal file
View file

@ -0,0 +1,41 @@
// 双视觉方向的全部 token 集中在此(按 Rick 协议跑起来看着选G2 门定默认)
export interface VisualTokens {
id: 'deep-space' | 'daylight';
background: number;
starfield: boolean;
motes: boolean; // 晨昼的尘埃微粒(替代星空)
bloomEnabled: boolean; // 亮底辉光=雾霾,晨昼强制关
lightMode: boolean; // 节点 shader 变体:墨水圆盘 + rim
/** 晨昼把 9 色相重定向到纸面对比度(保色相、压亮度) */
nodeLightness: number | null;
linkInk: string | null; // 晨昼链接 = 铅笔线(统一墨色,不用端点混色)
linkOpacityScale: number;
panelClass: string; // 面板风格 class
}
export const DEEP_SPACE: VisualTokens = {
id: 'deep-space',
background: 0x000003,
starfield: true,
motes: false,
bloomEnabled: true,
lightMode: false,
nodeLightness: null,
linkInk: null,
linkOpacityScale: 1,
panelClass: 'gx-theme-dark',
};
export const DAYLIGHT: VisualTokens = {
id: 'daylight',
background: 0xf6f4ef, // 暖纸底
starfield: false,
motes: true,
bloomEnabled: false,
lightMode: true,
nodeLightness: 0.44,
linkInk: '#2e2a24',
linkOpacityScale: 0.65,
panelClass: 'gx-theme-light',
};

47
src/render/shaders.ts Normal file
View file

@ -0,0 +1,47 @@
// 节点 = 单次 draw call 的 THREE.Points + 发光球 shaderNASA「luminous orb」配方
// 深空模式:白热核心 + 软边缘;浅色「晨昼」模式:实心墨水圆盘 + 深色 rimbloom 关)
// aDim: 聚焦模式下非邻居淡出0.12..1
export const NODE_VERTEX_SHADER = /* glsl */ `
attribute float aSize;
attribute float aGhost;
attribute float aDim;
varying vec3 vColor;
varying float vGhost;
varying float vDim;
uniform float uPixelScale; // drawingBufferHeight / (2·tan(fov/2))
uniform float uSizeMul; // 控制面板「节点大小」倍率
uniform float uMaxPoint; // 设备像素钳制穿行星团时防满屏大精灵打爆填充率M3
void main() {
vColor = color;
vGhost = aGhost;
vDim = aDim;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = min(aSize * uSizeMul * uPixelScale / max(-mv.z, 1.0), uMaxPoint);
gl_Position = projectionMatrix * mv;
}
`;
export const NODE_FRAGMENT_SHADER = /* glsl */ `
varying vec3 vColor;
varying float vGhost;
varying float vDim;
uniform float uLightMode; // 0 = 深空白热核心1 = 晨昼(墨水圆盘 + rim
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
float core = smoothstep(0.18, 0.0, d) * 0.55 * (1.0 - vGhost) * (1.0 - uLightMode);
vec3 col = mix(vColor, vec3(1.0), core);
// 晨昼:外缘 1px 深色 rim让节点「坐在纸上」
float rim = smoothstep(0.40, 0.46, d) * smoothstep(0.50, 0.46, d);
col = mix(col, col * 0.72, rim * uLightMode);
float alpha = smoothstep(0.5, 0.42, d) * mix(1.0, 0.45, vGhost) * vDim;
if (alpha < 0.01) discard;
gl_FragColor = vec4(col, alpha);
}
`;

120
src/render/starfield.ts Normal file
View file

@ -0,0 +1,120 @@
import { BufferAttribute, BufferGeometry, Color, Group, Points, PointsMaterial } from 'three';
// 星空3 个尺寸级 = 3 个 draw call视觉规格 §1.2);球壳分布近似无穷远
const CLASSES = [
{ count: 2600, size: 1.2 },
{ count: 900, size: 2.0 },
{ count: 250, size: 3.0 },
];
const COOL_A = new Color('#9da8c4');
const COOL_B = new Color('#ffffff');
const WARM = new Color('#ffe9c9');
const BLUE = new Color('#bfd3ff');
function mulberry(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function disposeStarfield(group: Group): void {
for (const child of group.children) {
const p = child as Points<BufferGeometry, PointsMaterial>;
p.geometry.dispose();
p.material.dispose();
}
}
/**
* G2.5
*
* + 1.6s
*/
export class Twinkler {
private baseColors: Float32Array;
private attr: BufferAttribute;
private active: { index: number; t: number } | null = null;
private nextIn = 3;
constructor(
private geometry: BufferGeometry,
private starCount: number,
) {
this.attr = geometry.getAttribute('color') as BufferAttribute;
this.baseColors = new Float32Array(this.attr.array as Float32Array);
}
/** freq期望每分钟眨眼次数 ÷ 10滑杆 020=关) */
update(deltaS: number, freq: number): void {
if (this.active) {
this.active.t += deltaS;
const t = this.active.t;
const DUR = 1.6;
const arr = this.attr.array as Float32Array;
const i = this.active.index * 3;
const k = t >= DUR ? 1 : 1 + 2.2 * Math.sin((Math.PI * t) / DUR);
arr[i] = (this.baseColors[i] ?? 1) * k;
arr[i + 1] = (this.baseColors[i + 1] ?? 1) * k;
arr[i + 2] = (this.baseColors[i + 2] ?? 1) * k;
this.attr.needsUpdate = true;
if (t >= DUR) this.active = null;
return;
}
if (freq <= 0.01) return;
this.nextIn -= deltaS;
if (this.nextIn <= 0) {
this.active = { index: Math.floor(Math.random() * this.starCount), t: 0 };
// 泊松间隔:均值 6/freq 秒freq=0.5 → 平均 12s 一次)
this.nextIn = Math.min(Math.max(-Math.log(Math.random() + 1e-9) * (6 / freq), 1.5), 90);
}
}
}
export function buildStarfield(shellRadius: number, scale = 1): { group: Group; twinkler: Twinkler } {
const group = new Group();
const rand = mulberry(0x517cc1);
let twinkler: Twinkler | null = null;
for (const base of CLASSES) {
const cls = { count: Math.max(Math.round(base.count * scale), 50), size: base.size };
const pos = new Float32Array(cls.count * 3);
const col = new Float32Array(cls.count * 3);
for (let i = 0; i < cls.count; i++) {
const theta = 2 * Math.PI * rand();
const phi = Math.acos(2 * rand() - 1);
const r = shellRadius * (0.95 + 0.1 * rand());
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
pos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
pos[i * 3 + 2] = r * Math.cos(phi);
const pick = rand();
const c = pick < 0.85 ? COOL_A.clone().lerp(COOL_B, rand()) : pick < 0.95 ? WARM.clone() : BLUE.clone();
// 大星级里 ~3% 提到 HDR 亮度,独享 bloom —— 仅有的几颗「真星」
if (cls.size >= 3.0 && rand() < 0.03) c.multiplyScalar(1.8);
col[i * 3] = c.r;
col[i * 3 + 1] = c.g;
col[i * 3 + 2] = c.b;
}
const geo = new BufferGeometry();
geo.setAttribute('position', new BufferAttribute(pos, 3));
geo.setAttribute('color', new BufferAttribute(col, 3));
const mat = new PointsMaterial({
size: cls.size,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: 0.55,
depthWrite: false,
});
const points = new Points(geo, mat);
points.renderOrder = -1; // 星空垫底
group.add(points);
if (cls.size >= 3.0) twinkler = new Twinkler(geo, cls.count); // 只有「真星」级会眨眼
}
return { group, twinkler: twinkler ?? new Twinkler(new BufferGeometry().setAttribute('color', new BufferAttribute(new Float32Array(3), 3)), 1) };
}

View file

@ -0,0 +1,44 @@
import type { BloomSettings, LookSettings, PhysicsSettings } from '../settings';
/**
* = + + G2
* flattenY
*/
export interface StylePreset {
id: string;
name: string;
bloom: BloomSettings;
physics: PhysicsSettings;
look: LookSettings;
}
export const STYLE_PRESETS: StylePreset[] = [
{
id: 'galaxy',
name: '银河',
bloom: { strength: 0.35, radius: 0.35, threshold: 0.22 },
physics: { repel: 200, linkDistance: 70, linkStrength: 1, centerPull: 0.04, flatten: 0.3 },
look: { nodeSize: 1, linkOpacity: 0.14, twinkle: 0.5, sizeBy: 'degree' },
},
{
id: 'nebula',
name: '星云',
bloom: { strength: 0.6, radius: 0.4, threshold: 0.18 },
physics: { repel: 180, linkDistance: 80, linkStrength: 1, centerPull: 0.04, flatten: 0 },
look: { nodeSize: 1, linkOpacity: 0.16, twinkle: 0.5, sizeBy: 'degree' },
},
{
id: 'minimal',
name: '极简',
bloom: { strength: 0, radius: 0.3, threshold: 0.3 },
physics: { repel: 220, linkDistance: 80, linkStrength: 1, centerPull: 0.04, flatten: 0 },
look: { nodeSize: 0.85, linkOpacity: 0.08, twinkle: 0.2, sizeBy: 'degree' },
},
{
id: 'fireworks',
name: '烟火',
bloom: { strength: 1.2, radius: 0.6, threshold: 0.1 },
physics: { repel: 160, linkDistance: 60, linkStrength: 1.2, centerPull: 0.05, flatten: 0 },
look: { nodeSize: 1.15, linkOpacity: 0.25, twinkle: 1.2, sizeBy: 'degree' },
},
];

344
src/settings.ts Normal file
View file

@ -0,0 +1,344 @@
export type ViewMode = 'radial2d' | 'map3d';
export type Language = 'en' | 'zh';
export interface BloomSettings {
strength: number;
radius: number;
threshold: number;
}
export interface PhysicsSettings {
repel: number; // Positive value; the layout uses the negative charge.
linkDistance: number;
linkStrength: number;
centerPull: number;
flatten: number;
}
export type SizeBy = 'degree' | 'fileSize' | 'uniform';
export interface LookSettings {
nodeSize: number;
linkOpacity: number;
twinkle: number;
sizeBy: SizeBy;
}
export type VisualPreset = 'deep-space' | 'adaptive';
export interface GalaxySettings {
bloom: BloomSettings;
physics: PhysicsSettings;
look: LookSettings;
cruise: boolean;
cruiseSpeed: number;
showUnresolved: boolean;
showOrphans: boolean;
colorTheme: string;
qualityOverride: 'auto' | 'high' | 'low' | 'mobile';
preset: VisualPreset;
colorGroups: import('./settings/graphJsonImport').ColorGroup[];
positionCache: Record<string, [number, number, number]>;
}
export type ColorScheme = 'auto' | 'day' | 'night';
export type LabelVisibility = 'auto' | 'hover';
export type HoverTargetMode = 'nodes' | 'links' | 'both';
export type HoverHighlightMode =
| 'none'
| 'note-links'
| 'hierarchy-parents'
| 'hierarchy-direct-children'
| 'hierarchy-descendants'
| 'hierarchy-parents-direct'
| 'hierarchy-all';
export type ExternalDetailMode = 'grouped' | 'selected' | 'exact';
export interface RadialSettings {
atlasDepth: number;
focusSiblingLimit: number;
linkLimit: number;
renderNodeLimit: number;
externalLinkAnchorLimit: number;
adaptiveDetail: boolean;
includeUnresolvedLinks: boolean;
showLinkOverlay: boolean;
showExternalLinks: boolean;
externalDetailMode: ExternalDetailMode;
colorScheme: ColorScheme;
labelVisibility: LabelVisibility;
hoverHighlightMode: HoverHighlightMode;
hoverTargetMode: HoverTargetMode;
swirlStrength: number;
hiddenLegendItems: string[];
ignoreFolders: string[];
}
export interface MiniWorldMapSettings {
language: Language;
viewMode: ViewMode;
radial: RadialSettings;
galaxy3d: GalaxySettings;
}
export const MAX_ATLAS_DEPTH = 80;
export const MAX_RENDER_NODE_LIMIT = 20_000;
export const MAX_LINK_LIMIT = 30_000;
export const MAX_EXTERNAL_LINK_ANCHOR_LIMIT = 20_000;
export const MAX_SWIRL_STRENGTH = 100;
export const HOVER_HIGHLIGHT_MODE_OPTIONS: [HoverHighlightMode, string][] = [
['none', 'None'],
['note-links', 'Note links'],
['hierarchy-parents', 'Hierarchy parents'],
['hierarchy-direct-children', 'Hierarchy direct children'],
['hierarchy-descendants', 'Hierarchy all children'],
['hierarchy-parents-direct', 'Hierarchy parents + direct'],
['hierarchy-all', 'Hierarchy parents + all children'],
];
export const LABEL_VISIBILITY_OPTIONS: [LabelVisibility, string][] = [
['auto', 'Auto'],
['hover', 'Hover only'],
];
export const HOVER_TARGET_MODE_OPTIONS: [HoverTargetMode, string][] = [
['nodes', 'Nodes'],
['links', 'Links'],
['both', 'Nodes + links'],
];
export const LEGEND_ITEM_DEFINITIONS: [string, string, string, string][] = [
['root', 'legend.root', 'legend.root.desc', 'mwm-legend-root'],
['folder', 'legend.folder', 'legend.folder.desc', 'mwm-legend-folder'],
['folder-meta', 'legend.folderMeta', 'legend.folderMeta.desc', 'mwm-legend-meta'],
['file', 'legend.note', 'legend.note.desc', 'mwm-legend-note'],
['outside', 'legend.outsideGroup', 'legend.outsideGroup.desc', 'mwm-legend-external'],
['outside-file', 'legend.outsideNote', 'legend.outsideNote.desc', 'mwm-legend-outside-file'],
['missing', 'legend.unresolvedNote', 'legend.unresolvedNote.desc', 'mwm-legend-unresolved'],
['tree', 'legend.hierarchy', 'legend.hierarchy.desc', 'mwm-legend-tree'],
['link', 'legend.noteLinks', 'legend.noteLinks.desc', 'mwm-legend-link'],
['outside-link', 'legend.outsideLinks', 'legend.outsideLinks.desc', 'mwm-legend-link-external'],
['dashed-link', 'legend.unresolvedLinks', 'legend.unresolvedLinks.desc', 'mwm-legend-link-unresolved'],
];
export const DEFAULT_RADIAL_SETTINGS: RadialSettings = {
atlasDepth: 6,
focusSiblingLimit: 160,
linkLimit: 1200,
renderNodeLimit: 4200,
externalLinkAnchorLimit: 700,
adaptiveDetail: true,
includeUnresolvedLinks: true,
showLinkOverlay: true,
showExternalLinks: true,
externalDetailMode: 'grouped',
colorScheme: 'auto',
labelVisibility: 'auto',
hoverHighlightMode: 'hierarchy-all',
hoverTargetMode: 'nodes',
swirlStrength: 0,
hiddenLegendItems: [],
ignoreFolders: ['.git', '.obsidian'],
};
export const DEFAULT_GALAXY_SETTINGS: GalaxySettings = {
bloom: { strength: 0.35, radius: 0.35, threshold: 0.22 },
physics: { repel: 200, linkDistance: 70, linkStrength: 1, centerPull: 0.04, flatten: 0.3 },
look: { nodeSize: 1, linkOpacity: 0.14, twinkle: 0.5, sizeBy: 'degree' },
cruise: true,
cruiseSpeed: 1,
showUnresolved: false,
showOrphans: true,
colorTheme: 'imported',
qualityOverride: 'auto',
preset: 'adaptive',
colorGroups: [],
positionCache: {},
};
export const DEFAULT_SETTINGS: MiniWorldMapSettings = {
language: 'en',
viewMode: 'radial2d',
radial: DEFAULT_RADIAL_SETTINGS,
galaxy3d: DEFAULT_GALAXY_SETTINGS,
};
export function mergeSettings(saved: unknown): MiniWorldMapSettings {
const raw = isRecord(saved) ? saved : {};
const hasNestedRadial = isRecord(raw['radial']);
const radialSource = hasNestedRadial ? raw['radial'] : raw;
const galaxySource = isRecord(raw['galaxy3d']) ? raw['galaxy3d'] : raw;
const radial = mergeRadialSettings(radialSource);
if (!hasNestedRadial && raw['showLinkOverlay'] === false) radial.showLinkOverlay = DEFAULT_RADIAL_SETTINGS.showLinkOverlay;
return {
language: normalizeLanguage(raw['language'] ?? raw['locale'] ?? raw['lang']),
viewMode: normalizeViewMode(raw['viewMode']),
radial,
galaxy3d: mergeGalaxySettings(galaxySource),
};
}
export function mergeRadialSettings(saved: unknown): RadialSettings {
const s = isRecord(saved) ? saved : {};
const d = DEFAULT_RADIAL_SETTINGS;
const legendIds = new Set(LEGEND_ITEM_DEFINITIONS.map(([id]) => id));
const hoverMode = s['hoverHighlightMode'] ?? (s['enableLinkHover'] === true ? 'note-links' : d.hoverHighlightMode);
return {
atlasDepth: clampNumber(s['atlasDepth'], 1, MAX_ATLAS_DEPTH, d.atlasDepth),
focusSiblingLimit: clampNumber(s['focusSiblingLimit'], 10, 1000, d.focusSiblingLimit),
linkLimit: clampNumber(s['linkLimit'], 0, MAX_LINK_LIMIT, d.linkLimit),
renderNodeLimit: clampNumber(s['renderNodeLimit'], 200, MAX_RENDER_NODE_LIMIT, d.renderNodeLimit),
externalLinkAnchorLimit: clampNumber(
s['externalLinkAnchorLimit'],
0,
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
d.externalLinkAnchorLimit,
),
adaptiveDetail: typeof s['adaptiveDetail'] === 'boolean' ? s['adaptiveDetail'] : d.adaptiveDetail,
includeUnresolvedLinks:
typeof s['includeUnresolvedLinks'] === 'boolean' ? s['includeUnresolvedLinks'] : d.includeUnresolvedLinks,
showLinkOverlay: typeof s['showLinkOverlay'] === 'boolean' ? s['showLinkOverlay'] : d.showLinkOverlay,
showExternalLinks: typeof s['showExternalLinks'] === 'boolean' ? s['showExternalLinks'] : d.showExternalLinks,
externalDetailMode: normalizeExternalDetailMode(s['externalDetailMode']),
colorScheme: normalizeColorScheme(s['colorScheme']),
labelVisibility: normalizeLabelVisibility(s['labelVisibility']),
hoverHighlightMode: normalizeHoverHighlightMode(hoverMode),
hoverTargetMode: normalizeHoverTargetMode(s['hoverTargetMode']),
swirlStrength: clampNumber(s['swirlStrength'], 0, MAX_SWIRL_STRENGTH, d.swirlStrength),
hiddenLegendItems: Array.isArray(s['hiddenLegendItems'])
? s['hiddenLegendItems'].filter((id): id is string => typeof id === 'string' && legendIds.has(id))
: d.hiddenLegendItems.slice(),
ignoreFolders: Array.isArray(s['ignoreFolders'])
? s['ignoreFolders'].filter((v): v is string => typeof v === 'string' && v.trim().length > 0)
: d.ignoreFolders.slice(),
};
}
export function mergeGalaxySettings(saved: unknown): GalaxySettings {
const s = isRecord(saved) ? saved : {};
const d = DEFAULT_GALAXY_SETTINGS;
const bloom = isRecord(s['bloom']) ? s['bloom'] : {};
const physics = isRecord(s['physics']) ? s['physics'] : {};
const look = isRecord(s['look']) ? s['look'] : {};
return {
bloom: {
strength: finiteNumber(bloom['strength'], d.bloom.strength),
radius: finiteNumber(bloom['radius'], d.bloom.radius),
threshold: finiteNumber(bloom['threshold'], d.bloom.threshold),
},
physics: {
repel: finiteNumber(physics['repel'], d.physics.repel),
linkDistance: finiteNumber(physics['linkDistance'], d.physics.linkDistance),
linkStrength: finiteNumber(physics['linkStrength'], d.physics.linkStrength),
centerPull: finiteNumber(physics['centerPull'], d.physics.centerPull),
flatten: finiteNumber(physics['flatten'], d.physics.flatten),
},
look: {
nodeSize: finiteNumber(look['nodeSize'], d.look.nodeSize),
linkOpacity: finiteNumber(look['linkOpacity'], d.look.linkOpacity),
twinkle: finiteNumber(look['twinkle'], d.look.twinkle),
sizeBy: (['degree', 'fileSize', 'uniform'] as const).includes(look['sizeBy'] as SizeBy)
? (look['sizeBy'] as SizeBy)
: d.look.sizeBy,
},
cruise: typeof s['cruise'] === 'boolean' ? s['cruise'] : d.cruise,
cruiseSpeed: finiteNumber(s['cruiseSpeed'], d.cruiseSpeed),
showUnresolved:
typeof s['showUnresolved'] === 'boolean'
? s['showUnresolved']
: typeof s['includeUnresolvedLinks'] === 'boolean'
? s['includeUnresolvedLinks']
: d.showUnresolved,
showOrphans: typeof s['showOrphans'] === 'boolean' ? s['showOrphans'] : d.showOrphans,
colorTheme: typeof s['colorTheme'] === 'string' ? s['colorTheme'] : d.colorTheme,
qualityOverride: (['auto', 'high', 'low', 'mobile'] as const).includes(s['qualityOverride'] as 'auto')
? (s['qualityOverride'] as GalaxySettings['qualityOverride'])
: d.qualityOverride,
preset: s['preset'] === 'deep-space' ? 'deep-space' : 'adaptive',
colorGroups: Array.isArray(s['colorGroups'])
? s['colorGroups'].filter(
(g): g is import('./settings/graphJsonImport').ColorGroup =>
isRecord(g) && typeof g['query'] === 'string' && typeof g['color'] === 'string',
)
: [],
positionCache:
isRecord(s['positionCache']) && !Array.isArray(s['positionCache'])
? (s['positionCache'] as Record<string, [number, number, number]>)
: {},
};
}
export interface SettingsHost {
settings: MiniWorldMapSettings;
saveSettings(): Promise<void>;
setViewMode(mode: ViewMode): void;
setLanguage(language: Language): void;
}
export function toLayoutParams(p: PhysicsSettings): import('./types').LayoutParams {
return {
charge: -p.repel,
linkDistance: p.linkDistance,
linkStrength: p.linkStrength,
centerPull: p.centerPull,
flatten: p.flatten,
velocityDecay: 0.6,
};
}
export function normalizeViewMode(value: unknown): ViewMode {
return value === 'map3d' || value === 'radial2d' ? value : DEFAULT_SETTINGS.viewMode;
}
export function normalizeLanguage(value: unknown): Language {
return value === 'zh' ? 'zh' : DEFAULT_SETTINGS.language;
}
export function normalizeColorScheme(value: unknown): ColorScheme {
return value === 'auto' || value === 'day' || value === 'night' ? value : DEFAULT_RADIAL_SETTINGS.colorScheme;
}
export function normalizeLabelVisibility(value: unknown): LabelVisibility {
return value === 'hover' ? 'hover' : DEFAULT_RADIAL_SETTINGS.labelVisibility;
}
export function normalizeHoverHighlightMode(value: unknown): HoverHighlightMode {
return HOVER_HIGHLIGHT_MODE_OPTIONS.some(([id]) => id === value)
? (value as HoverHighlightMode)
: DEFAULT_RADIAL_SETTINGS.hoverHighlightMode;
}
export function normalizeHoverTargetMode(value: unknown): HoverTargetMode {
return HOVER_TARGET_MODE_OPTIONS.some(([id]) => id === value)
? (value as HoverTargetMode)
: DEFAULT_RADIAL_SETTINGS.hoverTargetMode;
}
export function hoverHighlightsNoteLinks(mode: unknown): boolean {
return normalizeHoverHighlightMode(mode) === 'note-links';
}
export function hoverHighlightModeLabel(mode: unknown): string {
const normalized = normalizeHoverHighlightMode(mode);
return HOVER_HIGHLIGHT_MODE_OPTIONS.find(([id]) => id === normalized)?.[1].toLowerCase() ?? normalized;
}
export function normalizeExternalDetailMode(value: unknown): ExternalDetailMode {
return value === 'selected' || value === 'exact' || value === 'grouped'
? value
: DEFAULT_RADIAL_SETTINGS.externalDetailMode;
}
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
const n = finiteNumber(value, fallback);
return Math.min(Math.max(n, min), max);
}
function finiteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : Number.parseFloat(String(value)) || fallback;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

View file

@ -0,0 +1,31 @@
import type { App } from 'obsidian';
import { normalizePath } from 'obsidian';
export interface ColorGroup {
query: string; // 已 trim真实配置带尾随空格
color: string; // #rrggbb
}
/**
* colorGroups.obsidian/graph.json null
* intquery "path:01学习 "
*/
export async function readGraphColorGroups(app: App): Promise<ColorGroup[] | null> {
try {
const path = normalizePath(app.vault.configDir + '/graph.json');
if (!(await app.vault.adapter.exists(path))) return null;
const parsed = JSON.parse(await app.vault.adapter.read(path)) as {
colorGroups?: { query?: unknown; color?: { rgb?: unknown } }[];
};
const groups: ColorGroup[] = [];
for (const g of parsed.colorGroups ?? []) {
const query = typeof g.query === 'string' ? g.query.trim() : '';
const rgb = g.color?.rgb;
if (!query || typeof rgb !== 'number') continue;
groups.push({ query, color: `#${(rgb & 0xffffff).toString(16).padStart(6, '0')}` });
}
return groups;
} catch {
return null;
}
}

47
src/types.ts Normal file
View file

@ -0,0 +1,47 @@
export interface GraphNode {
id: string; // vault path未解析为 "unresolved:<名字>"
name: string;
folderTop: string; // 顶层文件夹;根目录 '';未解析 '__unresolved__'
degree: number; // 出 + 入
inDegree: number;
outDegree: number;
fileSize: number; // 字节;未解析为 0「质量」可选依据
unresolved: boolean;
}
/** 边用节点数组下标表示——聚合渲染按索引 gather 坐标 */
export interface GraphLink {
source: number;
target: number;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
export interface LayoutParams {
charge: number; // 负值=斥力
linkDistance: number;
linkStrength: number; // 倍率1 = d3 默认1/min(端点度数)
centerPull: number; // forceX/Y/Z 强度,防孤儿飞逸
flatten: number; // 0=自然球体;>0 在 Y 轴额外加压 → 银河盘(自然引斥力做不出盘,这是必要的额外力)
velocityDecay: number;
}
export interface FrameStats {
frames: number;
avgFps: number;
p95FrameMs: number;
worstFrameMs: number;
durationMs: number;
}
export interface BenchResult {
scenario: string;
timestamp: string;
nodes: number;
links: number;
bloom: boolean;
[key: string]: unknown;
}

90
src/typings/d3-force-3d.d.ts vendored Normal file
View file

@ -0,0 +1,90 @@
declare module 'd3-force-3d' {
export interface SimNode {
index?: number;
x?: number;
y?: number;
z?: number;
vx?: number;
vy?: number;
vz?: number;
fx?: number | null;
fy?: number | null;
fz?: number | null;
}
export interface SimLink<N extends SimNode = SimNode> {
source: number | N;
target: number | N;
index?: number;
}
export interface Force<N extends SimNode = SimNode> {
(alpha: number): void;
initialize?(nodes: N[], random: () => number, nDim: number): void;
}
export interface Simulation<N extends SimNode = SimNode> {
tick(iterations?: number): this;
restart(): this;
stop(): this;
nodes(): N[];
nodes(nodes: N[]): this;
alpha(): number;
alpha(alpha: number): this;
alphaMin(): number;
alphaMin(min: number): this;
alphaDecay(): number;
alphaDecay(decay: number): this;
alphaTarget(): number;
alphaTarget(target: number): this;
velocityDecay(): number;
velocityDecay(decay: number): this;
force(name: string): Force<N> | undefined;
force(name: string, force: Force<N> | null): this;
on(typenames: string, listener: ((this: this) => void) | null): this;
}
export interface LinkForce<N extends SimNode = SimNode> extends Force<N> {
links(): SimLink<N>[];
links(links: SimLink<N>[]): this;
distance(d: number | ((link: SimLink<N>) => number)): this;
strength(s: number | ((link: SimLink<N>) => number)): this;
}
export interface ManyBodyForce<N extends SimNode = SimNode> extends Force<N> {
strength(s: number | ((node: N) => number)): this;
theta(t: number): this;
distanceMax(d: number): this;
}
export interface PositionForce<N extends SimNode = SimNode> extends Force<N> {
strength(s: number | ((node: N) => number)): this;
x?(v: number): this;
y?(v: number): this;
z?(v: number): this;
}
export interface CenterForce<N extends SimNode = SimNode> extends Force<N> {
x(v: number): this;
y(v: number): this;
z(v: number): this;
strength(s: number): this;
}
export function forceSimulation<N extends SimNode = SimNode>(
nodes?: N[],
numDimensions?: 1 | 2 | 3,
): Simulation<N>;
export function forceLink<N extends SimNode = SimNode>(links?: SimLink<N>[]): LinkForce<N>;
export function forceManyBody<N extends SimNode = SimNode>(): ManyBodyForce<N>;
export function forceCenter<N extends SimNode = SimNode>(x?: number, y?: number, z?: number): CenterForce<N>;
export function forceX<N extends SimNode = SimNode>(x?: number): PositionForce<N>;
export function forceY<N extends SimNode = SimNode>(y?: number): PositionForce<N>;
export function forceZ<N extends SimNode = SimNode>(z?: number): PositionForce<N>;
export function forceCollide<N extends SimNode = SimNode>(radius?: number): Force<N>;
export function forceRadial<N extends SimNode = SimNode>(
radius: number,
x?: number,
y?: number,
z?: number,
): Force<N>;
}

2
src/typings/galaxy-dev.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// esbuild definedev 构建 true商店/release 构建 false基准等开发工具被摇树剔除
declare const __GALAXY_DEV__: boolean;

5
src/typings/inline-worker.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
// esbuild inline-worker 插件:'worker:' 前缀的导入被打包成 IIFE 文本字符串
declare module 'worker:*' {
const source: string;
export default source;
}

106
src/view/GalaxyView.ts Normal file
View file

@ -0,0 +1,106 @@
import { ItemView, WorkspaceLeaf } from 'obsidian';
import { VIEW_TYPE_MINI_WORLD_MAP } from '../constants';
import type { SettingsHost, ViewMode } from '../settings';
import { Map3DController } from './Map3DController';
import { Radial2DController } from './Radial2DController';
type ActiveController = Radial2DController | Map3DController;
export class MiniWorldMapView extends ItemView {
navigation = true;
controller: ActiveController | null = null;
constructor(
leaf: WorkspaceLeaf,
private host: SettingsHost,
) {
super(leaf);
}
getViewType(): string {
return VIEW_TYPE_MINI_WORLD_MAP;
}
getDisplayText(): string {
return 'Mini World Map';
}
getIcon(): string {
return 'network';
}
get counts(): { nodes: number; links: number } {
return this.controller?.counts ?? { nodes: 0, links: 0 };
}
async onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.addClass('mini-world-map-view', 'galaxy-view-content');
this.registerEvent(this.app.workspace.on('css-change', () => this.controller?.onCssChange?.()));
this.tryInit();
}
onResize(): void {
if (!this.controller) {
this.tryInit();
return;
}
this.controller.resize();
}
switchMode(mode: ViewMode): void {
if (this.host.settings.viewMode !== mode) {
this.host.setViewMode(mode);
return;
}
this.rebuild();
}
private tryInit(): void {
if (this.controller) return;
const { clientWidth: width, clientHeight: height } = this.contentEl;
if (width < 10 || height < 10) return;
if (this.host.settings.viewMode === 'map3d') {
const controller = new Map3DController(
this.app,
this.contentEl,
this.host.settings.galaxy3d,
() => void this.host.saveSettings(),
(mode) => this.switchMode(mode),
this.host.settings.language,
(language) => this.host.setLanguage(language),
);
controller.onContextLost = () => this.rebuild();
this.controller = controller;
this.addChild(controller.store);
void controller.start();
} else {
const controller = new Radial2DController(
this.app,
this.contentEl,
this.host.settings,
() => void this.host.saveSettings(),
(mode) => this.switchMode(mode),
(language) => this.host.setLanguage(language),
);
this.controller = controller;
this.addChild(controller);
void controller.start();
}
}
private rebuild(): void {
this.controller?.dispose();
this.controller = null;
this.contentEl.empty();
this.tryInit();
}
async onClose(): Promise<void> {
this.controller?.dispose();
this.controller = null;
this.contentEl.empty();
}
}
export { MiniWorldMapView as GalaxyView };

779
src/view/GraphController.ts Normal file
View file

@ -0,0 +1,779 @@
import type { App } from 'obsidian';
import { Menu, Notice, Platform, debounce, setIcon } from 'obsidian';
import { Spherical, Vector3 } from 'three';
import type { BenchResult } from '../types';
import type { GalaxySettings, Language, ViewMode } from '../settings';
import { DEFAULT_GALAXY_SETTINGS, toLayoutParams } from '../settings';
import { languageOptions, t } from '../i18n';
import { readGraphColorGroups } from '../settings/graphJsonImport';
import type { ColorTheme } from '../render/colorThemes';
import { GraphStore } from '../data/GraphStore';
import { seedRadius } from '../data/seed';
import type { LayoutEngine } from '../layout/LayoutEngine';
import { MainThreadForceLayout } from '../layout/MainThreadForceLayout';
import { WorkerForceLayout } from '../layout/WorkerForceLayout';
import { AggregateRenderer } from '../render/AggregateRenderer';
import { makeNodeColorFn, fallbackColorFn } from '../render/palette';
import { DAYLIGHT, DEEP_SPACE } from '../render/presets';
import { CameraDirector } from '../interactions/CameraDirector';
import { ControlPanel } from '../overlay/ControlPanel';
import { OverlayManager } from '../overlay/OverlayManager';
import { NodeSearchModal } from './SearchModal';
import { collectFrames, observeLongTasks, writeBenchResult, sleep } from '../bench/bench';
import type { QualityTier } from '../quality/tiers';
import { TIERS } from '../quality/tiers';
const WARM_CACHE_MIN_COVERAGE = 0.8;
const ESTABLISHING_MS = 3200;
/**
* Store Layout Renderer Director Overlay Panel
* rAF 1 tick
*/
export class GraphController {
readonly store: GraphStore;
private layout: LayoutEngine = new WorkerForceLayout();
private renderer: AggregateRenderer | null = null;
private director: CameraDirector | null = null;
private overlay: OverlayManager | null = null;
private panel: ControlPanel | null = null;
private rafId = 0;
private lastNow = 0;
private paused = false;
private visible = true;
private benchMode = false;
private benchRunning = false;
private selected = -1;
private graphRadius = 200;
private wasSettled = false;
private shot: { t0: number; durMs: number; fromBloom: number } | null = null;
private maskEl: HTMLElement | null = null;
private hudFrames: number[] = [];
private intersection: IntersectionObserver | null = null;
private disposeFns: (() => void)[] = [];
private saveSoon: () => void;
private tier: QualityTier = TIERS.high;
private watchdogTripped = false;
private lowFpsChecks = 0;
private lastWatchdogAt = 0;
/** WebGL 上下文丢失时由视图重建GalaxyView 注入) */
onContextLost: (() => void) | null = null;
constructor(
private app: App,
private contentEl: HTMLElement,
private settings: GalaxySettings,
saveSettings: () => void,
private onViewMode: ((mode: ViewMode) => void) | null = null,
private language: Language = 'en',
private onLanguage: ((language: Language) => void) | null = null,
) {
this.store = new GraphStore(app);
this.saveSoon = debounce(saveSettings, 800, true);
}
get counts(): { nodes: number; links: number } {
return { nodes: this.store.data.nodes.length, links: this.store.data.links.length };
}
async start(): Promise<void> {
await this.store.ensureCacheReady();
this.store.init(this.settings.showUnresolved, this.settings.showOrphans, () => this.onDataChanged());
this.store.rebuild(false);
// 暖启动:用上次沉降坐标覆盖种子 → 重开即成形
const coverage = this.applyPositionCache();
const warm = coverage >= WARM_CACHE_MIN_COVERAGE;
const container = this.contentEl.createDiv({ cls: 'galaxy-view-canvas' });
this.graphRadius = seedRadius(this.store.data.nodes.length) * 1.6;
const renderer = new AggregateRenderer(container, this.graphRadius);
this.renderer = renderer;
renderer.setColorFn(
this.settings.colorGroups.length > 0 ? makeNodeColorFn(this.settings.colorGroups) : fallbackColorFn,
);
renderer.setData(this.store.data, this.store.positions);
this.initLayout(warm ? 0.06 : 1);
this.director = new CameraDirector(renderer.camera, renderer.renderer.domElement, {
onFlyToSelected: () => this.flyToSelected(),
onResetView: () => this.recenter(),
});
this.overlay = new OverlayManager(this.contentEl, this.app, renderer, {
openNote: (id) => void this.app.workspace.openLinkText(id, '', true),
focusNode: (i) => this.selectNode(i, true),
}, this.language);
this.overlay.setData(this.store.data, this.graphRadius);
this.applySettings();
this.applyTier();
this.buildPanel();
this.buildFloatingControls();
this.applyPreset();
this.bindPicking(renderer.renderer.domElement);
this.bindContextLost(renderer.renderer.domElement);
this.bindVisibility();
this.resize();
// 首次导入 2D 配色(仅当从未导入过)
if (this.settings.colorGroups.length === 0) void this.importColors(false);
// 开场:暖启动走「拉出式」开场镜头;冷启动直接看星系成形(本身就是剧场)
if (warm) this.playEstablishing();
else this.director.setInitialFraming(this.graphRadius);
this.lastNow = performance.now();
const loop = (now: number) => {
const deltaS = Math.min((now - this.lastNow) / 1000, 0.1);
this.lastNow = now;
if (!this.paused) {
if (this.layout.step()) this.renderer?.updatePositions();
this.checkSettled();
if (!this.benchMode) this.director?.update(now, deltaS);
this.stepShot(now);
this.renderer?.render(deltaS);
const { clientWidth: w, clientHeight: h } = this.contentEl;
this.overlay?.update(w, h);
}
this.updateHud(now);
this.watchdog(now);
this.rafId = window.requestAnimationFrame(loop);
};
this.rafId = window.requestAnimationFrame(loop);
}
/** Electron GPU 重置 → 遮罩 + 一键重建(前人插件的隐性死因之一) */
private bindContextLost(canvas: HTMLElement): void {
const onLost = (e: Event) => {
e.preventDefault();
const mask = this.contentEl.createDiv({ cls: 'gx-mask' });
const btn = mask.createEl('button', { cls: 'gx-mask-btn', text: this.tt('3d.contextLost') });
btn.addEventListener('click', () => this.onContextLost?.());
};
canvas.addEventListener('webglcontextlost', onLost);
this.disposeFns.push(() => canvas.removeEventListener('webglcontextlost', onLost));
}
resize(): void {
const { clientWidth: w, clientHeight: h } = this.contentEl;
this.renderer?.resize(w, h);
}
// ---------- 暖启动与开场镜头 ----------
private applyPositionCache(): number {
const cache = this.settings.positionCache;
const nodes = this.store.data.nodes;
if (nodes.length === 0) return 0;
let hits = 0;
nodes.forEach((n, i) => {
const p = cache[n.id];
if (!p) return;
this.store.positions[i * 3] = p[0];
this.store.positions[i * 3 + 1] = p[1];
this.store.positions[i * 3 + 2] = p[2];
hits++;
});
return hits / nodes.length;
}
private checkSettled(): void {
const settled = this.layout.isSettled();
if (settled && !this.wasSettled) {
// 沉降时刻:写暖启动缓存(坐标取整 1 位小数,控制 data.json 体积)
const cache: Record<string, [number, number, number]> = {};
const pos = this.store.positions;
this.store.data.nodes.forEach((n, i) => {
cache[n.id] = [
Math.round((pos[i * 3] ?? 0) * 10) / 10,
Math.round((pos[i * 3 + 1] ?? 0) * 10) / 10,
Math.round((pos[i * 3 + 2] ?? 0) * 10) / 10,
];
});
this.settings.positionCache = cache;
this.saveSoon();
}
this.wasSettled = settled;
}
private playEstablishing(): void {
const renderer = this.renderer;
const director = this.director;
if (!renderer || !director) return;
this.maskEl = this.contentEl.createDiv({ cls: 'gx-mask' });
this.maskEl.createDiv({ cls: 'gx-mask-text', text: this.tt('loading.3d') });
// 等几帧让首批渲染就绪,再揭幕拉出
window.setTimeout(() => {
if (!this.maskEl) return;
this.maskEl.addClass('is-fading');
window.setTimeout(() => {
this.maskEl?.remove();
this.maskEl = null;
}, 650);
const inner = this.graphRadius * 0.5;
const elev = (10 * Math.PI) / 180;
renderer.camera.position.set(inner * Math.cos(elev), inner * Math.sin(elev), inner * 0.2);
director.target.set(0, 0, 0);
director.resetView(this.graphRadius, () => director.beginFocusOrbit(null)); // 内部 → 总览 → 即时巡航
renderer.playReveal(2600); // 创世动画节点从中心波次绽放G2.5 反馈)
this.shot = { t0: performance.now(), durMs: ESTABLISHING_MS, fromBloom: this.settings.bloom.strength * 1.8 };
}, 450);
}
/** 开场期间辉光从 1.8× 回落到设置值NASA「明亮诞生」 */
private stepShot(now: number): void {
if (!this.shot || !this.renderer) return;
const t = Math.min((now - this.shot.t0) / this.shot.durMs, 1);
const v = this.shot.fromBloom + (this.settings.bloom.strength - this.shot.fromBloom) * t;
this.renderer.setBloomStrength(v);
if (t >= 1) this.shot = null;
}
// ---------- 数据 ----------
private onDataChanged(): void {
if (!this.renderer) return;
this.clearSelection();
this.renderer.setData(this.store.data, this.store.positions);
this.overlay?.setData(this.store.data, this.graphRadius);
// 身份保持合并已保住旧坐标,低温重热让新节点滑入而不是全图爆炸
this.initLayout(0.3);
this.wasSettled = false;
}
/** Worker 优先,创建失败(罕见环境)回退主线程实现 */
private initLayout(initialAlpha: number): void {
const params = toLayoutParams(this.settings.physics);
try {
this.layout.init(this.store.data, this.store.positions, params, initialAlpha);
} catch {
if (this.layout instanceof MainThreadForceLayout) throw new Error('layout init failed');
this.layout.dispose();
this.layout = new MainThreadForceLayout();
this.layout.init(this.store.data, this.store.positions, params, initialAlpha);
new Notice(this.tt('3d.workerFallback'));
}
}
// ---------- 质量档位M4 ----------
/** Platform.isMobile 硬上限手动覆盖绝对优先auto=high+看门狗 */
private pickTier(): QualityTier {
if (Platform.isMobile) return TIERS.mobile;
const o = this.settings.qualityOverride;
if (o === 'high' || o === 'low' || o === 'mobile') return TIERS[o];
return this.watchdogTripped ? TIERS.low : TIERS.high;
}
applyTier(): void {
const prev = this.tier.id;
this.tier = this.pickTier();
this.renderer?.applyTier(this.tier, this.settings.bloom.strength);
this.overlay?.setBudgets(this.tier.hubLabels, this.tier.neighborLabels, this.tier.id === 'mobile');
this.contentEl.toggleClass('gx-mobile', this.tier.id === 'mobile');
const total = this.app.vault.getMarkdownFiles().length;
this.store.setCaps(this.tier.nodeCap, this.tier.linkCap); // 变化时触发重建
if (this.tier.nodeCap !== null && total > this.tier.nodeCap && prev !== this.tier.id) {
new Notice(this.tt('3d.mobileCap', { cap: this.tier.nodeCap, total }));
}
}
/** 沉降后 FPS 看门狗:连续 3 次 5s 采样 <30fps → 单向降到 low会话内不回升 */
private watchdog(now: number): void {
if (this.watchdogTripped || Platform.isMobile) return;
if (this.settings.qualityOverride !== 'auto' || !this.layout.isSettled() || this.benchRunning) return;
if (now - this.lastWatchdogAt < 5000) return;
this.lastWatchdogAt = now;
if (this.hudFrames.length > 0 && this.hudFrames.length < 30 && !this.paused) {
this.lowFpsChecks++;
if (this.lowFpsChecks >= 3) {
this.watchdogTripped = true;
this.applyTier();
new Notice(this.tt('3d.performanceMode'));
}
} else {
this.lowFpsChecks = 0;
}
}
// ---------- 设置与视觉方向 ----------
private applySettings(): void {
const s = this.settings;
this.renderer?.setBloomParams(s.bloom);
this.renderer?.setNodeScale(s.look.nodeSize);
this.renderer?.setLinkOpacity(s.look.linkOpacity);
this.renderer?.setSizeMode(s.look.sizeBy);
if (this.renderer) this.renderer.twinkleFreq = s.look.twinkle;
if (this.director) {
this.director.cruiseEnabled = s.cruise;
this.director.cruiseSpeed = s.cruiseSpeed;
}
}
/** 风格预设 = 辉光+力学+外观 成套切换 */
applyStylePreset(p: { bloom: typeof DEFAULT_GALAXY_SETTINGS.bloom; physics: typeof DEFAULT_GALAXY_SETTINGS.physics; look: typeof DEFAULT_GALAXY_SETTINGS.look }): void {
Object.assign(this.settings.bloom, p.bloom);
Object.assign(this.settings.physics, p.physics);
Object.assign(this.settings.look, p.look);
this.applySettings();
this.layout.updateParams(toLayoutParams(this.settings.physics));
this.wasSettled = false;
this.saveSoon();
}
/** 回中心:清选中 + 平滑回总览 + 到达即绕全局中心巡航 */
recenter(): void {
this.clearSelection();
this.director?.resetView(this.graphRadius, () => this.director?.beginFocusOrbit(null));
}
/** 应用配色主题:按序染给现有颜色组(无组则按节点数从顶层文件夹生成) */
applyColorTheme(theme: ColorTheme): void {
let groups = this.settings.colorGroups;
if (groups.length === 0) {
const byFolder = new Map<string, number>();
for (const n of this.store.data.nodes) {
if (n.folderTop && !n.unresolved) byFolder.set(n.folderTop, (byFolder.get(n.folderTop) ?? 0) + 1);
}
groups = [...byFolder.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 9)
.map(([folder]) => ({ query: `path:${folder}`, color: '#9aa4b2' }));
this.settings.colorGroups = groups;
}
groups.forEach((g, i) => (g.color = theme.colors[i % theme.colors.length] ?? g.color));
this.settings.colorTheme = theme.id;
this.renderer?.setColorFn(makeNodeColorFn(groups));
this.renderer?.recolor();
this.saveSoon();
}
/** 手动触发创世动画(坐标未沉降时给提示) */
playRevealManually(): void {
if (!this.layout.isSettled()) {
new Notice(this.tt('3d.revealWait'));
return;
}
this.renderer?.playReveal();
}
/** 在已导入的颜色组之间洗牌(同组不变,颜色互换) */
shuffleColors(): void {
const groups = this.settings.colorGroups;
if (groups.length < 2) {
new Notice(this.tt('3d.shuffleMissing'));
return;
}
const colors = groups.map((g) => g.color);
for (let i = colors.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[colors[i], colors[j]] = [colors[j]!, colors[i]!];
}
groups.forEach((g, i) => (g.color = colors[i] ?? g.color));
this.settings.colorTheme = 'custom';
this.renderer?.setColorFn(makeNodeColorFn(groups));
this.renderer?.recolor();
this.saveSoon();
}
/** preset + app 主题 → tokensadaptive 深色与深空共用场景) */
applyPreset(): void {
if (!this.renderer) return;
const isDark = activeDocument.body.hasClass('theme-dark');
const tokens = this.settings.preset === 'deep-space' || isDark ? DEEP_SPACE : DAYLIGHT;
this.renderer.applyTokens(tokens, this.settings.bloom.strength);
this.panel?.setPanelTheme(tokens.id === 'daylight' ? 'gx-theme-light' : 'gx-theme-dark');
this.contentEl.toggleClass('gx-daylight', tokens.id === 'daylight');
}
/** workspace css-change由视图转发 */
onCssChange(): void {
if (this.settings.preset === 'adaptive') this.applyPreset();
}
private async importColors(notify: boolean): Promise<void> {
const groups = await readGraphColorGroups(this.app);
if (!groups || groups.length === 0) {
if (notify) new Notice(this.tt('3d.importMissing'));
return;
}
this.settings.colorGroups = groups;
this.renderer?.setColorFn(makeNodeColorFn(groups));
this.renderer?.recolor();
this.saveSoon();
if (notify) new Notice(this.tt('3d.importDone', { count: groups.length }));
}
// ---------- 选中 / 聚焦 / 搜索 ----------
openSearch(): void {
new NodeSearchModal(this.app, this.store.data.nodes, (i) => this.selectNode(i, true), this.language).open();
}
selectNode(index: number, fly: boolean): void {
const renderer = this.renderer;
const director = this.director;
if (!renderer || !director) return;
this.selected = index;
const neighbors = new Set<number>();
const linkIdx: number[] = [];
this.store.data.links.forEach((l, li) => {
if (l.source === index || l.target === index) {
neighbors.add(l.source);
neighbors.add(l.target);
linkIdx.push(li);
}
});
renderer.setFocus(index, neighbors);
renderer.setSelectedLinks(linkIdx);
this.overlay?.setSelection(index, neighbors);
if (fly) {
const pos = renderer.nodePosition(index, new Vector3());
// 邻居质心方向:到达后环绕优先扫过链接密集的一侧
const density = new Vector3();
let count = 0;
const tmp = new Vector3();
for (const ni of neighbors) {
if (ni === index) continue;
density.add(renderer.nodePosition(ni, tmp));
count++;
}
const densityDir = count > 0 ? density.divideScalar(count).sub(pos) : null;
director.flyTo(pos, renderer.nodeRadius(index), () => director.beginFocusOrbit(densityDir));
}
}
clearSelection(): void {
this.selected = -1;
this.renderer?.setFocus(-1, null);
this.renderer?.setSelectedLinks([]);
this.overlay?.setSelection(-1, new Set());
}
private flyToSelected(): void {
if (this.selected < 0 || !this.renderer || !this.director) return;
const pos = this.renderer.nodePosition(this.selected, new Vector3());
this.director.flyTo(pos, this.renderer.nodeRadius(this.selected));
}
// ---------- 拾取 ----------
private bindPicking(dom: HTMLElement): void {
let downX = 0;
let downY = 0;
const onDown = (e: PointerEvent) => {
downX = e.clientX;
downY = e.clientY;
};
const onUp = (e: PointerEvent) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey) return; // 平移手势不选中
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 5) return;
const rect = dom.getBoundingClientRect();
const i = this.renderer?.pickNearest(e.clientX - rect.left, e.clientY - rect.top, rect.width, rect.height, 14) ?? -1;
if (i >= 0) this.selectNode(i, true);
else this.clearSelection();
};
let hoverPending = false;
const onMove = (e: PointerEvent) => {
const throttle = this.tier.hoverThrottleMs;
if (throttle === null || hoverPending) return; // 移动档:仅 tap无 hover
hoverPending = true;
window.setTimeout(() => {
hoverPending = false;
const renderer = this.renderer;
if (!renderer) return;
const rect = dom.getBoundingClientRect();
const i = renderer.pickNearest(e.clientX - rect.left, e.clientY - rect.top, rect.width, rect.height, 10);
this.overlay?.setHover(i);
dom.style.cursor = i >= 0 ? 'pointer' : 'default';
}, throttle);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
this.clearSelection();
e.preventDefault();
}
};
dom.addEventListener('pointerdown', onDown);
dom.addEventListener('pointerup', onUp);
dom.addEventListener('pointermove', onMove);
dom.addEventListener('keydown', onKey);
this.disposeFns.push(() => {
dom.removeEventListener('pointerdown', onDown);
dom.removeEventListener('pointerup', onUp);
dom.removeEventListener('pointermove', onMove);
dom.removeEventListener('keydown', onKey);
});
}
// ---------- 可见性暂停 ----------
private bindVisibility(): void {
const onVis = () => {
this.paused = activeDocument.hidden || !this.visible;
};
activeDocument.addEventListener('visibilitychange', onVis);
this.disposeFns.push(() => activeDocument.removeEventListener('visibilitychange', onVis));
this.intersection = new IntersectionObserver((entries) => {
this.visible = entries[0]?.isIntersecting ?? true;
onVis();
});
this.intersection.observe(this.contentEl);
}
// ---------- 控制面板 ----------
private buildPanel(): void {
this.panel = new ControlPanel(this.contentEl, this.settings, this.language, {
onViewMode: (mode) => this.onViewMode?.(mode),
onBloom: () => {
this.renderer?.setBloomParams(this.settings.bloom);
this.saveSoon();
},
onPhysics: () => {
this.layout.updateParams(toLayoutParams(this.settings.physics));
this.wasSettled = false;
this.saveSoon();
},
onLook: () => {
this.renderer?.setNodeScale(this.settings.look.nodeSize);
this.renderer?.setLinkOpacity(this.settings.look.linkOpacity);
if (this.renderer) this.renderer.twinkleFreq = this.settings.look.twinkle;
this.saveSoon();
},
onSizeBy: () => {
this.renderer?.setSizeMode(this.settings.look.sizeBy);
this.saveSoon();
},
onCruise: (on) => {
if (this.director) this.director.cruiseEnabled = on;
this.saveSoon();
},
onPreset: () => {
this.applyPreset();
this.saveSoon();
},
onShowUnresolved: (on) => {
this.store.setIncludeUnresolved(on);
this.saveSoon();
},
onImportColors: () => void this.importColors(true),
onShuffleColors: () => this.shuffleColors(),
onColorTheme: (t) => this.applyColorTheme(t),
onRecenter: () => this.recenter(),
onReveal: () => this.playRevealManually(),
onShowOrphans: (on) => {
this.store.setIncludeOrphans(on);
this.saveSoon();
},
onStylePreset: (p) => this.applyStylePreset(p),
onCruiseSpeed: () => {
if (this.director) this.director.cruiseSpeed = this.settings.cruiseSpeed;
this.saveSoon();
},
onQuality: () => {
this.watchdogTripped = false;
this.lowFpsChecks = 0;
this.applyTier();
this.saveSoon();
},
onSearch: () => this.openSearch(),
onReset: () => {
Object.assign(this.settings.bloom, DEFAULT_GALAXY_SETTINGS.bloom);
Object.assign(this.settings.physics, DEFAULT_GALAXY_SETTINGS.physics);
Object.assign(this.settings.look, DEFAULT_GALAXY_SETTINGS.look);
this.settings.cruise = DEFAULT_GALAXY_SETTINGS.cruise;
this.settings.cruiseSpeed = DEFAULT_GALAXY_SETTINGS.cruiseSpeed;
this.applySettings();
this.layout.updateParams(toLayoutParams(this.settings.physics));
this.wasSettled = false;
this.saveSoon();
},
runScenario: (s) => void this.runScenario(s),
}, 'map3d');
}
private buildFloatingControls(): void {
const controls = this.contentEl.createDiv({ cls: 'mwm-floating-controls mwm-3d-floating-controls' });
const button = controls.createEl('button', {
cls: 'mwm-floating-button',
attr: { type: 'button', title: this.tt('language'), 'aria-label': this.tt('language') },
});
setIcon(button, 'languages');
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.openLanguageMenu(button);
});
this.disposeFns.push(() => controls.remove());
}
private openLanguageMenu(anchor: HTMLElement): void {
const menu = new Menu();
for (const [value, label] of languageOptions(this.language)) {
menu.addItem((item) => {
item.setTitle(label);
if (value === this.language) item.setIcon('check');
item.onClick(() => {
if (value === this.language) return;
this.language = value;
this.overlay?.setLanguage(value);
this.panel?.setLanguage(value);
this.onLanguage?.(value);
});
});
}
const rect = anchor.getBoundingClientRect();
menu.showAtPosition({ x: rect.right, y: rect.bottom });
}
private updateHud(now: number): void {
this.hudFrames.push(now);
while (this.hudFrames.length > 0 && now - (this.hudFrames[0] ?? 0) > 1000) this.hudFrames.shift();
const el = this.panel?.statsEl;
if (!el || now % 500 > 250) return;
const c = this.counts;
el.setText(
this.tt('stats.3d', {
fps: this.hudFrames.length,
calls: this.renderer?.drawCalls ?? 0,
nodes: c.nodes,
links: c.links,
state: this.layout.isSettled() ? this.tt('state.settled') : this.tt('state.layout'),
}),
);
}
private tt(key: string, vars: Record<string, string | number> = {}): string {
return t(this.language, key, vars);
}
// ---------- 基准(与 M0/M1 同场景语义) ----------
private waitSettle(timeoutMs = 120_000): Promise<void> {
return new Promise((resolve) => {
const t0 = performance.now();
const check = () => {
if (this.layout.isSettled() || performance.now() - t0 > timeoutMs) resolve();
else window.setTimeout(check, 100);
};
check();
});
}
async runScenario(scenario: 'S1' | 'S2' | 'S3'): Promise<BenchResult | null> {
if (this.benchRunning || !this.renderer || !this.director) return null;
this.benchRunning = true;
try {
if (scenario === 'S2') return await this.benchColdLayout();
return await this.benchOrbit(scenario);
} finally {
this.benchRunning = false;
}
}
private async benchOrbit(scenario: 'S1' | 'S3'): Promise<BenchResult> {
const renderer = this.renderer;
const director = this.director;
if (!renderer || !director) throw new Error('not ready');
const wantUnresolved = scenario === 'S3';
if (this.store.getIncludeUnresolved() !== wantUnresolved) {
this.store.setIncludeUnresolved(wantUnresolved);
}
new Notice(this.tt('3d.bench.wait', { scenario }));
await this.waitSettle();
if (renderer.getBloomStrength() < 0.01) renderer.setBloomStrength(0.9);
await sleep(300);
this.benchMode = true;
const target = director.target.clone();
const sph = new Spherical().setFromVector3(renderer.camera.position.clone().sub(target));
new Notice(this.tt('3d.bench.orbit', { scenario }));
const stats = await collectFrames(20_000, (elapsed) => {
const angle = sph.theta + (elapsed / 20_000) * Math.PI * 2;
renderer.camera.position.setFromSpherical(new Spherical(sph.radius, sph.phi, angle)).add(target);
renderer.camera.lookAt(target);
});
this.benchMode = false;
const result: BenchResult = {
scenario,
timestamp: new Date().toISOString(),
nodes: this.counts.nodes,
links: this.counts.links,
bloom: renderer.getBloomStrength() > 0,
drawCalls: renderer.drawCalls,
renderer: 'aggregate',
...stats,
};
await writeBenchResult(this.app, result);
new Notice(this.tt('3d.bench.done', { scenario, fps: stats.avgFps.toFixed(1), calls: renderer.drawCalls }));
return result;
}
private async benchColdLayout(): Promise<BenchResult> {
new Notice(this.tt('3d.bench.s2Start'));
if (this.store.getIncludeUnresolved()) this.store.setIncludeUnresolved(false);
await sleep(300);
const longTasks = observeLongTasks();
const t0 = performance.now();
this.settings.positionCache = {}; // 冷布局必须无暖启动
this.store.rebuild(false); // 触发 onDataChangedalpha 0.3)……
this.initLayout(1); // ……随即以完整冷布局语义重新点火M2 起的语义偏差在此修正)
this.wasSettled = false;
await this.waitSettle();
const settleMs = performance.now() - t0;
const ticks = this.layout.ticks;
const lt = longTasks.stop();
const result: BenchResult = {
scenario: 'S2',
timestamp: new Date().toISOString(),
nodes: this.counts.nodes,
links: this.counts.links,
bloom: (this.renderer?.getBloomStrength() ?? 0) > 0,
renderer: 'aggregate',
settleMs,
ticks,
avgTickMs: ticks > 0 ? settleMs / ticks : -1,
longTaskCount: lt.count,
longestTaskMs: lt.longestMs,
longTaskTotalMs: lt.totalMs,
};
await writeBenchResult(this.app, result);
new Notice(
this.tt('3d.bench.s2Done', {
seconds: (settleMs / 1000).toFixed(1),
ticks,
longest: lt.longestMs.toFixed(0),
}),
);
return result;
}
// ---------- 销毁合同 ----------
dispose(): void {
window.cancelAnimationFrame(this.rafId);
this.intersection?.disconnect();
this.intersection = null;
for (const fn of this.disposeFns) fn();
this.disposeFns = [];
this.maskEl?.remove();
this.maskEl = null;
this.overlay?.dispose();
this.overlay = null;
this.director?.dispose();
this.director = null;
this.layout.dispose();
this.renderer?.dispose();
this.renderer = null;
this.panel?.dispose();
this.panel = null;
}
}

View file

@ -0,0 +1,3 @@
import { GraphController } from './GraphController';
export class Map3DController extends GraphController {}

File diff suppressed because it is too large Load diff

61
src/view/SearchModal.ts Normal file
View file

@ -0,0 +1,61 @@
import type { App } from 'obsidian';
import { SuggestModal, prepareFuzzySearch } from 'obsidian';
import type { Language } from '../settings';
import type { GraphNode } from '../types';
import { t } from '../i18n';
interface Hit {
index: number;
node: GraphNode;
score: number;
}
/** 搜索星系节点 → 镜头飞行Obsidian 原生 SuggestModal键盘友好 */
export class NodeSearchModal extends SuggestModal<Hit> {
constructor(
app: App,
private nodes: GraphNode[],
private onPick: (index: number) => void,
private language: Language = 'en',
) {
super(app);
this.setPlaceholder(this.tt('3d.searchPlaceholder'));
}
getSuggestions(query: string): Hit[] {
const q = query.trim();
if (!q) {
// 空查询:按度数给出枢纽 top 20——「星座导览」
return [...this.nodes.entries()]
.filter(([, n]) => !n.unresolved)
.sort((a, b) => b[1].degree - a[1].degree)
.slice(0, 20)
.map(([index, node]) => ({ index, node, score: 0 }));
}
const fuzzy = prepareFuzzySearch(q);
const hits: Hit[] = [];
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i];
if (!node) continue;
const m = fuzzy(node.name) ?? fuzzy(node.id);
if (m) hits.push({ index: i, node, score: m.score });
}
return hits.sort((a, b) => b.score - a.score || b.node.degree - a.node.degree).slice(0, 50);
}
renderSuggestion(hit: Hit, el: HTMLElement): void {
el.createDiv({ text: hit.node.name });
el.createDiv({
cls: 'gx-search-path',
text: `${hit.node.unresolved ? this.tt('3d.searchUnresolved') : hit.node.id} · ${this.tt('3d.searchLinks', { count: hit.node.degree })}`,
});
}
onChooseSuggestion(hit: Hit): void {
this.onPick(hit.index);
}
private tt(key: string, vars: Record<string, string | number> = {}): string {
return t(this.language, key, vars);
}
}

View file

@ -0,0 +1,98 @@
import type { App } from 'obsidian';
import { TFile, TFolder } from 'obsidian';
import type { RadialSettings } from '../settings';
import { buildWorldMap, normalizeVaultPath } from './buildWorldMap';
import type { LinkTable, VisibleGraphState, VisibleWorldGraph, WorldFileRecord, WorldModel, WorldNode } from './types';
import { ROOT_ID } from './types';
import { buildVisibleWorldGraph, visualNodeId } from './visibleGraph';
export class WorldMapIndex {
model: WorldModel | null = null;
constructor(
private app: App,
private settings: RadialSettings,
) {}
get ready(): boolean {
return this.model !== null;
}
get nodes(): Map<string, WorldNode> {
return this.model?.nodes ?? new Map();
}
get stats() {
return this.model?.stats ?? {
loadedEntries: 0,
scannedMarkdown: 0,
folders: 0,
notes: 0,
unresolved: 0,
hierarchyEdges: 0,
linkEdges: 0,
maxDepth: 0,
};
}
get linkEdgesBySource() {
return this.model?.linkEdgesBySource ?? new Map();
}
get linkEdgesByTarget() {
return this.model?.linkEdgesByTarget ?? new Map();
}
rebuild(settings: RadialSettings = this.settings): void {
this.settings = settings;
this.model = buildWorldMap(
this.collectVaultEntries(),
this.app.metadataCache.resolvedLinks as LinkTable,
this.app.metadataCache.unresolvedLinks as LinkTable,
settings,
);
}
buildVisibleGraph(state: VisibleGraphState): VisibleWorldGraph {
if (!this.model) this.rebuild();
return buildVisibleWorldGraph(this.model!, state, this.settings);
}
visualNodeId(id: string | null | undefined): string | null {
return this.model ? visualNodeId(this.model, id) : (id ?? null);
}
getActiveNotePath(): string | null {
const active = this.app.workspace.getActiveFile();
if (active && this.nodes.has(active.path)) return active.path;
return [...this.nodes.values()].find((node) => node.type === 'note')?.id ?? null;
}
private collectVaultEntries(): WorldFileRecord[] {
const entries: WorldFileRecord[] = [];
const root = typeof this.app.vault.getRoot === 'function' ? this.app.vault.getRoot() : null;
const stack = root && Array.isArray(root.children) ? [...root.children] : [];
while (stack.length > 0) {
const entry = stack.pop();
if (entry instanceof TFolder) {
const path = normalizeVaultPath(entry.path);
if (path) entries.push({ path, basename: entry.name, kind: 'folder' });
if (Array.isArray(entry.children)) {
for (let index = entry.children.length - 1; index >= 0; index--) {
const child = entry.children[index];
if (child) stack.push(child);
}
}
} else if (entry instanceof TFile && entry.extension === 'md') {
entries.push({ path: entry.path, basename: entry.basename, kind: 'note', size: entry.stat.size });
}
}
if (entries.length === 0) {
for (const file of this.app.vault.getMarkdownFiles()) {
entries.push({ path: file.path, basename: file.basename, kind: 'note', size: file.stat.size });
}
}
if (!entries.some((entry) => entry.path === ROOT_ID)) return entries;
return entries.filter((entry) => entry.path !== ROOT_ID);
}
}

255
src/world/buildWorldMap.ts Normal file
View file

@ -0,0 +1,255 @@
import type { RadialSettings } from '../settings';
import type { LinkTable, WorldEdge, WorldFileRecord, WorldModel, WorldNode, WorldStats } from './types';
import { ROOT_ID, ROOT_TITLE } from './types';
export function buildWorldMap(
records: WorldFileRecord[],
resolvedLinks: LinkTable,
unresolvedLinks: LinkTable,
settings: Pick<RadialSettings, 'includeUnresolvedLinks' | 'ignoreFolders'>,
): WorldModel {
const nodes = new Map<string, WorldNode>();
const hierarchyEdges: WorldEdge[] = [];
const linkEdges: WorldEdge[] = [];
const childrenByParent = new Map<string, string[]>();
const linkEdgesBySource = new Map<string, WorldEdge[]>();
const linkEdgesByTarget = new Map<string, WorldEdge[]>();
const folderRepresentatives = new Map<string, string>();
let loadedEntries = 0;
let scannedMarkdown = 0;
const ignored = (path: string) => shouldIgnorePath(path, settings.ignoreFolders);
const addNode = (node: WorldNode) => {
if (nodes.has(node.id) || ignored(node.id)) return;
nodes.set(node.id, node);
};
addNode({
id: ROOT_ID,
path: ROOT_ID,
title: ROOT_TITLE,
type: 'folder',
parentId: null,
depth: 0,
noteCount: 0,
linkCount: 0,
backlinkCount: 0,
descendantCount: 0,
});
const ensureFolder = (folderPath: string): string => {
const normalized = normalizeVaultPath(folderPath);
if (!normalized) return ROOT_ID;
let current = ROOT_ID;
for (const part of normalized.split('/').filter(Boolean)) {
const next = current ? `${current}/${part}` : part;
if (ignored(next)) return current;
if (!nodes.has(next)) {
addNode({
id: next,
path: next,
title: basename(next),
type: 'folder',
parentId: current,
depth: depthOfPath(next),
noteCount: 0,
linkCount: 0,
backlinkCount: 0,
descendantCount: 0,
});
}
current = next;
}
return current;
};
for (const record of records) {
loadedEntries++;
const path = normalizeVaultPath(record.path);
if (!path || ignored(path)) continue;
if (record.kind === 'folder') {
ensureFolder(path);
continue;
}
scannedMarkdown++;
const parentId = ensureFolder(parentPath(path));
addNode({
id: path,
path,
title: record.basename || basename(path).replace(/\.md$/i, ''),
type: 'note',
parentId,
depth: depthOfPath(path),
noteCount: 1,
linkCount: 0,
backlinkCount: 0,
descendantCount: 0,
});
}
identifyFolderRepresentatives(nodes, folderRepresentatives);
const addLink = (source: string, target: string, weight: number, type: 'link' | 'unresolved-link') => {
if (source === target || !nodes.has(source) || !nodes.has(target)) return;
const edge: WorldEdge = {
id: `${type}:${source}->${target}:${linkEdges.length}`,
type,
source,
target,
weight,
};
linkEdges.push(edge);
const sourceNode = nodes.get(source);
const targetNode = nodes.get(target);
if (sourceNode) sourceNode.linkCount += weight;
if (targetNode) targetNode.backlinkCount += weight;
pushMapArray(linkEdgesBySource, source, edge);
pushMapArray(linkEdgesByTarget, target, edge);
};
for (const [source, targets] of Object.entries(resolvedLinks)) {
if (!nodes.has(source) || ignored(source)) continue;
for (const [target, count] of Object.entries(targets ?? {})) {
if (!nodes.has(target) || ignored(target)) continue;
addLink(source, target, Math.max(1, Number(count) || 1), 'link');
}
}
if (settings.includeUnresolvedLinks) {
for (const [source, targets] of Object.entries(unresolvedLinks)) {
if (!nodes.has(source) || ignored(source)) continue;
for (const [linkText, count] of Object.entries(targets ?? {})) {
const id = `unresolved:${source}:${linkText}`;
if (!nodes.has(id)) {
const sourceNode = nodes.get(source);
addNode({
id,
path: linkText,
title: linkText,
type: 'unresolved',
parentId: sourceNode?.parentId ?? ROOT_ID,
depth: (sourceNode?.depth ?? 0) + 1,
noteCount: 0,
linkCount: 0,
backlinkCount: 0,
descendantCount: 0,
});
}
addLink(source, id, Math.max(1, Number(count) || 1), 'unresolved-link');
}
}
}
buildHierarchy(nodes, hierarchyEdges, childrenByParent);
computeFolderCounts(nodes);
const stats: WorldStats = {
loadedEntries,
scannedMarkdown,
folders: [...nodes.values()].filter((node) => node.type === 'folder').length,
notes: [...nodes.values()].filter((node) => node.type === 'note').length,
unresolved: [...nodes.values()].filter((node) => node.type === 'unresolved').length,
hierarchyEdges: hierarchyEdges.length,
linkEdges: linkEdges.length,
maxDepth: Math.max(0, ...[...nodes.values()].map((node) => node.depth)),
};
return { nodes, hierarchyEdges, linkEdges, childrenByParent, linkEdgesBySource, linkEdgesByTarget, folderRepresentatives, stats };
}
export function compareWorldNodes(a: WorldNode | undefined, b: WorldNode | undefined): number {
if (!a || !b) return a ? -1 : b ? 1 : 0;
const typeRank = (node: WorldNode) => (node.type === 'folder' ? 0 : node.type === 'note' ? 1 : node.type === 'external' ? 2 : 3);
return a.depth - b.depth || typeRank(a) - typeRank(b) || a.title.localeCompare(b.title) || a.id.localeCompare(b.id);
}
export function normalizeVaultPath(path: string): string {
return String(path || '')
.trim()
.replace(/^\/+|\/+$/g, '');
}
export function parentPath(path: string): string {
const normalized = normalizeVaultPath(path);
const index = normalized.lastIndexOf('/');
return index === -1 ? ROOT_ID : normalized.slice(0, index);
}
export function basename(path: string): string {
const normalized = normalizeVaultPath(path);
const index = normalized.lastIndexOf('/');
return index === -1 ? normalized : normalized.slice(index + 1);
}
export function depthOfPath(path: string): number {
const normalized = normalizeVaultPath(path);
return normalized ? normalized.split('/').filter(Boolean).length : 0;
}
export function shouldIgnorePath(path: string, ignoreFolders: string[]): boolean {
const normalized = normalizeVaultPath(path);
if (!normalized) return false;
return ignoreFolders.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`));
}
function identifyFolderRepresentatives(nodes: Map<string, WorldNode>, folderRepresentatives: Map<string, string>): void {
const notesByParent = new Map<string, WorldNode[]>();
for (const node of nodes.values()) {
if (node.type === 'note' && node.parentId !== null) pushMapArray(notesByParent, node.parentId, node);
}
for (const folder of nodes.values()) {
if (folder.type !== 'folder' || folder.id === ROOT_ID) continue;
const folderTitle = comparableTitle(folder.title);
const representative = (notesByParent.get(folder.id) ?? []).find((note) => comparableTitle(note.title) === folderTitle);
if (!representative) continue;
folder.representativeFile = representative.id;
representative.isRepresentativeFile = true;
representative.representativeFor = folder.id;
folderRepresentatives.set(folder.id, representative.id);
}
}
function buildHierarchy(
nodes: Map<string, WorldNode>,
hierarchyEdges: WorldEdge[],
childrenByParent: Map<string, string[]>,
): void {
for (const node of nodes.values()) {
if (node.parentId === null || !nodes.has(node.parentId)) continue;
hierarchyEdges.push({
id: `hierarchy:${node.parentId}->${node.id}`,
type: 'hierarchy',
source: node.parentId,
target: node.id,
weight: 1,
});
pushMapArray(childrenByParent, node.parentId, node.id);
}
for (const children of childrenByParent.values()) {
children.sort((a, b) => compareWorldNodes(nodes.get(a), nodes.get(b)));
}
}
function computeFolderCounts(nodes: Map<string, WorldNode>): void {
const sorted = [...nodes.values()].sort((a, b) => b.depth - a.depth);
for (const node of sorted) {
node.descendantCount = node.type === 'note' ? 1 : node.noteCount;
if (!node.parentId || !nodes.has(node.parentId)) continue;
const parent = nodes.get(node.parentId);
if (!parent) continue;
const subtreeNotes = node.type === 'note' ? 1 : node.noteCount;
parent.descendantCount += subtreeNotes;
parent.noteCount += subtreeNotes;
parent.linkCount += node.linkCount;
parent.backlinkCount += node.backlinkCount;
}
}
function comparableTitle(value: string): string {
return value.replace(/\.md$/i, '').trim().toLowerCase();
}
function pushMapArray<K, V>(map: Map<K, V[]>, key: K, value: V): void {
const values = map.get(key);
if (values) values.push(value);
else map.set(key, [value]);
}

103
src/world/types.ts Normal file
View file

@ -0,0 +1,103 @@
import type { ExternalDetailMode, HoverHighlightMode, LabelVisibility } from '../settings';
export const ROOT_ID = '';
export const ROOT_TITLE = 'Vault';
export type WorldNodeType = 'folder' | 'note' | 'unresolved' | 'external';
export interface WorldNode {
id: string;
path: string;
title: string;
type: WorldNodeType;
parentId: string | null;
depth: number;
noteCount: number;
linkCount: number;
backlinkCount: number;
descendantCount: number;
representativeFile?: string;
isRepresentativeFile?: boolean;
representativeFor?: string;
externalProxy?: boolean;
externalParentId?: string;
externalAnchorPath?: string | null;
}
export interface WorldEdge {
id: string;
type: 'hierarchy' | 'external-hierarchy' | 'link' | 'unresolved-link' | 'visible-link';
source: string;
target: string;
weight: number;
rawCount?: number;
unresolvedCount?: number;
externalCount?: number;
}
export interface WorldStats {
loadedEntries: number;
scannedMarkdown: number;
folders: number;
notes: number;
unresolved: number;
hierarchyEdges: number;
linkEdges: number;
maxDepth: number;
}
export interface WorldModel {
nodes: Map<string, WorldNode>;
hierarchyEdges: WorldEdge[];
linkEdges: WorldEdge[];
childrenByParent: Map<string, string[]>;
linkEdgesBySource: Map<string, WorldEdge[]>;
linkEdgesByTarget: Map<string, WorldEdge[]>;
folderRepresentatives: Map<string, string>;
stats: WorldStats;
}
export interface WorldFileRecord {
path: string;
basename: string;
kind: 'folder' | 'note';
size?: number;
}
export type LinkTable = Record<string, Record<string, number>>;
export interface VisibleGraphState {
mode: 'atlas' | 'focus';
rootPath: string;
focusPath: string | null;
search: string;
atlasDepth: number;
focusSiblingLimit: number;
nodeLimit: number;
linkLimit: number;
externalLinkAnchorLimit: number;
showLinkOverlay: boolean;
showExternalLinks: boolean;
externalDetailMode: ExternalDetailMode;
showCompleteRoot: boolean;
hoverHighlightMode: HoverHighlightMode;
pinNeedsHoverLinks: boolean;
selectedNodeId: string | null;
selectedLink: WorldEdge | null;
hiddenLegendItems: string[];
labelVisibility: LabelVisibility;
}
export interface VisibleWorldGraph {
nodes: WorldNode[];
nodesById: Map<string, WorldNode>;
hierarchyEdges: WorldEdge[];
linkEdges: WorldEdge[];
hoverLinkEdges: WorldEdge[];
rootId: string;
focusId: string | null;
hiddenNodeCount: number;
externalNodeCount: number;
externalFileCount: number;
externalGroupCount: number;
}

509
src/world/visibleGraph.ts Normal file
View file

@ -0,0 +1,509 @@
import {
DEFAULT_RADIAL_SETTINGS,
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
MAX_LINK_LIMIT,
MAX_RENDER_NODE_LIMIT,
clampNumber,
hoverHighlightsNoteLinks,
type RadialSettings,
} from '../settings';
import { basename, compareWorldNodes, parentPath } from './buildWorldMap';
import type { VisibleGraphState, VisibleWorldGraph, WorldEdge, WorldModel, WorldNode } from './types';
import { ROOT_ID } from './types';
export function defaultVisibleGraphState(settings: RadialSettings): VisibleGraphState {
const hiddenLegendItems = settings.hiddenLegendItems.slice();
const hidden = new Set(hiddenLegendItems);
return {
mode: 'atlas',
rootPath: ROOT_ID,
focusPath: null,
search: '',
atlasDepth: settings.atlasDepth,
focusSiblingLimit: settings.focusSiblingLimit,
nodeLimit: settings.renderNodeLimit,
linkLimit: settings.linkLimit,
externalLinkAnchorLimit: settings.externalLinkAnchorLimit,
showLinkOverlay: settings.showLinkOverlay || !hidden.has('link'),
showExternalLinks: settings.showExternalLinks,
externalDetailMode: settings.externalDetailMode,
showCompleteRoot: false,
hoverHighlightMode: settings.hoverHighlightMode,
pinNeedsHoverLinks: false,
selectedNodeId: null,
selectedLink: null,
hiddenLegendItems,
labelVisibility: settings.labelVisibility,
};
}
export function buildVisibleWorldGraph(
model: WorldModel,
state: VisibleGraphState,
settings: RadialSettings = DEFAULT_RADIAL_SETTINGS,
): VisibleWorldGraph {
if (state.mode === 'focus') return buildFocusGraph(model, state, settings);
return buildAtlasGraph(model, state, settings);
}
export function visualNodeId(model: WorldModel, id: string | null | undefined): string | null {
if (id === null || id === undefined) return null;
const node = model.nodes.get(id);
return node?.isRepresentativeFile && node.representativeFor && model.nodes.has(node.representativeFor)
? node.representativeFor
: id;
}
function buildAtlasGraph(model: WorldModel, state: VisibleGraphState, settings: RadialSettings): VisibleWorldGraph {
const rootId = model.nodes.has(state.rootPath) ? state.rootPath : ROOT_ID;
const rootDepth = model.nodes.get(rootId)?.depth ?? 0;
const maxDepth = clampNumber(state.atlasDepth, 1, 80, settings.atlasDepth);
const query = normalizedQuery(state.search);
const visible = new Set<string>();
const stack = [rootId];
while (stack.length > 0) {
const id = stack.pop();
const node = id ? model.nodes.get(id) : model.nodes.get(ROOT_ID);
if (!node) continue;
const relDepth = Math.max(0, node.depth - rootDepth);
const withinDepth = relDepth <= maxDepth;
const matches = query.length > 0 && nodeMatches(node, query);
if (withinDepth || matches) {
visible.add(node.id);
if (matches) addAncestors(model, node.id, visible, rootId);
}
if (withinDepth || matches) {
for (const child of [...(model.childrenByParent.get(node.id) ?? [])].reverse()) stack.push(child);
}
}
if (query) {
for (const node of model.nodes.values()) {
if (!nodeMatches(node, query)) continue;
visible.add(node.id);
addAncestors(model, node.id, visible, rootId);
}
}
addDirectFilesForVisibleFolders(model, visible);
return materializeVisibleGraph(model, visible, rootId, state, settings, null);
}
function buildFocusGraph(model: WorldModel, state: VisibleGraphState, settings: RadialSettings): VisibleWorldGraph {
const activePath = state.focusPath && model.nodes.has(state.focusPath) ? state.focusPath : firstNote(model);
const visible = new Set<string>([ROOT_ID]);
const siblingLimit = clampNumber(state.focusSiblingLimit, 10, 1000, settings.focusSiblingLimit);
if (activePath) {
visible.add(activePath);
addAncestors(model, activePath, visible, ROOT_ID);
const active = model.nodes.get(activePath);
if (active?.parentId) {
for (const sibling of (model.childrenByParent.get(active.parentId) ?? []).slice(0, siblingLimit)) visible.add(sibling);
}
const connected = [
...(model.linkEdgesBySource.get(activePath) ?? []),
...(model.linkEdgesByTarget.get(activePath) ?? []),
].sort((a, b) => b.weight - a.weight);
for (const edge of connected.slice(0, Math.max(30, siblingLimit))) {
visible.add(edge.source);
visible.add(edge.target);
addAncestors(model, edge.source, visible, ROOT_ID);
addAncestors(model, edge.target, visible, ROOT_ID);
}
}
const query = normalizedQuery(state.search);
if (query) {
for (const node of model.nodes.values()) {
if (!nodeMatches(node, query)) continue;
visible.add(node.id);
addAncestors(model, node.id, visible, ROOT_ID);
}
}
addDirectFilesForVisibleFolders(model, visible);
return materializeVisibleGraph(model, visible, ROOT_ID, state, settings, activePath);
}
function materializeVisibleGraph(
model: WorldModel,
visible: Set<string>,
rootId: string,
state: VisibleGraphState,
settings: RadialSettings,
focusId: string | null,
): VisibleWorldGraph {
let nodes = [...visible].map((id) => model.nodes.get(id)).filter((node): node is WorldNode => Boolean(node));
nodes.sort(compareWorldNodes);
const budget = applyNodeBudget(model, nodes, rootId, state, settings, focusId);
nodes = foldRepresentativeNodes(model, budget.nodes);
const nodeSet = new Set(nodes.map((node) => node.id));
const hierarchyEdges = model.hierarchyEdges.filter((edge) => nodeSet.has(edge.source) && nodeSet.has(edge.target));
const bundle = aggregateVisibleLinkEdges(model, nodeSet, state, settings, rootId);
nodes = nodes.concat(bundle.externalNodes);
const nodesById = new Map(nodes.map((node) => [node.id, node]));
return {
nodes,
nodesById,
hierarchyEdges: hierarchyEdges.concat(bundle.externalHierarchyEdges),
linkEdges: bundle.linkEdges,
hoverLinkEdges: bundle.hoverLinkEdges,
rootId,
focusId: visualNodeId(model, focusId),
hiddenNodeCount: budget.hiddenNodeCount,
externalNodeCount: bundle.externalNodes.length,
externalFileCount: bundle.externalNodes.filter((node) => node.externalProxy).length,
externalGroupCount: bundle.externalNodes.filter((node) => node.type === 'external' && !node.externalProxy).length,
};
}
function aggregateVisibleLinkEdges(
model: WorldModel,
visible: Set<string>,
state: VisibleGraphState,
settings: RadialSettings,
rootId: string,
): {
linkEdges: WorldEdge[];
hoverLinkEdges: WorldEdge[];
externalNodes: WorldNode[];
externalHierarchyEdges: WorldEdge[];
} {
const hidden = new Set(state.hiddenLegendItems);
const wantsInternal = state.showLinkOverlay && !hidden.has('link');
const wantsUnresolved = wantsInternal && !hidden.has('dashed-link');
const wantsExternalOverlay = !hidden.has('outside-link');
const wantsExternalNodes = !hidden.has('outside') || !hidden.has('outside-file');
const needsHoverLinks = hoverHighlightsNoteLinks(state.hoverHighlightMode) || state.pinNeedsHoverLinks;
const showExternal = state.showExternalLinks && rootId !== ROOT_ID && (wantsExternalNodes || wantsExternalOverlay);
if (!wantsInternal && !wantsUnresolved && !needsHoverLinks && !showExternal) {
return { linkEdges: [], hoverLinkEdges: [], externalNodes: [], externalHierarchyEdges: [] };
}
const aggregate = new Map<string, WorldEdge>();
const externalNodes = new Map<string, WorldNode>();
const externalHierarchyEdges: WorldEdge[] = [];
const externalLimit = clampNumber(
state.externalLinkAnchorLimit,
0,
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
settings.externalLinkAnchorLimit,
);
const externalContext = { fileCount: 0, overflowId: null as string | null };
for (const raw of model.linkEdges) {
let source = nearestVisibleAncestor(model, raw.source, visible, rootId);
let target = nearestVisibleAncestor(model, raw.target, visible, rootId);
let externalCount = 0;
if (showExternal) {
if (!source && target) {
source = externalEndpointFor(model, raw.source, rootId, externalNodes, externalHierarchyEdges, externalLimit, externalContext, shouldUseExactExternal(raw, state, target));
if (source) externalCount = 1;
}
if (source && !target) {
target = externalEndpointFor(model, raw.target, rootId, externalNodes, externalHierarchyEdges, externalLimit, externalContext, shouldUseExactExternal(raw, state, source));
if (target) externalCount = 1;
}
}
if (!source || !target || source === target) continue;
const key = `${source}->${target}`;
const edge = aggregate.get(key) ?? {
id: `visible-link:${key}`,
type: 'visible-link',
source,
target,
weight: 0,
rawCount: 0,
unresolvedCount: 0,
externalCount: 0,
};
edge.weight += raw.weight;
edge.rawCount = (edge.rawCount ?? 0) + 1;
if (raw.type === 'unresolved-link') edge.unresolvedCount = (edge.unresolvedCount ?? 0) + 1;
edge.externalCount = (edge.externalCount ?? 0) + externalCount;
aggregate.set(key, edge);
}
const all = [...aggregate.values()].sort((a, b) => linkRenderScore(b) - linkRenderScore(a));
const allowsVisibleLink = (edge: WorldEdge) => {
if (edge.externalCount) return wantsExternalOverlay && (!edge.unresolvedCount || wantsUnresolved);
if (edge.unresolvedCount) return wantsUnresolved;
return wantsInternal;
};
const allowsHoverLink = (edge: WorldEdge) => {
if (edge.externalCount) return showExternal && wantsExternalOverlay && (!edge.unresolvedCount || !hidden.has('dashed-link'));
if (edge.unresolvedCount) return !hidden.has('dashed-link');
return !hidden.has('link');
};
const limit = state.showCompleteRoot
? MAX_LINK_LIMIT
: clampNumber(state.linkLimit, 0, MAX_LINK_LIMIT, settings.linkLimit);
const linkEdges = all.filter(allowsVisibleLink).slice(0, limit);
const hoverLinkEdges = state.showCompleteRoot ? linkEdges : all.filter(allowsHoverLink);
const usedExternalIds = new Set<string>();
for (const edge of showExternal ? all.filter((e) => e.externalCount) : needsHoverLinks ? hoverLinkEdges : []) {
if (externalNodes.has(edge.source)) usedExternalIds.add(edge.source);
if (externalNodes.has(edge.target)) usedExternalIds.add(edge.target);
}
const collected = collectExternalNodes(usedExternalIds, externalNodes);
return {
linkEdges,
hoverLinkEdges,
externalNodes: collected,
externalHierarchyEdges: externalHierarchyEdges.filter((edge) => usedExternalIds.has(edge.target)),
};
}
function applyNodeBudget(
model: WorldModel,
nodes: WorldNode[],
rootId: string,
state: VisibleGraphState,
settings: RadialSettings,
focusId: string | null,
): { nodes: WorldNode[]; hiddenNodeCount: number } {
if (state.showCompleteRoot && nodes.length <= MAX_RENDER_NODE_LIMIT) return { nodes, hiddenNodeCount: 0 };
const limit = clampNumber(state.nodeLimit, 200, MAX_RENDER_NODE_LIMIT, settings.renderNodeLimit);
if (nodes.length <= limit) return { nodes, hiddenNodeCount: 0 };
const candidates = nodes
.slice()
.sort((a, b) => nodeRenderScore(b, rootId, focusId, state.search) - nodeRenderScore(a, rootId, focusId, state.search));
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const keep = new Set([rootId, focusId].filter((id): id is string => id !== null && id !== undefined));
addDirectFileChildren(model, rootId, keep, limit, state.showCompleteRoot ? 128 : 64);
for (const node of candidates) {
if (keep.size >= limit) break;
addWithAncestors(model, node.id, keep, nodeById, limit);
if (node.type === 'folder') addDirectFileChildren(model, node.id, keep, limit, state.showCompleteRoot ? 18 : 8);
}
const kept = nodes.filter((node) => keep.has(node.id));
return { nodes: kept, hiddenNodeCount: Math.max(0, nodes.length - kept.length) };
}
function foldRepresentativeNodes(model: WorldModel, nodes: WorldNode[]): WorldNode[] {
const visibleIds = new Set(nodes.map((node) => node.id));
return nodes.filter((node) => !node.isRepresentativeFile || !node.representativeFor || !visibleIds.has(node.representativeFor));
}
function addDirectFilesForVisibleFolders(model: WorldModel, visible: Set<string>): void {
for (const id of [...visible]) {
const node = model.nodes.get(id);
if (!node || node.type !== 'folder') continue;
for (const childId of model.childrenByParent.get(id) ?? []) {
const child = model.nodes.get(childId);
if (child?.type === 'note' || child?.type === 'unresolved') visible.add(childId);
}
}
}
function addAncestors(model: WorldModel, id: string, visible: Set<string>, stopId: string): void {
let current = model.nodes.get(id);
while (current && current.parentId !== null) {
visible.add(current.id);
if (current.id === stopId) break;
current = model.nodes.get(current.parentId);
}
visible.add(stopId || ROOT_ID);
}
function addWithAncestors(
model: WorldModel,
id: string,
keep: Set<string>,
nodeById: Map<string, WorldNode>,
limit: number,
): void {
const chain: string[] = [];
let current = nodeById.get(id);
while (current && !keep.has(current.id)) {
chain.push(current.id);
current = current.parentId === null ? undefined : nodeById.get(current.parentId);
}
for (let i = chain.length - 1; i >= 0 && keep.size < limit; i--) keep.add(chain[i] ?? ROOT_ID);
}
function addDirectFileChildren(model: WorldModel, parentId: string, keep: Set<string>, limit: number, perParentLimit: number): void {
let added = 0;
for (const childId of model.childrenByParent.get(parentId) ?? []) {
if (keep.size >= limit || added >= perParentLimit) return;
const child = model.nodes.get(childId);
if (child?.type !== 'note' && child?.type !== 'unresolved') continue;
keep.add(childId);
added++;
}
}
function nearestVisibleAncestor(model: WorldModel, id: string, visible: Set<string>, rootId: string): string | null {
let current = model.nodes.get(id);
while (current) {
if (visible.has(current.id)) return current.id;
if (current.id === rootId) return rootId;
current = current.parentId === null ? undefined : model.nodes.get(current.parentId);
}
return visible.has(ROOT_ID) ? ROOT_ID : null;
}
function externalEndpointFor(
model: WorldModel,
nodeId: string,
rootId: string,
externalNodes: Map<string, WorldNode>,
externalHierarchyEdges: WorldEdge[],
limit: number,
context: { fileCount: number; overflowId: string | null },
exact: boolean,
): string | null {
const node = model.nodes.get(nodeId);
if (!node) return null;
const anchorPath = externalAnchorPath(node, rootId);
if (!anchorPath) return null;
const groupId = externalGroupFor(model, anchorPath, rootId, externalNodes);
if (!exact || (node.type !== 'note' && node.type !== 'unresolved')) return groupId;
if (externalNodes.has(node.id)) return node.id;
if (context.fileCount >= limit) return externalOverflowFor(rootId, groupId, externalNodes, externalHierarchyEdges, context);
externalNodes.set(node.id, { ...node, externalProxy: true, externalParentId: groupId, externalAnchorPath: anchorPath });
context.fileCount++;
externalHierarchyEdges.push({
id: `external-hierarchy:${groupId}->${node.id}`,
type: 'external-hierarchy',
source: groupId,
target: node.id,
weight: 1,
});
return node.id;
}
function externalGroupFor(model: WorldModel, anchorPath: string, rootId: string, externalNodes: Map<string, WorldNode>): string {
const id = `external-group:${rootId}:${anchorPath}`;
if (!externalNodes.has(id)) {
const anchor = model.nodes.get(anchorPath);
externalNodes.set(id, {
id,
path: anchorPath,
title: `Outside: ${anchor?.title ?? basename(anchorPath)}`,
type: 'external',
parentId: null,
depth: (model.nodes.get(rootId)?.depth ?? 0) + 1,
noteCount: anchor?.noteCount ?? anchor?.descendantCount ?? 0,
linkCount: 0,
backlinkCount: 0,
descendantCount: anchor?.descendantCount ?? anchor?.noteCount ?? 0,
externalAnchorPath: anchorPath,
});
}
return id;
}
function externalOverflowFor(
rootId: string,
groupId: string,
externalNodes: Map<string, WorldNode>,
externalHierarchyEdges: WorldEdge[],
context: { overflowId: string | null },
): string {
if (!context.overflowId) {
context.overflowId = `external-overflow:${rootId}`;
externalNodes.set(context.overflowId, {
id: context.overflowId,
path: 'outside current root',
title: 'More outside files',
type: 'external',
parentId: null,
depth: 1,
noteCount: 0,
linkCount: 0,
backlinkCount: 0,
descendantCount: 0,
externalProxy: true,
externalParentId: groupId,
externalAnchorPath: null,
});
externalHierarchyEdges.push({
id: `external-hierarchy:${groupId}->${context.overflowId}`,
type: 'external-hierarchy',
source: groupId,
target: context.overflowId,
weight: 1,
});
}
return context.overflowId;
}
function collectExternalNodes(ids: Set<string>, externalNodes: Map<string, WorldNode>): WorldNode[] {
const collected = new Map<string, WorldNode>();
for (const id of ids) {
const node = externalNodes.get(id);
if (!node) continue;
if (node.externalParentId && externalNodes.has(node.externalParentId)) {
const parent = externalNodes.get(node.externalParentId);
if (parent) collected.set(parent.id, parent);
}
collected.set(id, node);
}
return [...collected.values()].sort(compareWorldNodes);
}
function shouldUseExactExternal(edge: WorldEdge, state: VisibleGraphState, visibleEndpoint: string): boolean {
if (state.externalDetailMode === 'exact') return true;
if (state.externalDetailMode === 'grouped') return false;
if (state.selectedNodeId && (edge.source === state.selectedNodeId || edge.target === state.selectedNodeId || visibleEndpoint === state.selectedNodeId)) {
return true;
}
const selected = state.selectedLink;
return Boolean(
selected &&
(selected.source === visibleEndpoint ||
selected.target === visibleEndpoint ||
selected.source === edge.source ||
selected.target === edge.target),
);
}
function externalAnchorPath(node: WorldNode, rootId: string): string {
const nodePath = node.type === 'unresolved' ? parentPath(node.path) : node.path;
const pathParts = nodePath.split('/').filter(Boolean);
if (!rootId) return pathParts[0] ?? nodePath;
const rootParts = rootId.split('/').filter(Boolean);
let common = 0;
while (common < rootParts.length && common < pathParts.length && rootParts[common] === pathParts[common]) common++;
return common < rootParts.length ? rootParts.slice(0, common + 1).join('/') : pathParts.slice(0, common + 1).join('/');
}
function firstNote(model: WorldModel): string | null {
return [...model.nodes.values()].find((node) => node.type === 'note')?.id ?? null;
}
function normalizedQuery(value: string): string {
return value.trim().toLowerCase();
}
function nodeMatches(node: WorldNode, query: string): boolean {
return node.title.toLowerCase().includes(query) || node.path.toLowerCase().includes(query);
}
function nodeRenderScore(node: WorldNode, rootId: string, focusId: string | null, query: string): number {
let score = 0;
if (node.id === rootId) score += 1_000_000;
if (node.id === focusId) score += 900_000;
if (query && nodeMatches(node, normalizedQuery(query))) score += 100_000;
if (node.type === 'folder') score += 5000 + Math.min(4000, Math.log2((node.noteCount || node.descendantCount || 1) + 1) * 720);
if (node.type === 'note') score += 1000 + Math.min(3000, Math.log2((node.linkCount || 0) + (node.backlinkCount || 0) + 1) * 520);
if (node.type === 'unresolved') score += 350;
return score - node.depth * 3;
}
function linkRenderScore(edge: WorldEdge): number {
return (
(edge.weight || 1) * 10 +
(edge.rawCount || 0) * 4 +
(edge.externalCount || 0) * 25 +
(edge.unresolvedCount || 0) * 8
);
}

1715
styles.css

File diff suppressed because it is too large Load diff

96
tests/buildGraph.test.ts Normal file
View file

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { buildGraph } from '../src/data/buildGraph';
const files = [
{ path: '01学习/笔记A.md', basename: '笔记A' },
{ path: '01学习/子目录/笔记B.md', basename: '笔记B' },
{ path: '02工作/笔记C.md', basename: '笔记C' },
{ path: '根笔记.md', basename: '根笔记' },
];
describe('buildGraph', () => {
it('节点:路径为 id顶层文件夹归组根目录为空串', () => {
const g = buildGraph(files, {}, {}, { includeUnresolved: false, includeOrphans: true });
expect(g.nodes).toHaveLength(4);
expect(g.nodes[0]).toMatchObject({ id: '01学习/笔记A.md', folderTop: '01学习', unresolved: false });
expect(g.nodes[1]?.folderTop).toBe('01学习');
expect(g.nodes[3]?.folderTop).toBe('');
});
it('边按索引degree=出+入;非 md 集合内目标(附件/不存在)被丢弃', () => {
const resolved = {
'01学习/笔记A.md': { '01学习/子目录/笔记B.md': 3, '附件/图.png': 1 },
'02工作/笔记C.md': { '01学习/笔记A.md': 1 },
'幽灵来源.md': { '01学习/笔记A.md': 1 },
};
const g = buildGraph(files, resolved, {}, { includeUnresolved: false, includeOrphans: true });
expect(g.links).toEqual([
{ source: 0, target: 1 },
{ source: 2, target: 0 },
]);
expect(g.nodes[0]?.degree).toBe(2);
expect(g.nodes[1]?.degree).toBe(1);
expect(g.nodes[2]?.degree).toBe(1);
expect(g.nodes[3]?.degree).toBe(0);
});
it('resolvedLinks 同对多次出现(值=次数)只产生一条边', () => {
const g = buildGraph(files, { '01学习/笔记A.md': { '02工作/笔记C.md': 99 } }, {}, { includeUnresolved: false, includeOrphans: true });
expect(g.links).toHaveLength(1);
});
it('未解析:开关开启时生成幽灵节点并跨来源去重', () => {
const unresolved = {
'01学习/笔记A.md': { 概念词典: 2 },
'02工作/笔记C.md': { 概念词典: 1, 另一个幽灵: 1 },
};
const off = buildGraph(files, {}, unresolved, { includeUnresolved: false, includeOrphans: true });
expect(off.nodes).toHaveLength(4);
expect(off.links).toHaveLength(0);
const on = buildGraph(files, {}, unresolved, { includeUnresolved: true, includeOrphans: true });
const ghosts = on.nodes.filter((n) => n.unresolved);
expect(ghosts).toHaveLength(2);
expect(ghosts[0]).toMatchObject({ id: 'unresolved:概念词典', folderTop: '__unresolved__' });
expect(on.links).toHaveLength(3);
expect(on.nodes.find((n) => n.id === 'unresolved:概念词典')?.degree).toBe(2);
});
it('空 vault 不炸', () => {
const g = buildGraph([], {}, {}, { includeUnresolved: true, includeOrphans: true });
expect(g.nodes).toHaveLength(0);
expect(g.links).toHaveLength(0);
});
});
describe('孤儿过滤', () => {
it('includeOrphans=false 时去掉零度节点并重排边索引', () => {
const resolved = { '01学习/笔记A.md': { '02工作/笔记C.md': 1 } };
const g = buildGraph(files, resolved, {}, { includeUnresolved: false, includeOrphans: false });
expect(g.nodes.map((n) => n.name)).toEqual(['笔记A', '笔记C']);
expect(g.links).toEqual([{ source: 0, target: 1 }]);
});
it('fileSize 从 FileRecord.size 透传', () => {
const sized = [{ path: 'a.md', basename: 'a', size: 12345 }];
const g = buildGraph(sized, {}, {}, { includeUnresolved: false, includeOrphans: true });
expect(g.nodes[0]?.fileSize).toBe(12345);
});
});
describe('质量档位帽M4', () => {
it('nodeCap 按度数取 top N 并重排索引linkCap 按 min(端点度数) 截断', () => {
const resolved = {
'01学习/笔记A.md': { '01学习/子目录/笔记B.md': 1, '02工作/笔记C.md': 1, '根笔记.md': 1 },
'01学习/子目录/笔记B.md': { '02工作/笔记C.md': 1 },
};
// 度数A=3, B=2, C=2, 根=1
const capped = buildGraph(files, resolved, {}, { includeUnresolved: false, includeOrphans: true, nodeCap: 3 });
expect(capped.nodes.map((n) => n.name)).toEqual(['笔记A', '笔记B', '笔记C']);
expect(capped.links).toHaveLength(3); // A-根 被丢弃
const linkCapped = buildGraph(files, resolved, {}, { includeUnresolved: false, includeOrphans: true, linkCap: 2 });
expect(linkCapped.links).toHaveLength(2);
// 保留的是 min 度数最高的边A-B(min2)、A-C(min2),丢 B-C? min(B,C)=2 同分按原序——丢的是 A-根(min1)
expect(linkCapped.links.every((l) => l.source !== 3 && l.target !== 3)).toBe(true);
});
});

62
tests/settings.test.ts Normal file
View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { mergeSettings } from '../src/settings';
describe('Mini World Map settings migration', () => {
it('defaults to 2D radial mode and preserves legacy radial keys', () => {
const settings = mergeSettings({
atlasDepth: 9,
linkLimit: 42,
renderNodeLimit: 900,
includeUnresolvedLinks: false,
showLinkOverlay: false,
hoverHighlightMode: 'note-links',
hoverTargetMode: 'both',
hiddenLegendItems: ['link', 'bogus'],
ignoreFolders: ['.trash'],
});
expect(settings.language).toBe('en');
expect(settings.viewMode).toBe('radial2d');
expect(settings.radial.atlasDepth).toBe(9);
expect(settings.radial.linkLimit).toBe(42);
expect(settings.radial.renderNodeLimit).toBe(900);
expect(settings.radial.includeUnresolvedLinks).toBe(false);
expect(settings.radial.showLinkOverlay).toBe(true);
expect(settings.radial.hoverHighlightMode).toBe('note-links');
expect(settings.radial.hoverTargetMode).toBe('both');
expect(settings.radial.hiddenLegendItems).toEqual(['link']);
expect(settings.radial.ignoreFolders).toEqual(['.trash']);
});
it('defaults 2D hover targets to nodes only', () => {
expect(mergeSettings({}).radial.hoverTargetMode).toBe('nodes');
expect(mergeSettings({ radial: { hoverTargetMode: 'links' } }).radial.hoverTargetMode).toBe('links');
expect(mergeSettings({ radial: { hoverTargetMode: 'anything' } }).radial.hoverTargetMode).toBe('nodes');
});
it('preserves intentional nested 2D note-link visibility', () => {
expect(mergeSettings({ radial: { showLinkOverlay: false } }).radial.showLinkOverlay).toBe(false);
});
it('normalizes the shared language option', () => {
expect(mergeSettings({ language: 'zh' }).language).toBe('zh');
expect(mergeSettings({ language: 'fr' }).language).toBe('en');
});
it('keeps Galaxy settings nested for 3D map mode', () => {
const settings = mergeSettings({
viewMode: 'map3d',
galaxy3d: {
cruise: false,
bloom: { strength: 1.2 },
physics: { repel: 123 },
look: { sizeBy: 'fileSize' },
},
});
expect(settings.viewMode).toBe('map3d');
expect(settings.galaxy3d.cruise).toBe(false);
expect(settings.galaxy3d.bloom.strength).toBe(1.2);
expect(settings.galaxy3d.physics.repel).toBe(123);
expect(settings.galaxy3d.look.sizeBy).toBe('fileSize');
expect(settings.galaxy3d.preset).toBe('adaptive');
});
});

163
tests/worldMap.test.ts Normal file
View file

@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_RADIAL_SETTINGS } from '../src/settings';
import { layoutRadialGraph } from '../src/layout/radial/layoutRadial';
import { buildWorldMap } from '../src/world/buildWorldMap';
import { ROOT_ID } from '../src/world/types';
import { buildVisibleWorldGraph, defaultVisibleGraphState } from '../src/world/visibleGraph';
const records = [
{ path: 'Atlas', basename: 'Atlas', kind: 'folder' as const },
{ path: 'Atlas/Atlas.md', basename: 'Atlas', kind: 'note' as const },
{ path: 'Atlas/Topic A.md', basename: 'Topic A', kind: 'note' as const },
{ path: 'Atlas/Sub', basename: 'Sub', kind: 'folder' as const },
{ path: 'Atlas/Sub/Topic B.md', basename: 'Topic B', kind: 'note' as const },
{ path: 'Outside/Other.md', basename: 'Other', kind: 'note' as const },
{ path: '.obsidian/Hidden.md', basename: 'Hidden', kind: 'note' as const },
];
function model(overrides: Partial<typeof DEFAULT_RADIAL_SETTINGS> = {}) {
const settings = { ...DEFAULT_RADIAL_SETTINGS, ...overrides };
return buildWorldMap(
records,
{
'Atlas/Topic A.md': { 'Atlas/Sub/Topic B.md': 2, 'Outside/Other.md': 1 },
'Outside/Other.md': { 'Atlas/Topic A.md': 1 },
},
{
'Atlas/Topic A.md': { Missing: 1 },
},
settings,
);
}
describe('world-map model', () => {
it('indexes folders, notes, representatives, unresolved links, and ignored folders', () => {
const m = model();
expect(m.nodes.has(ROOT_ID)).toBe(true);
expect(m.nodes.has('Atlas')).toBe(true);
expect(m.nodes.has('Atlas/Topic A.md')).toBe(true);
expect(m.nodes.has('.obsidian/Hidden.md')).toBe(false);
expect(m.nodes.get('Atlas')?.representativeFile).toBe('Atlas/Atlas.md');
expect(m.nodes.get('Atlas/Atlas.md')?.isRepresentativeFile).toBe(true);
expect([...m.nodes.values()].filter((node) => node.type === 'unresolved')).toHaveLength(1);
expect(m.stats.notes).toBe(4);
});
it('can omit unresolved nodes', () => {
const m = model({ includeUnresolvedLinks: false });
expect([...m.nodes.values()].filter((node) => node.type === 'unresolved')).toHaveLength(0);
});
});
describe('visible world graph', () => {
it('builds an atlas graph with hierarchy edges and representative folding', () => {
const m = model();
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.rootPath = 'Atlas';
state.showLinkOverlay = true;
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
expect(graph.rootId).toBe('Atlas');
expect(graph.nodesById.has('Atlas/Atlas.md')).toBe(false);
expect(graph.hierarchyEdges.length).toBeGreaterThan(0);
expect(graph.linkEdges.length).toBeGreaterThan(0);
});
it('creates grouped and exact outside-root proxies', () => {
const m = model();
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.rootPath = 'Atlas';
state.showLinkOverlay = true;
state.externalDetailMode = 'grouped';
const grouped = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
expect(grouped.nodes.some((node) => node.type === 'external' && !node.externalProxy)).toBe(true);
expect(grouped.nodes.some((node) => node.externalProxy)).toBe(false);
state.externalDetailMode = 'exact';
const exact = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
expect(exact.nodes.some((node) => node.externalProxy)).toBe(true);
});
it('keeps hover links available when the visible link overlay is off', () => {
const m = model();
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.rootPath = 'Atlas';
state.showLinkOverlay = false;
state.showExternalLinks = false;
state.hoverHighlightMode = 'note-links';
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
expect(graph.linkEdges).toHaveLength(0);
expect(graph.hoverLinkEdges.length).toBeGreaterThan(0);
});
it('builds a focus graph around the active note', () => {
const m = model();
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.mode = 'focus';
state.focusPath = 'Atlas/Topic A.md';
state.showLinkOverlay = true;
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
expect(graph.focusId).toBe('Atlas/Topic A.md');
expect(graph.nodesById.has('Atlas/Sub/Topic B.md')).toBe(true);
});
});
describe('radial layout', () => {
it('keeps the root centered and ring depths ordered', () => {
const m = model();
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.rootPath = 'Atlas';
state.showLinkOverlay = true;
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
const layout = layoutRadialGraph(graph, { ringSpacing: 960, nodeSpacing: 126, swirlStrength: 0 });
const root = layout.positions.get(graph.rootId);
expect(root?.x).toBeCloseTo(0);
expect(root?.y).toBeCloseTo(0);
expect([...layout.positions.values()].every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))).toBe(true);
const radii = layout.rings.map((ring) => ring.radius);
expect(radii).toEqual([...radii].sort((a, b) => a - b));
});
it('keeps folder descendants outside their parent folder rings', () => {
const m = model();
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.rootPath = ROOT_ID;
state.showLinkOverlay = false;
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
const layout = layoutRadialGraph(graph, { ringSpacing: 960, nodeSpacing: 126, swirlStrength: 0 });
const root = layout.positions.get(ROOT_ID);
const atlas = layout.positions.get('Atlas');
const sub = layout.positions.get('Atlas/Sub');
const topic = layout.positions.get('Atlas/Sub/Topic B.md');
expect(root?.radius).toBeCloseTo(0);
expect(atlas?.radius).toBeGreaterThan(root?.radius ?? -1);
expect(sub?.radius).toBeGreaterThan(atlas?.radius ?? -1);
expect(topic?.radius).toBeGreaterThan(sub?.radius ?? -1);
expect(atlas?.depth).toBe(1);
expect(sub?.depth).toBe(2);
expect(topic?.depth).toBe(3);
});
it('does not invent hierarchy rings for flat same-parent root notes', () => {
const crowdedRecords = Array.from({ length: 900 }, (_, index) => ({
path: `Note ${index}.md`,
basename: `Note ${index}`,
kind: 'note' as const,
}));
const m = buildWorldMap(crowdedRecords, {}, {}, DEFAULT_RADIAL_SETTINGS);
const state = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
state.showLinkOverlay = false;
state.nodeLimit = 1200;
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
const layout = layoutRadialGraph(graph, { ringSpacing: 960, nodeSpacing: 126, swirlStrength: 0 });
const nodeRadii = [...layout.positions.entries()]
.filter(([id]) => id !== graph.rootId)
.map(([, point]) => Math.hypot(point.x, point.y));
const depthOneRings = layout.rings.filter((ring) => ring.depth === 1);
const minRadius = Math.min(...nodeRadii);
const maxRadius = Math.max(...nodeRadii);
expect(depthOneRings).toHaveLength(1);
expect(maxRadius - minRadius).toBeLessThan((depthOneRings[0]?.radius ?? 1) * 0.5);
});
});

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2021",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"moduleResolution": "node",
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"lib": ["ES2021", "DOM"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

17
version-bump.mjs Normal file
View file

@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from 'fs';
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!(targetVersion in versions)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

4
versions.json Normal file
View file

@ -0,0 +1,4 @@
{
"0.1.0": "1.7.2",
"0.1.1": "1.7.2"
}