mirror of
https://github.com/miro0o/miniWorldMap.git
synced 2026-07-22 07:46:00 +00:00
temp savepoints. port succeeds. minor errors to be fixed.
This commit is contained in:
parent
e0c280a05c
commit
a726ca20e1
55 changed files with 30746 additions and 9607 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,3 +2,6 @@
|
|||
node_modules/
|
||||
data.json
|
||||
*.map
|
||||
dist/
|
||||
dev-vault/
|
||||
*.log
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -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
99
esbuild.config.mjs
Normal 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
35
eslint.config.mts
Normal 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
8957
legacy/main.legacy.js
Normal file
File diff suppressed because it is too large
Load diff
13025
main.js
13025
main.js
File diff suppressed because one or more lines are too long
7032
package-lock.json
generated
Normal file
7032
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
35
package.json
Normal file
35
package.json
Normal 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
83
src/bench/bench.ts
Normal 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
34
src/constants.ts
Normal 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
109
src/data/GraphStore.ts
Normal 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
151
src/data/buildGraph.ts
Normal 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
29
src/data/seed.ts
Normal 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
309
src/i18n.ts
Normal 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')],
|
||||
];
|
||||
}
|
||||
271
src/interactions/CameraDirector.ts
Normal file
271
src/interactions/CameraDirector.ts
Normal 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
|
||||
* - 编排:点击/搜索飞行 tween、闲置巡航(双不可通约周期)、F 飞向选中、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();
|
||||
}
|
||||
}
|
||||
23
src/layout/LayoutEngine.ts
Normal file
23
src/layout/LayoutEngine.ts
Normal 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;
|
||||
}
|
||||
109
src/layout/MainThreadForceLayout.ts
Normal file
109
src/layout/MainThreadForceLayout.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/layout/WorkerForceLayout.ts
Normal file
115
src/layout/WorkerForceLayout.ts
Normal 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;
|
||||
// 归还 buffer(transferable 乒乓)
|
||||
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
166
src/layout/forceWorker.ts
Normal 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';
|
||||
|
||||
/**
|
||||
* 布局 Worker(M3):d3-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;
|
||||
}
|
||||
};
|
||||
1316
src/layout/radial/layoutRadial.ts
Normal file
1316
src/layout/radial/layoutRadial.ts
Normal file
File diff suppressed because it is too large
Load diff
281
src/main.ts
Normal file
281
src/main.ts
Normal 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
347
src/overlay/ControlPanel.ts
Normal 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`);
|
||||
}
|
||||
237
src/overlay/OverlayManager.ts
Normal file
237
src/overlay/OverlayManager.ts
Normal 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
110
src/overlay/Slider.ts
Normal 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
54
src/quality/tiers.ts
Normal 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 关 bloom:shader 热核保住 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,
|
||||
},
|
||||
};
|
||||
672
src/render/AggregateRenderer.ts
Normal file
672
src/render/AggregateRenderer.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 质量档位(M4):pixelRatio / 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();
|
||||
}
|
||||
}
|
||||
790
src/render/RadialRenderer.ts
Normal file
790
src/render/RadialRenderer.ts
Normal 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
49
src/render/colorThemes.ts
Normal 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
57
src/render/palette.ts
Normal 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
41
src/render/presets.ts
Normal 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
47
src/render/shaders.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// 节点 = 单次 draw call 的 THREE.Points + 发光球 shader(NASA「luminous orb」配方)
|
||||
// 深空模式:白热核心 + 软边缘;浅色「晨昼」模式:实心墨水圆盘 + 深色 rim(bloom 关)
|
||||
// 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
120
src/render/starfield.ts
Normal 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(滑杆 0–2,0=关) */
|
||||
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) };
|
||||
}
|
||||
44
src/render/stylePresets.ts
Normal file
44
src/render/stylePresets.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { BloomSettings, LookSettings, PhysicsSettings } from '../settings';
|
||||
|
||||
/**
|
||||
* 风格预设 = 辉光 + 力学 + 外观 的成套参数(G2 反馈:给经典组合,新用户第一印象优先)。
|
||||
* 「银河」即出厂默认:扁平星盘靠 flatten(Y 轴额外向心力)实现——自然引斥力做不出盘。
|
||||
*/
|
||||
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
344
src/settings.ts
Normal 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;
|
||||
}
|
||||
31
src/settings/graphJsonImport.ts
Normal file
31
src/settings/graphJsonImport.ts
Normal 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)。
|
||||
* 颜色存的是十进制 int;query 形如 "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
47
src/types.ts
Normal 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
90
src/typings/d3-force-3d.d.ts
vendored
Normal 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
2
src/typings/galaxy-dev.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// esbuild define:dev 构建 true,商店/release 构建 false(基准等开发工具被摇树剔除)
|
||||
declare const __GALAXY_DEV__: boolean;
|
||||
5
src/typings/inline-worker.d.ts
vendored
Normal file
5
src/typings/inline-worker.d.ts
vendored
Normal 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
106
src/view/GalaxyView.ts
Normal 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
779
src/view/GraphController.ts
Normal 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 主题 → tokens(adaptive 深色与深空共用场景) */
|
||||
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); // 触发 onDataChanged(alpha 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;
|
||||
}
|
||||
}
|
||||
3
src/view/Map3DController.ts
Normal file
3
src/view/Map3DController.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { GraphController } from './GraphController';
|
||||
|
||||
export class Map3DController extends GraphController {}
|
||||
1069
src/view/Radial2DController.ts
Normal file
1069
src/view/Radial2DController.ts
Normal file
File diff suppressed because it is too large
Load diff
61
src/view/SearchModal.ts
Normal file
61
src/view/SearchModal.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/world/WorldMapIndex.ts
Normal file
98
src/world/WorldMapIndex.ts
Normal 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
255
src/world/buildWorldMap.ts
Normal 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
103
src/world/types.ts
Normal 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
509
src/world/visibleGraph.ts
Normal 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
1715
styles.css
File diff suppressed because it is too large
Load diff
96
tests/buildGraph.test.ts
Normal file
96
tests/buildGraph.test.ts
Normal 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
62
tests/settings.test.ts
Normal 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
163
tests/worldMap.test.ts
Normal 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
19
tsconfig.json
Normal 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
17
version-bump.mjs
Normal 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
4
versions.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"0.1.0": "1.7.2",
|
||||
"0.1.1": "1.7.2"
|
||||
}
|
||||
Loading…
Reference in a new issue