/* 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 = '
' + c.coord + "
" + c.body + "
"; 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); })();