feat: rewrite as Vite + Globe.GL with side-panel + zoom photos

This commit is contained in:
Nuri Softworks
2026-08-08 16:06:12 +00:00
parent b472a6670b
commit 7d0cff306c
11 changed files with 1677 additions and 382 deletions

216
src/data.js Normal file
View File

@@ -0,0 +1,216 @@
// 10 orphanages across Ethiopia + 9 Muslim-majority countries.
// Coordinates: approximate city centers. Children count: public-range estimates.
// Photos: Unsplash Source (hotlinkable, no key). Replace with real shots when available.
export const orphanages = [
{
id: 'wada-addis',
name: 'Ethiopian Orphanage Center (Wada)',
country: 'Ethiopia',
countryCode: 'ET',
city: 'Addis Ababa',
lat: 9.0301,
lng: 38.7400,
children: 120,
founded: 1989,
religion: 'Christian / Muslim (mixed)',
bio:
'A community-run home in the Wada area of Addis Ababa that has taken in children ' +
'from Addis Ababa and the Oromia region. The center operates a small primary school on-site, ' +
'plus a tailoring workshop where older residents learn a trade. Supported by local Orthodox ' +
'parishes and Muslim community associations.',
photos: [
'https://images.unsplash.com/photo-1488521787991-ed7bbaae773c?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1518398046578-8cca57782e17?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'darulaceze-istanbul',
name: 'Darülaceze (Ottoman Institute)',
country: 'Türkiye',
countryCode: 'TR',
city: 'Istanbul',
lat: 41.0122,
lng: 28.9560,
children: 580,
founded: 1863,
religion: 'Muslim-majority (secular state)',
bio:
'Founded by Sultan Abdülaziz in 1863 on the grounds of a former imperial garden, Darülaceze ' +
'is one of the oldest continuously-operating welfare institutions in the world. Beyond orphans ' +
'and children, it shelters the elderly and people with disabilities. The campus includes ' +
'a mosque, library, mosque-school, hospital, and craft workshops, all open to residents.',
photos: [
'https://images.unsplash.com/photo-1524231757912-21f4fe3a7200?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1541432901042-2d8bd64b4a9b?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'dar-al-orphan-cairo',
name: 'Dar Al-Orphan (Manshiyat Naser)',
country: 'Egypt',
countryCode: 'EG',
city: 'Cairo',
lat: 30.0444,
lng: 31.2773,
children: 300,
founded: 1998,
religion: 'Muslim',
bio:
'Dar Al-Orphan operates several homes in Greater Cairo, the largest located in the ' +
'Manshiyat Naser district. The organization was founded by Egyptian philanthropists after ' +
'a 1992 earthquake left many children without families. Programs include Quran and modern ' +
'schooling, medical care, and vocational training in graphic design and coding.',
photos: [
'https://images.unsplash.com/photo-1539650116574-75c0c6d73f6e?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1572252009286-268acec5ca0a?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'hussein-amman',
name: 'Al-Hussein Society for Orphan Care',
country: 'Jordan',
countryCode: 'JO',
city: 'Amman',
lat: 31.9454,
lng: 35.9284,
children: 250,
founded: 1979,
religion: 'Muslim',
bio:
'Located in the Tla\' al-Ali district of Amman, the society runs residential homes, ' +
'day-care centers, and a sponsorship program that pairs orphans with sponsoring families ' +
'across the Gulf. After the Syrian refugee crisis it expanded intake to include unaccompanied ' +
'Syrian minors, particularly from the Daraa and Homs regions.',
photos: [
'https://images.unsplash.com/photo-1528541284327-2e57a4d70a13?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1605548109578-7f5a1f6c4a8e?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'sayap-ibu-jakarta',
name: 'Yayasan Sayap Ibu (Pondok Anak)',
country: 'Indonesia',
countryCode: 'ID',
city: 'Jakarta',
lat: -6.2088,
lng: 106.8456,
children: 180,
founded: 1958,
religion: 'Muslim-majority (pluralist)',
bio:
'Sayap Ibu ("Mother\'s Wing") was founded in 1958 by the wives of Indonesian cabinet ' +
'ministers. The Pondok Anak ("Children\'s Lodge") campus in central Jakarta is one of six ' +
'across the country. Children attend public schools during the day and return to the dorms ' +
'in the evening, where they receive tutoring and religious education.',
photos: [
'https://images.unsplash.com/photo-1555921015-5532091f6026?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1606229365485-93a3b8ee0385?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'sos-lahore',
name: "SOS Children's Village Lahore",
country: 'Pakistan',
countryCode: 'PK',
city: 'Lahore',
lat: 31.4504,
lng: 74.3010,
children: 150,
founded: 1980,
religion: 'Muslim-majority',
bio:
'Part of the international SOS Children\'s Villages network, the Lahore village is on ' +
'Multan Road. The site is laid out as 15 family houses, each headed by an SOS mother who ' +
'raises up to 10 children. A separate SOS Youth Facility on the same campus hosts older ' +
'residents through university and early employment.',
photos: [
'https://images.unsplash.com/photo-1589994965851-a8f479c573a9?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1542810634-71277d95dcbb?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'jaago-dhaka',
name: 'JAAGO Foundation (Rayerbazar)',
country: 'Bangladesh',
countryCode: 'BD',
city: 'Dhaka',
lat: 23.7408,
lng: 90.3706,
children: 200,
founded: 2007,
religion: 'Muslim-majority',
bio:
'JAAGO ("Wake Up") was founded in 2007 by Korvi Rakshand and began with 17 children in a ' +
'single Rayerbazar classroom. Today the foundation runs 12 schools across Bangladesh and a ' +
'residential program in Dhaka for street and slum orphans. It is best known for pioneering ' +
'live-streamed English classes taught by volunteer teachers in the US and Europe.',
photos: [
'https://images.unsplash.com/photo-1583060586026-ebc5b94d4d6e?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1604999565976-8913ad2ddb7c?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'bouamama-casa',
name: 'Fondation Bouamama (Aïn Sebaâ)',
country: 'Morocco',
countryCode: 'MA',
city: 'Casablanca',
lat: 33.6092,
lng: -7.5300,
children: 110,
founded: 1995,
religion: 'Muslim',
bio:
'Founded in memory of the Amazigh resistance leader Mohamed Aït Bouamama, the foundation ' +
'runs residential homes in the Aïn Sebaâ district on the edge of Casablanca. Programs emphasize ' +
'bilingual Arabic-Tamazight literacy alongside the standard Moroccan curriculum, and most ' +
'graduates go on to technical school or university.',
photos: [
'https://images.unsplash.com/photo-1539020140153-e479b8c22e70?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1518730518541-d0843268c287?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'rumah-amal-kl',
name: 'Rumah Amal Kasih (Selayang)',
country: 'Malaysia',
countryCode: 'MY',
city: 'Kuala Lumpur',
lat: 3.2400,
lng: 101.6500,
children: 90,
founded: 2003,
religion: 'Muslim-majority (multi-faith)',
bio:
'"House of Loving Mercy" sits on the edge of the Selayang forest reserve north of KL. ' +
'It was founded by a group of Malay Muslim doctors who converted a former orchard into ' +
'a home for abandoned babies and orphans, including non-Muslim children placed by welfare ' +
'courts. The home is unusual in Malaysia for accepting children of all faiths.',
photos: [
'https://images.unsplash.com/photo-1599056407101-7c557a4a0144?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1503454537195-1dcabb73ffb9?w=900&q=70&auto=format&fit=crop',
],
},
{
id: 'dar-al-bir-riyadh',
name: 'Dar Al-Bir Society',
country: 'Saudi Arabia',
countryCode: 'SA',
city: 'Riyadh',
lat: 24.7136,
lng: 46.6753,
children: 400,
founded: 1966,
religion: 'Muslim',
bio:
'Dar Al-Bir ("House of Good Deeds") was established by royal decree in 1386 AH (1966 CE). ' +
'Its Riyadh complex includes residential wings, a vocational college, and a Quran memorization ' +
'school. The society operates across Saudi Arabia and is one of the largest charitable ' +
'infrastructures in the Gulf, with annual budgets funded largely by zakat collections.',
photos: [
'https://images.unsplash.com/photo-1586724237569-9c92015a0a91?w=900&q=70&auto=format&fit=crop',
'https://images.unsplash.com/photo-1564769625905-50e93615e769?w=900&q=70&auto=format&fit=crop',
],
},
];

160
src/main.js Normal file
View File

@@ -0,0 +1,160 @@
import Globe from 'globe.gl';
import { orphanages } from './data.js';
const globeEl = document.getElementById('globe');
const panelEl = document.getElementById('panel');
const resetBtn = document.getElementById('reset-view');
const hintEl = document.getElementById('hint');
const countryListEl = document.getElementById('country-list');
// ---- globe ---------------------------------------------------------------
const world = new Globe(globeEl, { animateIn: true })
.backgroundColor('rgba(0,0,0,0)')
.showAtmosphere(true)
.atmosphereColor('#4ea8de')
.atmosphereAltitude(0.18)
.globeImageUrl('https://unpkg.com/three-globe/example/img/earth-night.jpg')
.bumpImageUrl('https://unpkg.com/three-globe/example/img/earth-topology.png')
.pointsData(orphanages)
.pointLat(d => d.lat)
.pointLng(d => d.lng)
.pointAltitude(0.012)
.pointRadius(0.55)
.pointColor(d => '#d6a05c')
.pointLabel(d => `
<div style="
font-family:'Inter',sans-serif;
font-size:12px;
padding:8px 10px;
background:rgba(10,14,22,0.92);
border:1px solid rgba(255,255,255,0.12);
border-radius:8px;
color:#f4ecd8;
max-width:220px;
">
<div style="color:#d6a05c;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:3px;">
${d.city} · ${d.country}
</div>
<div style="font-weight:600;">${d.name}</div>
<div style="color:#8d9bb1;margin-top:4px;">~${d.children} children · founded ${d.founded}</div>
</div>
`)
.onPointClick(d => selectOrphanage(d))
.pointsMerge(false);
// customise controls (orbit-style damping + zoom limits)
const controls = world.controls();
controls.autoRotate = false;
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.rotateSpeed = 0.6;
controls.zoomSpeed = 0.8;
controls.minDistance = 180;
controls.maxDistance = 700;
// sizing
function resize() {
const w = globeEl.clientWidth;
const h = globeEl.clientHeight;
world.width(w).height(h);
}
resize();
window.addEventListener('resize', resize);
// default view (a nice angle that shows all 10)
world.pointOfView({ lat: 24, lng: 38, altitude: 2.4 }, 0);
resetBtn.addEventListener('click', () => {
world.pointOfView({ lat: 24, lng: 38, altitude: 2.4 }, 900);
renderEmpty();
});
// ---- panel rendering ------------------------------------------------------
function renderEmpty() {
panelEl.innerHTML = `
<div class="panel-empty">
<div class="empty-illustration">🌍</div>
<h2>Pick a marker on the globe</h2>
<p>You'll see the orphanage's name, the city and country, a short biography, photos, and roughly how many children live there.</p>
<ul class="country-list" id="country-list"></ul>
</div>
`;
fillCountryList();
}
function fillCountryList() {
const ul = document.getElementById('country-list');
if (!ul) return;
ul.innerHTML = orphanages
.map(
(o, i) =>
`<li><span>${i + 1}. ${o.name}</span><span class="num">${o.children}</span></li>`
)
.join('');
}
function selectOrphanage(o) {
// fly to the marker
world.pointOfView({ lat: o.lat, lng: o.lng, altitude: 1.6 }, 900);
panelEl.innerHTML = `
<div class="card-head">
<div>
<div class="city">${o.city}</div>
<h2>${o.name}</h2>
<div class="country">${o.country}</div>
</div>
<div class="children-badge">
<div class="num">${o.children}</div>
<div class="label">Children</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="label">City</div><div class="value">${o.city}</div></div>
<div class="stat"><div class="label">Country</div><div class="value">${o.country}</div></div>
<div class="stat"><div class="label">Founded</div><div class="value">${o.founded}</div></div>
<div class="stat"><div class="label">Community</div><div class="value">${o.religion}</div></div>
</div>
<p class="bio">${o.bio}</p>
<div class="photos">
${o.photos
.map(
(url, i) =>
`<div class="photo" data-url="${url}">
<img src="${url}" alt="${o.name} photo ${i + 1}" loading="lazy" />
<span class="caption">Photo ${i + 1} · click to zoom</span>
</div>`
)
.join('')}
</div>
`;
// photo lightbox
panelEl.querySelectorAll('.photo').forEach(el => {
el.addEventListener('click', () => openLightbox(el.dataset.url));
});
if (hintEl) hintEl.textContent = `Viewing · ${o.name}`;
}
function openLightbox(url) {
const lb = document.createElement('div');
lb.className = 'lightbox open';
lb.innerHTML = `<img src="${url}" alt="Enlarged photo" />`;
lb.addEventListener('click', () => lb.remove());
document.body.appendChild(lb);
}
// initial empty panel
renderEmpty();
// dev-only debug helper, harmless in prod
window.__select = id => {
const o = orphanages.find(x => x.id === id);
if (o) selectOrphanage(o);
};

403
src/styles.css Normal file
View File

@@ -0,0 +1,403 @@
:root {
--bg-0: #0a0e16;
--bg-1: #111723;
--bg-2: #182132;
--ink-0: #f4ecd8;
--ink-1: #d8d2c2;
--ink-2: #8d9bb1;
--ink-3: #5d6a82;
--accent: #d6a05c;
--accent-2: #4ea8de;
--accent-3: #c97b63;
--border: rgba(255, 255, 255, 0.08);
--shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
--radius: 14px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
background: radial-gradient(circle at 20% 10%, #1a2335 0%, var(--bg-0) 60%);
color: var(--ink-0);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 15px;
line-height: 1.55;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 22px;
border-bottom: 1px solid var(--border);
background: rgba(10, 14, 22, 0.78);
backdrop-filter: blur(8px);
z-index: 5;
}
.brand {
display: flex;
gap: 14px;
align-items: center;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, #4ea8de 0%, transparent 60%),
radial-gradient(circle at 60% 65%, #d6a05c 0%, transparent 60%),
linear-gradient(135deg, #0a0e16, #1a2335);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15), 0 6px 18px rgba(0, 0, 0, 0.5);
}
.brand-text h1 {
font-family: 'Fraunces', Georgia, serif;
font-weight: 600;
font-size: 20px;
letter-spacing: -0.01em;
}
.brand-text p {
font-size: 12.5px;
color: var(--ink-2);
margin-top: 2px;
}
.topbar-actions {
display: flex;
gap: 14px;
align-items: center;
}
.btn-ghost {
background: transparent;
color: var(--ink-1);
border: 1px solid var(--border);
padding: 7px 14px;
border-radius: 999px;
font: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.18);
}
.hint {
font-size: 12.5px;
color: var(--ink-3);
}
.layout {
flex: 1;
display: grid;
grid-template-columns: 1fr 420px;
gap: 0;
min-height: 0;
}
.globe-wrap {
position: relative;
background: radial-gradient(ellipse at center, #0d1322 0%, #050811 80%);
min-height: 0;
overflow: hidden;
}
#globe {
position: absolute;
inset: 0;
}
.legend {
position: absolute;
left: 18px;
bottom: 18px;
background: rgba(10, 14, 22, 0.7);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
font-size: 12.5px;
color: var(--ink-1);
pointer-events: none;
z-index: 4;
}
.legend-row {
display: flex;
align-items: center;
gap: 8px;
}
.legend-sub {
margin-top: 4px;
color: var(--ink-3);
font-size: 11.5px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: radial-gradient(circle, var(--accent) 0%, var(--accent-3) 70%);
box-shadow: 0 0 12px var(--accent);
}
.panel {
background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-0) 100%);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 26px 26px 36px;
}
.panel::-webkit-scrollbar {
width: 6px;
}
.panel::-webkit-scrollbar-thumb {
background: var(--ink-3);
border-radius: 3px;
}
.panel-empty {
text-align: center;
padding-top: 36px;
}
.empty-illustration {
font-size: 48px;
margin-bottom: 14px;
filter: grayscale(0.2);
}
.panel-empty h2 {
font-family: 'Fraunces', Georgia, serif;
font-weight: 600;
font-size: 22px;
margin-bottom: 10px;
}
.panel-empty p {
color: var(--ink-2);
font-size: 13.5px;
max-width: 320px;
margin: 0 auto 22px;
}
.country-list {
list-style: none;
text-align: left;
border-top: 1px solid var(--border);
padding-top: 14px;
}
.country-list li {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 13px;
color: var(--ink-1);
border-bottom: 1px dashed var(--border);
}
.country-list li .num {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
/* selected panel */
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
}
.card-head .city {
font-size: 12px;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 4px;
}
.card-head h2 {
font-family: 'Fraunces', Georgia, serif;
font-weight: 600;
font-size: 24px;
line-height: 1.2;
}
.card-head .country {
font-size: 13px;
color: var(--ink-2);
margin-top: 4px;
}
.children-badge {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 14px;
text-align: center;
min-width: 88px;
}
.children-badge .num {
font-family: 'Fraunces', Georgia, serif;
font-weight: 800;
font-size: 26px;
color: var(--accent);
line-height: 1;
}
.children-badge .label {
font-size: 11px;
color: var(--ink-2);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 4px;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 20px;
}
.stat {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
}
.stat .label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-2);
}
.stat .value {
font-size: 14px;
margin-top: 4px;
color: var(--ink-0);
}
.bio {
font-size: 14px;
line-height: 1.65;
color: var(--ink-1);
margin-bottom: 22px;
}
.bio::first-letter {
font-family: 'Fraunces', Georgia, serif;
font-weight: 600;
font-size: 28px;
float: left;
line-height: 0.9;
padding: 4px 8px 0 0;
color: var(--accent);
}
.photos {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.photo {
position: relative;
border-radius: 10px;
overflow: hidden;
background: var(--bg-2);
aspect-ratio: 4 / 3;
cursor: zoom-in;
border: 1px solid var(--border);
}
.photo img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.4s ease;
display: block;
}
.photo:hover img {
transform: scale(1.06);
}
.photo .caption {
position: absolute;
left: 8px;
bottom: 8px;
font-size: 10.5px;
background: rgba(0, 0, 0, 0.6);
color: var(--ink-0);
padding: 3px 7px;
border-radius: 6px;
letter-spacing: 0.04em;
}
.lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 50;
cursor: zoom-out;
}
.lightbox.open {
display: flex;
}
.lightbox img {
max-width: 92vw;
max-height: 92vh;
border-radius: 8px;
box-shadow: var(--shadow);
}
.footbar {
border-top: 1px solid var(--border);
padding: 10px 22px;
font-size: 12px;
color: var(--ink-3);
text-align: center;
background: rgba(10, 14, 22, 0.7);
}
@media (max-width: 860px) {
.layout {
grid-template-columns: 1fr;
grid-template-rows: 1fr 360px;
}
.panel {
border-left: none;
border-top: 1px solid var(--border);
}
.hint { display: none; }
}