Skip to content

Commit 93842ae

Browse files
committed
feat: pivot map mode back to guided nodes
1 parent a4cced7 commit 93842ae

8 files changed

Lines changed: 240 additions & 144 deletions

File tree

docs/react-flow-structured-view-plan.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# React Flow Structured View Plan
22

3+
## Status Note
4+
5+
As of March 16, 2026, this doc is best read as an experiment record, not the current shipping direction.
6+
7+
What we learned from the React Flow spike:
8+
- stronger hierarchy and branch guidance helped
9+
- always-on arrows felt too diagram-like
10+
- losing free rearrangement was a regression
11+
12+
Current direction:
13+
- keep the D3 node map as the main interaction surface
14+
- fold the useful structure ideas back into a draggable guided node-map mode
15+
- treat the React Flow implementation as reference material, not the primary renderer
16+
17+
This plan still matters as design history and may be useful again for future diagram or VR-adjacent work.
18+
319
## Goal
420

521
Create a genuinely different alternate renderer for WikiWebMap using React Flow.

src/App.tsx

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ import { WikiService } from './WikiService';
55
import './index.css';
66
import { SearchOverlay } from './components/SearchOverlay';
77
import { GraphControls } from './components/GraphControls';
8-
import { NodeDetailsPanel } from './components/NodeDetailsPanel';
8+
import { NodeDetailsPanel } from './components/NodeDetailsPanel';
99
import { SearchStatusOverlay } from './components/SearchStatusOverlay';
1010
import { LensingGridBackground } from './components/LensingGridBackground';
1111
import { ConnectionStatusBar } from './components/ConnectionStatusBar';
12-
import { StructuredFlowView } from './components/StructuredFlowView';
13-
import type { SearchProgress } from './types/SearchProgress';
12+
import type { SearchProgress } from './types/SearchProgress';
1413
import { runPathfinder } from './features/pathfinding/runPathfinder';
1514
import { SUGGESTED_PATHS, type SuggestedPath } from './data/suggestedPaths';
1615
import LogPanel from './components/LogPanel';
@@ -883,32 +882,11 @@ const WikiWebExplorer = () => {
883882
return () => window.clearTimeout(handle);
884883
}, [displayedLinkId, pinnedState.selectedId, graphManagerRef]);
885884

886-
const structuredSnapshot = graphManagerRef.current?.getStateSnapshot() || null;
887-
888885
return (
889886
<div className="w-screen h-screen bg-gray-900 text-white relative overflow-hidden font-sans">
890887
<div className="absolute inset-0 z-0">
891888
<LensingGridBackground graphManagerRef={graphManagerRef} layoutMode={layoutMode} />
892-
<svg
893-
ref={svgRef}
894-
className={`w-full h-full transition-opacity duration-200 ${
895-
layoutMode === 'structured' ? 'pointer-events-none opacity-0' : 'opacity-100'
896-
}`}
897-
/>
898-
{layoutMode === 'structured' && structuredSnapshot && (
899-
<StructuredFlowView
900-
snapshot={structuredSnapshot}
901-
nodeDescriptions={nodeDescriptions}
902-
clickedNodeId={clickedNode?.id || null}
903-
pathSelectedNodeIds={new Set(pathSelectedNodes.map(node => node.id))}
904-
showCrossLinks={showCrossLinks}
905-
preferredRootOrder={Array.from(userTypedNodes)}
906-
onNodeSelect={(node, event) => {
907-
void openNodeDetails(event, node);
908-
}}
909-
onPaneClick={clearFocusedNode}
910-
/>
911-
)}
889+
<svg ref={svgRef} className="w-full h-full" />
912890
</div>
913891

914892
<SearchOverlay

src/GraphManager.ts

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,14 @@ export class GraphManager {
278278
.on('tick', () => this.onTick());
279279
}
280280

281+
private usesGuidedTreeLayout() {
282+
return this.layoutMode !== 'web';
283+
}
284+
285+
private isStructuredMapMode() {
286+
return this.layoutMode === 'structured';
287+
}
288+
281289
private getMetadata(nodeId: string) {
282290
return this.nodeMetadata.get(nodeId) || createDefaultNodeMetadata();
283291
}
@@ -317,6 +325,7 @@ export class GraphManager {
317325
height: this.height,
318326
treeSpacing: this.treeSpacing,
319327
branchSpread: this.branchSpread,
328+
layoutMode: this.layoutMode,
320329
});
321330

322331
this.childrenByParent = forestLayout.childrenByParent;
@@ -343,25 +352,33 @@ export class GraphManager {
343352
(this.simulation.force('y') as d3.ForceY<Node>).strength((node) => this.getTargetStrength(node));
344353

345354
if (reheat) {
346-
this.simulation.alpha(this.layoutMode === 'forest' ? 0.55 : 0.3).restart();
355+
const nextAlpha = this.layoutMode === 'forest' ? 0.55 : this.isStructuredMapMode() ? 0.44 : 0.3;
356+
this.simulation.alpha(nextAlpha).restart();
347357
}
348358
}
349359

350360
private getCollisionRadius(node: Node) {
351361
const connections = this.degreeById.get(node.id) || 0;
352362
const meta = this.getMetadata(node.id);
353-
const layoutBoost = this.layoutMode === 'forest' && meta.colorRole === 'root' ? 6 : 0;
363+
const layoutBoost = this.usesGuidedTreeLayout() && meta.colorRole === 'root'
364+
? (this.isStructuredMapMode() ? 8 : 6)
365+
: 0;
354366
return (Math.min(30 + connections * 0.5, 60) * this.nodeSizeScale) + layoutBoost;
355367
}
356368

357369
private getChargeStrength(node: Node) {
358-
if (this.layoutMode !== 'forest') return -500;
370+
if (this.layoutMode === 'web') return -500;
359371
const meta = this.getMetadata(node.id);
372+
if (this.isStructuredMapMode()) {
373+
if (meta.colorRole === 'root') return -340;
374+
if (meta.layoutDepth !== undefined && meta.layoutDepth > 2) return -190;
375+
return -235;
376+
}
360377
return meta.colorRole === 'root' ? -360 : -220;
361378
}
362379

363380
private getLayoutTarget(node: Node) {
364-
if (this.layoutMode !== 'forest') {
381+
if (!this.usesGuidedTreeLayout()) {
365382
return { x: this.width / 2, y: this.height / 2 };
366383
}
367384

@@ -377,15 +394,24 @@ export class GraphManager {
377394
}
378395

379396
private getTargetStrength(node: Node) {
380-
if (this.layoutMode !== 'forest') return 0.02;
397+
if (!this.usesGuidedTreeLayout()) return 0.02;
381398
const meta = this.getMetadata(node.id);
382399
if (meta.isPinned) return 0.4;
400+
if (this.isStructuredMapMode()) {
401+
if (meta.colorRole === 'root') return 0.24;
402+
return 0.18;
403+
}
383404
if (meta.colorRole === 'root') return 0.18;
384405
return 0.12;
385406
}
386407

387408
private getLinkDistance(link: Link) {
388-
if (this.layoutMode !== 'forest') return this.nodeSpacing;
409+
if (!this.usesGuidedTreeLayout()) return this.nodeSpacing;
410+
if (this.isStructuredMapMode()) {
411+
return link.layoutRole === 'cross'
412+
? Math.max(this.nodeSpacing * 0.6, 96)
413+
: Math.max(this.treeSpacing * 0.74, 118);
414+
}
389415
return link.layoutRole === 'cross'
390416
? Math.max(this.nodeSpacing * 0.75, 120)
391417
: Math.max(this.treeSpacing * 0.58, 92);
@@ -400,7 +426,7 @@ export class GraphManager {
400426
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
401427

402428
if (this.hiddenNodeIds.has(sourceId) || this.hiddenNodeIds.has(targetId)) return false;
403-
if (this.layoutMode === 'forest' && !this.showCrossLinks && link.layoutRole === 'cross') return false;
429+
if (this.usesGuidedTreeLayout() && !this.showCrossLinks && link.layoutRole === 'cross') return false;
404430
return true;
405431
}
406432

@@ -781,7 +807,7 @@ export class GraphManager {
781807
const node = this.nodes.find((candidate) => candidate.id === nodeId);
782808
if (!node || node.x === undefined || node.y === undefined) return;
783809
this.updatePinnedNodePosition(nodeId, { x: node.x, y: node.y });
784-
this.refreshDerivedState({ reheat: this.layoutMode === 'forest' });
810+
this.refreshDerivedState({ reheat: this.usesGuidedTreeLayout() });
785811
this.updateDOM();
786812
}
787813

@@ -796,7 +822,7 @@ export class GraphManager {
796822
});
797823
node.fx = null;
798824
node.fy = null;
799-
this.refreshDerivedState({ reheat: this.layoutMode === 'forest' });
825+
this.refreshDerivedState({ reheat: this.usesGuidedTreeLayout() });
800826
this.updateDOM();
801827
}
802828

@@ -811,7 +837,7 @@ export class GraphManager {
811837
...meta,
812838
isCollapsed: nextValue,
813839
});
814-
this.refreshDerivedState({ reheat: this.layoutMode === 'forest' });
840+
this.refreshDerivedState({ reheat: this.usesGuidedTreeLayout() });
815841
this.updateDOM();
816842
return nextValue;
817843
}
@@ -834,7 +860,7 @@ export class GraphManager {
834860
node.fx = null;
835861
node.fy = null;
836862
});
837-
this.refreshDerivedState({ reheat: this.layoutMode === 'forest' });
863+
this.refreshDerivedState({ reheat: this.usesGuidedTreeLayout() });
838864
this.updateDOM();
839865
}
840866

@@ -1221,6 +1247,7 @@ export class GraphManager {
12211247
const isBacklink = typeof d.type === 'string' && d.type.includes('backlink');
12221248
const isCrossLink = d.layoutRole === 'cross';
12231249
const isForestPrimary = this.layoutMode === 'forest' && !isCrossLink;
1250+
const isStructuredPrimary = this.isStructuredMapMode() && !isCrossLink;
12241251

12251252
const focusActive = this.focusNodeId !== null;
12261253
const isIncidentToFocus = focusActive && (sourceId === this.focusNodeId || targetId === this.focusNodeId);
@@ -1236,6 +1263,9 @@ export class GraphManager {
12361263
const baseStroke = (() => {
12371264
if (isDimmed) return '#555';
12381265
if (isPathLink) return '#00ff88';
1266+
if (isStructuredPrimary && originSeed) {
1267+
return this.hashColor(originSeed, 0.7, 0.64, Math.max(0, Math.min(12, originDepth)) * 7);
1268+
}
12391269
if (isForestPrimary && originSeed) {
12401270
return this.hashColor(originSeed, 0.84, 0.66, Math.max(0, Math.min(12, originDepth)) * 8);
12411271
}
@@ -1248,6 +1278,7 @@ export class GraphManager {
12481278
const baseStrokeWidth = (() => {
12491279
if (isDimmed) return 1;
12501280
if (isPathLink) return 4;
1281+
if (isStructuredPrimary) return 3.2;
12511282
if (isForestPrimary) return 3.8;
12521283
if (isCrossLink) return 1.8;
12531284
return isBacklink ? 2 : 3;
@@ -1256,6 +1287,7 @@ export class GraphManager {
12561287
const baseStrokeOpacity = (() => {
12571288
if (isDimmed) return 0.12;
12581289
if (isPathLink) return 0.85;
1290+
if (isStructuredPrimary) return 0.72;
12591291
if (isForestPrimary) return 0.9;
12601292
if (isCrossLink) return 0.28;
12611293
return isBacklink ? 0.5 : 0.6;
@@ -1407,7 +1439,7 @@ export class GraphManager {
14071439

14081440
group.attr('opacity', this.getNodeOpacity(meta));
14091441

1410-
const radius = this.getCollisionRadius(d) - (this.layoutMode === 'forest' && meta.colorRole === 'root' ? 6 : 0);
1442+
const radius = this.getCollisionRadius(d) - (this.usesGuidedTreeLayout() && meta.colorRole === 'root' ? 6 : 0);
14111443
const focusScale = this.getFocusScale(meta);
14121444
inner.attr('transform', `scale(${focusScale})`);
14131445

@@ -1534,11 +1566,11 @@ export class GraphManager {
15341566
if (meta.isBulkSelected) return '#ff8800'; // Orange for bulk-selected
15351567
if (meta.originSeed) {
15361568
const depth = Math.max(0, Math.min(12, meta.layoutDepth ?? meta.originDepth ?? 0));
1537-
const hueOffset = depth * (this.layoutMode === 'forest' ? 11 : 14);
1538-
const saturation = this.layoutMode === 'forest'
1569+
const hueOffset = depth * (this.usesGuidedTreeLayout() ? 11 : 14);
1570+
const saturation = this.usesGuidedTreeLayout()
15391571
? Math.max(0.48, 0.86 - depth * 0.04)
15401572
: Math.max(0.42, 0.78 - depth * 0.045);
1541-
const lightness = this.layoutMode === 'forest'
1573+
const lightness = this.usesGuidedTreeLayout()
15421574
? Math.max(0.32, 0.62 - depth * 0.03)
15431575
: Math.max(0.34, 0.56 - depth * 0.02);
15441576
return this.hashColor(meta.originSeed, saturation, lightness, hueOffset);
@@ -1586,8 +1618,8 @@ export class GraphManager {
15861618
if (meta.isBulkSelected) return '#ffff00'; // Yellow stroke for bulk-selected
15871619
if (meta.isCurrentlyExploring) return '#ff6600'; // Orange stroke for exploring
15881620
if (meta.isExpanded) return '#00ffff'; // Cyan for expanded
1589-
if (this.layoutMode === 'forest' && meta.isPinned) return '#f8fafc';
1590-
if (this.layoutMode === 'forest' && meta.colorRole === 'root') return '#e2e8f0';
1621+
if (this.usesGuidedTreeLayout() && meta.isPinned) return '#f8fafc';
1622+
if (this.usesGuidedTreeLayout() && meta.colorRole === 'root') return '#e2e8f0';
15911623
return '#fff';
15921624
}
15931625

@@ -1598,8 +1630,8 @@ export class GraphManager {
15981630
if (meta.isPathEndpoint) return 5;
15991631
if (meta.isBulkSelected) return 3;
16001632
if (meta.isExpanded) return 3;
1601-
if (this.layoutMode === 'forest' && meta.isPinned) return 3;
1602-
if (this.layoutMode === 'forest' && meta.colorRole === 'root') return 2.5;
1633+
if (this.usesGuidedTreeLayout() && meta.isPinned) return 3;
1634+
if (this.usesGuidedTreeLayout() && meta.colorRole === 'root') return 2.5;
16031635
if (meta.isDimmed) return 1;
16041636
return 2;
16051637
}
@@ -1621,7 +1653,7 @@ export class GraphManager {
16211653
.attr('text-anchor', 'middle')
16221654
.attr('fill', '#ffffff')
16231655
.attr('font-size', `${Math.max(7, 9 * this.nodeSizeScale)}px`)
1624-
.attr('font-weight', this.layoutMode === 'forest' ? 700 : 'bold')
1656+
.attr('font-weight', this.usesGuidedTreeLayout() ? 700 : 'bold')
16251657
.attr('pointer-events', 'none');
16261658

16271659
// Simple text wrapping
@@ -1688,7 +1720,7 @@ export class GraphManager {
16881720
event.sourceEvent.preventDefault();
16891721
d.fx = event.x;
16901722
d.fy = event.y;
1691-
if (this.layoutMode === 'forest') {
1723+
if (this.usesGuidedTreeLayout()) {
16921724
this.updatePinnedNodePosition(d.id, { x: event.x, y: event.y });
16931725
}
16941726
}
@@ -1701,7 +1733,7 @@ export class GraphManager {
17011733
const distance = Math.sqrt(dx * dx + dy * dy);
17021734

17031735
if (distance > this.dragThreshold) {
1704-
if (this.layoutMode === 'forest') {
1736+
if (this.usesGuidedTreeLayout()) {
17051737
this.updatePinnedNodePosition(d.id, { x: event.x, y: event.y });
17061738
this.refreshDerivedState({ reheat: true });
17071739
this.updateDOM();
@@ -1768,7 +1800,8 @@ export class GraphManager {
17681800
const screenX = transform.applyX(n.x);
17691801
const screenY = transform.applyY(n.y);
17701802
const degree = this.degreeById.get(n.id) || 0;
1771-
const mass = (0.8 + Math.min(10, degree) * 0.25) * this.nodeSizeScale * (this.layoutMode === 'forest' ? 0.82 : 1);
1803+
const modeScale = this.layoutMode === 'forest' ? 0.82 : this.isStructuredMapMode() ? 0.88 : 1;
1804+
const mass = (0.8 + Math.min(10, degree) * 0.25) * this.nodeSizeScale * modeScale;
17721805
result.push({ x: screenX, y: screenY, mass });
17731806
}
17741807
return result;

0 commit comments

Comments
 (0)