1/* Terrapin Trail archive browser: pick a band, a year and a tour, listen to the
2 real shows, set the game's rules, share a tagged link, and see the tour's
3 leaderboard. Everything comes from the server's /api routes. */
4(function () {
5 "use strict";
6 var BOOT = window.TT_BOOT || {};
7 var $ = function (id) { return document.getElementById(id); };
8 function esc(s) {
9 return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
10 return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
11 });
12 }
13 function api(path, opts) {
14 return fetch(path, opts).then(function (r) {
15 return r.json().then(function (d) { if (!r.ok) throw new Error(d.error || "Something went wrong."); return d; });
16 });
17 }
18 function flash(msg) { var f = $("flash"); f.textContent = msg; f.hidden = !msg; }
19
20 var st = {
21 band: null, bandName: "", years: [], year: null, tours: [], tag: null, tour: null, board: null,
22 rules: [], presets: {}, gallery: {}, values: {}, code: "CLASSIC", stopIdx: null, show: null,
23 luck: BOOT.luck || null, from: BOOT.from || null,
24 };
25 // Where this visitor came from rides along on any score they post.
26 try {
27 if (BOOT.from) sessionStorage.setItem("tt_from", BOOT.from);
28 st.from = sessionStorage.getItem("tt_from") || "direct";
29 } catch (e) { st.from = st.from || "direct"; }
30
31 // ------------------------------------------------------------ bands
32 var searchTimer = null;
33 var bandSort = "az", bandLetter = "";
34 try { bandSort = localStorage.getItem("tt_band_sort") || "az"; } catch (e) {}
35 function loadBands(q) {
36 if (q === undefined) q = $("bandSearch").value.trim();
37 document.querySelectorAll(".sortbtn").forEach(function (b) { b.classList.toggle("on", b.dataset.sort === bandSort); });
38 $("letters").hidden = bandSort !== "az" || !!q;
39 document.querySelectorAll("#letters button").forEach(function (b) { b.classList.toggle("on", b.dataset.l === bandLetter); });
40 var url = "/api/bands?limit=600&sort=" + bandSort + "&q=" + encodeURIComponent(q || "") + (bandSort === "az" && !q && bandLetter ? "&letter=" + encodeURIComponent(bandLetter) : "");
41 api(url).then(function (d) {
42 // The unfiltered list is the archive's whole band count: keep the tagline and
43 // the search hint honest as bands are added.
44 if (!q && !(bandSort === "az" && bandLetter)) {
45 $("bandSearch").placeholder = "search " + d.total.toLocaleString() + " bands...";
46 }
47 var order = bandSort === "az" ? "A to Z" : "most played first";
48 $("bandCount").textContent = q ? d.total + " match" + (d.total === 1 ? "" : "es") :
49 d.total.toLocaleString() + " band" + (d.total === 1 ? "" : "s") + (bandLetter && bandSort === "az" ? " under " + bandLetter : "") + ", " + order +
50 (d.total > d.bands.length ? " (first " + d.bands.length + ")" : "");
51 var el = $("bands"); el.innerHTML = "";
52 d.bands.forEach(function (b) {
53 var row = document.createElement("div");
54 row.className = "band" + (b.id === st.band ? " on" : "");
55 row.tabIndex = 0;
56 row.innerHTML = "<span>" + esc(b.name) + "</span><small>" + b.items.toLocaleString() + "</small>";
57 row.onclick = function () { selectBand(b.id, null, null); };
58 row.onkeydown = function (e) { if (e.key === "Enter") row.onclick(); };
59 el.appendChild(row);
60 });
61 if (!d.bands.length) el.innerHTML = '<div class="empty">No band by that name on the archive.</div>';
62 }).catch(function (e) { $("bands").innerHTML = '<div class="empty">' + esc(e.message) + "</div>"; });
63 }
64 "#ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach(function (l) {
65 var b = document.createElement("button"); b.type = "button"; b.textContent = l; b.dataset.l = l;
66 b.onclick = function () { bandLetter = bandLetter === l ? "" : l; loadBands(); };
67 $("letters").appendChild(b);
68 });
69 document.querySelectorAll(".sortbtn").forEach(function (b) {
70 b.onclick = function () { bandSort = b.dataset.sort; try { localStorage.setItem("tt_band_sort", bandSort); } catch (e) {} loadBands(); };
71 });
72 $("bandSearch").addEventListener("input", function (e) {
73 clearTimeout(searchTimer);
74 searchTimer = setTimeout(function () { loadBands(e.target.value.trim()); }, 200);
75 });
76 $("pasteForm").addEventListener("submit", function (e) {
77 e.preventDefault();
78 var url = $("pasteUrl").value.trim(); if (!url) return;
79 flash("Reading that recording...");
80 api("/api/band", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url }) })
81 .then(function (d) { flash(""); selectBand(d.collection, d.year, d.date); })
82 .catch(function (err) { flash(err.message); });
83 });
84
85 // ------------------------------------------------------------ years and tours
86 // Each pick bumps its counter; an answer that arrives after a newer pick is
87 // dropped, so a quick second click can't draw the chart or the tours twice.
88 var latest = { band: 0, year: 0, tour: 0 };
89 function selectBand(id, year, date, tag) {
90 var mine = ++latest.band;
91 st.band = id;
92 document.querySelectorAll(".band").forEach(function (b) { b.classList.remove("on"); });
93 $("bandName").textContent = "loading...";
94 $("years").innerHTML = "";
95 return api("/api/years/" + encodeURIComponent(id)).then(function (d) {
96 if (mine !== latest.band) return;
97 st.bandName = d.band; st.years = d.years;
98 $("bandName").textContent = d.band;
99 var max = Math.max.apply(null, d.years.map(function (y) { return y.count; }).concat([1]));
100 var yEl = $("years"); yEl.innerHTML = "";
101 d.years.forEach(function (y) {
102 var bar = document.createElement("button");
103 bar.className = "yr"; bar.dataset.year = y.year;
104 bar.style.height = Math.max(4, y.count / max * 100) + "%";
105 bar.title = y.year + ": " + y.count + " shows (" + y.span + ")";
106 bar.setAttribute("aria-label", bar.title);
107 bar.onclick = function () { selectYear(y.year); };
108 yEl.appendChild(bar);
109 });
110 $("yrFirst").textContent = d.years.length ? d.years[0].year : "";
111 $("yrLast").textContent = d.years.length ? d.years[d.years.length - 1].year : "";
112 if (!d.years.length) { $("tours").innerHTML = '<div class="empty">No concerts with dates for this band yet.</div>'; return; }
113 var pick = year && d.years.some(function (y) { return y.year === year; }) ? year
114 : d.years.reduce(function (a, b) { return b.count > a.count ? b : a; }).year;
115 return selectYear(pick, date, tag);
116 }).catch(function (e) { $("bandName").textContent = st.bandName || ""; flash(e.message); });
117 }
118 function selectYear(year, date, tag) {
119 var mine = ++latest.year;
120 st.year = year;
121 document.querySelectorAll(".yr").forEach(function (b) { b.classList.toggle("on", +b.dataset.year === year); });
122 $("yearName").textContent = year;
123 $("tours").innerHTML = '<div class="empty">Finding the tours...</div>';
124 return api("/api/tours/" + encodeURIComponent(st.band) + "/" + year).then(function (d) {
125 if (mine !== latest.year) return;
126 st.tours = d.tours;
127 var el = $("tours"); el.innerHTML = "";
128 var mo = function (ymd) { return ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][+ymd.slice(5, 7) - 1]; };
129 d.tours.forEach(function (t) {
130 var c = document.createElement("button");
131 c.className = "chip"; c.dataset.tag = t.tag;
132 c.innerHTML = "<b>#" + esc(t.tag) + "</b> " + mo(t.start) + (t.end.slice(5, 7) !== t.start.slice(5, 7) ? "–" + mo(t.end) : "") +
133 " · " + t.nights + " night" + (t.nights > 1 ? "s" : "");
134 c.onclick = function () { selectTour(t.tag); };
135 el.appendChild(c);
136 });
137 var chosen = tag || (date && (d.tours.filter(function (t) { return t.start <= date && date <= t.end; })[0] || {}).tag) ||
138 d.tours.reduce(function (a, b) { return b.nights > a.nights ? b : a; }).tag;
139 return selectTour(chosen);
140 }).catch(function (e) { flash(e.message); });
141 }
142
143 // ------------------------------------------------------------ one tour
144 function selectTour(tag) {
145 var mine = ++latest.tour;
146 st.tag = tag;
147 document.querySelectorAll(".chip").forEach(function (c) { c.classList.toggle("on", c.dataset.tag === tag); });
148 $("stops").innerHTML = '<div class="empty">Building the tour from the archive...</div>';
149 return api("/api/tour/" + encodeURIComponent(fullTag())).then(function (d) {
150 if (mine !== latest.tour) return;
151 st.tour = d.tour; st.board = d.board;
152 renderTour(); renderBoard(d.board); syncTag();
153 var stops = d.tour.stops, leg = 0;
154 stops.forEach(function (s, i) { if (s.legendary) leg = i; });
155 selectStop(leg);
156 }).catch(function (e) { $("stops").innerHTML = '<div class="empty">' + esc(e.message) + "</div>"; });
157 }
158 function renderTour() {
159 var t = st.tour;
160 $("tourMeta").textContent = "· " + t.stops.length + " stops · " + t.total_miles.toLocaleString() + " miles";
161 var el = $("stops"); el.innerHTML = "";
162 t.stops.forEach(function (s, i) {
163 var row = document.createElement("div");
164 row.className = "stop"; row.tabIndex = 0; row.dataset.i = i;
165 var tag = s.legendary ? "★ legendary" : s.finale ? "finale" : "";
166 row.innerHTML = '<span class="d">' + esc(s.date) + "</span><span>" + esc(s.venue) + '<br><span class="muted">' + esc(s.city) +
167 '</span></span><span><span class="tag">' + tag + '</span><br><span class="mi">' + (i === 0 ? "start" : s.miles ? "+" + s.miles + " mi" : (s.lat == null ? "off the map" : "same town")) + "</span></span>";
168 row.onclick = function () { selectStop(i); };
169 row.onkeydown = function (e) { if (e.key === "Enter") selectStop(i); };
170 el.appendChild(row);
171 });
172 drawMap(t.stops);
173 }
174
175 var atlas = {};
176 function loadAtlas(name) {
177 if (!atlas[name]) atlas[name] = d3.json("https://cdn.jsdelivr.net/npm/" + name);
178 return atlas[name];
179 }
180 // Auto-crop: frame the tour's own stops, padded so edge labels fit, with the same
181 // shape as the map box. A floor (MAP_MIN_*) stops a two-night stand from zooming
182 // into one county; for a US-only tour a ceiling keeps it no wider than the lower 48.
183 var MAP_MIN_LAT = 4, MAP_MIN_LON = 6, US_MAX_LAT = 26, US_MAX_LON = 62;
184 function cropCorners(pts, W, H, inUS) {
185 var lats = pts.map(function (s) { return s.lat; }), lons = pts.map(function (s) { return s.lon; });
186 var lat0 = Math.min.apply(null, lats), lat1 = Math.max.apply(null, lats);
187 var lon0 = Math.min.apply(null, lons), lon1 = Math.max.apply(null, lons);
188 var cLat = (lat0 + lat1) / 2, cLon = (lon0 + lon1) / 2;
189 var dLat = Math.max(lat1 - lat0, MAP_MIN_LAT), dLon = Math.max(lon1 - lon0, MAP_MIN_LON);
190 var k = Math.cos(cLat * Math.PI / 180) || 1; // a degree of longitude shrinks toward the poles
191 if (dLon * k / dLat < W / H) dLon = dLat * (W / H) / k; else dLat = dLon * k * (H / W);
192 if (inUS) { dLat = Math.min(dLat, Math.max(US_MAX_LAT, lat1 - lat0)); dLon = Math.min(dLon, Math.max(US_MAX_LON, lon1 - lon0)); }
193 // Corners plus edge midpoints plus every stop: a conic map bends the box's
194 // straight edges, so fitting the corners alone can clip a stop near an edge.
195 var a = cLon - dLon / 2, b = cLon + dLon / 2, lo = cLat - dLat / 2, hi = cLat + dLat / 2;
196 return { type: "MultiPoint", coordinates: [[a, lo], [b, lo], [a, hi], [b, hi], [cLon, lo], [cLon, hi], [a, cLat], [b, cLat]]
197 .concat(pts.map(function (s) { return [s.lon, s.lat]; })) };
198 }
199 function drawMap(stops) {
200 // The map libraries come from a CDN; if it is blocked the tour still works, just without a map.
201 if (!window.d3 || !window.topojson) return;
202 var svg = d3.select("#map"); svg.selectAll("*").remove();
203 var W = svg.node().clientWidth || 600, H = svg.node().clientHeight || 330;
204 var pts = stops.filter(function (s) { return s.lat != null; });
205 var usTest = d3.geoAlbersUsa();
206 var inUS = pts.length > 0 && pts.every(function (s) { return usTest([s.lon, s.lat]) && s.lon > -130 && s.lat < 50; });
207 var proj;
208 if (!pts.length) {
209 proj = d3.geoAlbersUsa().fitExtent([[10, 10], [W - 10, H - 10]], { type: "Sphere" }).scale(W * 1.2).translate([W / 2, H / 2]);
210 } else {
211 var corners = cropCorners(pts, W, H, inUS);
212 var cLon = (corners.coordinates[0][0] + corners.coordinates[1][0]) / 2;
213 proj = (inUS ? d3.geoAlbers() : d3.geoNaturalEarth1().rotate([-cLon, 0]))
214 .fitExtent([[28, 26], [W - 28, H - 26]], corners);
215 }
216 var path = d3.geoPath(proj);
217 var css = getComputedStyle(document.body), cv = function (n) { return css.getPropertyValue(n).trim(); };
218 var land = svg.append("g").attr("class", "land");
219 var world = land.append("g"), states = land.append("g"), route = svg.append("g");
220 // Countries everywhere, US states on top, so a tour that crosses into Canada or
221 // Mexico still has its neighbours drawn.
222 loadAtlas("world-atlas@2/countries-50m.json").then(function (topo) {
223 world.selectAll("path").data(topojson.feature(topo, topo.objects.countries).features)
224 .join("path").attr("d", path).attr("fill", cv("--panel")).attr("stroke", cv("--line"));
225 });
226 loadAtlas("us-atlas@3/states-10m.json").then(function (topo) {
227 states.selectAll("path").data(topojson.feature(topo, topo.objects.states).features)
228 .join("path").attr("d", path).attr("fill", cv("--panel")).attr("stroke", cv("--line"));
229 });
230 var xy = pts.map(function (s) { return Object.assign({ xy: proj([s.lon, s.lat]) }, s); }).filter(function (s) { return s.xy; });
231 var line = route.append("path").attr("d", d3.line()(xy.map(function (p) { return p.xy; })))
232 .attr("fill", "none").attr("stroke", cv("--g")).attr("stroke-width", 1.5).attr("stroke-dasharray", "4 3");
233 var g = route.selectAll("g").data(xy).join("g").attr("transform", function (d) { return "translate(" + d.xy + ")"; })
234 .style("cursor", "pointer").on("click", function (e, d) { selectStop(stops.indexOf(stops.filter(function (s) { return s.ymd === d.ymd; })[0])); });
235 g.append("circle").attr("r", function (d) { return d.legendary ? 7 : 4; })
236 .attr("fill", function (d) { return d.legendary ? cv("--amber") : d.finale ? cv("--accent") : cv("--g"); });
237 // Labels on the right half point left, so none run off the edge.
238 g.append("text").text(function (d) { return d.city.split(",")[0]; })
239 .attr("x", function (d) { return d.xy[0] > W * 0.62 ? -8 : 8; }).attr("y", 4)
240 .attr("text-anchor", function (d) { return d.xy[0] > W * 0.62 ? "end" : "start"; })
241 .attr("fill", cv("--txt")).attr("font-family", "VT323").attr("font-size", 15);
242
243 // Zoom and pan. The land scales; the route, dots and names are re-placed at
244 // their zoomed positions instead, so they stay the same size and spread out.
245 // A plain scroll still scrolls the page: the wheel zooms only with ctrl/cmd.
246 function placeRoute(t) {
247 line.attr("d", d3.line()(xy.map(function (p) { return t.apply(p.xy); })));
248 g.attr("transform", function (d) { return "translate(" + t.apply(d.xy) + ")"; });
249 g.select("text")
250 .attr("x", function (d) { return t.applyX(d.xy[0]) > W * 0.62 ? -8 : 8; })
251 .attr("text-anchor", function (d) { return t.applyX(d.xy[0]) > W * 0.62 ? "end" : "start"; });
252 }
253 mapZoom = d3.zoom().scaleExtent([1, 16])
254 .translateExtent([[-W * 0.25, -H * 0.25], [W * 1.25, H * 1.25]])
255 // On touch screens one finger still scrolls the page; two fingers pinch and move the map.
256 .filter(function (e) {
257 if (e.type === "wheel") return e.ctrlKey || e.metaKey;
258 if (e.type === "touchstart") return e.touches.length > 1;
259 return !e.button;
260 })
261 .on("zoom", function (e) { land.attr("transform", e.transform); placeRoute(e.transform); });
262 svg.call(mapZoom); // double-click zooms in on that spot
263 mapSvg = svg;
264 }
265 var mapZoom = null, mapSvg = null;
266 function zoomBy(k) { if (mapZoom) mapSvg.transition().duration(250).call(mapZoom.scaleBy, k); }
267 $("zoomIn").onclick = function () { zoomBy(1.6); };
268 $("zoomOut").onclick = function () { zoomBy(1 / 1.6); };
269 $("zoomFit").onclick = function () { if (mapZoom) mapSvg.transition().duration(300).call(mapZoom.transform, d3.zoomIdentity); };
270
271
272 // ------------------------------------------------------------ listening
273 function selectStop(i) {
274 var s = st.tour && st.tour.stops[i]; if (!s) return;
275 st.stopIdx = i;
276 document.querySelectorAll(".stop").forEach(function (r) { r.classList.toggle("on", +r.dataset.i === i); });
277 loadShow(s.identifier, s.date + " · " + s.city.split(",")[0], s.venue + (s.legendary ? " · the legendary night" : s.finale ? " · the finale" : ""));
278 }
279 function loadShow(identifier, title, venueLine) {
280 $("stopTitle").textContent = title;
281 $("stopVenue").textContent = venueLine;
282 $("setlist").innerHTML = '<div class="empty">Pulling the tape...</div>';
283 $("credit").textContent = "";
284 api("/api/show/" + encodeURIComponent(identifier)).then(function (d) {
285 st.show = d;
286 var el = $("setlist"); el.innerHTML = "";
287 if (!d.tracks.length) el.innerHTML = '<div class="empty">This tape has no streamable songs. Open it on the archive below.</div>';
288 d.tracks.forEach(function (t, k) {
289 var b = document.createElement("button");
290 b.className = "song"; b.dataset.k = k;
291 b.innerHTML = "<span>" + esc(t.title) + '</span><span class="muted">' + esc(t.length || "") + "</span>";
292 b.onclick = function () { playTrack(k); };
293 el.appendChild(b);
294 });
295 var who = [d.taper && "taped by " + d.taper, d.transferer && "transferred by " + d.transferer].filter(Boolean).join(", ");
296 $("credit").innerHTML = (who ? "Thanks to the tapers: " + esc(who) + ". " : "Recorded and shared by a fan taper. ") +
297 '<a href="' + esc(d.details) + '" target="_blank" rel="noopener">This tape on archive.org ↗</a>';
298 }).catch(function (e) { $("setlist").innerHTML = '<div class="empty">' + esc(e.message) + "</div>"; });
299 }
300 // The site's one music player (player.js). The game's van stereo uses this same
301 // player when the game is open in the page, so a song never restarts.
302 var audio = $("audio");
303 var player = window.TTPlayer = window.TTPlayerCreate(audio);
304 function playTrack(k) { if (st.show) player.playShow(st.show, k); }
305 player.onChange(function (p) {
306 var mine = p.show && st.show && p.show.identifier === st.show.identifier;
307 document.querySelectorAll(".song").forEach(function (b) { b.classList.toggle("on", !!mine && +b.dataset.k === p.idx); });
308 var np = $("np");
309 if (p.blocked) {
310 np.innerHTML = '<button type="button" class="resume" id="resumeBtn">▶ Keep listening</button> ' + esc(p.track ? p.track.title : "");
311 $("resumeBtn").onclick = function () { player.toggle(); };
312 } else if (p.loading) np.textContent = "Threading the tape...";
313 else if (p.error) np.textContent = p.error;
314 else if (p.track) np.textContent = "♫ " + p.track.title + " · " + p.show.date + " " + (p.show.venue || "");
315 });
316
317 // ------------------------------------------------------------ game DNA
318 function positions(r) { return Math.round((r.max - r.min) / r.step) + 1; }
319 function snap(r, v) { var i = Math.round((Math.min(Math.max(+v, r.min), r.max) - r.min) / r.step); return +(r.min + i * r.step).toFixed(4); }
320 function encode(values) {
321 var norm = {}; st.rules.forEach(function (r) { norm[r.id] = snap(r, values[r.id] != null ? values[r.id] : r.def); });
322 var names = Object.keys(st.presets);
323 for (var p = 0; p < names.length; p++) {
324 var pv = st.presets[names[p]].values;
325 if (st.rules.every(function (r) { return pv[r.id] === norm[r.id]; })) return names[p];
326 }
327 var n = 0;
328 st.rules.forEach(function (r) { n = n * positions(r) + Math.round((norm[r.id] - r.min) / r.step); });
329 return "X" + n.toString(36).toUpperCase();
330 }
331 function decode(code) {
332 var vals = {}; st.rules.forEach(function (r) { vals[r.id] = r.def; });
333 if (!code || code === "CLASSIC") return vals;
334 if (st.presets[code]) return Object.assign(vals, st.presets[code].values);
335 if (!/^X[0-9A-Z]+$/i.test(code)) return vals;
336 var n = parseInt(code.slice(1), 36);
337 for (var i = st.rules.length - 1; i >= 0; i--) { var r = st.rules[i], p = positions(r); vals[r.id] = snap(r, r.min + (n % p) * r.step); n = Math.floor(n / p); }
338 return vals;
339 }
340 function fullTag() { return st.tag ? st.tag + (st.code === "CLASSIC" ? "" : st.code) : ""; }
341 function setupDNA(d, code) {
342 st.rules = d.rules; st.presets = d.presets;
343 st.values = decode(code); st.code = encode(st.values);
344 var pEl = $("presets");
345 Object.keys(d.presets).forEach(function (k) {
346 var b = document.createElement("button"); b.className = "preset"; b.dataset.k = k;
347 b.textContent = d.presets[k].label + (k === "CLASSIC" ? " (as built)" : "");
348 b.onclick = function () { st.values = decode(k); rulesChanged(); };
349 pEl.appendChild(b);
350 });
351 var sEl = $("sliders");
352 d.rules.forEach(function (r) {
353 var w = document.createElement("div"); w.className = "sl" + (r.isNew ? " new" : "");
354 w.innerHTML = '<label for="r-' + r.id + '">' + esc(r.name) + (r.isNew ? " (new rule)" : "") + ' <b id="v-' + r.id + '"></b></label>' +
355 '<input type="range" id="r-' + r.id + '" min="' + r.min + '" max="' + r.max + '" step="' + r.step + '"><div class="hint">' + esc(r.hint) + "</div>";
356 sEl.appendChild(w);
357 w.querySelector("input").addEventListener("input", function (e) { st.values[r.id] = +e.target.value; rulesChanged(); });
358 });
359 if (st.code !== "CLASSIC" && !st.presets[st.code]) $("dnaMore").open = true;
360 paintDNA();
361 }
362 function paintDNA() {
363 st.rules.forEach(function (r) {
364 var v = st.values[r.id];
365 $("r-" + r.id).value = v;
366 $("v-" + r.id).textContent = r.id === "code" && !v ? "off" : v + r.unit;
367 });
368 document.querySelectorAll(".preset").forEach(function (b) { b.classList.toggle("on", b.dataset.k === st.code); });
369 $("publish").hidden = !!st.presets[st.code] || !!st.gallery[st.code];
370 $("dnaLabel").textContent = st.presets[st.code] ? st.presets[st.code].label : st.gallery[st.code] || "custom " + st.code;
371 $("strand").innerHTML = st.rules.map(function (r) {
372 var t = (st.values[r.id] - r.min) / (r.max - r.min);
373 return '<span title="' + esc(r.name) + '" style="background:hsl(' + (140 - t * 140) + ',70%,' + (25 + t * 30) + '%)"></span>';
374 }).join("");
375 }
376 var boardTimer = null;
377 function rulesChanged() {
378 st.code = encode(st.values);
379 paintDNA(); syncTag();
380 clearTimeout(boardTimer);
381 boardTimer = setTimeout(function () { if (st.tag) api("/api/board/" + encodeURIComponent(fullTag())).then(renderBoard).catch(function () {}); }, 250);
382 }
383
384 // ------------------------------------------------------------ rule-set gallery
385 function loadGallery() {
386 api("/api/rulesets").then(function (d) {
387 if (!d.rulesets.length) return;
388 var el = $("gallery"); el.innerHTML = "";
389 d.rulesets.forEach(function (r) {
390 st.gallery[r.code] = r.name;
391 var b = document.createElement("button"); b.className = "preset"; b.dataset.k = r.code;
392 b.title = "by " + r.by + " · played " + r.plays + " time" + (r.plays === 1 ? "" : "s");
393 b.textContent = r.name + " (" + r.plays + ")";
394 b.onclick = function () { st.values = decode(r.code); $("dnaMore").open = true; rulesChanged(); };
395 el.appendChild(b);
396 });
397 paintDNA();
398 }).catch(function () {});
399 }
400 $("rulesPublish").addEventListener("click", function () {
401 $("publishMsg").textContent = "Publishing...";
402 api("/api/rulesets", { method: "POST", headers: { "Content-Type": "application/json" },
403 body: JSON.stringify({ values: st.values, name: $("rulesName").value, by: myName() }) })
404 .then(function (r) { $("publishMsg").textContent = "Published as \u201c" + r.name + "\u201d. Share the tour and it travels with the link."; loadGallery(); syncTag(); })
405 .catch(function (e) { $("publishMsg").textContent = e.message; });
406 });
407
408 // ------------------------------------------------------------ my tapes
409 function renderTapes() {
410 var tapes = [];
411 try { tapes = JSON.parse(localStorage.getItem("tt_tapes") || "[]"); } catch (e) {}
412 var el = $("tapes"); el.innerHTML = "";
413 if (!tapes.length) { el.innerHTML = '<div class="empty">No tapes yet. Tape a show in the game and it shows up here.</div>'; return; }
414 tapes.forEach(function (t) {
415 var b = document.createElement("button"); b.className = "tape";
416 b.innerHTML = "<b>" + esc(t.date) + " " + esc(t.year) + "</b> " + esc(t.band) + '<br><span class="muted">' + esc(t.venue) + ", " + esc(t.city) +
417 (t.legendary ? " · legendary catch" : "") + "</span>";
418 b.onclick = function () { loadShow(t.identifier, t.date + " " + t.year + " · " + String(t.city).split(",")[0], t.band + " · " + t.venue); $("listenCard").scrollIntoView({ behavior: "smooth" }); };
419 el.appendChild(b);
420 });
421 }
422
423 // ------------------------------------------------------------ tour of the day
424 // ------------------------------------------------------------ first-visit welcome
425 // Shown once. Players of the old Terrapin Trail II (it left its saves, badges and
426 // Hall in this browser under terrapin_* names) are told what changed and offered
427 // the classic Dead game straight away; new visitors get a one-line how-to.
428 // Not shown to someone who arrived on a shared tour link: they came for that tour.
429 var OLD_GAME_KEYS = ["terrapin_achievements_v1", "terrapin_save_v1", "terrapin_last_setup_v1", "terrapin_hall_v1"];
430 function showWelcome() {
431 var seen = false, returning = false;
432 try {
433 seen = !!localStorage.getItem("tt_welcomed");
434 returning = OLD_GAME_KEYS.some(function (k) { return localStorage.getItem(k); });
435 } catch (e) { return; }
436 if (seen || BOOT.tag || new URLSearchParams(location.search).get("tag")) return;
437 $("welcomeHead").textContent = returning ? "Welcome back to the trail" : "Welcome to Terrapin Trail";
438 $("welcomeText").textContent = returning
439 ? "Terrapin Trail II grew up. The ten classic Grateful Dead tours are all still here, and now you can drive any band's tour " +
440 "from the Live Music Archive, with the real recordings playing in the van. Your saved trip, badges and the Hall of the Fallen came with you."
441 : "Pick any band on the Live Music Archive, pick a year and a tour, and drive it: tape the shows, work the lot, keep the van alive. " +
442 "The real recordings play in the van. Or start with the classic: ten Grateful Dead tours, 1970 to 1979.";
443 $("welcome").hidden = false;
444 }
445 function closeWelcome() {
446 try { localStorage.setItem("tt_welcomed", "1"); } catch (e) {}
447 $("welcome").hidden = true;
448 }
449 $("welcomeX").onclick = closeWelcome;
450 $("welcomeClassic").addEventListener("click", closeWelcome);
451 $("welcomePick").onclick = function () { closeWelcome(); $("bandSearch").focus(); $("bandSearch").scrollIntoView({ block: "center" }); };
452
453 function loadWishes() {
454 api("/api/wishlist").then(function (d) {
455 var el = $("wishes"), items = d.items || [];
456 el.innerHTML = items.length ? "" : '<li class="empty">Nothing on the list yet. Send the first idea.</li>';
457 items.slice(0, 12).forEach(function (w) {
458 var li = document.createElement("li");
459 li.innerHTML = '<span class="wstat ' + w.status + '">' + (w.status === "shipped" ? "IN" : "PLANNED") + "</span> " + esc(w.title);
460 el.appendChild(li);
461 });
462 }).catch(function () { $("wishes").innerHTML = '<li class="empty">The list is resting. Ideas still reach the maker.</li>'; });
463 }
464 function loadDaily() {
465 api("/api/daily").then(function (d) {
466 $("dailyCard").hidden = false;
467 $("dailyDate").textContent = "· " + d.date;
468 var when = d.night.slice(5) === d.date.slice(5) ? "On this day in " + d.year : "Near this day in " + d.year;
469 $("dailyText").innerHTML = "<b class=\"shift\">#" + esc(d.fullTag) + "</b> · " + esc(d.band) + " · " + esc(d.rules) + " rules<br>" +
470 '<span class="muted">' + when + ": " + esc(d.venue) + ", " + esc(d.city) + ". Everyone plays the same tour today, on one leaderboard.</span>";
471 var q = new URLSearchParams({ tag: d.tag }); if (d.dna !== "CLASSIC") q.set("dna", d.dna);
472 $("dailyPlay").href = "/play?" + q.toString();
473 $("dailyOpen").onclick = function () { st.values = decode(d.dna); rulesChanged(); selectBand(d.collection, d.year, null, d.tag); };
474 }).catch(function () {});
475 }
476
477 // ------------------------------------------------------------ sharing
478 var SHARES = [
479 { id: "copy", label: "Copy link", medium: "link" },
480 { id: "x", label: "X", medium: "social" },
481 { id: "reddit", label: "Reddit", medium: "social" },
482 { id: "facebook", label: "Facebook", medium: "social" },
483 { id: "instagram", label: "Instagram", medium: "social" },
484 { id: "sms", label: "Text message", medium: "text" },
485 { id: "email", label: "Email", medium: "email" },
486 ];
487 function myName() { return ($("myName").value || "").trim().replace(/[^A-Za-z0-9 _.'-]/g, "").slice(0, 24); }
488 function shareUrl(src, medium, extra) {
489 var q = new URLSearchParams();
490 if (st.code !== "CLASSIC") q.set("dna", st.code);
491 Object.keys(extra || {}).forEach(function (k) { q.set(k, extra[k]); });
492 q.set("utm_source", src); q.set("utm_medium", medium); q.set("utm_campaign", fullTag());
493 if (myName()) q.set("utm_content", myName());
494 return location.origin + "/t/" + st.tag + "?" + q.toString();
495 }
496 function postText() {
497 var t = st.tour; if (!t) return "";
498 return "Can you survive the " + t.band + " " + t.year + " tour? Real shows from the Internet Archive. #" + fullTag();
499 }
500 function copy(text) {
501 if (navigator.clipboard) return navigator.clipboard.writeText(text);
502 var ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select();
503 try { document.execCommand("copy"); } catch (e) {} ta.remove(); return Promise.resolve();
504 }
505 function doShare(s) {
506 var url = shareUrl(s.id, s.medium), text = postText();
507 if (s.id === "x") window.open("https://twitter.com/intent/tweet?text=" + encodeURIComponent(text) + "&url=" + encodeURIComponent(url), "_blank", "noopener");
508 else if (s.id === "reddit") window.open("https://www.reddit.com/submit?url=" + encodeURIComponent(url) + "&title=" + encodeURIComponent(text), "_blank", "noopener");
509 else if (s.id === "facebook") window.open("https://www.facebook.com/sharer/sharer.php?u=" + encodeURIComponent(url), "_blank", "noopener");
510 else if (s.id === "sms") location.href = "sms:?&body=" + encodeURIComponent(text + " " + url);
511 else if (s.id === "email") location.href = "mailto:?subject=" + encodeURIComponent("#" + fullTag()) + "&body=" + encodeURIComponent(text + "\n\n" + url);
512 else copy(s.id === "instagram" ? text + " " + url : url).then(function () {
513 flash(s.id === "instagram" ? "Post text and link copied. Paste them into your Instagram story or caption." : "Link copied.");
514 setTimeout(function () { flash(""); }, 3000);
515 });
516 $("shareLink").textContent = url;
517 }
518 function renderShares() {
519 var el = $("shares"); el.innerHTML = "";
520 SHARES.forEach(function (s) {
521 var b = document.createElement("button"); b.className = "share"; b.textContent = s.label;
522 b.onclick = function () { doShare(s); };
523 el.appendChild(b);
524 });
525 }
526 try { $("myName").value = localStorage.getItem("tt_name") || ""; } catch (e) {}
527 $("myName").addEventListener("input", function () {
528 try { localStorage.setItem("tt_name", myName()); } catch (e) {}
529 syncTag();
530 });
531
532 function syncTag() {
533 if (!st.tag) return;
534 var ft = "#" + fullTag();
535 $("tagHero").textContent = ft; $("boardTag").textContent = ft;
536 $("shareLink").textContent = shareUrl("copy", "link");
537 var q = new URLSearchParams({ tag: st.tag });
538 if (st.code !== "CLASSIC") q.set("dna", st.code);
539 if (st.luck) q.set("luck", st.luck);
540 $("playBtn").href = "/play?" + q.toString();
541 var u = new URL(location.href);
542 if (!/^\/t\//.test(u.pathname)) {
543 u.search = ""; u.searchParams.set("tag", st.tag);
544 if (st.code !== "CLASSIC") u.searchParams.set("dna", st.code);
545 history.replaceState(null, "", u.pathname + "?" + u.searchParams.toString());
546 }
547 document.title = ft + " · Terrapin Trail";
548 }
549
550 // ------------------------------------------------------------ leaderboard
551 function renderBoard(b) {
552 st.board = b;
553 $("boardRules").textContent = b.rules + " rules · " + b.players + " player" + (b.players === 1 ? "" : "s");
554 var rows = b.top.map(function (e, i) {
555 var q = new URLSearchParams({ tag: st.tag, luck: e.seed });
556 if (st.code !== "CLASSIC") q.set("dna", st.code);
557 return "<tr><td>" + (i + 1) + "</td><td>" + esc(e.name) + '</td><td class="muted">' + esc(e.from) +
558 '</td><td class="n">' + Number(e.score).toLocaleString() + '</td><td><a class="luck" title="Play this tour with the same luck this player had" href="/guide/play?' +
559 q.toString() + '">same luck ▶</a></td></tr>';
560 }).join("");
561 $("board").innerHTML = rows || '<tr><td colspan="5" class="empty">No scores yet. Be the first on #' + esc(b.tag) + ".</td></tr>";
562 var src = Object.keys(b.sources).map(function (k) { return [k, b.sources[k]]; }).sort(function (a, c) { return c[1] - a[1]; });
563 var max = Math.max.apply(null, src.map(function (s) { return s[1]; }).concat([1]));
564 $("sources").innerHTML = src.length ? src.map(function (s) {
565 return '<div class="row"><span class="bar" style="width:' + Math.max(4, s[1] / max * 120) + 'px"></span><span>' + esc(s[0]) +
566 '</span><span class="muted">' + s[1] + " visit" + (s[1] === 1 ? "" : "s") + "</span></div>";
567 }).join("") : '<div class="empty">Nobody has opened a shared link yet.</div>';
568 }
569
570 // ------------------------------------------------------------ playing inside the page (desktop)
571 // On a desktop-sized screen the game opens in the middle column, next to the band
572 // list and the leaderboard. Phones and narrow windows open the game on its own page.
573 var DESKTOP = window.matchMedia("(min-width: 1024px) and (pointer: fine)");
574 function openGame(href) {
575 var url = new URL(href, location.origin);
576 url.searchParams.set("embed", "1");
577 document.body.classList.add("gaming"); // the game's own stereo takes over the controls
578 $("gameFrame").src = url.pathname + url.search;
579 var pop = new URL(href, location.origin); pop.searchParams.delete("embed");
580 $("gamePop").href = pop.pathname + pop.search;
581 $("gameTag").textContent = url.searchParams.get("tag") ? "#" + url.searchParams.get("tag") + (url.searchParams.get("dna") && url.searchParams.get("dna") !== "CLASSIC" ? url.searchParams.get("dna") : "") : "Classic: Grateful Dead 1970-79";
582 $("browseArea").hidden = true; $("gamePanel").hidden = false;
583 window.scrollTo(0, 0);
584 $("gameFrame").focus();
585 }
586 function closeGame() {
587 if (document.fullscreenElement) document.exitFullscreen().catch(function () {});
588 document.body.classList.remove("gaming");
589 $("gameFrame").src = "about:blank";
590 $("gamePanel").hidden = true; $("browseArea").hidden = false;
591 if (st.tour) drawMap(st.tour.stops);
592 renderTapes();
593 }
594 $("gameClose").onclick = closeGame;
595 // Full screen grows the game in place (no new page), so the music carries on.
596 $("gamePop").addEventListener("click", function (e) {
597 var panel = $("gamePanel");
598 if (!panel.requestFullscreen) return; // no fullscreen support: fall back to opening it in a tab
599 e.preventDefault();
600 if (document.fullscreenElement) document.exitFullscreen(); else panel.requestFullscreen().catch(function () {});
601 });
602 document.addEventListener("fullscreenchange", function () {
603 $("gamePop").innerHTML = document.fullscreenElement ? "Exit full screen" : "Full screen ↗";
604 });
605 document.addEventListener("click", function (e) {
606 var a = e.target.closest && e.target.closest('a[href^="/play"]');
607 if (!a || !DESKTOP.matches || e.metaKey || e.ctrlKey || e.shiftKey) return;
608 e.preventDefault();
609 openGame(a.getAttribute("href"));
610 });
611 // The embedded game reports posted scores and closing.
612 window.addEventListener("message", function (e) {
613 if (e.origin !== location.origin || !e.data) return;
614 if (e.data.type === "tt:board" && e.data.board && st.tag && e.data.board.base === st.tag) renderBoard(e.data.board);
615 if (e.data.type === "tt:close") closeGame();
616 });
617
618 // ------------------------------------------------------------ color scheme
619 // Stored in the game's own options, so a pick here is the game's color too.
620 var THEMES = [["green", "#3bf07a"], ["amber", "#ffc24d"], ["cyan", "#57e6ff"], ["white", "#e9f2e9"]];
621 function gameOpts() { try { return JSON.parse(localStorage.getItem("terrapin_opts") || "{}") || {}; } catch (e) { return {}; } }
622 function applyTheme(t) {
623 THEMES.forEach(function (x) { document.body.classList.toggle("theme-" + x[0], x[0] === t && t !== "green"); });
624 document.querySelectorAll(".swatch").forEach(function (b) { b.classList.toggle("on", b.dataset.t === t); });
625 if (st.tour) drawMap(st.tour.stops);
626 }
627 function setTheme(t) {
628 var o = gameOpts(); o.theme = t;
629 try { localStorage.setItem("terrapin_opts", JSON.stringify(o)); } catch (e) {}
630 applyTheme(t);
631 var f = $("gameFrame");
632 try { if (f.contentWindow && f.contentWindow.TTGame) f.contentWindow.TTGame.setTheme(t); } catch (e) {}
633 }
634 THEMES.forEach(function (x) {
635 var b = document.createElement("button"); b.type = "button"; b.className = "swatch"; b.dataset.t = x[0];
636 b.style.background = x[1]; b.title = x[0].charAt(0).toUpperCase() + x[0].slice(1) + " screen"; b.setAttribute("aria-label", b.title);
637 b.onclick = function () { setTheme(x[0]); };
638 $("themes").appendChild(b);
639 });
640 applyTheme(gameOpts().theme || "green");
641 window.addEventListener("storage", function (e) { if (e.key === "terrapin_opts") applyTheme(gameOpts().theme || "green"); });
642
643 // ------------------------------------------------------------ "how this works" links
644 // Each panel links to the guide chapter that explains it, in a new tab so a run
645 // is never interrupted. The beginners' edition by default; the chapter links to
646 // the coders' edition from there.
647 function guideEdition() { try { return localStorage.getItem("tt_guide_edition") || "primer"; } catch (e) { return "primer"; } }
648 document.querySelectorAll("a.how").forEach(function (a) {
649 a.href = "/guide/" + guideEdition() + "/" + a.dataset.ch;
650 a.target = "_blank"; a.rel = "noopener";
651 a.title = "Opens the guide chapter on this, in a new tab";
652 });
653
654 // ------------------------------------------------------------ boot
655 renderShares();
656 if (BOOT.donate) document.querySelectorAll("a.donate,#donateTop").forEach(function (a) { a.href = BOOT.donate; });
657 var params = new URLSearchParams(location.search);
658 var startTag = BOOT.tag || params.get("tag");
659 var startDna = BOOT.dna || params.get("dna") || "CLASSIC";
660 if (BOOT.by && startTag) {
661 var ch = $("challenge"); ch.hidden = false;
662 ch.innerHTML = esc(BOOT.by) + " sent you #" + esc(startTag + (startDna === "CLASSIC" ? "" : startDna)) +
663 (BOOT.luck ? ", with the same luck they had." : ".") + " Hit play to take it on.";
664 }
665 loadBands();
666 player.resume(); // back from the game on its own page: carry on from the same second
667 // The tagline's numbers come from the archive itself and keep up on their own.
668 api("/api/stats").then(function (s) {
669 $("statBands").textContent = s.bands.toLocaleString();
670 $("statTapes").textContent = s.tapes.toLocaleString();
671 $("statYears").textContent = s.years;
672 $("statYears").title = "Since the oldest show on the archive, in " + s.firstYear;
673 }).catch(function () {});
674 renderTapes();
675 window.addEventListener("storage", function (e) { if (e.key === "tt_tapes") renderTapes(); });
676 window.addEventListener("focus", renderTapes);
677 loadDaily();
678 loadWishes();
679 showWelcome();
680 api("/api/dna").then(function (d) {
681 setupDNA(d, startDna);
682 loadGallery();
683 if (startTag) {
684 return api("/api/tour/" + encodeURIComponent(startTag)).then(function (t) {
685 st.code = encode(st.values);
686 return selectBand(t.info.collection, t.info.year, null, t.tour.tag);
687 }).catch(function (e) { flash(e.message); selectBand("GratefulDead", 1977, "1977-05-08"); });
688 }
689 return selectBand("GratefulDead", 1977, "1977-05-08");
690 });
691 window.addEventListener("resize", function () { if (st.tour) drawMap(st.tour.stops); });
692})();
693