Compare commits

...

4 commits
0.2.0 ... main

Author SHA1 Message Date
Kur0mi
4c0a64e8b7 Release 0.2.4 2026-06-25 10:26:21 +02:00
Kur0mi
91d48e372a Release 0.2.3 2026-06-24 23:48:22 +02:00
Kur0mi
f6cdeb16c6 Release 0.2.2 2026-06-24 22:31:25 +02:00
Kur0mi
53cc944d25 Release 0.2.1 2026-06-24 15:39:05 +02:00
21 changed files with 837 additions and 683 deletions

View file

@ -1,5 +1,30 @@
# 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.
- Raise the minimum supported Obsidian version to 1.13.0 for the current settings definitions API.
- Remove reveal CSS that relied on partially supported browser features in older Obsidian builds.
- Tighten 2D neighbor list typing and add regression coverage for configured vault folders.
## 0.2.1
- Add default day/night map background fallbacks when the active Obsidian theme cannot provide a matching background color.
- Refresh explicit 3D day/night backgrounds after Obsidian CSS changes.
## 0.2.0
- Add the Galaxy-derived 3D map mode based on [Longwind1984/galaxy-view](https://github.com/Longwind1984/galaxy-view).

View file

@ -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.

385
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,10 +1,10 @@
{
"id": "mini-world-map",
"name": "Mini World Map",
"version": "0.2.0",
"minAppVersion": "1.5.0",
"description": "Visualize your vault as a hierarchy-first world map with internal links layered on top.",
"author": "Miro0o and Codex",
"authorUrl": "https://github.com/Miro0o",
"isDesktopOnly": false
}
"id": "mini-world-map",
"name": "Mini World Map",
"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",
"authorUrl": "https://github.com/Miro0o",
"isDesktopOnly": false
}

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "mini-world-map",
"version": "0.2.0",
"version": "0.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mini-world-map",
"version": "0.2.0",
"version": "0.2.4",
"license": "MIT",
"dependencies": {
"3d-force-graph": "1.80.0",

View file

@ -1,6 +1,6 @@
{
"name": "mini-world-map",
"version": "0.2.0",
"version": "0.2.4",
"description": "Hierarchy-first 2D radial rings and 3D map views for your Obsidian vault",
"main": "main.js",
"type": "module",

View file

@ -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} 出链' },
@ -300,7 +305,8 @@ export function colorSchemeOptions(language: Language): [ColorScheme, string][]
];
}
export function languageOptions(_language: Language): [Language, string][] {
export function languageOptions(language: Language): [Language, string][] {
void language;
return LANGUAGE_OPTIONS.map(([value, label]) => [value, label]);
}

View file

@ -1,4 +1,4 @@
import { Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
import { Notice, Plugin, PluginSettingTab, Setting, type App, type SettingDefinitionItem, type SettingDefinitionRender } from 'obsidian';
import { VIEW_TYPE_MINI_WORLD_MAP } from './constants';
import type { Language, MiniWorldMapSettings, ViewMode } from './settings';
import {
@ -10,6 +10,7 @@ import {
MAX_LINK_LIMIT,
MAX_RENDER_NODE_LIMIT,
MAX_SWIRL_STRENGTH,
applyVaultConfigDirDefault,
clampNumber,
mergeSettings,
normalizeHoverHighlightMode,
@ -26,7 +27,8 @@ export default class MiniWorldMapPlugin extends Plugin {
async onload(): Promise<void> {
console.info('[Mini World Map] loading');
this.settings = mergeSettings(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));
this.addRibbonIcon('network', 'Open Mini World Map', () => {
@ -35,13 +37,13 @@ export default class MiniWorldMapPlugin extends Plugin {
this.addCommand({
id: 'open-map',
name: 'Open Mini World Map',
name: 'Open map',
callback: () => void this.activateView(),
});
this.addCommand({
id: 'toggle-render-mode',
name: 'Toggle Mini World Map render mode',
name: 'Toggle render mode',
callback: () => {
this.setViewMode(this.settings.viewMode === 'radial2d' ? 'map3d' : 'radial2d');
void this.activateView();
@ -50,7 +52,7 @@ export default class MiniWorldMapPlugin extends Plugin {
this.addCommand({
id: 'rebuild-index',
name: 'Rebuild Mini World Map index',
name: 'Rebuild index',
callback: () => {
let rebuilt = false;
for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_MINI_WORLD_MAP)) {
@ -66,7 +68,7 @@ export default class MiniWorldMapPlugin extends Plugin {
this.addCommand({
id: 'search-3d',
name: 'Search Mini World Map',
name: 'Search map',
callback: () => {
void this.activateView().then((view) => {
const controller = view?.controller;
@ -119,167 +121,155 @@ export default class MiniWorldMapPlugin extends Plugin {
class MiniWorldMapSettingTab extends PluginSettingTab {
constructor(
app: import('obsidian').App,
app: App,
private plugin: MiniWorldMapPlugin,
) {
super(app, plugin);
}
display(): void {
const { containerEl } = this;
getSettingDefinitions(): SettingDefinitionItem[] {
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();
return [
{
type: 'group',
heading: t(language, 'settings.title'),
items: [
this.renderSetting(t(language, 'language'), t(language, 'settings.languageDesc'), (setting) => {
setting.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.update();
});
});
}),
);
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();
this.renderSetting(t(language, 'settings.defaultMode'), t(language, 'settings.defaultModeDesc'), (setting) => {
setting.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.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();
this.renderSetting(t(language, 'control.defaultDepth'), t(language, 'settings.depthDesc'), (setting) => {
setting.addSlider((slider) =>
slider
.setLimits(1, 20, 1)
.setValue(radial.atlasDepth)
.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.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();
this.renderSetting(t(language, 'control.defaultNodes'), t(language, 'settings.nodeLimitDesc'), (setting) => {
setting.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.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();
this.renderSetting(t(language, 'control.defaultNoteLinks'), t(language, 'settings.linkLimitDesc'), (setting) => {
setting.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();
}),
);
}),
);
this.renderSetting(t(language, 'control.showNoteLinks'), t(language, 'settings.showLinksDesc'), (setting) => {
setting.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();
}),
);
}),
this.renderSetting(t(language, 'control.hover'), t(language, 'settings.hoverDesc'), (setting) => {
setting.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();
});
});
}),
this.renderSetting(t(language, 'control.hoverTargets'), t(language, 'settings.hoverTargetsDesc'), (setting) => {
setting.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();
});
});
}),
this.renderSetting(t(language, 'control.labels'), t(language, 'settings.labelsDesc'), (setting) => {
setting.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();
});
});
}),
this.renderSetting(t(language, 'control.spin'), t(language, 'settings.spinDesc'), (setting) => {
setting.addSlider((slider) =>
slider
.setLimits(0, MAX_SWIRL_STRENGTH, 1)
.setValue(radial.swirlStrength)
.onChange(async (value) => {
radial.swirlStrength = clampNumber(value, 0, MAX_SWIRL_STRENGTH, DEFAULT_RADIAL_SETTINGS.swirlStrength);
await this.plugin.saveSettings();
}),
);
}),
this.renderSetting(t(language, 'control.unresolvedLinks'), t(language, 'settings.unresolvedDesc'), (setting) => {
setting.addToggle((toggle) =>
toggle.setValue(radial.includeUnresolvedLinks).onChange(async (value) => {
radial.includeUnresolvedLinks = value;
await this.plugin.saveSettings();
}),
);
}),
this.renderSetting(t(language, 'control.ignoredFolders'), t(language, 'settings.ignoredDesc'), (setting) => {
setting.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();
}),
);
}),
],
},
];
}
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();
}),
);
private renderSetting(name: string, desc: string, render: (setting: Setting) => void): SettingDefinitionRender {
return {
name,
desc,
render,
};
}
}

View file

@ -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');

View file

@ -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();
}

View file

@ -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,
@ -236,9 +237,6 @@ export class RadialRenderer {
showLoadingMask(text: string): void {
cancelAnimationFrame(this.revealFrame);
this.revealDepthLimit = Number.POSITIVE_INFINITY;
this.container.style.setProperty('--mwm-reveal-x', '50%');
this.container.style.setProperty('--mwm-reveal-y', '50%');
this.container.style.setProperty('--mwm-reveal-radius', '0px');
this.container.removeClass('is-radial-revealing');
this.container.removeClass('is-radial-depth-revealing');
this.container.addClass('is-radial-preparing');

View file

@ -1,6 +1,11 @@
export type ObsidianThemeScheme = 'day' | 'night';
export function resolveObsidianBackground(scheme: ObsidianThemeScheme, fallback: string | number): string {
export const DEFAULT_MAP_BACKGROUNDS: Record<ObsidianThemeScheme, string> = {
day: '#f8fafc',
night: '#1e1e1e',
};
export function resolveObsidianBackground(scheme: ObsidianThemeScheme, fallback: string | number = DEFAULT_MAP_BACKGROUNDS[scheme]): string {
const sampled = sampleObsidianBackground(scheme);
if (sampled && fitsScheme(sampled, scheme)) return sampled;
return colorFallbackToCss(fallback);
@ -30,9 +35,8 @@ function normalizeCssColor(value: string): string | null {
const doc = activeDocument;
const body = doc.body;
const probe = doc.createElement('span');
probe.style.color = value;
probe.setCssStyles({ color: value, display: 'none' });
if (!probe.style.color) return null;
probe.style.display = 'none';
body.appendChild(probe);
try {
const computed = doc.defaultView?.getComputedStyle(probe).color ?? getComputedStyle(probe).color;
@ -55,16 +59,18 @@ function fitsScheme(color: string, scheme: ObsidianThemeScheme): boolean {
}
function rgbStringToHex(value: string): string | null {
if (value.trim().toLowerCase() === 'transparent') return null;
const match = value.match(/^rgba?\((.*)\)$/i);
if (!match) return expandHex(value);
const parts = match[1]
?.replaceAll(',', ' ')
.split(/[ /]+/)
.map((part) => part.trim())
.filter(Boolean)
.slice(0, 3);
.filter(Boolean);
if (!parts || parts.length < 3) return null;
const channels = parts.map(parseChannel);
const alpha = parts[3] === undefined ? 1 : parseAlpha(parts[3]);
if (alpha === null || alpha <= 0.05) return null;
const channels = parts.slice(0, 3).map(parseChannel);
if (channels.some((channel) => channel === null)) return null;
return `#${channels.map((channel) => channel!.toString(16).padStart(2, '0')).join('')}`;
}
@ -86,10 +92,23 @@ function parseChannel(value: string): number | null {
return Number.isFinite(channel) ? clampByte(channel) : null;
}
function parseAlpha(value: string): number | null {
if (value.endsWith('%')) {
const percent = Number.parseFloat(value);
return Number.isFinite(percent) ? clampUnit(percent / 100) : null;
}
const alpha = Number.parseFloat(value);
return Number.isFinite(alpha) ? clampUnit(alpha) : null;
}
function clampByte(value: number): number {
return Math.min(255, Math.max(0, Math.round(value)));
}
function clampUnit(value: number): number {
return Math.min(1, Math.max(0, value));
}
function hexToRgb(value: string): { r: number; g: number; b: number } | null {
const hex = expandHex(value);
if (!hex) return null;

View file

@ -141,7 +141,7 @@ export const DEFAULT_RADIAL_SETTINGS: RadialSettings = {
swirlStrength: 0,
showRingGuides: false,
hiddenLegendItems: [],
ignoreFolders: ['.git', '.obsidian'],
ignoreFolders: ['.git'],
};
export const DEFAULT_GALAXY_SETTINGS: GalaxySettings = {
@ -181,6 +181,15 @@ export function mergeSettings(saved: unknown): MiniWorldMapSettings {
};
}
export function applyVaultConfigDirDefault(settings: MiniWorldMapSettings, saved: unknown, configDir: string): MiniWorldMapSettings {
if (hasSavedIgnoreFolders(saved)) return settings;
const normalized = normalizeIgnoredFolder(configDir);
if (normalized && !settings.radial.ignoreFolders.includes(normalized)) {
settings.radial.ignoreFolders = [...settings.radial.ignoreFolders, normalized];
}
return settings;
}
export function mergeRadialSettings(saved: unknown): RadialSettings {
const s = isRecord(saved) ? saved : {};
const d = DEFAULT_RADIAL_SETTINGS;
@ -348,6 +357,19 @@ function finiteNumber(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : Number.parseFloat(String(value)) || fallback;
}
function hasSavedIgnoreFolders(saved: unknown): boolean {
const raw = isRecord(saved) ? saved : {};
const radial = isRecord(raw['radial']) ? raw['radial'] : raw;
return Array.isArray(radial['ignoreFolders']);
}
function normalizeIgnoredFolder(path: string): string {
return path
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

View file

@ -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); // 变化时触发重建
@ -394,7 +391,7 @@ export class GraphController {
/** workspace css-change由视图转发 */
onCssChange(): void {
if (this.settings.preset === 'auto') this.applyPreset();
if (this.settings.preset !== 'deep-space') this.applyPreset();
}
private resolveVisualTokens(): VisualTokens {
@ -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);
});

View file

@ -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;

View file

@ -2,7 +2,7 @@ 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 type { LinkTable, VisibleGraphState, VisibleWorldGraph, WorldEdge, WorldFileRecord, WorldModel, WorldNode } from './types';
import { ROOT_ID } from './types';
import { buildVisibleWorldGraph, visualNodeId } from './visibleGraph';
@ -35,12 +35,12 @@ export class WorldMapIndex {
};
}
get linkEdgesBySource() {
return this.model?.linkEdgesBySource ?? new Map();
get linkEdgesBySource(): Map<string, WorldEdge[]> {
return this.model?.linkEdgesBySource ?? new Map<string, WorldEdge[]>();
}
get linkEdgesByTarget() {
return this.model?.linkEdgesByTarget ?? new Map();
get linkEdgesByTarget(): Map<string, WorldEdge[]> {
return this.model?.linkEdgesByTarget ?? new Map<string, WorldEdge[]>();
}
rebuild(settings: RadialSettings = this.settings): void {

View file

@ -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

View file

@ -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;
@ -1147,11 +1157,6 @@
height: 16px;
}
.mwm-radial-host.is-radial-revealing .mwm-radial-canvas,
.mwm-radial-host.is-radial-revealing .mwm-radial-labels {
clip-path: circle(var(--mwm-reveal-radius, 0px) at var(--mwm-reveal-x, 50%) var(--mwm-reveal-y, 50%));
}
.mwm-radial-host.is-radial-preparing .mwm-radial-canvas,
.mwm-radial-host.is-radial-preparing .mwm-radial-labels {
opacity: 0;

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { mergeSettings } from '../src/settings';
import { applyVaultConfigDirDefault, mergeSettings } from '../src/settings';
describe('Mini World Map settings migration', () => {
it('defaults to 2D radial mode and preserves legacy radial keys', () => {
@ -33,6 +33,14 @@ describe('Mini World Map settings migration', () => {
expect(mergeSettings({ radial: { hoverTargetMode: 'anything' } }).radial.hoverTargetMode).toBe('nodes');
});
it('adds the current vault config folder to default ignored folders', () => {
const settings = applyVaultConfigDirDefault(mergeSettings({}), {}, '.custom-config');
expect(settings.radial.ignoreFolders).toContain('.custom-config');
const explicit = applyVaultConfigDirDefault(mergeSettings({ radial: { ignoreFolders: ['Archive'] } }), { radial: { ignoreFolders: ['Archive'] } }, '.custom-config');
expect(explicit.radial.ignoreFolders).toEqual(['Archive']);
});
it('preserves intentional nested 2D note-link visibility', () => {
expect(mergeSettings({ radial: { showLinkOverlay: false } }).radial.showLinkOverlay).toBe(false);
});

View file

@ -16,7 +16,7 @@ const records = [
];
function model(overrides: Partial<typeof DEFAULT_RADIAL_SETTINGS> = {}) {
const settings = { ...DEFAULT_RADIAL_SETTINGS, ...overrides };
const settings = { ...DEFAULT_RADIAL_SETTINGS, ignoreFolders: [...DEFAULT_RADIAL_SETTINGS.ignoreFolders, '.obsidian'], ...overrides };
return buildWorldMap(
records,
{
@ -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', () => {

View file

@ -3,5 +3,9 @@
"0.1.1": "1.5.0",
"0.1.2": "1.5.0",
"0.1.3": "1.5.0",
"0.2.0": "1.5.0"
"0.2.0": "1.5.0",
"0.2.1": "1.5.0",
"0.2.2": "1.13.0",
"0.2.3": "1.13.0",
"0.2.4": "1.13.0"
}