Globe scroll hero ported from trekmap-shop
This commit is contained in:
parent
84cb38ba20
commit
9f7d39aae4
1 changed files with 236 additions and 0 deletions
236
globe.js
Normal file
236
globe.js
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
/* TrekGlobe hero — vanilla port of trekmap-shop's Globe.tsx + HeroStory.tsx.
|
||||||
|
*
|
||||||
|
* Same idea as the Next.js original: a d3-geo orthographic globe on canvas,
|
||||||
|
* rendered from real Natural Earth geometry, with the demo traveller's wished
|
||||||
|
* countries as embers and visited countries at full gold. Scroll rotates it
|
||||||
|
* eastward through each chapter's longitude and, at the payoff, three wishes
|
||||||
|
* burn up to visited.
|
||||||
|
*
|
||||||
|
* Differences from the React version, all forced by this being a static page:
|
||||||
|
* - geometry is pre-converted from TopoJSON to GeoJSON at build time, so no
|
||||||
|
* topojson-client ships to the browser;
|
||||||
|
* - state lives in a plain object instead of a React ref (the original used a
|
||||||
|
* ref specifically to avoid re-rendering per frame — same intent);
|
||||||
|
* - country ids are zero-padded before comparison. The dataset ids are
|
||||||
|
* "076"/"036" but the app's ID table declares "76"/"36", so Brazil and
|
||||||
|
* Australia never light up upstream. Padding fixes it here.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var d3 = window.TG;
|
||||||
|
var cv = document.getElementById("globe");
|
||||||
|
if (!cv || !d3) return;
|
||||||
|
|
||||||
|
var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
var ctx = cv.getContext("2d");
|
||||||
|
var TILT = -18;
|
||||||
|
|
||||||
|
var pad = function (id) { id = String(id); return id.length >= 3 ? id : ("00" + id).slice(-3); };
|
||||||
|
var VISITED = ["840", "124", "484", "826", "250", "380", "392"].map(pad);
|
||||||
|
var WISHED = ["352", "620", "300", "764", "554", "76", "504", "818", "36", "604", "578", "724", "704"].map(pad);
|
||||||
|
var PAYOFF = ["352", "620", "764"].map(pad);
|
||||||
|
|
||||||
|
var CHAPTERS = [
|
||||||
|
{ coord: "35.6762° N · 139.6503° E — TOKYO", title: "Wish it from anywhere",
|
||||||
|
body: "Saw it in a Reel? Paste the link. The app reads the video, finds the place, and drops the pin for you — and on the globe, that country starts to glow.",
|
||||||
|
lon: 139.65, side: "right" },
|
||||||
|
{ coord: "64.1466° N · 21.9426° W — REYKJAVÍK", title: "A map that keeps score",
|
||||||
|
body: "Every wish and every visit lives on one interactive map — pins, routes, and a passport of countries that fills in as your life happens.",
|
||||||
|
lon: -21.94, side: "left" },
|
||||||
|
{ coord: "41.9028° N · 12.4964° E — ROME", title: "Plan it together",
|
||||||
|
body: "Boards are better shared. Friends drop pins on your map, you scheme on theirs, and a someday-trip starts taking shape.",
|
||||||
|
lon: 12.49, side: "right" },
|
||||||
|
{ coord: "40.6413° N · 73.7781° W — JFK", title: "Time it right",
|
||||||
|
body: "The flights tab watches fares to the places you've wished for and taps you on the shoulder when a deal lands.",
|
||||||
|
lon: -73.77, side: "left" },
|
||||||
|
{ coord: "13.7563° N · 100.5018° E — BANGKOK", title: "Keep the story",
|
||||||
|
body: "A block-based journal turns each trip into pages worth rereading — photos, routes, and the notes you'll be glad you wrote.",
|
||||||
|
lon: 100.5, side: "right" },
|
||||||
|
{ coord: "YOUR LIVING ROOM", title: "And it lights up at home",
|
||||||
|
body: "Everything above happens in the app. This is the part you can touch: the moment you land somewhere you'd wished for, its ember burns to full gold — on your wall, live.",
|
||||||
|
lon: -21.94, side: "left" }
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── build the chapter cards ─────────────────────────────────────── */
|
||||||
|
var cardWrap = document.getElementById("chapters");
|
||||||
|
var cards = CHAPTERS.map(function (c) {
|
||||||
|
var el = document.createElement("article");
|
||||||
|
el.className = "chapter " + c.side;
|
||||||
|
el.innerHTML = '<p class="coord">' + c.coord + "</p><h2>" + c.title + "</h2><p class=\"cbody\">" + c.body + "</p>";
|
||||||
|
cardWrap.appendChild(el);
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
// The scroll story needs one viewport of runway per chapter, plus the intro.
|
||||||
|
document.getElementById("story").style.height = (CHAPTERS.length + 1) * 100 + "vh";
|
||||||
|
|
||||||
|
var state = { lon: 8, wished: new Set(WISHED), visited: new Set(VISITED) };
|
||||||
|
var world = null;
|
||||||
|
|
||||||
|
var clamp01 = function (v) { return v < 0 ? 0 : v > 1 ? 1 : v; };
|
||||||
|
var easeInOut = function (t) { return t * t * (3 - 2 * t); };
|
||||||
|
var lerp = function (a, b, t) { return a + (b - a) * t; };
|
||||||
|
|
||||||
|
/* Rotation targets, unwrapped so the globe always turns the same way as the
|
||||||
|
scroll rather than snapping backwards between chapters. */
|
||||||
|
var raw = [state.lon].concat(CHAPTERS.map(function (c) { return c.lon; }));
|
||||||
|
var targets = [raw[0]];
|
||||||
|
for (var i = 1; i < raw.length; i++) {
|
||||||
|
var t = raw[i], prev = targets[i - 1];
|
||||||
|
while (t <= prev + 15) t += 360;
|
||||||
|
targets.push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
var EQUATOR = { type: "LineString", coordinates: [] };
|
||||||
|
for (var e = 0; e < 73; e++) EQUATOR.coordinates.push([-180 + e * 5, 0]);
|
||||||
|
var graticule = d3.geoGraticule10();
|
||||||
|
var sphere = { type: "Sphere" };
|
||||||
|
|
||||||
|
var story = document.getElementById("story");
|
||||||
|
var intro = document.getElementById("intro");
|
||||||
|
var cue = document.getElementById("cue");
|
||||||
|
var wrap = document.getElementById("globewrap");
|
||||||
|
var payoffOn = false;
|
||||||
|
|
||||||
|
function applyPayoff(on) {
|
||||||
|
if (on === payoffOn) return;
|
||||||
|
payoffOn = on;
|
||||||
|
if (on) {
|
||||||
|
state.wished = new Set(WISHED.filter(function (id) { return PAYOFF.indexOf(id) < 0; }));
|
||||||
|
state.visited = new Set(VISITED.concat(PAYOFF));
|
||||||
|
} else {
|
||||||
|
state.wished = new Set(WISHED);
|
||||||
|
state.visited = new Set(VISITED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── scroll → state ──────────────────────────────────────────────── */
|
||||||
|
function story_tick() {
|
||||||
|
var vh = innerHeight;
|
||||||
|
var rect = story.getBoundingClientRect();
|
||||||
|
var scrolled = -rect.top;
|
||||||
|
var fx = Math.max(0, Math.min(CHAPTERS.length, scrolled / vh));
|
||||||
|
|
||||||
|
var i = Math.min(Math.floor(fx), targets.length - 2);
|
||||||
|
state.lon = lerp(targets[i], targets[i + 1], easeInOut(clamp01(fx - i)));
|
||||||
|
applyPayoff(fx > CHAPTERS.length - 0.55);
|
||||||
|
|
||||||
|
// Globe sits low and small behind the headline, then rises to centre stage.
|
||||||
|
var introT = easeInOut(clamp01(fx));
|
||||||
|
var mobile = innerWidth < 768;
|
||||||
|
var shift = mobile ? 22 : 11, scale = mobile ? 0.74 : 0.84;
|
||||||
|
wrap.style.transform = "translateY(" + (1 - introT) * shift + "vh) scale(" + (scale + introT * (1 - scale)) + ")";
|
||||||
|
|
||||||
|
var op = 1 - clamp01((fx - 0.25) * 1.8);
|
||||||
|
intro.style.opacity = op;
|
||||||
|
intro.style.transform = "translateY(" + (-fx * 40) + "px)";
|
||||||
|
intro.style.pointerEvents = op < 0.4 ? "none" : "";
|
||||||
|
cue.style.opacity = 1 - clamp01(fx * 3);
|
||||||
|
|
||||||
|
for (var c = 0; c < cards.length; c++) {
|
||||||
|
var centre = c + 0.85, last = c === cards.length - 1;
|
||||||
|
var o = Math.min(clamp01((fx - (centre - 0.5)) * 3), last ? 1 : 1 - clamp01((fx - (centre + 0.35)) * 3));
|
||||||
|
cards[c].style.opacity = o;
|
||||||
|
cards[c].style.transform = "translateY(" + (1 - o) * 28 + "px)";
|
||||||
|
cards[c].style.pointerEvents = o > 0.5 ? "" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── render ──────────────────────────────────────────────────────── */
|
||||||
|
var visible = true;
|
||||||
|
if ("IntersectionObserver" in window) {
|
||||||
|
new IntersectionObserver(function (es) { visible = es[0].isIntersecting; }).observe(cv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(time) {
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
story_tick();
|
||||||
|
if (!visible || !world) return;
|
||||||
|
|
||||||
|
var rect = cv.getBoundingClientRect();
|
||||||
|
var dpr = Math.min(devicePixelRatio || 1, 2);
|
||||||
|
var w = Math.round(rect.width * dpr), h = Math.round(rect.height * dpr);
|
||||||
|
if (!w || !h) return;
|
||||||
|
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||||
|
|
||||||
|
var drift = reduce ? 0 : 4 * Math.sin(time / 6000);
|
||||||
|
var pulse = reduce ? 1 : 0.75 + 0.25 * Math.sin(time / 700);
|
||||||
|
var cx = w / 2, cy = h / 2, r = Math.min(w, h) * 0.42;
|
||||||
|
|
||||||
|
var projection = d3.geoOrthographic()
|
||||||
|
.rotate([-(state.lon + drift), TILT, 0]).translate([cx, cy]).scale(r).clipAngle(90);
|
||||||
|
var path = d3.geoPath(projection, ctx);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Glass sphere
|
||||||
|
var glass = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.4, r * 0.1, cx, cy, r);
|
||||||
|
glass.addColorStop(0, "#10263f"); glass.addColorStop(0.55, "#071527"); glass.addColorStop(1, "#02080f");
|
||||||
|
ctx.beginPath(); path(sphere); ctx.fillStyle = glass; ctx.fill();
|
||||||
|
|
||||||
|
// Engraved graticule, brighter equator band
|
||||||
|
ctx.beginPath(); path(graticule);
|
||||||
|
ctx.strokeStyle = "rgba(230,184,0,.07)"; ctx.lineWidth = 0.6 * dpr; ctx.stroke();
|
||||||
|
ctx.beginPath(); path(EQUATOR);
|
||||||
|
ctx.strokeStyle = "rgba(230,184,0,.22)"; ctx.lineWidth = 0.9 * dpr; ctx.stroke();
|
||||||
|
|
||||||
|
var f, id, k;
|
||||||
|
// Unlit land
|
||||||
|
for (k = 0; k < world.features.length; k++) {
|
||||||
|
f = world.features[k]; id = f.id;
|
||||||
|
if (state.wished.has(id) || state.visited.has(id)) continue;
|
||||||
|
ctx.beginPath(); path(f);
|
||||||
|
ctx.fillStyle = "#0d1f33"; ctx.fill();
|
||||||
|
ctx.strokeStyle = "rgba(230,184,0,.3)"; ctx.lineWidth = 0.55 * dpr; ctx.stroke();
|
||||||
|
}
|
||||||
|
// Wished — ember
|
||||||
|
for (k = 0; k < world.features.length; k++) {
|
||||||
|
f = world.features[k]; id = f.id;
|
||||||
|
if (!state.wished.has(id) || state.visited.has(id)) continue;
|
||||||
|
ctx.beginPath(); path(f);
|
||||||
|
ctx.fillStyle = "rgba(230,184,0,.38)"; ctx.fill();
|
||||||
|
ctx.strokeStyle = "rgba(230,184,0,.7)"; ctx.lineWidth = 0.7 * dpr; ctx.stroke();
|
||||||
|
}
|
||||||
|
// Visited — full light with a breathing halo
|
||||||
|
ctx.save();
|
||||||
|
ctx.shadowColor = "rgba(255,213,74,.9)"; ctx.shadowBlur = 16 * dpr * pulse;
|
||||||
|
for (k = 0; k < world.features.length; k++) {
|
||||||
|
f = world.features[k];
|
||||||
|
if (!state.visited.has(f.id)) continue;
|
||||||
|
ctx.beginPath(); path(f); ctx.fillStyle = "#ffd54a"; ctx.fill();
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Specular sheen + rim light
|
||||||
|
var spec = ctx.createRadialGradient(cx - r * 0.45, cy - r * 0.55, 0, cx - r * 0.45, cy - r * 0.55, r * 0.9);
|
||||||
|
spec.addColorStop(0, "rgba(245,239,224,.16)"); spec.addColorStop(0.4, "rgba(245,239,224,.05)");
|
||||||
|
spec.addColorStop(1, "rgba(245,239,224,0)");
|
||||||
|
ctx.beginPath(); path(sphere); ctx.fillStyle = spec; ctx.fill();
|
||||||
|
ctx.beginPath(); path(sphere);
|
||||||
|
ctx.strokeStyle = "rgba(226,189,114,.25)"; ctx.lineWidth = 1.4 * dpr; ctx.stroke();
|
||||||
|
|
||||||
|
// Antique bronze half-meridian, pole to pole around the right
|
||||||
|
var ringR = r * 1.13;
|
||||||
|
var bronze = ctx.createLinearGradient(cx, cy - ringR, cx, cy + ringR);
|
||||||
|
bronze.addColorStop(0, "#e2bd72"); bronze.addColorStop(0.35, "#8a6435");
|
||||||
|
bronze.addColorStop(0.55, "#c9a45c"); bronze.addColorStop(0.8, "#5f451f");
|
||||||
|
bronze.addColorStop(1, "#4a3418");
|
||||||
|
ctx.save(); ctx.lineCap = "round"; ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, ringR, -Math.PI / 2, Math.PI / 2);
|
||||||
|
ctx.strokeStyle = bronze; ctx.lineWidth = r * 0.045; ctx.stroke(); ctx.restore();
|
||||||
|
|
||||||
|
// Axis finials
|
||||||
|
for (var d = -1; d <= 1; d += 2) {
|
||||||
|
var fy = cy + ringR * d;
|
||||||
|
ctx.beginPath(); ctx.arc(cx, fy, r * 0.04, 0, 6.2832); ctx.fillStyle = "#8a6435"; ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.arc(cx - r * 0.012, fy - r * 0.012, r * 0.018, 0, 6.2832);
|
||||||
|
ctx.fillStyle = "#e2bd72"; ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch("world.json").then(function (r) { return r.json(); }).then(function (w) {
|
||||||
|
w.features.forEach(function (f) { f.id = pad(f.id); });
|
||||||
|
world = w;
|
||||||
|
}).catch(function () { /* globe stays dark; the copy still reads */ });
|
||||||
|
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue