temp savepoints. this really sucks.

This commit is contained in:
Kur0mi 2026-06-22 10:09:15 +02:00
parent a726ca20e1
commit 0bd2278ec2
8 changed files with 650 additions and 399 deletions

360
main.js

File diff suppressed because one or more lines are too long

View file

@ -45,6 +45,7 @@ const STRINGS: Record<string, Record<Language, string>> = {
'common.pinCurrent': { en: 'Pin current', zh: '固定当前' },
'common.clear': { en: 'Clear', zh: '清空' },
'common.group': { en: 'Group', zh: '分组' },
'common.ungroup': { en: 'Ungroup', zh: '取消分组' },
'common.inspect': { en: 'Inspect', zh: '检查' },
'common.open': { en: 'Open', zh: '打开' },
'common.focus': { en: 'Focus', zh: '聚焦' },
@ -78,12 +79,17 @@ const STRINGS: Record<string, Record<Language, string>> = {
'inspect.external': { en: 'Outside', zh: '外部' },
'inspect.outgoing': { en: 'Outgoing ({count})', zh: '出链({count}' },
'inspect.backlinks': { en: 'Backlinks ({count})', zh: '反向链接({count}' },
'inspect.parentRoot': { en: 'Parent of root', zh: '当前根的父文件夹' },
'pins.groupName': { en: 'Group name', zh: '分组名称' },
'pins.empty': { en: 'No pinned paths.', zh: '暂无固定路径。' },
'pins.already': { en: 'That path is already pinned.', zh: '这条路径已经固定。' },
'pins.selectFirst': { en: 'Select pinned paths to group.', zh: '先选择要分组的固定路径。' },
'pins.selectBeforePin': { en: 'Select or hover a path before pinning.', zh: '先选择或悬停一条路径再固定。' },
'pins.ungrouped': { en: 'Ungrouped', zh: '未分组' },
'pins.selectForGroup': { en: 'Select for grouping', zh: '选择用于分组' },
'pins.hideHighlight': { en: 'Hide route', zh: '隐藏路线' },
'pins.showHighlight': { en: 'Show route', zh: '显示路线' },
'control.depth': { en: 'Depth', zh: '深度' },
'control.nodes': { en: 'Nodes', zh: '节点' },

View file

@ -8,8 +8,8 @@ export const DEFAULT_NODE_SPACING = 126;
export const MIN_NODE_SPACING = 72;
export const MAX_NODE_SPACING = 360;
const RING_JAGGED_BAND_FACTOR = 0.36;
const RING_JAGGED_MAX_FACTOR = 0.28;
const RING_JAGGED_BAND_FACTOR = 0.26;
const RING_JAGGED_MAX_FACTOR = 0.22;
export interface RadialPoint {
x: number;
@ -100,15 +100,22 @@ interface RingItem {
export function layoutRadialGraph(graph: VisibleWorldGraph, options: RadialLayoutOptions): RadialLayout {
const baseRingGap = clamp(options.ringSpacing, MIN_RING_SPACING, MAX_RING_SPACING);
const baseNodeGap = clamp(options.nodeSpacing, MIN_NODE_SPACING, MAX_NODE_SPACING);
const padding = 340;
const positions = new Map<string, RadialPoint>();
const nodesById = graph.nodesById;
const maxDegree = maxLinkDegree(graph.nodes);
const normalNodes = graph.nodes.filter((node) => !node.externalProxy && node.type !== 'external');
const normalIds = new Set(normalNodes.map((node) => node.id));
const spacing = adaptiveLayoutSpacing(graph, normalIds, baseRingGap, baseNodeGap);
const childrenByParent = childrenByParentMap(graph, normalIds, nodesById);
const metrics = measureMetrics(childrenByParent, nodesById, spacing);
const rootId = normalIds.has(graph.rootId) ? graph.rootId : normalIds.has(ROOT_ID) ? ROOT_ID : (normalNodes[0]?.id ?? null);
const childrenByParent = childrenByParentMap(graph, normalIds, nodesById);
const metrics = new Map<string, Metric>();
const reachable = new Set<string>();
if (rootId !== null) {
measureSubtree(rootId, 0, childrenByParent, spacing, metrics);
collectReachable(rootId, childrenByParent, reachable);
}
if (rootId !== null) {
const rootPoint = makePoint(0, -Math.PI / 2, 0, nodeRadius(nodesById.get(rootId), maxDegree), false);
@ -129,7 +136,6 @@ export function layoutRadialGraph(graph: VisibleWorldGraph, options: RadialLayou
);
}
const reachable = new Set(positions.keys());
const orphanNodes = normalNodes.filter((node) => node.id !== rootId && !reachable.has(node.id)).sort(compareLayoutNode);
const rootMetric = rootId !== null ? metrics.get(rootId) : null;
const maxTreeDepth = Math.max(0, rootMetric?.maxDepth ?? 0);
@ -151,29 +157,46 @@ export function layoutRadialGraph(graph: VisibleWorldGraph, options: RadialLayou
const externalFiles = graph.nodes.filter((node) => node.externalProxy).sort(compareLayoutNode);
if (externalFiles.length > 0) {
placeOuterCircleNodes(externalFiles, positions, graph, nodesById, outerRadius + Math.max(220, spacing.ringGap * 0.52), spacing.nodeGap, -Math.PI / 5, maxTreeDepth + 2, true, maxDegree);
outerRadius = Math.max(
outerRadius,
placeOuterCircleNodes(externalFiles, positions, graph, nodesById, outerRadius + Math.max(220, spacing.ringGap * 0.52), spacing.nodeGap, -Math.PI / 5, maxTreeDepth + 2, true, maxDegree),
);
}
const ringTargets = assignDepthRingTargets(positions, graph, spacing, spacing.nodeGap, maxDegree);
resolveRadialCollisions(positions, graph, spacing, spacing.nodeGap, ringTargets, maxDegree);
enforceDepthRingBands(positions, graph, spacing, spacing.nodeGap, ringTargets, maxDegree);
if (spacing.radiusExpansion > 1.001) applyAdaptiveRadiusExpansion(positions, ringTargets, spacing);
alignChildrenToParentLanes(positions, childrenByParent, graph, spacing);
if (options.swirlStrength > 0.001) applyRadialSwirl(positions, graph, spacing, options.swirlStrength / 100);
const spinSpeed = clamp(options.swirlStrength, 0, 100) / 100;
const swirlStrength = spinSpeed > 0.001 ? clamp(0.24 + spinSpeed * 0.34, 0.24, 0.58) : 0;
if (swirlStrength > 0.001) applyRadialSwirl(positions, graph, spacing, swirlStrength);
outerRadius = Math.max(outerRadius, maxRadius(positions));
const routeMaxDepth = maxTreeDepth + (externalGroups.length || externalFiles.length ? 2 : orphanNodes.length ? 1 : 0);
const routeBundle = computeLinkRoutes(
graph.linkEdges,
positions,
routeMaxDepth,
spacing.ringGap,
outerRadius + Math.max(150, spacing.ringGap * 0.35),
);
const rawBounds = radialLayoutBounds(positions, Math.max(routeBundle.maxRadius, outerRadius + Math.max(160, spacing.ringGap * 0.34)));
const offsetX = padding - rawBounds.minX;
const offsetY = padding - rawBounds.minY;
shiftRadialLayout(positions, routeBundle.routes, offsetX, offsetY);
anchorHomePositions(positions);
const rings = computeRings(positions, ringTargets);
const routeMaxDepth = maxTreeDepth + (externalGroups.length || externalFiles.length ? 2 : orphanNodes.length ? 1 : 0);
const routes = routeLinks(graph.linkEdges, positions, routeMaxDepth, spacing.ringGap);
const bounds = computeBounds(positions, routes);
const width = Math.max(900, rawBounds.maxX - rawBounds.minX + padding * 2);
const height = Math.max(520, rawBounds.maxY - rawBounds.minY + padding * 2);
const bounds = { minX: 0, minY: 0, maxX: width, maxY: height };
return {
positions,
rings,
routes,
routes: routeBundle.routes,
bounds,
width: bounds.maxX - bounds.minX,
height: bounds.maxY - bounds.minY,
centerX: 0,
centerY: 0,
width,
height,
centerX: offsetX,
centerY: offsetY,
ringSpacing: spacing.ringGap,
nodeSpacing: spacing.nodeGap,
};
@ -185,44 +208,52 @@ function childrenByParentMap(
nodesById: Map<string, WorldNode>,
): Map<string, string[]> {
const childrenByParent = new Map<string, string[]>();
for (const edge of graph.hierarchyEdges) {
if (!normalIds.has(edge.source) || !normalIds.has(edge.target)) continue;
const list = childrenByParent.get(edge.source);
if (list) list.push(edge.target);
else childrenByParent.set(edge.source, [edge.target]);
for (const node of graph.nodes) {
if (!normalIds.has(node.id) || node.parentId === null || node.parentId === undefined || !normalIds.has(node.parentId)) continue;
const list = childrenByParent.get(node.parentId);
if (list) list.push(node.id);
else childrenByParent.set(node.parentId, [node.id]);
}
for (const children of childrenByParent.values()) children.sort((a, b) => compareLayoutNode(nodesById.get(a), nodesById.get(b)));
return childrenByParent;
}
function measureMetrics(
function measureSubtree(
id: string,
depth: number,
childrenByParent: Map<string, string[]>,
nodesById: Map<string, WorldNode>,
spacing: SpacingProfile,
): Map<string, Metric> {
const metrics = new Map<string, Metric>();
const measure = (id: string, depth: number, visiting = new Set<string>()): Metric => {
const cached = metrics.get(id);
if (cached) return cached;
if (visiting.has(id)) return { weight: 1, count: 1, maxDepth: depth };
visiting.add(id);
const incidentPressure = spacing.incidentPressureByNode.get(id) ?? 0;
let weight = Math.min(9, incidentPressure * 0.24);
let count = 1;
let maxDepth = depth;
for (const childId of childrenByParent.get(id) ?? []) {
const child = measure(childId, depth + 1, visiting);
weight += child.weight;
count += child.count;
maxDepth = Math.max(maxDepth, child.maxDepth);
}
visiting.delete(id);
const metric = { weight: Math.max(1, weight || 1), count, maxDepth };
metrics.set(id, metric);
return metric;
};
for (const id of childrenByParent.keys()) measure(id, 0);
return metrics;
metrics: Map<string, Metric>,
visiting = new Set<string>(),
): Metric {
const cached = metrics.get(id);
if (cached) return cached;
if (visiting.has(id)) {
const fallback = { weight: 1, count: 1, maxDepth: depth };
metrics.set(id, fallback);
return fallback;
}
visiting.add(id);
const incidentPressure = spacing.incidentPressureByNode.get(id) ?? 0;
let weight = Math.min(9, incidentPressure * 0.24);
let count = 1;
let maxDepth = depth;
for (const childId of childrenByParent.get(id) ?? []) {
const child = measureSubtree(childId, depth + 1, childrenByParent, spacing, metrics, visiting);
weight += child.weight;
count += child.count;
maxDepth = Math.max(maxDepth, child.maxDepth);
}
visiting.delete(id);
const metric = { weight: Math.max(1, weight || 1), count, maxDepth };
metrics.set(id, metric);
return metric;
}
function collectReachable(id: string | null | undefined, childrenByParent: Map<string, string[]>, reachable: Set<string>): void {
if (id === null || id === undefined || reachable.has(id)) return;
reachable.add(id);
for (const childId of childrenByParent.get(id) ?? []) collectReachable(childId, childrenByParent, reachable);
}
function placeRadialChildren(
@ -775,64 +806,19 @@ function applyAdaptiveRadiusExpansion(
}
}
function alignChildrenToParentLanes(
positions: Map<string, RadialPoint>,
childrenByParent: Map<string, string[]>,
graph: VisibleWorldGraph,
spacing: SpacingProfile,
): void {
for (const [parentId, childIds] of childrenByParent.entries()) {
const parent = positions.get(parentId);
if (!parent || parent.radius <= 0.001 || childIds.length === 0) continue;
const parentAngle = Number.isFinite(parent.angle) ? parent.angle : Math.atan2(parent.y, parent.x);
const children = childIds
.map((id) => {
const point = positions.get(id);
const node = graph.nodesById.get(id);
return point && node && !point.external ? { id, point, node, delta: shortestAngleDelta(parentAngle, point.angle) } : null;
})
.filter((item): item is { id: string; point: RadialPoint; node: WorldNode; delta: number } => Boolean(item))
.sort((a, b) => a.delta - b.delta);
if (children.length === 0) continue;
const averageRadius = children.reduce((sum, child) => sum + Math.max(1, child.point.radius), 0) / children.length;
const requiredArc = children.reduce((sum, child) => sum + child.point.nodeRadius * 2.25 + labelArcPadding(child.node) * 0.76, 0);
const requiredSpan = Math.min(Math.PI * 1.1, requiredArc / Math.max(1, averageRadius));
const currentSpan = children.length > 1 ? Math.max(...children.map((child) => child.delta)) - Math.min(...children.map((child) => child.delta)) : 0;
const compactSpan = clamp(Math.max(requiredSpan, currentSpan * 0.58), children.length === 1 ? 0 : 0.035, Math.PI * 0.86);
const maxShift = Math.min(Math.PI * 0.18, Math.max(0.03, spacing.branchFanSpan * 0.08));
const strength = children.length <= 2 ? 0.54 : children.length <= 8 ? 0.46 : 0.34;
for (let index = 0; index < children.length; index++) {
const child = children[index]!;
const slot =
children.length === 1
? 0
: -compactSpan / 2 + (compactSpan * index) / Math.max(1, children.length - 1);
const targetAngle = normalizeAngle(parentAngle + slot);
const currentAngle = normalizeAngle(child.point.angle);
const shift = clamp(shortestAngleDelta(currentAngle, targetAngle) * strength, -maxShift, maxShift);
const angle = normalizeAngle(currentAngle + shift);
const radius = child.point.radius;
child.point.x = Math.cos(angle) * radius;
child.point.y = Math.sin(angle) * radius;
child.point.angle = angle;
}
}
}
function routeLinks(
function computeLinkRoutes(
edges: WorldEdge[],
positions: Map<string, RadialPoint>,
maxDepth: number,
ringSpacing: number,
): Map<string, RadialRoute> {
outerRadius: number,
): { routes: Map<string, RadialRoute>; maxRadius: number } {
const routes = new Map<string, RadialRoute>();
const maxRadiusValue = Math.max(outerRadius || 0, 1);
for (const edge of edges) {
const source = positions.get(edge.source);
const target = positions.get(edge.target);
if (!source || !target) continue;
const depthDelta = Math.abs(source.depth - target.depth);
const sourceRadius = Number.isFinite(source.radius) ? source.radius : 0;
const targetRadius = Number.isFinite(target.radius) ? target.radius : 0;
const sourceAngle = Number.isFinite(source.angle) ? source.angle : Math.atan2(target.y - source.y, target.x - source.x);
@ -844,7 +830,7 @@ function routeLinks(
const shouldCurve =
isExternal ||
sameOrNearRing ||
(depthDelta <= 1 && touchesOuterRing && angleDistance > 0.34) ||
(touchesOuterRing && angleDistance > 0.34) ||
angleDistance > Math.PI * 0.42;
if (shouldCurve) {
routes.set(edge.id, {
@ -858,7 +844,7 @@ function routeLinks(
});
}
}
return routes;
return { routes, maxRadius: maxRadiusValue };
}
function computeRings(positions: Map<string, RadialPoint>, ringTargets: Map<number, number>): RadialRing[] {
@ -896,28 +882,37 @@ function computeRings(positions: Map<string, RadialPoint>, ringTargets: Map<numb
.sort((a, b) => a.radius - b.radius);
}
function computeBounds(positions: Map<string, RadialPoint>, routes: Map<string, RadialRoute>): RadialLayout['bounds'] {
let minX = -500;
let minY = -500;
let maxX = 500;
let maxY = 500;
function radialLayoutBounds(positions: Map<string, RadialPoint>, routeMaxRadius: number): RadialLayout['bounds'] {
let minX = -Math.max(1, routeMaxRadius || 1);
let minY = -Math.max(1, routeMaxRadius || 1);
let maxX = Math.max(1, routeMaxRadius || 1);
let maxY = Math.max(1, routeMaxRadius || 1);
for (const point of positions.values()) {
const pad = point.nodeRadius + 180;
minX = Math.min(minX, point.x - pad);
minY = Math.min(minY, point.y - pad);
maxX = Math.max(maxX, point.x + pad);
maxY = Math.max(maxY, point.y + pad);
}
for (const route of routes.values()) {
if (route.kind !== 'outer') continue;
minX = Math.min(minX, -route.radius - 160);
minY = Math.min(minY, -route.radius - 160);
maxX = Math.max(maxX, route.radius + 160);
maxY = Math.max(maxY, route.radius + 160);
const labelPadX = 170;
const labelPadTop = 46;
const labelPadBottom = 126;
minX = Math.min(minX, point.x - labelPadX);
minY = Math.min(minY, point.y - labelPadTop);
maxX = Math.max(maxX, point.x + labelPadX);
maxY = Math.max(maxY, point.y + labelPadBottom);
}
return { minX, minY, maxX, maxY };
}
function shiftRadialLayout(positions: Map<string, RadialPoint>, routes: Map<string, RadialRoute>, offsetX: number, offsetY: number): void {
for (const point of positions.values()) {
point.x += offsetX;
point.y += offsetY;
point.centerX = offsetX;
point.centerY = offsetY;
}
for (const route of routes.values()) {
if (!Number.isFinite(route.centerX) || !Number.isFinite(route.centerY)) continue;
route.centerX += offsetX;
route.centerY += offsetY;
}
}
function makePoint(radius: number, angle: number, depth: number, nodeRadiusValue: number, external: boolean): RadialPoint {
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;

View file

@ -15,7 +15,7 @@ import {
} from 'three';
import type { LabelVisibility } from '../settings';
import type { RadialLayout, RadialPoint, RadialRoute } from '../layout/radial/layoutRadial';
import type { VisibleWorldGraph, WorldEdge, WorldNode } from '../world/types';
import { ROOT_ID, type VisibleWorldGraph, type WorldEdge, type WorldNode } from '../world/types';
import { NODE_FRAGMENT_SHADER, NODE_VERTEX_SHADER } from './shaders';
export interface RadialActiveState {
@ -99,8 +99,8 @@ const PALETTES: Record<RadialResolvedScheme, RadialPalette> = {
linkOpacity: 0.065,
externalLinkOpacity: 0.09,
highlightOpacity: 0.98,
nodeScale: 0.2,
maxLabels: 140,
nodeScale: 0.19,
maxLabels: 260,
},
};
@ -111,7 +111,7 @@ export class RadialRenderer {
private scene = new Scene();
private labelRoot: HTMLElement;
private ringSegments: Mesh | null = null;
private ringSegments: LineSegments | null = null;
private hierarchySegments: LineSegments | null = null;
private linkSegments: LineSegments | null = null;
private highlightSegments: Mesh | null = null;
@ -131,6 +131,8 @@ export class RadialRenderer {
private scheme: RadialResolvedScheme = 'night';
private revealFrame = 0;
private revealOverlay: HTMLElement | null = null;
private renderBatchDepth = 0;
private pendingRender = false;
constructor(private container: HTMLElement) {
this.renderer = new WebGLRenderer({ antialias: true, alpha: false });
@ -207,19 +209,40 @@ export class RadialRenderer {
return { centerX: this.centerX, centerY: this.centerY, zoom: this.zoom };
}
showLoadingMask(text: string): void {
cancelAnimationFrame(this.revealFrame);
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.addClass('is-radial-revealing');
this.ensureRevealOverlay(text);
}
beginRenderBatch(): void {
this.renderBatchDepth++;
}
endRenderBatch(): void {
if (this.renderBatchDepth <= 0) return;
this.renderBatchDepth--;
if (this.renderBatchDepth === 0 && this.pendingRender) {
this.pendingRender = false;
this.render();
}
}
fitToLayout(): void {
if (!this.layout) return;
const padding = 160;
const w = Math.max(1, this.layout.bounds.maxX - this.layout.bounds.minX + padding * 2);
const h = Math.max(1, this.layout.bounds.maxY - this.layout.bounds.minY + padding * 2);
const zoom = Math.min(this.width / w, this.height / h, 1.2) * 0.92;
this.setView((this.layout.bounds.minX + this.layout.bounds.maxX) / 2, (this.layout.bounds.minY + this.layout.bounds.maxY) / 2, zoom);
const inset = 42;
const availableWidth = Math.max(1, this.width - inset);
const availableHeight = Math.max(1, this.height - inset);
const zoom = Math.min(availableWidth / Math.max(1, this.layout.width), availableHeight / Math.max(1, this.layout.height)) * 1.08;
this.setView(this.layout.width / 2, this.layout.height / 2, zoom);
}
playRevealFromRoot(rootId: string, text: string, durMs = 1200): void {
if (!this.layout || this.width < 2 || this.height < 2) return;
cancelAnimationFrame(this.revealFrame);
this.revealOverlay?.remove();
const root = this.layout.positions.get(rootId) ?? { x: 0, y: 0 };
const screen = this.worldToScreen(root.x, root.y);
const maxRadius = Math.hypot(Math.max(screen.x, this.width - screen.x), Math.max(screen.y, this.height - screen.y)) + 120;
@ -228,7 +251,7 @@ export class RadialRenderer {
this.container.style.setProperty('--mwm-reveal-y', `${screen.y.toFixed(1)}px`);
this.container.style.setProperty('--mwm-reveal-radius', '0px');
this.container.addClass('is-radial-revealing');
this.revealOverlay = this.container.createDiv({ cls: 'mwm-radial-loading gx-mask-text', text });
this.ensureRevealOverlay(text);
const step = (now: number) => {
const p = Math.min(Math.max((now - start) / durMs, 0), 1);
const eased = 1 - Math.pow(1 - p, 3);
@ -256,6 +279,10 @@ export class RadialRenderer {
}
render(): void {
if (this.renderBatchDepth > 0) {
this.pendingRender = true;
return;
}
this.renderer.render(this.scene, this.camera);
}
@ -288,7 +315,7 @@ export class RadialRenderer {
const point = this.layout.positions.get(node.id);
if (!point) continue;
const distance = Math.hypot(world.x - point.x, world.y - point.y);
const visualRadius = Math.max(4, point.nodeRadius * this.palette().nodeScale * nodeScreenScale(this.zoom) * 0.55);
const visualRadius = Math.max(4, nodePointSize(point.nodeRadius, this.palette().nodeScale) * nodeScreenScale(this.zoom) * 0.55);
const radius = Math.max(point.nodeRadius * 0.36, visualRadius / Math.max(0.003, this.zoom)) + Math.max(5, 6 / this.zoom);
if (distance <= radius && (!bestNode || distance < bestNode.distance)) bestNode = { id: node.id, distance };
}
@ -323,6 +350,15 @@ export class RadialRenderer {
return PALETTES[this.scheme];
}
private ensureRevealOverlay(text: string): void {
if (!this.revealOverlay) {
this.revealOverlay = this.container.createDiv({ cls: 'mwm-radial-loading gx-mask-text', text });
return;
}
this.revealOverlay.removeClass('is-fading');
this.revealOverlay.setText(text);
}
private applyCamera(): void {
this.camera.position.set(this.centerX, this.centerY, 1000);
this.camera.zoom = this.zoom;
@ -331,7 +367,33 @@ export class RadialRenderer {
}
private buildRings(): void {
this.ringSegments = null;
if (!this.layout?.rings.length) {
this.ringSegments = null;
return;
}
const positions: number[] = [];
const colors: number[] = [];
const color = new Color(this.palette().ring);
const centerX = Number.isFinite(this.layout.centerX) ? this.layout.centerX : 0;
const centerY = Number.isFinite(this.layout.centerY) ? this.layout.centerY : 0;
for (const ring of this.layout.rings) {
if (!Number.isFinite(ring.radius) || ring.radius <= 0) continue;
pushRingSegments(positions, colors, color, centerX, centerY, ring.radius, ring.depth, -3);
}
if (positions.length === 0) {
this.ringSegments = null;
return;
}
this.ringSegments = new LineSegments(
makeLineGeometry(positions, colors),
new LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: this.palette().ringOpacity,
depthWrite: false,
}),
);
this.scene.add(this.ringSegments);
}
private buildEdges(edges: WorldEdge[], layout: RadialLayout, kind: 'hierarchy' | 'links'): void {
@ -384,7 +446,7 @@ export class RadialRenderer {
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
sizes[index] = Math.max(2.1, (point?.nodeRadius ?? 8) * palette.nodeScale);
sizes[index] = nodePointSize(point?.nodeRadius ?? 8, palette.nodeScale);
ghost[index] = node.type === 'unresolved' || node.type === 'external' || node.externalProxy ? 1 : 0;
});
this.nodeGeometry = new BufferGeometry();
@ -403,7 +465,7 @@ export class RadialRenderer {
uPixelScale: { value: 1000 * Math.min(window.devicePixelRatio || 1, 2) },
uSizeMul: { value: nodeScreenScale(this.zoom) },
uLightMode: { value: this.scheme === 'day' ? 1 : 0 },
uMaxPoint: { value: 58 * Math.min(window.devicePixelRatio || 1, 2) },
uMaxPoint: { value: 72 * Math.min(window.devicePixelRatio || 1, 2) },
},
});
this.nodePoints = new Points(this.nodeGeometry, this.nodeMaterial);
@ -430,7 +492,7 @@ export class RadialRenderer {
const sizeMul = this.nodeMaterial.uniforms['uSizeMul'];
const maxPoint = this.nodeMaterial.uniforms['uMaxPoint'];
if (sizeMul) sizeMul.value = nodeScreenScale(this.zoom);
if (maxPoint) maxPoint.value = 58 * Math.min(window.devicePixelRatio || 1, 2);
if (maxPoint) maxPoint.value = 72 * Math.min(window.devicePixelRatio || 1, 2);
}
private rebuildHighlights(): void {
@ -484,50 +546,55 @@ export class RadialRenderer {
private updateLabels(labelVisibility: LabelVisibility = 'auto'): void {
if (!this.graph || !this.layout) return;
this.labelRoot.empty();
const directIds = new Set<string>([
...this.active.labelNodes,
...this.active.pinnedNodeIds,
this.active.activeNodeId ?? '',
this.graph.rootId,
this.graph.focusId ?? '',
]);
directIds.delete('');
const directIds = new Set<string>();
const addDirect = (id: string | null | undefined) => {
if (id !== null && id !== undefined) directIds.add(id);
};
for (const id of this.active.labelNodes) addDirect(id);
for (const id of this.active.pinnedNodeIds) addDirect(id);
addDirect(this.active.activeNodeId);
addDirect(ROOT_ID);
addDirect(this.graph.rootId);
addDirect(this.graph.focusId);
const ranked = this.graph.nodes
.map((node) => {
const point = this.layout?.positions.get(node.id) ?? null;
return point ? { node, point, score: labelScore(node, point, this.graph!) } : null;
if (!point) return null;
const screen = this.worldToScreen(point.x, point.y);
const visible = screen.x >= -160 && screen.y >= -80 && screen.x <= this.width + 160 && screen.y <= this.height + 120;
return visible ? { node, point, screen, score: labelScore(node, point, this.graph!) } : null;
})
.filter((item): item is { node: WorldNode; point: RadialPoint; score: number } => Boolean(item))
.filter((item): item is { node: WorldNode; point: RadialPoint; screen: { x: number; y: number }; score: number } => Boolean(item))
.sort((a, b) => b.score - a.score);
const denominator = Math.max(1, ranked.length - 1);
const viewportScale = clampNumber(Math.sqrt(Math.max(1, this.width * this.height)) / 1050, 0.65, 1.8);
const zoomCapacity = 22 + smoothstep(0.035, 0.42, this.zoom) * 64 + smoothstep(0.36, 1.3, this.zoom) * 112 + smoothstep(1.1, 6, this.zoom) * 72;
const autoBudget =
labelVisibility === 'auto' && this.zoom >= 0.08
? Math.round(Math.min(this.palette().maxLabels, Math.max(0, (this.zoom - 0.08) * 84 + Math.sqrt(this.zoom) * 18)))
labelVisibility === 'auto' && this.zoom >= 0.045
? Math.round(Math.min(this.palette().maxLabels, Math.max(0, zoomCapacity * viewportScale)))
: 0;
let autoShown = 0;
for (let rank = 0; rank < ranked.length; rank++) {
const item = ranked[rank]!;
const { node, point } = item;
const { node, point, screen } = item;
const direct = directIds.has(node.id);
const strength = direct
? 1
: labelVisibility === 'auto'
? zoomLabelStrength(node, point, this.zoom, rank, denominator, this.graph)
? zoomLabelStrength(node, point, this.zoom, rank, denominator, this.graph, this.width, this.height)
: 0;
if (!direct) {
if (strength <= 0.08 || autoShown >= autoBudget) continue;
if (strength <= 0.06 || autoShown >= autoBudget) continue;
autoShown++;
}
const screen = this.worldToScreen(point.x, point.y);
if (screen.x < -160 || screen.y < -80 || screen.x > this.width + 160 || screen.y > this.height + 120) continue;
const label = this.labelRoot.createDiv({ cls: node.id === this.graph.rootId ? 'mwm-radial-label is-root' : 'mwm-radial-label' });
const label = this.labelRoot.createDiv({ cls: node.id === this.graph.rootId || node.id === ROOT_ID ? 'mwm-radial-label is-root' : 'mwm-radial-label' });
label.setText(node.title);
const scale = labelScreenScale(this.zoom) * (node.id === this.graph.rootId ? 1.18 : point.nodeRadius >= 24 ? 1.1 : point.nodeRadius >= 15 ? 1.04 : 1);
const fontSize = Math.max(node.id === this.graph.rootId ? 12 : 9.5, 12 * scale);
const scale = labelScreenScale(this.zoom) * (node.id === this.graph.rootId || node.id === ROOT_ID ? 1.18 : point.nodeRadius >= 24 ? 1.1 : point.nodeRadius >= 15 ? 1.04 : 1);
const fontSize = Math.max(node.id === this.graph.rootId || node.id === ROOT_ID ? 12 : 9.5, 12 * scale);
label.style.fontSize = `${fontSize.toFixed(2)}px`;
label.style.maxWidth = `${Math.round((node.id === this.graph.rootId ? 190 : node.type === 'folder' ? 156 : point.nodeRadius >= 18 ? 146 : 124) * scale)}px`;
label.style.maxWidth = `${Math.round((node.id === this.graph.rootId || node.id === ROOT_ID ? 190 : node.type === 'folder' ? 156 : point.nodeRadius >= 18 ? 146 : 124) * scale)}px`;
label.style.opacity = String(direct ? 0.96 : Math.min(0.9, 0.22 + strength * 0.68));
const visualNodeRadius = Math.max(5, point.nodeRadius * this.palette().nodeScale * nodeScreenScale(this.zoom) * 0.62);
const visualNodeRadius = Math.max(5, nodePointSize(point.nodeRadius, this.palette().nodeScale) * nodeScreenScale(this.zoom) * 0.62);
const labelOffset = Math.max(9, visualNodeRadius + 6 * scale);
label.style.transform = `translate3d(${screen.x.toFixed(1)}px, ${(screen.y + labelOffset).toFixed(1)}px, 0)`;
if (this.active.dimOthers && !directIds.has(node.id) && !this.active.relatedNodes.has(node.id)) {
@ -582,6 +649,30 @@ function makeMeshGeometry(positions: number[], colors: number[]): BufferGeometry
return geometry;
}
function pushRingSegments(
positions: number[],
colors: number[],
color: Color,
centerX: number,
centerY: number,
radius: number,
depth: number,
z: number,
): void {
const steps = Math.max(96, Math.ceil(radius / 16));
const dashEvery = Math.max(3, Math.round(steps / 72));
const phase = depth % 2 === 0 ? 0 : dashEvery;
for (let step = 0; step < steps; step++) {
if (((step + phase) % (dashEvery * 2)) >= dashEvery) continue;
const a0 = (step / steps) * Math.PI * 2;
const a1 = ((step + 1) / steps) * Math.PI * 2;
positions.push(centerX + Math.cos(a0) * radius, centerY + Math.sin(a0) * radius, z);
positions.push(centerX + Math.cos(a1) * radius, centerY + Math.sin(a1) * radius, z);
pushColor(colors, color, 1);
pushColor(colors, color, 1);
}
}
function pushSegmentBand(
positions: number[],
colors: number[],
@ -655,7 +746,9 @@ function routePoints(source: RadialPoint, target: RadialPoint, route: RadialRout
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
const pull = route.curveStrength ?? 0.16;
const ctrl = { x: midX - midX * pull, y: midY - midY * pull };
const centerX = Number.isFinite(route.centerX) ? route.centerX : Number.isFinite(source.centerX) ? source.centerX : midX;
const centerY = Number.isFinite(route.centerY) ? route.centerY : Number.isFinite(source.centerY) ? source.centerY : midY;
const ctrl = { x: midX + (centerX - midX) * pull, y: midY + (centerY - midY) * pull };
const points: { x: number; y: number }[] = [];
for (let i = 0; i <= 12; i++) {
const t = i / 12;
@ -688,9 +781,9 @@ function distanceToSegment(point: { x: number; y: number }, a: { x: number; y: n
}
function highlightWidthPx(edge: WorldEdge): number {
if (edge.type === 'hierarchy' || edge.type === 'external-hierarchy') return 3.1;
if (edge.externalCount) return 3.6;
return 4.1;
if (edge.type === 'hierarchy' || edge.type === 'external-hierarchy') return 1.8;
if (edge.externalCount) return 2.05;
return 2.35;
}
function labelScore(node: WorldNode, point: RadialPoint, graph: VisibleWorldGraph): number {
@ -718,6 +811,8 @@ function zoomLabelStrength(
rank: number,
denominator: number,
graph: VisibleWorldGraph,
width: number,
height: number,
): number {
const clampedZoom = clampNumber(zoom, 0.003, 6);
const root = node.id === graph.rootId;
@ -729,37 +824,41 @@ function zoomLabelStrength(
const sizeSignal = clampNumber((point.nodeRadius - 4) / 42, 0, 1);
const degreeSignal = clampNumber(Math.log1p(degree) / Math.log1p(80), 0, 1);
const salienceSignal = clampNumber(1 - rank / Math.max(1, denominator), 0, 1);
const screenRadius = nodePointSize(point.nodeRadius, 0.24) * nodeScreenScale(clampedZoom);
const apparentSignal = clampNumber((screenRadius - 2.5) / 13, 0, 1);
const viewportSignal = clampNumber(Math.sqrt(Math.max(1, width * height)) / 1200, 0.55, 1.45);
const nodeCount = Math.max(1, graph.nodes.length || 1);
const leading = rank < Math.max(10, Math.min(58, Math.ceil(nodeCount * 0.05)));
const secondary = rank < Math.max(28, Math.min(170, Math.ceil(nodeCount * 0.18)));
const tertiary = rank < Math.max(80, Math.min(520, Math.ceil(nodeCount * 0.38)));
let threshold = 1.24 - sizeSignal * 0.64 - degreeSignal * 0.22 - salienceSignal * 0.42;
let threshold = 1.04 - apparentSignal * 0.5 - sizeSignal * 0.34 - degreeSignal * 0.2 - salienceSignal * 0.3 - (viewportSignal - 0.55) * 0.08;
if (leading) threshold -= 0.28;
else if (secondary) threshold -= 0.2;
if (leading) threshold -= 0.24;
else if (secondary) threshold -= 0.17;
else if (tertiary) threshold -= 0.08;
if (folder) threshold -= node.representativeFile ? 0.18 : 0.12;
else if (external) threshold -= 0.04;
if (folder) threshold -= node.representativeFile ? 0.22 : 0.15;
else if (external) threshold -= 0.06;
if (unresolved) threshold += 0.18;
threshold = clampNumber(threshold, 0.16, 1.38);
const fade = smoothstep(threshold - 0.14, threshold + 0.1, clampedZoom);
threshold = clampNumber(threshold, 0.08, 1.22);
const fade = smoothstep(threshold - 0.18, threshold + 0.1, clampedZoom);
const leadingFade = leading
? smoothstep(0.12, 0.38, clampedZoom)
? smoothstep(0.07, 0.26, clampedZoom)
: secondary
? smoothstep(0.22, 0.62, clampedZoom) * 0.9
? smoothstep(0.14, 0.44, clampedZoom) * 0.92
: tertiary
? smoothstep(0.46, 0.96, clampedZoom) * 0.7
? smoothstep(0.28, 0.7, clampedZoom) * 0.74
: 0;
const largeFade =
point.nodeRadius >= 24
? smoothstep(0.22, 0.56, clampedZoom) * 0.96
? smoothstep(0.14, 0.42, clampedZoom) * 0.98
: point.nodeRadius >= 15
? smoothstep(0.42, 0.86, clampedZoom) * 0.78
? smoothstep(0.24, 0.64, clampedZoom) * 0.82
: 0;
const smallFade = !unresolved ? smoothstep(0.86, 1.28, clampedZoom) * 0.82 : 0;
const closeFade = !unresolved ? smoothstep(1.12, 1.46, clampedZoom) * 0.98 : 0;
return clampNumber(Math.max(fade, leadingFade, largeFade, smallFade, closeFade), 0, 1);
const apparentFade = !unresolved ? smoothstep(0.12, 0.82, clampedZoom) * apparentSignal * 0.96 : 0;
const smallFade = !unresolved ? smoothstep(0.48, 0.98, clampedZoom) * 0.9 : 0;
const closeFade = !unresolved ? smoothstep(0.82, 1.18, clampedZoom) * 0.98 : 0;
return clampNumber(Math.max(fade, leadingFade, largeFade, apparentFade, smallFade, closeFade), 0, 1);
}
function labelScreenScale(zoom: number): number {
@ -767,14 +866,20 @@ function labelScreenScale(zoom: number): number {
return 0.62 + smoothstep(0.06, 1.48, clampedZoom) * 0.88;
}
function nodePointSize(nodeRadius: number, paletteScale: number): number {
const radius = Math.max(0, Number.isFinite(nodeRadius) ? nodeRadius : 8);
const gentleBase = 2.7 + radius * paletteScale + Math.sqrt(radius) * 0.44;
const hubLift = smoothstep(20, 66, radius) * radius * paletteScale * 0.16;
return clampNumber(gentleBase + hubLift, 3.4, 24);
}
function nodeScreenScale(zoom: number): number {
const clampedZoom = clampNumber(zoom, 0.003, 6);
return (
0.7 +
smoothstep(0.07, 0.36, clampedZoom) * 0.36 +
smoothstep(0.36, 1.32, clampedZoom) * 0.82 +
smoothstep(1.32, 3.2, clampedZoom) * 0.72 +
smoothstep(3.2, 6, clampedZoom) * 0.28
0.68 +
smoothstep(0.035, 0.24, clampedZoom) * 0.4 +
smoothstep(0.18, 1.2, clampedZoom) * 0.5 +
smoothstep(1.2, 6, clampedZoom) * 0.24
);
}

View file

@ -44,6 +44,7 @@ interface PinPath {
id: string;
key: string;
kind: 'node' | 'link';
active: boolean;
nodeId?: string;
edgeId?: string;
source?: string;
@ -122,7 +123,6 @@ export class Radial2DController extends Component {
if (!this.renderer || !this.canvasHost) return;
this.renderer.resize(this.canvasHost.clientWidth, this.canvasHost.clientHeight);
if (this.needsFit) {
this.renderer.fitToLayout();
this.needsFit = false;
}
this.renderer.render();
@ -134,18 +134,19 @@ export class Radial2DController extends Component {
rebuild(reason: string): void {
const radial = this.radial();
const shouldReveal = ['start', 'manual', 'root', 'focus', 'atlas'].includes(reason);
const loadingText = this.t('loading.radial');
if (shouldReveal) this.renderer?.showLoadingMask(loadingText);
this.index.rebuild(radial);
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');
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
this.state.selectedNodeId = this.selectedNodeId;
this.state.selectedLink = this.selectedLink;
const layoutGraph = this.index.buildVisibleGraph({
...this.state,
showLinkOverlay: true,
hiddenLegendItems: [],
pinNeedsHoverLinks: true,
});
const renderHidden = new Set(this.state.hiddenLegendItems);
if (!this.state.showLinkOverlay) renderHidden.add('link');
@ -156,14 +157,18 @@ export class Radial2DController extends Component {
nodeSpacing: DEFAULT_NODE_SPACING,
swirlStrength: radial.swirlStrength,
});
this.renderer?.setTheme(this.resolvedCanvasScheme());
this.renderer?.setData(graph, this.layout, radial.labelVisibility);
this.resize();
this.applyActiveState();
this.renderPanel();
if (['start', 'manual', 'root', 'focus', 'atlas'].includes(reason)) {
this.renderer?.playRevealFromRoot(graph.rootId, this.t('loading.radial'));
const renderer = this.renderer;
renderer?.beginRenderBatch();
try {
renderer?.setTheme(this.resolvedCanvasScheme());
this.resize();
renderer?.setData(graph, this.layout, radial.labelVisibility);
this.applyActiveState();
if (shouldReveal) renderer?.playRevealFromRoot(graph.rootId, loadingText);
} finally {
renderer?.endRenderBatch();
}
this.renderPanel();
}
private radial(): RadialSettings {
@ -171,7 +176,6 @@ export class Radial2DController extends Component {
}
private registerVaultEvents(): void {
this.registerEvent(this.app.metadataCache.on('resolved', this.redrawSoon));
this.registerEvent(this.app.vault.on('rename', this.redrawSoon));
this.registerEvent(this.app.vault.on('delete', this.redrawSoon));
this.registerEvent(this.app.vault.on('create', this.redrawSoon));
@ -351,10 +355,11 @@ export class Radial2DController extends Component {
addNode(activeNode, this.state.hoverHighlightMode, false);
addLink(activeLink, false);
for (const pin of this.pinnedPaths) {
if (!pin.active) continue;
if (pin.kind === 'node') addNode(pin.nodeId, pin.mode, true);
else addLink(this.findGraphEdgeForPin(pin), true);
}
state.hasActive = Boolean(activeNode || activeLink || this.pinnedPaths.length);
state.hasActive = Boolean(activeNode || activeLink || this.pinnedPaths.some((pin) => pin.active));
state.dimOthers = Boolean(activeNode || activeLink);
return state;
}
@ -447,6 +452,8 @@ export class Radial2DController extends Component {
if (node.type === 'note') this.button(actions, this.t('common.open'), () => void this.openNode(node.id));
if (node.type === 'note') this.button(actions, this.t('common.focus'), () => this.focusNote(node.id));
if (node.type === 'folder') this.button(actions, this.t('common.root'), () => this.useAsRoot(node.id));
const rootParentId = this.currentRootParentId();
if (rootParentId !== null) this.button(actions, this.t('inspect.parentRoot'), () => this.useAsRoot(rootParentId));
this.renderNeighborList(parent, node);
}
@ -502,20 +509,54 @@ export class Radial2DController extends Component {
parent.createDiv({ cls: 'mwm-side-muted', text: this.t('pins.empty') });
return;
}
const groupsById = new Map(this.pinGroups.map((group) => [group.id, group]));
const pinsByGroup = new Map<string, PinPath[]>();
const ungrouped: PinPath[] = [];
for (const pin of this.pinnedPaths) {
const row = parent.createDiv({ cls: 'mwm-pin-row' });
const check = row.createEl('input', { attr: { type: 'checkbox' } });
check.checked = this.selectedPinIds.has(pin.id);
check.addEventListener('change', () => {
if (check.checked) this.selectedPinIds.add(pin.id);
else this.selectedPinIds.delete(pin.id);
this.renderPanel();
});
const main = row.createEl('button', { cls: 'mwm-pin-main', text: pin.title });
main.addEventListener('click', () => this.locatePin(pin));
this.button(row, this.t('common.inspect'), () => this.inspectPin(pin));
this.button(row, 'X', () => this.removePin(pin.id));
if (!pin.groupId || !groupsById.has(pin.groupId)) {
ungrouped.push(pin);
continue;
}
const list = pinsByGroup.get(pin.groupId) ?? [];
list.push(pin);
pinsByGroup.set(pin.groupId, list);
}
if (ungrouped.length) this.renderPinGroup(parent, null, ungrouped);
for (const group of this.pinGroups) {
const pins = pinsByGroup.get(group.id) ?? [];
if (pins.length) this.renderPinGroup(parent, group, pins);
}
}
private renderPinGroup(parent: HTMLElement, group: PinGroup | null, pins: PinPath[]): void {
const wrapper = parent.createDiv({ cls: 'mwm-pin-group' });
const header = wrapper.createDiv({ cls: 'mwm-pin-group-header' });
header.createSpan({ cls: 'mwm-pin-group-title', text: group?.name ?? this.t('pins.ungrouped') });
header.createSpan({ cls: 'mwm-pin-count', text: String(pins.length) });
if (group) this.button(header, this.t('common.ungroup'), () => this.removePinGroup(group.id));
for (const pin of pins) this.renderPinRow(wrapper, pin);
}
private renderPinRow(parent: HTMLElement, pin: PinPath): void {
const row = parent.createDiv({ cls: pin.active ? 'mwm-pin-row' : 'mwm-pin-row is-muted' });
const check = row.createEl('input', {
cls: 'mwm-pin-check',
attr: { type: 'checkbox', title: this.t('pins.selectForGroup'), 'aria-label': this.t('pins.selectForGroup') },
});
check.checked = this.selectedPinIds.has(pin.id);
check.addEventListener('change', () => {
if (check.checked) this.selectedPinIds.add(pin.id);
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 } });
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));
}
private renderViewPage(parent: HTMLElement): void {
@ -810,16 +851,9 @@ export class Radial2DController extends Component {
});
}
private addPin(candidate: Omit<PinPath, 'id' | 'key' | 'groupId'>): void {
private addPin(candidate: Omit<PinPath, 'id' | 'key' | 'active' | 'groupId'>): void {
const key = candidate.kind === 'node' ? `node:${candidate.nodeId}:${candidate.mode}` : `link:${candidate.edgeId ?? `${candidate.source}->${candidate.target}`}`;
const existing = this.pinnedPaths.find((pin) => pin.key === key);
if (existing) {
this.selectedPinIds.add(existing.id);
new Notice(this.t('pins.already'));
this.renderPanel();
return;
}
const pin: PinPath = { ...candidate, id: `pin-${this.nextPinId++}`, key, groupId: null };
const pin: PinPath = { ...candidate, id: `pin-${this.nextPinId++}`, key, active: true, groupId: null };
this.pinnedPaths.push(pin);
this.selectedPinIds.add(pin.id);
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
@ -831,6 +865,7 @@ export class Radial2DController extends Component {
private removePin(id: string): void {
this.pinnedPaths = this.pinnedPaths.filter((pin) => pin.id !== id);
this.selectedPinIds.delete(id);
this.dropEmptyPinGroups();
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
this.applyActiveState();
this.renderPanel();
@ -854,11 +889,42 @@ export class Radial2DController extends Component {
const groupId = `pin-group-${this.nextPinGroupId++}`;
this.pinGroups.push({ id: groupId, name: this.pinGroupName.trim() || `Group ${this.pinGroups.length + 1}` });
for (const pin of pins) pin.groupId = groupId;
this.dropEmptyPinGroups();
this.pinGroupName = '';
this.selectedPinIds.clear();
this.renderPanel();
}
private removePinGroup(groupId: string): void {
this.pinGroups = this.pinGroups.filter((group) => group.id !== groupId);
for (const pin of this.pinnedPaths) {
if (pin.groupId === groupId) pin.groupId = null;
}
this.renderPanel();
}
private ungroupPin(pinId: string): void {
const pin = this.pinnedPaths.find((item) => item.id === pinId);
if (!pin) return;
pin.groupId = null;
this.dropEmptyPinGroups();
this.renderPanel();
}
private dropEmptyPinGroups(): void {
const usedGroups = new Set(this.pinnedPaths.map((pin) => pin.groupId).filter((id): id is string => Boolean(id)));
this.pinGroups = this.pinGroups.filter((group) => usedGroups.has(group.id));
}
private togglePinHighlight(pinId: string): void {
const pin = this.pinnedPaths.find((item) => item.id === pinId);
if (!pin) return;
pin.active = !pin.active;
this.state.pinNeedsHoverLinks = this.pinnedPathsNeedHoverLinks();
this.applyActiveState();
this.renderPanel();
}
private locatePin(pin: PinPath): void {
const nodeId = pin.kind === 'node' ? pin.nodeId : pin.source;
if (!nodeId) return;
@ -881,7 +947,11 @@ export class Radial2DController extends Component {
}
private pinnedPathsNeedHoverLinks(): boolean {
return this.pinnedPaths.some((pin) => pin.kind === 'link' || (pin.kind === 'node' && hoverHighlightsNoteLinks(pin.mode)));
return this.pinnedPaths.some((pin) => pin.active && (pin.kind === 'link' || (pin.kind === 'node' && hoverHighlightsNoteLinks(pin.mode))));
}
private pinKindLabel(pin: PinPath): string {
return pin.kind === 'link' ? this.t('inspect.linkOverlay') : this.t(`hover.${normalizeHoverHighlightMode(pin.mode)}`);
}
private clearDisallowedHoverTargets(): void {
@ -914,6 +984,15 @@ export class Radial2DController extends Component {
this.rebuild('root');
}
private currentRootParentId(): string | null {
const rootId = this.graph?.rootId ?? this.state.rootPath;
if (rootId === ROOT_ID) return null;
const rootNode = this.graph?.nodesById.get(rootId) ?? this.index.nodes.get(rootId);
if (rootNode?.parentId !== undefined && rootNode.parentId !== null) return rootNode.parentId;
const lastSlash = rootId.lastIndexOf('/');
return lastSlash >= 0 ? rootId.slice(0, lastSlash) : ROOT_ID;
}
private useAsRoot(nodeId: string): void {
this.state.mode = 'atlas';
this.state.rootPath = nodeId;
@ -923,7 +1002,8 @@ export class Radial2DController extends Component {
}
private focusActiveNote(): void {
const active = this.index.getActiveNotePath();
const selected = this.selectedNodeId ? this.index.nodes.get(this.selectedNodeId) : null;
const active = selected?.type === 'note' ? selected.id : this.index.getActiveNotePath();
if (active) this.focusNote(active);
}
@ -966,8 +1046,14 @@ export class Radial2DController extends Component {
this.panel?.removeClass('gx-theme-dark');
this.panel?.removeClass('gx-theme-light');
this.panel?.addClass(panelScheme === 'day' ? 'gx-theme-light' : 'gx-theme-dark');
const changed = this.renderer?.setTheme(canvasScheme) ?? false;
if (changed && this.graph && this.layout) this.renderer?.setData(this.graph, this.layout, this.radial().labelVisibility);
const renderer = this.renderer;
renderer?.beginRenderBatch();
try {
const changed = renderer?.setTheme(canvasScheme) ?? false;
if (changed && this.graph && this.layout) renderer?.setData(this.graph, this.layout, this.radial().labelVisibility);
} finally {
renderer?.endRenderBatch();
}
}
private resolvedCanvasScheme(): RadialResolvedScheme {

View file

@ -98,6 +98,7 @@ function buildFocusGraph(model: WorldModel, state: VisibleGraphState, settings:
if (activePath) {
visible.add(activePath);
addAncestors(model, activePath, visible, ROOT_ID);
addDescendants(model, activePath, visible, Math.max(30, siblingLimit));
const active = model.nodes.get(activePath);
if (active?.parentId) {
for (const sibling of (model.childrenByParent.get(active.parentId) ?? []).slice(0, siblingLimit)) visible.add(sibling);
@ -302,6 +303,18 @@ function addDirectFilesForVisibleFolders(model: WorldModel, visible: Set<string>
}
}
function addDescendants(model: WorldModel, id: string, visible: Set<string>, limit: number): void {
const queue = [...(model.childrenByParent.get(id) ?? [])];
let added = 0;
while (queue.length > 0 && added < limit) {
const childId = queue.shift();
if (!childId || visible.has(childId)) continue;
visible.add(childId);
added++;
queue.push(...(model.childrenByParent.get(childId) ?? []));
}
}
function addAncestors(model: WorldModel, id: string, visible: Set<string>, stopId: string): void {
let current = model.nodes.get(id);
while (current && current.parentId !== null) {

View file

@ -928,6 +928,40 @@
opacity: 0.72;
}
.mwm-pin-group {
display: grid;
gap: 6px;
margin-top: 8px;
}
.mwm-pin-group-header {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.mwm-pin-group-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 600;
}
.mwm-pin-count {
flex: 0 0 auto;
min-width: 18px;
padding: 1px 5px;
border-radius: 999px;
background: var(--background-modifier-hover);
font-size: 10px;
line-height: 1.4;
text-align: center;
opacity: 0.72;
}
.mwm-facts {
display: grid;
grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
@ -970,8 +1004,13 @@
gap: 6px;
}
.mwm-pin-row.is-muted {
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 {
flex: 0 0 auto;
font-size: 10px;
padding: 3px 5px;
@ -984,6 +1023,13 @@
overflow: hidden;
}
.mwm-pin-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mwm-floating-controls {
position: absolute;
top: 10px;

View file

@ -110,8 +110,8 @@ describe('radial layout', () => {
const graph = buildVisibleWorldGraph(m, state, DEFAULT_RADIAL_SETTINGS);
const layout = layoutRadialGraph(graph, { ringSpacing: 960, nodeSpacing: 126, swirlStrength: 0 });
const root = layout.positions.get(graph.rootId);
expect(root?.x).toBeCloseTo(0);
expect(root?.y).toBeCloseTo(0);
expect(root?.x).toBeCloseTo(layout.centerX);
expect(root?.y).toBeCloseTo(layout.centerY);
expect([...layout.positions.values()].every((point) => Number.isFinite(point.x) && Number.isFinite(point.y))).toBe(true);
const radii = layout.rings.map((ring) => ring.radius);
expect(radii).toEqual([...radii].sort((a, b) => a - b));
@ -152,7 +152,7 @@ describe('radial layout', () => {
const layout = layoutRadialGraph(graph, { ringSpacing: 960, nodeSpacing: 126, swirlStrength: 0 });
const nodeRadii = [...layout.positions.entries()]
.filter(([id]) => id !== graph.rootId)
.map(([, point]) => Math.hypot(point.x, point.y));
.map(([, point]) => point.radius);
const depthOneRings = layout.rings.filter((ring) => ring.depth === 1);
const minRadius = Math.min(...nodeRadii);
const maxRadius = Math.max(...nodeRadii);