]> gitweb.michael.orlitzky.com - charm-bypass.git/blob - index.html.in
ada92ffe5182dd1049599516c308a9de0e23d281
[charm-bypass.git] / index.html.in
1 <!doctype html>
2 <html lang="en-US">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <link rel="icon"
7 type="image/svg+xml"
8 href="data:image/svg+xml;base64,@FAVICON@" />
9
10 <title>
11 CharmBypass: got that transit equity
12 </title>
13
14 <style>
15 body {
16 /* For consistency with the SVG */
17 font-family: sans-serif;
18 }
19
20 fieldset {
21 margin-top: 1em;
22 padding-top: 1em;
23 }
24
25 input[type=submit] {
26 margin-top: 1em;
27 margin-bottom: 1em;
28 }
29
30 legend {
31 font-weight: bold;
32 }
33
34 /* Display form errors for the one browser that doesn't support
35 * them natively, mobile Firefox. We use "visibility" to toggle
36 * it on and off, but "display" to hide it completely in non-
37 * stupid web browsers. */
38 #marc-form-errors {
39 color: #a00;
40 visibility: hidden;
41 display: none;
42 }
43
44 svg {
45 /* Set the height to 100% of the screen, which we'll keep; and the
46 * initial position to (0,0), which we're going to change
47 * every time the window is resized, because the exact amount
48 * that we have to slide the SVG to the left to get the ticket
49 * into the center will change. */
50 position: fixed;
51 top: 0;
52 left: 0;
53 height: 100%;
54
55 /* Hide everything by default. We only show it once the user has
56 * submitted the menu form */
57 display: none;
58 }
59
60 /* The blinking fade in/out animation for the ticket date and time */
61 @keyframes blink {
62 25% { opacity: 0.5; }
63 50% { opacity: 0; }
64 75% { opacity: 0.5; }
65 }
66
67 #tickettime, #ticketdate {
68 /* 300 two-second blinks is ten minutes */
69 animation: blink 2s linear 300;
70 }
71
72 /* Define, load, and specify the custom font we use for the ticket
73 * date, time, and service name. */
74 @font-face {
75 font-family: "CharmBypass Regular";
76 src:
77 url("data:font/woff2;base64,@CBPREGULAR@") format("woff2")
78 }
79
80 #origindest, #servicename, #tickettime, #ticketdate, #codetext, #zone {
81 font-family: "CharmBypass Regular", sans-serif;
82 }
83
84 @font-face {
85 font-family: "CharmBypass Bold";
86 src:
87 url("data:font/woff2;base64,@CBPBOLD@") format("woff2")
88 }
89
90 #serviceid {
91 font-family: "CharmBypass Bold", sans-serif;
92 }
93
94 /************************/
95 /* Scrolling animations */
96 /************************/
97
98 /* Bus */
99 @keyframes busroll {
100 from { transform: translateX(0%); }
101 to { transform: translateX(-100%); }
102 }
103
104 #bus {
105 animation: busroll 15s linear infinite;
106 }
107
108
109 /* Tram */
110 @keyframes tramroll {
111 from { transform: translateX(0%); }
112 to { transform: translateX(100%); }
113 }
114
115 #tram {
116 animation: tramroll 15s linear infinite;
117 }
118
119
120 /* Train */
121 @keyframes trainroll {
122 from { transform: translateX(0%); }
123 to { transform: translateX(100%); }
124 }
125
126 #train {
127 animation: trainroll 10s linear infinite;
128 }
129
130
131 /* Clouds */
132 @keyframes cloudsfloat {
133 from { transform: translateX(0%); }
134 to { transform: translateX(-50%); }
135 }
136
137 #clouds {
138 animation: cloudsfloat 25s linear infinite;
139 }
140
141 @keyframes cloudscopyfloat {
142 from { transform: translateX(0%); }
143 to { transform: translateX(-50%); }
144 }
145
146 #cloudscopy {
147 animation: cloudscopyfloat 25s linear infinite;
148 }
149
150
151 /* Trees */
152 @keyframes treespass {
153 from { transform: translateX(0%); }
154 to { transform: translateX(-50%); }
155 }
156
157 #trees {
158 /* The trees move a little faster than the clouds */
159 animation: treespass 16s linear infinite;
160 }
161
162 @keyframes treescopypass {
163 from { transform: translateX(0%); }
164 to { transform: translateX(-50%); }
165 }
166
167 #treescopy {
168 /* The trees move a little faster than the clouds */
169 animation: treescopypass 16s linear infinite;
170 }
171
172
173 /* City skyline */
174 @keyframes cityscroll {
175 from { transform: translateX(0%); }
176 to { transform: translateX(-50%); }
177 }
178
179 #city {
180 /* The city moves faster than the clouds but slower
181 * than the trees */
182 animation: cityscroll 20s linear infinite;
183 }
184
185 @keyframes citycopyscroll {
186 from { transform: translateX(0%); }
187 to { transform: translateX(-50%); }
188 }
189
190 #citycopy {
191 /* The city moves faster than the clouds but slower
192 * than the trees */
193 animation: citycopyscroll 20s linear infinite;
194 }
195 </style>
196 </head>
197
198 <body>
199 <div id="menu">
200 <h1>CharmBypass</h1>
201 <p><strong>got that <em>transit equity</em></strong></p>
202
203 <ol>
204 <li>
205 <a href="https://michael.orlitzky.com/articles/charmbypass_pt._1%3A_introducing_charmbypass.xhtml">
206 Introduction
207 </a>
208 </li>
209 </ol>
210
211 <form>
212 <fieldset>
213 <legend>Local Bus, Light Rail, or Metro</legend>
214 <div>
215 <label for="code1">
216 Daily security code (optional):
217 </label>
218 <input id="code1"
219 name="code"
220 type="text"
221 size="2"
222 minlength="2"
223 maxlength="2"
224 pattern="[a-zA-Z0-9]*" />
225 </div>
226 <input type="hidden" name="servicename" value="BaltimoreLink" />
227 <input type="submit" name="go" value="Generate Ticket" />
228 </fieldset>
229 </form>
230 <form>
231 <fieldset>
232 <legend>Commuter Bus</legend>
233 <input type="hidden" name="serviceid" value="R" />
234 <input type="hidden" name="servicename" value="Commuter Bus" />
235
236 <div>
237 <label for="code2">
238 Daily security code (optional):
239 </label>
240 <input id="code2"
241 name="code"
242 type="text"
243 size="2"
244 minlength="2"
245 maxlength="2"
246 pattern="[a-zA-Z0-9]*" />
247 </div>
248
249 <p>
250 Zone<sup>&dagger;</sup>:
251 </p>
252 <div>
253 <input type="radio" required
254 name="zone"
255 id="zone1"
256 value="Zone 1" />
257 <label for="zone1">Zone 1</label>
258 </div>
259 <div>
260 <input type="radio" required
261 name="zone"
262 id="zone2"
263 value="Zone 2" />
264 <label for="zone2">Zone 2</label>
265 </div>
266 <div>
267 <input type="radio" required checked
268 name="zone"
269 id="zone3"
270 value="Zone 3" />
271 <label for="zone3">Zone 3</label>
272 </div>
273 <div>
274 <input type="radio" required
275 name="zone"
276 id="zone4"
277 value="Zone 4" />
278 <label for="zone4">Zone 4</label>
279 </div>
280 <div>
281 <input type="radio" required
282 name="zone"
283 id="zone5"
284 value="Zone 5" />
285 <label for="zone5">Zone 5</label>
286 </div>
287
288 <input type="submit" name="go" value="Generate Ticket" />
289
290 <p>
291 <sup>&dagger;</sup>
292 On the MTA's PDF schedule for your route
293 </p>
294 </fieldset>
295 </form>
296 <form>
297 <fieldset>
298 <legend>MARC Train</legend>
299
300 <div>
301 <label for="code3">
302 Daily security code (optional):
303 </label>
304 <input id="code3"
305 name="code"
306 type="text"
307 size="2"
308 minlength="2"
309 maxlength="2"
310 pattern="[a-zA-Z0-9]*" />
311 </div>
312
313 <p>Origin:</p>
314 <div>
315 <input type="radio" required checked
316 name="origin"
317 id="origin1"
318 value="BAL" />
319 <label for="origin1">Baltimore/Penn (Penn Line)</label>
320 </div>
321 <div>
322 <input type="radio" required
323 name="origin"
324 id="origin2"
325 value="BCA" />
326 <label for="origin2">Baltimore/Camden (Camden Line)</label>
327 </div>
328 <div>
329 <input type="radio" required
330 name="origin"
331 id="origin3"
332 value="BWE" />
333 <label for="origin3">Bowie State (Penn Line)</label>
334 </div>
335 <div>
336 <input type="radio" required
337 name="origin"
338 id="origin4"
339 value="BWI" />
340 <label for="origin4">BWI Airport (Penn Line)</label>
341 </div>
342 <div>
343 <input type="radio" required
344 name="origin"
345 id="origin5"
346 value="CPK" />
347 <label for="origin5">College Park (Camden Line)</label>
348 </div>
349 <div>
350 <input type="radio" required
351 name="origin"
352 id="origin6"
353 value="SEB" />
354 <label for="origin6">Seabrook (Penn Line)</label>
355 </div>
356 <div>
357 <input type="radio" required
358 name="origin"
359 id="origin7"
360 value="WAS" />
361 <label for="origin7">Washington D.C.</label>
362 </div>
363 <div>
364 <input type="radio" required
365 name="origin"
366 id="origin8"
367 value="WBL" />
368 <label for="origin8">West Baltimore (Penn Line)</label>
369 </div>
370
371 <p>Destination:</p>
372 <div>
373 <input type="radio" required
374 name="destination"
375 id="destination1"
376 value="BAL" />
377 <label for="destination1">Baltimore/Penn (Penn Line)</label>
378 </div>
379 <div>
380 <input type="radio" required
381 name="destination"
382 id="destination2"
383 value="BCA" />
384 <label for="destination2">Baltimore/Camden (Camden Line)</label>
385 </div>
386 <div>
387 <input type="radio" required
388 name="destination"
389 id="destination3"
390 value="BWE" />
391 <label for="destination3">Bowie State (Penn Line)</label>
392 </div>
393 <div>
394 <input type="radio" required
395 name="destination"
396 id="destination4"
397 value="BWI" />
398 <label for="destination4">BWI Airport (Penn Line)</label>
399 </div>
400 <div>
401 <input type="radio" required
402 name="destination"
403 id="destination5"
404 value="CPK" />
405 <label for="destination5">College Park (Camden Line)</label>
406 </div>
407 <div>
408 <input type="radio" required
409 name="destination"
410 id="destination6"
411 value="SEB" />
412 <label for="destination6">Seabrook (Penn Line)</label>
413 </div>
414 <div>
415 <input type="radio" required checked
416 name="destination"
417 id="destination7"
418 value="WAS" />
419 <label for="destination7">Washington D.C.</label>
420 </div>
421 <div>
422 <input type="radio" required
423 name="destination"
424 id="destination8"
425 value="WBL" />
426 <label for="destination8">West Baltimore (Penn Line)</label>
427 </div>
428
429 <p id="marc-form-errors">OK</p>
430
431 <input type="hidden" name="serviceid" value="R" />
432 <input type="hidden" name="servicename" value="MARC Train" />
433 <input type="submit"
434 name="go"
435 id="marc-submit"
436 value="Generate Ticket" />
437 </fieldset>
438 </form>
439 </div>
440
441 @SVGDATA@
442
443 <script>
444
445 /**
446 * Center the ticket within the browser by translating the SVG
447 * until the ticket and the viewport centerlines coincide.
448 */
449 function center_ticket() {
450 /* We're relying on the SVG being the full height of the
451 * viewport already, and on the aspect ratio being
452 * preserved. First, find the center of the ticket. */
453 const r = document.getElementById("ticket").getBoundingClientRect();
454 const c = r.left + (r.width / 2);
455
456 /* That's the center-line of the ticket. We want to move it to
457 * the center-line of the viewport. */
458 const vc = document.documentElement.clientWidth / 2;
459
460 /* This is how much we need to translate the SVG */
461 const hdelta = vc - c;
462
463 /* But before we can set the absolute left-coordinate of the
464 * SVG, we need to know where it is now. Note: without the
465 * "px" this doesn't default to pixels like CSS does. */
466 const svg = document.querySelector("svg");
467 svg.style.left = (svg.getBoundingClientRect().left + hdelta) + "px";
468 }
469
470
471 /**
472 * Set the service identifier from the querystring if it's there.
473 * Otherwise, leave it at the default of "F".
474 */
475 function set_service_id() {
476 const sid = document.getElementById("serviceid");
477
478 /* Get the "serviceid" from the querystring if it's there */
479 const params = new URLSearchParams(document.location.search);
480 if (params.get("serviceid")) {
481 sid.textContent = params.get("serviceid");
482 }
483 }
484
485
486 /**
487 * Set the service name from the querystring if it's there.
488 * Otherwise, leave it at the default of "BaltimoreLink".
489 */
490 function set_service_name() {
491 const sid = document.getElementById("servicename");
492
493 /* Get the "servicename" from the querystring if it's there */
494 const params = new URLSearchParams(document.location.search);
495 if (params.get("servicename")) {
496 sid.textContent = params.get("servicename");
497 }
498 }
499
500
501 /**
502 * Set the zone from the given "zone" parameter and then unhide it.
503 */
504 function set_zone(zone) {
505 const z = document.getElementById("zone");
506
507 z.textContent = zone;
508 z.style.display = "block"; /* hidden by default */
509 }
510
511
512 /**
513 * Resize the ticket background based on the service name.
514 * The BaltimoreLink, Commuter Bus, and MARC Train tickets
515 * are all different heights and are arranged vertically a
516 * bit different.
517 *
518 * Rather than design three completelty separate tickets and
519 * then have to keep track of which one we're using, I have
520 * instead decided to use one ticket and to reposition it
521 * on-the-fly based on the service name. This is necessarily
522 * a bit ugly because it involves a lot of magic numbers that
523 * can only be explained if you open up inkscape with a CharmPass
524 * screenshot to see where things belong and how to get them there.
525 *
526 * The SVG was designed with BaltimoreLink in mind, so this
527 * is a no-op if the service is BaltimoreLink.
528 */
529 function resize_ticket() {
530 /* Get the "servicename" from the querystring if it's there */
531 const params = new URLSearchParams(document.location.search);
532 const tbg = document.getElementById("ticketbg");
533 const t = document.getElementById("ticket");
534 const sn = document.getElementById("servicename");
535
536 if (params.get("servicename") === "Commuter Bus") {
537 /* The top of the background is initially at y=246.859, and
538 * we scale it by a factor of 1.12 to y=276.482 for a change
539 * of 29.623. So after we scale it, we translate it upwards
540 * by that amount to put it back where it started. */
541 tbg.setAttribute("transform", "translate(0 -29.623) scale(1 1.12)");
542
543 /* Now translate the entire ticket up by the magic amount, 1/5
544 * of the size change we made to the background. This ratio
545 * was found by measuring pixels in side-by-side screenshots
546 * of BaltimoreLink and Commuter Bus tickets. */
547 t.setAttribute("transform", "translate(0 -9.33)");
548
549 /* More magic numbers discovered by comparing the two
550 * tickets overlayed in inkscape */
551 sn.setAttribute("transform", "translate(0 64.28)");
552 }
553 else if (params.get("servicename") === "MARC Train") {
554 /* insane tricks are explained above */
555 tbg.setAttribute("transform",
556 "translate(0 -72.378) scale(1 1.2932)");
557 t.setAttribute("transform", "translate(0 -67.17)");
558 sn.setAttribute("transform", "translate(0 131.0)");
559 }
560 }
561
562
563 /**
564 * Set the security code from the querystring if it was given;
565 * otherwise generate a random code.
566 */
567 function set_code() {
568 const ct = document.getElementById("codetext");
569
570 /* Get the "code" from the querystring if it's there */
571 const params = new URLSearchParams(document.location.search);
572 if (params.get("code")) {
573 ct.textContent = params.get("code").toUpperCase();
574 }
575 else {
576 /* Otherwise, use a random code */
577 const bucket = ["0","1","2","3","4","5","6","7","8","9",
578 "A","B","C","D","E","F","G","H","I","J",
579 "K","L","M","N","O","P","Q","R","S","T",
580 "U","V","W","X","Y","Z"];
581
582 /* Two random ints between 0 and 35 */
583 const i1 = Math.floor(Math.random() * 36);
584 const i2 = Math.floor(Math.random() * 36);
585 const d1 = bucket[i1];
586 const d2 = bucket[i2];
587 ct.textContent = d1 + d2;
588 }
589 }
590
591
592 /**
593 * Center the security code within its container.
594 *
595 * Some codes like "II" and "WW" can take up wildly different
596 * amounts of horizonetal space, but they should always be
597 * centered inside their little red box. This turns out to be
598 * harder than it sounds because we can only find the width of
599 * the code in browser coordinates, whereas its "x" coordinate
600 * is in SVG coordinates. Anyway, we do it.
601 */
602 function center_code() {
603 /* Center the security code inside its red box */
604 const ct = document.getElementById("codetext");
605 const bg = document.getElementById("codebg");
606
607 /* First, find the center of the red box */
608 const r1 = bg.getBoundingClientRect();
609 const c1 = r1.left + (r1.width / 2);
610
611 /* Now the center of the code text */
612 const r2 = ct.getBoundingClientRect();
613 const c2 = r2.left + (r2.width / 2);
614
615 /* What do we add to c2 to make it equal to c1? */
616 const hdelta = c1 - c2;
617
618 /* We've measured everything so far in "client rect"
619 * coordinates, because that's the only available measurement
620 * we have for the width of the <text> element. But when we
621 * reposition that <text> element, it will be by adjusting its
622 * "x" attribute, and that attribute uses a different coordinate
623 * system than the client rect does. Specifically, "x" refers to
624 * an offset within the SVG's coordinate system, and the client
625 * rect coordinates are pixels on-screen. To convert between the
626 * two, we can take the "width" attribute of the background
627 * element and compare it to the width of the background
628 * element's client rect. Since the size of the background is
629 * fixed, this should give us a multiplier that turns client rect
630 * distances (what we have) into SVG distances (what we want) */
631 const client_to_svg = parseFloat(bg.getAttribute("width"))/r1.width;
632
633 /* Convert hdelta from client rect to SVG coordinates */
634 const svg_hdelta = hdelta * client_to_svg;
635
636 /* Since this <text> element has an "x" attribute it's easier for
637 * us to shift that than it is to mess with the "left" style. */
638 ct.setAttribute("x", parseFloat(ct.getAttribute("x")) + svg_hdelta);
639 }
640
641 /**
642 * Set the ticket's expiration date and time.
643 *
644 * BaltimoreLink and MARC Train tickets expire after 90 minutes;
645 * while Commuter Bus tickets expire after 10 minutes.
646 */
647 function set_ticket_expiry() {
648 /* There are two parameters, time and date, that we store in one
649 * underlying "date" variable. */
650 const date = new Date();
651
652 /* BaltimoreLink and MARC Train */
653 let minutes = 90;
654 const params = new URLSearchParams(document.location.search);
655 if (params.get("servicename") === "Commuter Bus") {
656 /* Commuter bus tickets are only valid for ten minutes */
657 minutes = 10;
658 }
659
660 /* We use the low-level get/setTime to change the number of
661 * milliseconds since the epoch that this date represents
662 * Obviously correct, and avoids all suspicious corner cases
663 * for a few more decades. */
664 date.setTime(date.getTime() + (minutes*60*1000));
665
666 tt = document.getElementById("tickettime");
667 tt.textContent = date.toLocaleTimeString();
668
669 const td = document.getElementById("ticketdate");
670 const dateopts = {
671 day: "2-digit",
672 month: "2-digit",
673 year: "2-digit"
674 };
675 td.textContent = date.toLocaleDateString("en-US", dateopts);
676 }
677
678
679 /**
680 * Swap the day/night sky colors.
681 *
682 * We use CSS classes to keep track of the current state because
683 * it's a tiny bit cleaner than a global variable, but for some
684 * reason we can't use those same classes to actually change the
685 * color. (The classes, change, but the color doesn't.) Rather
686 * than waste time trying to explain this, we just set the "fill"
687 * attribute ourselves whenever we swap classes.
688 */
689 function swap_day_night() {
690 const sky = document.getElementById("sky");
691
692 if (sky.getAttribute("class") === "night") {
693 sky.setAttribute("fill", "#efb02f");
694 sky.setAttribute("class", "day");
695 }
696 else {
697 /* Put this case second so that the first time the
698 * screen is tapped (when there are no classes on
699 * the sky element) the color still changes. */
700 sky.setAttribute("fill", "#143b66");
701 sky.setAttribute("class", "night");
702 }
703 }
704
705
706 /**
707 * Compute the zone (string) for the given origin/destination pair.
708 *
709 * If we don't know it or if you chose in invalid pair (destination
710 * not on the same line as your origin?) then null is returned.
711 */
712 function compute_marc_zone(src, dest) {
713
714 /* Sorted on the first component, then the second.
715 *
716 * Key:
717 *
718 * $6.00 => 1
719 * $7.00 => 2
720 * $8.00 => 3
721 * $9.00 => 4
722 */
723 const zone_map = {
724 BAL_BWE: 2,
725 BAL_BWI: 1,
726 BAL_SEB: 3,
727 BAL_WAS: 4,
728 BAL_WBL: 1,
729 BCA_CPK: 3,
730 BCA_WAS: 4,
731 BWE_BWI: 1,
732 BWE_SEB: 1,
733 BWE_WAS: 2,
734 BWE_WBL: 2,
735 BWI_SEB: 2,
736 BWI_WAS: 3,
737 BWI_WBL: 1,
738 SEB_WAS: 1,
739 SEB_WBL: 3,
740 WAS_WBL: 4
741 };
742
743 /* Forward direction key for zone_map */
744 const fwd = src + "_" + dest;
745
746 /* Reverse direction key for zone_map. The zone_map only
747 * has them listed in one direction, so we check both
748 * directions here. */
749 const rev = dest + "_" + src;
750
751 /* The default. Obviously wrong for when we don't
752 * have the necessary data. */
753 let zone = -1;
754
755 if (zone_map[fwd]) {
756 zone = zone_map[fwd];
757 }
758 else if (zone_map[rev]) {
759 zone = zone_map[rev];
760 }
761
762 /* Convert the number to a string */
763 switch (zone) {
764 case 1:
765 return "One Zone";
766 case 2:
767 return "Two Zone";
768 case 3:
769 return "Three Zone";
770 case 4:
771 return "Four Zone";
772 default:
773 return null;
774 }
775 }
776
777
778 /**
779 * Compute and set the zone.
780 *
781 * We can be given a zone in two ways. First, on Commuter Bus
782 * tickets, it is given explicitly via the querystring. But
783 * It can also be specified implicitly via the origin and
784 * destination on a MARC Train ticket. Here we try both and
785 * then call set_zone() with the result if something worked.
786 */
787 function compute_and_set_zone() {
788 const params = new URLSearchParams(document.location.search);
789 const src = params.get("origin");
790 const dest = params.get("destination");
791
792 if (src && dest) {
793 /* MARC Train. We can assume that compute_marc_zone() doesn't
794 * return null because that's part of our form validation. */
795 const zone = compute_marc_zone(src, dest);
796 set_zone(zone);
797 }
798 else if (params.get("zone")) {
799 /* Commuter Bus */
800 set_zone(params.get("zone"));
801 }
802 }
803
804
805 /**
806 * Set the origin and destination for the MARC Train if they
807 * were provided, and unhide them if so.
808 */
809 function set_marc_origin_destination() {
810 const params = new URLSearchParams(document.location.search);
811 if (!params.get("origin") || !params.get("destination")) {
812 return;
813 }
814
815 const src = params.get("origin");
816 const dest = params.get("destination");
817
818 /* origindest contains both the origin and destination */
819 const origindest = document.getElementById("origindest");
820
821 const origin = document.getElementById("origin");
822 const destination = document.getElementById("destination");
823
824 origin.textContent = params.get("origin");
825 destination.textContent = params.get("destination");
826
827 origindest.style.display = "block"; /* hidden by default */
828 }
829
830
831 /**
832 * Hide the menu and display the ticket. This is what happens
833 * when you submit the form.
834 */
835 function go() {
836 /* To create our "window" onto the scene, we're going to slide the
837 * SVG off the left-hand side of the screen, and we don't want
838 * scroll bars to appear. */
839 document.body.style.overflow = "hidden";
840 document.body.style.margin = "0";
841 document.body.style.padding = "0";
842
843 const svg = document.querySelector("svg");
844 const menu = document.getElementById("menu");
845 svg.style.display = "initial";
846 menu.style.display = "none";
847 }
848
849 /**
850 * Determine if the user agent is mobile Firefox.
851 */
852 function ua_is_mobile_ff() {
853 const ua = navigator.userAgent.toLowerCase();
854 return (ua.includes("firefox") && ua.includes("mobile"));
855 }
856
857 /**
858 * Validate the MARC form's origin/destination.
859 *
860 * We don't want the user to be able to choose a pair of stops that
861 * aren't actually connected by the same MARC line. If we don't have
862 * zone information for the (origin,destination) pair, that indicates
863 * that it's probably not a valid choice; otherwise I would have
864 * filled in the information from the CharmPass app already.
865 *
866 * All browsers except mobile Firefox let us call setCustomValidity()
867 * to provide an error message that is displayed if the user tries
868 * to submit invalid choices. But amazingly, mobile Firefox does not:
869 *
870 * https://bugzilla.mozilla.org/show_bug.cgi?id=1510450
871 *
872 * Instead we have to work around it (in that one browser) by
873 * showing/hiding a paragraph that we fill with the errors.
874 */
875 function validate_origin_destination(event) {
876 const origins = document.getElementsByName("origin");
877 const destinations = document.getElementsByName("destination");
878 const mfe = document.getElementById("marc-form-errors");
879 const marcsubmit = document.getElementById("marc-submit");
880
881 if (ua_is_mobile_ff()) {
882 /* Even though this is only for one browser, empty paragraphs
883 * are handled inconsistently and should be avoided as a rule.
884 * So, we make it say "OK" before we hide it. */
885 mfe.textContent = "OK";
886 mfe.style.visibility = "hidden";
887 marcsubmit.disabled = false;
888 }
889
890 let src = null;
891 let dest = null;
892 origins.forEach((x) => { if (x.checked) src = x; })
893 destinations.forEach((x) => {
894 if (x.checked) dest = x;
895
896 /* clear all errors before possibly setting one */
897 x.setCustomValidity('');
898 })
899
900 if (src.value === dest.value) {
901 let err = "Origin and destination are the same";
902 dest.setCustomValidity(err);
903
904 if (ua_is_mobile_ff()) {
905 mfe.textContent = err;
906 mfe.style.visibility = "visible";
907 marcsubmit.disabled = true;
908 }
909 }
910 else if (compute_marc_zone(src.value, dest.value) === null) {
911 let err = "Origin and destination are on different lines";
912 dest.setCustomValidity(err);
913
914 if (ua_is_mobile_ff()) {
915 mfe.textContent = err;
916 mfe.style.visibility = "visible";
917 marcsubmit.disabled = true;
918 }
919 }
920 }
921
922 /*****************************************************/
923 /* Add event handlers for all of the functions above */
924 /*****************************************************/
925
926 const params = new URLSearchParams(document.location.search);
927 if (params.get("go")) {
928 /* First unhide the SVG (swap it with the form) */
929 window.addEventListener("load", go);
930
931 /* Center the ticket once when the page has loaded */
932 window.addEventListener("load", center_ticket);
933
934 /* Re-center the ticket when the window is resized */
935 window.addEventListener("resize", center_ticket);
936
937 /* Set the service identifier when the page has loaded */
938 window.addEventListener("load", set_service_id);
939
940 /* Set the service name when the page has loaded */
941 window.addEventListener("load", set_service_name);
942
943 /* Resize the ticket background if necessary */
944 window.addEventListener("load", resize_ticket);
945
946 /* Set the security code text when the page has loaded */
947 window.addEventListener("load", set_code);
948
949 /* Center the security code text when the page has loaded; in
950 * particular, after we set the code. */
951 window.addEventListener("load", center_code);
952
953 /* Set the ticket expiration date/time upon page load */
954 window.addEventListener("load", set_ticket_expiry);
955
956 /* Set the MARC Train origin and destination, if applicable */
957 window.addEventListener("load", set_marc_origin_destination);
958
959 /* Compute and set the zone, if applicable */
960 window.addEventListener("load", compute_and_set_zone);
961
962 /* Swap colors when the screen is tapped */
963 document.body.addEventListener("click", swap_day_night);
964 }
965 else {
966 /* If we haven't submitted the form yet, set up change handlers
967 * for the origin/destination radio buttons that validate that
968 * the origin and destination are on the same line. */
969 document.getElementsByName("origin").forEach(
970 (x) => x.addEventListener("change", validate_origin_destination)
971 );
972 document.getElementsByName("destination").forEach(
973 (x) => x.addEventListener("change", validate_origin_destination)
974 );
975
976 /* Also do it when the page loads, because firefox likes to
977 * remember your selection even after the page reloads. */
978 window.addEventListener("load", validate_origin_destination);
979
980 /* Finally, we have to babysit mobile Firefox, who doesn't
981 * support HTML5 form validation going into 2024. Turn on
982 * the little form errors paragraph so we can toggle its
983 * visibility (and make it display the error) when the user
984 * makes an invalid selection. */
985 window.addEventListener("load", () => {
986 if (ua_is_mobile_ff()) {
987 const mfe = document.getElementById("marc-form-errors");
988 mfe.style.display = "block";
989 }
990 });
991 }
992
993 </script>
994 </body>
995 </html>