feat: introduce interactive 3D globe with country borders and GitHub activity feed.

This commit is contained in:
2025-12-15 20:19:30 +00:00
parent 8dd658f206
commit 06968a6820
9 changed files with 445 additions and 7 deletions

57
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

139
src/canvas/Globe.jsx Normal file
View 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;

View 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 &rarr;
</a>
</div>
</div>
</section>
);
};
export default GithubHistory;

View File

@@ -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>
);

View 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;

View 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;