mirror of
https://github.com/miro0o/miniWorldMap.git
synced 2026-07-22 07:46:00 +00:00
Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a64e8b7 | ||
|
|
91d48e372a |
17 changed files with 444 additions and 341 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
## 0.2.4
|
||||
|
||||
- Move 3D node inspection details into the side panel.
|
||||
- Improve long label, pin, neighbor, and button wrapping in the map panels.
|
||||
- Type Obsidian saved plugin data as `unknown` before settings migration to avoid unsafe assignment warnings.
|
||||
|
||||
## 0.2.3
|
||||
|
||||
- Add a 2D Complete map action that shows the vault root with expanded node, link, depth, and outside-link detail budgets.
|
||||
- Allow farther 2D zoom-out for large complete maps.
|
||||
- Improve 2D panel wrapping and alignment so long labels remain readable.
|
||||
- Soften the 2D outside-link road color and legend swatch.
|
||||
|
||||
## 0.2.2
|
||||
|
||||
- Address Obsidian community plugin review findings around settings APIs, command names, deprecated slider tooltips, direct static styles, and config folder handling.
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ Global plugin settings include:
|
|||
- 2D unresolved link handling.
|
||||
- Ignored folders.
|
||||
|
||||
The 2D view panel also includes per-view controls for atlas/focus mode, vault root, theme, depth, visible nodes, note-link budget, outside-link detail, exact outside-note limit, ring guides, and legend visibility.
|
||||
The 2D view panel also includes per-view controls for atlas/focus mode, vault root, complete map display, theme, depth, visible nodes, note-link budget, outside-link detail, exact outside-note limit, ring guides, and legend visibility.
|
||||
|
||||
The 3D view panel includes controls for search, recentering, reveal animation, visual style presets, theme presets, color themes, imported Obsidian graph colors, node sizing, bloom, physics, cruise motion, unresolved links, orphan nodes, quality tier, and default reset.
|
||||
|
||||
|
|
|
|||
66
main.js
66
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "mini-world-map",
|
||||
"name": "Mini World Map",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"minAppVersion": "1.13.0",
|
||||
"description": "Visualize your vault as a hierarchy-first world map with internal links layered on top.",
|
||||
"author": "Miro0o and Codex",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "mini-world-map",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mini-world-map",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"3d-force-graph": "1.80.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "mini-world-map",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.4",
|
||||
"description": "Hierarchy-first 2D radial rings and 3D map views for your Obsidian vault",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ const STRINGS: Record<string, Record<Language, string>> = {
|
|||
'view.atlas': { en: 'Atlas', zh: '图谱' },
|
||||
'view.focus': { en: 'Focus', zh: '聚焦' },
|
||||
'view.vaultRoot': { en: 'Vault root', zh: '库根目录' },
|
||||
'view.completeMap': { en: 'Full map', zh: '完整地图' },
|
||||
'view.mode': { en: 'Map mode', zh: '地图模式' },
|
||||
'view.theme': { en: 'Theme', zh: '主题' },
|
||||
'theme.auto': { en: 'System', zh: '跟随系统' },
|
||||
|
|
@ -70,6 +71,8 @@ const STRINGS: Record<string, Record<Language, string>> = {
|
|||
'inspect.type': { en: 'Type', zh: '类型' },
|
||||
'inspect.depth': { en: 'Depth', zh: '深度' },
|
||||
'inspect.notes': { en: 'Notes', zh: '笔记' },
|
||||
'inspect.folder': { en: 'Folder', zh: '文件夹' },
|
||||
'inspect.modified': { en: 'Modified', zh: '修改时间' },
|
||||
'inspect.out': { en: 'Out', zh: '出链' },
|
||||
'inspect.in': { en: 'In', zh: '入链' },
|
||||
'inspect.linkOverlay': { en: 'Note link', zh: '笔记链接' },
|
||||
|
|
@ -227,6 +230,8 @@ const STRINGS: Record<string, Record<Language, string>> = {
|
|||
'3d.searchPlaceholder': { en: 'Search notes, press Enter to fly…', zh: '搜索笔记,回车飞过去…' },
|
||||
'3d.searchUnresolved': { en: 'Unresolved', zh: '未解析' },
|
||||
'3d.searchLinks': { en: '{count} links', zh: '{count} 链接' },
|
||||
'3d.inspect.none': { en: 'Select a node to inspect it.', zh: '选择一个节点查看详情。' },
|
||||
'3d.inspect.note': { en: 'Note', zh: '笔记' },
|
||||
'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} 出链' },
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default class MiniWorldMapPlugin extends Plugin {
|
|||
|
||||
async onload(): Promise<void> {
|
||||
console.info('[Mini World Map] loading');
|
||||
const saved = await this.loadData();
|
||||
const saved: unknown = await this.loadData();
|
||||
this.settings = applyVaultConfigDirDefault(mergeSettings(saved), saved, this.app.vault.configDir);
|
||||
this.registerView(VIEW_TYPE_MINI_WORLD_MAP, (leaf) => new MiniWorldMapView(leaf, this));
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,18 @@ import type { VisualTokens } from '../render/presets';
|
|||
import { t, viewModeLabel } from '../i18n';
|
||||
import { Slider } from './Slider';
|
||||
|
||||
type PanelPage = 'view' | 'appearance' | 'physics' | 'motion' | 'advanced';
|
||||
type PanelPage = 'inspect' | 'view' | 'appearance' | 'physics' | 'motion' | 'advanced';
|
||||
|
||||
export interface PanelInspectNode {
|
||||
title: string;
|
||||
path: string;
|
||||
folder: string;
|
||||
type: string;
|
||||
inDegree: number;
|
||||
outDegree: number;
|
||||
modified: string | null;
|
||||
canOpen: boolean;
|
||||
}
|
||||
|
||||
export interface ControlPanelCallbacks {
|
||||
onViewMode?: (mode: ViewMode) => void;
|
||||
|
|
@ -29,6 +40,8 @@ export interface ControlPanelCallbacks {
|
|||
onSizeBy: () => void;
|
||||
onQuality: () => void;
|
||||
onSearch: () => void;
|
||||
onOpenSelected: () => void;
|
||||
onFocusSelected: () => void;
|
||||
onReset: () => void;
|
||||
runScenario: (s: 'S1' | 'S2' | 'S3') => void;
|
||||
}
|
||||
|
|
@ -46,6 +59,7 @@ export class ControlPanel {
|
|||
private sizeBySelect: HTMLSelectElement | null = null;
|
||||
private qualityBtn: HTMLButtonElement | null = null;
|
||||
private styleChips: HTMLButtonElement[] = [];
|
||||
private inspectNode: PanelInspectNode | null = null;
|
||||
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
|
|
@ -87,6 +101,7 @@ export class ControlPanel {
|
|||
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 [
|
||||
['inspect', this.tt('tab.inspect')],
|
||||
['view', this.tt('tab.view')],
|
||||
['appearance', this.tt('tab.appearance')],
|
||||
['physics', this.tt('tab.physics')],
|
||||
|
|
@ -101,13 +116,43 @@ export class ControlPanel {
|
|||
}
|
||||
|
||||
const pageEl = settings.createDiv({ cls: 'mwm-panel-page' });
|
||||
if (this.page === 'appearance') this.renderAppearancePage(pageEl);
|
||||
if (this.page === 'inspect') this.renderInspectPage(pageEl);
|
||||
else 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 renderInspectPage(parent: HTMLElement): void {
|
||||
const node = this.inspectNode;
|
||||
if (!node) {
|
||||
parent.createDiv({ cls: 'mwm-side-muted', text: this.tt('3d.inspect.none') });
|
||||
return;
|
||||
}
|
||||
parent.createDiv({ cls: 'mwm-side-title', text: node.title });
|
||||
const path = parent.createDiv({ cls: 'mwm-side-path mwm-scroll-path', text: node.path || this.tt('3d.card.root') });
|
||||
path.setAttr('title', node.path || this.tt('3d.card.root'));
|
||||
const facts = parent.createDiv({ cls: 'mwm-facts' });
|
||||
for (const [label, value] of [
|
||||
[this.tt('inspect.type'), node.type],
|
||||
[this.tt('inspect.folder'), node.folder || this.tt('3d.card.root')],
|
||||
[this.tt('inspect.in'), String(node.inDegree)],
|
||||
[this.tt('inspect.out'), String(node.outDegree)],
|
||||
...(node.modified ? ([[this.tt('inspect.modified'), node.modified]] as const) : []),
|
||||
] as const) {
|
||||
facts.createSpan({ text: label });
|
||||
facts.createSpan({ text: value });
|
||||
}
|
||||
const actions = parent.createDiv({ cls: 'galaxy-panel-row' });
|
||||
if (node.canOpen) {
|
||||
const openBtn = actions.createEl('button', { text: this.tt('context.openNote') });
|
||||
openBtn.addEventListener('click', this.cb.onOpenSelected);
|
||||
}
|
||||
const focusBtn = actions.createEl('button', { text: this.tt('common.focus') });
|
||||
focusBtn.addEventListener('click', this.cb.onFocusSelected);
|
||||
}
|
||||
|
||||
private renderViewPage(parent: HTMLElement): void {
|
||||
const row = parent.createDiv({ cls: 'galaxy-panel-row' });
|
||||
const searchBtn = row.createEl('button', { text: this.tt('common.search') });
|
||||
|
|
@ -344,6 +389,12 @@ export class ControlPanel {
|
|||
this.render();
|
||||
}
|
||||
|
||||
setInspectNode(node: PanelInspectNode | null, activate = false): void {
|
||||
this.inspectNode = node;
|
||||
if (activate) this.page = 'inspect';
|
||||
if (activate || this.page === 'inspect') this.render();
|
||||
}
|
||||
|
||||
setPanelTheme(cls: VisualTokens['panelClass']): void {
|
||||
this.root.removeClass('gx-theme-space');
|
||||
this.root.removeClass('gx-theme-night');
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { TFile, getAllTags } from 'obsidian';
|
||||
import type { Language } from '../settings';
|
||||
import type { GraphData, GraphNode } from '../types';
|
||||
import type { GraphData } 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 次投影,可忽略。
|
||||
* DOM labels for hub, hover, and selected-neighbor names.
|
||||
*/
|
||||
export class OverlayManager {
|
||||
private root: HTMLElement;
|
||||
|
|
@ -20,42 +10,23 @@ export class OverlayManager {
|
|||
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 {
|
||||
setBudgets(hub: number, neighbor: number): void {
|
||||
this.hubBudget = hub;
|
||||
this.neighborBudget = neighbor;
|
||||
this.mobileCard = mobileCard;
|
||||
this.setData(this.data, this.graphRadius);
|
||||
}
|
||||
|
||||
|
|
@ -88,31 +59,11 @@ export class OverlayManager {
|
|||
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 清空 */
|
||||
/** Selected node: neighbor labels only; details render in the left panel. */
|
||||
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;
|
||||
}
|
||||
if (index < 0) return;
|
||||
const byDegree = [...neighbors]
|
||||
.filter((i) => i !== index)
|
||||
.sort((a, b) => (this.data.nodes[b]?.degree ?? 0) - (this.data.nodes[a]?.degree ?? 0))
|
||||
|
|
@ -121,63 +72,6 @@ export class OverlayManager {
|
|||
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 合成,无重排) */
|
||||
|
|
@ -204,15 +98,6 @@ export class OverlayManager {
|
|||
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 {
|
||||
|
|
@ -220,18 +105,4 @@ export class OverlayManager {
|
|||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import type { RadialLayout, RadialPoint, RadialRoute } from '../layout/radial/la
|
|||
import { ROOT_ID, type VisibleWorldGraph, type WorldEdge, type WorldNode } from '../world/types';
|
||||
import { NODE_FRAGMENT_SHADER, NODE_VERTEX_SHADER } from './shaders';
|
||||
|
||||
export const MIN_RADIAL_ZOOM = 0.0001;
|
||||
// Hard floor keeps wheel math and hit-tests finite while still allowing huge complete maps to fit.
|
||||
export const MIN_RADIAL_ZOOM = 0.00001;
|
||||
export const MAX_RADIAL_ZOOM = 6;
|
||||
const NODE_BASE_POINT = 4.8;
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
|||
tree: '#93a3b8',
|
||||
link: '#a2acba',
|
||||
external: '#7b6fd6',
|
||||
externalLink: '#c98135',
|
||||
externalLink: '#b8752e',
|
||||
unresolved: '#dc4a4a',
|
||||
focus: '#3f7fe8',
|
||||
folder: '#7f92a8',
|
||||
|
|
@ -80,7 +81,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
|||
ringOpacity: 0.22,
|
||||
treeOpacity: 0.26,
|
||||
linkOpacity: 0.1,
|
||||
externalLinkOpacity: 0.16,
|
||||
externalLinkOpacity: 0.14,
|
||||
highlightOpacity: 0.92,
|
||||
nodeScale: 0.32,
|
||||
maxLabels: 170,
|
||||
|
|
@ -91,7 +92,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
|||
tree: '#8a8f9c',
|
||||
link: '#8b8f99',
|
||||
external: '#a78bfa',
|
||||
externalLink: '#f59e0b',
|
||||
externalLink: '#d97706',
|
||||
unresolved: '#fb7185',
|
||||
focus: '#8b7cf6',
|
||||
folder: '#d5d8de',
|
||||
|
|
@ -101,7 +102,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
|||
ringOpacity: 0.22,
|
||||
treeOpacity: 0.28,
|
||||
linkOpacity: 0.12,
|
||||
externalLinkOpacity: 0.16,
|
||||
externalLinkOpacity: 0.14,
|
||||
highlightOpacity: 0.98,
|
||||
nodeScale: 0.32,
|
||||
maxLabels: 300,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { Menu, Notice, Platform, debounce, setIcon } from 'obsidian';
|
||||
import { Menu, Notice, Platform, TFile, debounce, setIcon } from 'obsidian';
|
||||
import { Spherical, Vector3 } from 'three';
|
||||
import type { BenchResult } from '../types';
|
||||
import type { GalaxySettings, Language, ViewMode } from '../settings';
|
||||
|
|
@ -18,7 +18,7 @@ import { DAYLIGHT, DEEP_SPACE, NIGHT } from '../render/presets';
|
|||
import type { VisualTokens } from '../render/presets';
|
||||
import { resolveObsidianBackground } from '../render/obsidianTheme';
|
||||
import { CameraDirector } from '../interactions/CameraDirector';
|
||||
import { ControlPanel } from '../overlay/ControlPanel';
|
||||
import { ControlPanel, type PanelInspectNode } from '../overlay/ControlPanel';
|
||||
import { OverlayManager } from '../overlay/OverlayManager';
|
||||
import { NodeSearchModal } from './SearchModal';
|
||||
import { collectFrames, observeLongTasks, writeBenchResult, sleep } from '../bench/bench';
|
||||
|
|
@ -104,10 +104,7 @@ export class GraphController {
|
|||
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 = new OverlayManager(this.contentEl, renderer);
|
||||
this.overlay.setData(this.store.data, this.graphRadius);
|
||||
|
||||
this.applySettings();
|
||||
|
|
@ -275,7 +272,7 @@ export class GraphController {
|
|||
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.overlay?.setBudgets(this.tier.hubLabels, this.tier.neighborLabels);
|
||||
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); // 变化时触发重建
|
||||
|
|
@ -454,6 +451,7 @@ export class GraphController {
|
|||
renderer.setFocus(index, neighbors);
|
||||
renderer.setSelectedLinks(linkIdx);
|
||||
this.overlay?.setSelection(index, neighbors);
|
||||
this.panel?.setInspectNode(this.panelInspectNode(index), true);
|
||||
if (fly) {
|
||||
const pos = renderer.nodePosition(index, new Vector3());
|
||||
// 邻居质心方向:到达后环绕优先扫过链接密集的一侧
|
||||
|
|
@ -475,6 +473,31 @@ export class GraphController {
|
|||
this.renderer?.setFocus(-1, null);
|
||||
this.renderer?.setSelectedLinks([]);
|
||||
this.overlay?.setSelection(-1, new Set());
|
||||
this.panel?.setInspectNode(null);
|
||||
}
|
||||
|
||||
private panelInspectNode(index: number): PanelInspectNode | null {
|
||||
const node = this.store.data.nodes[index];
|
||||
if (!node) return null;
|
||||
const file = node.unresolved ? null : this.app.vault.getAbstractFileByPath(node.id);
|
||||
const tfile = file instanceof TFile ? file : null;
|
||||
const folder = node.unresolved ? this.tt('3d.searchUnresolved') : node.id.includes('/') ? node.id.slice(0, node.id.lastIndexOf('/')) : this.tt('3d.card.root');
|
||||
return {
|
||||
title: node.name,
|
||||
path: node.unresolved ? node.id : node.id || this.tt('3d.card.root'),
|
||||
folder,
|
||||
type: node.unresolved ? this.tt('3d.searchUnresolved') : this.tt('3d.inspect.note'),
|
||||
inDegree: node.inDegree,
|
||||
outDegree: node.outDegree,
|
||||
modified: tfile ? new Date(tfile.stat.mtime).toLocaleDateString(this.language === 'zh' ? 'zh-CN' : 'en-US') : null,
|
||||
canOpen: !node.unresolved,
|
||||
};
|
||||
}
|
||||
|
||||
private openSelectedNode(): void {
|
||||
const node = this.store.data.nodes[this.selected];
|
||||
if (!node || node.unresolved) return;
|
||||
void this.app.workspace.openLinkText(node.id, '', true);
|
||||
}
|
||||
|
||||
private flyToSelected(): void {
|
||||
|
|
@ -605,6 +628,8 @@ export class GraphController {
|
|||
this.saveSoon();
|
||||
},
|
||||
onSearch: () => this.openSearch(),
|
||||
onOpenSelected: () => this.openSelectedNode(),
|
||||
onFocusSelected: () => this.flyToSelected(),
|
||||
onReset: () => {
|
||||
Object.assign(this.settings.bloom, DEFAULT_GALAXY_SETTINGS.bloom);
|
||||
Object.assign(this.settings.physics, DEFAULT_GALAXY_SETTINGS.physics);
|
||||
|
|
@ -644,7 +669,6 @@ export class GraphController {
|
|||
item.onClick(() => {
|
||||
if (value === this.language) return;
|
||||
this.language = value;
|
||||
this.overlay?.setLanguage(value);
|
||||
this.panel?.setLanguage(value);
|
||||
this.onLanguage?.(value);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -165,18 +165,22 @@ export class Radial2DController extends Component {
|
|||
|
||||
private async rebuildNow(reason: string, token: number): Promise<void> {
|
||||
const radial = this.radial();
|
||||
const shouldReveal = ['start', 'manual', 'root', 'focus', 'atlas'].includes(reason);
|
||||
const shouldReveal = ['start', 'manual', 'root', 'focus', 'atlas', 'complete'].includes(reason);
|
||||
const loadingText = this.t('loading.radial');
|
||||
if (shouldReveal) {
|
||||
this.renderer?.showLoadingMask(loadingText);
|
||||
await animationFrames(2);
|
||||
if (this.disposed || token !== this.rebuildToken) return;
|
||||
}
|
||||
this.index.rebuild(radial);
|
||||
const indexSettings = this.state.showCompleteRoot ? { ...radial, includeUnresolvedLinks: true } : radial;
|
||||
this.index.rebuild(indexSettings);
|
||||
this.state.hoverHighlightMode = radial.hoverHighlightMode;
|
||||
this.state.labelVisibility = radial.labelVisibility;
|
||||
this.state.hiddenLegendItems = radial.hiddenLegendItems.slice();
|
||||
this.state.showLinkOverlay = radial.showLinkOverlay || !this.state.hiddenLegendItems.includes('link');
|
||||
if (this.state.showCompleteRoot) this.applyCompleteMapState();
|
||||
else {
|
||||
this.state.hiddenLegendItems = radial.hiddenLegendItems.slice();
|
||||
this.state.showLinkOverlay = radial.showLinkOverlay || !this.state.hiddenLegendItems.includes('link');
|
||||
}
|
||||
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
|
||||
this.state.selectedNodeId = this.selectedNodeId;
|
||||
this.state.selectedLink = this.selectedLink;
|
||||
|
|
@ -488,7 +492,7 @@ export class Radial2DController extends Component {
|
|||
['controls', this.t('tab.controls')],
|
||||
['defaults', this.t('tab.defaults')],
|
||||
] as const) {
|
||||
const button = tabs.createEl('button', { cls: this.activePanelPage === id ? 'is-active' : '', text: label });
|
||||
const button = tabs.createEl('button', { cls: this.activePanelPage === id ? 'is-active' : '', text: label, attr: { title: label } });
|
||||
button.addEventListener('click', () => {
|
||||
this.activePanelPage = id;
|
||||
this.renderPanel();
|
||||
|
|
@ -504,7 +508,7 @@ export class Radial2DController extends Component {
|
|||
|
||||
private modeButton(parent: HTMLElement, label: string, mode: ViewMode): void {
|
||||
const active = this.settings.viewMode === mode;
|
||||
const button = parent.createEl('button', { cls: active ? 'is-active' : '', text: label });
|
||||
const button = parent.createEl('button', { cls: active ? 'is-active' : '', text: label, attr: { title: label } });
|
||||
button.addEventListener('click', () => this.onViewMode(mode));
|
||||
}
|
||||
|
||||
|
|
@ -568,12 +572,16 @@ export class Radial2DController extends Component {
|
|||
[this.t('inspect.outgoing', { count: outgoing.length }), outgoing, 'target'],
|
||||
[this.t('inspect.backlinks', { count: incoming.length }), incoming, 'source'],
|
||||
] as const) {
|
||||
parent.createDiv({ cls: 'mwm-side-heading', text: title });
|
||||
if (edges.length === 0) parent.createDiv({ cls: 'mwm-side-muted', text: this.t('common.none') });
|
||||
const section = parent.createDiv({ cls: 'mwm-neighbor-section' });
|
||||
section.createDiv({ cls: 'mwm-side-heading', text: title });
|
||||
if (edges.length === 0) section.createDiv({ cls: 'mwm-side-muted', text: this.t('common.none') });
|
||||
const list = section.createDiv({ cls: 'mwm-neighbor-list' });
|
||||
for (const edge of edges) {
|
||||
const neighbor = this.index.nodes.get(edge[side]);
|
||||
if (!neighbor) continue;
|
||||
const button = parent.createEl('button', { cls: 'mwm-link-row', text: `${neighbor.title} (${edge.weight})` });
|
||||
const label = `${neighbor.title} (${edge.weight})`;
|
||||
const button = list.createEl('button', { cls: 'mwm-link-row', attr: { title: label } });
|
||||
button.createSpan({ cls: 'mwm-link-row-label', text: label });
|
||||
button.addEventListener('click', () => this.inspectNode(neighbor.id));
|
||||
}
|
||||
}
|
||||
|
|
@ -631,14 +639,18 @@ export class Radial2DController extends Component {
|
|||
else this.selectedPinIds.delete(pin.id);
|
||||
this.renderPanel();
|
||||
});
|
||||
const main = row.createEl('button', { cls: 'mwm-pin-main', attr: { type: 'button', title: pin.path || pin.title } });
|
||||
const main = row.createEl('button', { cls: 'mwm-pin-main', attr: { type: 'button' } });
|
||||
main.addEventListener('click', () => this.locatePin(pin));
|
||||
main.createDiv({ cls: 'mwm-pin-title', text: pin.title });
|
||||
main.createDiv({ cls: 'mwm-pin-meta', text: `${this.pinKindLabel(pin)} - ${pin.path || '/'}` });
|
||||
this.button(row, pin.active ? this.t('pins.hideHighlight') : this.t('pins.showHighlight'), () => this.togglePinHighlight(pin.id));
|
||||
this.button(row, this.t('common.inspect'), () => this.inspectPin(pin));
|
||||
if (pin.groupId) this.button(row, this.t('common.ungroup'), () => this.ungroupPin(pin.id));
|
||||
this.button(row, 'X', () => this.removePin(pin.id));
|
||||
const displayTitle = this.pinDisplayTitle(pin);
|
||||
const displayPath = pin.path || '/';
|
||||
main.setAttr('title', `${displayTitle}\n${this.pinKindLabel(pin)} - ${displayPath}`);
|
||||
main.createDiv({ cls: 'mwm-pin-title', text: displayTitle });
|
||||
main.createDiv({ cls: 'mwm-pin-meta', text: displayPath });
|
||||
const actions = row.createDiv({ cls: 'mwm-pin-actions' });
|
||||
this.button(actions, pin.active ? this.t('pins.hideHighlight') : this.t('pins.showHighlight'), () => this.togglePinHighlight(pin.id));
|
||||
this.button(actions, this.t('common.inspect'), () => this.inspectPin(pin));
|
||||
if (pin.groupId) this.button(actions, this.t('common.ungroup'), () => this.ungroupPin(pin.id));
|
||||
this.button(actions, 'X', () => this.removePin(pin.id));
|
||||
}
|
||||
|
||||
private renderViewPage(parent: HTMLElement): void {
|
||||
|
|
@ -647,9 +659,10 @@ export class Radial2DController extends Component {
|
|||
this.button(row, this.t('common.recenter'), () => this.centerCurrentView());
|
||||
this.button(row, this.t('common.rebuild'), () => this.rebuild('manual'));
|
||||
const row2 = parent.createDiv({ cls: 'galaxy-panel-row' });
|
||||
this.button(row2, this.t('view.atlas'), () => this.resetToAtlas(), this.state.mode === 'atlas');
|
||||
this.button(row2, this.t('view.atlas'), () => this.resetToAtlas(), this.state.mode === 'atlas' && !this.state.showCompleteRoot);
|
||||
this.button(row2, this.t('view.focus'), () => this.focusActiveNote(), this.state.mode === 'focus');
|
||||
this.button(row2, this.t('view.vaultRoot'), () => this.resetToRoot());
|
||||
this.button(row2, this.t('view.completeMap'), () => this.showCompleteMap(), this.state.showCompleteRoot);
|
||||
this.select(parent, this.t('view.theme'), this.radial().colorScheme, colorSchemeOptions(this.settings.language), (value) => {
|
||||
this.radial().colorScheme = normalizeColorScheme(value);
|
||||
this.saveSoon();
|
||||
|
|
@ -707,6 +720,7 @@ export class Radial2DController extends Component {
|
|||
this.state.mode = 'atlas';
|
||||
this.state.focusPath = null;
|
||||
if (node.type === 'folder') {
|
||||
this.leaveCompleteMap();
|
||||
this.state.rootPath = visualId;
|
||||
await this.queueRebuild('root');
|
||||
return;
|
||||
|
|
@ -716,6 +730,7 @@ export class Radial2DController extends Component {
|
|||
this.renderPanel();
|
||||
return;
|
||||
}
|
||||
this.leaveCompleteMap();
|
||||
this.state.rootPath = this.searchAtlasRoot(node, visualId);
|
||||
await this.queueRebuild('root');
|
||||
}
|
||||
|
|
@ -732,6 +747,42 @@ export class Radial2DController extends Component {
|
|||
if (this.renderer?.fitToLayout(rootId)) this.needsFit = false;
|
||||
}
|
||||
|
||||
private showCompleteMap(): void {
|
||||
this.state.showCompleteRoot = true;
|
||||
this.applyCompleteMapState();
|
||||
this.needsFit = true;
|
||||
this.rebuild('complete');
|
||||
}
|
||||
|
||||
private applyCompleteMapState(): void {
|
||||
this.state.mode = 'atlas';
|
||||
this.state.rootPath = ROOT_ID;
|
||||
this.state.focusPath = null;
|
||||
this.state.search = '';
|
||||
this.state.atlasDepth = MAX_ATLAS_DEPTH;
|
||||
this.state.nodeLimit = MAX_RENDER_NODE_LIMIT;
|
||||
this.state.linkLimit = MAX_LINK_LIMIT;
|
||||
this.state.externalLinkAnchorLimit = MAX_EXTERNAL_LINK_ANCHOR_LIMIT;
|
||||
this.state.showLinkOverlay = true;
|
||||
this.state.showExternalLinks = true;
|
||||
this.state.externalDetailMode = 'exact';
|
||||
this.state.hiddenLegendItems = [];
|
||||
}
|
||||
|
||||
private leaveCompleteMap(): void {
|
||||
if (!this.state.showCompleteRoot) return;
|
||||
const radial = this.radial();
|
||||
this.state.showCompleteRoot = false;
|
||||
this.state.atlasDepth = radial.atlasDepth;
|
||||
this.state.nodeLimit = radial.renderNodeLimit;
|
||||
this.state.linkLimit = radial.linkLimit;
|
||||
this.state.externalLinkAnchorLimit = radial.externalLinkAnchorLimit;
|
||||
this.state.showExternalLinks = radial.showExternalLinks;
|
||||
this.state.externalDetailMode = radial.externalDetailMode;
|
||||
this.state.hiddenLegendItems = radial.hiddenLegendItems.slice();
|
||||
this.state.showLinkOverlay = radial.showLinkOverlay || !this.state.hiddenLegendItems.includes('link');
|
||||
}
|
||||
|
||||
private centerNode(nodeId: string): void {
|
||||
const point = this.renderer?.nodePoint(nodeId);
|
||||
const view = this.renderer?.getView();
|
||||
|
|
@ -750,20 +801,24 @@ export class Radial2DController extends Component {
|
|||
private renderControlsPage(parent: HTMLElement): void {
|
||||
const radial = this.radial();
|
||||
this.numberInput(parent, this.t('control.depth'), this.state.atlasDepth, 1, MAX_ATLAS_DEPTH, 1, (value) => {
|
||||
this.leaveCompleteMap();
|
||||
this.state.atlasDepth = value;
|
||||
this.rebuild('depth');
|
||||
});
|
||||
this.numberInput(parent, this.t('control.nodes'), this.state.nodeLimit, 200, MAX_RENDER_NODE_LIMIT, 100, (value) => {
|
||||
this.leaveCompleteMap();
|
||||
this.state.nodeLimit = value;
|
||||
this.rebuild('nodes');
|
||||
});
|
||||
this.numberInput(parent, this.t('control.noteLinks'), this.state.linkLimit, 0, MAX_LINK_LIMIT, 50, (value) => {
|
||||
this.leaveCompleteMap();
|
||||
this.state.linkLimit = value;
|
||||
this.rebuild('links');
|
||||
});
|
||||
const hidden = new Set(radial.hiddenLegendItems);
|
||||
const hidden = new Set(this.state.hiddenLegendItems);
|
||||
const noteLinksVisible = this.state.showLinkOverlay && !hidden.has('link');
|
||||
this.toggle(parent, this.t('control.showNoteLinks'), noteLinksVisible, (value) => {
|
||||
this.leaveCompleteMap();
|
||||
radial.showLinkOverlay = value;
|
||||
this.state.showLinkOverlay = value;
|
||||
this.setLegendHidden('link', !value);
|
||||
|
|
@ -806,6 +861,7 @@ export class Radial2DController extends Component {
|
|||
this.renderPanel();
|
||||
});
|
||||
this.toggle(parent, this.t('control.outsideLinks'), this.state.showExternalLinks, (value) => {
|
||||
this.leaveCompleteMap();
|
||||
this.state.showExternalLinks = value;
|
||||
radial.showExternalLinks = value;
|
||||
this.saveSoon();
|
||||
|
|
@ -817,6 +873,7 @@ export class Radial2DController extends Component {
|
|||
this.state.externalDetailMode,
|
||||
outsideDetailOptions(this.settings.language),
|
||||
(value) => {
|
||||
this.leaveCompleteMap();
|
||||
this.state.externalDetailMode = normalizeExternalDetailMode(value);
|
||||
radial.externalDetailMode = this.state.externalDetailMode;
|
||||
this.saveSoon();
|
||||
|
|
@ -824,6 +881,7 @@ export class Radial2DController extends Component {
|
|||
},
|
||||
);
|
||||
this.numberInput(parent, this.t('control.exactOutsideFiles'), this.state.externalLinkAnchorLimit, 0, MAX_EXTERNAL_LINK_ANCHOR_LIMIT, 50, (value) => {
|
||||
this.leaveCompleteMap();
|
||||
this.state.externalLinkAnchorLimit = value;
|
||||
radial.externalLinkAnchorLimit = value;
|
||||
this.saveSoon();
|
||||
|
|
@ -866,7 +924,7 @@ export class Radial2DController extends Component {
|
|||
|
||||
private renderLegend(parent: HTMLElement): void {
|
||||
parent.createDiv({ cls: 'mwm-side-heading', text: this.t('control.legend') });
|
||||
const hidden = new Set(this.radial().hiddenLegendItems);
|
||||
const hidden = new Set(this.state.hiddenLegendItems);
|
||||
for (const [id, labelKey, titleKey, markerClass] of LEGEND_ITEM_DEFINITIONS) {
|
||||
const row = parent.createEl('label', {
|
||||
cls: hidden.has(id) ? 'mwm-legend-item is-muted' : 'mwm-legend-item',
|
||||
|
|
@ -879,6 +937,7 @@ export class Radial2DController extends Component {
|
|||
text.createSpan({ cls: 'mwm-legend-label', text: this.t(labelKey) });
|
||||
text.createSpan({ cls: 'mwm-legend-desc', text: this.t(titleKey) });
|
||||
checkbox.addEventListener('change', () => {
|
||||
this.leaveCompleteMap();
|
||||
const value = checkbox.checked;
|
||||
if (value) hidden.delete(id);
|
||||
else hidden.add(id);
|
||||
|
|
@ -938,7 +997,8 @@ export class Radial2DController extends Component {
|
|||
}
|
||||
|
||||
private button(parent: HTMLElement, label: string, onClick: () => void, active = false): HTMLButtonElement {
|
||||
const button = parent.createEl('button', { cls: active ? 'is-active' : '', text: label });
|
||||
const button = parent.createEl('button', { cls: active ? 'is-active' : '', attr: { title: label } });
|
||||
button.createSpan({ cls: 'mwm-button-label', text: label });
|
||||
button.addEventListener('click', onClick);
|
||||
return button;
|
||||
}
|
||||
|
|
@ -951,7 +1011,7 @@ export class Radial2DController extends Component {
|
|||
}
|
||||
|
||||
private textArea(parent: HTMLElement, label: string, value: string, onChange: (value: string) => void): void {
|
||||
const field = parent.createEl('label', { cls: 'mwm-panel-field' });
|
||||
const field = parent.createEl('label', { cls: 'mwm-panel-field mwm-panel-field-stack' });
|
||||
field.createSpan({ text: label });
|
||||
const input = field.createEl('textarea');
|
||||
input.value = value;
|
||||
|
|
@ -1110,6 +1170,23 @@ export class Radial2DController extends Component {
|
|||
return pin.kind === 'link' ? this.t('inspect.linkOverlay') : this.t(`hover.${normalizeHoverHighlightMode(pin.mode)}`);
|
||||
}
|
||||
|
||||
private pinDisplayTitle(pin: PinPath): string {
|
||||
if (pin.kind === 'link') {
|
||||
const [source = pin.source ?? '', target = pin.target ?? ''] = pin.path.split(' -> ');
|
||||
const sourceName = this.pathLeaf(source) || this.pathLeaf(pin.source ?? '') || this.t('common.source');
|
||||
const targetName = this.pathLeaf(target) || this.pathLeaf(pin.target ?? '') || this.t('common.target');
|
||||
return `${sourceName} -> ${targetName}`;
|
||||
}
|
||||
return this.pathLeaf(pin.path) || pin.title;
|
||||
}
|
||||
|
||||
private pathLeaf(path: string): string {
|
||||
const clean = path.trim().replace(/[/\\]+$/, '');
|
||||
if (!clean || clean === '/') return '';
|
||||
const leaf = clean.split(/[/\\]/).filter(Boolean).pop() ?? clean;
|
||||
return leaf.endsWith('.md') ? leaf.slice(0, -3) : leaf;
|
||||
}
|
||||
|
||||
private clearDisallowedHoverTargets(): void {
|
||||
const targets = this.hoverTargets();
|
||||
if (!targets.nodes) this.hoverNodeId = null;
|
||||
|
|
@ -1125,6 +1202,7 @@ export class Radial2DController extends Component {
|
|||
}
|
||||
|
||||
private resetToAtlas(): void {
|
||||
this.leaveCompleteMap();
|
||||
this.state.mode = 'atlas';
|
||||
this.state.rootPath = this.state.rootPath || ROOT_ID;
|
||||
this.state.focusPath = null;
|
||||
|
|
@ -1133,6 +1211,7 @@ export class Radial2DController extends Component {
|
|||
}
|
||||
|
||||
private resetToRoot(): void {
|
||||
this.leaveCompleteMap();
|
||||
this.state.mode = 'atlas';
|
||||
this.state.rootPath = ROOT_ID;
|
||||
this.state.focusPath = null;
|
||||
|
|
@ -1150,6 +1229,7 @@ export class Radial2DController extends Component {
|
|||
}
|
||||
|
||||
private useAsRoot(nodeId: string): void {
|
||||
this.leaveCompleteMap();
|
||||
this.state.mode = 'atlas';
|
||||
this.state.rootPath = nodeId;
|
||||
this.state.focusPath = null;
|
||||
|
|
@ -1164,6 +1244,7 @@ export class Radial2DController extends Component {
|
|||
}
|
||||
|
||||
private focusNote(nodeId: string): void {
|
||||
this.leaveCompleteMap();
|
||||
this.state.mode = 'focus';
|
||||
this.state.focusPath = nodeId;
|
||||
this.state.rootPath = ROOT_ID;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
DEFAULT_RADIAL_SETTINGS,
|
||||
MAX_ATLAS_DEPTH,
|
||||
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
|
||||
MAX_LINK_LIMIT,
|
||||
MAX_RENDER_NODE_LIMIT,
|
||||
|
|
@ -57,7 +58,9 @@ export function visualNodeId(model: WorldModel, id: string | null | undefined):
|
|||
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 maxDepth = state.showCompleteRoot
|
||||
? Math.max(MAX_ATLAS_DEPTH, model.stats.maxDepth - rootDepth)
|
||||
: clampNumber(state.atlasDepth, 1, MAX_ATLAS_DEPTH, settings.atlasDepth);
|
||||
const query = normalizedQuery(state.search);
|
||||
const visible = new Set<string>();
|
||||
|
||||
|
|
@ -186,12 +189,14 @@ function aggregateVisibleLinkEdges(
|
|||
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 externalLimit = state.showCompleteRoot
|
||||
? MAX_EXTERNAL_LINK_ANCHOR_LIMIT
|
||||
: 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) {
|
||||
|
|
@ -269,7 +274,9 @@ function applyNodeBudget(
|
|||
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);
|
||||
const limit = state.showCompleteRoot
|
||||
? MAX_RENDER_NODE_LIMIT
|
||||
: clampNumber(state.nodeLimit, 200, MAX_RENDER_NODE_LIMIT, settings.renderNodeLimit);
|
||||
if (nodes.length <= limit) return { nodes, hiddenNodeCount: 0 };
|
||||
|
||||
const candidates = nodes
|
||||
|
|
|
|||
256
styles.css
256
styles.css
|
|
@ -18,6 +18,8 @@
|
|||
/* ---------- 控制面板 ---------- */
|
||||
|
||||
.galaxy-panel {
|
||||
--mwm-action-button-min-height: 40px;
|
||||
--mwm-link-row-min-height: 46px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
|
|
@ -138,6 +140,7 @@
|
|||
.galaxy-panel-body {
|
||||
padding: 0 10px 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
|
@ -163,11 +166,15 @@
|
|||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.galaxy-panel-row button {
|
||||
flex: 1 1 104px;
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1 1 132px;
|
||||
min-height: var(--mwm-action-button-min-height);
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
padding: 5px 8px;
|
||||
|
|
@ -178,8 +185,19 @@
|
|||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.galaxy-panel-row button .mwm-button-label {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.galaxy-panel-row button:hover {
|
||||
|
|
@ -339,98 +357,6 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gx-card {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 6; /* 卡片永远盖住浮层标签(G2 bug 修复) */
|
||||
width: 280px;
|
||||
transform: translate3d(-1000px, -1000px, 0);
|
||||
pointer-events: auto;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
background: rgba(10, 14, 24, 0.78);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #e8ecf6;
|
||||
}
|
||||
|
||||
.gx-daylight .gx-card {
|
||||
background: rgba(252, 250, 245, 0.88);
|
||||
border: 1px solid rgba(31, 41, 51, 0.14);
|
||||
color: #1f2933;
|
||||
}
|
||||
|
||||
.gx-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.gx-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.gx-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gx-card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.gx-card-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
background: rgba(127, 155, 255, 0.18);
|
||||
border: 1px solid rgba(127, 155, 255, 0.3);
|
||||
}
|
||||
|
||||
.gx-card-stats {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.gx-card-snippet {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
opacity: 0.65;
|
||||
max-height: 50px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gx-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.gx-card-actions button {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
border: 1px solid rgba(127, 127, 127, 0.25);
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gx-search-path {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
|
|
@ -556,20 +482,6 @@
|
|||
|
||||
/* ---------- M4 移动端 ---------- */
|
||||
|
||||
/* .gx-mobile .gx-card 比 .gx-card 更具体 → 靠选择器特异性覆盖 left/top/transform
|
||||
(JS 已在移动端清掉内联 transform,见 OverlayManager.setSelection) */
|
||||
.gx-mobile .gx-card {
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: auto;
|
||||
/* 自适应:实测 navbar 重叠(--gx-bottom-inset)与 iPhone 横条安全区取大者 */
|
||||
bottom: calc(10px + max(var(--gx-bottom-inset, 0px), env(safe-area-inset-bottom, 0px)));
|
||||
width: auto;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.gx-mask-btn {
|
||||
font-size: 13px;
|
||||
padding: 8px 18px;
|
||||
|
|
@ -604,7 +516,7 @@
|
|||
--mwm-legend-unresolved-color: #dc4a4a;
|
||||
--mwm-legend-tree-color: #93a3b8;
|
||||
--mwm-legend-link-color: #a2acba;
|
||||
--mwm-legend-external-link-color: #c98135;
|
||||
--mwm-legend-external-link-color: #b8752e;
|
||||
--mwm-floating-bg: rgba(255, 255, 255, 0.82);
|
||||
--mwm-floating-border: rgba(23, 32, 51, 0.14);
|
||||
--mwm-floating-color: #172033;
|
||||
|
|
@ -625,7 +537,7 @@
|
|||
--mwm-legend-unresolved-color: #fb7185;
|
||||
--mwm-legend-tree-color: #818cf8;
|
||||
--mwm-legend-link-color: #94a3b8;
|
||||
--mwm-legend-external-link-color: #fb923c;
|
||||
--mwm-legend-external-link-color: #d97706;
|
||||
--mwm-floating-bg: rgba(10, 14, 24, 0.64);
|
||||
--mwm-floating-border: rgba(255, 255, 255, 0.12);
|
||||
--mwm-floating-color: #e8ecf6;
|
||||
|
|
@ -788,10 +700,11 @@
|
|||
|
||||
.mwm-panel-tabs {
|
||||
gap: 4px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
|
||||
}
|
||||
|
||||
.mwm-panel-tabs button {
|
||||
flex: 1 1 86px;
|
||||
min-height: 28px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
|
|
@ -803,8 +716,9 @@
|
|||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.mwm-panel-tabs button.is-active,
|
||||
|
|
@ -830,8 +744,8 @@
|
|||
.mwm-panel-field,
|
||||
.mwm-panel-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(88px, 0.42fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
|
@ -839,11 +753,17 @@
|
|||
.mwm-panel-toggle {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mwm-panel-field-stack {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mwm-panel-field > span,
|
||||
.mwm-panel-toggle > span {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
|
|
@ -1015,10 +935,24 @@
|
|||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.mwm-neighbor-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mwm-neighbor-list {
|
||||
display: grid;
|
||||
grid-auto-rows: minmax(var(--mwm-link-row-min-height), 1fr);
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mwm-pin-group {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mwm-pin-group-header {
|
||||
|
|
@ -1068,6 +1002,7 @@
|
|||
.mwm-link-row,
|
||||
.mwm-pin-main {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
|
|
@ -1081,33 +1016,91 @@
|
|||
box-shadow: none;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mwm-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--mwm-link-row-min-height);
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.mwm-link-row-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.mwm-pin-row,
|
||||
.mwm-pin-group-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mwm-pin-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
.mwm-pin-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mwm-pin-group-row input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mwm-pin-row.is-muted {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.mwm-pin-row > button:not(.mwm-pin-main),
|
||||
.mwm-pin-group-row > button,
|
||||
.mwm-pin-group-header > button {
|
||||
flex: 0 0 auto;
|
||||
.mwm-pin-group-header > button,
|
||||
.mwm-pin-actions > button {
|
||||
font-size: 10px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.mwm-pin-check {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.mwm-pin-main {
|
||||
flex: 1;
|
||||
flex-basis: 150px;
|
||||
display: block;
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mwm-pin-actions {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mwm-pin-actions > button {
|
||||
flex: 1 1 72px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mwm-pin-title {
|
||||
|
|
@ -1117,6 +1110,23 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mwm-pin-meta {
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.mwm-scroll-path {
|
||||
max-height: 56px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.mwm-floating-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
|
|
|
|||
|
|
@ -155,6 +155,44 @@ describe('visible world graph', () => {
|
|||
expect(graph.focusId).toBe('Atlas/Topic A.md');
|
||||
expect(graph.nodesById.has('Atlas/Sub/Topic B.md')).toBe(true);
|
||||
});
|
||||
|
||||
it('expands the complete root atlas across depth and link budgets', () => {
|
||||
const deepRecords: { path: string; basename: string; kind: 'folder' | 'note' }[] = [
|
||||
{ path: 'Deep', basename: 'Deep', kind: 'folder' },
|
||||
];
|
||||
let current = 'Deep';
|
||||
for (let index = 0; index < 8; index++) {
|
||||
current = `${current}/Layer ${index}`;
|
||||
deepRecords.push({ path: current, basename: `Layer ${index}`, kind: 'folder' });
|
||||
}
|
||||
const deepNote = `${current}/Deep.md`;
|
||||
deepRecords.push({ path: deepNote, basename: 'Deep', kind: 'note' });
|
||||
for (let index = 0; index < 4; index++) {
|
||||
deepRecords.push({ path: `Note ${index}.md`, basename: `Note ${index}`, kind: 'note' });
|
||||
}
|
||||
const resolvedLinks = {
|
||||
[deepNote]: Object.fromEntries(Array.from({ length: 4 }, (_, index) => [`Note ${index}.md`, 1])),
|
||||
};
|
||||
const m = buildWorldMap(deepRecords, resolvedLinks, {}, DEFAULT_RADIAL_SETTINGS);
|
||||
const limitedState = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
|
||||
limitedState.atlasDepth = 1;
|
||||
limitedState.linkLimit = 1;
|
||||
limitedState.showLinkOverlay = true;
|
||||
const limited = buildVisibleWorldGraph(m, limitedState, DEFAULT_RADIAL_SETTINGS);
|
||||
|
||||
const completeState = defaultVisibleGraphState(DEFAULT_RADIAL_SETTINGS);
|
||||
completeState.atlasDepth = 1;
|
||||
completeState.linkLimit = 1;
|
||||
completeState.showLinkOverlay = true;
|
||||
completeState.showCompleteRoot = true;
|
||||
const complete = buildVisibleWorldGraph(m, completeState, DEFAULT_RADIAL_SETTINGS);
|
||||
|
||||
expect(limited.nodesById.has(deepNote)).toBe(false);
|
||||
expect(limited.linkEdges).toHaveLength(1);
|
||||
expect(complete.nodesById.has(deepNote)).toBe(true);
|
||||
expect(complete.hiddenNodeCount).toBe(0);
|
||||
expect(complete.linkEdges).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radial layout', () => {
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@
|
|||
"0.1.3": "1.5.0",
|
||||
"0.2.0": "1.5.0",
|
||||
"0.2.1": "1.5.0",
|
||||
"0.2.2": "1.13.0"
|
||||
"0.2.2": "1.13.0",
|
||||
"0.2.3": "1.13.0",
|
||||
"0.2.4": "1.13.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue