diff --git a/globe.js b/globe.js new file mode 100644 index 0000000..a41048f --- /dev/null +++ b/globe.js @@ -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 = '

' + c.coord + "

" + c.title + "

" + 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); +})();