diff --git a/.playwright-cli/page-2026-03-15T14-40-38-280Z.yml b/.playwright-cli/page-2026-03-15T14-40-38-280Z.yml new file mode 100644 index 0000000..338e6ef --- /dev/null +++ b/.playwright-cli/page-2026-03-15T14-40-38-280Z.yml @@ -0,0 +1,82 @@ +- generic [ref=e3]: + - img [ref=e5] + - generic [ref=e8]: + - generic [ref=e11]: + - heading "WikiWebMap" [level=1] [ref=e12] + - paragraph [ref=e13]: Trace how Wikipedia topics connect. Start with one idea, or jump into a curated path and watch the graph bridge the gap. + - generic [ref=e14]: + - generic [ref=e15]: Search a Wikipedia topic + - generic [ref=e16]: + - generic [ref=e17]: Explore A Topic + - generic [ref=e18]: Press Enter to add it + - textbox "Search a Wikipedia topic" [ref=e19]: + - /placeholder: Search a Wikipedia topic... + - generic [ref=e20]: + - generic [ref=e21]: + - generic [ref=e22]: Start Here + - paragraph [ref=e23]: Search one topic to seed the map, then click nodes to inspect them or pick two ideas to trace a path. + - generic [ref=e24]: + - generic [ref=e25]: Quick Topics + - generic [ref=e26]: + - button "Physics" [ref=e27] [cursor=pointer] + - button "Jazz" [ref=e28] [cursor=pointer] + - button "Mount Everest" [ref=e29] [cursor=pointer] + - generic [ref=e30]: + - generic [ref=e31]: + - generic [ref=e32]: + - generic [ref=e33]: Curated Path Ideas + - generic [ref=e34]: Use these when you want the app to show you an interesting bridge instead of starting from one page. + - button "Shuffle" [ref=e35] [cursor=pointer] + - generic [ref=e36]: + - button "place Run path Great Barrier Reef→Coral Explore a place through nearby topics and landmarks." [ref=e37] [cursor=pointer]: + - generic [ref=e38]: + - generic [ref=e39]: place + - generic [ref=e40]: Run path + - generic [ref=e41]: Great Barrier Reef→Coral + - generic [ref=e42]: Explore a place through nearby topics and landmarks. + - button "technology Run path OpenAI→Transformer (machine learning model) See how technical ideas and tools connect." [ref=e43] [cursor=pointer]: + - generic [ref=e44]: + - generic [ref=e45]: technology + - generic [ref=e46]: Run path + - generic [ref=e47]: OpenAI→Transformer (machine learning model) + - generic [ref=e48]: See how technical ideas and tools connect. + - button "technology Run path Minecraft→Procedural generation See how technical ideas and tools connect." [ref=e49] [cursor=pointer]: + - generic [ref=e50]: + - generic [ref=e51]: technology + - generic [ref=e52]: Run path + - generic [ref=e53]: Minecraft→Procedural generation + - generic [ref=e54]: See how technical ideas and tools connect. + - button "Start Exploration" [disabled] [ref=e55] + - generic [ref=e56]: "Tip: after the graph appears, click a topic to inspect it. To find a bridge between two topics, use Shift+Click on desktop or Select For Path in the mobile details sheet." + - generic: + - generic [ref=e57]: + - button "Settings" [ref=e58] [cursor=pointer]: + - img [ref=e59] + - button "Legend" [ref=e61] [cursor=pointer]: + - img [ref=e62] + - generic [ref=e64]: + - button "✂️ Prune" [disabled] [ref=e65]: + - generic [ref=e66]: ✂️ + - generic [ref=e67]: Prune + - button "🗑️ Delete Selection" [disabled] [ref=e68]: + - generic [ref=e69]: 🗑️ + - generic [ref=e70]: Delete Selection + - generic [ref=e71]: + - generic [ref=e72]: + - generic [ref=e73]: + - text: "Nodes:" + - strong [ref=e74]: "0" + - generic [ref=e75]: + - text: "Connections:" + - strong [ref=e76]: "0" + - generic [ref=e77]: + - text: "Path:" + - strong [ref=e78]: Shift+Click + - generic [ref=e79]: + - text: "Select:" + - strong [ref=e80]: Alt+Drag + - button "View Session Diagnostics" [ref=e81] [cursor=pointer]: + - img [ref=e82] + - generic [ref=e84]: Start with one topic to seed the graph, or use a curated path card to watch WikiWebMap search for a bridge between two ideas. + - generic: + - generic: Hover a connection line for context. Click a line to pin it. \ No newline at end of file diff --git a/.preview.pid b/.preview.pid new file mode 100644 index 0000000..e76de87 --- /dev/null +++ b/.preview.pid @@ -0,0 +1 @@ +34580 diff --git a/README.md b/README.md index fe9af95..0d24cf3 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Live: `https://wikiconnectionsmap.web.app/` - GitHub Actions validates pull requests and publishes Firebase preview channels before merge. - Merges to `main` deploy to Firebase Hosting live. - Bluehost is assumed to be used only for DNS or custom-domain management unless you have separate hosting outside this repo. -- See `docs/deployment.md`, `docs/release-checklist.md`, `docs/release-prep.md`, `docs/development-plan.md`, `docs/ai-api-mcp-plan.md`, and `docs/env-inventory.md` for release workflow details. +- See `docs/deployment.md`, `docs/release-checklist.md`, `docs/release-prep.md`, `docs/development-plan.md`, `docs/ux-effects-plan.md`, `docs/ai-api-mcp-plan.md`, and `docs/env-inventory.md` for release workflow details. ## Controls (high level) - Drag canvas to pan; scroll to zoom. diff --git a/docs/ux-effects-plan.md b/docs/ux-effects-plan.md new file mode 100644 index 0000000..50d0cb6 --- /dev/null +++ b/docs/ux-effects-plan.md @@ -0,0 +1,183 @@ +# UX And Web Effects Plan + +## Goal + +Make WikiWebMap feel more legible, more intentional, and more polished without destabilizing the production graph experience. + +This plan focuses on: +- first-run clarity +- mobile usability +- interaction feedback +- visual hierarchy +- motion/effects that support understanding instead of distracting from it + +## Design direction + +The product should feel like: +- a research instrument, not a generic dashboard +- visually alive, but still readable +- fast and tactile on desktop +- guided and spacious on mobile + +Core principles: +- Motion should explain state changes. +- Effects should reinforce graph relationships. +- Panels should never compete with the graph for attention. +- The first 30 seconds of use should feel obvious. + +## Biggest current UX gaps + +### 1. First-run orientation is still weak +- New users do not get a strong explanation of what to do first. +- Suggested paths help, but the screen still relies on inference. + +### 2. Mobile feels functional, not polished +- The mobile sheets are safer now, but they still feel like adapted desktop UI. +- Important actions are present, but the experience is not yet elegant. + +### 3. Visual hierarchy is flat in places +- The search box, graph, connection context, settings, and diagnostics all compete. +- There is not yet a strong “primary task” rhythm on load. + +### 4. Motion is mostly utilitarian +- The graph has energy, but UI transitions and contextual reveals are still basic. +- There is room for effects that make cause-and-effect easier to understand. + +### 5. Trust/polish is improving but not complete +- The product is closer to professional release quality now, but the brand/system still needs a more cohesive finish. + +## Recommended rollout + +### Phase 1: Clarity pass +Goal: +- Make the product easier to understand in the first minute. + +Changes: +- Replace the current “just search” feeling with a stronger first-run prompt. +- Add a short guided intro block above or below the search input. +- Clarify the value of suggested paths with better microcopy. +- Improve empty-state language for path search, logs, and connection context. + +Suggested UI work: +- Add a “Start here” card in [SearchOverlay.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/SearchOverlay.tsx) +- Add one-line purpose text under the title +- Give suggested paths short category labels such as “Science”, “Culture”, or “Surprising” +- Refine helper text in [SearchStatusOverlay.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/SearchStatusOverlay.tsx) + +Risk: +- Low + +### Phase 2: Mobile UX pass +Goal: +- Make the mobile experience feel intentionally designed, not merely adapted. + +Changes: +- Turn node details into a more polished bottom sheet with snap-point behavior styling. +- Make connection context feel like a real mobile drawer instead of a floating status panel. +- Prevent panel overlap between search, status, node details, and diagnostics. +- Improve touch-target sizes and spacing consistency. + +Suggested UI work: +- Refine [NodeDetailsPanel.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/NodeDetailsPanel.tsx) +- Refine [ConnectionStatusBar.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/ConnectionStatusBar.tsx) +- Revisit mobile layout in [GraphControls.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/GraphControls.tsx) +- Consider a mobile-specific diagnostics entry point in [LogPanel.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/LogPanel.tsx) + +Risk: +- Medium + +### Phase 3: Motion and effects pass +Goal: +- Add effects that teach the graph and reward interaction. + +Changes: +- Animate panel entrances with consistent timing and easing. +- Add subtle reveal/stagger behavior for suggested paths and search results. +- Make path discovery feel more cinematic when a result resolves. +- Add better hover/focus lighting for nodes and connections. +- Add a soft “state handoff” animation when restoring graph state after an aborted search. + +Suggested effects: +- Search panel fade/slide on first load +- Node details sheet spring-in on mobile +- Context drawer expand/collapse animation +- Path result pulse or trace effect in [GraphManager.ts](/C:/Users/monro/Codex/WikiWebMap/src/GraphManager.ts) +- Softer background parallax or light-field drift in [LensingGridBackground.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/LensingGridBackground.tsx) + +Guardrails: +- Keep transitions short +- Respect `prefers-reduced-motion` +- Avoid effects that obscure text or graph readability + +Risk: +- Medium + +### Phase 4: Visual system polish +Goal: +- Make the whole interface feel like one product, not a collection of good components. + +Changes: +- Tighten typography hierarchy and repeated component spacing. +- Standardize panel treatments, border opacity, blur strength, and button emphasis. +- Replace any remaining “default utility look” with a stronger visual language. +- Align iconography and CTA weight across controls. + +Suggested UI work: +- Normalize shared panel/button styles in [index.css](/C:/Users/monro/Codex/WikiWebMap/src/index.css) +- Unify the title/search/header feel in [SearchOverlay.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/SearchOverlay.tsx) +- Harmonize floating surfaces across [GraphControls.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/GraphControls.tsx), [SearchStatusOverlay.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/SearchStatusOverlay.tsx), and [ConnectionStatusBar.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/ConnectionStatusBar.tsx) + +Risk: +- Low to medium + +### Phase 5: Trust and product finish +Goal: +- Make the app feel release-grade to a new visitor. + +Changes: +- Add slightly better product framing and explanation of what makes the map useful. +- Improve brand consistency in copy and headings. +- Make diagnostics/logging feel intentionally “advanced mode” instead of accidental. +- Add subtle success states when major actions complete. + +Suggested UI work: +- Better product framing in [README.md](/C:/Users/monro/Codex/WikiWebMap/README.md) and the app header +- Stronger loading, success, and empty-state language +- Cleaner affordances for diagnostics access + +Risk: +- Low + +## High-impact UX tasks + +### Top 5 to do next +1. Add a stronger first-run search/onboarding block in [SearchOverlay.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/SearchOverlay.tsx) +2. Convert connection context into a more deliberate mobile/desktop drawer pattern in [ConnectionStatusBar.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/ConnectionStatusBar.tsx) +3. Add motion rules and shared transitions in [index.css](/C:/Users/monro/Codex/WikiWebMap/src/index.css) +4. Polish the node details sheet in [NodeDetailsPanel.tsx](/C:/Users/monro/Codex/WikiWebMap/src/components/NodeDetailsPanel.tsx) +5. Add a more expressive path-result reveal in [GraphManager.ts](/C:/Users/monro/Codex/WikiWebMap/src/GraphManager.ts) + +## Safe implementation order + +1. Copy and hierarchy improvements +2. Mobile sheet/drawer layout cleanup +3. Shared motion tokens and reduced-motion handling +4. Path-result visual effects +5. Larger brand/style polish + +This order keeps the highest-value improvements low-risk at the start. + +## Suggested success criteria + +- A new user can understand what to do in under 10 seconds. +- Mobile users can complete a topic search and a path search without confusion. +- Panels no longer feel crowded or overlapping. +- Motion makes state changes clearer instead of noisier. +- The app feels more premium without slowing down the graph. + +## Recommended branch strategy + +- Ship UX copy and hierarchy changes in one branch. +- Ship mobile panel/layout polish in a separate branch. +- Ship effects/motion work in a separate branch after the layout stabilizes. +- Keep `GraphManager.ts` effect changes isolated from broader refactors. diff --git a/src/App.tsx b/src/App.tsx index 5f6bb52..96d087d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,13 @@ import { RecaptchaService } from './services/RecaptchaService'; import { clientErrorReporter } from './services/ClientErrorReporter'; import { useGraphState, type AppSnapshot } from './hooks/useGraphState'; import { runtimeConfig } from './config/runtimeConfig'; +import { + DEFAULT_BRANCH_SPREAD, + DEFAULT_SHOW_CROSS_LINKS, + DEFAULT_TREE_SPACING, + getDefaultLayoutMode, + type LayoutMode, +} from './features/layout/layoutConfig'; type SearchJob = { id: string; @@ -65,10 +72,11 @@ const WikiWebExplorer = () => { clickedNode, setClickedNode, clickedSummary, setClickedSummary, addTopic, - expandNode, - deleteNodeImperative, - pruneGraph, + expandNode, + deleteNodeImperative, + pruneGraph, pruneLeafNodes, + pruneBranch, undo, redo, pushHistory, @@ -136,7 +144,7 @@ const WikiWebExplorer = () => { const [isTouchDevice, setIsTouchDevice] = useState(false); // Settings - const [showSettings, setShowSettings] = useState(false); + const [showSettings, setShowSettings] = useState(false); const [includeBacklinks, setIncludeBacklinks] = useState(() => { const raw = localStorage.getItem('wikiIncludeBacklinks'); return raw === null ? true : raw === 'true'; @@ -147,10 +155,27 @@ const WikiWebExplorer = () => { return fromStorage || fromEnv; }); - // Visual settings - const [nodeSpacing, setNodeSpacing] = useState(150); - const [recursionDepth, setRecursionDepth] = useState(3); - const [nodeSizeScale, setNodeSizeScale] = useState(1); + // Visual settings + const [layoutMode, setLayoutMode] = useState(() => { + const stored = localStorage.getItem('wikiLayoutMode'); + if (stored === 'web' || stored === 'forest') return stored; + return getDefaultLayoutMode(import.meta.env.DEV); + }); + const [nodeSpacing, setNodeSpacing] = useState(150); + const [treeSpacing, setTreeSpacing] = useState(() => { + const raw = Number(localStorage.getItem('wikiTreeSpacing')); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TREE_SPACING; + }); + const [branchSpread, setBranchSpread] = useState(() => { + const raw = Number(localStorage.getItem('wikiBranchSpread')); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_BRANCH_SPREAD; + }); + const [showCrossLinks, setShowCrossLinks] = useState(() => { + const raw = localStorage.getItem('wikiShowCrossLinks'); + return raw === null ? DEFAULT_SHOW_CROSS_LINKS : raw === 'true'; + }); + const [recursionDepth, setRecursionDepth] = useState(3); + const [nodeSizeScale, setNodeSizeScale] = useState(1); // Refs needed for App logic const svgRef = useRef(null); @@ -164,6 +189,9 @@ const WikiWebExplorer = () => { const searchDebounceTimeoutRef = useRef(null); const searchSnapshotRef = useRef(null); const restoreSearchSnapshotRef = useRef(false); + const refreshClickedNode = useCallback(() => { + setClickedNode(prev => (prev ? { ...prev } : prev)); + }, [setClickedNode]); useEffect(() => { searchQueueRef.current = searchQueue; @@ -328,7 +356,7 @@ const WikiWebExplorer = () => { deleteNodeImperative(nodeId); }; - const handlePruneGraph = () => { + const handleDeleteSelection = () => { pruneGraph(setError); // Extra cleaner for App-local state setActiveLinkContexts(new Set()); @@ -337,6 +365,49 @@ const WikiWebExplorer = () => { dispatchPinned({ type: 'clear' }); }; + const handleToggleNodePin = useCallback(() => { + if (!clickedNode || !graphManagerRef.current) return; + pushHistory(); + if (graphManagerRef.current.isNodePinned(clickedNode.id)) { + graphManagerRef.current.unpinNode(clickedNode.id); + setError(`Released "${clickedNode.title}" from its pinned position.`); + } else { + graphManagerRef.current.pinNode(clickedNode.id); + setError(`Pinned "${clickedNode.title}" in place.`); + } + refreshClickedNode(); + }, [clickedNode, graphManagerRef, pushHistory, refreshClickedNode]); + + const handleToggleBranchCollapse = useCallback(() => { + if (!clickedNode || !graphManagerRef.current) return; + pushHistory(); + const collapsed = graphManagerRef.current.toggleBranchCollapse(clickedNode.id); + setError( + collapsed + ? `Collapsed the branch under "${clickedNode.title}".` + : `Expanded the branch under "${clickedNode.title}".` + ); + refreshClickedNode(); + }, [clickedNode, graphManagerRef, pushHistory, refreshClickedNode]); + + const handleRelayoutTree = useCallback(() => { + if (!clickedNode || !graphManagerRef.current) return; + pushHistory(); + graphManagerRef.current.resetTreeLayout(clickedNode.id); + setError(`Reflowed the tree around "${clickedNode.title}".`); + refreshClickedNode(); + }, [clickedNode, graphManagerRef, pushHistory, refreshClickedNode]); + + const handlePruneBranch = useCallback(() => { + if (!clickedNode) return; + pruneBranch(clickedNode.id, setError); + setActiveLinkContexts(new Set()); + setSearchDockLinkId(null); + setSearchDockPosition(null); + dispatchPinned({ type: 'clear' }); + refreshClickedNode(); + }, [clickedNode, pruneBranch, refreshClickedNode]); + const togglePathSelection = useCallback(async (node: GraphNode) => { const nextSelection = [...pathSelectedNodesRef.current]; const existingIndex = nextSelection.findIndex(item => item.id === node.id); @@ -443,9 +514,14 @@ const WikiWebExplorer = () => { setNodeCount(stats.nodeCount); setLinkCount(stats.linkCount); }, - }); - - updateQueueRef.current = new UpdateQueue(graphManagerRef.current, 500); + }); + + graphManagerRef.current.setLayoutMode(layoutMode); + graphManagerRef.current.setTreeSpacing(treeSpacing); + graphManagerRef.current.setBranchSpread(branchSpread); + graphManagerRef.current.setShowCrossLinks(showCrossLinks); + + updateQueueRef.current = new UpdateQueue(graphManagerRef.current, 500); const trackDock = () => { const dockLinkId = searchDockLinkIdRef.current; @@ -483,13 +559,29 @@ const WikiWebExplorer = () => { return () => window.removeEventListener('resize', onResize); }, []); - useEffect(() => { - if (graphManagerRef.current) graphManagerRef.current.setNodeSpacing(nodeSpacing); - }, [nodeSpacing]); - - useEffect(() => { - if (graphManagerRef.current) graphManagerRef.current.setNodeSizeScale(nodeSizeScale); - }, [nodeSizeScale]); + useEffect(() => { + if (graphManagerRef.current) graphManagerRef.current.setLayoutMode(layoutMode); + }, [layoutMode]); + + useEffect(() => { + if (graphManagerRef.current) graphManagerRef.current.setNodeSpacing(nodeSpacing); + }, [nodeSpacing]); + + useEffect(() => { + if (graphManagerRef.current) graphManagerRef.current.setTreeSpacing(treeSpacing); + }, [treeSpacing]); + + useEffect(() => { + if (graphManagerRef.current) graphManagerRef.current.setBranchSpread(branchSpread); + }, [branchSpread]); + + useEffect(() => { + if (graphManagerRef.current) graphManagerRef.current.setShowCrossLinks(showCrossLinks); + }, [showCrossLinks]); + + useEffect(() => { + if (graphManagerRef.current) graphManagerRef.current.setNodeSizeScale(nodeSizeScale); + }, [nodeSizeScale]); useEffect(() => { const trimmed = apiContactEmail.trim(); @@ -501,9 +593,25 @@ const WikiWebExplorer = () => { WikiService.setApiUserAgent(apiUserAgent); }, [apiContactEmail]); - useEffect(() => { - localStorage.setItem('wikiIncludeBacklinks', includeBacklinks ? 'true' : 'false'); - }, [includeBacklinks]); + useEffect(() => { + localStorage.setItem('wikiIncludeBacklinks', includeBacklinks ? 'true' : 'false'); + }, [includeBacklinks]); + + useEffect(() => { + localStorage.setItem('wikiLayoutMode', layoutMode); + }, [layoutMode]); + + useEffect(() => { + localStorage.setItem('wikiTreeSpacing', String(treeSpacing)); + }, [treeSpacing]); + + useEffect(() => { + localStorage.setItem('wikiBranchSpread', String(branchSpread)); + }, [branchSpread]); + + useEffect(() => { + localStorage.setItem('wikiShowCrossLinks', showCrossLinks ? 'true' : 'false'); + }, [showCrossLinks]); // Sync Metadata useEffect(() => { @@ -717,14 +825,24 @@ const WikiWebExplorer = () => { await enqueueSearch(from, to, 'suggested'); }; - const displayedLinkId = - pinnedState.selectedId || - hoveredLinkId || - (pinnedState.ids.length > 0 ? pinnedState.ids[pinnedState.ids.length - 1] : null); - const displayedLink = displayedLinkId ? graphManagerRef.current?.getLinkById(displayedLinkId) || null : null; - const pinnedLinks = pinnedState.ids - .map(id => graphManagerRef.current?.getLinkById(id)) - .filter((x): x is Link => Boolean(x)); + const displayedLinkId = + pinnedState.selectedId || + hoveredLinkId || + (pinnedState.ids.length > 0 ? pinnedState.ids[pinnedState.ids.length - 1] : null); + const displayedLink = displayedLinkId ? graphManagerRef.current?.getLinkById(displayedLinkId) || null : null; + const pinnedLinks = pinnedState.ids + .map(id => graphManagerRef.current?.getLinkById(id)) + .filter((x): x is Link => Boolean(x)); + const clickedNodeMeta = clickedNode + ? graphManagerRef.current?.getNodeMetadata(clickedNode.id) + : undefined; + const mobileSearchDockMode = !isTouchDevice + ? 'none' + : searchProgress.isSearching && !searchTerminalMinimized + ? 'sheet' + : (searchTerminalMinimized || keepSearching || searchQueue.length > 0 || Boolean(activeSearch)) + ? 'bar' + : 'none'; useEffect(() => { if (!displayedLinkId || !graphManagerRef.current) return; @@ -751,16 +869,18 @@ const WikiWebExplorer = () => { return (
-
- - -
- - + + +
+ + 0} + isTouchDevice={isTouchDevice} + loading={loading} + error={error} suggestions={suggestions} showSuggestions={showSuggestions} setShowSuggestions={setShowSuggestions} @@ -781,11 +901,19 @@ const WikiWebExplorer = () => { { canPruneLeaves={nodeCount > 0} canDeleteSelection={bulkSelectedNodes.length > 0} isTouchDevice={isTouchDevice} + mobileDockMode={mobileSearchDockMode} onPruneLeaves={() => pruneLeafNodes(setError)} - onDeleteSelection={handlePruneGraph} + onDeleteSelection={handleDeleteSelection} + onOpenLogs={() => setLogPanelOpen(true)} /> { clickedCategories={clickedNode ? nodeCategories[clickedNode.title] : undefined} clickedBacklinkCount={clickedNode ? nodeBacklinkCounts[clickedNode.title] : undefined} nodeThumbnails={nodeThumbnails} + layoutMode={layoutMode} + isPinned={Boolean(clickedNodeMeta?.isPinned)} + isBranchCollapsed={Boolean(clickedNodeMeta?.isCollapsed)} isPathSelected={Boolean(clickedNode && pathSelectedNodes.some(node => node.id === clickedNode.id))} pathSelectionCount={pathSelectedNodes.length} onClose={() => setClickedNode(null)} @@ -838,6 +971,10 @@ const WikiWebExplorer = () => { if (!clickedNode) return Promise.resolve(); return togglePathSelection(clickedNode); }} + onTogglePin={handleToggleNodePin} + onToggleBranchCollapse={handleToggleBranchCollapse} + onPruneBranch={handlePruneBranch} + onRelayoutTree={handleRelayoutTree} onDelete={handleDeleteNode} /> diff --git a/src/GraphManager.ts b/src/GraphManager.ts index 29fe730..a251daa 100644 --- a/src/GraphManager.ts +++ b/src/GraphManager.ts @@ -1,4 +1,46 @@ import * as d3 from 'd3'; +import { + collectBranchNodeIds, + computeForestLayout, + type ForestLayoutMetadata, +} from './features/layout/forestLayout'; +import { type LayoutMode } from './features/layout/layoutConfig'; + +const TREE_META_KEYS = new Set([ + 'primaryParentId', + 'treeId', + 'layoutDepth', + 'isPinned', + 'manualPosition', + 'isCollapsed', +]); + +const createDefaultNodeMetadata = (): NodeMetadata => ({ + isUserTyped: false, + isAutoDiscovered: false, + isExpanded: false, + isInPath: false, + isRecentlyAdded: false, + isCurrentlyExploring: false, + isSelected: false, + isPathEndpoint: false, + isBulkSelected: false, + isDimmed: false, + isDimmedByPath: false, + isFocusTarget: false, + isFocusNeighbor: false, + thumbnail: undefined, + originSeed: undefined, + originDepth: undefined, + colorSeed: undefined, + colorRole: undefined, + primaryParentId: undefined, + treeId: undefined, + layoutDepth: undefined, + isPinned: false, + manualPosition: undefined, + isCollapsed: false, +}); export interface Node { id: string; @@ -18,6 +60,7 @@ export interface Link { id: string; // Made required for easier tracking type?: string; // 'manual', 'auto', 'expand', 'path' context?: string; // Text context from Wikipedia + layoutRole?: 'primary' | 'cross'; } export type GraphStateSnapshot = { @@ -58,6 +101,12 @@ export interface NodeMetadata { originDepth?: number; colorSeed?: string; colorRole?: 'root' | 'child'; + primaryParentId?: string; + treeId?: string; + layoutDepth?: number; + isPinned?: boolean; + manualPosition?: { x: number; y: number }; + isCollapsed?: boolean; } /** @@ -82,6 +131,15 @@ export class GraphManager { private nodeMetadata: Map = new Map(); private focusNodeId: string | null = null; private focusNeighborIds: Set = new Set(); + private degreeById: Map = new Map(); + private neighborIdsById: Map> = new Map(); + private childrenByParent: Map = new Map(); + private hiddenNodeIds: Set = new Set(); + private forestTargets: Map = new Map(); + private layoutMode: LayoutMode = 'web'; + private treeSpacing: number = 190; + private branchSpread: number = 160; + private showCrossLinks: boolean = true; private callbacks: GraphCallbacks = {}; private width: number; @@ -208,21 +266,166 @@ export class GraphManager { this.simulation = d3.forceSimulation(this.nodes) .force('link', d3.forceLink(this.links) .id((d: any) => d.id) - .distance(this.nodeSpacing)) - .force('charge', d3.forceManyBody().strength(-500)) // Stronger repulsion + .distance((link) => this.getLinkDistance(link))) + .force('charge', d3.forceManyBody().strength((node) => this.getChargeStrength(node as Node))) .force('center', d3.forceCenter(this.width / 2, this.height / 2)) .force('collision', d3.forceCollide().radius((d: any) => { const node = d as Node; - // Dynamic collision based on connection count - const connections = this.links.filter(l => - (typeof l.source === 'object' ? l.source.id : l.source) === node.id || - (typeof l.target === 'object' ? l.target.id : l.target) === node.id - ).length; - return (Math.min(30 + connections * 0.5, 60) * this.nodeSizeScale) + 15; + return this.getCollisionRadius(node) + 15; })) + .force('x', d3.forceX((node) => this.getLayoutTarget(node).x).strength((node) => this.getTargetStrength(node))) + .force('y', d3.forceY((node) => this.getLayoutTarget(node).y).strength((node) => this.getTargetStrength(node))) .on('tick', () => this.onTick()); } + private getMetadata(nodeId: string) { + return this.nodeMetadata.get(nodeId) || createDefaultNodeMetadata(); + } + + private rebuildGraphCaches() { + this.degreeById = new Map(); + this.neighborIdsById = new Map(); + + for (const node of this.nodes) { + this.degreeById.set(node.id, 0); + this.neighborIdsById.set(node.id, new Set()); + } + + for (const link of this.links) { + const sourceId = typeof link.source === 'object' ? link.source.id : link.source; + const targetId = typeof link.target === 'object' ? link.target.id : link.target; + + this.degreeById.set(sourceId, (this.degreeById.get(sourceId) || 0) + 1); + this.degreeById.set(targetId, (this.degreeById.get(targetId) || 0) + 1); + if (!this.neighborIdsById.has(sourceId)) this.neighborIdsById.set(sourceId, new Set()); + if (!this.neighborIdsById.has(targetId)) this.neighborIdsById.set(targetId, new Set()); + this.neighborIdsById.get(sourceId)!.add(targetId); + this.neighborIdsById.get(targetId)!.add(sourceId); + } + } + + private rebuildTreeCaches() { + const metadataById = new Map(); + this.nodes.forEach((node) => { + metadataById.set(node.id, this.getMetadata(node.id)); + }); + + const forestLayout = computeForestLayout({ + nodes: this.nodes, + metadataById, + width: this.width, + height: this.height, + treeSpacing: this.treeSpacing, + branchSpread: this.branchSpread, + }); + + this.childrenByParent = forestLayout.childrenByParent; + this.hiddenNodeIds = forestLayout.hiddenNodeIds; + this.forestTargets = forestLayout.targets; + } + + private refreshDerivedState(options: { reheat?: boolean; structureChanged?: boolean } = {}) { + const structureChanged = options.structureChanged ?? false; + const reheat = options.reheat ?? structureChanged; + + this.rebuildGraphCaches(); + this.rebuildTreeCaches(); + + if (structureChanged) { + this.simulation.nodes(this.nodes); + (this.simulation.force('link') as d3.ForceLink).links(this.links); + } + + (this.simulation.force('link') as d3.ForceLink).distance((link) => this.getLinkDistance(link)); + (this.simulation.force('charge') as d3.ForceManyBody).strength((node) => this.getChargeStrength(node)); + (this.simulation.force('collision') as d3.ForceCollide).radius((node) => this.getCollisionRadius(node) + 15); + (this.simulation.force('x') as d3.ForceX).strength((node) => this.getTargetStrength(node)); + (this.simulation.force('y') as d3.ForceY).strength((node) => this.getTargetStrength(node)); + + if (reheat) { + this.simulation.alpha(this.layoutMode === 'forest' ? 0.55 : 0.3).restart(); + } + } + + private getCollisionRadius(node: Node) { + const connections = this.degreeById.get(node.id) || 0; + const meta = this.getMetadata(node.id); + const layoutBoost = this.layoutMode === 'forest' && meta.colorRole === 'root' ? 6 : 0; + return (Math.min(30 + connections * 0.5, 60) * this.nodeSizeScale) + layoutBoost; + } + + private getChargeStrength(node: Node) { + if (this.layoutMode !== 'forest') return -500; + const meta = this.getMetadata(node.id); + return meta.colorRole === 'root' ? -360 : -220; + } + + private getLayoutTarget(node: Node) { + if (this.layoutMode !== 'forest') { + return { x: this.width / 2, y: this.height / 2 }; + } + + const meta = this.getMetadata(node.id); + if (meta.isPinned && meta.manualPosition) { + return meta.manualPosition; + } + + return this.forestTargets.get(node.id) || { + x: node.x ?? this.width / 2, + y: node.y ?? this.height / 2, + }; + } + + private getTargetStrength(node: Node) { + if (this.layoutMode !== 'forest') return 0.02; + const meta = this.getMetadata(node.id); + if (meta.isPinned) return 0.4; + if (meta.colorRole === 'root') return 0.18; + return 0.12; + } + + private getLinkDistance(link: Link) { + if (this.layoutMode !== 'forest') return this.nodeSpacing; + return link.layoutRole === 'cross' + ? Math.max(this.nodeSpacing * 0.75, 120) + : Math.max(this.treeSpacing * 0.58, 92); + } + + private hasTreeMetadataChange(metadata: Partial) { + return Object.keys(metadata).some((key) => TREE_META_KEYS.has(key)); + } + + private isLinkVisible(link: Link) { + const sourceId = typeof link.source === 'object' ? link.source.id : link.source; + const targetId = typeof link.target === 'object' ? link.target.id : link.target; + + if (this.hiddenNodeIds.has(sourceId) || this.hiddenNodeIds.has(targetId)) return false; + if (this.layoutMode === 'forest' && !this.showCrossLinks && link.layoutRole === 'cross') return false; + return true; + } + + private updatePinnedNodePosition(nodeId: string, position: { x: number; y: number }) { + const node = this.nodes.find((candidate) => candidate.id === nodeId); + if (!node) return; + const meta = this.getMetadata(nodeId); + const nextMeta = { + ...meta, + isPinned: true, + manualPosition: position, + }; + this.nodeMetadata.set(nodeId, nextMeta); + node.fx = position.x; + node.fy = position.y; + } + + private applyForestVisibilityToSelection( + selection: d3.Selection, + visible: boolean + ) { + selection.attr('display', visible ? null : 'none'); + selection.style('pointer-events', visible ? 'auto' : 'none'); + } + /** * Add nodes to the graph (idempotent - won't add duplicates) */ @@ -244,25 +447,7 @@ export class GraphManager { // Initialize metadata if not exists if (!this.nodeMetadata.has(cleanNode.id)) { - this.nodeMetadata.set(cleanNode.id, { - isUserTyped: false, - isAutoDiscovered: false, - isExpanded: false, - isInPath: false, - isRecentlyAdded: false, - isCurrentlyExploring: false, - isSelected: false, - isPathEndpoint: false, - isBulkSelected: false, - originSeed: undefined, - originDepth: undefined, - colorSeed: undefined, - colorRole: undefined, - isDimmed: false, // Focus dimming - isDimmedByPath: false, // Path dimming - isFocusTarget: false, - isFocusNeighbor: false - }); + this.nodeMetadata.set(cleanNode.id, createDefaultNodeMetadata()); } if (incomingMetadata) { @@ -273,8 +458,7 @@ export class GraphManager { }); if (added > 0) { - this.simulation.nodes(this.nodes); - this.simulation.alpha(0.3).restart(); + this.refreshDerivedState({ structureChanged: true }); this.updateDOM(); this.notifyStats(); } @@ -324,8 +508,7 @@ export class GraphManager { }); if (added > 0) { - (this.simulation.force('link') as d3.ForceLink).links(this.links); - this.simulation.alpha(0.3).restart(); + this.refreshDerivedState({ structureChanged: true }); this.updateDOM(); this.notifyStats(); } @@ -343,17 +526,23 @@ export class GraphManager { * Delete a node and all its connections */ deleteNode(nodeId: string) { - this.nodes = this.nodes.filter(n => n.id !== nodeId); - this.links = this.links.filter(l => { - const sourceId = typeof l.source === 'object' ? l.source.id : l.source; - const targetId = typeof l.target === 'object' ? l.target.id : l.target; - return sourceId !== nodeId && targetId !== nodeId; + this.deleteNodes([nodeId]); + } + + deleteNodes(nodeIds: string[]) { + const idsToDelete = new Set(nodeIds); + if (idsToDelete.size === 0) return; + + this.nodes = this.nodes.filter((node) => !idsToDelete.has(node.id)); + this.links = this.links.filter((link) => { + const sourceId = typeof link.source === 'object' ? link.source.id : link.source; + const targetId = typeof link.target === 'object' ? link.target.id : link.target; + return !idsToDelete.has(sourceId) && !idsToDelete.has(targetId); }); - this.nodeMetadata.delete(nodeId); + idsToDelete.forEach((nodeId) => this.nodeMetadata.delete(nodeId)); - this.simulation.nodes(this.nodes); - (this.simulation.force('link') as d3.ForceLink).links(this.links); + this.refreshDerivedState({ structureChanged: true }); this.updateDOM(); this.notifyStats(); } @@ -366,11 +555,7 @@ export class GraphManager { const nodesToDelete = new Set(); this.nodes.forEach(node => { - const connections = this.links.filter(l => { - const sourceId = typeof l.source === 'object' ? l.source.id : l.source; - const targetId = typeof l.target === 'object' ? l.target.id : l.target; - return sourceId === node.id || targetId === node.id; - }).length; + const connections = this.degreeById.get(node.id) || 0; if (connections < 2) { nodesToDelete.add(node.id); @@ -378,23 +563,8 @@ export class GraphManager { }); if (nodesToDelete.size > 0) { - this.nodes = this.nodes.filter(n => !nodesToDelete.has(n.id)); - this.links = this.links.filter(l => { - const sourceId = typeof l.source === 'object' ? l.source.id : l.source; - const targetId = typeof l.target === 'object' ? l.target.id : l.target; - return !nodesToDelete.has(sourceId) && !nodesToDelete.has(targetId); - }); - - nodesToDelete.forEach(id => this.nodeMetadata.delete(id)); - - this.simulation.nodes(this.nodes); - (this.simulation.force('link') as d3.ForceLink).links(this.links); - - // Force aggressive reorganization + this.deleteNodes(Array.from(nodesToDelete)); this.simulation.alpha(1).restart(); - - this.updateDOM(); - this.notifyStats(); } return nodesToDelete.size; @@ -404,32 +574,18 @@ export class GraphManager { * Update node metadata for styling */ setNodeMetadata(nodeId: string, metadata: Partial) { - const existing = this.nodeMetadata.get(nodeId) || { - isUserTyped: false, - isAutoDiscovered: false, - isExpanded: false, - isInPath: false, - isRecentlyAdded: false, - isCurrentlyExploring: false, - isSelected: false, - isPathEndpoint: false, - isBulkSelected: false, - originSeed: undefined, - originDepth: undefined, - colorSeed: undefined, - colorRole: undefined, - isDimmed: false, - isDimmedByPath: false, - isFocusTarget: false, - isFocusNeighbor: false - }; + const existing = this.nodeMetadata.get(nodeId) || createDefaultNodeMetadata(); const newMeta = { ...existing, ...metadata }; this.nodeMetadata.set(nodeId, newMeta); - // Re-render specifically this node (optimization could be better but this is safe) - this.updateNodes(); - this.updateLinks(); // Update styles + if (this.hasTreeMetadataChange(metadata)) { + this.refreshDerivedState({ reheat: this.layoutMode === 'forest' }); + this.updateDOM(); + return; + } + + this.updateStyleState(); } @@ -512,31 +668,21 @@ export class GraphManager { * Set multiple nodes' metadata at once */ setNodesMetadata(updates: Array<{ nodeId: string; metadata: Partial }>) { + let treeMetadataChanged = false; updates.forEach(({ nodeId, metadata }) => { - const existing = this.nodeMetadata.get(nodeId) || { - isUserTyped: false, - isAutoDiscovered: false, - isExpanded: false, - isInPath: false, - isRecentlyAdded: false, - isCurrentlyExploring: false, - isSelected: false, - isPathEndpoint: false, - isBulkSelected: false, - originSeed: undefined, - originDepth: undefined, - colorSeed: undefined, - colorRole: undefined, - isDimmed: false, - isDimmedByPath: false, - isFocusTarget: false, - isFocusNeighbor: false - }; + const existing = this.nodeMetadata.get(nodeId) || createDefaultNodeMetadata(); this.nodeMetadata.set(nodeId, { ...existing, ...metadata }); + treeMetadataChanged = treeMetadataChanged || this.hasTreeMetadataChange(metadata); }); - this.updateDOM(); + if (treeMetadataChanged) { + this.refreshDerivedState({ reheat: this.layoutMode === 'forest' }); + this.updateDOM(); + return; + } + + this.updateStyleState(); } /** @@ -554,14 +700,7 @@ export class GraphManager { meta.isFocusNeighbor = false; }); } else { - // Find neighbors - const neighbors = new Set(); - this.links.forEach(l => { - const sourceId = typeof l.source === 'object' ? l.source.id : l.source; - const targetId = typeof l.target === 'object' ? l.target.id : l.target; - if (sourceId === targetNodeId) neighbors.add(targetId); - if (targetId === targetNodeId) neighbors.add(sourceId); - }); + const neighbors = new Set(this.neighborIdsById.get(targetNodeId) || []); this.focusNeighborIds = neighbors; // Apply dimming @@ -575,7 +714,7 @@ export class GraphManager { }); } - this.updateDOM(); + this.updateStyleState(); } /** @@ -588,7 +727,7 @@ export class GraphManager { if (!meta) return; meta.isDimmedByPath = active ? !pathNodeIds!.has(n.id) : false; }); - this.updateDOM(); + this.updateStyleState(); } /** @@ -596,15 +735,107 @@ export class GraphManager { */ setNodeSpacing(spacing: number) { this.nodeSpacing = spacing; - (this.simulation.force('link') as d3.ForceLink).distance(spacing); - this.simulation.alpha(0.3).restart(); + this.refreshDerivedState({ reheat: true }); } setNodeSizeScale(scale: number) { const next = Number.isFinite(scale) ? Math.min(2, Math.max(0.4, scale)) : 1; this.nodeSizeScale = next; + this.refreshDerivedState({ reheat: true }); + this.updateDOM(); + } + + setLayoutMode(mode: LayoutMode) { + if (this.layoutMode === mode) return; + this.layoutMode = mode; + this.refreshDerivedState({ reheat: true }); + this.updateDOM(); + } + + setTreeSpacing(spacing: number) { + this.treeSpacing = Number.isFinite(spacing) ? Math.max(100, Math.min(320, spacing)) : this.treeSpacing; + this.refreshDerivedState({ reheat: true }); + this.updateDOM(); + } + + setBranchSpread(spread: number) { + this.branchSpread = Number.isFinite(spread) ? Math.max(80, Math.min(260, spread)) : this.branchSpread; + this.refreshDerivedState({ reheat: true }); + this.updateDOM(); + } + + setShowCrossLinks(show: boolean) { + this.showCrossLinks = show; + this.updateStyleState(); + } + + getLayoutMode() { + return this.layoutMode; + } + + isNodePinned(nodeId: string) { + return Boolean(this.getMetadata(nodeId).isPinned); + } + + pinNode(nodeId: string) { + const node = this.nodes.find((candidate) => candidate.id === nodeId); + if (!node || node.x === undefined || node.y === undefined) return; + this.updatePinnedNodePosition(nodeId, { x: node.x, y: node.y }); + this.refreshDerivedState({ reheat: this.layoutMode === 'forest' }); + this.updateDOM(); + } + + unpinNode(nodeId: string) { + const node = this.nodes.find((candidate) => candidate.id === nodeId); + if (!node) return; + const meta = this.getMetadata(nodeId); + this.nodeMetadata.set(nodeId, { + ...meta, + isPinned: false, + manualPosition: undefined, + }); + node.fx = null; + node.fy = null; + this.refreshDerivedState({ reheat: this.layoutMode === 'forest' }); + this.updateDOM(); + } + + isBranchCollapsed(nodeId: string) { + return Boolean(this.getMetadata(nodeId).isCollapsed); + } + + toggleBranchCollapse(nodeId: string) { + const meta = this.getMetadata(nodeId); + const nextValue = !meta.isCollapsed; + this.nodeMetadata.set(nodeId, { + ...meta, + isCollapsed: nextValue, + }); + this.refreshDerivedState({ reheat: this.layoutMode === 'forest' }); + this.updateDOM(); + return nextValue; + } + + getBranchNodeIds(nodeId: string, options: { includeSelf?: boolean } = {}) { + return collectBranchNodeIds(nodeId, this.childrenByParent, options); + } + + resetTreeLayout(nodeId: string) { + const meta = this.getMetadata(nodeId); + const treeId = meta.treeId || nodeId; + this.nodes.forEach((node) => { + const nodeMeta = this.getMetadata(node.id); + if ((nodeMeta.treeId || node.id) !== treeId) return; + this.nodeMetadata.set(node.id, { + ...nodeMeta, + isPinned: false, + manualPosition: undefined, + }); + node.fx = null; + node.fy = null; + }); + this.refreshDerivedState({ reheat: this.layoutMode === 'forest' }); this.updateDOM(); - this.simulation.alpha(0.3).restart(); } /** @@ -626,6 +857,11 @@ export class GraphManager { this.nodeMetadata.clear(); this.focusNodeId = null; this.focusNeighborIds = new Set(); + this.degreeById = new Map(); + this.neighborIdsById = new Map(); + this.childrenByParent = new Map(); + this.hiddenNodeIds = new Set(); + this.forestTargets = new Map(); this.simulation.nodes(this.nodes); (this.simulation.force('link') as d3.ForceLink).links(this.links); @@ -640,7 +876,7 @@ export class GraphManager { this.width = nextW; this.height = nextH; (this.simulation.force('center') as d3.ForceCenter).x(this.width / 2).y(this.height / 2); - this.simulation.alpha(0.2).restart(); + this.refreshDerivedState({ reheat: true }); } hasNode(nodeId: string) { @@ -652,11 +888,91 @@ export class GraphManager { } getNodeDegree(nodeId: string) { - return this.links.filter(l => { - const sourceId = typeof l.source === 'object' ? l.source.id : l.source; - const targetId = typeof l.target === 'object' ? l.target.id : l.target; - return sourceId === nodeId || targetId === nodeId; - }).length; + return this.degreeById.get(nodeId) || 0; + } + + getViewportCenter() { + return { x: this.width / 2, y: this.height / 2 }; + } + + getNodePosition(nodeId: string) { + const node = this.nodes.find(n => n.id === nodeId); + if (!node || typeof node.x !== 'number' || typeof node.y !== 'number') return null; + return { x: node.x, y: node.y }; + } + + applyQueuedUpdate(newNodes: Node[], newLinks: Link[]) { + let addedNodes = 0; + let addedLinks = 0; + const addedLinkRecords: Link[] = []; + const updatedLinks: Link[] = []; + + newNodes.forEach(node => { + if (!this.nodes.find(n => n.id === node.id)) { + const incomingMetadata = node.metadata; + const cleanNode: Node = { ...node }; + delete (cleanNode as any).metadata; + + if (cleanNode.x === undefined) cleanNode.x = this.width / 2 + (Math.random() - 0.5) * 300; + if (cleanNode.y === undefined) cleanNode.y = this.height / 2 + (Math.random() - 0.5) * 300; + + this.nodes.push(cleanNode); + addedNodes++; + + if (!this.nodeMetadata.has(cleanNode.id)) { + this.nodeMetadata.set(cleanNode.id, createDefaultNodeMetadata()); + } + + if (incomingMetadata) { + const existing = this.nodeMetadata.get(cleanNode.id)!; + this.nodeMetadata.set(cleanNode.id, { ...existing, ...incomingMetadata }); + } + } + }); + + newLinks.forEach(link => { + const sourceId = typeof link.source === 'object' ? link.source.id : link.source; + const targetId = typeof link.target === 'object' ? link.target.id : link.target; + + const sourceExists = this.nodes.find(n => n.id === sourceId); + const targetExists = this.nodes.find(n => n.id === targetId); + + if (sourceExists && targetExists) { + const existingLink = this.links.find(l => { + const lSourceId = typeof l.source === 'object' ? l.source.id : l.source; + const lTargetId = typeof l.target === 'object' ? l.target.id : l.target; + return lSourceId === sourceId && lTargetId === targetId; + }); + + if (!existingLink) { + if (!link.id) link.id = `${sourceId}-${targetId}`; + this.links.push(link); + addedLinks++; + addedLinkRecords.push(link); + } else { + if (link.type === 'path' && existingLink.type !== 'path') { + existingLink.type = 'path'; + updatedLinks.push(existingLink); + } + if (link.context && !existingLink.context) { + existingLink.context = link.context; + if (!updatedLinks.includes(existingLink)) updatedLinks.push(existingLink); + } + } + } + }); + + if (addedNodes > 0 || addedLinks > 0) { + this.refreshDerivedState({ structureChanged: true }); + this.updateDOM(); + this.notifyStats(); + } else if (updatedLinks.length > 0) { + this.updateDOM(); + } + + if ((addedLinkRecords.length > 0 || updatedLinks.length > 0) && this.callbacks.onLinksApplied) { + this.callbacks.onLinksApplied({ added: addedLinkRecords, updated: updatedLinks }); + } } getStateSnapshot(): GraphStateSnapshot { @@ -710,6 +1026,12 @@ export class GraphManager { this.onTick(); } + private updateStyleState() { + this.updateLinks(); + this.updateNodes(); + this.onTick(); + } + private updateImagePatterns() { const nodesWithThumbnails = this.nodes.filter(n => { const meta = this.nodeMetadata.get(n.id); @@ -772,11 +1094,7 @@ export class GraphManager { const linkGroups = this.linksGroup .selectAll('g.link') - .data(this.links, (d: any) => { - const sourceId = typeof d.source === 'object' ? d.source.id : d.source; - const targetId = typeof d.target === 'object' ? d.target.id : d.target; - return `${sourceId}-${targetId}`; - }); + .data(this.links, (d: any) => d.id); const enter = linkGroups.enter() .append('g') @@ -797,6 +1115,7 @@ export class GraphManager { .attr('stroke', '#444') .attr('stroke-width', 0) .attr('stroke-opacity', 0) + .attr('stroke-linecap', 'round') .style('pointer-events', 'none'); const merged = enter.merge(linkGroups as any); @@ -806,6 +1125,8 @@ export class GraphManager { // Initial style application (visible + hit sizing) merged.each((d, i, nodes) => { const group = d3.select(nodes[i]); + const isVisible = this.isLinkVisible(d); + this.applyForestVisibilityToSelection(group, isVisible); const v = group.select('line.visible'); this.applyLinkStyles(v as any, d); const style = this.getLinkStyle(d); @@ -898,6 +1219,8 @@ export class GraphManager { const isPathLink = Boolean(sourceMeta?.isInPath && targetMeta?.isInPath); const gradientId = isPathLink ? this.getGradientId(sourceId, targetId) : undefined; const isBacklink = typeof d.type === 'string' && d.type.includes('backlink'); + const isCrossLink = d.layoutRole === 'cross'; + const isForestPrimary = this.layoutMode === 'forest' && !isCrossLink; const focusActive = this.focusNodeId !== null; const isIncidentToFocus = focusActive && (sourceId === this.focusNodeId || targetId === this.focusNodeId); @@ -913,6 +1236,10 @@ export class GraphManager { const baseStroke = (() => { if (isDimmed) return '#555'; if (isPathLink) return '#00ff88'; + if (isForestPrimary && originSeed) { + return this.hashColor(originSeed, 0.84, 0.66, Math.max(0, Math.min(12, originDepth)) * 8); + } + if (isCrossLink) return 'rgba(186, 197, 214, 0.85)'; if (isBacklink) return '#ffb020'; if (originSeed) return this.hashColor(originSeed, 0.72, 0.62, Math.max(0, Math.min(12, originDepth)) * 10); return '#888'; @@ -921,12 +1248,16 @@ export class GraphManager { const baseStrokeWidth = (() => { if (isDimmed) return 1; if (isPathLink) return 4; + if (isForestPrimary) return 3.8; + if (isCrossLink) return 1.8; return isBacklink ? 2 : 3; })(); const baseStrokeOpacity = (() => { if (isDimmed) return 0.12; if (isPathLink) return 0.85; + if (isForestPrimary) return 0.9; + if (isCrossLink) return 0.28; return isBacklink ? 0.5 : 0.6; })(); @@ -955,7 +1286,7 @@ export class GraphManager { strokeOpacity, useGradient: isPathLink, gradientId, - dasharray: isBacklink ? '6 10' : undefined, + dasharray: isCrossLink ? '2 8' : isBacklink ? '6 10' : undefined, }; } @@ -1053,89 +1384,86 @@ export class GraphManager { private updateNodes() { const nodeGroups = this.nodesGroup - .selectAll('g') + .selectAll('g.node') .data(this.nodes, (d: any) => d.id); - // Enter const enter = nodeGroups.enter() .append('g') .call(this.setupDrag() as any) .attr('class', 'node') .style('cursor', 'pointer'); - // Merge enter + update - const merged = enter.merge(nodeGroups as any); + enter.append('g').attr('class', 'node-inner'); - // Clear and rebuild node contents (keep outer group for translate) - merged.selectAll('*').remove(); + const merged = enter.merge(nodeGroups as any); merged.each((d, i, nodes) => { const group = d3.select(nodes[i]); - const inner = group.append('g').attr('class', 'node-inner'); - const meta: Partial = this.nodeMetadata.get(d.id) || {}; - - // Update opacity based on dimming (focus vs path) - const isFocusTarget = Boolean(meta.isFocusTarget); - const isFocusNeighbor = Boolean(meta.isFocusNeighbor); - const isDimmedByPath = Boolean(meta.isDimmedByPath); - const isDimmedByFocus = Boolean(meta.isDimmed); - const opacity = (() => { - if (isFocusTarget) return 1; - if (isFocusNeighbor) return 0.92; - if (isDimmedByPath && isDimmedByFocus) return 0.70; - if (isDimmedByPath) return 0.78; - if (isDimmedByFocus) return 0.86; - return 1; - })(); - group.attr('opacity', opacity); - - // Calculate radius based on connections - const connections = this.links.filter(l => { - const sourceId = typeof l.source === 'object' ? l.source.id : l.source; - const targetId = typeof l.target === 'object' ? l.target.id : l.target; - return sourceId === d.id || targetId === d.id; - }).length; - const radius = Math.min(30 + connections * 0.5, 60) * this.nodeSizeScale; + const inner = group.select('g.node-inner'); + const meta: Partial = this.getMetadata(d.id); + const isVisible = !this.hiddenNodeIds.has(d.id); + this.applyForestVisibilityToSelection(group, isVisible); + if (!isVisible) return; + + group.attr('opacity', this.getNodeOpacity(meta)); + const radius = this.getCollisionRadius(d) - (this.layoutMode === 'forest' && meta.colorRole === 'root' ? 6 : 0); const focusScale = this.getFocusScale(meta); inner.attr('transform', `scale(${focusScale})`); - // Background image circle if thumbnail exists - if (meta.thumbnail) { - inner.append('circle') - .attr('r', radius) - .attr('fill', `url(#img-${this.sanitizeId(d.id)})`) - .attr('fill-opacity', 0.4); - } - - // Overlay colored circle - const color = this.getNodeColor(d.id, meta); - inner.append('circle') + const outerRingData: Array<{ + radius: number; + stroke: string; + opacity: number; + width: number; + dasharray?: string; + }> = meta.isFocusTarget + ? [{ radius: radius + 9, stroke: '#22d3ee', opacity: 0.55, width: 4, dasharray: '10 14' }] + : meta.isFocusNeighbor + ? [{ radius: radius + 7, stroke: '#a855f7', opacity: 0.22, width: 3 }] + : []; + + inner + .selectAll('circle.focus-ring') + .data(outerRingData) + .join( + (enterSelection) => enterSelection.append('circle').attr('class', 'focus-ring'), + (updateSelection) => updateSelection, + (exitSelection) => exitSelection.remove() + ) + .attr('r', (ring) => ring.radius) + .attr('fill', 'none') + .attr('stroke', (ring) => ring.stroke) + .attr('stroke-opacity', (ring) => ring.opacity) + .attr('stroke-width', (ring) => ring.width) + .attr('stroke-dasharray', (ring) => ring.dasharray ?? null); + + inner + .selectAll('circle.thumbnail-fill') + .data(meta.thumbnail ? [meta.thumbnail] : []) + .join( + (enterSelection) => enterSelection.append('circle').attr('class', 'thumbnail-fill'), + (updateSelection) => updateSelection, + (exitSelection) => exitSelection.remove() + ) + .attr('r', radius) + .attr('fill', `url(#img-${this.sanitizeId(d.id)})`) + .attr('fill-opacity', 0.4); + + inner + .selectAll('circle.node-fill') + .data([d]) + .join( + (enterSelection) => enterSelection.append('circle').attr('class', 'node-fill'), + (updateSelection) => updateSelection + ) .attr('r', radius) - .attr('fill', color) + .attr('fill', this.getNodeColor(d.id, meta)) .attr('fill-opacity', meta.thumbnail ? 0.3 : 1) .attr('stroke', this.getNodeStroke(meta)) .attr('stroke-width', this.getNodeStrokeWidth(meta)) - .attr('filter', isFocusTarget ? 'url(#focus-glow)' : isFocusNeighbor ? 'url(#neighbor-glow)' : null); - - if (isFocusTarget) { - inner.insert('circle', ':first-child') - .attr('r', radius + 9) - .attr('fill', 'none') - .attr('stroke', '#22d3ee') - .attr('stroke-opacity', 0.55) - .attr('stroke-width', 4) - .attr('stroke-dasharray', '10 14'); - } else if (isFocusNeighbor) { - inner.insert('circle', ':first-child') - .attr('r', radius + 7) - .attr('fill', 'none') - .attr('stroke', '#a855f7') - .attr('stroke-opacity', 0.22) - .attr('stroke-width', 3); - } + .attr('filter', meta.isFocusTarget ? 'url(#focus-glow)' : meta.isFocusNeighbor ? 'url(#neighbor-glow)' : null); - // Text label this.addTextLabel(inner as any, d.title, radius); }); @@ -1205,10 +1533,14 @@ export class GraphManager { if (meta.isPathEndpoint) return '#ff8800'; // Orange for selected path endpoints if (meta.isBulkSelected) return '#ff8800'; // Orange for bulk-selected if (meta.originSeed) { - const depth = Math.max(0, Math.min(12, meta.originDepth ?? 0)); - const hueOffset = depth * 14; - const saturation = Math.max(0.42, 0.78 - depth * 0.045); - const lightness = Math.max(0.34, 0.56 - depth * 0.02); + const depth = Math.max(0, Math.min(12, meta.layoutDepth ?? meta.originDepth ?? 0)); + const hueOffset = depth * (this.layoutMode === 'forest' ? 11 : 14); + const saturation = this.layoutMode === 'forest' + ? Math.max(0.48, 0.86 - depth * 0.04) + : Math.max(0.42, 0.78 - depth * 0.045); + const lightness = this.layoutMode === 'forest' + ? Math.max(0.32, 0.62 - depth * 0.03) + : Math.max(0.34, 0.56 - depth * 0.02); return this.hashColor(meta.originSeed, saturation, lightness, hueOffset); } if (meta.isUserTyped) return '#0088ff'; // Fallback @@ -1233,6 +1565,20 @@ export class GraphManager { return meta ? { ...meta } : undefined; } + private getNodeOpacity(meta: Partial) { + const isFocusTarget = Boolean(meta.isFocusTarget); + const isFocusNeighbor = Boolean(meta.isFocusNeighbor); + const isDimmedByPath = Boolean(meta.isDimmedByPath); + const isDimmedByFocus = Boolean(meta.isDimmed); + + if (isFocusTarget) return 1; + if (isFocusNeighbor) return 0.92; + if (isDimmedByPath && isDimmedByFocus) return 0.7; + if (isDimmedByPath) return 0.78; + if (isDimmedByFocus) return 0.86; + return 1; + } + private getNodeStroke(meta: Partial): string { if (meta.isFocusTarget) return '#22d3ee'; if (meta.isFocusNeighbor) return '#a855f7'; @@ -1240,6 +1586,8 @@ export class GraphManager { if (meta.isBulkSelected) return '#ffff00'; // Yellow stroke for bulk-selected if (meta.isCurrentlyExploring) return '#ff6600'; // Orange stroke for exploring if (meta.isExpanded) return '#00ffff'; // Cyan for expanded + if (this.layoutMode === 'forest' && meta.isPinned) return '#f8fafc'; + if (this.layoutMode === 'forest' && meta.colorRole === 'root') return '#e2e8f0'; return '#fff'; } @@ -1250,6 +1598,8 @@ export class GraphManager { if (meta.isPathEndpoint) return 5; if (meta.isBulkSelected) return 3; if (meta.isExpanded) return 3; + if (this.layoutMode === 'forest' && meta.isPinned) return 3; + if (this.layoutMode === 'forest' && meta.colorRole === 'root') return 2.5; if (meta.isDimmed) return 1; return 2; } @@ -1261,11 +1611,17 @@ export class GraphManager { } private addTextLabel(group: d3.Selection, title: string, radius: number) { - const textElement = group.append('text') + const textElement = group + .selectAll('text.node-label') + .data([title]) + .join( + (enterSelection) => enterSelection.append('text').attr('class', 'node-label'), + (updateSelection) => updateSelection + ) .attr('text-anchor', 'middle') .attr('fill', '#ffffff') .attr('font-size', `${Math.max(7, 9 * this.nodeSizeScale)}px`) - .attr('font-weight', 'bold') + .attr('font-weight', this.layoutMode === 'forest' ? 700 : 'bold') .attr('pointer-events', 'none'); // Simple text wrapping @@ -1295,12 +1651,17 @@ export class GraphManager { const totalHeight = lines.length * lineHeight; const startY = -(totalHeight / 2) + (lineHeight / 2); - lines.forEach((line, i) => { - textElement.append('tspan') - .attr('x', 0) - .attr('y', startY + (i * lineHeight)) - .text(line); - }); + textElement + .selectAll('tspan') + .data(lines) + .join( + (enterSelection) => enterSelection.append('tspan'), + (updateSelection) => updateSelection, + (exitSelection) => exitSelection.remove() + ) + .attr('x', 0) + .attr('y', (_line, index) => startY + (index * lineHeight)) + .text((line) => line); } private setupDrag() { @@ -1327,6 +1688,9 @@ export class GraphManager { event.sourceEvent.preventDefault(); d.fx = event.x; d.fy = event.y; + if (this.layoutMode === 'forest') { + this.updatePinnedNodePosition(d.id, { x: event.x, y: event.y }); + } } }) .on('end', (event, d) => { @@ -1337,7 +1701,13 @@ export class GraphManager { const distance = Math.sqrt(dx * dx + dy * dy); if (distance > this.dragThreshold) { - // Drag end (no-op hook kept for future drag end handling) + if (this.layoutMode === 'forest') { + this.updatePinnedNodePosition(d.id, { x: event.x, y: event.y }); + this.refreshDerivedState({ reheat: true }); + this.updateDOM(); + this.dragStartPos = null; + return; + } } d.fx = null; @@ -1391,21 +1761,14 @@ export class GraphManager { getLensingNodes(): Array<{ x: number; y: number; mass: number }> { const transform = d3.zoomTransform(this.svg.node()!); - const degreeById = new Map(); - for (const l of this.links) { - const s = typeof l.source === 'object' ? l.source.id : l.source; - const t = typeof l.target === 'object' ? l.target.id : l.target; - degreeById.set(s, (degreeById.get(s) || 0) + 1); - degreeById.set(t, (degreeById.get(t) || 0) + 1); - } - const result: Array<{ x: number; y: number; mass: number }> = []; for (const n of this.nodes) { + if (this.hiddenNodeIds.has(n.id)) continue; if (n.x === undefined || n.y === undefined) continue; const screenX = transform.applyX(n.x); const screenY = transform.applyY(n.y); - const degree = degreeById.get(n.id) || 0; - const mass = (0.8 + Math.min(10, degree) * 0.25) * this.nodeSizeScale; + const degree = this.degreeById.get(n.id) || 0; + const mass = (0.8 + Math.min(10, degree) * 0.25) * this.nodeSizeScale * (this.layoutMode === 'forest' ? 0.82 : 1); result.push({ x: screenX, y: screenY, mass }); } return result; diff --git a/src/UpdateQueue.ts b/src/UpdateQueue.ts index 1560ee9..4d6f113 100644 --- a/src/UpdateQueue.ts +++ b/src/UpdateQueue.ts @@ -58,6 +58,14 @@ export class UpdateQueue { * Flush queued updates to the graph manager */ flush() { + if (this.nodeQueue.length > 0 && this.linkQueue.length > 0) { + this.graphManager.applyQueuedUpdate(this.nodeQueue, this.linkQueue); + this.nodeQueue = []; + this.linkQueue = []; + this.timer = null; + return; + } + if (this.nodeQueue.length > 0) { this.graphManager.addNodes(this.nodeQueue); this.nodeQueue = []; diff --git a/src/components/ConnectionStatusBar.tsx b/src/components/ConnectionStatusBar.tsx index ee56fb6..8b3166b 100644 --- a/src/components/ConnectionStatusBar.tsx +++ b/src/components/ConnectionStatusBar.tsx @@ -20,8 +20,12 @@ export function ConnectionStatusBar(props: { }, [link?.id, props.selectedPinnedLinkId]); if (!link && props.pinnedLinks.length === 0) { + if (props.isTouchDevice) { + return null; + } + return ( -
+
{props.isTouchDevice ? 'Tap a connection line for context. Tap again to pin it.' @@ -49,12 +53,24 @@ export function ConnectionStatusBar(props: { const isPinned = Boolean(link && props.pinnedLinks.some(l => l.id === link.id)); const hasLongContext = Boolean(link?.context && link.context.length > 180); + const wrapperClassName = props.isTouchDevice + ? 'fixed inset-x-3 bottom-[6.8rem] z-30 pointer-events-none' + : 'fixed left-3 right-3 bottom-3 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-40 sm:w-[min(720px,calc(100vw-1.5rem))] pointer-events-none'; + const panelClassName = props.isTouchDevice + ? 'pointer-events-auto rounded-[1.75rem] border border-gray-700/60 bg-gray-900/90 px-4 py-4 shadow-[0_20px_60px_rgba(2,6,23,0.55)] backdrop-blur-md' + : 'pointer-events-auto bg-gray-900/85 backdrop-blur-md border border-gray-700/60 rounded-2xl shadow-2xl px-4 py-3'; return ( -
-
+
+
+ {props.isTouchDevice && ( +
+
+
+ )} + {props.pinnedLinks.length > 0 && ( -
+
Pinned
@@ -90,11 +106,11 @@ export function ConnectionStatusBar(props: {
)} -
+
-
-
- Connection +
+
+ Connection Context
{link && (
@@ -108,7 +124,7 @@ export function ConnectionStatusBar(props: { )}
{link ? ( -
+
) : ( -
+
Select a pinned connection.
)}
-
+
-
+
+
+
+ Article Snippet +
+ {hasLongContext && !isLoading && ( + + )} +
+ {!link ? null : isLoading ? (
@@ -169,14 +199,6 @@ export function ConnectionStatusBar(props: { “{link?.context}”
)} - {hasLongContext && !isLoading && ( - - )}
diff --git a/src/components/GraphControls.tsx b/src/components/GraphControls.tsx index bc76420..24292e9 100644 --- a/src/components/GraphControls.tsx +++ b/src/components/GraphControls.tsx @@ -1,10 +1,19 @@ import React, { useState } from 'react'; +import { type LayoutMode } from '../features/layout/layoutConfig'; interface GraphControlsProps { showSettings: boolean; setShowSettings: (show: boolean) => void; + layoutMode: LayoutMode; + setLayoutMode: (mode: LayoutMode) => void; nodeSpacing: number; setNodeSpacing: (spacing: number) => void; + treeSpacing: number; + setTreeSpacing: (spacing: number) => void; + branchSpread: number; + setBranchSpread: (spread: number) => void; + showCrossLinks: boolean; + setShowCrossLinks: (value: boolean) => void; recursionDepth: number; setRecursionDepth: (depth: number) => void; nodeSizeScale: number; @@ -18,15 +27,25 @@ interface GraphControlsProps { canPruneLeaves: boolean; canDeleteSelection: boolean; isTouchDevice: boolean; + mobileDockMode?: 'none' | 'bar' | 'sheet'; onPruneLeaves: () => void; onDeleteSelection: () => void; + onOpenLogs?: () => void; } export const GraphControls: React.FC = ({ showSettings, setShowSettings, + layoutMode, + setLayoutMode, nodeSpacing, setNodeSpacing, + treeSpacing, + setTreeSpacing, + branchSpread, + setBranchSpread, + showCrossLinks, + setShowCrossLinks, recursionDepth, setRecursionDepth, nodeSizeScale, @@ -40,211 +59,427 @@ export const GraphControls: React.FC = ({ canPruneLeaves, canDeleteSelection, isTouchDevice, + mobileDockMode = 'none', onPruneLeaves, onDeleteSelection, + onOpenLogs, }) => { const [showLegend, setShowLegend] = useState(false); + const mobileSheetClassName = + 'fixed inset-x-3 bottom-20 pointer-events-auto rounded-[1.75rem] border border-slate-700/70 bg-slate-900/94 p-4 shadow-[0_22px_60px_rgba(2,6,23,0.6)] backdrop-blur-xl max-h-[60vh] overflow-y-auto'; - return ( -
-
- +
+
+
+ Forest mode emphasizes first-discovered branches while keeping cross-links lighter. Click or tap a node to spotlight its neighbors; click or tap empty space to clear. +
+ + ); - + +
+
+ Forest guides topics into branches. Web keeps the freer spider-map layout. +
+
+
+
+ Spacing + {nodeSpacing}px +
+ setNodeSpacing(Number(e.target.value))} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" + /> +
+
+
+ Tree Spacing + {treeSpacing}px +
+ setTreeSpacing(Number(e.target.value))} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" + /> +
+ Controls the vertical growth distance between branch levels in forest mode. +
+
+
+
+ Branch Spread + {branchSpread}px +
+ setBranchSpread(Number(e.target.value))} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" + /> +
+ Widens or tightens sibling branches in forest mode. +
+
+
+
+ Recursion Depth + {recursionDepth} +
+ setRecursionDepth(Number(e.target.value))} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" + /> +
+ Effective max depth: {recursionDepth * 2} (capped at 6). +
+
+
+
+ Node Size + {Math.round(nodeSizeScale * 100)}% +
+ setNodeSizeScale(Number(e.target.value))} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" + /> +
+
+
+ Link Visibility +
+ +
+ Keeps secondary links between trees visible while forest branches stay readable. +
+
+
+
+ Discovery +
+ +
+ Adds connections from other pages that link to this topic. +
+
+
+
+ API Contact Email +
+ setApiContactEmail(e.target.value)} + placeholder="you@example.com" + className="w-full bg-black/30 border border-gray-700/70 rounded px-2 py-1 text-xs text-gray-200 placeholder:text-gray-500 focus:outline-none focus:border-green-500/60" + /> +
+ Used for Wikipedia API identification. +
+
+
+ {isTouchDevice && ( +
+ Touch shortcuts: open a node, then use Select For Path in the details sheet. Multi-select deletion remains desktop-only. +
+ )} +
+ Protected by reCAPTCHA.{' '} + Privacy + {' · '} + Terms
+ + ); - {showLegend && ( -
-

- Legend -

-
-
- Outgoing links - - + const mobileDockOffsetClassName = mobileDockMode === 'sheet' + ? 'bottom-[15.5rem]' + : mobileDockMode === 'bar' + ? 'bottom-[4.75rem]' + : 'bottom-3'; + + return ( +
+
+ {isTouchDevice ? ( + <> +
-
- Incoming links - - + + +
-
- Path result - - - - - - - - + + + {onOpenLogs && ( + + )} + + + + + ) : ( + <> + + + + + )} +
+ + {showLegend && ( + isTouchDevice ? ( +
+
+

+ Legend +

+
+

+ Use the line styles below to read how topics connect as the map expands. +

+ {renderLegendContent()}
-
- Incoming links are other articles that link to this topic. Click or tap a node to spotlight its neighbors; click or tap empty space to clear. + ) : ( +
+

+ Legend +

+ {renderLegendContent()}
-
+ ) )} - {/* Settings Bubble */} {showSettings && ( -
-

- Graph Physics -

-
- - Nodes: {nodeCount} - - - Connections: {linkCount} - -
-
-
-
- Spacing - {nodeSpacing}px -
- setNodeSpacing(Number(e.target.value))} - className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" - /> -
-
-
- Recursion Depth - {recursionDepth} -
- setRecursionDepth(Number(e.target.value))} - className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" - /> -
- Effective max depth: {recursionDepth * 2} (capped at 6). -
+ isTouchDevice ? ( +
+
+

+ Graph Layout +

+
-
-
- Node Size - {Math.round(nodeSizeScale * 100)}% -
- setNodeSizeScale(Number(e.target.value))} - className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" - /> -
-
-
- Discovery -
- -
- Adds connections from other pages that link to this topic. -
-
-
-
- API Contact Email -
- setApiContactEmail(e.target.value)} - placeholder="you@example.com" - className="w-full bg-black/30 border border-gray-700/70 rounded px-2 py-1 text-xs text-gray-200 placeholder:text-gray-500 focus:outline-none focus:border-green-500/60" - /> -
- Used for Wikipedia API identification. -
+
+ Tune how the map organizes space, how trees fan out, and how many connections it explores when you expand a topic.
+ {renderSettingsContent()}
- {isTouchDevice && ( -
- Touch shortcuts: open a node, then use Select For Path in the details sheet. Multi-select deletion remains desktop-only. -
- )} -
- Protected by reCAPTCHA.{' '} - Privacy - {' · '} - Terms + ) : ( +
+

+ Graph Layout +

+ {renderSettingsContent()}
-
+ ) )} -
- + {!isTouchDevice && ( +
+ - -
+ +
+ )}
); }; diff --git a/src/components/LensingGridBackground.tsx b/src/components/LensingGridBackground.tsx index bbf054f..9913492 100644 --- a/src/components/LensingGridBackground.tsx +++ b/src/components/LensingGridBackground.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, type MutableRefObject } from 'react'; import type { GraphManager } from '../GraphManager'; +import { type LayoutMode } from '../features/layout/layoutConfig'; type LensingNode = { x: number; y: number; mass: number }; @@ -42,7 +43,10 @@ function displacePoint(x: number, y: number, masses: LensingNode[]) { return { x: x + dxTotal, y: y + dyTotal }; } -export function LensingGridBackground(props: { graphManagerRef: MutableRefObject }) { +export function LensingGridBackground(props: { + graphManagerRef: MutableRefObject; + layoutMode: LayoutMode; +}) { const canvasRef = useRef(null); const rafRef = useRef(null); @@ -64,17 +68,29 @@ export function LensingGridBackground(props: { graphManagerRef: MutableRefObject window.addEventListener('resize', onResize); resize(); + let frameCount = 0; const draw = () => { - const intensity = Math.min(1.5, Math.max(0, EFFECT_INTENSITY)); + frameCount++; const w = canvas.clientWidth; const h = canvas.clientHeight; + const rawMasses = props.graphManagerRef.current?.getLensingNodes() || []; + const isForest = props.layoutMode === 'forest'; + const denseGraph = rawMasses.length > 120; + if ((isForest && frameCount % 2 === 1) || (denseGraph && frameCount % 3 !== 0)) { + rafRef.current = requestAnimationFrame(draw); + return; + } + ctx.clearRect(0, 0, w, h); - const rawMasses = props.graphManagerRef.current?.getLensingNodes() || []; + const intensity = Math.min( + 1.5, + Math.max(0, EFFECT_INTENSITY * (isForest ? 0.55 : 1) * (denseGraph ? 0.6 : 1)) + ); const masses = rawMasses .sort((a, b) => b.mass - a.mass) - .slice(0, Math.max(10, Math.round(20 + 40 * intensity))); + .slice(0, Math.max(8, Math.round(16 + 28 * intensity))); const gridSpacing = Math.max(40, Math.round(64 - 14 * intensity)); const sampleStep = Math.max(10, Math.round(18 - 6 * intensity)); @@ -136,9 +152,10 @@ export function LensingGridBackground(props: { graphManagerRef: MutableRefObject window.removeEventListener('resize', onResize); if (rafRef.current) cancelAnimationFrame(rafRef.current); }; - }, [props.graphManagerRef]); + }, [props.graphManagerRef, props.layoutMode]); - const opacity = Math.min(0.9, Math.max(0.2, 0.35 + EFFECT_INTENSITY * 0.55)); + const modeOpacity = props.layoutMode === 'forest' ? 0.26 : 0.35; + const opacity = Math.min(0.9, Math.max(0.16, modeOpacity + EFFECT_INTENSITY * (props.layoutMode === 'forest' ? 0.26 : 0.55))); return ( void; -} - + +interface LogPanelProps { + isOpen: boolean; + onClose: () => void; +} + const LogPanel = ({ isOpen, onClose }: LogPanelProps) => { const [logs, setLogs] = useState([]); const [runtimeErrors, setRuntimeErrors] = useState([]); @@ -15,29 +15,29 @@ const LogPanel = ({ isOpen, onClose }: LogPanelProps) => { const [statusMessage, setStatusMessage] = useState(null); const refreshLogs = () => { - setLogs([...connectionLogger.getLogs()].reverse()); // Show newest first + setLogs([...connectionLogger.getLogs()].reverse()); setRuntimeErrors(clientErrorReporter.getErrors()); }; - - useEffect(() => { - if (isOpen) { - refreshLogs(); - if (autoRefresh) { - const interval = setInterval(refreshLogs, 2000); - return () => clearInterval(interval); - } - } - }, [isOpen, autoRefresh]); - + + useEffect(() => { + if (isOpen) { + refreshLogs(); + if (autoRefresh) { + const interval = setInterval(refreshLogs, 2000); + return () => clearInterval(interval); + } + } + }, [isOpen, autoRefresh]); + const handleExport = () => { const csv = connectionLogger.exportCSV(); const blob = new Blob([csv], { type: 'text/csv' }); const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `wikiweb_logs_${new Date().toISOString().slice(0, 10)}.csv`; - a.click(); - window.URL.revokeObjectURL(url); + const a = document.createElement('a'); + a.href = url; + a.download = `wikiweb_logs_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + window.URL.revokeObjectURL(url); }; const handleClear = () => { @@ -60,180 +60,229 @@ const LogPanel = ({ isOpen, onClose }: LogPanelProps) => { setStatusMessage('Clipboard copy failed. Try exporting the CSV instead.'); } }; - - if (!isOpen) { - return null; - } - - return ( -
-
-

- Session Diagnostics ({logs.length} links / {runtimeErrors.length} errors) -

-
- -
-
- -
- -
- - + + if (!isOpen) { + return null; + } + + return ( +
+
-
- {(confirmClear || statusMessage) && ( -
- {confirmClear ? ( -
- Clear all connection logs and runtime errors for this browser session? -
- - -
-
- ) : ( -
- {statusMessage} + +
+
+ +
+ +
- )} +
- )} -
-
-
- Connection Logs + {(confirmClear || statusMessage) && ( +
+ {confirmClear ? ( +
+ Clear all connection logs and runtime errors for this browser session? +
+ + +
+
+ ) : ( +
+ {statusMessage} + +
+ )}
- - - - - - - - - - {logs.map(log => ( - - - - - - ))} - {logs.length === 0 && ( - - - + )} + +
+
+
+ Connection Logs +
+
+ {logs.length === 0 ? ( +
+ No connection logs yet. Explore the graph to generate them. +
+ ) : ( + logs.map(log => ( +
+
+ + {new Date(log.timestamp).toLocaleTimeString()} + + + {log.type} + +
+
+ {log.source} + + {log.target} +
+
+ )) )} -
-
TimeSource → TargetType
- {new Date(log.timestamp).toLocaleTimeString()} - - {log.source} - - {log.target} - - - {log.type} - -
- No connection logs yet. Explore the graph to generate them. -
-
+
-
-
- Runtime Errors + + + + + + + + + + {logs.map(log => ( + + + + + + ))} + {logs.length === 0 && ( + + + + )} + +
TimeSource → TargetType
+ {new Date(log.timestamp).toLocaleTimeString()} + + {log.source} + + {log.target} + + + {log.type} + +
+ No connection logs yet. Explore the graph to generate them. +
- {runtimeErrors.length === 0 ? ( -
No runtime errors captured in this session.
- ) : ( -
- {runtimeErrors.map(error => ( -
-
-
{error.message}
-
- {new Date(error.timestamp).toLocaleTimeString()} + +
+
+ Runtime Errors +
+ {runtimeErrors.length === 0 ? ( +
No runtime errors captured in this session.
+ ) : ( +
+ {runtimeErrors.map(error => ( +
+
+
{error.message}
+
+ {new Date(error.timestamp).toLocaleTimeString()} +
+
+ {error.source} + {error.url ? ` · ${error.url}` : ''} + {typeof error.line === 'number' ? `:${error.line}` : ''} + {typeof error.column === 'number' ? `:${error.column}` : ''} +
+ {error.detail && ( +
{error.detail}
+ )} + {error.stack && ( +
+                                                {error.stack}
+                                            
+ )}
-
- {error.source} - {error.url ? ` · ${error.url}` : ''} - {typeof error.line === 'number' ? `:${error.line}` : ''} - {typeof error.column === 'number' ? `:${error.column}` : ''} -
- {error.detail && ( -
{error.detail}
- )} - {error.stack && ( -
-                                            {error.stack}
-                                        
- )} -
- ))} -
- )} + ))} +
+ )} +
); }; - -export default LogPanel; + +export default LogPanel; diff --git a/src/components/NodeDetailsPanel.tsx b/src/components/NodeDetailsPanel.tsx index e01ca1d..845e55e 100644 --- a/src/components/NodeDetailsPanel.tsx +++ b/src/components/NodeDetailsPanel.tsx @@ -1,6 +1,7 @@ -import React from 'react'; -import { Node as GraphNode } from '../GraphManager'; - +import React from 'react'; +import { Node as GraphNode } from '../GraphManager'; +import { type LayoutMode } from '../features/layout/layoutConfig'; + interface NodeDetailsPanelProps { clickedNode: GraphNode | null; clickedSummary: string; @@ -8,14 +9,21 @@ interface NodeDetailsPanelProps { clickedCategories?: string[]; clickedBacklinkCount?: number; nodeThumbnails: Record; + layoutMode: LayoutMode; + isPinned: boolean; + isBranchCollapsed: boolean; isPathSelected: boolean; pathSelectionCount: number; onClose: () => void; onExpand: (id: string) => void; onTogglePathSelection: () => Promise; + onTogglePin: () => void; + onToggleBranchCollapse: () => void; + onPruneBranch: () => void; + onRelayoutTree: () => void; onDelete: (id: string) => void; } - + export const NodeDetailsPanel: React.FC = ({ clickedNode, clickedSummary, @@ -23,132 +31,203 @@ export const NodeDetailsPanel: React.FC = ({ clickedCategories, clickedBacklinkCount, nodeThumbnails, + layoutMode, + isPinned, + isBranchCollapsed, isPathSelected, pathSelectionCount, onClose, onExpand, onTogglePathSelection, + onTogglePin, + onToggleBranchCollapse, + onPruneBranch, + onRelayoutTree, onDelete, }) => { if (!clickedNode) return null; - - const thumbnail = nodeThumbnails[clickedNode.title]; - + + const thumbnail = nodeThumbnails[clickedNode.title]; + return ( -
-
-
-
-
-
- {thumbnail ? ( - {clickedNode.title} - ) : ( -
- W -
- )} - -
- -
-

- {clickedNode.title} -

- {(clickedDescription || typeof clickedBacklinkCount === 'number') && ( -
- {clickedDescription && ( - - {clickedDescription} - +
+ +
+ +
+
+
+ Topic Details +
+

+ {clickedNode.title} +

+ {(clickedDescription || typeof clickedBacklinkCount === 'number') && ( +
+ {clickedDescription && ( + + {clickedDescription} + + )} + {layoutMode === 'forest' && ( + + {isPinned ? 'Pinned' : 'Tree-guided'} + + )} + {typeof clickedBacklinkCount === 'number' && ( + + Backlinks: {clickedBacklinkCount} + + )} +
)} - {typeof clickedBacklinkCount === 'number' && ( - - Backlinks: {clickedBacklinkCount} - +
+ +
+
+
Summary
+

+ {clickedSummary || 'Loading summary...'} +

+
+ + {clickedCategories && clickedCategories.length > 0 && ( +
+
Categories
+
+ {clickedCategories.slice(0, 18).map(cat => ( + + {cat} + + ))} +
+
)}
- )} -

- {clickedSummary || 'Loading summary...'} -

- {clickedCategories && clickedCategories.length > 0 && ( -
-
Categories
-
- {clickedCategories.slice(0, 18).map(cat => ( - + +
+
+ + + {layoutMode === 'forest' && ( + <> +
+ {isPinned ? 'Unpin Node' : 'Pin Node'} + + + + )} + + {layoutMode === 'forest' && ( + <> + + + + )}
- )} -
- -
- -
-
-
- ); -}; +
+
+
+ ); +}; diff --git a/src/components/SearchOverlay.tsx b/src/components/SearchOverlay.tsx index 8756355..b4a191a 100644 --- a/src/components/SearchOverlay.tsx +++ b/src/components/SearchOverlay.tsx @@ -1,9 +1,53 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import type { SuggestedPath } from '../data/suggestedPaths'; +const QUICK_START_TOPICS = ['Physics', 'Jazz', 'Mount Everest']; + +const THEME_STYLES: Record = { + science: 'border-cyan-400/25 bg-cyan-400/10 text-cyan-100', + technology: 'border-blue-400/25 bg-blue-400/10 text-blue-100', + culture: 'border-fuchsia-400/25 bg-fuchsia-400/10 text-fuchsia-100', + history: 'border-amber-400/25 bg-amber-400/10 text-amber-100', + place: 'border-emerald-400/25 bg-emerald-400/10 text-emerald-100', + ideas: 'border-violet-400/25 bg-violet-400/10 text-violet-100', +}; + +const inferTheme = (path: SuggestedPath) => { + const haystack = `${path.from} ${path.to}`.toLowerCase(); + + if (/(physics|quantum|mars|moon|saturn|dna|vaccine|brain|statistics|mathematics|topology|science)/.test(haystack)) return 'science'; + if (/(python|javascript|linux|git|openai|internet|cryptography|blockchain|bitcoin|tesla|apple|microsoft|google|minecraft|video game)/.test(haystack)) return 'technology'; + if (/(jazz|hip hop|beatles|taylor swift|harry potter|lord of the rings|dune|star wars|marvel|batman|sherlock|opera|music|film|photography)/.test(haystack)) return 'culture'; + if (/(renaissance|impressionism|roman empire|french revolution|world war ii|samurai|vikings|greek mythology|norse mythology|egyptian mythology)/.test(haystack)) return 'history'; + if (/(tokyo|paris|london|new york city|machu picchu|antarctica|amazon river|great barrier reef|everest|cairo|athens|naples|italy|japan)/.test(haystack)) return 'place'; + return 'ideas'; +}; + +const describePath = (path: SuggestedPath) => { + if (path.note) return path.note; + + const theme = inferTheme(path); + switch (theme) { + case 'science': + return 'Trace a science idea through linked concepts.'; + case 'technology': + return 'See how technical ideas and tools connect.'; + case 'culture': + return 'Follow a path through culture, media, and art.'; + case 'history': + return 'Jump across people, eras, and historical movements.'; + case 'place': + return 'Explore a place through nearby topics and landmarks.'; + default: + return 'Start from two topics and let the graph bridge them.'; + } +}; + interface SearchOverlayProps { searchTerm: string; setSearchTerm: (term: string) => void; + hasGraphContent: boolean; + isTouchDevice: boolean; loading: boolean; error: string; suggestions: string[]; @@ -22,6 +66,8 @@ interface SearchOverlayProps { export const SearchOverlay: React.FC = ({ searchTerm, setSearchTerm, + hasGraphContent, + isTouchDevice, loading, error, suggestions, @@ -36,105 +82,270 @@ export const SearchOverlay: React.FC = ({ onFocusSearch, onBlurSearch, }) => { - return ( -
-
-

- WikiWeb Explorer -

- -
- - { - if (suggestions.length > 0) setShowSuggestions(true); - onFocusSearch(); - }} - onBlur={() => { - setTimeout(() => setShowSuggestions(false), 200); - setTimeout(() => onBlurSearch(), 200); - }} - onKeyDown={(e) => e.key === 'Enter' && onAddTopic(searchTerm)} - placeholder="Search a Wikipedia topic..." - aria-label="Search a Wikipedia topic" - className="w-full pl-4 pr-4 py-3 bg-gray-900/50 border border-gray-700 rounded-xl focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all text-sm placeholder-gray-500 text-white" - /> -
+ const [mobilePanelExpanded, setMobilePanelExpanded] = useState(!isTouchDevice); + + useEffect(() => { + if (!isTouchDevice) { + setMobilePanelExpanded(true); + return; + } + + setMobilePanelExpanded(false); + }, [hasGraphContent, isTouchDevice]); - {showFeaturedPaths && featuredPaths.length > 0 && searchTerm.trim().length === 0 && ( -
-
-
- Try A Path + const isCollapsedMobilePanel = isTouchDevice && !mobilePanelExpanded; + const isSeededCollapsedMobilePanel = isCollapsedMobilePanel && hasGraphContent; + const showDiscoveryPanels = searchTerm.trim().length === 0 && (!isTouchDevice || mobilePanelExpanded); + const mobileToggleLabel = mobilePanelExpanded + ? 'Collapse' + : hasGraphContent + ? 'Expand' + : 'Open Ideas'; + + return ( +
+
+
+
+
+
+
+

+ WikiWebMap +

+ {isSeededCollapsedMobilePanel ? null : isCollapsedMobilePanel ? ( +

+ {hasGraphContent + ? 'Tap a topic to inspect it, or search here to add another branch.' + : 'Search one topic to start, or expand the panel for quick ideas.'} +

+ ) : ( +

+ Trace how Wikipedia topics connect. Start with one idea, or jump into a curated path and watch the graph bridge the gap. +

+ )}
+
+ {isTouchDevice && ( + )} +
+ + {isSeededCollapsedMobilePanel ? ( +
+ + { + if (suggestions.length > 0) setShowSuggestions(true); + onFocusSearch(); + }} + onBlur={() => { + setTimeout(() => setShowSuggestions(false), 200); + setTimeout(() => onBlurSearch(), 200); + }} + onKeyDown={(e) => e.key === 'Enter' && onAddTopic(searchTerm)} + placeholder="Add another topic..." + aria-label="Search a Wikipedia topic" + className="w-full rounded-2xl border border-slate-700 bg-slate-950/60 px-4 py-3 text-sm text-white placeholder-slate-500 transition-all focus:border-cyan-400/60 focus:outline-none focus:ring-2 focus:ring-cyan-500/15" + /> + +
+ ) : ( +
+ +
+ Explore A Topic + Press Enter to add it +
+ { + if (suggestions.length > 0) setShowSuggestions(true); + onFocusSearch(); + }} + onBlur={() => { + setTimeout(() => setShowSuggestions(false), 200); + setTimeout(() => onBlurSearch(), 200); + }} + onKeyDown={(e) => e.key === 'Enter' && onAddTopic(searchTerm)} + placeholder="Search a Wikipedia topic..." + aria-label="Search a Wikipedia topic" + className="w-full rounded-2xl border border-slate-700 bg-slate-950/60 px-4 py-3 text-sm text-white placeholder-slate-500 transition-all focus:border-cyan-400/60 focus:outline-none focus:ring-2 focus:ring-cyan-500/15" + />
-
- {featuredPaths.map((p, idx) => ( + )} + + {showDiscoveryPanels && ( +
+
+
+ Start Here +
+

+ Search one topic to seed the map, then click nodes to inspect them or pick two ideas to trace a path. +

+
+ +
+
+ Quick Topics +
+
+ {QUICK_START_TOPICS.map((topic) => ( + + ))} +
+
+
+ )} + + {showFeaturedPaths && featuredPaths.length > 0 && showDiscoveryPanels && ( +
+
+
+
+ Curated Path Ideas +
+
+ Use these when you want the app to show you an interesting bridge instead of starting from one page. +
+
+
+
+ {featuredPaths.map((p, idx) => { + const theme = inferTheme(p); + return ( + + ); + })} +
+
+ )} + + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + ))}
-
- )} + )} - {showSuggestions && suggestions.length > 0 && ( -
- {suggestions.map((suggestion, index) => ( - - ))} -
- )} - - - - {error && ( -
- {error} -
- )} + {!isSeededCollapsedMobilePanel && ( + + )} + + {!error && searchTerm.trim().length === 0 && (!isTouchDevice || mobilePanelExpanded) && ( +
+ {isCollapsedMobilePanel ? ( + <> + Tip: the graph is live now. Tap a topic to inspect it, or use + Select For Path + in the details sheet to trace between two ideas. + + ) : ( + <> + Tip: after the graph appears, click a topic to inspect it. To find a bridge between two topics, use + Shift+Click + on desktop or + Select For Path + in the mobile details sheet. + + )} +
+ )} + + {error && ( +
+ {error} +
+ )} +
); diff --git a/src/components/SearchStatusOverlay.tsx b/src/components/SearchStatusOverlay.tsx index 4098f52..8af5226 100644 --- a/src/components/SearchStatusOverlay.tsx +++ b/src/components/SearchStatusOverlay.tsx @@ -22,6 +22,9 @@ export function SearchStatusOverlay(props: { isTouchDevice: boolean; onOpenLogs?: () => void; }) { + const mobileShellClassName = 'fixed inset-x-0 bottom-0 z-20 pointer-events-none'; + const mobilePanelClassName = 'pointer-events-auto mx-3 rounded-t-[1.75rem] border border-slate-700/70 bg-slate-900/94 px-4 py-3 shadow-[0_-18px_48px_rgba(2,6,23,0.62)] backdrop-blur-xl'; + const renderQueue = () => { if (props.queue.length === 0) return null; return ( @@ -46,10 +49,35 @@ export function SearchStatusOverlay(props: { }; if (props.isMinimized) { + if (props.isTouchDevice) { + return ( +
+
+
+
+
+ Search In Progress +
+
+ {props.activeSearch ? `${props.activeSearch.from} → ${props.activeSearch.to}` : 'Search terminal ready'} +
+
+ +
+
+
+ ); + } + return ( -
+
- {props.activeSearch ? `Searching ${props.activeSearch.from} → ${props.activeSearch.to}` : 'Search terminal'} + {props.activeSearch ? `Searching ${props.activeSearch.from} → ${props.activeSearch.to}` : 'Search terminal ready'} {props.queue.length > 0 && +{props.queue.length}} +
+ +
+ Depth D{props.searchProgress.currentDepth}/{props.searchProgress.maxDepth} + Nodes {props.searchProgress.exploredCount} + Queue {props.searchProgress.queueSize} + Found {props.foundCount} +
+ + + +
+ {props.searchLog.slice(-4).map((log, i) => ( +
+ {log} +
+ ))} +
+ + {renderQueue()} + +
+ {!props.searchProgress.isPaused ? ( + + ) : ( + + )} + +
+
+
+ ); + } + const wrapperClassName = props.isDocked ? 'absolute z-30 w-80 pointer-events-none' - : 'fixed bottom-3 right-3 left-3 sm:left-auto sm:bottom-6 sm:right-6 z-30 sm:w-80 pointer-events-none'; + : 'fixed bottom-[11.25rem] right-3 left-3 sm:left-auto sm:bottom-6 sm:right-6 z-30 sm:w-80 pointer-events-none'; const wrapperStyle = props.isDocked && props.dockPosition ? { @@ -149,8 +258,38 @@ export function SearchStatusOverlay(props: { } if (props.persistentVisible || props.queue.length > 0) { + if (props.isTouchDevice) { + return ( +
+
+
+
+
+ Search Queue +
+
+ {props.queue.length > 0 ? `${props.queue.length} queued search${props.queue.length === 1 ? '' : 'es'}` : 'Search terminal ready'} +
+
+ +
+ {renderQueue() || ( +
+ Queue empty. Start from the search bar or one of the curated path cards. +
+ )} +
+
+ ); + } + return ( -
+
Search Terminal @@ -161,44 +300,57 @@ export function SearchStatusOverlay(props: { Minimize
- {renderQueue() ||
Queue empty.
} + {renderQueue() || ( +
+ Queue empty. Start from the left panel with a topic search or one of the curated path cards. +
+ )}
); } + if (props.isTouchDevice) { + return null; + } + return ( -
-
- - Nodes: {props.nodeCount} - - - Connections: {props.linkCount} - - - Path:{' '} - - {props.isTouchDevice ? 'Select In Details' : 'Shift+Click'} - - - - {props.isTouchDevice ? 'Delete selection:' : 'Select:'}{' '} - - {props.isTouchDevice ? 'Desktop only' : 'Alt+Drag'} - - - {props.onOpenLogs && ( - - )} +
+
+
+ + Nodes: {props.nodeCount} + + + Connections: {props.linkCount} + + + Path:{' '} + + {props.isTouchDevice ? 'Select In Details' : 'Shift+Click'} + + + + {props.isTouchDevice ? 'Delete selection:' : 'Select:'}{' '} + + {props.isTouchDevice ? 'Desktop only' : 'Alt+Drag'} + + + {props.onOpenLogs && ( + + )} +
+
+ Start with one topic to seed the graph, or use a curated path card to watch WikiWebMap search for a bridge between two ideas. +
); diff --git a/src/features/layout/forestLayout.test.ts b/src/features/layout/forestLayout.test.ts new file mode 100644 index 0000000..f664a47 --- /dev/null +++ b/src/features/layout/forestLayout.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { collectBranchNodeIds, computeForestLayout } from './forestLayout'; + +describe('forestLayout', () => { + it('builds primary-child branches and returns descendants in branch order', () => { + const metadataById = new Map([ + ['Root', { treeId: 'Root', layoutDepth: 0 }], + ['Branch A', { primaryParentId: 'Root', treeId: 'Root', layoutDepth: 1 }], + ['Leaf', { primaryParentId: 'Branch A', treeId: 'Root', layoutDepth: 2 }], + ]); + + const result = computeForestLayout({ + nodes: [ + { id: 'Root' }, + { id: 'Branch A' }, + { id: 'Leaf' }, + ], + metadataById, + width: 1200, + height: 800, + treeSpacing: 180, + branchSpread: 150, + }); + + expect(collectBranchNodeIds('Root', result.childrenByParent)).toEqual([ + 'Root', + 'Branch A', + 'Leaf', + ]); + expect(result.hiddenNodeIds.size).toBe(0); + expect(result.targets.has('Root')).toBe(true); + expect(result.targets.has('Branch A')).toBe(true); + expect(result.targets.has('Leaf')).toBe(true); + }); + + it('hides descendants of collapsed branches while leaving the collapsed node visible', () => { + const metadataById = new Map([ + ['Root', { treeId: 'Root', layoutDepth: 0 }], + ['Branch A', { primaryParentId: 'Root', treeId: 'Root', layoutDepth: 1, isCollapsed: true }], + ['Leaf', { primaryParentId: 'Branch A', treeId: 'Root', layoutDepth: 2 }], + ['Other Root', { treeId: 'Other Root', layoutDepth: 0 }], + ]); + + const result = computeForestLayout({ + nodes: [ + { id: 'Root' }, + { id: 'Branch A' }, + { id: 'Leaf' }, + { id: 'Other Root' }, + ], + metadataById, + width: 1200, + height: 800, + treeSpacing: 180, + branchSpread: 150, + }); + + expect(result.hiddenNodeIds.has('Branch A')).toBe(false); + expect(result.hiddenNodeIds.has('Leaf')).toBe(true); + expect(result.targets.has('Branch A')).toBe(true); + expect(result.targets.has('Leaf')).toBe(false); + expect(result.treeIds).toEqual(['Other Root', 'Root']); + }); +}); diff --git a/src/features/layout/forestLayout.ts b/src/features/layout/forestLayout.ts new file mode 100644 index 0000000..51d7104 --- /dev/null +++ b/src/features/layout/forestLayout.ts @@ -0,0 +1,217 @@ +export type ForestLayoutNode = { + id: string; + x?: number; + y?: number; +}; + +export type ForestManualPosition = { + x: number; + y: number; +}; + +export type ForestLayoutMetadata = { + primaryParentId?: string; + treeId?: string; + layoutDepth?: number; + isPinned?: boolean; + manualPosition?: ForestManualPosition; + isCollapsed?: boolean; +}; + +type ForestLayoutInput = { + nodes: ForestLayoutNode[]; + metadataById: Map; + width: number; + height: number; + treeSpacing: number; + branchSpread: number; +}; + +type ForestLayoutResult = { + targets: Map; + childrenByParent: Map; + hiddenNodeIds: Set; + treeIds: string[]; +}; + +const getMeta = ( + metadataById: Map, + nodeId: string +): ForestLayoutMetadata => metadataById.get(nodeId) || {}; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); + +export const collectPrimaryChildren = ( + nodeIds: Iterable, + metadataById: Map +) => { + const idSet = new Set(nodeIds); + const childrenByParent = new Map(); + + idSet.forEach((nodeId) => { + const meta = getMeta(metadataById, nodeId); + const parentId = meta.primaryParentId; + if (!parentId || !idSet.has(parentId)) return; + const existing = childrenByParent.get(parentId) || []; + existing.push(nodeId); + childrenByParent.set(parentId, existing); + }); + + childrenByParent.forEach((children, parentId) => { + children.sort((a, b) => { + const depthA = getMeta(metadataById, a).layoutDepth ?? 0; + const depthB = getMeta(metadataById, b).layoutDepth ?? 0; + if (depthA !== depthB) return depthA - depthB; + return a.localeCompare(b); + }); + childrenByParent.set(parentId, children); + }); + + return childrenByParent; +}; + +export const collectBranchNodeIds = ( + startNodeId: string, + childrenByParent: Map, + options: { includeSelf?: boolean } = {} +) => { + const includeSelf = options.includeSelf ?? true; + const result: string[] = []; + const stack = includeSelf + ? [startNodeId] + : [...(childrenByParent.get(startNodeId) || [])]; + + while (stack.length > 0) { + const nodeId = stack.pop()!; + result.push(nodeId); + const children = childrenByParent.get(nodeId) || []; + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } + } + + return result; +}; + +export const computeForestLayout = ({ + nodes, + metadataById, + width, + height, + treeSpacing, + branchSpread, +}: ForestLayoutInput): ForestLayoutResult => { + const nodeIds = nodes.map((node) => node.id); + const childrenByParent = collectPrimaryChildren(nodeIds, metadataById); + const hiddenNodeIds = new Set(); + + const rootIds = nodes + .map((node) => node.id) + .filter((nodeId) => { + const meta = getMeta(metadataById, nodeId); + return !meta.primaryParentId || !nodeIds.includes(meta.primaryParentId) || meta.treeId === nodeId; + }) + .sort((a, b) => a.localeCompare(b)); + + const collectHiddenDescendants = (nodeId: string) => { + const descendants = collectBranchNodeIds(nodeId, childrenByParent, { includeSelf: false }); + descendants.forEach((descendantId) => hiddenNodeIds.add(descendantId)); + }; + + nodeIds.forEach((nodeId) => { + if (getMeta(metadataById, nodeId).isCollapsed) { + collectHiddenDescendants(nodeId); + } + }); + + const leafCountMemo = new Map(); + const countLeaves = (nodeId: string): number => { + if (leafCountMemo.has(nodeId)) return leafCountMemo.get(nodeId)!; + const visibleChildren = (childrenByParent.get(nodeId) || []).filter( + (childId) => !hiddenNodeIds.has(childId) + ); + if (visibleChildren.length === 0) { + leafCountMemo.set(nodeId, 1); + return 1; + } + const value = visibleChildren.reduce((sum, childId) => sum + countLeaves(childId), 0); + leafCountMemo.set(nodeId, value); + return value; + }; + + const targets = new Map(); + const visibleRootIds = rootIds.filter((rootId) => !hiddenNodeIds.has(rootId)); + const rootCount = Math.max(visibleRootIds.length, 1); + const columns = Math.max(1, Math.min(3, Math.ceil(Math.sqrt(rootCount)))); + const rows = Math.max(1, Math.ceil(rootCount / columns)); + const spacingX = clamp(treeSpacing * 2.75, 260, Math.max(260, width * 0.42)); + const spacingY = clamp(treeSpacing * 1.6, 180, Math.max(180, height * 0.34)); + + const getRootAnchor = (rootId: string, index: number) => { + const meta = getMeta(metadataById, rootId); + if (meta.manualPosition) return meta.manualPosition; + + const row = Math.floor(index / columns); + const col = index % columns; + const itemsInRow = + row === rows - 1 ? Math.max(1, rootCount - row * columns) : Math.min(columns, rootCount); + const rowStartX = width / 2 - ((itemsInRow - 1) * spacingX) / 2; + const x = rowStartX + col * spacingX; + const y = clamp(height * 0.22 + row * spacingY, 120, Math.max(140, height - 240)); + return { x, y }; + }; + + const placeNode = ( + nodeId: string, + x: number, + y: number, + spanWidth: number, + depth: number + ) => { + targets.set(nodeId, { x, y }); + const visibleChildren = (childrenByParent.get(nodeId) || []).filter( + (childId) => !hiddenNodeIds.has(childId) + ); + + if (visibleChildren.length === 0) return; + + const totalLeaves = visibleChildren.reduce((sum, childId) => sum + countLeaves(childId), 0); + const totalSpan = Math.max(spanWidth, visibleChildren.length * branchSpread * 0.95); + let cursor = x - totalSpan / 2; + + visibleChildren.forEach((childId, index) => { + const leafCount = countLeaves(childId); + const childSpan = Math.max(branchSpread * 0.92, (totalSpan * leafCount) / Math.max(totalLeaves, 1)); + const childMeta = getMeta(metadataById, childId); + const verticalDepth = childMeta.layoutDepth ?? depth + 1; + const sway = + Math.sin((index + 1) * 1.18 + depth * 0.65) * + Math.min(branchSpread * 0.15, 26); + const childX = cursor + childSpan / 2 + sway; + const childY = y + treeSpacing * (verticalDepth > depth + 1 ? 1.15 : 1); + placeNode(childId, childX, childY, childSpan * 0.9, depth + 1); + cursor += childSpan; + }); + }; + + visibleRootIds.forEach((rootId, index) => { + const rootAnchor = getRootAnchor(rootId, index); + const rootSpan = Math.max(branchSpread * 2.2, countLeaves(rootId) * branchSpread * 0.92); + placeNode(rootId, rootAnchor.x, rootAnchor.y, rootSpan, 0); + }); + + nodes.forEach((node) => { + if (targets.has(node.id) || hiddenNodeIds.has(node.id)) return; + const meta = getMeta(metadataById, node.id); + const fallbackX = meta.manualPosition?.x ?? node.x ?? width / 2; + const fallbackY = meta.manualPosition?.y ?? node.y ?? height / 2; + targets.set(node.id, { x: fallbackX, y: fallbackY }); + }); + + return { + targets, + childrenByParent, + hiddenNodeIds, + treeIds: visibleRootIds, + }; +}; diff --git a/src/features/layout/layoutConfig.test.ts b/src/features/layout/layoutConfig.test.ts new file mode 100644 index 0000000..c508608 --- /dev/null +++ b/src/features/layout/layoutConfig.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_BRANCH_SPREAD, + DEFAULT_SHOW_CROSS_LINKS, + DEFAULT_TREE_SPACING, + getDefaultLayoutMode, +} from './layoutConfig'; + +describe('layoutConfig', () => { + it('defaults to forest in development', () => { + expect(getDefaultLayoutMode(true)).toBe('forest'); + }); + + it('defaults to web outside development', () => { + expect(getDefaultLayoutMode(false)).toBe('web'); + }); + + it('exposes stable defaults for forest tuning', () => { + expect(DEFAULT_TREE_SPACING).toBeGreaterThan(100); + expect(DEFAULT_BRANCH_SPREAD).toBeGreaterThan(80); + expect(DEFAULT_SHOW_CROSS_LINKS).toBe(true); + }); +}); diff --git a/src/features/layout/layoutConfig.ts b/src/features/layout/layoutConfig.ts new file mode 100644 index 0000000..3d041da --- /dev/null +++ b/src/features/layout/layoutConfig.ts @@ -0,0 +1,8 @@ +export type LayoutMode = 'web' | 'forest'; + +export const DEFAULT_TREE_SPACING = 180; +export const DEFAULT_BRANCH_SPREAD = 160; +export const DEFAULT_SHOW_CROSS_LINKS = true; + +export const getDefaultLayoutMode = (isDevelopment: boolean): LayoutMode => + isDevelopment ? 'forest' : 'web'; diff --git a/src/hooks/useGraphState.test.tsx b/src/hooks/useGraphState.test.tsx index 0d91861..36a71e8 100644 --- a/src/hooks/useGraphState.test.tsx +++ b/src/hooks/useGraphState.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GraphManager, GraphStateSnapshot, NodeMetadata } from '../GraphManager'; import type { UpdateQueue } from '../UpdateQueue'; +import { WikiService } from '../WikiService'; import { useGraphState } from './useGraphState'; function createMetadata(overrides: Partial = {}): NodeMetadata { @@ -66,7 +67,13 @@ describe('useGraphState', () => { nodes: [{ id: 'Physics', title: 'Physics' }], links: [{ id: 'Physics-Mathematics', source: 'Physics', target: 'Mathematics', type: 'manual', context: 'Physics mentions Mathematics.' }], nodeMetadata: { - Physics: createMetadata({ isUserTyped: true }), + Physics: createMetadata({ + isUserTyped: true, + treeId: 'Physics', + layoutDepth: 0, + isPinned: true, + manualPosition: { x: 120, y: 240 }, + }), }, }; @@ -141,4 +148,61 @@ describe('useGraphState', () => { rendered.unmount(); }); + + it('limits the initial seed burst to a calmer set of leaves', async () => { + const rendered = renderUseGraphStateHook(); + const current = rendered.getCurrent(); + + const graphManagerFns = { + getNodeIds: vi.fn(() => []), + getViewportCenter: vi.fn(() => ({ x: 0, y: 0 })), + setNodeMetadata: vi.fn(), + getStateSnapshot: vi.fn(() => ({ + nodes: [], + links: [], + nodeMetadata: {}, + })), + }; + + const queueUpdate = vi.fn(); + const updateQueueFns = { + queueUpdate, + }; + + vi.spyOn(WikiService, 'resolveTitle').mockResolvedValue('Physics'); + vi.spyOn(WikiService, 'fetchLinks').mockResolvedValue( + Array.from({ length: 20 }, (_, index) => ({ + title: `Outlink ${index + 1}`, + context: `Context ${index + 1}`, + })) + ); + vi.spyOn(WikiService, 'fetchSummary').mockResolvedValue({ + title: 'Physics', + extract: 'Physics summary', + summary: 'Physics summary', + description: 'Scientific field', + }); + vi.spyOn(WikiService, 'fetchCategories').mockResolvedValue(['Science']); + vi.spyOn(WikiService, 'fetchBacklinks').mockResolvedValue( + Array.from({ length: 10 }, (_, index) => `Backlink ${index + 1}`) + ); + vi.spyOn(WikiService, 'getCachedNodes').mockReturnValue([]); + + act(() => { + current.graphManagerRef.current = graphManagerFns as unknown as GraphManager; + current.updateQueueRef.current = updateQueueFns as unknown as UpdateQueue; + }); + + await act(async () => { + await rendered.getCurrent().addTopic('Physics', true); + }); + + expect(queueUpdate).toHaveBeenCalledTimes(1); + const [queuedNodes, queuedLinks] = queueUpdate.mock.calls[0] as [Array<{ id: string }>, Array<{ id: string }>]; + expect(queuedNodes).toHaveLength(13); + expect(queuedLinks).toHaveLength(12); + expect(queuedNodes[0]?.id).toBe('Physics'); + + rendered.unmount(); + }); }); diff --git a/src/hooks/useGraphState.ts b/src/hooks/useGraphState.ts index 819e807..773aeec 100644 --- a/src/hooks/useGraphState.ts +++ b/src/hooks/useGraphState.ts @@ -15,7 +15,26 @@ export type AppSnapshot = { nodeBacklinkCounts: Record; }; +const INITIAL_SEED_OUTGOING_LIMIT = 9; +const INITIAL_SEED_BACKLINK_LIMIT = 3; + export const useGraphState = () => { + const getRadialSpawnPosition = ( + anchor: { x: number; y: number }, + index: number, + total: number, + baseRadius: number + ) => { + const count = Math.max(total, 1); + const angle = ((index % count) / count) * Math.PI * 2 - Math.PI / 2; + const ring = Math.floor(index / Math.max(6, count)); + const radius = baseRadius + ring * 54; + return { + x: anchor.x + Math.cos(angle) * radius, + y: anchor.y + Math.sin(angle) * radius, + }; + }; + // --- Refs --- const graphManagerRef = useRef(null); const updateQueueRef = useRef(null); @@ -139,6 +158,39 @@ export const useGraphState = () => { restoreSnapshot(next); }; + const cleanupDeletedNodeIds = (ids: Iterable) => { + const deletedIds = new Set(ids); + if (deletedIds.size === 0) return; + + const removeFromSet = (prev: Set) => { + const next = new Set(prev); + deletedIds.forEach(id => next.delete(id)); + return next; + }; + const removeFromMap = (prev: Record) => { + const next = { ...prev }; + deletedIds.forEach(id => delete next[id]); + return next; + }; + + setPathSelectedNodes(prev => prev.filter(node => !deletedIds.has(node.id))); + setBulkSelectedNodes(prev => prev.filter(node => !deletedIds.has(node.id))); + setUserTypedNodes(removeFromSet); + setAutoDiscoveredNodes(removeFromSet); + setExpandedNodes(removeFromSet); + setPathNodes(removeFromSet); + setRecentlyAddedNodes(removeFromSet); + setNodeThumbnails(removeFromMap); + setNodeDescriptions(removeFromMap); + setNodeCategories(removeFromMap); + setNodeBacklinkCounts(removeFromMap); + + if (clickedNode && deletedIds.has(clickedNode.id)) { + setClickedNode(null); + setClickedSummary(''); + } + }; + // --- Logic --- const addTopic = async (title: string, includeBacklinks: boolean, setLoading?: (v: boolean) => void, setError?: (v: string) => void) => { @@ -179,20 +231,61 @@ export const useGraphState = () => { } setUserTypedNodes(prev => new Set([...prev, resolvedTitle])); + graphManagerRef.current?.setNodeMetadata(resolvedTitle, { + originSeed: resolvedTitle, + originDepth: 0, + colorRole: 'root', + treeId: resolvedTitle, + layoutDepth: 0, + primaryParentId: undefined, + }); + const existingNodeCount = graphManagerRef.current?.getNodeIds().length || 0; + const isInitialSeed = existingNodeCount === 0; + const visibleLinks = isInitialSeed ? links.slice(0, INITIAL_SEED_OUTGOING_LIMIT) : links; + const visibleBacklinks = isInitialSeed ? backlinks.slice(0, INITIAL_SEED_BACKLINK_LIMIT) : backlinks; + const viewportCenter = graphManagerRef.current?.getViewportCenter() || { x: 0, y: 0 }; + const rootAngle = existingNodeCount * 0.85; + const rootRadius = existingNodeCount === 0 ? 0 : Math.min(260, 90 + existingNodeCount * 14); + const rootPosition = { + x: viewportCenter.x + Math.cos(rootAngle) * rootRadius, + y: viewportCenter.y + Math.sin(rootAngle) * rootRadius, + }; + const spawnCount = Math.max(visibleLinks.length + visibleBacklinks.length, 1); + let spawnIndex = 0; const newNodes: GraphNode[] = [{ id: resolvedTitle, title: resolvedTitle, - metadata: { originSeed: resolvedTitle, originDepth: 0, colorRole: 'root' } + x: rootPosition.x, + y: rootPosition.y, + metadata: { + originSeed: resolvedTitle, + originDepth: 0, + colorRole: 'root', + treeId: resolvedTitle, + layoutDepth: 0, + primaryParentId: undefined, + } }]; const newLinks: Link[] = []; const newAutoDiscovered = new Set(); - links.forEach((linkObj: LinkWithContext) => { + visibleLinks.forEach((linkObj: LinkWithContext) => { + const position = getRadialSpawnPosition(rootPosition, spawnIndex, spawnCount, 130); + spawnIndex++; newNodes.push({ id: linkObj.title, title: linkObj.title, - metadata: { originSeed: resolvedTitle, originDepth: 1, colorRole: 'child' } + x: position.x, + y: position.y, + metadata: { + originSeed: resolvedTitle, + originDepth: 1, + colorRole: 'child', + treeId: resolvedTitle, + layoutDepth: 1, + primaryParentId: resolvedTitle, + } }); newAutoDiscovered.add(linkObj.title); newLinks.push({ @@ -200,16 +293,28 @@ export const useGraphState = () => { target: linkObj.title, id: `${resolvedTitle}-${linkObj.title}`, type: 'manual', - context: linkObj.context + context: linkObj.context, + layoutRole: 'primary', }); }); - backlinks.forEach((blTitle: string) => { + visibleBacklinks.forEach((blTitle: string) => { if (!blTitle || blTitle === resolvedTitle) return; + const position = getRadialSpawnPosition(rootPosition, spawnIndex, spawnCount, 130); + spawnIndex++; newNodes.push({ id: blTitle, title: blTitle, - metadata: { originSeed: resolvedTitle, originDepth: 1, colorRole: 'child' } + x: position.x, + y: position.y, + metadata: { + originSeed: resolvedTitle, + originDepth: 1, + colorRole: 'child', + treeId: resolvedTitle, + layoutDepth: 1, + primaryParentId: resolvedTitle, + } }); newAutoDiscovered.add(blTitle); newLinks.push({ @@ -217,6 +322,7 @@ export const useGraphState = () => { target: resolvedTitle, id: `${blTitle}-${resolvedTitle}`, type: 'backlink', + layoutRole: 'primary', }); }); @@ -234,7 +340,8 @@ export const useGraphState = () => { target: resolvedTitle, id: `${existingNodeId}-${resolvedTitle}`, type: 'auto', - context: match.context + context: match.context, + layoutRole: 'cross', }); } }); @@ -257,25 +364,7 @@ export const useGraphState = () => { if (graphManagerRef.current) { graphManagerRef.current.deleteNode(nodeId); } - const deleteFromSet = (prev: Set) => { const s = new Set(prev); s.delete(nodeId); return s; }; - const deleteFromMap = (prev: Record) => { - const next = { ...prev }; - delete next[nodeId]; - return next; - }; - setUserTypedNodes(deleteFromSet); - setAutoDiscoveredNodes(deleteFromSet); - setExpandedNodes(deleteFromSet); - setPathNodes(deleteFromSet); - setRecentlyAddedNodes(deleteFromSet); - setNodeThumbnails(deleteFromMap); - setNodeDescriptions(deleteFromMap); - setNodeCategories(deleteFromMap); - setNodeBacklinkCounts(deleteFromMap); - - if (clickedNode?.id === nodeId) { - setClickedNode(null); - } + cleanupDeletedNodeIds([nodeId]); }; const pruneGraph = (setError?: (msg: string) => void) => { @@ -287,34 +376,9 @@ export const useGraphState = () => { if (!graphManagerRef.current) return; pushHistory(); - - ids.forEach(id => graphManagerRef.current!.deleteNode(id)); - const deletedIds = new Set(ids); + graphManagerRef.current.deleteNodes(ids); setBulkSelectedNodes([]); - setPathSelectedNodes(prev => prev.filter(n => !deletedIds.has(n.id))); - - const removeFromSet = (prev: Set) => { - const s = new Set(prev); - ids.forEach(id => s.delete(id)); - return s; - }; - const removeFromMap = (prev: Record) => { - const next = { ...prev }; - ids.forEach(id => delete next[id]); - return next; - }; - - setPathNodes(removeFromSet); - setUserTypedNodes(removeFromSet); - setAutoDiscoveredNodes(removeFromSet); - setExpandedNodes(removeFromSet); - setRecentlyAddedNodes(removeFromSet); - setNodeThumbnails(removeFromMap); - setNodeDescriptions(removeFromMap); - setNodeCategories(removeFromMap); - setNodeBacklinkCounts(removeFromMap); - - if (clickedNode && deletedIds.has(clickedNode.id)) setClickedNode(null); + cleanupDeletedNodeIds(ids); // Callbacks for extra cleanup if needed if (setError) setError(`Deleted ${ids.length} selected nodes.`); @@ -329,34 +393,7 @@ export const useGraphState = () => { const deletedCount = graphManagerRef.current.pruneNodes(); if (deletedCount > 0) { - const deletedIds = new Set(idsToDelete); - const removeFromSet = (prev: Set) => { - const next = new Set(prev); - idsToDelete.forEach(id => next.delete(id)); - return next; - }; - const removeFromMap = (prev: Record) => { - const next = { ...prev }; - idsToDelete.forEach(id => delete next[id]); - return next; - }; - - setPathSelectedNodes(prev => prev.filter(node => !deletedIds.has(node.id))); - setBulkSelectedNodes(prev => prev.filter(node => !deletedIds.has(node.id))); - setUserTypedNodes(removeFromSet); - setAutoDiscoveredNodes(removeFromSet); - setExpandedNodes(removeFromSet); - setPathNodes(removeFromSet); - setRecentlyAddedNodes(removeFromSet); - setNodeThumbnails(removeFromMap); - setNodeDescriptions(removeFromMap); - setNodeCategories(removeFromMap); - setNodeBacklinkCounts(removeFromMap); - - if (clickedNode && deletedIds.has(clickedNode.id)) { - setClickedNode(null); - setClickedSummary(''); - } + cleanupDeletedNodeIds(idsToDelete); } if (deletedCount > 0) { @@ -388,8 +425,11 @@ export const useGraphState = () => { const gm = graphManagerRef.current; const originMeta = gm?.getNodeMetadata(title); const originSeed = originMeta?.originSeed || title; + const treeId = originMeta?.treeId || originSeed; const originDepthBase = originMeta?.originDepth ?? 0; + const layoutDepthBase = originMeta?.layoutDepth ?? originDepthBase; const existingGraphNodeIds = new Set(gm?.getNodeIds() || []); + const originPosition = gm?.getNodePosition(title) || gm?.getViewportCenter() || { x: 0, y: 0 }; if (categories.length > 0) setNodeCategories(prev => ({ ...prev, [title]: categories })); if (includeBacklinks) setNodeBacklinkCounts(prev => ({ ...prev, [title]: backlinks.length })); @@ -462,12 +502,25 @@ export const useGraphState = () => { sortedCandidates.forEach(c => { const candidateTitle = c.title; - nodesToAdd.push({ - id: candidateTitle, - title: candidateTitle, - metadata: { originSeed, originDepth: originDepthBase + 1, colorRole: 'child' } - }); - newAutoDiscovered.add(candidateTitle); + const alreadyInGraph = existingGraphNodeIds.has(candidateTitle); + const position = getRadialSpawnPosition(originPosition, nodesToAdd.length, sortedCandidates.length, 145); + if (!alreadyInGraph) { + nodesToAdd.push({ + id: candidateTitle, + title: candidateTitle, + x: position.x, + y: position.y, + metadata: { + originSeed, + originDepth: originDepthBase + 1, + colorRole: 'child', + treeId, + layoutDepth: layoutDepthBase + 1, + primaryParentId: title, + } + }); + newAutoDiscovered.add(candidateTitle); + } if (c.direction === 'out') { linksToAdd.push({ @@ -475,7 +528,8 @@ export const useGraphState = () => { target: candidateTitle, id: `${title}-${candidateTitle}`, type: c.isBidirectional ? 'expand_backlink' : 'expand', - context: c.context + context: c.context, + layoutRole: alreadyInGraph ? 'cross' : 'primary', }); } else { linksToAdd.push({ @@ -483,6 +537,7 @@ export const useGraphState = () => { target: title, id: `${candidateTitle}-${title}`, type: 'expand_backlink', + layoutRole: alreadyInGraph ? 'cross' : 'primary', }); } @@ -495,7 +550,8 @@ export const useGraphState = () => { target: candidateTitle, id: `${existing}-${candidateTitle}`, type: 'auto', - context: match.context + context: match.context, + layoutRole: 'cross', }); }); }); @@ -515,6 +571,20 @@ export const useGraphState = () => { } }; + const pruneBranch = (nodeId: string, setError?: (msg: string) => void) => { + if (!graphManagerRef.current) return; + const branchIds = graphManagerRef.current.getBranchNodeIds(nodeId); + if (branchIds.length === 0) { + if (setError) setError('No branch nodes found to prune.'); + return; + } + + pushHistory(); + graphManagerRef.current.deleteNodes(branchIds); + cleanupDeletedNodeIds(branchIds); + if (setError) setError(`Pruned ${branchIds.length} nodes from the branch.`); + }; + return { // Refs graphManagerRef, @@ -545,6 +615,7 @@ export const useGraphState = () => { deleteNodeImperative, pruneGraph, pruneLeafNodes, + pruneBranch, undo, redo, pushHistory, diff --git a/src/index.css b/src/index.css index d3ac674..32f91ad 100644 --- a/src/index.css +++ b/src/index.css @@ -12,6 +12,11 @@ html, body, #root { width: 100%; height: 100%; overflow: hidden; + background: + radial-gradient(circle at top left, rgba(56, 189, 248, 0.08), transparent 28%), + radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.08), transparent 32%), + #020617; + color: #e2e8f0; } svg { @@ -26,3 +31,27 @@ svg * { .grecaptcha-badge { visibility: hidden !important; } + +.panel-rise { + animation: panel-rise 260ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes panel-rise { + from { + opacity: 0; + transform: translateY(12px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; + } +}