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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user