panel.js (14.3 KB)
1 // Right panel: title/body/RSVP text, its color-shift + character-by-character typing, and 2 // which event is selected. map.js calls selectEvent / deselectEvent on taps; nav.js reads 3 // window.getActiveEvent and draws the location->event line via window.navLine. 4 5 (function () { 6 var fmt = window.fmt; 7 8 var intro = { 9 color: "antiquewhite", 10 title: "Upcoming Events" 11 }; 12 // flipped on by setIntroData when there are no upcoming events: the intro lists the 13 // most-recently-passed events under a "Past Events" heading instead. 14 var pastMode = false; 15 16 var rightBox = document.getElementById("right-box"); 17 var themeColorMeta = document.querySelector('meta[name="theme-color"]'); 18 var titleEl = document.getElementById("text-title"); 19 var contentEl = document.getElementById("text-content"); 20 var actionWrap = document.getElementById("action-wrap"); 21 var swapTimer = null; 22 var linkTimer = null; 23 var typeRaf = null; 24 var currentEvent = null; 25 var activePin = null; // .event-pin element of the selected marker (square -> circle) 26 var introEvents = []; // upcoming events for the intro table, soonest first 27 var introDataReady = false; // true once map.js hands over the events (post-fetch) 28 var selectFromIntro = null; // map.js's selectMarker, so table rows can activate an event 29 var introListEl = null; // the current intro grid, so fitIntroColumns can re-measure on resize 30 31 function introTitle() { return pastMode ? "Past Events" : intro.title; } 32 33 var CHAR_MS = readMs("--type-char-ms"); 34 35 function readMs(name) { 36 var raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); 37 var ms = parseFloat(raw); 38 return /s$/.test(raw) && !/ms$/.test(raw) ? ms * 1000 : ms; 39 } 40 41 function clearTyping() { 42 if (typeRaf !== null) { cancelAnimationFrame(typeRaf); typeRaf = null; } 43 } 44 45 // Lay the full text in up front, split into a "shown" span and a trailing "hidden" 46 // (visibility:hidden) span. Layout/wrapping/height are final from frame one, so typing 47 // only moves the boundary 48 function prepTyped(el, text) { 49 el.textContent = ""; 50 var shown = document.createElement("span"); 51 var hidden = document.createElement("span"); 52 hidden.className = "type-hidden"; 53 hidden.textContent = text; 54 el.appendChild(shown); 55 el.appendChild(hidden); 56 return { shown: shown, hidden: hidden, text: text }; 57 } 58 59 function typeStream(segments) { 60 var prepped = segments.map(function (seg) { 61 // flat segments (RSVP labels) already reserve width via a CSS sizer; flowing 62 // body text gets the split-span treatment to pre-reserve wrapping + height 63 var p = seg.flat 64 ? { shown: seg.el, hidden: null, text: seg.text } 65 : prepTyped(seg.el, seg.text); 66 // list marker stays hidden until this segment's first char reveals 67 p.markerEl = seg.markerEl || null; 68 return p; 69 }); 70 // total time scales with text length; progress through it is smoothstep-eased. 71 // rAF-driven (one reflow/frame) so CHAR_MS can sit below the ~1ms timer floor. 72 var total = prepped.reduce(function (n, s) { return n + s.text.length; }, 0); 73 var duration = total * CHAR_MS; 74 var start = null; 75 function frame(now) { 76 if (start === null) { start = now; } 77 var t = duration > 0 ? Math.min(1, (now - start) / duration) : 1; 78 var eased = t * t * (3 - 2 * t); // smoothstep: zero velocity at both ends 79 var revealed = Math.min(total, Math.round(eased * total)); 80 var n = revealed; 81 prepped.forEach(function (s) { 82 var len = s.text.length; 83 var c = n <= 0 ? 0 : (n >= len ? len : n); 84 s.shown.textContent = s.text.slice(0, c); 85 if (s.hidden) { s.hidden.textContent = s.text.slice(c); } 86 if (s.markerEl && c > 0) { s.markerEl.classList.add("li-typing"); } 87 n -= len; 88 }); 89 typeRaf = revealed < total ? requestAnimationFrame(frame) : null; 90 } 91 typeRaf = requestAnimationFrame(frame); 92 return duration; 93 } 94 95 // Build the event's .ics in memory and trigger a download. On iOS, tapping the file 96 // offers "Add to Calendar"; desktop drops it in Downloads. 97 function downloadICS(m) { 98 if (!m) { return; } 99 var blob = new Blob([fmt.icsContent(m)], { type: "text/calendar;charset=utf-8" }); 100 var url = URL.createObjectURL(blob); 101 var a = document.createElement("a"); 102 a.href = url; 103 a.download = fmt.icsFilename(m); 104 document.body.appendChild(a); 105 a.click(); 106 document.body.removeChild(a); 107 setTimeout(function () { URL.revokeObjectURL(url); }, 0); 108 } 109 110 var canShare = typeof navigator.share === "function"; 111 112 // "Copy Link" -> "Copied Link!" feedback: swap instantly, hold, swap back 113 function flashCopied(a) { 114 a.textContent = "Copied Link!"; 115 a._copyTimer = setTimeout(function () { 116 a.textContent = "Copy Link"; 117 a._copyTimer = null; 118 }, 3000); 119 } 120 121 var LINKS = [ 122 { text: "RSVP by E-mail", onClick: function (_a, m) { window.location.href = fmt.mailtoHref(m); } }, 123 { text: "Add to Calendar", onClick: function (a, m) { downloadICS(m); } }, 124 // maps:// via location.href: the OS intercepts the scheme and the page stays put, 125 // so no orphaned about:blank tab. https links still get a real tab. 126 { text: "Get Directions", onClick: function (a, m) { 127 var href = fmt.directionsHref(m); 128 if (href.indexOf("maps:") === 0) { window.location.href = href; } 129 else { window.open(href, "_blank"); } 130 } }, 131 { text: canShare ? "Share Link" : "Copy Link", onClick: function (a, m) { 132 var url = fmt.eventUrl(m); 133 if (canShare) { 134 navigator.share({ url: url }).catch(function () {}); 135 } else if (navigator.clipboard && !a._copyTimer) { 136 navigator.clipboard.writeText(url); 137 flashCopied(a); 138 } 139 } }, 140 // on Safari this opens a printable PDF in a new tab instead of the print dialog 141 window.flyer.enabled ? { text: "Print Flyer", onClick: function (a, m) { window.flyer.print(m); } } : null, 142 ].filter(Boolean); 143 144 function buildLinkRows() { 145 actionWrap.innerHTML = ""; 146 return LINKS.map(function (def) { 147 var row = document.createElement("div"); 148 row.className = "action-row"; 149 150 var box = document.createElement("span"); 151 box.className = "action-link action-typing"; 152 153 var sizer = document.createElement("span"); 154 sizer.className = "action-sizer"; 155 sizer.textContent = def.text; 156 157 var shown = document.createElement("span"); 158 shown.className = "action-shown"; 159 160 box.appendChild(sizer); 161 box.appendChild(shown); 162 row.appendChild(box); 163 actionWrap.appendChild(row); 164 165 return { el: shown, row: row, def: def }; 166 }); 167 } 168 169 // Build the detail <ul> inside #text-content and return its <li>s for typing. Each <li> 170 // carries its text on ._text; the marker + hanging indent come from CSS. 171 function buildDetailList(items) { 172 var ul = document.createElement("ul"); 173 ul.className = "detail-list"; 174 contentEl.appendChild(ul); 175 return items.map(function (text) { 176 var li = document.createElement("li"); 177 li._text = text; 178 ul.appendChild(li); 179 return li; 180 }); 181 } 182 183 // Build the intro's upcoming-events table and return the segments to type (one per 184 // cell). Rows are clickable. No events -> a single "No upcoming events." line. A flex 185 // list of <div>s (not a <table>) so columns collapse cleanly. 186 function buildIntroList() { 187 introListEl = null; 188 if (!introEvents.length) { 189 var p = document.createElement("p"); 190 contentEl.appendChild(p); 191 return [{ el: p, text: pastMode ? "No past events." : "No upcoming events." }]; 192 } 193 194 var list = document.createElement("div"); 195 list.className = "intro-list"; 196 contentEl.appendChild(list); 197 introListEl = list; 198 199 var segments = []; 200 introEvents.forEach(function (m) { 201 var row = document.createElement("div"); 202 row.className = "intro-row"; 203 row.addEventListener("click", function () { 204 if (selectFromIntro) { selectFromIntro(m, true); } 205 }); 206 207 // each cell types into a child span so its final width is reserved (prepTyped) 208 [ 209 { text: m.title, cls: "intro-name" }, 210 { text: fmt.shortDate(m, pastMode), cls: "intro-when" }, 211 { text: m.venue, cls: "intro-where" } 212 ].forEach(function (col) { 213 var cell = document.createElement("div"); 214 cell.className = col.cls; 215 var span = document.createElement("span"); 216 cell.appendChild(span); 217 row.appendChild(cell); 218 segments.push({ el: span, text: col.text }); 219 }); 220 221 list.appendChild(row); 222 }); 223 224 return segments; 225 } 226 227 // True if any title cell is clipped at the current column layout. With date + location 228 // held rigid, a long title (or a title squeezed by them) ellipsizes here, which is the 229 // signal to shed a column. 230 function titleClipped() { 231 var cells = introListEl.querySelectorAll(".intro-name"); 232 for (var i = 0; i < cells.length; i++) { 233 if (cells[i].scrollWidth - cells[i].clientWidth > 1) { return true; } 234 } 235 return false; 236 } 237 238 // Shed columns by priority until the title fits: drop the date (.cols-2), then the 239 // location (.cols-1). Re-measured on build and on container resize. Reading scrollWidth 240 // forces the reflow between steps. 241 function fitIntroColumns() { 242 if (!introListEl || !introListEl.isConnected) { return; } 243 introListEl.classList.remove("cols-2", "cols-1"); 244 if (titleClipped()) { 245 introListEl.classList.add("cols-2"); 246 if (titleClipped()) { introListEl.classList.add("cols-1"); } 247 } 248 } 249 250 function swapLinks(links) { 251 links.forEach(function (link) { 252 var def = link.def; 253 var a = document.createElement("a"); 254 a.className = "action-link"; 255 a.textContent = def.text; 256 if (def.href) { 257 a.href = def.href(currentEvent); 258 } else { 259 a.href = "#"; 260 a.addEventListener("click", function (e) { 261 e.preventDefault(); 262 def.onClick(a, currentEvent); 263 }); 264 } 265 link.row.replaceChild(a, link.row.firstChild); 266 }); 267 } 268 269 // Round the active pin into a circle (and square the previous one back) via a class. 270 function setActivePin(m) { 271 if (activePin) { activePin.classList.remove("active"); } 272 activePin = null; 273 var icon = m && m._marker && m._marker._icon; 274 var pin = icon && icon.querySelector(".event-pin"); 275 if (pin) { pin.classList.add("active"); activePin = pin; } 276 } 277 278 function selectEvent(m) { 279 if (m === currentEvent) { return; } // already viewing it; don't retype 280 currentEvent = m; 281 setActivePin(m); 282 // draw the location->event line (no-op unless nav mode dropped a ladybug) 283 if (window.navLine) { window.navLine.setTarget(L.latLng(m.latitude, m.longitude), m.color); } 284 transitionTo(m.color, function () { 285 var links = buildLinkRows(); 286 // description types into its own <p>, then each detail <li> as its own segment 287 var para = document.createElement("p"); 288 contentEl.appendChild(para); 289 var details = buildDetailList(fmt.detailItems(m, true)); 290 var segments = [ 291 { el: titleEl, text: m.title }, 292 { el: para, text: m.description } 293 ]; 294 details.forEach(function (li) { 295 segments.push({ el: li, text: li._text, markerEl: li }); 296 }); 297 links.forEach(function (link) { 298 segments.push({ el: link.el, text: link.def.text, flat: true }); 299 }); 300 var duration = typeStream(segments); 301 // once the whole stream finishes, make the links clickable 302 linkTimer = setTimeout(function () { 303 swapLinks(links); 304 }, duration); 305 }); 306 } 307 308 // nav.js asks for this on its first location fix: if an event is already selected, it 309 // draws the line to it immediately. 310 window.getActiveEvent = function () { 311 if (!currentEvent) { return null; } 312 return { latlng: L.latLng(currentEvent.latitude, currentEvent.longitude), color: currentEvent.color }; 313 }; 314 315 // Background tap deselects and types the intro back in. 316 function deselectEvent() { 317 if (currentEvent === null) { return; } // already showing the intro 318 currentEvent = null; 319 setActivePin(null); 320 if (window.navLine) { window.navLine.clear(); } 321 transitionTo(intro.color, function () { 322 var segments = [{ el: titleEl, text: introTitle() }]; 323 typeStream(segments.concat(buildIntroList())); 324 fitIntroColumns(); 325 }); 326 } 327 328 // Drive the panel background and the iOS Safari top/bottom bars off one color so the 329 // bars track the selected event. 330 function setEventColor(color) { 331 // :root so the body strips (overscroll/safe-area) and the panel both pick it up 332 document.documentElement.style.setProperty("--event-color", color); 333 if (themeColorMeta) themeColorMeta.setAttribute("content", color); 334 } 335 336 // Shared transition: shift color, fade the text out, then run `paint` once faded. 337 function transitionTo(color, paint) { 338 clearTimeout(swapTimer); 339 clearTimeout(linkTimer); 340 clearTyping(); 341 342 setEventColor(color); 343 titleEl.classList.add("fading"); 344 contentEl.classList.add("fading"); 345 actionWrap.classList.add("fading"); 346 347 swapTimer = setTimeout(function () { 348 contentEl.textContent = ""; 349 actionWrap.innerHTML = ""; 350 // reset scroll now (old text faded + cleared) so the prior event never flashes back 351 rightBox.scrollTop = 0; 352 353 titleEl.classList.remove("fading"); 354 contentEl.classList.remove("fading"); 355 actionWrap.classList.remove("fading"); 356 357 paint(); 358 }, 125); 359 } 360 361 // Intro fully shown (no typing). used on root page load, where typing is skipped. 362 function paintIntro() { 363 setEventColor(intro.color); 364 titleEl.textContent = introTitle(); 365 contentEl.textContent = ""; 366 actionWrap.innerHTML = ""; 367 if (!introDataReady) { return; } // pre-fetch: title only, no table yet 368 buildIntroList().forEach(function (seg) { seg.el.textContent = seg.text; }); 369 fitIntroColumns(); 370 } 371 372 // map.js hands over the upcoming events (soonest first) + selectMarker, before the 373 // first paintIntro / deselect, so the table can build from real data. 374 function setIntroData(events, selectFn, past) { 375 introEvents = events; 376 selectFromIntro = selectFn; 377 pastMode = !!past; 378 introDataReady = true; 379 } 380 381 // Set the panel color up front (before any text paints) so a deep-linked event shows in 382 // its own color instead of fading in from antiquewhite. Zero the color-shift duration 383 // for this one set so it snaps, then restore. 384 function presetEventColor(m) { 385 var root = document.documentElement; 386 root.style.setProperty("--color-shift-ms", "0ms"); 387 setEventColor(m.color); 388 void root.offsetWidth; // force reflow so the instant set lands before restore 389 root.style.removeProperty("--color-shift-ms"); 390 } 391 392 // Re-fit the intro columns when the panel changes width (rotation, window resize). 393 if (typeof ResizeObserver === "function") { 394 new ResizeObserver(fitIntroColumns).observe(rightBox); 395 } 396 397 window.panel = { 398 selectEvent: selectEvent, 399 deselectEvent: deselectEvent, 400 paintIntro: paintIntro, 401 setIntroData: setIntroData, 402 presetEventColor: presetEventColor, 403 // the event currently shown, or null on the intro (lets flyer.js print on Cmd+P) 404 getSelectedEvent: function () { return currentEvent; } 405 }; 406 })();