feat: introduce interactive 3D globe with country borders and GitHub activity feed.
This commit is contained in:
57
package-lock.json
generated
57
package-lock.json
generated
@@ -11,11 +11,13 @@
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.4.2",
|
||||
"@studio-freight/lenis": "^1.0.42",
|
||||
"d3-geo": "^3.1.1",
|
||||
"gsap": "^3.14.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"three": "^0.182.0"
|
||||
"three": "^0.182.0",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -1899,6 +1901,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1951,6 +1959,30 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2502,6 +2534,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -3233,6 +3274,20 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-text": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.4.2",
|
||||
"@studio-freight/lenis": "^1.0.42",
|
||||
"d3-geo": "^3.1.1",
|
||||
"gsap": "^3.14.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"three": "^0.182.0"
|
||||
"three": "^0.182.0",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||
import Header from './components/Header';
|
||||
import HeroModel from './canvas/HeroModel';
|
||||
import ProductGrid from './components/ProductGrid';
|
||||
import WhereAmI from './components/WhereAmI';
|
||||
import Footer from './components/Footer';
|
||||
import './styles/index.css';
|
||||
import gsap from 'gsap';
|
||||
@@ -109,6 +110,7 @@ function App() {
|
||||
|
||||
{/* Product Grid Section */}
|
||||
<ProductGrid />
|
||||
<WhereAmI />
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
|
||||
BIN
src/assets/earth_texture.jpg
Normal file
BIN
src/assets/earth_texture.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
139
src/canvas/Globe.jsx
Normal file
139
src/canvas/Globe.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React, { useRef, useMemo, useState, useEffect } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrbitControls } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
import * as d3 from 'd3-geo';
|
||||
import * as topojson from 'topojson-client';
|
||||
|
||||
// Helper to convert Lat/Lon to Vector3
|
||||
const calcPosFromLatLonRad = (lat, lon, radius) => {
|
||||
const phi = (90 - lat) * (Math.PI / 180);
|
||||
const theta = (lon + 180) * (Math.PI / 180);
|
||||
const x = -(radius * Math.sin(phi) * Math.cos(theta));
|
||||
const z = (radius * Math.sin(phi) * Math.sin(theta));
|
||||
const y = (radius * Math.cos(phi));
|
||||
return [x, y, z];
|
||||
};
|
||||
|
||||
const GlobeMesh = () => {
|
||||
const groupRef = useRef();
|
||||
const [bordersTexture, setBordersTexture] = useState(null);
|
||||
|
||||
// Generate Borders Texture using D3
|
||||
useEffect(() => {
|
||||
const generateTexture = async () => {
|
||||
try {
|
||||
// Fetch World Topology (small 110m res)
|
||||
const response = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json');
|
||||
const world = await response.json();
|
||||
const countries = topojson.feature(world, world.objects.countries);
|
||||
|
||||
// Setup Offscreen Canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 2048;
|
||||
canvas.height = 1024;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// D3 Projection (Equirectangular matches UV map of Sphere)
|
||||
const projection = d3.geoEquirectangular()
|
||||
.fitSize([2048, 1024], countries)
|
||||
.translate([1024, 512]);
|
||||
|
||||
const path = d3.geoPath(projection, context);
|
||||
|
||||
// Draw
|
||||
context.strokeStyle = '#ffffff'; // White borders
|
||||
context.lineWidth = 2; // Thicker lines
|
||||
context.beginPath();
|
||||
path(countries);
|
||||
context.stroke();
|
||||
|
||||
// Create Texture
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
setBordersTexture(texture);
|
||||
} catch (err) {
|
||||
console.error("Failed to load globe data", err);
|
||||
}
|
||||
};
|
||||
|
||||
generateTexture();
|
||||
}, []);
|
||||
|
||||
// Ireland Coordinates
|
||||
const irelandPos = useMemo(() => calcPosFromLatLonRad(53.35, -6.26, 2.02), []);
|
||||
|
||||
useFrame((state, delta) => {
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.y += delta * 0.05; // Gentle Auto-rotation
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{/* 1. Base Dark Sphere (blocks background stars/wireframe from showing through backface) */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.95, 64, 64]} />
|
||||
<meshBasicMaterial color="#000000" />
|
||||
</mesh>
|
||||
|
||||
{/* 2. Light Wireframe Sphere - Outer Cage */}
|
||||
<mesh>
|
||||
<sphereGeometry args={[2.0, 32, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#444"
|
||||
wireframe={true}
|
||||
transparent
|
||||
opacity={0.15}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 3. Borders Sphere (Texture) */}
|
||||
{bordersTexture && (
|
||||
<mesh>
|
||||
<sphereGeometry args={[2.01, 64, 64]} />
|
||||
<meshBasicMaterial
|
||||
map={bordersTexture}
|
||||
transparent={true}
|
||||
opacity={0.8}
|
||||
side={THREE.DoubleSide}
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Ireland Marker */}
|
||||
<mesh position={irelandPos}>
|
||||
<sphereGeometry args={[0.04, 16, 16]} />
|
||||
<meshBasicMaterial color="#ff4d00" />
|
||||
</mesh>
|
||||
<mesh position={irelandPos}>
|
||||
<ringGeometry args={[0.06, 0.09, 32]} />
|
||||
<meshBasicMaterial color="#ff4d00" side={THREE.DoubleSide} transparent opacity={0.6} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const Globe = () => {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', minHeight: '400px', cursor: 'grab' }}>
|
||||
<Canvas camera={{ position: [0, 0, 5.5], fov: 45 }}>
|
||||
<ambientLight intensity={1} />
|
||||
<pointLight position={[10, 10, 10]} />
|
||||
|
||||
<GlobeMesh />
|
||||
|
||||
<OrbitControls
|
||||
enableZoom={false}
|
||||
enablePan={false}
|
||||
minPolarAngle={Math.PI / 4}
|
||||
maxPolarAngle={Math.PI / 1.5}
|
||||
/>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Globe;
|
||||
117
src/components/GithubHistory.jsx
Normal file
117
src/components/GithubHistory.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const GithubHistory = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/users/MatissJurevics/events/public?per_page=5');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch GitHub history');
|
||||
}
|
||||
const data = await response.json();
|
||||
setEvents(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const getEventMessage = (event) => {
|
||||
switch (event.type) {
|
||||
case 'PushEvent':
|
||||
return `Pushed to ${event.repo.name.replace('MatissJurevics/', '')}`;
|
||||
case 'CreateEvent':
|
||||
return `Created ${event.payload.ref_type} in ${event.repo.name.replace('MatissJurevics/', '')}`;
|
||||
case 'WatchEvent':
|
||||
return `Starred ${event.repo.name.replace('MatissJurevics/', '')}`;
|
||||
case 'PullRequestEvent':
|
||||
return `${event.payload.action} PR in ${event.repo.name.replace('MatissJurevics/', '')}`;
|
||||
default:
|
||||
return `${event.type} on ${event.repo.name.replace('MatissJurevics/', '')}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#666', fontFamily: 'monospace' }}>
|
||||
LOADING HISTORY...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d00', fontFamily: 'monospace' }}>
|
||||
ERROR LOAD GITHUB DATA
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{
|
||||
height: '600px',
|
||||
background: '#0a0a0a',
|
||||
color: '#e4e4e4',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '600px', // Narrower container for list
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<h2 className="uppercase" style={{ fontSize: '3rem', marginBottom: '40px', lineHeight: 1, textAlign: 'center' }}>
|
||||
Github <br /> History
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{events.map(event => (
|
||||
<div key={event.id} style={{
|
||||
borderLeft: '2px solid #333',
|
||||
paddingLeft: '20px',
|
||||
transition: 'border-color 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = '#ff4d00'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#333'}
|
||||
>
|
||||
<p className="mono" style={{ fontSize: '0.8rem', color: '#666', marginBottom: '5px' }}>
|
||||
{formatDate(event.created_at)}
|
||||
</p>
|
||||
<a href={`https://github.com/${event.repo.name}`} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<p style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>
|
||||
{getEventMessage(event)}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '40px', textAlign: 'center' }}>
|
||||
<a href="https://github.com/MatissJurevics" target="_blank" rel="noopener noreferrer" className="mono" style={{ color: '#ff4d00', textDecoration: 'none', fontSize: '0.9rem' }}>
|
||||
VIEW FULL PROFILE →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubHistory;
|
||||
@@ -31,11 +31,7 @@ const Header = () => {
|
||||
mj
|
||||
</div>
|
||||
|
||||
<nav style={navStyle}>
|
||||
<a href="#work">Work</a>
|
||||
<a href="#about">About</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
|
||||
|
||||
</header>
|
||||
);
|
||||
|
||||
63
src/components/InfoTabs.jsx
Normal file
63
src/components/InfoTabs.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import WhereAmI from './WhereAmI';
|
||||
import GithubHistory from './GithubHistory';
|
||||
|
||||
const InfoTabs = () => {
|
||||
const [activeTab, setActiveTab] = useState('location');
|
||||
|
||||
return (
|
||||
<div style={{ background: '#0a0a0a' }}>
|
||||
{/* Tab Navigation */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '40px',
|
||||
paddingTop: '60px',
|
||||
borderBottom: '1px solid #222'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setActiveTab('location')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'location' ? '2px solid #ff4d00' : '2px solid transparent',
|
||||
color: activeTab === 'location' ? '#fff' : '#666',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Where Am I?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('github')}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'github' ? '2px solid #ff4d00' : '2px solid transparent',
|
||||
color: activeTab === 'github' ? '#fff' : '#666',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
Github History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div>
|
||||
{activeTab === 'location' && <WhereAmI />}
|
||||
{activeTab === 'github' && <GithubHistory />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoTabs;
|
||||
64
src/components/WhereAmI.jsx
Normal file
64
src/components/WhereAmI.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import Globe from '../canvas/Globe';
|
||||
|
||||
const WhereAmI = () => {
|
||||
return (
|
||||
<section style={{
|
||||
height: '600px',
|
||||
background: '#0a0a0a', // Dark Background
|
||||
color: '#e4e4e4', // Light Text
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '1400px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{/* Text Content */}
|
||||
<div style={{ flex: '1', minWidth: '300px', paddingRight: '40px' }}>
|
||||
<h2 className="uppercase" style={{ fontSize: '3rem', marginBottom: '20px', lineHeight: 1 }}>
|
||||
Where <br /> Am I?
|
||||
</h2>
|
||||
<div style={{ borderLeft: '2px solid #ff4d00', paddingLeft: '20px' }}>
|
||||
<p className="mono" style={{ fontSize: '1rem', color: '#888', marginBottom: '5px' }}>
|
||||
CURRENT LOCATION
|
||||
</p>
|
||||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold' }}>
|
||||
Dublin, Ireland
|
||||
</p>
|
||||
<p style={{ marginTop: '20px', color: '#ccc', maxWidth: '400px' }}>
|
||||
Based in the heart of Dublin. Working globally with clients to build digital products that matter.
|
||||
</p>
|
||||
<p className="mono" style={{ marginTop: '10px', fontSize: '0.8rem', color: '#666' }}>
|
||||
53.3498° N, 6.2603° W
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Globe Visual */}
|
||||
<div style={{
|
||||
flex: '1',
|
||||
minWidth: '300px',
|
||||
height: '100%',
|
||||
minHeight: '400px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Globe />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhereAmI;
|
||||
Reference in New Issue
Block a user