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:
@@ -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>
|
||||
|
37
src/components/Connection.tsx
Normal file
37
src/components/Connection.tsx
Normal 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>
|
||||
);
|
||||
};
|
100
src/components/GalaxyMap.tsx
Normal file
100
src/components/GalaxyMap.tsx
Normal 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>
|
||||
);
|
||||
};
|
95
src/components/MapNode.tsx
Normal file
95
src/components/MapNode.tsx
Normal 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>
|
||||
);
|
||||
};
|
122
src/components/RegionMap.tsx
Normal file
122
src/components/RegionMap.tsx
Normal 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
125
src/data/galaxyData.ts
Normal 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' }
|
||||
]
|
||||
}
|
||||
};
|
@@ -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
34
src/pages/RegionPage.tsx
Normal 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;
|
Reference in New Issue
Block a user