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