feat: Implement interactive map views

Create interactive map views using SVG for nodes and connections. Implement navigation to region and system pages on click.
This commit is contained in:
gpt-engineer-app[bot]
2025-06-13 23:32:09 +00:00
parent 2320c73cc2
commit 1fdf39d60a
8 changed files with 519 additions and 9 deletions

View File

@@ -1,9 +1,11 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import RegionPage from "./pages/RegionPage";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
@@ -16,6 +18,7 @@ const App = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/regions/:region" element={<RegionPage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@@ -0,0 +1,37 @@
import React from 'react';
interface ConnectionProps {
from: { x: number; y: number };
to: { x: number; y: number };
}
export const Connection: React.FC<ConnectionProps> = ({ from, to }) => {
return (
<g>
{/* Glow effect */}
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke="#8b5cf6"
strokeWidth="3"
opacity="0.3"
filter="url(#glow)"
/>
{/* Main line */}
<line
x1={from.x}
y1={from.y}
x2={to.x}
y2={to.y}
stroke="#a855f7"
strokeWidth="1"
opacity="0.7"
className="transition-all duration-300"
/>
</g>
);
};

View File

@@ -0,0 +1,100 @@
import React, { useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { MapNode } from './MapNode';
import { Connection } from './Connection';
import { galaxyData } from '../data/galaxyData';
interface Position {
x: number;
y: number;
}
export const GalaxyMap = () => {
const navigate = useNavigate();
const [draggedNode, setDraggedNode] = useState<string | null>(null);
const [nodePositions, setNodePositions] = useState<Record<string, Position>>(galaxyData.nodePositions);
const svgRef = useRef<SVGSVGElement>(null);
const handleNodeClick = (regionId: string) => {
navigate(`/regions/${regionId}`);
};
const handleMouseDown = useCallback((nodeId: string) => {
setDraggedNode(nodeId);
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!draggedNode || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setNodePositions(prev => ({
...prev,
[draggedNode]: { x, y }
}));
}, [draggedNode]);
const handleMouseUp = useCallback(() => {
setDraggedNode(null);
}, []);
return (
<div className="w-full h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 overflow-hidden relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-purple-900/20 via-slate-900/40 to-black"></div>
<div className="relative z-10 p-8">
<h1 className="text-4xl font-bold text-white mb-2 text-center">Galaxy Map</h1>
<p className="text-purple-200 text-center mb-8">Navigate the known regions of space</p>
<div className="w-full h-[calc(100vh-200px)] border border-purple-500/30 rounded-lg overflow-hidden bg-black/20 backdrop-blur-sm">
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 1200 800"
className="cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* Render connections first (behind nodes) */}
{galaxyData.connections.map((connection, index) => (
<Connection
key={index}
from={nodePositions[connection.from]}
to={nodePositions[connection.to]}
/>
))}
{/* Render nodes */}
{galaxyData.regions.map((region) => (
<MapNode
key={region.id}
id={region.id}
name={region.name}
position={nodePositions[region.id]}
onClick={() => handleNodeClick(region.id)}
onMouseDown={() => handleMouseDown(region.id)}
isDragging={draggedNode === region.id}
type="region"
/>
))}
</svg>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,95 @@
import React, { useState } from 'react';
interface MapNodeProps {
id: string;
name: string;
position: { x: number; y: number };
onClick: () => void;
onMouseDown: () => void;
isDragging: boolean;
type: 'region' | 'system';
}
export const MapNode: React.FC<MapNodeProps> = ({
id,
name,
position,
onClick,
onMouseDown,
isDragging,
type
}) => {
const [isHovered, setIsHovered] = useState(false);
const nodeSize = type === 'region' ? 12 : 8;
const textOffset = type === 'region' ? 20 : 15;
const nodeColor = type === 'region'
? (isHovered ? '#8b5cf6' : '#a855f7')
: (isHovered ? '#06b6d4' : '#0891b2');
return (
<g
transform={`translate(${position.x}, ${position.y})`}
className="cursor-pointer select-none"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseDown={(e) => {
e.preventDefault();
onMouseDown();
}}
onClick={(e) => {
e.stopPropagation();
if (!isDragging) {
onClick();
}
}}
>
{/* Node glow effect */}
<circle
r={nodeSize + 6}
fill={nodeColor}
opacity={isHovered ? 0.3 : 0.1}
filter="url(#glow)"
className="transition-all duration-300"
/>
{/* Main node */}
<circle
r={nodeSize}
fill={nodeColor}
stroke="#ffffff"
strokeWidth="2"
filter="url(#glow)"
className={`transition-all duration-300 ${
isHovered ? 'drop-shadow-lg' : ''
} ${isDragging ? 'opacity-80' : ''}`}
/>
{/* Inner core */}
<circle
r={nodeSize - 4}
fill={isHovered ? '#ffffff' : nodeColor}
opacity={0.8}
className="transition-all duration-300"
/>
{/* Node label */}
<text
x="0"
y={textOffset}
textAnchor="middle"
fill="#ffffff"
fontSize={type === 'region' ? '14' : '12'}
fontWeight="bold"
className={`transition-all duration-300 ${
isHovered ? 'fill-purple-200' : 'fill-white'
} pointer-events-none select-none`}
style={{ textShadow: '2px 2px 4px rgba(0,0,0,0.8)' }}
>
{name}
</text>
</g>
);
};

View File

@@ -0,0 +1,122 @@
import React, { useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { MapNode } from './MapNode';
import { Connection } from './Connection';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
interface Position {
x: number;
y: number;
}
interface RegionMapProps {
regionData: {
name: string;
systems: Array<{ id: string; name: string }>;
nodePositions: Record<string, Position>;
connections: Array<{ from: string; to: string }>;
};
}
export const RegionMap: React.FC<RegionMapProps> = ({ regionData }) => {
const navigate = useNavigate();
const [draggedNode, setDraggedNode] = useState<string | null>(null);
const [nodePositions, setNodePositions] = useState<Record<string, Position>>(regionData.nodePositions);
const svgRef = useRef<SVGSVGElement>(null);
const handleSystemClick = (systemId: string) => {
navigate(`/systems/${systemId}`);
};
const handleMouseDown = useCallback((nodeId: string) => {
setDraggedNode(nodeId);
}, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!draggedNode || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setNodePositions(prev => ({
...prev,
[draggedNode]: { x, y }
}));
}, [draggedNode]);
const handleMouseUp = useCallback(() => {
setDraggedNode(null);
}, []);
return (
<div className="w-full h-screen bg-gradient-to-br from-slate-900 via-cyan-900 to-slate-900 overflow-hidden relative">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-cyan-900/20 via-slate-900/40 to-black"></div>
<div className="relative z-10 p-8">
<div className="flex items-center gap-4 mb-6">
<Button
variant="outline"
onClick={() => navigate('/')}
className="bg-black/20 border-cyan-500/30 text-cyan-200 hover:bg-cyan-500/20"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Galaxy
</Button>
<div>
<h1 className="text-4xl font-bold text-white">{regionData.name} Region</h1>
<p className="text-cyan-200">Explore the systems within this region</p>
</div>
</div>
<div className="w-full h-[calc(100vh-200px)] border border-cyan-500/30 rounded-lg overflow-hidden bg-black/20 backdrop-blur-sm">
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 1200 800"
className="cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{/* Render connections first (behind nodes) */}
{regionData.connections.map((connection, index) => (
<Connection
key={index}
from={nodePositions[connection.from]}
to={nodePositions[connection.to]}
/>
))}
{/* Render systems */}
{regionData.systems.map((system) => (
<MapNode
key={system.id}
id={system.id}
name={system.name}
position={nodePositions[system.id]}
onClick={() => handleSystemClick(system.id)}
onMouseDown={() => handleMouseDown(system.id)}
isDragging={draggedNode === system.id}
type="system"
/>
))}
</svg>
</div>
</div>
</div>
);
};

125
src/data/galaxyData.ts Normal file
View File

@@ -0,0 +1,125 @@
export const galaxyData = {
regions: [
{ id: 'outer-ring', name: 'Outer Ring' },
{ id: 'syndicate', name: 'Syndicate' },
{ id: 'fountain', name: 'Fountain' },
{ id: 'cloud-ring', name: 'Cloud Ring' },
{ id: 'fade', name: 'Fade' },
{ id: 'pure-blind', name: 'Pure Blind' },
{ id: 'deklein', name: 'Deklein' },
{ id: 'branch', name: 'Branch' },
{ id: 'tenal', name: 'Tenal' },
{ id: 'venal', name: 'Venal' },
{ id: 'tribute', name: 'Tribute' },
{ id: 'vale-of-silent', name: 'Vale of the Silent' },
{ id: 'geminate', name: 'Geminate' },
{ id: 'the-citadel', name: 'The Citadel' },
{ id: 'the-forge', name: 'The Forge' },
{ id: 'lonetrek', name: 'Lonetrek' }
],
nodePositions: {
'outer-ring': { x: 150, y: 200 },
'syndicate': { x: 250, y: 350 },
'fountain': { x: 120, y: 400 },
'cloud-ring': { x: 320, y: 220 },
'fade': { x: 450, y: 180 },
'pure-blind': { x: 480, y: 250 },
'deklein': { x: 450, y: 130 },
'branch': { x: 550, y: 100 },
'tenal': { x: 650, y: 120 },
'venal': { x: 580, y: 170 },
'tribute': { x: 580, y: 220 },
'vale-of-silent': { x: 680, y: 260 },
'geminate': { x: 720, y: 320 },
'the-citadel': { x: 560, y: 370 },
'the-forge': { x: 660, y: 370 },
'lonetrek': { x: 580, y: 290 }
},
connections: [
{ from: 'outer-ring', to: 'syndicate' },
{ from: 'outer-ring', to: 'fountain' },
{ from: 'syndicate', to: 'cloud-ring' },
{ from: 'cloud-ring', to: 'fade' },
{ from: 'cloud-ring', to: 'pure-blind' },
{ from: 'fade', to: 'pure-blind' },
{ from: 'fade', to: 'deklein' },
{ from: 'deklein', to: 'branch' },
{ from: 'branch', to: 'tenal' },
{ from: 'tenal', to: 'venal' },
{ from: 'venal', to: 'tribute' },
{ from: 'pure-blind', to: 'tribute' },
{ from: 'tribute', to: 'vale-of-silent' },
{ from: 'vale-of-silent', to: 'geminate' },
{ from: 'tribute', to: 'lonetrek' },
{ from: 'lonetrek', to: 'the-citadel' },
{ from: 'lonetrek', to: 'the-forge' },
{ from: 'the-citadel', to: 'the-forge' },
{ from: 'geminate', to: 'the-forge' }
]
};
export const regionData: Record<string, any> = {
'the-forge': {
name: 'The Forge',
systems: [
{ id: 'jita', name: 'Jita' },
{ id: 'perimeter', name: 'Perimeter' },
{ id: 'sobaseki', name: 'Sobaseki' },
{ id: 'urlen', name: 'Urlen' },
{ id: 'maurasi', name: 'Maurasi' },
{ id: 'kimotoro', name: 'Kimotoro' },
{ id: 'new-caldari', name: 'New Caldari' },
{ id: 'outuni', name: 'Outuni' }
],
nodePositions: {
'jita': { x: 600, y: 400 },
'perimeter': { x: 550, y: 350 },
'sobaseki': { x: 650, y: 350 },
'urlen': { x: 500, y: 400 },
'maurasi': { x: 700, y: 400 },
'kimotoro': { x: 600, y: 300 },
'new-caldari': { x: 600, y: 500 },
'outuni': { x: 550, y: 450 }
},
connections: [
{ from: 'jita', to: 'perimeter' },
{ from: 'jita', to: 'sobaseki' },
{ from: 'jita', to: 'urlen' },
{ from: 'jita', to: 'maurasi' },
{ from: 'jita', to: 'kimotoro' },
{ from: 'jita', to: 'new-caldari' },
{ from: 'perimeter', to: 'kimotoro' },
{ from: 'perimeter', to: 'outuni' },
{ from: 'sobaseki', to: 'maurasi' },
{ from: 'new-caldari', to: 'outuni' }
]
},
'syndicate': {
name: 'Syndicate',
systems: [
{ id: 'poitot', name: 'Poitot' },
{ id: '6-cz49', name: '6-CZ49' },
{ id: 'x-7omm', name: 'X-7OMM' },
{ id: 'pc9-ay', name: 'PC9-AY' },
{ id: 'mj-5f9', name: 'MJ-5F9' },
{ id: 'f-88pk', name: 'F-88PK' }
],
nodePositions: {
'poitot': { x: 600, y: 400 },
'6-cz49': { x: 500, y: 350 },
'x-7omm': { x: 700, y: 350 },
'pc9-ay': { x: 550, y: 300 },
'mj-5f9': { x: 650, y: 300 },
'f-88pk': { x: 600, y: 250 }
},
connections: [
{ from: 'poitot', to: '6-cz49' },
{ from: 'poitot', to: 'x-7omm' },
{ from: '6-cz49', to: 'pc9-ay' },
{ from: 'x-7omm', to: 'mj-5f9' },
{ from: 'pc9-ay', to: 'f-88pk' },
{ from: 'mj-5f9', to: 'f-88pk' }
]
}
};

View File

@@ -1,14 +1,8 @@
// Update this page (the content is just a fallback if you fail to update the page)
import { GalaxyMap } from '../components/GalaxyMap';
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
</div>
);
return <GalaxyMap />;
};
export default Index;

34
src/pages/RegionPage.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { useParams } from 'react-router-dom';
import { RegionMap } from '../components/RegionMap';
import { regionData } from '../data/galaxyData';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
const RegionPage = () => {
const { region } = useParams<{ region: string }>();
const navigate = useNavigate();
if (!region || !regionData[region]) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-white mb-4">Region Not Found</h1>
<p className="text-purple-200 mb-6">The requested region does not exist in our database.</p>
<Button
onClick={() => navigate('/')}
className="bg-purple-600 hover:bg-purple-700"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Return to Galaxy Map
</Button>
</div>
</div>
);
}
return <RegionMap regionData={regionData[region]} />;
};
export default RegionPage;