mirror of
https://github.com/miro0o/miniWorldMap.git
synced 2026-07-22 07:46:00 +00:00
temp savepoints. this really sucks.
This commit is contained in:
parent
a726ca20e1
commit
0bd2278ec2
8 changed files with 650 additions and 399 deletions
360
main.js
360
main.js
File diff suppressed because one or more lines are too long
|
|
@ -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: '节点' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
48
styles.css
48
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue