mirror of
https://github.com/miro0o/miniWorldMap.git
synced 2026-07-22 07:46:00 +00:00
Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a64e8b7 | ||
|
|
91d48e372a | ||
|
|
f6cdeb16c6 | ||
|
|
53cc944d25 |
21 changed files with 837 additions and 683 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
385
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
|||
298
src/main.ts
298
src/main.ts
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
261
styles.css
261
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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue