Spaces:
Sleeping
Sleeping
| import React, { useMemo } from 'react'; | |
| import { useGridStore } from '../../store/gridStore'; | |
| import type { ColoredPath } from '../../store/gridStore'; | |
| import { Map as MapIcon } from 'lucide-react'; | |
| import type { SearchStep } from '../../types'; | |
| const CELL_SIZE = 90; | |
| const PADDING = 70; | |
| const NODE_RADIUS = 14; | |
| const COLORS = { | |
| background: '#0a0a0b', | |
| gridLine: '#1f1f23', | |
| // Node states | |
| nodeDefault: '#27272a', | |
| nodeDefaultStroke: '#3f3f46', | |
| frontier: '#0ea5e9', | |
| frontierLight: '#38bdf8', | |
| explored: '#52525b', | |
| exploredLight: '#71717a', | |
| current: '#f59e0b', | |
| currentLight: '#fbbf24', | |
| path: '#10b981', | |
| pathLight: '#34d399', | |
| // Entities | |
| store: '#3b82f6', | |
| storeLight: '#60a5fa', | |
| destination: '#10b981', | |
| destinationLight: '#34d399', | |
| tunnel: '#a855f7', | |
| tunnelLight: '#c084fc', | |
| // Edges | |
| edge1: '#a1a1aa', | |
| edge2: '#71717a', | |
| edge3: '#52525b', | |
| edge4: '#3f3f46', | |
| blocked: '#ef4444', | |
| blockedBg: '#450a0a', | |
| // Text | |
| text: '#fafafa', | |
| textMuted: '#a1a1aa', | |
| textDim: '#52525b', | |
| }; | |
| export const Grid: React.FC = () => { | |
| const { grid, steps, currentStep, searchResult, showPlanPaths, planPaths } = useGridStore(); | |
| const stepData = useMemo(() => { | |
| const step: SearchStep | null = steps[currentStep] || null; | |
| return { | |
| step, | |
| exploredSet: new Set(step?.explored.map(p => `${p.x},${p.y}`) || []), | |
| frontierSet: new Set(step?.frontier.map(p => `${p.x},${p.y}`) || []), | |
| pathSet: new Set(step?.currentPath.map(p => `${p.x},${p.y}`) || []), | |
| finalPathSet: new Set((searchResult?.path || []).map(p => `${p.x},${p.y}`)), | |
| }; | |
| }, [steps, currentStep, searchResult]); | |
| // Build a map of position -> color for plan paths visualization | |
| const planPathData = useMemo(() => { | |
| if (!showPlanPaths || planPaths.length === 0) { | |
| return { pathColorMap: new Map<string, string>(), pathEdges: [] as { from: string; to: string; color: string }[] }; | |
| } | |
| const pathColorMap = new Map<string, string>(); | |
| const pathEdges: { from: string; to: string; color: string }[] = []; | |
| planPaths.forEach((coloredPath: ColoredPath) => { | |
| const path = coloredPath.path; | |
| for (let i = 0; i < path.length; i++) { | |
| const key = `${path[i].x},${path[i].y}`; | |
| // First path to claim a position gets to color it | |
| if (!pathColorMap.has(key)) { | |
| pathColorMap.set(key, coloredPath.color); | |
| } | |
| // Build edges | |
| if (i < path.length - 1) { | |
| const fromKey = `${path[i].x},${path[i].y}`; | |
| const toKey = `${path[i + 1].x},${path[i + 1].y}`; | |
| pathEdges.push({ from: fromKey, to: toKey, color: coloredPath.color }); | |
| } | |
| } | |
| }); | |
| return { pathColorMap, pathEdges }; | |
| }, [showPlanPaths, planPaths]); | |
| if (!grid) { | |
| return ( | |
| <div className="flex items-center justify-center h-full" style={{ backgroundColor: '#0a0a0b' }}> | |
| <div className="text-center"> | |
| <div className="w-20 h-20 rounded-2xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mx-auto mb-6"> | |
| <MapIcon className="w-10 h-10 text-zinc-700" /> | |
| </div> | |
| <p className="text-zinc-400 text-sm font-medium">No Grid Generated</p> | |
| <p className="text-zinc-600 text-xs mt-2">Configure and generate a grid from the sidebar</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const width = grid.width * CELL_SIZE + PADDING * 2; | |
| const height = grid.height * CELL_SIZE + PADDING * 2; | |
| const { step, exploredSet, frontierSet, pathSet, finalPathSet } = stepData; | |
| const { pathColorMap, pathEdges } = planPathData; | |
| const getNodeState = (x: number, y: number): { state: string; color?: string } => { | |
| const key = `${x},${y}`; | |
| if (step?.currentNode.x === x && step?.currentNode.y === y) return { state: 'current' }; | |
| if (pathSet.has(key) || (!step && finalPathSet.has(key))) return { state: 'path' }; | |
| if (frontierSet.has(key)) return { state: 'frontier' }; | |
| if (exploredSet.has(key)) return { state: 'explored' }; | |
| // Check if this node is part of a plan path | |
| if (showPlanPaths && pathColorMap.has(key)) { | |
| return { state: 'planPath', color: pathColorMap.get(key) }; | |
| } | |
| return { state: 'default' }; | |
| }; | |
| const getEdgeStyle = (traffic: number) => { | |
| if (traffic === 0) return { color: COLORS.blocked, width: 2, opacity: 0.4 }; | |
| const colors = [COLORS.edge1, COLORS.edge2, COLORS.edge3, COLORS.edge4]; | |
| const widths = [3, 2.5, 2, 1.5]; | |
| return { color: colors[traffic - 1], width: widths[traffic - 1], opacity: 0.7 }; | |
| }; | |
| const toSvgY = (gridY: number) => PADDING + (grid.height - 1 - gridY) * CELL_SIZE + CELL_SIZE / 2; | |
| const toSvgX = (gridX: number) => PADDING + gridX * CELL_SIZE + CELL_SIZE / 2; | |
| return ( | |
| <div className="w-full h-full flex items-center justify-center overflow-auto" style={{ backgroundColor: '#0a0a0b' }}> | |
| <svg | |
| width={width} | |
| height={height} | |
| viewBox={`0 0 ${width} ${height}`} | |
| style={{ minWidth: Math.min(width, 500), minHeight: Math.min(height, 400) }} | |
| > | |
| <defs> | |
| {/* Glows for different states */} | |
| <filter id="glow-current" x="-100%" y="-100%" width="300%" height="300%"> | |
| <feGaussianBlur stdDeviation="6" result="blur"/> | |
| <feFlood floodColor={COLORS.current} result="color"/> | |
| <feComposite in="color" in2="blur" operator="in" result="glow"/> | |
| <feMerge> | |
| <feMergeNode in="glow"/> | |
| <feMergeNode in="glow"/> | |
| <feMergeNode in="SourceGraphic"/> | |
| </feMerge> | |
| </filter> | |
| <filter id="glow-path" x="-100%" y="-100%" width="300%" height="300%"> | |
| <feGaussianBlur stdDeviation="4" result="blur"/> | |
| <feFlood floodColor={COLORS.path} result="color"/> | |
| <feComposite in="color" in2="blur" operator="in" result="glow"/> | |
| <feMerge> | |
| <feMergeNode in="glow"/> | |
| <feMergeNode in="SourceGraphic"/> | |
| </feMerge> | |
| </filter> | |
| <filter id="glow-frontier" x="-100%" y="-100%" width="300%" height="300%"> | |
| <feGaussianBlur stdDeviation="3" result="blur"/> | |
| <feFlood floodColor={COLORS.frontier} result="color"/> | |
| <feComposite in="color" in2="blur" operator="in" result="glow"/> | |
| <feMerge> | |
| <feMergeNode in="glow"/> | |
| <feMergeNode in="SourceGraphic"/> | |
| </feMerge> | |
| </filter> | |
| <filter id="glow-tunnel" x="-100%" y="-100%" width="300%" height="300%"> | |
| <feGaussianBlur stdDeviation="4" result="blur"/> | |
| <feFlood floodColor={COLORS.tunnel} result="color"/> | |
| <feComposite in="color" in2="blur" operator="in" result="glow"/> | |
| <feMerge> | |
| <feMergeNode in="glow"/> | |
| <feMergeNode in="SourceGraphic"/> | |
| </feMerge> | |
| </filter> | |
| {/* Tunnel gradient */} | |
| <linearGradient id="tunnel-gradient" x1="0%" y1="0%" x2="100%" y2="0%"> | |
| <stop offset="0%" stopColor={COLORS.tunnel} stopOpacity="0.3"/> | |
| <stop offset="50%" stopColor={COLORS.tunnelLight} stopOpacity="0.6"/> | |
| <stop offset="100%" stopColor={COLORS.tunnel} stopOpacity="0.3"/> | |
| </linearGradient> | |
| {/* Blocked pattern */} | |
| <pattern id="blocked-hatch" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)"> | |
| <line x1="0" y1="0" x2="0" y2="6" stroke={COLORS.blocked} strokeWidth="1" opacity="0.3"/> | |
| </pattern> | |
| </defs> | |
| {/* Background */} | |
| <rect x={0} y={0} width={width} height={height} fill={COLORS.background} rx={8}/> | |
| {/* Grid cells background */} | |
| {Array.from({ length: grid.width }).map((_, x) => | |
| Array.from({ length: grid.height }).map((_, y) => ( | |
| <rect | |
| key={`cell-bg-${x}-${y}`} | |
| x={PADDING + x * CELL_SIZE + 2} | |
| y={PADDING + (grid.height - 1 - y) * CELL_SIZE + 2} | |
| width={CELL_SIZE - 4} | |
| height={CELL_SIZE - 4} | |
| fill={COLORS.gridLine} | |
| opacity={0.3} | |
| rx={4} | |
| /> | |
| )) | |
| )} | |
| {/* Edges */} | |
| {grid.segments.map((segment, i) => { | |
| const x1 = toSvgX(segment.src.x); | |
| const y1 = toSvgY(segment.src.y); | |
| const x2 = toSvgX(segment.dst.x); | |
| const y2 = toSvgY(segment.dst.y); | |
| const midX = (x1 + x2) / 2; | |
| const midY = (y1 + y2) / 2; | |
| const isBlocked = segment.traffic === 0; | |
| const style = getEdgeStyle(segment.traffic); | |
| if (isBlocked) { | |
| return ( | |
| <g key={`seg-${i}`}> | |
| {/* Blocked edge line */} | |
| <line | |
| x1={x1} y1={y1} x2={x2} y2={y2} | |
| stroke={COLORS.blocked} | |
| strokeWidth={2} | |
| strokeDasharray="8,6" | |
| opacity={0.25} | |
| /> | |
| {/* Blocked indicator */} | |
| <g transform={`translate(${midX}, ${midY})`}> | |
| <circle r={14} fill={COLORS.blockedBg} stroke={COLORS.blocked} strokeWidth={1.5} opacity={0.9}/> | |
| <line x1={-5} y1={-5} x2={5} y2={5} stroke={COLORS.blocked} strokeWidth={2} strokeLinecap="round"/> | |
| <line x1={5} y1={-5} x2={-5} y2={5} stroke={COLORS.blocked} strokeWidth={2} strokeLinecap="round"/> | |
| </g> | |
| </g> | |
| ); | |
| } | |
| return ( | |
| <g key={`seg-${i}`}> | |
| {/* Edge line */} | |
| <line | |
| x1={x1} y1={y1} x2={x2} y2={y2} | |
| stroke={style.color} | |
| strokeWidth={style.width} | |
| strokeLinecap="round" | |
| opacity={style.opacity} | |
| /> | |
| {/* Cost badge */} | |
| <g transform={`translate(${midX}, ${midY})`}> | |
| <rect | |
| x={-10} y={-10} | |
| width={20} height={20} | |
| fill={COLORS.background} | |
| stroke={style.color} | |
| strokeWidth={1} | |
| rx={4} | |
| opacity={0.95} | |
| /> | |
| <text | |
| textAnchor="middle" | |
| dominantBaseline="middle" | |
| fill={style.color} | |
| fontSize={11} | |
| fontWeight="600" | |
| fontFamily="ui-monospace, monospace" | |
| > | |
| {segment.traffic} | |
| </text> | |
| </g> | |
| </g> | |
| ); | |
| })} | |
| {/* Tunnels */} | |
| {grid.tunnels.map((tunnel, i) => { | |
| const x1 = toSvgX(tunnel.entrance1.x); | |
| const y1 = toSvgY(tunnel.entrance1.y); | |
| const x2 = toSvgX(tunnel.entrance2.x); | |
| const y2 = toSvgY(tunnel.entrance2.y); | |
| const midX = (x1 + x2) / 2; | |
| const midY = (y1 + y2) / 2; | |
| const dx = x2 - x1; | |
| const dy = y2 - y1; | |
| const len = Math.sqrt(dx * dx + dy * dy); | |
| const curveOffset = Math.min(60, len / 2.5); | |
| const perpX = -dy / len * curveOffset; | |
| const perpY = dx / len * curveOffset; | |
| return ( | |
| <g key={`tunnel-${i}`}> | |
| {/* Tunnel glow background */} | |
| <path | |
| d={`M ${x1} ${y1} Q ${midX + perpX} ${midY + perpY} ${x2} ${y2}`} | |
| stroke={COLORS.tunnelLight} | |
| strokeWidth={12} | |
| fill="none" | |
| opacity={0.15} | |
| strokeLinecap="round" | |
| /> | |
| {/* Tunnel path */} | |
| <path | |
| d={`M ${x1} ${y1} Q ${midX + perpX} ${midY + perpY} ${x2} ${y2}`} | |
| stroke="url(#tunnel-gradient)" | |
| strokeWidth={4} | |
| fill="none" | |
| strokeLinecap="round" | |
| /> | |
| {/* Animated dashes */} | |
| <path | |
| d={`M ${x1} ${y1} Q ${midX + perpX} ${midY + perpY} ${x2} ${y2}`} | |
| stroke={COLORS.tunnel} | |
| strokeWidth={2} | |
| strokeDasharray="4,8" | |
| fill="none" | |
| opacity={0.8} | |
| strokeLinecap="round" | |
| > | |
| <animate | |
| attributeName="stroke-dashoffset" | |
| from="0" | |
| to="24" | |
| dur="1.5s" | |
| repeatCount="indefinite" | |
| /> | |
| </path> | |
| {/* Portal entrance 1 */} | |
| <g transform={`translate(${x1}, ${y1})`} filter="url(#glow-tunnel)"> | |
| <circle r={16} fill={COLORS.background} stroke={COLORS.tunnel} strokeWidth={2}/> | |
| <circle r={10} fill={COLORS.tunnel} opacity={0.3}/> | |
| <circle r={5} fill={COLORS.tunnelLight}/> | |
| </g> | |
| {/* Portal entrance 2 */} | |
| <g transform={`translate(${x2}, ${y2})`} filter="url(#glow-tunnel)"> | |
| <circle r={16} fill={COLORS.background} stroke={COLORS.tunnel} strokeWidth={2}/> | |
| <circle r={10} fill={COLORS.tunnel} opacity={0.3}/> | |
| <circle r={5} fill={COLORS.tunnelLight}/> | |
| </g> | |
| {/* Cost label */} | |
| <g transform={`translate(${midX + perpX * 0.6}, ${midY + perpY * 0.6})`}> | |
| <rect | |
| x={-18} y={-11} | |
| width={36} height={22} | |
| fill={COLORS.background} | |
| stroke={COLORS.tunnel} | |
| strokeWidth={1.5} | |
| rx={6} | |
| /> | |
| <text | |
| textAnchor="middle" | |
| dominantBaseline="middle" | |
| fill={COLORS.tunnelLight} | |
| fontSize={11} | |
| fontWeight="600" | |
| fontFamily="ui-monospace, monospace" | |
| > | |
| T:{tunnel.cost} | |
| </text> | |
| </g> | |
| </g> | |
| ); | |
| })} | |
| {/* Plan Path Edges (rendered before nodes) */} | |
| {showPlanPaths && pathEdges.map((edge, i) => { | |
| const [fromX, fromY] = edge.from.split(',').map(Number); | |
| const [toX, toY] = edge.to.split(',').map(Number); | |
| const x1 = toSvgX(fromX); | |
| const y1 = toSvgY(fromY); | |
| const x2 = toSvgX(toX); | |
| const y2 = toSvgY(toY); | |
| return ( | |
| <line | |
| key={`plan-edge-${i}`} | |
| x1={x1} y1={y1} x2={x2} y2={y2} | |
| stroke={edge.color} | |
| strokeWidth={5} | |
| strokeLinecap="round" | |
| opacity={0.8} | |
| /> | |
| ); | |
| })} | |
| {/* Nodes */} | |
| {Array.from({ length: grid.width }).map((_, x) => | |
| Array.from({ length: grid.height }).map((_, y) => { | |
| const cx = toSvgX(x); | |
| const cy = toSvgY(y); | |
| const nodeState = getNodeState(x, y); | |
| const state = nodeState.state; | |
| let fill = COLORS.nodeDefault; | |
| let stroke = COLORS.nodeDefaultStroke; | |
| let filter = ''; | |
| let radius = NODE_RADIUS; | |
| let strokeWidth = 1.5; | |
| switch (state) { | |
| case 'current': | |
| fill = COLORS.current; | |
| stroke = COLORS.currentLight; | |
| filter = 'url(#glow-current)'; | |
| radius = NODE_RADIUS + 2; | |
| strokeWidth = 2; | |
| break; | |
| case 'path': | |
| fill = COLORS.path; | |
| stroke = COLORS.pathLight; | |
| filter = 'url(#glow-path)'; | |
| strokeWidth = 2; | |
| break; | |
| case 'frontier': | |
| fill = COLORS.frontier; | |
| stroke = COLORS.frontierLight; | |
| filter = 'url(#glow-frontier)'; | |
| break; | |
| case 'explored': | |
| fill = COLORS.explored; | |
| stroke = COLORS.exploredLight; | |
| break; | |
| case 'planPath': | |
| fill = nodeState.color || COLORS.path; | |
| stroke = nodeState.color || COLORS.pathLight; | |
| strokeWidth = 2; | |
| break; | |
| } | |
| return ( | |
| <g key={`node-${x}-${y}`}> | |
| <circle | |
| cx={cx} | |
| cy={cy} | |
| r={radius} | |
| fill={fill} | |
| stroke={stroke} | |
| strokeWidth={strokeWidth} | |
| filter={filter} | |
| opacity={state === 'default' ? 0.4 : 1} | |
| style={{ transition: 'all 0.15s ease-out' }} | |
| /> | |
| {state === 'current' && ( | |
| <circle | |
| cx={cx} | |
| cy={cy} | |
| r={radius + 6} | |
| fill="none" | |
| stroke={COLORS.current} | |
| strokeWidth={1.5} | |
| opacity={0.4} | |
| > | |
| <animate | |
| attributeName="r" | |
| from={radius + 4} | |
| to={radius + 12} | |
| dur="1s" | |
| repeatCount="indefinite" | |
| /> | |
| <animate | |
| attributeName="opacity" | |
| from="0.5" | |
| to="0" | |
| dur="1s" | |
| repeatCount="indefinite" | |
| /> | |
| </circle> | |
| )} | |
| </g> | |
| ); | |
| }) | |
| )} | |
| {/* Stores */} | |
| {grid.stores.map((store) => { | |
| const cx = toSvgX(store.position.x); | |
| const cy = toSvgY(store.position.y); | |
| return ( | |
| <g key={`store-${store.id}`}> | |
| <rect | |
| x={cx - 18} | |
| y={cy - 18} | |
| width={36} | |
| height={36} | |
| fill={COLORS.store} | |
| stroke={COLORS.storeLight} | |
| strokeWidth={2} | |
| rx={8} | |
| /> | |
| <rect | |
| x={cx - 12} | |
| y={cy - 6} | |
| width={24} | |
| height={4} | |
| fill={COLORS.storeLight} | |
| rx={2} | |
| /> | |
| <text | |
| x={cx} | |
| y={cy + 8} | |
| textAnchor="middle" | |
| dominantBaseline="middle" | |
| fill={COLORS.text} | |
| fontSize={11} | |
| fontWeight="700" | |
| fontFamily="ui-monospace, monospace" | |
| > | |
| S{store.id} | |
| </text> | |
| </g> | |
| ); | |
| })} | |
| {/* Destinations */} | |
| {grid.destinations.map((dest) => { | |
| const cx = toSvgX(dest.position.x); | |
| const cy = toSvgY(dest.position.y); | |
| return ( | |
| <g key={`dest-${dest.id}`}> | |
| <circle | |
| cx={cx} | |
| cy={cy} | |
| r={18} | |
| fill={COLORS.destination} | |
| stroke={COLORS.destinationLight} | |
| strokeWidth={2} | |
| /> | |
| {/* Target rings */} | |
| <circle cx={cx} cy={cy} r={12} fill="none" stroke={COLORS.destinationLight} strokeWidth={1.5} opacity={0.5}/> | |
| <circle cx={cx} cy={cy} r={6} fill={COLORS.destinationLight} opacity={0.8}/> | |
| <text | |
| x={cx} | |
| y={cy + 28} | |
| textAnchor="middle" | |
| fill={COLORS.textMuted} | |
| fontSize={10} | |
| fontWeight="600" | |
| fontFamily="ui-monospace, monospace" | |
| > | |
| D{dest.id} | |
| </text> | |
| </g> | |
| ); | |
| })} | |
| {/* Axis labels */} | |
| {Array.from({ length: grid.width }).map((_, x) => ( | |
| <text | |
| key={`x-${x}`} | |
| x={toSvgX(x)} | |
| y={height - 25} | |
| textAnchor="middle" | |
| fontSize={11} | |
| fill={COLORS.textDim} | |
| fontFamily="ui-monospace, monospace" | |
| fontWeight="500" | |
| > | |
| {x} | |
| </text> | |
| ))} | |
| {Array.from({ length: grid.height }).map((_, y) => ( | |
| <text | |
| key={`y-${y}`} | |
| x={25} | |
| y={toSvgY(y) + 4} | |
| textAnchor="middle" | |
| fontSize={11} | |
| fill={COLORS.textDim} | |
| fontFamily="ui-monospace, monospace" | |
| fontWeight="500" | |
| > | |
| {y} | |
| </text> | |
| ))} | |
| </svg> | |
| </div> | |
| ); | |
| }; | |
| export default Grid; | |