diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 diff --git a/CLAUDE.md b/CLAUDE.md index 935a1f4..dfd2385 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,9 +22,21 @@ Next.js application called "Map of AI Futures" - an interactive probability flow - `npm run build` - Build for production - `npm run lint` - Run ESLint +**Port Management:** + +- **IMPORTANT:** Each worktree MUST use its designated port: + - **WorkTree1** (`ai-world-model-worktree-1`): Port **3000** + - **WorkTree2** (`ai-world-model-worktree-2`): Port **3001** +- **NEVER** start a dev server on a different port without explicit user permission +- **Before starting a dev server:** + 1. Check which worktree you're in (from working directory) + 2. Determine the correct port (3000 for WorkTree1, 3001 for WorkTree2) + 3. Kill any process on that port: `fuser -k /tcp` + 4. Start dev server with: `npm run dev -- -p ` (e.g., `npm run dev -- -p 3001`) + **Process Management:** -- **To stop a dev server cleanly:** `fuser -k /tcp` (e.g., `fuser -k 3000/tcp`) +- **To stop a dev server cleanly:** `fuser -k /tcp` (e.g., `fuser -k 3000/tcp` or `fuser -k 3001/tcp`) - This kills all processes using that port, including orphaned child processes - More reliable than KillShell which can leave next-server processes running - Prevents accumulation of zombie processes that consume CPU/memory diff --git a/app/page.tsx b/app/page.tsx index 7e31819..fa8542b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,43 +1,76 @@ "use client"; -import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'; -import { flushSync } from 'react-dom'; -import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; -import Sidebar from '@/components/Sidebar'; -import SettingsMenu from '@/components/SettingsMenu'; -import Flowchart from '@/components/Flowchart'; -import ZoomControls from '@/components/ZoomControls'; -import DragHint from '@/components/DragHint'; -import { WelcomeModal } from '@/components/WelcomeModal'; -import { AuthModal } from '@/components/auth/AuthModal'; -import MobileWarning from '@/components/MobileWarning'; -import { useAuth } from '@/hooks/useAuth'; -import { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP, NodeType, EdgeType, type GraphData, type DocumentData, type GraphNode } from '@/lib/types'; -import { startNodeIndex, AUTHORS_ESTIMATES, graphData as defaultGraphData } from '@/lib/graphData'; -import { calculateProbabilities } from '@/lib/probability'; -import { loadFromLocalStorage, saveToLocalStorage, createDefaultDocumentData, clearLocalStorage, createEmptyDocumentData } from '@/lib/documentState'; -import { getLastOpenedDocument, loadDocument } from '@/lib/actions/documents'; -import { useAutoSave } from '@/lib/autoSave'; -import AutoSaveIndicator from '@/components/AutoSaveIndicator'; -import DocumentPicker from '@/components/DocumentPicker'; -import { ShareModal } from '@/components/ShareModal'; -import { FeedbackButton } from '@/components/FeedbackButton'; -import { analytics } from '@/lib/analytics'; +import { + useState, + useEffect, + useMemo, + useCallback, + useRef, + Suspense, +} from "react"; +import { flushSync } from "react-dom"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import Sidebar from "@/components/Sidebar"; +import SettingsMenu from "@/components/SettingsMenu"; +import Flowchart from "@/components/Flowchart"; +import ZoomControls from "@/components/ZoomControls"; +import DragHint from "@/components/DragHint"; +import { WelcomeModal } from "@/components/WelcomeModal"; +import { AuthModal } from "@/components/auth/AuthModal"; +import MobileWarning from "@/components/MobileWarning"; +import { useAuth } from "@/hooks/useAuth"; +import { + MIN_ZOOM, + MAX_ZOOM, + ZOOM_STEP, + NodeType, + EdgeType, + type GraphData, + type DocumentData, + type GraphNode, +} from "@/lib/types"; +import { + startNodeIndex, + AUTHORS_ESTIMATES, + graphData as defaultGraphData, +} from "@/lib/graphData"; +import { calculateProbabilities } from "@/lib/probability"; +import { + loadFromLocalStorage, + saveToLocalStorage, + createDefaultDocumentData, + clearLocalStorage, + createEmptyDocumentData, +} from "@/lib/documentState"; +import { getLastOpenedDocument, loadDocument } from "@/lib/actions/documents"; +import { useAutoSave } from "@/lib/autoSave"; +import AutoSaveIndicator from "@/components/AutoSaveIndicator"; +import DocumentPicker from "@/components/DocumentPicker"; +import { ShareModal } from "@/components/ShareModal"; +import { FeedbackButton } from "@/components/FeedbackButton"; +import { analytics } from "@/lib/analytics"; function HomeContent() { const { user, loading: authLoading } = useAuth(); const searchParams = useSearchParams(); - const [probabilityRootIndex, setProbabilityRootIndex] = useState(startNodeIndex); - const [previewProbabilityRootIndex, setPreviewProbabilityRootIndex] = useState(null); + const [probabilityRootIndex, setProbabilityRootIndex] = + useState(startNodeIndex); + const [previewProbabilityRootIndex, setPreviewProbabilityRootIndex] = + useState(null); const [hoveredNodeIndex, setHoveredNodeIndex] = useState(-1); const [selectedEdgeIndex, setSelectedEdgeIndex] = useState(-1); const [selectedNodeId, setSelectedNodeId] = useState(null); const [autoEditNodeId, setAutoEditNodeId] = useState(null); - const [hoveredDestinationDotIndex, setHoveredDestinationDotIndex] = useState(-1); + const [hoveredDestinationDotIndex, setHoveredDestinationDotIndex] = + useState(-1); const [draggingEdgeIndex, setDraggingEdgeIndex] = useState(-1); - const [pendingNewArrow, setPendingNewArrow] = useState<{ nodeId: string; edgeIndex: number; mousePos: { clientX: number; clientY: number } } | null>(null); + const [pendingNewArrow, setPendingNewArrow] = useState<{ + nodeId: string; + edgeIndex: number; + mousePos: { clientX: number; clientY: number }; + } | null>(null); const [minOpacity, setMinOpacity] = useState(60); // Undo/redo history: Single array with current position index @@ -69,8 +102,10 @@ function HomeContent() { const [dragCursorPos, setDragCursorPos] = useState({ x: 0, y: 0 }); // Document state (unified storage) - const [currentDocumentId, setCurrentDocumentId] = useState(null); - const [documentName, setDocumentName] = useState('Untitled Document'); + const [currentDocumentId, setCurrentDocumentId] = useState( + null + ); + const [documentName, setDocumentName] = useState("Untitled Document"); const [graphData, setGraphData] = useState(defaultGraphData); // Track if we've done the initial document load @@ -112,58 +147,74 @@ function HomeContent() { // Load sidebar collapse state from localStorage on mount useEffect(() => { - const savedState = localStorage.getItem('sidebarCollapsed'); + const savedState = localStorage.getItem("sidebarCollapsed"); if (savedState !== null) { - setIsSidebarCollapsed(savedState === 'true'); + setIsSidebarCollapsed(savedState === "true"); } }, []); // Save sidebar collapse state to localStorage when it changes const handleToggleSidebar = useCallback(() => { - setIsSidebarCollapsed(prev => { + setIsSidebarCollapsed((prev) => { const newState = !prev; - localStorage.setItem('sidebarCollapsed', String(newState)); + localStorage.setItem("sidebarCollapsed", String(newState)); return newState; }); }, []); // Load specific document from URL parameter (e.g., from shared links) useEffect(() => { - console.log('[Page] URL param effect - authLoading:', authLoading, 'user:', !!user) + console.log( + "[Page] URL param effect - authLoading:", + authLoading, + "user:", + !!user + ); if (authLoading) return; // Only wait for auth loading, not user - const docId = searchParams.get('doc'); - console.log('[Page] URL param docId:', docId, 'currentDocumentId:', currentDocumentId) + const docId = searchParams.get("doc"); + console.log( + "[Page] URL param docId:", + docId, + "currentDocumentId:", + currentDocumentId + ); if (!docId) return; // Don't reload if we're already viewing this document if (currentDocumentId === docId) { - console.log('[Page] Already viewing this document, skipping load') + console.log("[Page] Already viewing this document, skipping load"); return; } // Only authenticated users can load documents from URL if (!user) { - console.log('[Page] No user, cannot load document from URL') + console.log("[Page] No user, cannot load document from URL"); return; } - console.log('[Page] Loading document from URL:', docId) + console.log("[Page] Loading document from URL:", docId); loadDocument(docId).then(({ data, error }) => { - console.log('[Page] Load result - error:', error, 'data:', !!data) + console.log("[Page] Load result - error:", error, "data:", !!data); if (!error && data) { // Migrate old data: ensure all nodes have probability field - const migratedNodes = data.data.nodes.map(node => { + const migratedNodes = data.data.nodes.map((node) => { if (node.probability === undefined) { return { ...node, - probability: node.type === NodeType.QUESTION ? 50 : null + probability: node.type === NodeType.QUESTION ? 50 : null, }; } return node; }); - console.log('[Page] Setting document state:', data.id, data.name, migratedNodes.length, 'nodes') + console.log( + "[Page] Setting document state:", + data.id, + data.name, + migratedNodes.length, + "nodes" + ); setCurrentDocumentId(data.id); setDocumentName(data.name); setGraphData({ metadata: data.data.metadata, nodes: migratedNodes }); @@ -177,7 +228,7 @@ function HomeContent() { useEffect(() => { if (authLoading || hasLoadedInitialDocument.current) return; - const docId = searchParams.get('doc'); + const docId = searchParams.get("doc"); if (docId) return; // Will be handled by the effect above hasLoadedInitialDocument.current = true; @@ -187,11 +238,11 @@ function HomeContent() { getLastOpenedDocument().then(({ data, error }) => { if (!error && data) { // Migrate old data: ensure all nodes have probability field - const migratedNodes = data.data.nodes.map(node => { + const migratedNodes = data.data.nodes.map((node) => { if (node.probability === undefined) { return { ...node, - probability: node.type === NodeType.QUESTION ? 50 : null + probability: node.type === NodeType.QUESTION ? 50 : null, }; } return node; @@ -209,8 +260,11 @@ function HomeContent() { const result = loadFromLocalStorage(); if (result) { // Migration is already done in loadFromLocalStorage() - setGraphData({ metadata: result.data.metadata, nodes: result.data.nodes }); - setDocumentName(result.name || 'My Draft'); + setGraphData({ + metadata: result.data.metadata, + nodes: result.data.nodes, + }); + setDocumentName(result.name || "My Draft"); } } }, [user, authLoading, searchParams]); @@ -226,48 +280,86 @@ function HomeContent() { // Check if user was created recently (within last 30 minutes for testing) const userCreatedAt = new Date(user.created_at); const now = new Date(); - const minutesSinceCreation = (now.getTime() - userCreatedAt.getTime()) / 1000 / 60; + const minutesSinceCreation = + (now.getTime() - userCreatedAt.getTime()) / 1000 / 60; - console.log('User created:', userCreatedAt, 'Minutes ago:', minutesSinceCreation); + console.log( + "User created:", + userCreatedAt, + "Minutes ago:", + minutesSinceCreation + ); if (minutesSinceCreation < 30) { // New user! Show welcome modal - console.log('Showing welcome modal'); + console.log("Showing welcome modal"); setShowWelcomeModal(true); - localStorage.setItem(welcomeShownKey, 'true'); + localStorage.setItem(welcomeShownKey, "true"); } } else { - console.log('Welcome modal already shown for this user'); + console.log("Welcome modal already shown for this user"); } } }, [user, authLoading]); // Calculate probabilities using useMemo (only recalculate when dependencies change) - const { nodes, edges, maxOutcomeProbability } = useMemo(() => { - // Use preview index if hovering, otherwise use actual root index - const effectiveRootIndex = previewProbabilityRootIndex ?? probabilityRootIndex; - const result = calculateProbabilities(effectiveRootIndex, graphData); - - // Find max probability among outcome nodes (good, ambivalent, existential) - const outcomeNodes = result.nodes.filter( - n => n.type === NodeType.GOOD || n.type === NodeType.AMBIVALENT || n.type === NodeType.EXISTENTIAL - ); - const maxOutcomeProbability = Math.max( - ...outcomeNodes.map(n => n.p), - 0 // Fallback to 0 if no outcome nodes - ); + const { nodes, edges, maxOutcomeProbability, outcomeProbabilities } = + useMemo(() => { + // Use preview index if hovering, otherwise use actual root index + const effectiveRootIndex = + previewProbabilityRootIndex ?? probabilityRootIndex; + const result = calculateProbabilities(effectiveRootIndex, graphData); + + // Find max probability among outcome nodes (good, ambivalent, existential) + const outcomeNodes = result.nodes.filter( + (n) => + n.type === NodeType.GOOD || + n.type === NodeType.AMBIVALENT || + n.type === NodeType.EXISTENTIAL + ); + const maxOutcomeProbability = Math.max( + ...outcomeNodes.map((n) => n.p), + 0 // Fallback to 0 if no outcome nodes + ); + + // Calculate total probability for each outcome category + const goodProbability = result.nodes + .filter((n) => n.type === NodeType.GOOD) + .reduce((sum, n) => sum + n.p, 0); + const ambivalentProbability = result.nodes + .filter((n) => n.type === NodeType.AMBIVALENT) + .reduce((sum, n) => sum + n.p, 0); + const existentialProbability = result.nodes + .filter((n) => n.type === NodeType.EXISTENTIAL) + .reduce((sum, n) => sum + n.p, 0); - return { nodes: result.nodes, edges: result.edges, maxOutcomeProbability }; - }, [probabilityRootIndex, previewProbabilityRootIndex, graphData]); + return { + nodes: result.nodes, + edges: result.edges, + maxOutcomeProbability, + outcomeProbabilities: { + good: goodProbability, + ambivalent: ambivalentProbability, + existential: existentialProbability, + }, + }; + }, [probabilityRootIndex, previewProbabilityRootIndex, graphData]); // Create document data for auto-save - const documentData: DocumentData = useMemo(() => ({ - nodes: graphData.nodes, - metadata: graphData.metadata, - }), [graphData]); + const documentData: DocumentData = useMemo( + () => ({ + nodes: graphData.nodes, + metadata: graphData.metadata, + }), + [graphData] + ); // Auto-save hook - const { saveStatus, error: saveError, lastSavedAt } = useAutoSave({ + const { + saveStatus, + error: saveError, + lastSavedAt, + } = useAutoSave({ documentId: currentDocumentId, documentName, data: documentData, @@ -288,7 +380,7 @@ function HomeContent() { } // Append current state to history - setHistory(prev => { + setHistory((prev) => { // Truncate history after current index (if user made changes after undo) const truncated = prev.slice(0, historyIndex + 1); const newHistory = [...truncated, graphData.nodes]; @@ -296,7 +388,7 @@ function HomeContent() { }); // Increment index to point to the new state - setHistoryIndex(prev => Math.min(prev + 1, 49)); // Cap at 49 (50 states - 1) + setHistoryIndex((prev) => Math.min(prev + 1, 49)); // Cap at 49 (50 states - 1) }, [graphData.nodes, history, historyIndex]); // Slider change handler @@ -304,8 +396,8 @@ function HomeContent() { // Suppress history tracking during continuous drag suppressHistoryRef.current = true; - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => node.id === nodeId ? { ...node, probability: value } : node ); return { ...prev, nodes: updatedNodes }; @@ -313,48 +405,60 @@ function HomeContent() { }, []); // Slider change complete handler - const handleSliderChangeComplete = useCallback((nodeId: string) => { - // Re-enable history tracking - suppressHistoryRef.current = false; - - // Force one final state update to capture end state in history - setGraphData(prev => ({ ...prev, nodes: [...prev.nodes] })); - - // Track analytics - const node = graphData.nodes.find(n => n.id === nodeId); - if (node && node.probability !== null) { - analytics.trackSliderChange(nodeId, node.probability); - } - }, [graphData.nodes]); + const handleSliderChangeComplete = useCallback( + (nodeId: string) => { + // Re-enable history tracking + suppressHistoryRef.current = false; + + // Force one final state update to capture end state in history + setGraphData((prev) => ({ ...prev, nodes: [...prev.nodes] })); + + // Track analytics + const node = graphData.nodes.find((n) => n.id === nodeId); + if (node && node.probability !== null) { + analytics.trackSliderChange(nodeId, node.probability); + } + }, + [graphData.nodes] + ); // Node click handler - no longer changes probability root - const handleNodeClick = useCallback((index: number) => { - // Track analytics - const nodeId = nodes[index]?.id || `node-${index}`; - const nodeType = nodes[index]?.type || 'unknown'; - analytics.trackNodeClick(nodeId, nodeType); - - // Deselect any selected edge when clicking a node - setSelectedEdgeIndex(-1); - }, [nodes]); + const handleNodeClick = useCallback( + (index: number) => { + // Track analytics + const nodeId = nodes[index]?.id || `node-${index}`; + const nodeType = nodes[index]?.type || "unknown"; + analytics.trackNodeClick(nodeId, nodeType); + + // Deselect any selected edge when clicking a node + setSelectedEdgeIndex(-1); + }, + [nodes] + ); // Set probability root handler (for the "100" button) - const handleSetProbabilityRoot = useCallback((index: number) => { - setProbabilityRootIndex(prev => { - const newIndex = index === prev ? startNodeIndex : index; - - // Track probability root change - const newNodeId = newIndex === startNodeIndex ? null : (nodes[newIndex]?.id || `node-${newIndex}`); - analytics.trackProbabilityRootChange(newNodeId); - - if (index === prev) { - // Click same button again = reset to start - return startNodeIndex; - } else { - return index; - } - }); - }, [nodes]); + const handleSetProbabilityRoot = useCallback( + (index: number) => { + setProbabilityRootIndex((prev) => { + const newIndex = index === prev ? startNodeIndex : index; + + // Track probability root change + const newNodeId = + newIndex === startNodeIndex + ? null + : nodes[newIndex]?.id || `node-${newIndex}`; + analytics.trackProbabilityRootChange(newNodeId); + + if (index === prev) { + // Click same button again = reset to start + return startNodeIndex; + } else { + return index; + } + }); + }, + [nodes] + ); // Preview hover handlers for probability root button const handleSetProbabilityRootHoverStart = useCallback((index: number) => { @@ -383,7 +487,7 @@ function HomeContent() { // Edge click handler const handleEdgeClick = useCallback((edgeIndex: number) => { - setSelectedEdgeIndex(prev => prev === edgeIndex ? -1 : edgeIndex); + setSelectedEdgeIndex((prev) => (prev === edgeIndex ? -1 : edgeIndex)); // Deselect node when selecting an edge setProbabilityRootIndex(startNodeIndex); setSelectedNodeId(null); @@ -409,373 +513,483 @@ function HomeContent() { }, []); // Edge reconnect handler - const handleEdgeReconnect = useCallback((edgeIndex: number, end: 'source' | 'target', newNodeIdOrCoords: string | { x: number; y: number }) => { - const edge = edges[edgeIndex]; - if (!edge) return; - - // Convert indices to IDs - const sourceNodeId = nodes[edge.source].id; - const targetNodeId = edge.target !== undefined ? nodes[edge.target].id : undefined; - - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === sourceNodeId) { - // This is the source node, update its connection - const updatedConnections = node.connections.map((conn) => { - // Find which connection corresponds to this edge (by targetId or by targetX/targetY AND edge type) - // We need to match by edge type (conn.type === edge.yn) to distinguish between stacked edges - const isMatchingConnection = targetNodeId ? - conn.targetId === targetNodeId && conn.type === edge.yn : - conn.targetX === edge.targetX && conn.targetY === edge.targetY && conn.type === edge.yn; - - if (isMatchingConnection && end === 'target') { - if (typeof newNodeIdOrCoords === 'string') { - // Reconnecting to a node - return { ...conn, targetId: newNodeIdOrCoords, targetX: undefined, targetY: undefined }; - } else { - // Creating floating endpoint - return { ...conn, targetId: undefined, targetX: newNodeIdOrCoords.x, targetY: newNodeIdOrCoords.y }; + const handleEdgeReconnect = useCallback( + ( + edgeIndex: number, + end: "source" | "target", + newNodeIdOrCoords: string | { x: number; y: number } + ) => { + const edge = edges[edgeIndex]; + if (!edge) return; + + // Convert indices to IDs + const sourceNodeId = nodes[edge.source].id; + const targetNodeId = + edge.target !== undefined ? nodes[edge.target].id : undefined; + + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === sourceNodeId) { + // This is the source node, update its connection + const updatedConnections = node.connections.map((conn) => { + // Find which connection corresponds to this edge (by targetId or by targetX/targetY AND edge type) + // We need to match by edge type (conn.type === edge.yn) to distinguish between stacked edges + const isMatchingConnection = targetNodeId + ? conn.targetId === targetNodeId && conn.type === edge.yn + : conn.targetX === edge.targetX && + conn.targetY === edge.targetY && + conn.type === edge.yn; + + if (isMatchingConnection && end === "target") { + if (typeof newNodeIdOrCoords === "string") { + // Reconnecting to a node + return { + ...conn, + targetId: newNodeIdOrCoords, + targetX: undefined, + targetY: undefined, + }; + } else { + // Creating floating endpoint + return { + ...conn, + targetId: undefined, + targetX: newNodeIdOrCoords.x, + targetY: newNodeIdOrCoords.y, + }; + } } - } - return conn; - }); - return { ...node, connections: updatedConnections }; - } - - return node; - }); - - return { ...prev, nodes: updatedNodes }; - }); + return conn; + }); + return { ...node, connections: updatedConnections }; + } - }, [edges, nodes]); + return node; + }); - // Edge label update handler - const handleEdgeLabelUpdate = useCallback((edgeIndex: number, newLabel: string) => { - const edge = edges[edgeIndex]; - if (!edge || edge.source === undefined || edge.target === undefined) return; - - // Convert indices to IDs - const sourceNodeId = nodes[edge.source].id; - const targetNodeId = nodes[edge.target].id; - - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === sourceNodeId) { - const updatedConnections = node.connections.map((conn) => { - // Match by both targetId and edge type to distinguish between stacked edges - if (conn.targetId === targetNodeId && conn.type === edge.yn) { - return { ...conn, label: newLabel }; - } - return conn; - }); - return { ...node, connections: updatedConnections }; - } - return node; + return { ...prev, nodes: updatedNodes }; }); + }, + [edges, nodes] + ); - return { ...prev, nodes: updatedNodes }; - }); + // Edge label update handler + const handleEdgeLabelUpdate = useCallback( + (edgeIndex: number, newLabel: string) => { + const edge = edges[edgeIndex]; + if (!edge || edge.source === undefined || edge.target === undefined) + return; + + // Convert indices to IDs + const sourceNodeId = nodes[edge.source].id; + const targetNodeId = nodes[edge.target].id; + + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === sourceNodeId) { + const updatedConnections = node.connections.map((conn) => { + // Match by both targetId and edge type to distinguish between stacked edges + if (conn.targetId === targetNodeId && conn.type === edge.yn) { + return { ...conn, label: newLabel }; + } + return conn; + }); + return { ...node, connections: updatedConnections }; + } + return node; + }); - }, [edges, nodes]); + return { ...prev, nodes: updatedNodes }; + }); + }, + [edges, nodes] + ); // Delete edge handler - const handleDeleteEdge = useCallback((edgeIndex: number) => { - const edge = edges[edgeIndex]; - if (!edge || edge.source === undefined) return; - - const sourceNode = nodes[edge.source]; - const sourceGraphNode = graphData.nodes.find(n => n.id === sourceNode.id); - const targetNodeId = edge.target !== undefined ? nodes[edge.target].id : undefined; - - // Get the sliderIndex from the GraphNode (if it's a question node) - const sliderIndexToRemove = sourceGraphNode?.sliderIndex; - - setGraphData(prev => { - let updatedNodes = prev.nodes.map(node => { - if (node.id === sourceNode.id) { - // Remove the connection - const updatedConnections = node.connections.filter(conn => { - // Match by edge type to distinguish between stacked edges - const isMatchingConnection = targetNodeId ? - conn.targetId === targetNodeId && conn.type === edge.yn : - conn.targetX === edge.targetX && conn.targetY === edge.targetY && conn.type === edge.yn; - return !isMatchingConnection; - }); - - // Handle node type conversion based on remaining connections - let finalConnections = updatedConnections; - let updatedType = node.type; - let updatedSliderIndex = node.sliderIndex; - - if (updatedConnections.length === 0) { - // No connections left: convert to AMBIVALENT outcome - updatedType = NodeType.AMBIVALENT; - updatedSliderIndex = null; // Clear sliderIndex - } else if (updatedConnections.length === 1) { - // One connection left: convert from QUESTION to INTERMEDIATE - // Also convert the remaining connection from YES/NO to E100 - updatedType = NodeType.INTERMEDIATE; - updatedSliderIndex = null; // Clear sliderIndex - finalConnections = updatedConnections.map(conn => ({ - ...conn, - type: EdgeType.ALWAYS, - label: '', - })); - } + const handleDeleteEdge = useCallback( + (edgeIndex: number) => { + const edge = edges[edgeIndex]; + if (!edge || edge.source === undefined) return; + + const sourceNode = nodes[edge.source]; + const sourceGraphNode = graphData.nodes.find( + (n) => n.id === sourceNode.id + ); + const targetNodeId = + edge.target !== undefined ? nodes[edge.target].id : undefined; + + // Get the sliderIndex from the GraphNode (if it's a question node) + const sliderIndexToRemove = sourceGraphNode?.sliderIndex; + + setGraphData((prev) => { + let updatedNodes = prev.nodes.map((node) => { + if (node.id === sourceNode.id) { + // Remove the connection + const updatedConnections = node.connections.filter((conn) => { + // Match by edge type to distinguish between stacked edges + const isMatchingConnection = targetNodeId + ? conn.targetId === targetNodeId && conn.type === edge.yn + : conn.targetX === edge.targetX && + conn.targetY === edge.targetY && + conn.type === edge.yn; + return !isMatchingConnection; + }); - return { ...node, connections: finalConnections, type: updatedType, sliderIndex: updatedSliderIndex, probability: updatedSliderIndex === null ? null : node.probability }; - } - return node; - }); + // Handle node type conversion based on remaining connections + let finalConnections = updatedConnections; + let updatedType = node.type; + let updatedSliderIndex = node.sliderIndex; + + if (updatedConnections.length === 0) { + // No connections left: convert to AMBIVALENT outcome + updatedType = NodeType.AMBIVALENT; + updatedSliderIndex = null; // Clear sliderIndex + } else if (updatedConnections.length === 1) { + // One connection left: convert from QUESTION to INTERMEDIATE + // Also convert the remaining connection from YES/NO to E100 + updatedType = NodeType.INTERMEDIATE; + updatedSliderIndex = null; // Clear sliderIndex + finalConnections = updatedConnections.map((conn) => ({ + ...conn, + type: EdgeType.ALWAYS, + label: "", + })); + } - // Re-index remaining question nodes if we removed a question - if (sliderIndexToRemove !== null && sliderIndexToRemove !== undefined) { - updatedNodes = updatedNodes.map(n => { - if (n.type === NodeType.QUESTION && n.sliderIndex !== null && n.sliderIndex > sliderIndexToRemove) { - return { ...n, sliderIndex: n.sliderIndex - 1 }; + return { + ...node, + connections: finalConnections, + type: updatedType, + sliderIndex: updatedSliderIndex, + probability: + updatedSliderIndex === null ? null : node.probability, + }; } - return n; + return node; }); - } - return { ...prev, nodes: updatedNodes }; - }); + // Re-index remaining question nodes if we removed a question + if (sliderIndexToRemove !== null && sliderIndexToRemove !== undefined) { + updatedNodes = updatedNodes.map((n) => { + if ( + n.type === NodeType.QUESTION && + n.sliderIndex !== null && + n.sliderIndex > sliderIndexToRemove + ) { + return { ...n, sliderIndex: n.sliderIndex - 1 }; + } + return n; + }); + } - setSelectedEdgeIndex(-1); - }, [edges, nodes, graphData.nodes]); + return { ...prev, nodes: updatedNodes }; + }); + + setSelectedEdgeIndex(-1); + }, + [edges, nodes, graphData.nodes] + ); // Add arrow handler - const handleAddArrow = useCallback((nodeId: string, direction: 'top' | 'bottom' | 'left' | 'right', nodeWidth?: number, nodeHeight?: number, canvasPos?: { x: number; y: number }, mousePos?: { clientX: number; clientY: number }) => { - // Close any open text editor before adding the arrow - // Force blur on any active textarea to trigger save - if (document.activeElement instanceof HTMLTextAreaElement) { - document.activeElement.blur(); - } - handleEditorClose(); + const handleAddArrow = useCallback( + ( + nodeId: string, + direction: "top" | "bottom" | "left" | "right", + nodeWidth?: number, + nodeHeight?: number, + canvasPos?: { x: number; y: number }, + mousePos?: { clientX: number; clientY: number } + ) => { + // Close any open text editor before adding the arrow + // Force blur on any active textarea to trigger save + if (document.activeElement instanceof HTMLTextAreaElement) { + document.activeElement.blur(); + } + handleEditorClose(); - // Find the node we're adding an arrow to - const targetNode = graphData.nodes.find(n => n.id === nodeId); - if (!targetNode) return; + // Find the node we're adding an arrow to + const targetNode = graphData.nodes.find((n) => n.id === nodeId); + if (!targetNode) return; - // Calculate which edge index the new arrow will have (for drag-to-create) - const currentConnectionCount = targetNode.connections.length; - const newEdgeIndex = currentConnectionCount; // Will be added at this index + // Calculate which edge index the new arrow will have (for drag-to-create) + const currentConnectionCount = targetNode.connections.length; + const newEdgeIndex = currentConnectionCount; // Will be added at this index - // Determine if this will convert to a question node - const isIntermediateNode = targetNode.type === NodeType.INTERMEDIATE; - const willBecomeQuestion = isIntermediateNode && targetNode.connections.length === 1; + // Determine if this will convert to a question node + const isIntermediateNode = targetNode.type === NodeType.INTERMEDIATE; + const willBecomeQuestion = + isIntermediateNode && targetNode.connections.length === 1; - // Calculate the next sliderIndex BEFORE state updates (for new question nodes) - const existingQuestions = graphData.nodes.filter(n => n.type === NodeType.QUESTION); - const maxSliderIndex = existingQuestions.reduce( - (max, n) => n.sliderIndex !== null && n.sliderIndex > max ? n.sliderIndex : max, - -1 - ); - const newSliderIndex = maxSliderIndex + 1; - - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === nodeId) { - // Calculate floating endpoint position - let targetX: number; - let targetY: number; - - if (canvasPos) { - // Always use cursor position on mouse down - // Snapping to default position happens only on mouse up (in ConnectorDots) - targetX = canvasPos.x; - targetY = canvasPos.y; - console.log('[handleAddArrow] Creating arrow at cursor position:', { targetX, targetY }); - } else { - // Otherwise, calculate based on direction - // Use actual node dimensions if available, with fallbacks - const width = nodeWidth ?? 145; - const height = nodeHeight ?? 55; - const desiredClearance = 50; // How far beyond the node edge we want the endpoint - - targetX = node.position.x; - targetY = node.position.y; - - switch (direction) { - case 'top': - targetY -= (height / 2 + desiredClearance); - break; - case 'bottom': - targetY += (height / 2 + desiredClearance); - break; - case 'left': - targetX -= (width / 2 + desiredClearance); - break; - case 'right': - targetX += (width / 2 + desiredClearance); - break; + // Calculate the next sliderIndex BEFORE state updates (for new question nodes) + const existingQuestions = graphData.nodes.filter( + (n) => n.type === NodeType.QUESTION + ); + const maxSliderIndex = existingQuestions.reduce( + (max, n) => + n.sliderIndex !== null && n.sliderIndex > max ? n.sliderIndex : max, + -1 + ); + const newSliderIndex = maxSliderIndex + 1; + + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === nodeId) { + // Calculate floating endpoint position + let targetX: number; + let targetY: number; + + if (canvasPos) { + // Always use cursor position on mouse down + // Snapping to default position happens only on mouse up (in ConnectorDots) + targetX = canvasPos.x; + targetY = canvasPos.y; + console.log( + "[handleAddArrow] Creating arrow at cursor position:", + { targetX, targetY } + ); + } else { + // Otherwise, calculate based on direction + // Use actual node dimensions if available, with fallbacks + const width = nodeWidth ?? 145; + const height = nodeHeight ?? 55; + const desiredClearance = 50; // How far beyond the node edge we want the endpoint + + targetX = node.position.x; + targetY = node.position.y; + + switch (direction) { + case "top": + targetY -= height / 2 + desiredClearance; + break; + case "bottom": + targetY += height / 2 + desiredClearance; + break; + case "left": + targetX -= width / 2 + desiredClearance; + break; + case "right": + targetX += width / 2 + desiredClearance; + break; + } } - } - - const isOutcomeNode = node.type === NodeType.GOOD || node.type === NodeType.AMBIVALENT || node.type === NodeType.EXISTENTIAL; - const isIntermediateNode = node.type === NodeType.INTERMEDIATE; - if (isOutcomeNode && node.connections.length === 0) { - // Case: OUTCOME node with 0 connections -> add 1 E100 connection -> convert to INTERMEDIATE - const newConnection = { - type: EdgeType.ALWAYS, - targetX, - targetY, - label: '', - }; - - return { ...node, connections: [newConnection], type: NodeType.INTERMEDIATE }; - } else if (isIntermediateNode && node.connections.length === 1) { - // Case: INTERMEDIATE node with 1 connection -> add YES/NO connections -> convert to QUESTION - // Convert existing connection from E100 to YES - const updatedExistingConnections = node.connections.map(conn => { - if (conn.type === EdgeType.ALWAYS) { - return { ...conn, type: EdgeType.YES, label: 'Yes' }; - } - return conn; - }); + const isOutcomeNode = + node.type === NodeType.GOOD || + node.type === NodeType.AMBIVALENT || + node.type === NodeType.EXISTENTIAL; + const isIntermediateNode = node.type === NodeType.INTERMEDIATE; + + if (isOutcomeNode && node.connections.length === 0) { + // Case: OUTCOME node with 0 connections -> add 1 E100 connection -> convert to INTERMEDIATE + const newConnection = { + type: EdgeType.ALWAYS, + targetX, + targetY, + label: "", + }; - // Add new connection with NO type - const newConnection = { - type: EdgeType.NO, - targetX, - targetY, - label: 'No', - }; + return { + ...node, + connections: [newConnection], + type: NodeType.INTERMEDIATE, + }; + } else if (isIntermediateNode && node.connections.length === 1) { + // Case: INTERMEDIATE node with 1 connection -> add YES/NO connections -> convert to QUESTION + // Convert existing connection from E100 to YES + const updatedExistingConnections = node.connections.map( + (conn) => { + if (conn.type === EdgeType.ALWAYS) { + return { ...conn, type: EdgeType.YES, label: "Yes" }; + } + return conn; + } + ); + + // Add new connection with NO type + const newConnection = { + type: EdgeType.NO, + targetX, + targetY, + label: "No", + }; - const updatedConnections = [...updatedExistingConnections, newConnection]; + const updatedConnections = [ + ...updatedExistingConnections, + newConnection, + ]; - // Convert node from INTERMEDIATE to QUESTION - // CRITICAL: Assign sliderIndex and initialize probability - return { ...node, connections: updatedConnections, type: NodeType.QUESTION, sliderIndex: newSliderIndex, probability: 50 }; + // Convert node from INTERMEDIATE to QUESTION + // CRITICAL: Assign sliderIndex and initialize probability + return { + ...node, + connections: updatedConnections, + type: NodeType.QUESTION, + sliderIndex: newSliderIndex, + probability: 50, + }; + } } - } - return node; + return node; + }); + + return { ...prev, nodes: updatedNodes }; }); - return { ...prev, nodes: updatedNodes }; - }); + // If mousePos is provided, signal that we want to start dragging this new arrow + if (mousePos && canvasPos) { + // Track if mouse has moved to distinguish click from drag + let hasMouseMoved = false; + let mouseUpFired = false; + const initialScreenPos = { x: mousePos.clientX, y: mousePos.clientY }; + + const handleMouseMove = (e: MouseEvent) => { + const dx = e.clientX - initialScreenPos.x; + const dy = e.clientY - initialScreenPos.y; + const distMoved = Math.sqrt(dx * dx + dy * dy); + if (distMoved > 5) { + // 5px threshold + hasMouseMoved = true; + // Once we detect movement, remove this listener - ConnectorDots will handle the drag + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + console.log( + "[handleAddArrow] Mouse moved, letting ConnectorDots handle drag" + ); + } + }; - // If mousePos is provided, signal that we want to start dragging this new arrow - if (mousePos && canvasPos) { - // Track if mouse has moved to distinguish click from drag - let hasMouseMoved = false; - let mouseUpFired = false; - const initialScreenPos = { x: mousePos.clientX, y: mousePos.clientY }; - - const handleMouseMove = (e: MouseEvent) => { - const dx = e.clientX - initialScreenPos.x; - const dy = e.clientY - initialScreenPos.y; - const distMoved = Math.sqrt(dx * dx + dy * dy); - if (distMoved > 5) { // 5px threshold - hasMouseMoved = true; - // Once we detect movement, remove this listener - ConnectorDots will handle the drag - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - console.log('[handleAddArrow] Mouse moved, letting ConnectorDots handle drag'); - } - }; + const handleMouseUp = () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + mouseUpFired = true; + + if (hasMouseMoved) { + // User dragged - let ConnectorDots handle the snap logic + console.log( + "[handleAddArrow] Drag detected, ConnectorDots will handle" + ); + return; + } - const handleMouseUp = () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - mouseUpFired = true; + // Quick click without drag - handle snap-to-default here + console.log( + "[handleAddArrow] Quick click detected, checking snap-to-default" + ); - if (hasMouseMoved) { - // User dragged - let ConnectorDots handle the snap logic - console.log('[handleAddArrow] Drag detected, ConnectorDots will handle'); - return; - } - - // Quick click without drag - handle snap-to-default here - console.log('[handleAddArrow] Quick click detected, checking snap-to-default'); + // Get the source node + const sourceNode = graphData.nodes.find((n) => n.id === nodeId); + if (!sourceNode) { + setPendingNewArrow(null); + return; + } - // Get the source node - const sourceNode = graphData.nodes.find(n => n.id === nodeId); - if (!sourceNode) { - setPendingNewArrow(null); - return; - } + // Calculate node bounds + const width = nodeWidth ?? 145; + const height = nodeHeight ?? 55; + const nodeLeft = sourceNode.position.x - width / 2; + const nodeRight = sourceNode.position.x + width / 2; + const nodeTop = sourceNode.position.y - height / 2; + const nodeBottom = sourceNode.position.y + height / 2; + + // Calculate distance from canvasPos to node edge + const closestX = Math.max(nodeLeft, Math.min(canvasPos.x, nodeRight)); + const closestY = Math.max(nodeTop, Math.min(canvasPos.y, nodeBottom)); + const dx = closestX - canvasPos.x; + const dy = closestY - canvasPos.y; + const distSquared = dx * dx + dy * dy; + + const SNAP_DISTANCE = 50; + const SNAP_TO_DEFAULT_DISTANCE = SNAP_DISTANCE * 2; // 100 pixels + const SNAP_TO_DEFAULT_DISTANCE_SQUARED = + SNAP_TO_DEFAULT_DISTANCE * SNAP_TO_DEFAULT_DISTANCE; + + console.log( + "[handleAddArrow] Distance to source edge:", + Math.sqrt(distSquared), + "threshold:", + SNAP_TO_DEFAULT_DISTANCE + ); + + if (distSquared < SNAP_TO_DEFAULT_DISTANCE_SQUARED) { + console.log( + "[handleAddArrow] Within snap threshold! Snapping to default position..." + ); + // Snap to default position + const sourceCenterX = sourceNode.position.x; + const sourceCenterY = sourceNode.position.y; + const angleFromCenter = Math.atan2( + canvasPos.y - sourceCenterY, + canvasPos.x - sourceCenterX + ); + const defaultClearance = 50; + const nodeRadius = Math.max(width, height) / 2; + const snappedX = + sourceCenterX + + Math.cos(angleFromCenter) * (nodeRadius + defaultClearance); + const snappedY = + sourceCenterY + + Math.sin(angleFromCenter) * (nodeRadius + defaultClearance); + + console.log("[handleAddArrow] Snapping from", canvasPos, "to", { + x: snappedX, + y: snappedY, + }); - // Calculate node bounds - const width = nodeWidth ?? 145; - const height = nodeHeight ?? 55; - const nodeLeft = sourceNode.position.x - width / 2; - const nodeRight = sourceNode.position.x + width / 2; - const nodeTop = sourceNode.position.y - height / 2; - const nodeBottom = sourceNode.position.y + height / 2; - - // Calculate distance from canvasPos to node edge - const closestX = Math.max(nodeLeft, Math.min(canvasPos.x, nodeRight)); - const closestY = Math.max(nodeTop, Math.min(canvasPos.y, nodeBottom)); - const dx = closestX - canvasPos.x; - const dy = closestY - canvasPos.y; - const distSquared = dx * dx + dy * dy; - - const SNAP_DISTANCE = 50; - const SNAP_TO_DEFAULT_DISTANCE = SNAP_DISTANCE * 2; // 100 pixels - const SNAP_TO_DEFAULT_DISTANCE_SQUARED = SNAP_TO_DEFAULT_DISTANCE * SNAP_TO_DEFAULT_DISTANCE; - - console.log('[handleAddArrow] Distance to source edge:', Math.sqrt(distSquared), 'threshold:', SNAP_TO_DEFAULT_DISTANCE); - - if (distSquared < SNAP_TO_DEFAULT_DISTANCE_SQUARED) { - console.log('[handleAddArrow] Within snap threshold! Snapping to default position...'); - // Snap to default position - const sourceCenterX = sourceNode.position.x; - const sourceCenterY = sourceNode.position.y; - const angleFromCenter = Math.atan2(canvasPos.y - sourceCenterY, canvasPos.x - sourceCenterX); - const defaultClearance = 50; - const nodeRadius = Math.max(width, height) / 2; - const snappedX = sourceCenterX + Math.cos(angleFromCenter) * (nodeRadius + defaultClearance); - const snappedY = sourceCenterY + Math.sin(angleFromCenter) * (nodeRadius + defaultClearance); - - console.log('[handleAddArrow] Snapping from', canvasPos, 'to', { x: snappedX, y: snappedY }); - - // Update the arrow position - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === nodeId) { - const updatedConnections = node.connections.map((conn, idx) => { - if (idx === newEdgeIndex) { - return { ...conn, targetX: snappedX, targetY: snappedY }; - } - return conn; - }); - return { ...node, connections: updatedConnections }; - } - return node; + // Update the arrow position + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === nodeId) { + const updatedConnections = node.connections.map( + (conn, idx) => { + if (idx === newEdgeIndex) { + return { + ...conn, + targetX: snappedX, + targetY: snappedY, + }; + } + return conn; + } + ); + return { ...node, connections: updatedConnections }; + } + return node; + }); + return { ...prev, nodes: updatedNodes }; }); - return { ...prev, nodes: updatedNodes }; - }); - } + } - // Clear any pending arrow state since we handled the quick click - setPendingNewArrow(null); - console.log('[handleAddArrow] Quick click handled, cleared pendingNewArrow'); - }; + // Clear any pending arrow state since we handled the quick click + setPendingNewArrow(null); + console.log( + "[handleAddArrow] Quick click handled, cleared pendingNewArrow" + ); + }; - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp, { once: true }); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp, { once: true }); - // Use setTimeout to ensure the arrow is rendered before we try to drag it - // Only set pendingNewArrow if mouseUp hasn't fired (meaning user is actually dragging) - setTimeout(() => { - if (!mouseUpFired) { - console.log('[handleAddArrow] Setting pendingNewArrow for drag'); - setPendingNewArrow({ nodeId, edgeIndex: newEdgeIndex, mousePos }); - } else { - console.log('[handleAddArrow] Not setting pendingNewArrow - mouseUp already fired'); - } - }, 0); - } - }, [graphData.nodes, handleEditorClose]); + // Use setTimeout to ensure the arrow is rendered before we try to drag it + // Only set pendingNewArrow if mouseUp hasn't fired (meaning user is actually dragging) + setTimeout(() => { + if (!mouseUpFired) { + console.log("[handleAddArrow] Setting pendingNewArrow for drag"); + setPendingNewArrow({ nodeId, edgeIndex: newEdgeIndex, mousePos }); + } else { + console.log( + "[handleAddArrow] Not setting pendingNewArrow - mouseUp already fired" + ); + } + }, 0); + } + }, + [graphData.nodes, handleEditorClose] + ); // Reset sliders to 50% const handleResetSliders = useCallback(() => { - - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { if (node.type === NodeType.QUESTION && node.sliderIndex !== null) { return { ...node, probability: 50 }; } @@ -785,20 +999,26 @@ function HomeContent() { }); // Track analytics - analytics.trackAction('reset'); + analytics.trackAction("reset"); }, []); // Load default estimates (only updates recognized nodes, preserves custom nodes) const handleLoadAuthorsEstimates = useCallback(() => { - - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { // Find this node in defaultGraphData to get its original probability - const defaultNode = defaultGraphData.nodes.find(n => n.id === node.id); + const defaultNode = defaultGraphData.nodes.find( + (n) => n.id === node.id + ); // Only update probability for question nodes that exist in the default data // AND have a valid probability value (not null or undefined) // Custom nodes and nodes without default probabilities are left unchanged - if (node.type === NodeType.QUESTION && node.sliderIndex !== null && defaultNode && defaultNode.probability != null) { + if ( + node.type === NodeType.QUESTION && + node.sliderIndex !== null && + defaultNode && + defaultNode.probability != null + ) { // Preserve all node fields (including custom title), only update probability return { ...node, probability: defaultNode.probability }; } @@ -808,7 +1028,7 @@ function HomeContent() { }); // Track analytics - analytics.trackAction('load_authors_estimates'); + analytics.trackAction("load_authors_estimates"); }, []); // Undo handler @@ -819,10 +1039,10 @@ function HomeContent() { // Move to previous state const newIndex = historyIndex - 1; setHistoryIndex(newIndex); - setGraphData(prev => ({ ...prev, nodes: history[newIndex] })); + setGraphData((prev) => ({ ...prev, nodes: history[newIndex] })); // Track analytics - analytics.trackAction('undo'); + analytics.trackAction("undo"); }, [historyIndex, history]); // Redo handler @@ -833,151 +1053,168 @@ function HomeContent() { // Move to next state const newIndex = historyIndex + 1; setHistoryIndex(newIndex); - setGraphData(prev => ({ ...prev, nodes: history[newIndex] })); + setGraphData((prev) => ({ ...prev, nodes: history[newIndex] })); // Track analytics - analytics.trackAction('redo'); + analytics.trackAction("redo"); }, [historyIndex, history]); // Node drag end handler - const handleNodeDragEnd = useCallback((nodeId: string, newX: number, newY: number) => { - // Update graph data with new position (auto-save will handle persistence) - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === nodeId) { - return { - ...node, - position: { x: newX, y: newY }, - }; - } - return node; - }); - - // Helper function to check if there's a path from one node to another - const hasPath = (fromId: string, toId: string, nodes: typeof updatedNodes): boolean => { - if (fromId === toId) return true; - - const visited = new Set(); - const queue = [fromId]; - - while (queue.length > 0) { - const currentId = queue.shift()!; - if (currentId === toId) return true; - if (visited.has(currentId)) continue; - - visited.add(currentId); - const currentNode = nodes.find(n => n.id === currentId); + const handleNodeDragEnd = useCallback( + (nodeId: string, newX: number, newY: number) => { + // Update graph data with new position (auto-save will handle persistence) + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === nodeId) { + return { + ...node, + position: { x: newX, y: newY }, + }; + } + return node; + }); - if (currentNode) { - currentNode.connections.forEach(conn => { - if (conn.targetId && !visited.has(conn.targetId)) { - queue.push(conn.targetId); - } - }); + // Helper function to check if there's a path from one node to another + const hasPath = ( + fromId: string, + toId: string, + nodes: typeof updatedNodes + ): boolean => { + if (fromId === toId) return true; + + const visited = new Set(); + const queue = [fromId]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + if (currentId === toId) return true; + if (visited.has(currentId)) continue; + + visited.add(currentId); + const currentNode = nodes.find((n) => n.id === currentId); + + if (currentNode) { + currentNode.connections.forEach((conn) => { + if (conn.targetId && !visited.has(conn.targetId)) { + queue.push(conn.targetId); + } + }); + } } - } - return false; - }; + return false; + }; - // Connect or push away floating arrow endpoints depending on whether it would create a cycle - const nodeWidth = 145; - const nodeHeight = 55; - const padding = 10; // Small padding around the node + // Connect or push away floating arrow endpoints depending on whether it would create a cycle + const nodeWidth = 145; + const nodeHeight = 55; + const padding = 10; // Small padding around the node - const nodeBounds = { - left: newX - nodeWidth / 2 - padding, - right: newX + nodeWidth / 2 + padding, - top: newY - nodeHeight / 2 - padding, - bottom: newY + nodeHeight / 2 + padding, - }; + const nodeBounds = { + left: newX - nodeWidth / 2 - padding, + right: newX + nodeWidth / 2 + padding, + top: newY - nodeHeight / 2 - padding, + bottom: newY + nodeHeight / 2 + padding, + }; - const updatedNodesWithPushedEndpoints = updatedNodes.map(node => { - const updatedConnections = node.connections.map(connection => { - // Check if this is a floating endpoint - if (connection.targetX !== undefined && connection.targetY !== undefined) { - // Check if the endpoint is within the moved node's bounds + const updatedNodesWithPushedEndpoints = updatedNodes.map((node) => { + const updatedConnections = node.connections.map((connection) => { + // Check if this is a floating endpoint if ( - connection.targetX >= nodeBounds.left && - connection.targetX <= nodeBounds.right && - connection.targetY >= nodeBounds.top && - connection.targetY <= nodeBounds.bottom + connection.targetX !== undefined && + connection.targetY !== undefined ) { - // Check if connecting this arrow to the dropped node would create a cycle - // A cycle would occur if there's already a path from the dropped node to the source node - const wouldCreateCycle = hasPath(nodeId, node.id, updatedNodes); - - if (wouldCreateCycle) { - // Push the arrow away to avoid creating a cycle - const dx = connection.targetX - newX; - const dy = connection.targetY - newY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // If endpoint is at the exact center, push it to the right - if (distance === 0) { - return { - ...connection, - targetX: nodeBounds.right + 5, - targetY: newY, - }; - } + // Check if the endpoint is within the moved node's bounds + if ( + connection.targetX >= nodeBounds.left && + connection.targetX <= nodeBounds.right && + connection.targetY >= nodeBounds.top && + connection.targetY <= nodeBounds.bottom + ) { + // Check if connecting this arrow to the dropped node would create a cycle + // A cycle would occur if there's already a path from the dropped node to the source node + const wouldCreateCycle = hasPath(nodeId, node.id, updatedNodes); + + if (wouldCreateCycle) { + // Push the arrow away to avoid creating a cycle + const dx = connection.targetX - newX; + const dy = connection.targetY - newY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // If endpoint is at the exact center, push it to the right + if (distance === 0) { + return { + ...connection, + targetX: nodeBounds.right + 5, + targetY: newY, + }; + } - // Normalize direction - const normalizedDx = dx / distance; - const normalizedDy = dy / distance; + // Normalize direction + const normalizedDx = dx / distance; + const normalizedDy = dy / distance; - // Calculate which edge of the rectangle we'll exit from - const halfWidth = nodeWidth / 2; - const halfHeight = nodeHeight / 2; + // Calculate which edge of the rectangle we'll exit from + const halfWidth = nodeWidth / 2; + const halfHeight = nodeHeight / 2; - // Find which edge we hit first by comparing ratios - const tX = normalizedDx !== 0 ? halfWidth / Math.abs(normalizedDx) : Infinity; - const tY = normalizedDy !== 0 ? halfHeight / Math.abs(normalizedDy) : Infinity; - const t = Math.min(tX, tY); + // Find which edge we hit first by comparing ratios + const tX = + normalizedDx !== 0 + ? halfWidth / Math.abs(normalizedDx) + : Infinity; + const tY = + normalizedDy !== 0 + ? halfHeight / Math.abs(normalizedDy) + : Infinity; + const t = Math.min(tX, tY); - // Push distance is to the edge plus padding plus extra space (doubled) - const pushDistance = t + (padding + 5) * 2; + // Push distance is to the edge plus padding plus extra space (doubled) + const pushDistance = t + (padding + 5) * 2; - return { - ...connection, - targetX: newX + normalizedDx * pushDistance, - targetY: newY + normalizedDy * pushDistance, - }; - } else { - // Connect the arrow to the dropped node (no cycle would be created) - return { - ...connection, - targetId: nodeId, - targetX: undefined, - targetY: undefined, - }; + return { + ...connection, + targetX: newX + normalizedDx * pushDistance, + targetY: newY + normalizedDy * pushDistance, + }; + } else { + // Connect the arrow to the dropped node (no cycle would be created) + return { + ...connection, + targetId: nodeId, + targetX: undefined, + targetY: undefined, + }; + } } } - } - return connection; + return connection; + }); + + return { + ...node, + connections: updatedConnections, + }; }); return { - ...node, - connections: updatedConnections, + ...prev, + nodes: updatedNodesWithPushedEndpoints, }; }); - - return { - ...prev, - nodes: updatedNodesWithPushedEndpoints, - }; - }); - - }, []); + }, + [] + ); // Reset node positions handler const handleResetNodePositions = useCallback(() => { // Reset positions to defaults from defaultGraphData - setGraphData(prev => ({ + setGraphData((prev) => ({ ...prev, - nodes: prev.nodes.map(node => { - const defaultNode = defaultGraphData.nodes.find(n => n.id === node.id); + nodes: prev.nodes.map((node) => { + const defaultNode = defaultGraphData.nodes.find( + (n) => n.id === node.id + ); if (defaultNode) { return { ...node, position: defaultNode.position }; } @@ -987,24 +1224,32 @@ function HomeContent() { }, []); // Node drag state handler - const handleNodeDragStateChange = useCallback((isDragging: boolean, shiftHeld: boolean, cursorX?: number, cursorY?: number) => { - setIsDraggingNode(isDragging); - setDragShiftHeld(shiftHeld); - if (cursorX !== undefined && cursorY !== undefined) { - setDragCursorPos({ x: cursorX, y: cursorY }); - } - }, []); + const handleNodeDragStateChange = useCallback( + ( + isDragging: boolean, + shiftHeld: boolean, + cursorX?: number, + cursorY?: number + ) => { + setIsDraggingNode(isDragging); + setDragShiftHeld(shiftHeld); + if (cursorX !== undefined && cursorY !== undefined) { + setDragCursorPos({ x: cursorX, y: cursorY }); + } + }, + [] + ); // Document picker handlers const handleDocumentSelect = useCallback(async (documentId: string) => { const { data, error } = await loadDocument(documentId); if (!error && data) { // Migrate old data: ensure all nodes have probability field - const migratedNodes = data.data.nodes.map(node => { + const migratedNodes = data.data.nodes.map((node) => { if (node.probability === undefined) { return { ...node, - probability: node.type === NodeType.QUESTION ? 50 : null + probability: node.type === NodeType.QUESTION ? 50 : null, }; } return node; @@ -1019,7 +1264,7 @@ function HomeContent() { const handleCreateNewDocument = useCallback((useTemplate: boolean) => { setCurrentDocumentId(null); - setDocumentName('Untitled Document'); + setDocumentName("Untitled Document"); if (useTemplate) { // Load the full template with all question nodes and outcomes @@ -1042,7 +1287,7 @@ function HomeContent() { // Zoom control handlers const handleZoomIn = useCallback(() => { - setZoom(prev => { + setZoom((prev) => { const newZoom = Math.min(prev + ZOOM_STEP, MAX_ZOOM); // Buttons don't zoom to cursor, they zoom to center // No pan adjustment needed, clamping will happen in Flowchart @@ -1051,7 +1296,7 @@ function HomeContent() { }, []); const handleZoomOut = useCallback(() => { - setZoom(prev => { + setZoom((prev) => { const newZoom = Math.max(prev - ZOOM_STEP, MIN_ZOOM); // Buttons don't zoom to cursor, they zoom to center // No pan adjustment needed, clamping will happen in Flowchart @@ -1062,7 +1307,7 @@ function HomeContent() { const handleResetView = useCallback(() => { setZoom(100); // Increment reset trigger to force scroll reset even if zoom is already 100% - setResetTrigger(prev => prev + 1); + setResetTrigger((prev) => prev + 1); }, []); const handleZoomChange = useCallback((newZoom: number) => { @@ -1070,27 +1315,28 @@ function HomeContent() { }, []); // Graph editing handlers - const handleUpdateNodeText = useCallback((nodeId: string, newText: string) => { - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === nodeId) { - return { - ...node, - title: newText, - }; - } - return node; + const handleUpdateNodeText = useCallback( + (nodeId: string, newText: string) => { + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === nodeId) { + return { + ...node, + title: newText, + }; + } + return node; + }); + return { + ...prev, + nodes: updatedNodes, + }; }); - return { - ...prev, - nodes: updatedNodes, - }; - }); - }, []); - + }, + [] + ); const handleAddNode = useCallback((x: number, y: number) => { - // Generate a unique ID for the new node const timestamp = Date.now(); const randomSuffix = Math.random().toString(36).substring(2, 9); @@ -1100,284 +1346,337 @@ function HomeContent() { const newNode = { id: newNodeId, type: NodeType.AMBIVALENT, // Ambivalent outcome node type - title: '', + title: "", connections: [], // Outcome nodes have no outgoing connections position: { x, y }, sliderIndex: null, probability: null, }; - setGraphData(prev => ({ + setGraphData((prev) => ({ ...prev, nodes: [...prev.nodes, newNode], })); // Trigger auto-edit for the newly created node setAutoEditNodeId(newNodeId); - }, []); - const handleCreateNodeFromFloatingArrow = useCallback((edgeIndex: number, position: { x: number; y: number }) => { - const edge = edges[edgeIndex]; - if (!edge || edge.target !== undefined) return; // Only handle floating arrows - - // Close any open text editor before creating the new node - // Force blur on any active textarea to trigger save - if (document.activeElement instanceof HTMLTextAreaElement) { - document.activeElement.blur(); - } - handleEditorClose(); + const handleCreateNodeFromFloatingArrow = useCallback( + (edgeIndex: number, position: { x: number; y: number }) => { + const edge = edges[edgeIndex]; + if (!edge || edge.target !== undefined) return; // Only handle floating arrows - // Generate a unique ID for the new node - const timestamp = Date.now(); - const randomSuffix = Math.random().toString(36).substring(2, 9); - const newNodeId = `node_${timestamp}_${randomSuffix}`; + // Close any open text editor before creating the new node + // Force blur on any active textarea to trigger save + if (document.activeElement instanceof HTMLTextAreaElement) { + document.activeElement.blur(); + } + handleEditorClose(); + + // Generate a unique ID for the new node + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(2, 9); + const newNodeId = `node_${timestamp}_${randomSuffix}`; + + // Create a new ambivalent outcome node at the specified position + const newNode = { + id: newNodeId, + type: NodeType.AMBIVALENT, // Ambivalent outcome node type + title: "", + connections: [], // Outcome nodes have no outgoing connections + position: { x: position.x, y: position.y }, + sliderIndex: null, + probability: null, + }; - // Create a new ambivalent outcome node at the specified position - const newNode = { - id: newNodeId, - type: NodeType.AMBIVALENT, // Ambivalent outcome node type - title: '', - connections: [], // Outcome nodes have no outgoing connections - position: { x: position.x, y: position.y }, - sliderIndex: null, - probability: null, - }; + // Get the source node ID + const sourceNodeId = nodes[edge.source].id; + + // Update graph: add new node and reconnect the floating arrow to it + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((node) => { + if (node.id === sourceNodeId) { + // Update the connection to point to the new node + const updatedConnections = node.connections.map((conn) => { + // Find the floating connection that matches this edge + const isMatchingConnection = + conn.targetX === edge.targetX && + conn.targetY === edge.targetY && + conn.type === edge.yn; + + if (isMatchingConnection) { + // Connect to the new node + return { + ...conn, + targetId: newNodeId, + targetX: undefined, + targetY: undefined, + }; + } + return conn; + }); + return { ...node, connections: updatedConnections }; + } + return node; + }); - // Get the source node ID - const sourceNodeId = nodes[edge.source].id; - - // Update graph: add new node and reconnect the floating arrow to it - setGraphData(prev => { - const updatedNodes = prev.nodes.map(node => { - if (node.id === sourceNodeId) { - // Update the connection to point to the new node - const updatedConnections = node.connections.map((conn) => { - // Find the floating connection that matches this edge - const isMatchingConnection = - conn.targetX === edge.targetX && - conn.targetY === edge.targetY && - conn.type === edge.yn; - - if (isMatchingConnection) { - // Connect to the new node - return { ...conn, targetId: newNodeId, targetX: undefined, targetY: undefined }; - } - return conn; - }); - return { ...node, connections: updatedConnections }; - } - return node; + return { + ...prev, + nodes: [...updatedNodes, newNode], + }; }); - return { - ...prev, - nodes: [...updatedNodes, newNode], - }; - }); + // Trigger auto-edit for the newly created node + setAutoEditNodeId(newNodeId); + }, + [edges, nodes, handleEditorClose] + ); - // Trigger auto-edit for the newly created node - setAutoEditNodeId(newNodeId); - }, [edges, nodes, handleEditorClose]); + const handleDeleteNode = useCallback( + (nodeId: string) => { + // Find the node to delete + const nodeToDelete = graphData.nodes.find((n) => n.id === nodeId); + if (!nodeToDelete) return; - const handleDeleteNode = useCallback((nodeId: string) => { - // Find the node to delete - const nodeToDelete = graphData.nodes.find(n => n.id === nodeId); - if (!nodeToDelete) return; + // Get the node's position for converting incoming edges to free-floating + const nodePosition = nodeToDelete.position; - // Get the node's position for converting incoming edges to free-floating - const nodePosition = nodeToDelete.position; + // Check if we need to reset probabilityRootIndex + const deletedNodeIndex = nodes.findIndex((n) => n.id === nodeId); + const shouldResetSelection = deletedNodeIndex === probabilityRootIndex; - // Check if we need to reset probabilityRootIndex - const deletedNodeIndex = nodes.findIndex(n => n.id === nodeId); - const shouldResetSelection = deletedNodeIndex === probabilityRootIndex; + // Use flushSync to ensure all state updates happen synchronously + // This prevents intermediate renders with mismatched indices + flushSync(() => { + // Clear UI state + setSelectedNodeId(null); - // Use flushSync to ensure all state updates happen synchronously - // This prevents intermediate renders with mismatched indices - flushSync(() => { - // Clear UI state - setSelectedNodeId(null); + // Reset selection if needed + if (shouldResetSelection) { + setProbabilityRootIndex(startNodeIndex); + } - // Reset selection if needed - if (shouldResetSelection) { - setProbabilityRootIndex(startNodeIndex); - } + // Update graph data + setGraphData((prev) => { + // Remove the node from the array + const updatedNodes = prev.nodes.filter((n) => n.id !== nodeId); - // Update graph data - setGraphData(prev => { - // Remove the node from the array - const updatedNodes = prev.nodes.filter(n => n.id !== nodeId); + // Convert incoming edges to free-floating endpoints + const nodesWithUpdatedConnections = updatedNodes.map((node) => { + const updatedConnections = node.connections.map((conn) => { + if (conn.targetId === nodeId) { + // Convert to free-floating endpoint + return { + ...conn, + targetId: undefined, + targetX: nodePosition.x, + targetY: nodePosition.y, + }; + } + return conn; + }); - // Convert incoming edges to free-floating endpoints - const nodesWithUpdatedConnections = updatedNodes.map(node => { - const updatedConnections = node.connections.map(conn => { - if (conn.targetId === nodeId) { - // Convert to free-floating endpoint - return { - ...conn, - targetId: undefined, - targetX: nodePosition.x, - targetY: nodePosition.y, - }; - } - return conn; + return { + ...node, + connections: updatedConnections, + }; }); + // Re-index slider indices if deleting a question node + const deletedSliderIndex = nodeToDelete.sliderIndex; + const finalNodes = + deletedSliderIndex !== null && deletedSliderIndex !== undefined + ? nodesWithUpdatedConnections.map((node) => { + if ( + node.sliderIndex !== null && + node.sliderIndex !== undefined && + node.sliderIndex > deletedSliderIndex + ) { + return { + ...node, + sliderIndex: node.sliderIndex - 1, + }; + } + return node; + }) + : nodesWithUpdatedConnections; + return { - ...node, - connections: updatedConnections, + ...prev, + nodes: finalNodes, }; }); - - // Re-index slider indices if deleting a question node - const deletedSliderIndex = nodeToDelete.sliderIndex; - const finalNodes = deletedSliderIndex !== null && deletedSliderIndex !== undefined - ? nodesWithUpdatedConnections.map(node => { - if (node.sliderIndex !== null && node.sliderIndex !== undefined && node.sliderIndex > deletedSliderIndex) { - return { - ...node, - sliderIndex: node.sliderIndex - 1, - }; - } - return node; - }) - : nodesWithUpdatedConnections; - - return { - ...prev, - nodes: finalNodes, - }; }); - }); - }, [graphData, nodes, probabilityRootIndex]); + }, + [graphData, nodes, probabilityRootIndex] + ); - const handleInitiateDelete = useCallback((nodeId: string) => { - // Find the node to delete - const node = graphData.nodes.find(n => n.id === nodeId); - if (!node) return; + const handleInitiateDelete = useCallback( + (nodeId: string) => { + // Find the node to delete + const node = graphData.nodes.find((n) => n.id === nodeId); + if (!node) return; - // Prevent deleting the start node - if (node.type === NodeType.START) { - alert('Cannot delete the start node'); - return; - } + // Prevent deleting the start node + if (node.type === NodeType.START) { + alert("Cannot delete the start node"); + return; + } - // Delete immediately (undo/redo provides safety net) - handleDeleteNode(nodeId); - }, [graphData, handleDeleteNode]); + // Delete immediately (undo/redo provides safety net) + handleDeleteNode(nodeId); + }, + [graphData, handleDeleteNode] + ); - const handleInitiateDeleteEdge = useCallback((edgeIndex: number) => { - // Delete immediately (undo/redo provides safety net) - handleDeleteEdge(edgeIndex); - }, [handleDeleteEdge]); + const handleInitiateDeleteEdge = useCallback( + (edgeIndex: number) => { + // Delete immediately (undo/redo provides safety net) + handleDeleteEdge(edgeIndex); + }, + [handleDeleteEdge] + ); // Change node type handler - const handleChangeNodeType = useCallback((nodeId: string, newType: NodeType) => { - const node = graphData.nodes.find(n => n.id === nodeId); - if (!node) return; + const handleChangeNodeType = useCallback( + (nodeId: string, newType: NodeType) => { + const node = graphData.nodes.find((n) => n.id === nodeId); + if (!node) return; + + // Don't allow changing start node type + if (node.type === NodeType.START) { + alert("Cannot change the type of the start node"); + return; + } - // Don't allow changing start node type - if (node.type === NodeType.START) { - alert('Cannot change the type of the start node'); - return; - } + const oldType = node.type; + if (oldType === newType) return; // No change + + flushSync(() => { + setGraphData((prev) => { + const updatedNodes = prev.nodes.map((n) => { + if (n.id !== nodeId) return n; + + // Changing TO question node + if (newType === NodeType.QUESTION) { + // Find the highest sliderIndex among existing questions + const questionNodes = prev.nodes.filter( + (node) => node.type === NodeType.QUESTION + ); + const maxSliderIndex = questionNodes.reduce( + (max, node) => + node.sliderIndex !== null && node.sliderIndex > max + ? node.sliderIndex + : max, + -1 + ); + const newSliderIndex = maxSliderIndex + 1; + + // Ensure node has exactly 2 connections (YES and NO) + let connections = [...n.connections]; + if (connections.length === 0) { + // Create 2 new free-floating connections + connections = [ + { + type: EdgeType.YES, + targetX: n.position.x + 75, + targetY: n.position.y - 50, + label: "Yes", + }, + { + type: EdgeType.NO, + targetX: n.position.x + 75, + targetY: n.position.y + 50, + label: "No", + }, + ]; + } else if (connections.length === 1) { + // Keep existing as YES, add NO + connections[0] = { ...connections[0], type: EdgeType.YES }; + connections.push({ + type: EdgeType.NO, + targetX: n.position.x + 75, + targetY: n.position.y + 50, + label: "No", + }); + } else { + // Has 2+ connections: convert first to YES, second to NO, keep rest as-is + connections[0] = { ...connections[0], type: EdgeType.YES }; + connections[1] = { ...connections[1], type: EdgeType.NO }; + } - const oldType = node.type; - if (oldType === newType) return; // No change - - flushSync(() => { - setGraphData(prev => { - const updatedNodes = prev.nodes.map(n => { - if (n.id !== nodeId) return n; - - // Changing TO question node - if (newType === NodeType.QUESTION) { - // Find the highest sliderIndex among existing questions - const questionNodes = prev.nodes.filter(node => node.type === NodeType.QUESTION); - const maxSliderIndex = questionNodes.reduce((max, node) => - node.sliderIndex !== null && node.sliderIndex > max ? node.sliderIndex : max, -1); - const newSliderIndex = maxSliderIndex + 1; - - // Ensure node has exactly 2 connections (YES and NO) - let connections = [...n.connections]; - if (connections.length === 0) { - // Create 2 new free-floating connections - connections = [ - { type: EdgeType.YES, targetX: n.position.x + 75, targetY: n.position.y - 50, label: 'Yes' }, - { type: EdgeType.NO, targetX: n.position.x + 75, targetY: n.position.y + 50, label: 'No' }, - ]; - } else if (connections.length === 1) { - // Keep existing as YES, add NO - connections[0] = { ...connections[0], type: EdgeType.YES }; - connections.push({ type: EdgeType.NO, targetX: n.position.x + 75, targetY: n.position.y + 50, label: 'No' }); - } else { - // Has 2+ connections: convert first to YES, second to NO, keep rest as-is - connections[0] = { ...connections[0], type: EdgeType.YES }; - connections[1] = { ...connections[1], type: EdgeType.NO }; + return { + ...n, + type: newType, + sliderIndex: newSliderIndex, + connections, + }; } - return { - ...n, - type: newType, - sliderIndex: newSliderIndex, - connections, - }; - } + // Changing FROM question node to something else + if (oldType === NodeType.QUESTION) { + // Convert YES/NO connections to ALWAYS + const connections = n.connections.map((conn) => ({ + ...conn, + type: EdgeType.ALWAYS, + })); - // Changing FROM question node to something else - if (oldType === NodeType.QUESTION) { - // Convert YES/NO connections to ALWAYS - const connections = n.connections.map(conn => ({ - ...conn, - type: EdgeType.ALWAYS, - })); + return { + ...n, + type: newType, + sliderIndex: null, + connections, + }; + } + // Other type changes (just change the type) return { ...n, type: newType, - sliderIndex: null, - connections, }; - } - - // Other type changes (just change the type) - return { - ...n, - type: newType, - }; - }); + }); - // If changing FROM question, need to re-index remaining questions and update sliderValues - if (oldType === NodeType.QUESTION) { - const oldSliderIndex = node.sliderIndex; - if (oldSliderIndex !== null) { - // Re-index all questions that had higher indices - const finalNodes = updatedNodes.map(n => { - if (n.type === NodeType.QUESTION && n.sliderIndex !== null && n.sliderIndex > oldSliderIndex) { - return { ...n, sliderIndex: n.sliderIndex - 1 }; - } - return n; - }); + // If changing FROM question, need to re-index remaining questions and update sliderValues + if (oldType === NodeType.QUESTION) { + const oldSliderIndex = node.sliderIndex; + if (oldSliderIndex !== null) { + // Re-index all questions that had higher indices + const finalNodes = updatedNodes.map((n) => { + if ( + n.type === NodeType.QUESTION && + n.sliderIndex !== null && + n.sliderIndex > oldSliderIndex + ) { + return { ...n, sliderIndex: n.sliderIndex - 1 }; + } + return n; + }); - return { ...prev, nodes: finalNodes }; + return { ...prev, nodes: finalNodes }; + } } - } - - return { ...prev, nodes: updatedNodes }; - }); + return { ...prev, nodes: updatedNodes }; + }); }); - }, [graphData]); + }, + [graphData] + ); // Keyboard handler for Delete/Backspace keys useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Only trigger if a node or edge is selected and we're not editing text if (!selectedNodeId && selectedEdgeIndex < 0) return; - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if ((e.target as HTMLElement).contentEditable === 'true') return; - - if (e.key === 'Delete' || e.key === 'Backspace') { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + if ((e.target as HTMLElement).contentEditable === "true") return; + + if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); // Delete edge if one is selected @@ -1391,40 +1690,49 @@ function HomeContent() { } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [selectedNodeId, selectedEdgeIndex, handleInitiateDelete, handleInitiateDeleteEdge]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + selectedNodeId, + selectedEdgeIndex, + handleInitiateDelete, + handleInitiateDeleteEdge, + ]); // Keyboard handler for Undo/Redo useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't trigger when typing in text inputs (but allow for range/slider inputs) - if (e.target instanceof HTMLInputElement && e.target.type !== 'range') return; + if (e.target instanceof HTMLInputElement && e.target.type !== "range") + return; if (e.target instanceof HTMLTextAreaElement) return; - if ((e.target as HTMLElement).contentEditable === 'true') return; + if ((e.target as HTMLElement).contentEditable === "true") return; - const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; - if (cmdOrCtrl && e.key === 'z' && !e.shiftKey) { + if (cmdOrCtrl && e.key === "z" && !e.shiftKey) { // Ctrl+Z / Cmd+Z = Undo e.preventDefault(); handleUndo(); - } else if (cmdOrCtrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + } else if ( + cmdOrCtrl && + (e.key === "y" || (e.key === "z" && e.shiftKey)) + ) { // Ctrl+Y / Cmd+Y or Ctrl+Shift+Z / Cmd+Shift+Z = Redo e.preventDefault(); handleRedo(); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [handleUndo, handleRedo]); // Settings change handlers with analytics const handleMinOpacityChange = useCallback((value: number) => { setMinOpacity(value); - analytics.trackSettingChange('min_opacity', value); + analytics.trackSettingChange("min_opacity", value); }, []); const handleContinueAnyway = useCallback(() => { @@ -1435,241 +1743,248 @@ function HomeContent() { return ( <> {/* Mobile Warning - only shows on mobile devices */} - {showMobileWarning && ( - - )} + {showMobileWarning && }
{/* Sidebar - hide on mobile view */} {!isMobileView && ( + minOpacity={minOpacity} + hoveredNodeIndex={hoveredNodeIndex} + probabilityRootIndex={probabilityRootIndex} + graphData={graphData} + nodes={nodes} + isCollapsed={isSidebarCollapsed} + onToggleCollapse={handleToggleSidebar} + onSliderChange={handleSliderChange} + onSliderChangeComplete={handleSliderChangeComplete} + onMinOpacityChange={handleMinOpacityChange} + onSliderHover={handleNodeHover} + onSliderLeave={handleNodeLeave} + onResetSliders={handleResetSliders} + onLoadAuthorsEstimates={handleLoadAuthorsEstimates} + onResetNodePositions={handleResetNodePositions} + /> )} - {/* Main content */} -
- {/* Header */} -
-
-
-

- Map of AI Futures -

- - setAuthModalOpen(true)} - /> -
-
- {/* Show Sign In button when user is not logged in */} - {!authLoading && !user && ( - - )} - {/* Show Share button - enabled for logged in users, disabled with tooltip for anonymous users */} - {user && currentDocumentId ? ( - - ) : ( -
+ {/* Main content */} +
+ {/* Header */} +
+
+
+

Map of AI Futures

+ + setAuthModalOpen(true)} + /> +
+
+ {/* Show Sign In button when user is not logged in */} + {!authLoading && !user && ( + + )} + {/* Show Share button - enabled for logged in users, disabled with tooltip for anonymous users */} + {user && currentDocumentId ? ( - {showShareTooltip && ( -
- Sign in to share your scenarios -
- )} -
- )} - - About - - + ) : ( +
+ + {showShareTooltip && ( +
+ Sign in to share your scenarios +
+ )} +
+ )} + + About + + +
+
+ + {/* Flowchart */} +
+ setAutoEditNodeId(null)} + editorCloseTimestampRef={editorCloseTimestampRef} + /> + + + + {/* Feedback Button */} +
- - - {/* Flowchart */} -
- setAutoEditNodeId(null)} - editorCloseTimestampRef={editorCloseTimestampRef} - /> - - - - {/* Feedback Button */} -
-
- {/* Welcome Modal for new users */} - setShowWelcomeModal(false)} - userEmail={user?.email || ''} - /> - - {/* Auth Modal */} - setAuthModalOpen(false)} - /> - - {/* Share Modal */} - setIsShareModalOpen(false)} - documentId={currentDocumentId} - initialIsPublic={false} - /> - - {/* Dev-only buttons */} - {process.env.NODE_ENV === 'development' && ( -
- {/* Clear Site Data button */} - - - {/* Test Welcome Modal button */} - {user && ( + {/* Welcome Modal for new users */} + setShowWelcomeModal(false)} + userEmail={user?.email || ""} + /> + + {/* Auth Modal */} + setAuthModalOpen(false)} + /> + + {/* Share Modal */} + setIsShareModalOpen(false)} + documentId={currentDocumentId} + initialIsPublic={false} + /> + + {/* Dev-only buttons */} + {process.env.NODE_ENV === "development" && ( +
+ {/* Clear Site Data button */} - )} -
- )} + + {/* Test Welcome Modal button */} + {user && ( + + )} +
+ )}
); @@ -1677,7 +1992,13 @@ function HomeContent() { export default function Home() { return ( - Loading...}> + + Loading... + + } + > ); diff --git a/components/Flowchart.tsx b/components/Flowchart.tsx index 88fe6eb..2fdd52d 100644 --- a/components/Flowchart.tsx +++ b/components/Flowchart.tsx @@ -562,6 +562,34 @@ export default function Flowchart({ } }, [isDragging]); + // Screen to canvas coordinate conversion - used by nodes and edges for dragging + const screenToCanvasCoords = useCallback( + (screenX: number, screenY: number) => { + if (!containerRef.current || !scrollContainerRef.current) + return { x: 0, y: 0 }; + const scrollContainer = scrollContainerRef.current; + const scrollRect = scrollContainer.getBoundingClientRect(); + const zoomFactor = zoom / 100; + + // Convert screen coords to position within scrollable area, then to canvas coords + const canvasX = + (scrollContainer.scrollLeft + + screenX - + scrollRect.left - + CANVAS_PADDING) / + zoomFactor; + const canvasY = + (scrollContainer.scrollTop + + screenY - + scrollRect.top - + CANVAS_PADDING) / + zoomFactor; + + return { x: canvasX, y: canvasY }; + }, + [zoom] + ); + return (
{ const bounds = nodeBounds.get(node.id); // Convert screen coordinates to canvas coordinates if mousePos provided let canvasPos: { x: number; y: number } | undefined; - if (mousePos && scrollContainerRef.current) { - const scrollContainer = scrollContainerRef.current; - const scrollRect = - scrollContainer.getBoundingClientRect(); - const zoomFactor = zoom / 100; - canvasPos = { - x: - (scrollContainer.scrollLeft + - mousePos.clientX - - scrollRect.left - - CANVAS_PADDING) / - zoomFactor, - y: - (scrollContainer.scrollTop + - mousePos.clientY - - scrollRect.top - - CANVAS_PADDING) / - zoomFactor, - }; + if (mousePos) { + canvasPos = screenToCanvasCoords( + mousePos.clientX, + mousePos.clientY + ); } onAddArrow( node.id, diff --git a/components/Node.tsx b/components/Node.tsx index 042e93e..16b8775 100644 --- a/components/Node.tsx +++ b/components/Node.tsx @@ -60,6 +60,10 @@ interface NodeProps { onSliderChange?: (value: number) => void; onSliderChangeComplete?: () => void; showAddArrows?: boolean; + screenToCanvasCoords: ( + screenX: number, + screenY: number + ) => { x: number; y: number }; onAddArrow?: ( direction: "top" | "bottom" | "left" | "right", mousePos?: { clientX: number; clientY: number } @@ -98,6 +102,7 @@ const Node = forwardRef( onSliderChange, onSliderChangeComplete, showAddArrows, + screenToCanvasCoords, onAddArrow, }, ref @@ -108,8 +113,10 @@ const Node = forwardRef( const [isDragging, setIsDragging] = useState(false); const [shiftHeld, setShiftHeld] = useState(false); // Track if Shift is held during drag const dragStartRef = useRef<{ - mouseX: number; - mouseY: number; + // Offset from mouse position to node center in canvas coordinates + nodeOffsetX: number; + nodeOffsetY: number; + // Original node position for calculating deltas nodeX: number; nodeY: number; } | null>(null); @@ -121,6 +128,9 @@ const Node = forwardRef( // Pin button click cooldown - prevents hover preview immediately after clicking const pinClickCooldownRef = useRef(false); + // Pin button hover state - controls extended hover zone to prevent flickering + const [isPinHovered, setIsPinHovered] = useState(false); + // Edit state const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(text); @@ -339,9 +349,14 @@ const Node = forwardRef( onDragStateChange(true, false, e.clientX, e.clientY); didDragRef.current = false; lastUpdateTimeRef.current = Date.now(); // Reset timer for throttling + + // Convert mouse position to canvas coordinates + const canvasPos = screenToCanvasCoords(e.clientX, e.clientY); + + // Store offset from mouse to node center in canvas coordinates dragStartRef.current = { - mouseX: e.clientX, - mouseY: e.clientY, + nodeOffsetX: x - canvasPos.x, + nodeOffsetY: y - canvasPos.y, nodeX: x, nodeY: y, }; @@ -372,24 +387,22 @@ const Node = forwardRef( } onDragStateChange(true, e.shiftKey, e.clientX, e.clientY); - // Calculate raw delta in screen space - const rawDeltaX = e.clientX - dragStartRef.current.mouseX; - const rawDeltaY = e.clientY - dragStartRef.current.mouseY; + // Convert current mouse position to canvas coordinates + const canvasPos = screenToCanvasCoords(e.clientX, e.clientY); + + // Calculate new node position by adding the stored offset + let newX = canvasPos.x + dragStartRef.current.nodeOffsetX; + let newY = canvasPos.y + dragStartRef.current.nodeOffsetY; + + // Calculate raw delta to check if we've moved enough to count as dragging + const rawDeltaX = newX - dragStartRef.current.nodeX; + const rawDeltaY = newY - dragStartRef.current.nodeY; // Mark that we've dragged (moved more than a few pixels) if (Math.abs(rawDeltaX) > 3 || Math.abs(rawDeltaY) > 3) { didDragRef.current = true; } - // Convert to canvas coordinates - const zoomFactor = zoom / 100; - const canvasDeltaX = rawDeltaX / zoomFactor; - const canvasDeltaY = rawDeltaY / zoomFactor; - - // Calculate new position in canvas coordinates - let newX = dragStartRef.current.nodeX + canvasDeltaX; - let newY = dragStartRef.current.nodeY + canvasDeltaY; - // Apply snap to grid unless Shift is held // Simple, predictable: what you see during drag = final position if (!e.shiftKey) { @@ -412,9 +425,7 @@ const Node = forwardRef( lastUpdateTimeRef.current = now; // Trigger bounds recalculation so arrows update (use snapped position) - const snappedCanvasDeltaX = newX - dragStartRef.current.nodeX; - const snappedCanvasDeltaY = newY - dragStartRef.current.nodeY; - onDragMove(node.index, snappedCanvasDeltaX, snappedCanvasDeltaY); + onDragMove(node.index, snappedDeltaX, snappedDeltaY); } }; @@ -424,15 +435,11 @@ const Node = forwardRef( if (!dragStartRef.current) return; // Calculate final position (same logic as mousemove for consistency) - const rawDeltaX = e.clientX - dragStartRef.current.mouseX; - const rawDeltaY = e.clientY - dragStartRef.current.mouseY; + const canvasPos = screenToCanvasCoords(e.clientX, e.clientY); - const zoomFactor = zoom / 100; - const canvasDeltaX = rawDeltaX / zoomFactor; - const canvasDeltaY = rawDeltaY / zoomFactor; - - let newX = dragStartRef.current.nodeX + canvasDeltaX; - let newY = dragStartRef.current.nodeY + canvasDeltaY; + // Calculate new node position by adding the stored offset + let newX = canvasPos.x + dragStartRef.current.nodeOffsetX; + let newY = canvasPos.y + dragStartRef.current.nodeOffsetY; // Apply snap to grid unless Shift was held if (!e.shiftKey) { @@ -463,7 +470,17 @@ const Node = forwardRef( window.removeEventListener("mousemove", handleGlobalMouseMove); window.removeEventListener("mouseup", handleGlobalMouseUp); }; - }, [isDragging, zoom, onDragMove, onDragEnd, node.id, node.index]); + }, [ + isDragging, + zoom, + onDragMove, + onDragEnd, + onDragStateChange, + node.id, + node.index, + screenToCanvasCoords, + shiftHeld, + ]); // Format text (replace | with line breaks) const formattedText = text.replace(/\|/g, "\n"); @@ -628,46 +645,45 @@ const Node = forwardRef( > {/* Pin button (left) */} {onSetProbabilityRoot && ( - { + e.stopPropagation(); + if (!pinClickCooldownRef.current) { + setIsPinHovered(true); + onSetProbabilityRootHoverStart?.(); + } + }} + onMouseLeave={(e) => { + e.stopPropagation(); + setIsPinHovered(false); + pinClickCooldownRef.current = false; // Reset cooldown when mouse leaves + onSetProbabilityRootHoverEnd?.(); + }} + style={{ + paddingBottom: isPinHovered ? "10px" : "0", + }} > - - + + +
)} {/* Trash button (right) */} @@ -699,33 +715,41 @@ const Node = forwardRef( right: `${-borderWidth}px`, }} > - - - +
{ + e.stopPropagation(); + if (!pinClickCooldownRef.current) { + setIsPinHovered(true); + onSetProbabilityRootHoverStart?.(); + } + }} + onMouseLeave={(e) => { + e.stopPropagation(); + setIsPinHovered(false); + pinClickCooldownRef.current = false; // Reset cooldown when mouse leaves + onSetProbabilityRootHoverEnd?.(); + }} + style={{ + paddingBottom: isPinHovered ? "10px" : "0", + }} + > + + + +
)} diff --git a/components/OutcomeBarGraph.tsx b/components/OutcomeBarGraph.tsx new file mode 100644 index 0000000..9e05914 --- /dev/null +++ b/components/OutcomeBarGraph.tsx @@ -0,0 +1,103 @@ +import React, { useState } from "react"; +import { NODE_COLORS, Node } from "@/lib/types"; +import { OutcomeDetailModal } from "./OutcomeDetailModal"; +import Tooltip from "./Tooltip"; + +interface OutcomeBarGraphProps { + existentialProbability: number; + ambivalentProbability: number; + goodProbability: number; + nodes: Node[]; +} + +export default function OutcomeBarGraph({ + existentialProbability, + ambivalentProbability, + goodProbability, + nodes, +}: OutcomeBarGraphProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + const total = + existentialProbability + ambivalentProbability + goodProbability; + + // Calculate percentages (normalize to 100% if there's any probability) + const existentialPercent = + total > 0 ? (existentialProbability / total) * 100 : 0; + const ambivalentPercent = + total > 0 ? (ambivalentProbability / total) * 100 : 0; + const goodPercent = total > 0 ? (goodProbability / total) * 100 : 0; + + const tooltipContent = `Good: ${goodPercent.toFixed(1)}% | Ambivalent: ${ambivalentPercent.toFixed(1)}% | Existential: ${existentialPercent.toFixed(1)}%`; + + return ( + <> + + + + + setIsModalOpen(false)} + nodes={nodes} + /> + + ); +} diff --git a/components/OutcomeDetailModal.tsx b/components/OutcomeDetailModal.tsx new file mode 100644 index 0000000..8eb0341 --- /dev/null +++ b/components/OutcomeDetailModal.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useState } from "react"; +import { Node, NodeType, NODE_COLORS } from "@/lib/types"; +import { X } from "lucide-react"; + +interface OutcomeDetailModalProps { + isOpen: boolean; + onClose: () => void; + nodes: Node[]; +} + +export function OutcomeDetailModal({ + isOpen, + onClose, + nodes, +}: OutcomeDetailModalProps) { + const [mouseDownOutside, setMouseDownOutside] = useState(false); + + if (!isOpen) return null; + + // Filter and categorize outcome nodes + const goodOutcomes = nodes + .filter((n) => n.type === NodeType.GOOD) + .sort((a, b) => b.p - a.p); // Sort by probability descending + + const ambivalentOutcomes = nodes + .filter((n) => n.type === NodeType.AMBIVALENT) + .sort((a, b) => b.p - a.p); + + const existentialOutcomes = nodes + .filter((n) => n.type === NodeType.EXISTENTIAL) + .sort((a, b) => b.p - a.p); + + // Calculate totals + const goodTotal = goodOutcomes.reduce((sum, n) => sum + n.p, 0); + const ambivalentTotal = ambivalentOutcomes.reduce((sum, n) => sum + n.p, 0); + const existentialTotal = existentialOutcomes.reduce((sum, n) => sum + n.p, 0); + const grandTotal = goodTotal + ambivalentTotal + existentialTotal; + + const handleBackdropMouseDown = () => { + setMouseDownOutside(true); + }; + + const handleBackdropMouseUp = () => { + if (mouseDownOutside) { + onClose(); + } + setMouseDownOutside(false); + }; + + const renderCategoryBar = ( + outcomes: Node[], + color: string, + label: string + ) => { + const categoryTotal = outcomes.reduce((sum, n) => sum + n.p, 0); + const heightPercent = + grandTotal > 0 ? (categoryTotal / grandTotal) * 100 : 0; + const categoryPercent = (categoryTotal * 100).toFixed(0); + + if (heightPercent === 0) return null; + + return ( +
+ {heightPercent > 8 && ( +
+ {label} - {categoryPercent}% +
+ )} +
+ ); + }; + + return ( +
+
e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + > + {/* Header */} +
+

+ Outcome Distribution +

+ +
+ + {/* Content */} +
+
+ {/* Left: Large Bar Chart */} +
+

+ Visual Distribution +

+
+ {/* Good outcomes */} + {renderCategoryBar( + goodOutcomes, + NODE_COLORS.GOOD.border, + "Good" + )} + + {/* Ambivalent outcomes */} + {renderCategoryBar( + ambivalentOutcomes, + NODE_COLORS.AMBIVALENT.border, + "Ambivalent" + )} + + {/* Existential outcomes */} + {renderCategoryBar( + existentialOutcomes, + NODE_COLORS.EXISTENTIAL.border, + "Existential" + )} +
+
+ + {/* Right: Detailed List */} +
+

+ Detailed Probabilities +

+
+ {/* Good Outcomes */} + {goodOutcomes.length > 0 && ( +
+

+ Good Outcomes ({(goodTotal * 100).toFixed(0)}%) +

+
+ {goodOutcomes.map((outcome) => ( +
+ + {outcome.text} + + + {(outcome.p * 100).toFixed(1)}% + +
+ ))} +
+
+ )} + + {/* Ambivalent Outcomes */} + {ambivalentOutcomes.length > 0 && ( +
+

+ Ambivalent Outcomes ({(ambivalentTotal * 100).toFixed(0)} + %) +

+
+ {ambivalentOutcomes.map((outcome) => ( +
+ + {outcome.text} + + + {(outcome.p * 100).toFixed(1)}% + +
+ ))} +
+
+ )} + + {/* Existential Outcomes */} + {existentialOutcomes.length > 0 && ( +
+

+ Existential Outcomes ( + {(existentialTotal * 100).toFixed(0)}%) +

+
+ {existentialOutcomes.map((outcome) => ( +
+ + {outcome.text} + + + {(outcome.p * 100).toFixed(1)}% + +
+ ))} +
+
+ )} +
+
+
+
+ + {/* Footer */} +
+
+ Total probability: {(grandTotal * 100).toFixed(2)}% +
+ +
+
+
+ ); +} diff --git a/components/ZoomControls.tsx b/components/ZoomControls.tsx index c164f38..47b1f12 100644 --- a/components/ZoomControls.tsx +++ b/components/ZoomControls.tsx @@ -1,7 +1,8 @@ -import { MIN_ZOOM, MAX_ZOOM } from '@/lib/types'; -import Tooltip from './Tooltip'; -import { useModifierKey } from '@/hooks/useKeyboardShortcut'; -import { Locate, Plus, Minus } from 'lucide-react'; +import { MIN_ZOOM, MAX_ZOOM, Node } from "@/lib/types"; +import Tooltip from "./Tooltip"; +import { useModifierKey } from "@/hooks/useKeyboardShortcut"; +import { Locate, Plus, Minus } from "lucide-react"; +import OutcomeBarGraph from "./OutcomeBarGraph"; interface ZoomControlsProps { zoom: number; @@ -12,6 +13,10 @@ interface ZoomControlsProps { canRedo: boolean; onUndo: () => void; onRedo: () => void; + existentialProbability: number; + ambivalentProbability: number; + goodProbability: number; + nodes: Node[]; } export default function ZoomControls({ @@ -23,10 +28,25 @@ export default function ZoomControls({ canRedo, onUndo, onRedo, + existentialProbability, + ambivalentProbability, + goodProbability, + nodes, }: ZoomControlsProps) { const modKey = useModifierKey(); return (
+ {/* Outcome Bar Graph */} + + + {/* Divider */} +
+ {/* Redo Button */}