mirror of
https://github.com/miro0o/miniWorldMap.git
synced 2026-07-22 07:46:00 +00:00
Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0a64e8b7 | ||
|
|
91d48e372a |
17 changed files with 444 additions and 341 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -1,5 +1,18 @@
|
||||||
# Changelog
|
# 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
|
## 0.2.2
|
||||||
|
|
||||||
- Address Obsidian community plugin review findings around settings APIs, command names, deprecated slider tooltips, direct static styles, and config folder handling.
|
- Address Obsidian community plugin review findings around settings APIs, command names, deprecated slider tooltips, direct static styles, and config folder handling.
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ Global plugin settings include:
|
||||||
- 2D unresolved link handling.
|
- 2D unresolved link handling.
|
||||||
- Ignored folders.
|
- 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.
|
The 3D view panel includes controls for search, recentering, reveal animation, visual style presets, theme presets, color themes, imported Obsidian graph colors, node sizing, bloom, physics, cruise motion, unresolved links, orphan nodes, quality tier, and default reset.
|
||||||
|
|
||||||
|
|
|
||||||
66
main.js
66
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "mini-world-map",
|
"id": "mini-world-map",
|
||||||
"name": "Mini World Map",
|
"name": "Mini World Map",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"minAppVersion": "1.13.0",
|
"minAppVersion": "1.13.0",
|
||||||
"description": "Visualize your vault as a hierarchy-first world map with internal links layered on top.",
|
"description": "Visualize your vault as a hierarchy-first world map with internal links layered on top.",
|
||||||
"author": "Miro0o and Codex",
|
"author": "Miro0o and Codex",
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "mini-world-map",
|
"name": "mini-world-map",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mini-world-map",
|
"name": "mini-world-map",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"3d-force-graph": "1.80.0",
|
"3d-force-graph": "1.80.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "mini-world-map",
|
"name": "mini-world-map",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"description": "Hierarchy-first 2D radial rings and 3D map views for your Obsidian vault",
|
"description": "Hierarchy-first 2D radial rings and 3D map views for your Obsidian vault",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ const STRINGS: Record<string, Record<Language, string>> = {
|
||||||
'view.atlas': { en: 'Atlas', zh: '图谱' },
|
'view.atlas': { en: 'Atlas', zh: '图谱' },
|
||||||
'view.focus': { en: 'Focus', zh: '聚焦' },
|
'view.focus': { en: 'Focus', zh: '聚焦' },
|
||||||
'view.vaultRoot': { en: 'Vault root', zh: '库根目录' },
|
'view.vaultRoot': { en: 'Vault root', zh: '库根目录' },
|
||||||
|
'view.completeMap': { en: 'Full map', zh: '完整地图' },
|
||||||
'view.mode': { en: 'Map mode', zh: '地图模式' },
|
'view.mode': { en: 'Map mode', zh: '地图模式' },
|
||||||
'view.theme': { en: 'Theme', zh: '主题' },
|
'view.theme': { en: 'Theme', zh: '主题' },
|
||||||
'theme.auto': { en: 'System', zh: '跟随系统' },
|
'theme.auto': { en: 'System', zh: '跟随系统' },
|
||||||
|
|
@ -70,6 +71,8 @@ const STRINGS: Record<string, Record<Language, string>> = {
|
||||||
'inspect.type': { en: 'Type', zh: '类型' },
|
'inspect.type': { en: 'Type', zh: '类型' },
|
||||||
'inspect.depth': { en: 'Depth', zh: '深度' },
|
'inspect.depth': { en: 'Depth', zh: '深度' },
|
||||||
'inspect.notes': { en: 'Notes', zh: '笔记' },
|
'inspect.notes': { en: 'Notes', zh: '笔记' },
|
||||||
|
'inspect.folder': { en: 'Folder', zh: '文件夹' },
|
||||||
|
'inspect.modified': { en: 'Modified', zh: '修改时间' },
|
||||||
'inspect.out': { en: 'Out', zh: '出链' },
|
'inspect.out': { en: 'Out', zh: '出链' },
|
||||||
'inspect.in': { en: 'In', zh: '入链' },
|
'inspect.in': { en: 'In', zh: '入链' },
|
||||||
'inspect.linkOverlay': { en: 'Note link', 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.searchPlaceholder': { en: 'Search notes, press Enter to fly…', zh: '搜索笔记,回车飞过去…' },
|
||||||
'3d.searchUnresolved': { en: 'Unresolved', zh: '未解析' },
|
'3d.searchUnresolved': { en: 'Unresolved', zh: '未解析' },
|
||||||
'3d.searchLinks': { en: '{count} links', zh: '{count} 链接' },
|
'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.unresolved': { en: 'Unresolved link (note does not exist)', zh: '未解析链接(笔记尚不存在)' },
|
||||||
'3d.card.root': { en: 'Vault root', zh: '根目录' },
|
'3d.card.root': { en: 'Vault root', zh: '根目录' },
|
||||||
'3d.card.stats': { en: '↩ {in} backlinks · → {out} outgoing', zh: '↩ {in} 反链 · → {out} 出链' },
|
'3d.card.stats': { en: '↩ {in} backlinks · → {out} outgoing', zh: '↩ {in} 反链 · → {out} 出链' },
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export default class MiniWorldMapPlugin extends Plugin {
|
||||||
|
|
||||||
async onload(): Promise<void> {
|
async onload(): Promise<void> {
|
||||||
console.info('[Mini World Map] loading');
|
console.info('[Mini World Map] loading');
|
||||||
const saved = await this.loadData();
|
const saved: unknown = await this.loadData();
|
||||||
this.settings = applyVaultConfigDirDefault(mergeSettings(saved), saved, this.app.vault.configDir);
|
this.settings = applyVaultConfigDirDefault(mergeSettings(saved), saved, this.app.vault.configDir);
|
||||||
this.registerView(VIEW_TYPE_MINI_WORLD_MAP, (leaf) => new MiniWorldMapView(leaf, this));
|
this.registerView(VIEW_TYPE_MINI_WORLD_MAP, (leaf) => new MiniWorldMapView(leaf, this));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,18 @@ import type { VisualTokens } from '../render/presets';
|
||||||
import { t, viewModeLabel } from '../i18n';
|
import { t, viewModeLabel } from '../i18n';
|
||||||
import { Slider } from './Slider';
|
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 {
|
export interface ControlPanelCallbacks {
|
||||||
onViewMode?: (mode: ViewMode) => void;
|
onViewMode?: (mode: ViewMode) => void;
|
||||||
|
|
@ -29,6 +40,8 @@ export interface ControlPanelCallbacks {
|
||||||
onSizeBy: () => void;
|
onSizeBy: () => void;
|
||||||
onQuality: () => void;
|
onQuality: () => void;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
|
onOpenSelected: () => void;
|
||||||
|
onFocusSelected: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
runScenario: (s: 'S1' | 'S2' | 'S3') => void;
|
runScenario: (s: 'S1' | 'S2' | 'S3') => void;
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +59,7 @@ export class ControlPanel {
|
||||||
private sizeBySelect: HTMLSelectElement | null = null;
|
private sizeBySelect: HTMLSelectElement | null = null;
|
||||||
private qualityBtn: HTMLButtonElement | null = null;
|
private qualityBtn: HTMLButtonElement | null = null;
|
||||||
private styleChips: HTMLButtonElement[] = [];
|
private styleChips: HTMLButtonElement[] = [];
|
||||||
|
private inspectNode: PanelInspectNode | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
parent: HTMLElement,
|
parent: HTMLElement,
|
||||||
|
|
@ -87,6 +101,7 @@ export class ControlPanel {
|
||||||
const settings = this.body.createDiv({ cls: 'mwm-panel-settings mwm-3d-settings' });
|
const settings = this.body.createDiv({ cls: 'mwm-panel-settings mwm-3d-settings' });
|
||||||
const tabs = settings.createDiv({ cls: 'mwm-panel-tabs' });
|
const tabs = settings.createDiv({ cls: 'mwm-panel-tabs' });
|
||||||
for (const [id, label] of [
|
for (const [id, label] of [
|
||||||
|
['inspect', this.tt('tab.inspect')],
|
||||||
['view', this.tt('tab.view')],
|
['view', this.tt('tab.view')],
|
||||||
['appearance', this.tt('tab.appearance')],
|
['appearance', this.tt('tab.appearance')],
|
||||||
['physics', this.tt('tab.physics')],
|
['physics', this.tt('tab.physics')],
|
||||||
|
|
@ -101,13 +116,43 @@ export class ControlPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageEl = settings.createDiv({ cls: 'mwm-panel-page' });
|
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 === 'physics') this.renderPhysicsPage(pageEl);
|
||||||
else if (this.page === 'motion') this.renderMotionPage(pageEl);
|
else if (this.page === 'motion') this.renderMotionPage(pageEl);
|
||||||
else if (this.page === 'advanced') this.renderAdvancedPage(pageEl);
|
else if (this.page === 'advanced') this.renderAdvancedPage(pageEl);
|
||||||
else this.renderViewPage(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 {
|
private renderViewPage(parent: HTMLElement): void {
|
||||||
const row = parent.createDiv({ cls: 'galaxy-panel-row' });
|
const row = parent.createDiv({ cls: 'galaxy-panel-row' });
|
||||||
const searchBtn = row.createEl('button', { text: this.tt('common.search') });
|
const searchBtn = row.createEl('button', { text: this.tt('common.search') });
|
||||||
|
|
@ -344,6 +389,12 @@ export class ControlPanel {
|
||||||
this.render();
|
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 {
|
setPanelTheme(cls: VisualTokens['panelClass']): void {
|
||||||
this.root.removeClass('gx-theme-space');
|
this.root.removeClass('gx-theme-space');
|
||||||
this.root.removeClass('gx-theme-night');
|
this.root.removeClass('gx-theme-night');
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,8 @@
|
||||||
import type { App } from 'obsidian';
|
import type { GraphData } from '../types';
|
||||||
import { TFile, getAllTags } from 'obsidian';
|
|
||||||
import type { Language } from '../settings';
|
|
||||||
import type { GraphData, GraphNode } from '../types';
|
|
||||||
import type { AggregateRenderer } from '../render/AggregateRenderer';
|
import type { AggregateRenderer } from '../render/AggregateRenderer';
|
||||||
import { t } from '../i18n';
|
|
||||||
|
|
||||||
export interface OverlayCallbacks {
|
|
||||||
openNote: (id: string) => void;
|
|
||||||
focusNode: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DOM 浮层(NASA 模式:标签和卡片不进画布)。
|
* DOM labels for hub, hover, and selected-neighbor names.
|
||||||
* 硬预算:枢纽 14 + hover 1 + 邻居 ≤20 + 卡片 1 —— 每帧 ≤36 次投影,可忽略。
|
|
||||||
*/
|
*/
|
||||||
export class OverlayManager {
|
export class OverlayManager {
|
||||||
private root: HTMLElement;
|
private root: HTMLElement;
|
||||||
|
|
@ -20,42 +10,23 @@ export class OverlayManager {
|
||||||
private neighborEls: { index: number; el: HTMLElement }[] = [];
|
private neighborEls: { index: number; el: HTMLElement }[] = [];
|
||||||
private hoverEl: HTMLElement;
|
private hoverEl: HTMLElement;
|
||||||
private hoverIndex = -1;
|
private hoverIndex = -1;
|
||||||
private card: HTMLElement;
|
|
||||||
private cardIndex = -1;
|
|
||||||
private data: GraphData = { nodes: [], links: [] };
|
private data: GraphData = { nodes: [], links: [] };
|
||||||
private graphRadius = 200;
|
private graphRadius = 200;
|
||||||
private snippetToken = 0;
|
|
||||||
private hubBudget = 14;
|
private hubBudget = 14;
|
||||||
private neighborBudget = 20;
|
private neighborBudget = 20;
|
||||||
private mobileCard = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
parent: HTMLElement,
|
parent: HTMLElement,
|
||||||
private app: App,
|
|
||||||
private renderer: AggregateRenderer,
|
private renderer: AggregateRenderer,
|
||||||
private cb: OverlayCallbacks,
|
|
||||||
private language: Language = 'en',
|
|
||||||
) {
|
) {
|
||||||
this.root = parent.createDiv({ cls: 'gx-overlay' });
|
this.root = parent.createDiv({ cls: 'gx-overlay' });
|
||||||
this.hoverEl = this.root.createDiv({ cls: 'gx-label gx-label-hover' });
|
this.hoverEl = this.root.createDiv({ cls: 'gx-label gx-label-hover' });
|
||||||
this.hoverEl.hide();
|
this.hoverEl.hide();
|
||||||
this.card = this.root.createDiv({ cls: 'gx-card' });
|
|
||||||
this.card.hide();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLanguage(language: Language): void {
|
setBudgets(hub: number, neighbor: number): void {
|
||||||
this.language = language;
|
|
||||||
if (this.cardIndex >= 0) {
|
|
||||||
const node = this.data.nodes[this.cardIndex];
|
|
||||||
if (node) this.buildCard(node, this.cardIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 质量档位预算;卡片切底部抽屉模式(移动端) */
|
|
||||||
setBudgets(hub: number, neighbor: number, mobileCard: boolean): void {
|
|
||||||
this.hubBudget = hub;
|
this.hubBudget = hub;
|
||||||
this.neighborBudget = neighbor;
|
this.neighborBudget = neighbor;
|
||||||
this.mobileCard = mobileCard;
|
|
||||||
this.setData(this.data, this.graphRadius);
|
this.setData(this.data, this.graphRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,31 +59,11 @@ export class OverlayManager {
|
||||||
this.hoverEl.show();
|
this.hoverEl.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Selected node: neighbor labels only; details render in the left panel. */
|
||||||
* 自适应底部留白(M4.1):实测 .mobile-navbar 与画布的重叠像素。
|
|
||||||
* 官方未暴露 navbar 高度变量,且平板/隐藏设置/安卓变体下可能不存在——
|
|
||||||
* 运行时测量在所有形态下自适应:无 navbar 时为 0,不会多出空白。
|
|
||||||
*/
|
|
||||||
private refreshBottomInset(): void {
|
|
||||||
let inset = 0;
|
|
||||||
const navbar = activeDocument.querySelector('.mobile-navbar');
|
|
||||||
if (navbar) {
|
|
||||||
const nb = navbar.getBoundingClientRect();
|
|
||||||
const ce = this.root.getBoundingClientRect();
|
|
||||||
inset = Math.max(0, Math.round(ce.bottom - nb.top));
|
|
||||||
}
|
|
||||||
this.root.setCssProps({ '--gx-bottom-inset': `${inset}px` });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 选中:邻居标签 + 卡片;index<0 清空 */
|
|
||||||
setSelection(index: number, neighbors: Set<number>): void {
|
setSelection(index: number, neighbors: Set<number>): void {
|
||||||
for (const e of this.neighborEls) e.el.remove();
|
for (const e of this.neighborEls) e.el.remove();
|
||||||
this.neighborEls = [];
|
this.neighborEls = [];
|
||||||
this.cardIndex = index;
|
if (index < 0) return;
|
||||||
if (index < 0) {
|
|
||||||
this.card.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const byDegree = [...neighbors]
|
const byDegree = [...neighbors]
|
||||||
.filter((i) => i !== index)
|
.filter((i) => i !== index)
|
||||||
.sort((a, b) => (this.data.nodes[b]?.degree ?? 0) - (this.data.nodes[a]?.degree ?? 0))
|
.sort((a, b) => (this.data.nodes[b]?.degree ?? 0) - (this.data.nodes[a]?.degree ?? 0))
|
||||||
|
|
@ -121,63 +72,6 @@ export class OverlayManager {
|
||||||
index: i,
|
index: i,
|
||||||
el: this.root.createDiv({ cls: 'gx-label gx-label-neighbor', text: this.data.nodes[i]?.name ?? '' }),
|
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 合成,无重排) */
|
/** 每帧:投影所有被追踪节点,translate3d 定位(GPU 合成,无重排) */
|
||||||
|
|
@ -204,15 +98,6 @@ export class OverlayManager {
|
||||||
const p = this.renderer.projectNode(this.hoverIndex, w, h);
|
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 (!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 {
|
dispose(): void {
|
||||||
|
|
@ -220,18 +105,4 @@ export class OverlayManager {
|
||||||
this.hubEls = [];
|
this.hubEls = [];
|
||||||
this.neighborEls = [];
|
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 { ROOT_ID, type VisibleWorldGraph, type WorldEdge, type WorldNode } from '../world/types';
|
||||||
import { NODE_FRAGMENT_SHADER, NODE_VERTEX_SHADER } from './shaders';
|
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;
|
export const MAX_RADIAL_ZOOM = 6;
|
||||||
const NODE_BASE_POINT = 4.8;
|
const NODE_BASE_POINT = 4.8;
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
||||||
tree: '#93a3b8',
|
tree: '#93a3b8',
|
||||||
link: '#a2acba',
|
link: '#a2acba',
|
||||||
external: '#7b6fd6',
|
external: '#7b6fd6',
|
||||||
externalLink: '#c98135',
|
externalLink: '#b8752e',
|
||||||
unresolved: '#dc4a4a',
|
unresolved: '#dc4a4a',
|
||||||
focus: '#3f7fe8',
|
focus: '#3f7fe8',
|
||||||
folder: '#7f92a8',
|
folder: '#7f92a8',
|
||||||
|
|
@ -80,7 +81,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
||||||
ringOpacity: 0.22,
|
ringOpacity: 0.22,
|
||||||
treeOpacity: 0.26,
|
treeOpacity: 0.26,
|
||||||
linkOpacity: 0.1,
|
linkOpacity: 0.1,
|
||||||
externalLinkOpacity: 0.16,
|
externalLinkOpacity: 0.14,
|
||||||
highlightOpacity: 0.92,
|
highlightOpacity: 0.92,
|
||||||
nodeScale: 0.32,
|
nodeScale: 0.32,
|
||||||
maxLabels: 170,
|
maxLabels: 170,
|
||||||
|
|
@ -91,7 +92,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
||||||
tree: '#8a8f9c',
|
tree: '#8a8f9c',
|
||||||
link: '#8b8f99',
|
link: '#8b8f99',
|
||||||
external: '#a78bfa',
|
external: '#a78bfa',
|
||||||
externalLink: '#f59e0b',
|
externalLink: '#d97706',
|
||||||
unresolved: '#fb7185',
|
unresolved: '#fb7185',
|
||||||
focus: '#8b7cf6',
|
focus: '#8b7cf6',
|
||||||
folder: '#d5d8de',
|
folder: '#d5d8de',
|
||||||
|
|
@ -101,7 +102,7 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
|
||||||
ringOpacity: 0.22,
|
ringOpacity: 0.22,
|
||||||
treeOpacity: 0.28,
|
treeOpacity: 0.28,
|
||||||
linkOpacity: 0.12,
|
linkOpacity: 0.12,
|
||||||
externalLinkOpacity: 0.16,
|
externalLinkOpacity: 0.14,
|
||||||
highlightOpacity: 0.98,
|
highlightOpacity: 0.98,
|
||||||
nodeScale: 0.32,
|
nodeScale: 0.32,
|
||||||
maxLabels: 300,
|
maxLabels: 300,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { App } from 'obsidian';
|
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 { Spherical, Vector3 } from 'three';
|
||||||
import type { BenchResult } from '../types';
|
import type { BenchResult } from '../types';
|
||||||
import type { GalaxySettings, Language, ViewMode } from '../settings';
|
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 type { VisualTokens } from '../render/presets';
|
||||||
import { resolveObsidianBackground } from '../render/obsidianTheme';
|
import { resolveObsidianBackground } from '../render/obsidianTheme';
|
||||||
import { CameraDirector } from '../interactions/CameraDirector';
|
import { CameraDirector } from '../interactions/CameraDirector';
|
||||||
import { ControlPanel } from '../overlay/ControlPanel';
|
import { ControlPanel, type PanelInspectNode } from '../overlay/ControlPanel';
|
||||||
import { OverlayManager } from '../overlay/OverlayManager';
|
import { OverlayManager } from '../overlay/OverlayManager';
|
||||||
import { NodeSearchModal } from './SearchModal';
|
import { NodeSearchModal } from './SearchModal';
|
||||||
import { collectFrames, observeLongTasks, writeBenchResult, sleep } from '../bench/bench';
|
import { collectFrames, observeLongTasks, writeBenchResult, sleep } from '../bench/bench';
|
||||||
|
|
@ -104,10 +104,7 @@ export class GraphController {
|
||||||
onResetView: () => this.recenter(),
|
onResetView: () => this.recenter(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.overlay = new OverlayManager(this.contentEl, this.app, renderer, {
|
this.overlay = new OverlayManager(this.contentEl, renderer);
|
||||||
openNote: (id) => void this.app.workspace.openLinkText(id, '', true),
|
|
||||||
focusNode: (i) => this.selectNode(i, true),
|
|
||||||
}, this.language);
|
|
||||||
this.overlay.setData(this.store.data, this.graphRadius);
|
this.overlay.setData(this.store.data, this.graphRadius);
|
||||||
|
|
||||||
this.applySettings();
|
this.applySettings();
|
||||||
|
|
@ -275,7 +272,7 @@ export class GraphController {
|
||||||
const prev = this.tier.id;
|
const prev = this.tier.id;
|
||||||
this.tier = this.pickTier();
|
this.tier = this.pickTier();
|
||||||
this.renderer?.applyTier(this.tier, this.settings.bloom.strength);
|
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');
|
this.contentEl.toggleClass('gx-mobile', this.tier.id === 'mobile');
|
||||||
const total = this.app.vault.getMarkdownFiles().length;
|
const total = this.app.vault.getMarkdownFiles().length;
|
||||||
this.store.setCaps(this.tier.nodeCap, this.tier.linkCap); // 变化时触发重建
|
this.store.setCaps(this.tier.nodeCap, this.tier.linkCap); // 变化时触发重建
|
||||||
|
|
@ -454,6 +451,7 @@ export class GraphController {
|
||||||
renderer.setFocus(index, neighbors);
|
renderer.setFocus(index, neighbors);
|
||||||
renderer.setSelectedLinks(linkIdx);
|
renderer.setSelectedLinks(linkIdx);
|
||||||
this.overlay?.setSelection(index, neighbors);
|
this.overlay?.setSelection(index, neighbors);
|
||||||
|
this.panel?.setInspectNode(this.panelInspectNode(index), true);
|
||||||
if (fly) {
|
if (fly) {
|
||||||
const pos = renderer.nodePosition(index, new Vector3());
|
const pos = renderer.nodePosition(index, new Vector3());
|
||||||
// 邻居质心方向:到达后环绕优先扫过链接密集的一侧
|
// 邻居质心方向:到达后环绕优先扫过链接密集的一侧
|
||||||
|
|
@ -475,6 +473,31 @@ export class GraphController {
|
||||||
this.renderer?.setFocus(-1, null);
|
this.renderer?.setFocus(-1, null);
|
||||||
this.renderer?.setSelectedLinks([]);
|
this.renderer?.setSelectedLinks([]);
|
||||||
this.overlay?.setSelection(-1, new Set());
|
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 {
|
private flyToSelected(): void {
|
||||||
|
|
@ -605,6 +628,8 @@ export class GraphController {
|
||||||
this.saveSoon();
|
this.saveSoon();
|
||||||
},
|
},
|
||||||
onSearch: () => this.openSearch(),
|
onSearch: () => this.openSearch(),
|
||||||
|
onOpenSelected: () => this.openSelectedNode(),
|
||||||
|
onFocusSelected: () => this.flyToSelected(),
|
||||||
onReset: () => {
|
onReset: () => {
|
||||||
Object.assign(this.settings.bloom, DEFAULT_GALAXY_SETTINGS.bloom);
|
Object.assign(this.settings.bloom, DEFAULT_GALAXY_SETTINGS.bloom);
|
||||||
Object.assign(this.settings.physics, DEFAULT_GALAXY_SETTINGS.physics);
|
Object.assign(this.settings.physics, DEFAULT_GALAXY_SETTINGS.physics);
|
||||||
|
|
@ -644,7 +669,6 @@ export class GraphController {
|
||||||
item.onClick(() => {
|
item.onClick(() => {
|
||||||
if (value === this.language) return;
|
if (value === this.language) return;
|
||||||
this.language = value;
|
this.language = value;
|
||||||
this.overlay?.setLanguage(value);
|
|
||||||
this.panel?.setLanguage(value);
|
this.panel?.setLanguage(value);
|
||||||
this.onLanguage?.(value);
|
this.onLanguage?.(value);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -165,18 +165,22 @@ export class Radial2DController extends Component {
|
||||||
|
|
||||||
private async rebuildNow(reason: string, token: number): Promise<void> {
|
private async rebuildNow(reason: string, token: number): Promise<void> {
|
||||||
const radial = this.radial();
|
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');
|
const loadingText = this.t('loading.radial');
|
||||||
if (shouldReveal) {
|
if (shouldReveal) {
|
||||||
this.renderer?.showLoadingMask(loadingText);
|
this.renderer?.showLoadingMask(loadingText);
|
||||||
await animationFrames(2);
|
await animationFrames(2);
|
||||||
if (this.disposed || token !== this.rebuildToken) return;
|
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.hoverHighlightMode = radial.hoverHighlightMode;
|
||||||
this.state.labelVisibility = radial.labelVisibility;
|
this.state.labelVisibility = radial.labelVisibility;
|
||||||
this.state.hiddenLegendItems = radial.hiddenLegendItems.slice();
|
if (this.state.showCompleteRoot) this.applyCompleteMapState();
|
||||||
this.state.showLinkOverlay = radial.showLinkOverlay || !this.state.hiddenLegendItems.includes('link');
|
else {
|
||||||
|
this.state.hiddenLegendItems = radial.hiddenLegendItems.slice();
|
||||||
|
this.state.showLinkOverlay = radial.showLinkOverlay || !this.state.hiddenLegendItems.includes('link');
|
||||||
|
}
|
||||||
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
|
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
|
||||||
this.state.selectedNodeId = this.selectedNodeId;
|
this.state.selectedNodeId = this.selectedNodeId;
|
||||||
this.state.selectedLink = this.selectedLink;
|
this.state.selectedLink = this.selectedLink;
|
||||||
|
|
@ -488,7 +492,7 @@ export class Radial2DController extends Component {
|
||||||
['controls', this.t('tab.controls')],
|
['controls', this.t('tab.controls')],
|
||||||
['defaults', this.t('tab.defaults')],
|
['defaults', this.t('tab.defaults')],
|
||||||
] as const) {
|
] 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', () => {
|
button.addEventListener('click', () => {
|
||||||
this.activePanelPage = id;
|
this.activePanelPage = id;
|
||||||
this.renderPanel();
|
this.renderPanel();
|
||||||
|
|
@ -504,7 +508,7 @@ export class Radial2DController extends Component {
|
||||||
|
|
||||||
private modeButton(parent: HTMLElement, label: string, mode: ViewMode): void {
|
private modeButton(parent: HTMLElement, label: string, mode: ViewMode): void {
|
||||||
const active = this.settings.viewMode === mode;
|
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));
|
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.outgoing', { count: outgoing.length }), outgoing, 'target'],
|
||||||
[this.t('inspect.backlinks', { count: incoming.length }), incoming, 'source'],
|
[this.t('inspect.backlinks', { count: incoming.length }), incoming, 'source'],
|
||||||
] as const) {
|
] as const) {
|
||||||
parent.createDiv({ cls: 'mwm-side-heading', text: title });
|
const section = parent.createDiv({ cls: 'mwm-neighbor-section' });
|
||||||
if (edges.length === 0) parent.createDiv({ cls: 'mwm-side-muted', text: this.t('common.none') });
|
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) {
|
for (const edge of edges) {
|
||||||
const neighbor = this.index.nodes.get(edge[side]);
|
const neighbor = this.index.nodes.get(edge[side]);
|
||||||
if (!neighbor) continue;
|
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));
|
button.addEventListener('click', () => this.inspectNode(neighbor.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -631,14 +639,18 @@ export class Radial2DController extends Component {
|
||||||
else this.selectedPinIds.delete(pin.id);
|
else this.selectedPinIds.delete(pin.id);
|
||||||
this.renderPanel();
|
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.addEventListener('click', () => this.locatePin(pin));
|
||||||
main.createDiv({ cls: 'mwm-pin-title', text: pin.title });
|
const displayTitle = this.pinDisplayTitle(pin);
|
||||||
main.createDiv({ cls: 'mwm-pin-meta', text: `${this.pinKindLabel(pin)} - ${pin.path || '/'}` });
|
const displayPath = pin.path || '/';
|
||||||
this.button(row, pin.active ? this.t('pins.hideHighlight') : this.t('pins.showHighlight'), () => this.togglePinHighlight(pin.id));
|
main.setAttr('title', `${displayTitle}\n${this.pinKindLabel(pin)} - ${displayPath}`);
|
||||||
this.button(row, this.t('common.inspect'), () => this.inspectPin(pin));
|
main.createDiv({ cls: 'mwm-pin-title', text: displayTitle });
|
||||||
if (pin.groupId) this.button(row, this.t('common.ungroup'), () => this.ungroupPin(pin.id));
|
main.createDiv({ cls: 'mwm-pin-meta', text: displayPath });
|
||||||
this.button(row, 'X', () => this.removePin(pin.id));
|
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 {
|
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.recenter'), () => this.centerCurrentView());
|
||||||
this.button(row, this.t('common.rebuild'), () => this.rebuild('manual'));
|
this.button(row, this.t('common.rebuild'), () => this.rebuild('manual'));
|
||||||
const row2 = parent.createDiv({ cls: 'galaxy-panel-row' });
|
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.focus'), () => this.focusActiveNote(), this.state.mode === 'focus');
|
||||||
this.button(row2, this.t('view.vaultRoot'), () => this.resetToRoot());
|
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.select(parent, this.t('view.theme'), this.radial().colorScheme, colorSchemeOptions(this.settings.language), (value) => {
|
||||||
this.radial().colorScheme = normalizeColorScheme(value);
|
this.radial().colorScheme = normalizeColorScheme(value);
|
||||||
this.saveSoon();
|
this.saveSoon();
|
||||||
|
|
@ -707,6 +720,7 @@ export class Radial2DController extends Component {
|
||||||
this.state.mode = 'atlas';
|
this.state.mode = 'atlas';
|
||||||
this.state.focusPath = null;
|
this.state.focusPath = null;
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.rootPath = visualId;
|
this.state.rootPath = visualId;
|
||||||
await this.queueRebuild('root');
|
await this.queueRebuild('root');
|
||||||
return;
|
return;
|
||||||
|
|
@ -716,6 +730,7 @@ export class Radial2DController extends Component {
|
||||||
this.renderPanel();
|
this.renderPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.rootPath = this.searchAtlasRoot(node, visualId);
|
this.state.rootPath = this.searchAtlasRoot(node, visualId);
|
||||||
await this.queueRebuild('root');
|
await this.queueRebuild('root');
|
||||||
}
|
}
|
||||||
|
|
@ -732,6 +747,42 @@ export class Radial2DController extends Component {
|
||||||
if (this.renderer?.fitToLayout(rootId)) this.needsFit = false;
|
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 {
|
private centerNode(nodeId: string): void {
|
||||||
const point = this.renderer?.nodePoint(nodeId);
|
const point = this.renderer?.nodePoint(nodeId);
|
||||||
const view = this.renderer?.getView();
|
const view = this.renderer?.getView();
|
||||||
|
|
@ -750,20 +801,24 @@ export class Radial2DController extends Component {
|
||||||
private renderControlsPage(parent: HTMLElement): void {
|
private renderControlsPage(parent: HTMLElement): void {
|
||||||
const radial = this.radial();
|
const radial = this.radial();
|
||||||
this.numberInput(parent, this.t('control.depth'), this.state.atlasDepth, 1, MAX_ATLAS_DEPTH, 1, (value) => {
|
this.numberInput(parent, this.t('control.depth'), this.state.atlasDepth, 1, MAX_ATLAS_DEPTH, 1, (value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.atlasDepth = value;
|
this.state.atlasDepth = value;
|
||||||
this.rebuild('depth');
|
this.rebuild('depth');
|
||||||
});
|
});
|
||||||
this.numberInput(parent, this.t('control.nodes'), this.state.nodeLimit, 200, MAX_RENDER_NODE_LIMIT, 100, (value) => {
|
this.numberInput(parent, this.t('control.nodes'), this.state.nodeLimit, 200, MAX_RENDER_NODE_LIMIT, 100, (value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.nodeLimit = value;
|
this.state.nodeLimit = value;
|
||||||
this.rebuild('nodes');
|
this.rebuild('nodes');
|
||||||
});
|
});
|
||||||
this.numberInput(parent, this.t('control.noteLinks'), this.state.linkLimit, 0, MAX_LINK_LIMIT, 50, (value) => {
|
this.numberInput(parent, this.t('control.noteLinks'), this.state.linkLimit, 0, MAX_LINK_LIMIT, 50, (value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.linkLimit = value;
|
this.state.linkLimit = value;
|
||||||
this.rebuild('links');
|
this.rebuild('links');
|
||||||
});
|
});
|
||||||
const hidden = new Set(radial.hiddenLegendItems);
|
const hidden = new Set(this.state.hiddenLegendItems);
|
||||||
const noteLinksVisible = this.state.showLinkOverlay && !hidden.has('link');
|
const noteLinksVisible = this.state.showLinkOverlay && !hidden.has('link');
|
||||||
this.toggle(parent, this.t('control.showNoteLinks'), noteLinksVisible, (value) => {
|
this.toggle(parent, this.t('control.showNoteLinks'), noteLinksVisible, (value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
radial.showLinkOverlay = value;
|
radial.showLinkOverlay = value;
|
||||||
this.state.showLinkOverlay = value;
|
this.state.showLinkOverlay = value;
|
||||||
this.setLegendHidden('link', !value);
|
this.setLegendHidden('link', !value);
|
||||||
|
|
@ -806,6 +861,7 @@ export class Radial2DController extends Component {
|
||||||
this.renderPanel();
|
this.renderPanel();
|
||||||
});
|
});
|
||||||
this.toggle(parent, this.t('control.outsideLinks'), this.state.showExternalLinks, (value) => {
|
this.toggle(parent, this.t('control.outsideLinks'), this.state.showExternalLinks, (value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.showExternalLinks = value;
|
this.state.showExternalLinks = value;
|
||||||
radial.showExternalLinks = value;
|
radial.showExternalLinks = value;
|
||||||
this.saveSoon();
|
this.saveSoon();
|
||||||
|
|
@ -817,6 +873,7 @@ export class Radial2DController extends Component {
|
||||||
this.state.externalDetailMode,
|
this.state.externalDetailMode,
|
||||||
outsideDetailOptions(this.settings.language),
|
outsideDetailOptions(this.settings.language),
|
||||||
(value) => {
|
(value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.externalDetailMode = normalizeExternalDetailMode(value);
|
this.state.externalDetailMode = normalizeExternalDetailMode(value);
|
||||||
radial.externalDetailMode = this.state.externalDetailMode;
|
radial.externalDetailMode = this.state.externalDetailMode;
|
||||||
this.saveSoon();
|
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.numberInput(parent, this.t('control.exactOutsideFiles'), this.state.externalLinkAnchorLimit, 0, MAX_EXTERNAL_LINK_ANCHOR_LIMIT, 50, (value) => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.externalLinkAnchorLimit = value;
|
this.state.externalLinkAnchorLimit = value;
|
||||||
radial.externalLinkAnchorLimit = value;
|
radial.externalLinkAnchorLimit = value;
|
||||||
this.saveSoon();
|
this.saveSoon();
|
||||||
|
|
@ -866,7 +924,7 @@ export class Radial2DController extends Component {
|
||||||
|
|
||||||
private renderLegend(parent: HTMLElement): void {
|
private renderLegend(parent: HTMLElement): void {
|
||||||
parent.createDiv({ cls: 'mwm-side-heading', text: this.t('control.legend') });
|
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) {
|
for (const [id, labelKey, titleKey, markerClass] of LEGEND_ITEM_DEFINITIONS) {
|
||||||
const row = parent.createEl('label', {
|
const row = parent.createEl('label', {
|
||||||
cls: hidden.has(id) ? 'mwm-legend-item is-muted' : 'mwm-legend-item',
|
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-label', text: this.t(labelKey) });
|
||||||
text.createSpan({ cls: 'mwm-legend-desc', text: this.t(titleKey) });
|
text.createSpan({ cls: 'mwm-legend-desc', text: this.t(titleKey) });
|
||||||
checkbox.addEventListener('change', () => {
|
checkbox.addEventListener('change', () => {
|
||||||
|
this.leaveCompleteMap();
|
||||||
const value = checkbox.checked;
|
const value = checkbox.checked;
|
||||||
if (value) hidden.delete(id);
|
if (value) hidden.delete(id);
|
||||||
else hidden.add(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 {
|
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);
|
button.addEventListener('click', onClick);
|
||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
@ -951,7 +1011,7 @@ export class Radial2DController extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private textArea(parent: HTMLElement, label: string, value: string, onChange: (value: string) => void): void {
|
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 });
|
field.createSpan({ text: label });
|
||||||
const input = field.createEl('textarea');
|
const input = field.createEl('textarea');
|
||||||
input.value = value;
|
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)}`);
|
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 {
|
private clearDisallowedHoverTargets(): void {
|
||||||
const targets = this.hoverTargets();
|
const targets = this.hoverTargets();
|
||||||
if (!targets.nodes) this.hoverNodeId = null;
|
if (!targets.nodes) this.hoverNodeId = null;
|
||||||
|
|
@ -1125,6 +1202,7 @@ export class Radial2DController extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetToAtlas(): void {
|
private resetToAtlas(): void {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.mode = 'atlas';
|
this.state.mode = 'atlas';
|
||||||
this.state.rootPath = this.state.rootPath || ROOT_ID;
|
this.state.rootPath = this.state.rootPath || ROOT_ID;
|
||||||
this.state.focusPath = null;
|
this.state.focusPath = null;
|
||||||
|
|
@ -1133,6 +1211,7 @@ export class Radial2DController extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetToRoot(): void {
|
private resetToRoot(): void {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.mode = 'atlas';
|
this.state.mode = 'atlas';
|
||||||
this.state.rootPath = ROOT_ID;
|
this.state.rootPath = ROOT_ID;
|
||||||
this.state.focusPath = null;
|
this.state.focusPath = null;
|
||||||
|
|
@ -1150,6 +1229,7 @@ export class Radial2DController extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private useAsRoot(nodeId: string): void {
|
private useAsRoot(nodeId: string): void {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.mode = 'atlas';
|
this.state.mode = 'atlas';
|
||||||
this.state.rootPath = nodeId;
|
this.state.rootPath = nodeId;
|
||||||
this.state.focusPath = null;
|
this.state.focusPath = null;
|
||||||
|
|
@ -1164,6 +1244,7 @@ export class Radial2DController extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
private focusNote(nodeId: string): void {
|
private focusNote(nodeId: string): void {
|
||||||
|
this.leaveCompleteMap();
|
||||||
this.state.mode = 'focus';
|
this.state.mode = 'focus';
|
||||||
this.state.focusPath = nodeId;
|
this.state.focusPath = nodeId;
|
||||||
this.state.rootPath = ROOT_ID;
|
this.state.rootPath = ROOT_ID;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
DEFAULT_RADIAL_SETTINGS,
|
DEFAULT_RADIAL_SETTINGS,
|
||||||
|
MAX_ATLAS_DEPTH,
|
||||||
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
|
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
|
||||||
MAX_LINK_LIMIT,
|
MAX_LINK_LIMIT,
|
||||||
MAX_RENDER_NODE_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 {
|
function buildAtlasGraph(model: WorldModel, state: VisibleGraphState, settings: RadialSettings): VisibleWorldGraph {
|
||||||
const rootId = model.nodes.has(state.rootPath) ? state.rootPath : ROOT_ID;
|
const rootId = model.nodes.has(state.rootPath) ? state.rootPath : ROOT_ID;
|
||||||
const rootDepth = model.nodes.get(rootId)?.depth ?? 0;
|
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 query = normalizedQuery(state.search);
|
||||||
const visible = new Set<string>();
|
const visible = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -186,12 +189,14 @@ function aggregateVisibleLinkEdges(
|
||||||
const aggregate = new Map<string, WorldEdge>();
|
const aggregate = new Map<string, WorldEdge>();
|
||||||
const externalNodes = new Map<string, WorldNode>();
|
const externalNodes = new Map<string, WorldNode>();
|
||||||
const externalHierarchyEdges: WorldEdge[] = [];
|
const externalHierarchyEdges: WorldEdge[] = [];
|
||||||
const externalLimit = clampNumber(
|
const externalLimit = state.showCompleteRoot
|
||||||
state.externalLinkAnchorLimit,
|
? MAX_EXTERNAL_LINK_ANCHOR_LIMIT
|
||||||
0,
|
: clampNumber(
|
||||||
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
|
state.externalLinkAnchorLimit,
|
||||||
settings.externalLinkAnchorLimit,
|
0,
|
||||||
);
|
MAX_EXTERNAL_LINK_ANCHOR_LIMIT,
|
||||||
|
settings.externalLinkAnchorLimit,
|
||||||
|
);
|
||||||
const externalContext = { fileCount: 0, overflowId: null as string | null };
|
const externalContext = { fileCount: 0, overflowId: null as string | null };
|
||||||
|
|
||||||
for (const raw of model.linkEdges) {
|
for (const raw of model.linkEdges) {
|
||||||
|
|
@ -269,7 +274,9 @@ function applyNodeBudget(
|
||||||
focusId: string | null,
|
focusId: string | null,
|
||||||
): { nodes: WorldNode[]; hiddenNodeCount: number } {
|
): { nodes: WorldNode[]; hiddenNodeCount: number } {
|
||||||
if (state.showCompleteRoot && nodes.length <= MAX_RENDER_NODE_LIMIT) return { nodes, hiddenNodeCount: 0 };
|
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 };
|
if (nodes.length <= limit) return { nodes, hiddenNodeCount: 0 };
|
||||||
|
|
||||||
const candidates = nodes
|
const candidates = nodes
|
||||||
|
|
|
||||||
256
styles.css
256
styles.css
|
|
@ -18,6 +18,8 @@
|
||||||
/* ---------- 控制面板 ---------- */
|
/* ---------- 控制面板 ---------- */
|
||||||
|
|
||||||
.galaxy-panel {
|
.galaxy-panel {
|
||||||
|
--mwm-action-button-min-height: 40px;
|
||||||
|
--mwm-link-row-min-height: 46px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
|
|
@ -138,6 +140,7 @@
|
||||||
.galaxy-panel-body {
|
.galaxy-panel-body {
|
||||||
padding: 0 10px 10px;
|
padding: 0 10px 10px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
@ -163,11 +166,15 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.galaxy-panel-row button {
|
.galaxy-panel-row button {
|
||||||
flex: 1 1 104px;
|
display: inline-flex;
|
||||||
min-height: 28px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1 1 132px;
|
||||||
|
min-height: var(--mwm-action-button-min-height);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
|
|
@ -178,8 +185,19 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.galaxy-panel-row button:hover {
|
||||||
|
|
@ -339,98 +357,6 @@
|
||||||
font-weight: 600;
|
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 {
|
.gx-search-path {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|
@ -556,20 +482,6 @@
|
||||||
|
|
||||||
/* ---------- M4 移动端 ---------- */
|
/* ---------- 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 {
|
.gx-mask-btn {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 8px 18px;
|
padding: 8px 18px;
|
||||||
|
|
@ -604,7 +516,7 @@
|
||||||
--mwm-legend-unresolved-color: #dc4a4a;
|
--mwm-legend-unresolved-color: #dc4a4a;
|
||||||
--mwm-legend-tree-color: #93a3b8;
|
--mwm-legend-tree-color: #93a3b8;
|
||||||
--mwm-legend-link-color: #a2acba;
|
--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-bg: rgba(255, 255, 255, 0.82);
|
||||||
--mwm-floating-border: rgba(23, 32, 51, 0.14);
|
--mwm-floating-border: rgba(23, 32, 51, 0.14);
|
||||||
--mwm-floating-color: #172033;
|
--mwm-floating-color: #172033;
|
||||||
|
|
@ -625,7 +537,7 @@
|
||||||
--mwm-legend-unresolved-color: #fb7185;
|
--mwm-legend-unresolved-color: #fb7185;
|
||||||
--mwm-legend-tree-color: #818cf8;
|
--mwm-legend-tree-color: #818cf8;
|
||||||
--mwm-legend-link-color: #94a3b8;
|
--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-bg: rgba(10, 14, 24, 0.64);
|
||||||
--mwm-floating-border: rgba(255, 255, 255, 0.12);
|
--mwm-floating-border: rgba(255, 255, 255, 0.12);
|
||||||
--mwm-floating-color: #e8ecf6;
|
--mwm-floating-color: #e8ecf6;
|
||||||
|
|
@ -788,10 +700,11 @@
|
||||||
|
|
||||||
.mwm-panel-tabs {
|
.mwm-panel-tabs {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(58px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mwm-panel-tabs button {
|
.mwm-panel-tabs button {
|
||||||
flex: 1 1 86px;
|
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
@ -803,8 +716,9 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mwm-panel-tabs button.is-active,
|
.mwm-panel-tabs button.is-active,
|
||||||
|
|
@ -830,8 +744,8 @@
|
||||||
.mwm-panel-field,
|
.mwm-panel-field,
|
||||||
.mwm-panel-toggle {
|
.mwm-panel-toggle {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(88px, 0.42fr) minmax(0, 1fr);
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
@ -839,11 +753,17 @@
|
||||||
.mwm-panel-toggle {
|
.mwm-panel-toggle {
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mwm-panel-field-stack {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mwm-panel-field > span,
|
.mwm-panel-field > span,
|
||||||
.mwm-panel-toggle > span {
|
.mwm-panel-toggle > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
line-height: 1.2;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1015,10 +935,24 @@
|
||||||
opacity: 0.72;
|
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 {
|
.mwm-pin-group {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mwm-pin-group-header {
|
.mwm-pin-group-header {
|
||||||
|
|
@ -1068,6 +1002,7 @@
|
||||||
.mwm-link-row,
|
.mwm-link-row,
|
||||||
.mwm-pin-main {
|
.mwm-pin-main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 5px 6px;
|
padding: 5px 6px;
|
||||||
border: 1px solid var(--background-modifier-border);
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
|
@ -1081,33 +1016,91 @@
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
overflow-wrap: anywhere;
|
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-row,
|
||||||
.mwm-pin-group-row {
|
.mwm-pin-group-row {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
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 {
|
.mwm-pin-row.is-muted {
|
||||||
opacity: 0.58;
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mwm-pin-row > button:not(.mwm-pin-main),
|
|
||||||
.mwm-pin-group-row > button,
|
.mwm-pin-group-row > button,
|
||||||
.mwm-pin-group-header > button {
|
.mwm-pin-group-header > button,
|
||||||
flex: 0 0 auto;
|
.mwm-pin-actions > button {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mwm-pin-check {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.mwm-pin-main {
|
.mwm-pin-main {
|
||||||
flex: 1;
|
display: block;
|
||||||
flex-basis: 150px;
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
white-space: normal;
|
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 {
|
.mwm-pin-title {
|
||||||
|
|
@ -1117,6 +1110,23 @@
|
||||||
white-space: nowrap;
|
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 {
|
.mwm-floating-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,44 @@ describe('visible world graph', () => {
|
||||||
expect(graph.focusId).toBe('Atlas/Topic A.md');
|
expect(graph.focusId).toBe('Atlas/Topic A.md');
|
||||||
expect(graph.nodesById.has('Atlas/Sub/Topic B.md')).toBe(true);
|
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', () => {
|
describe('radial layout', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,7 @@
|
||||||
"0.1.3": "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.1": "1.5.0",
|
||||||
"0.2.2": "1.13.0"
|
"0.2.2": "1.13.0",
|
||||||
|
"0.2.3": "1.13.0",
|
||||||
|
"0.2.4": "1.13.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue