easyfaa 0.90.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +218 -7
  3. data/MIT-LICENSE +3 -1
  4. data/README.md +64 -20
  5. data/app/assets/fonts/easy/outfit-latin-ext.woff2 +0 -0
  6. data/app/assets/fonts/easy/outfit-latin.woff2 +0 -0
  7. data/app/assets/images/easy/favicon.ico +0 -0
  8. data/app/assets/images/easy/favicon.svg +11 -0
  9. data/app/assets/images/easy/sprite.svg +52 -0
  10. data/app/assets/javascripts/easy/add_rows.js +38 -0
  11. data/app/assets/javascripts/easy/flyer.js +100 -0
  12. data/app/assets/javascripts/easy/highlight.js +54 -0
  13. data/app/assets/javascripts/easy/konami.js +73 -0
  14. data/app/assets/javascripts/easy/modal.js +49 -0
  15. data/app/assets/javascripts/easy/pin_code.js +43 -0
  16. data/app/assets/javascripts/easy/report_sort.js +57 -0
  17. data/app/assets/javascripts/easy/rotating_placeholder.js +35 -0
  18. data/app/assets/javascripts/easy/step_foot.js +17 -0
  19. data/app/assets/javascripts/easy/switches.js +96 -0
  20. data/app/assets/javascripts/easy/switches_address.js +39 -0
  21. data/app/assets/javascripts/easy/tabs.js +35 -0
  22. data/app/assets/javascripts/easy/theme.js +41 -0
  23. data/app/assets/stylesheets/easy/base.css +42 -0
  24. data/app/assets/stylesheets/easy/components.css +533 -0
  25. data/app/assets/stylesheets/easy/layout.css +47 -0
  26. data/app/assets/stylesheets/easy/tokens.css +11 -0
  27. data/app/controllers/easyfaa/errors_controller.rb +41 -0
  28. data/app/helpers/easyfaa/easy_helper.rb +58 -0
  29. data/app/models/aircraft_avatar.rb +61 -0
  30. data/app/views/easy/_ad_card.html.erb +16 -0
  31. data/app/views/easy/_ad_list.html.erb +11 -0
  32. data/app/views/easy/_flyer.html.erb +10 -0
  33. data/app/views/easy/_logo.html.erb +15 -0
  34. data/app/views/easy/_modal.html.erb +17 -0
  35. data/app/views/easy/_part_tabs.html.erb +24 -0
  36. data/app/views/easy/_pin_code.html.erb +10 -0
  37. data/app/views/easy/_report_head.html.erb +28 -0
  38. data/app/views/easy/mailer/_button.html.erb +10 -0
  39. data/app/views/easy/mailer/_card.html.erb +23 -0
  40. data/app/views/easy/mailer/_code.html.erb +28 -0
  41. data/app/views/easy/mailer/_code.text.erb +19 -0
  42. data/app/views/easy/mailer/_frame.html.erb +35 -0
  43. data/app/views/easy/mailer/_text.html.erb +7 -0
  44. data/app/views/easyfaa/errors/show.html.erb +17 -0
  45. data/app/views/layouts/easy/site.html.erb +55 -0
  46. data/app/views/layouts/easyfaa/errors.html.erb +30 -0
  47. data/config/aircraft_avatars.yml +26 -0
  48. data/lib/easyfaa/engine.rb +26 -0
  49. data/lib/easyfaa/version.rb +3 -3
  50. data/lib/easyfaa.rb +16 -7
  51. metadata +99 -16
  52. data/exe/faa +0 -4
  53. data/lib/easyfaa/cli.rb +0 -65
@@ -0,0 +1,100 @@
1
+ // The plane of render "easy/flyer": across the sky in 14 seconds on a gentle wave, nose along it, its trail behind it.
2
+ // On a holiday, by the visitor's own calendar, it flies in the day's colors with a line of smoke per color: every day
3
+ // is in this file, so whatever cached it, it's right at midnight. No plane where motion is off. ES5, for old phones.
4
+ (function () {
5
+ if (!document.querySelectorAll || !document.createElementNS || !window.requestAnimationFrame) { return; }
6
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; }
7
+
8
+ var SKY = { trail: ['#5aa9e6'], opacity: 0.45 }, HANUKKAH = { plane: '#2f6fd0', trail: ['#2f6fd0', '#cfd8e3'] };
9
+ var GOLD = { plane: '#d4a514', trail: ['#d4a514', '#a8b0b8'] };
10
+ var USA = { plane: '#2f5fc4', trail: ['#d63a3a', '#ffffff', '#2f5fc4'] };
11
+ // Hanukkah's first day (25 Kislev) as a day of December, 2025 to 2045 (Nov 28 is -2); its days yield to the others.
12
+ var KISLEV_25 = [15, 5, 25, 13, 2, 21, 10, -2, 17, 7, 26, 14, 3, 22, 12, 0, 18, 8, 27, 15, 5];
13
+
14
+ // The holidays of a year, by "month-day": the plane's color, then its trail's, top to bottom. National Aviation Day
15
+ // is in pilot's-wings gold and blue, Wright Brothers Day in the Wright Flyer's muslin and spruce.
16
+ function holidays(year) {
17
+ var easter = easterOf(year), days = {};
18
+ for (var k = 0; k < 16; k++) { // last year's eight days of Hanukkah, reaching into January, then this year's
19
+ var y = year - 1 + (k >> 3), day = new Date(y, 11, KISLEV_25[y - 2025] + k % 8);
20
+ if (day.getFullYear() === year) { days[(day.getMonth() + 1) + '-' + day.getDate()] = HANUKKAH; }
21
+ }
22
+ days['1-1'] = days['12-31'] = GOLD;
23
+ days['2-14'] = { plane: '#e0457b', trail: ['#d62f4b', '#f07aa0'] };
24
+ days['3-17'] = { plane: '#1f9d55', trail: ['#1f9d55', '#7cc576'] };
25
+ days[easter] = { plane: '#a77bd6', trail: ['#f2c94c', '#a77bd6', '#6fcf97'] };
26
+ days['5-' + weekday(year, 5, 1, -1)] = days['7-4'] = days['11-11'] = USA;
27
+ days['8-19'] = { plane: '#1a70d0', trail: ['#e0b43a', '#5aa9e6'] };
28
+ days['10-31'] = { plane: '#f28c28', trail: ['#f28c28', '#7b3fa0'] };
29
+ days['11-' + weekday(year, 11, 4, 4)] = { plane: '#c0662b', trail: ['#e0a526', '#c0662b', '#8a4b2a'] };
30
+ days['12-17'] = { plane: '#8a6a43', trail: ['#d9c9a3', '#8a6a43'] };
31
+ days['12-25'] = { plane: '#d42f2f', trail: ['#1f8a4c', '#d42f2f'] };
32
+ return days;
33
+ }
34
+
35
+ // The day of the month of its nth weekday (0 Sunday), the last for n -1: Memorial Day, Thanksgiving.
36
+ function weekday(year, month, day, n) {
37
+ if (n < 0) { var last = new Date(year, month, 0); return last.getDate() - (last.getDay() - day + 7) % 7; }
38
+ return 1 + (day - new Date(year, month - 1, 1).getDay() + 7) % 7 + (n - 1) * 7;
39
+ }
40
+
41
+ // Easter Sunday's "month-day": the anonymous Gregorian algorithm.
42
+ function easterOf(y) {
43
+ var a = y % 19, b = Math.floor(y / 100), c = y % 100, f = Math.floor((b + 8) / 25);
44
+ var h = (19 * a + b - Math.floor(b / 4) - Math.floor((b - f + 1) / 3) + 15) % 30;
45
+ var l = (32 + 2 * (b % 4) + 2 * Math.floor(c / 4) - h - c % 4) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
46
+ return Math.floor((h + l - 7 * m + 114) / 31) + '-' + ((h + l - 7 * m + 114) % 31 + 1);
47
+ }
48
+
49
+ // The sky's colors: its data-date's ("2026-12-25"), to see a holiday on any day, or today's.
50
+ function colorsOf(sky) {
51
+ var date = /^(\d{4})-(\d\d)-(\d\d)$/.exec(sky.getAttribute('data-date') || '');
52
+ var day = date ? new Date(+date[1], date[2] - 1, +date[3]) : new Date();
53
+ return holidays(day.getFullYear())[(day.getMonth() + 1) + '-' + day.getDate()] || SKY;
54
+ }
55
+
56
+ function svg(name, attributes, parent) {
57
+ var node = document.createElementNS('http://www.w3.org/2000/svg', name);
58
+ for (var key in attributes) { node.setAttribute(key, attributes[key]); }
59
+ return parent.appendChild(node);
60
+ }
61
+
62
+ function fly(sky, n) {
63
+ var flyer = sky.querySelector('.js-flyer'), trail = sky.querySelector('.js-trail'), colors = colorsOf(sky);
64
+ var defs = svg('defs', {}, trail), lines = [], fades = [];
65
+ if (colors.plane) { flyer.style.color = colors.plane; }
66
+ // One line per color, 3px apart, each fading in from the sky's left edge to the tail.
67
+ for (var i = 0; i < colors.trail.length; i++) {
68
+ var id = 'flyer-' + n + '-' + i, color = colors.trail[i];
69
+ var fade = svg('linearGradient', { id: id, gradientUnits: 'userSpaceOnUse', x1: 0, y1: 0, x2: 100, y2: 0 }, defs);
70
+ svg('stop', { offset: 0, 'stop-color': color, 'stop-opacity': 0 }, fade);
71
+ svg('stop', { offset: 1, 'stop-color': color, 'stop-opacity': colors.opacity || 0.7 }, fade);
72
+ fades.push(fade);
73
+ lines.push(svg('polyline', { fill: 'none', stroke: 'url(#' + id + ')', 'stroke-width': 2, 'stroke-linecap': 'round' }, trail));
74
+ }
75
+ var span = 14000, size = 34, began = null;
76
+ function wave(x, width) { return 14 + 9 * Math.sin((x / width) * Math.PI * 3); }
77
+ function frame(now) {
78
+ if (began === null) { began = now; }
79
+ var width = sky.offsetWidth, t = ((now - began) % span) / span;
80
+ var x = -size - 40 + (width + size + 80) * t, y = wave(x + size / 2, width);
81
+ var angle = Math.atan(wave(x + size / 2 + 1, width) - y) * 180 / Math.PI;
82
+ var move = 'translate(' + x.toFixed(1) + 'px,' + (y - size / 2).toFixed(1) + 'px) rotate(' + angle.toFixed(1) + 'deg)';
83
+ flyer.style.webkitTransform = move; flyer.style.transform = move;
84
+ // The tail is the plane's left edge, at its middle, turned with it.
85
+ var tailX = x + size / 2 - (size / 2 - 3) * Math.cos(angle * Math.PI / 180);
86
+ for (var i = 0; i < lines.length; i++) {
87
+ var shift = (i - (lines.length - 1) / 2) * 3, points = [];
88
+ for (var px = 0; px < tailX; px += 6) { points.push(px + ',' + (wave(px, width) + shift).toFixed(1)); }
89
+ if (tailX > 0) { points.push(tailX.toFixed(1) + ',' + (wave(tailX, width) + shift).toFixed(1)); }
90
+ lines[i].setAttribute('points', points.join(' '));
91
+ fades[i].setAttribute('x2', Math.max(tailX, 1).toFixed(1));
92
+ }
93
+ if (sky.className.indexOf('is-gone') < 0) { window.requestAnimationFrame(frame); } // freed (easy/konami)
94
+ }
95
+ window.requestAnimationFrame(frame);
96
+ }
97
+
98
+ var skies = document.querySelectorAll('.js-sky');
99
+ for (var i = 0; i < skies.length; i++) { fly(skies[i], i); }
100
+ })();
@@ -0,0 +1,54 @@
1
+ // Marks the words of a search in the rows it leaves (easy/switches calls it at each draw): every place a word is
2
+ // found in a row's text is wrapped in mark.search-hit, and the marks of the search before are taken out first. Words
3
+ // are found within one piece of text, never across two elements, and never in a form: what it asks is the
4
+ // same in every row, and what is typed there is the reader's.
5
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
6
+ (function () {
7
+ var SKIP = /^(FORM|SCRIPT|STYLE)$/;
8
+
9
+ // Straight in a flex or grid box each piece of text is laid out apart, losing the space beside it: the text is
10
+ // first put whole in a span, one piece as it was, and marked inside it.
11
+ function texts(node, found) {
12
+ var laid = /flex|grid/.test(window.getComputedStyle(node).display);
13
+ for (var child = node.firstChild; child; child = child.nextSibling) {
14
+ if (child.nodeType === 3 && laid && /\S/.test(child.nodeValue)) {
15
+ var whole = document.createElement('span');
16
+ node.replaceChild(whole, child);
17
+ whole.appendChild(child);
18
+ found.push(child);
19
+ child = whole;
20
+ } else if (child.nodeType === 3) { found.push(child); } else if (child.nodeType === 1 && !SKIP.test(child.nodeName)) { texts(child, found); }
21
+ }
22
+ return found;
23
+ }
24
+
25
+ // Wraps the first word found in the text, then goes on in what follows it.
26
+ function mark(text, words) {
27
+ var lower = text.nodeValue.toLowerCase(), at = -1, size = 0;
28
+ for (var w = 0; w < words.length; w++) {
29
+ var index = lower.indexOf(words[w]);
30
+ if (index >= 0 && (at < 0 || index < at)) { at = index; size = words[w].length; }
31
+ }
32
+ if (at < 0) { return; }
33
+ var hit = text.splitText(at), rest = hit.splitText(size), wrap = document.createElement('mark');
34
+ wrap.className = 'search-hit';
35
+ hit.parentNode.replaceChild(wrap, hit);
36
+ wrap.appendChild(hit);
37
+ mark(rest, words);
38
+ }
39
+
40
+ window.easyHighlight = function (box, rows, words) {
41
+ var marks = box.querySelectorAll('mark.search-hit'), typed = [];
42
+ for (var m = 0; m < marks.length; m++) {
43
+ var parent = marks[m].parentNode;
44
+ parent.replaceChild(document.createTextNode(marks[m].textContent), marks[m]);
45
+ parent.normalize();
46
+ }
47
+ for (var w = 0; w < words.length; w++) { if (words[w]) { typed.push(words[w]); } }
48
+ if (!typed.length) { return; }
49
+ for (var r = 0; r < rows.length; r++) {
50
+ var found = texts(rows[r], []);
51
+ for (var t = 0; t < found.length; t++) { mark(found[t], typed); }
52
+ }
53
+ };
54
+ })();
@@ -0,0 +1,73 @@
1
+ // The easter egg of render "easy/flyer": the Konami code sets the plane free, ↑ ↑ ↓ ↓ ← → ← → B A on a keyboard,
2
+ // or on a phone swipes up, up, down, down, left, right, left, right and two taps. It leaves its wave, wanders the
3
+ // window at random until it flies out of it, and never comes back in this browser (localStorage, per site).
4
+ // Included with easy/flyer; where motion is turned off there is no plane, and nothing to free.
5
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
6
+ (function () {
7
+ if (!document.querySelectorAll || !document.addEventListener || !window.requestAnimationFrame) { return; }
8
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; }
9
+ var GONE = 'easy-plane-gone', CODES = ['UUDDLRLRBA', 'UUDDLRLRTT'];
10
+ var KEYS = { 38: 'U', 40: 'D', 37: 'L', 39: 'R', 66: 'B', 65: 'A' };
11
+ var skies = document.querySelectorAll('.js-sky'), typed = '', start = null, freed = false;
12
+
13
+ // A sky gone keeps no plane, and its trail fades; easy/flyer stops drawing it.
14
+ function gone() { for (var i = 0; i < skies.length; i++) { skies[i].className += ' is-gone'; } }
15
+ try { if (window.localStorage.getItem(GONE)) { gone(); return; } } catch (err) { /* no storage: it comes back */ }
16
+
17
+ // The plane, out of its sky and over the window, where it was and heading as it was: at 160px a second, it turns
18
+ // one way then the other, never sharper than a loop in three seconds, until it is past an edge.
19
+ function fly(flyer) {
20
+ var box = flyer.getBoundingClientRect(), plane = flyer.cloneNode(true), size = 34;
21
+ var rotation = /rotate\((-?[\d.]+)deg\)/.exec(flyer.style.transform || '');
22
+ var x = box.left + box.width / 2 - size / 2, y = box.top + box.height / 2 - size / 2;
23
+ var heading = rotation ? rotation[1] * Math.PI / 180 : 0, turn = 0, last = null;
24
+ if (!box.width) { return; }
25
+ function place() {
26
+ var turn = (heading * 180 / Math.PI).toFixed(1);
27
+ var move = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) rotate(' + turn + 'deg)';
28
+ plane.style.webkitTransform = move; plane.style.transform = move;
29
+ }
30
+ plane.className = 'flyer is-free';
31
+ place();
32
+ document.body.appendChild(plane);
33
+ function frame(now) {
34
+ var dt = last === null ? 16 : Math.min(now - last, 50);
35
+ last = now;
36
+ turn = Math.max(-0.002, Math.min(0.002, turn + (Math.random() - 0.5) * 0.00002 * dt));
37
+ heading += turn * dt;
38
+ x += Math.cos(heading) * 0.16 * dt; y += Math.sin(heading) * 0.16 * dt;
39
+ place();
40
+ if (x < -size || y < -size || x > window.innerWidth || y > window.innerHeight) {
41
+ return document.body.removeChild(plane);
42
+ }
43
+ window.requestAnimationFrame(frame);
44
+ }
45
+ window.requestAnimationFrame(frame);
46
+ }
47
+
48
+ function free() {
49
+ try { window.localStorage.setItem(GONE, '1'); } catch (err) { /* no storage: it comes back */ }
50
+ for (var i = 0; i < skies.length; i++) { fly(skies[i].querySelector('.js-flyer')); }
51
+ gone();
52
+ }
53
+
54
+ function push(symbol) {
55
+ typed = (typed + symbol).slice(-10);
56
+ if (!freed && (typed === CODES[0] || typed === CODES[1])) { freed = true; free(); }
57
+ }
58
+
59
+ document.addEventListener('keydown', function (event) { push(KEYS[event.keyCode] || '-'); });
60
+ // A swipe is its longer way, 30px at least; a touch that hardly moved is a tap.
61
+ document.addEventListener('touchstart', function (event) {
62
+ start = event.touches.length === 1 ? { x: event.touches[0].clientX, y: event.touches[0].clientY } : null;
63
+ }, { passive: true });
64
+ document.addEventListener('touchend', function (event) {
65
+ if (!start) { return; }
66
+ var dx = event.changedTouches[0].clientX - start.x, dy = event.changedTouches[0].clientY - start.y;
67
+ var far = Math.max(Math.abs(dx), Math.abs(dy));
68
+ if (far < 10) { push('T'); } else if (far < 30) { push('-'); } else if (Math.abs(dx) > Math.abs(dy)) {
69
+ push(dx > 0 ? 'R' : 'L');
70
+ } else { push(dy > 0 ? 'D' : 'U'); }
71
+ start = null;
72
+ }, { passive: true });
73
+ })();
@@ -0,0 +1,49 @@
1
+ // Every dialog of render "easy/modal" lets its reader out: its ×, Esc or a click around it closes it where it is,
2
+ // without loading a page, and the address becomes the × link's (the page without what opened it), or loses the #id
3
+ // that opened it. A link to that #id opens it again. Without the script, the × is a plain link.
4
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
5
+ (function () {
6
+ if (!document.querySelector || !document.addEventListener) { return; }
7
+
8
+ function close(modal) {
9
+ var link = modal.querySelector('.modal-x'), href = link && link.getAttribute('href');
10
+ modal.className = modal.className.replace(/\s*\bis-(open|closed)\b/g, '') + ' is-closed';
11
+ if (!window.history || !history.replaceState) { return; }
12
+ if (href && href.charAt(0) !== '#') { history.replaceState(null, '', href + location.hash); }
13
+ else if (location.hash === '#' + modal.id) { history.replaceState(null, '', location.pathname + location.search); }
14
+ }
15
+
16
+ // The dialog showing: drawn open, or the one the address's #id names.
17
+ function shown() {
18
+ var target = location.hash.length > 1 && document.getElementById(location.hash.slice(1));
19
+ var open = document.querySelector('.modal.is-open');
20
+ if (open) { return open; }
21
+ var open = target && /(^|\s)modal(\s|$)/.test(target.className) && !/\bis-closed\b/.test(target.className);
22
+ return open ? target : null;
23
+ }
24
+
25
+ // The node or its nearest ancestor with the class: old browsers have no closest().
26
+ function up(node, name) {
27
+ var pattern = new RegExp('(^|\\s)' + name + '(\\s|$)');
28
+ while (node && node.nodeType === 1 && !pattern.test(node.className)) { node = node.parentNode; }
29
+ return node && node.nodeType === 1 ? node : null;
30
+ }
31
+
32
+ // The ×, or the dimmed page around the box (the dialog itself, not what it holds).
33
+ document.addEventListener('click', function (event) {
34
+ var x = up(event.target, 'modal-x');
35
+ var modal = x ? up(x, 'modal') : /(^|\s)modal(\s|$)/.test(event.target.className) && event.target;
36
+ if (!modal) { return; }
37
+ event.preventDefault();
38
+ close(modal);
39
+ });
40
+ document.addEventListener('keydown', function (event) {
41
+ if (event.key !== 'Escape' && event.key !== 'Esc' && event.keyCode !== 27) { return; }
42
+ var modal = shown();
43
+ if (modal) { event.preventDefault(); close(modal); }
44
+ });
45
+ window.addEventListener('hashchange', function () {
46
+ var target = location.hash.length > 1 && document.getElementById(location.hash.slice(1));
47
+ if (target) { target.className = target.className.replace(/\s*\bis-closed\b/g, ''); }
48
+ });
49
+ })();
@@ -0,0 +1,43 @@
1
+ // The 6-digit code of render "easy/pin_code": its six slots show what its one real field holds, the next one to fill
2
+ // lit, and six digits submit the form without a click, typed, pasted or offered by the phone from the email. A field
3
+ // the page empties redraws on an input event; a code refused is the page's to mark, is-invalid on the .pin-code,
4
+ // which the next digit clears. A .pin-code already drawn is left alone, so including the script twice is harmless.
5
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
6
+ (function () {
7
+ if (!document.querySelectorAll || !document.addEventListener) { return; }
8
+
9
+ // The form's own submit, so its handlers run first: requestSubmit, or an old browser's submit event.
10
+ function submit(form) {
11
+ if (form.requestSubmit) { form.requestSubmit(); return; }
12
+ var event = document.createEvent('Event');
13
+ event.initEvent('submit', true, true);
14
+ if (form.dispatchEvent(event)) { form.submit(); }
15
+ }
16
+
17
+ function drawn(pin) {
18
+ var input = pin.querySelector('.pin-code-input'), slots = pin.querySelectorAll('.pin-code-slots span');
19
+ function draw() {
20
+ var value = input.value.replace(/\D/g, '').slice(0, 6), focused = document.activeElement === input;
21
+ if (value !== input.value) { input.value = value; }
22
+ for (var i = 0; i < slots.length; i++) {
23
+ slots[i].textContent = value.charAt(i);
24
+ slots[i].className = focused && i === Math.min(value.length, 5) ? 'is-active' : '';
25
+ }
26
+ return value;
27
+ }
28
+ input.addEventListener('input', function () {
29
+ pin.className = pin.className.replace(/\s*\bis-invalid\b/g, '');
30
+ input.removeAttribute('aria-invalid');
31
+ if (draw().length === 6 && input.form) { submit(input.form); }
32
+ });
33
+ input.addEventListener('focus', draw);
34
+ input.addEventListener('blur', draw);
35
+ pin.className += ' is-drawn';
36
+ draw();
37
+ }
38
+
39
+ var pins = document.querySelectorAll('.pin-code');
40
+ for (var i = 0; i < pins.length; i++) {
41
+ if (!/\bis-drawn\b/.test(pins[i].className)) { drawn(pins[i]); }
42
+ }
43
+ })();
@@ -0,0 +1,57 @@
1
+ // The sort button of easy/report_head: flips every list data-sort-list names, each by its rows' data-sort, remembered
2
+ // in this browser. ES5 alone. A page that redraws its list calls window.easyReportSort() after. Without this script the
3
+ // list stays as the server sent it, and the button hidden.
4
+ (function () {
5
+ // A page with two heads links the script twice: the second would flip every list back.
6
+ if (window.easyReportSort) { return; }
7
+
8
+ function saved(head) {
9
+ try {
10
+ return localStorage.getItem('report-sort:' + head.getAttribute('data-sort-list'));
11
+ } catch (blocked) { return null; }
12
+ }
13
+
14
+ function save(head, order) {
15
+ try {
16
+ localStorage.setItem('report-sort:' + head.getAttribute('data-sort-list'), order);
17
+ } catch (blocked) { return; }
18
+ }
19
+
20
+ function order(head) { return head.easyOrder || saved(head) || head.getAttribute('data-sort-default'); }
21
+
22
+ function compared(a, b) {
23
+ return a.getAttribute('data-sort').localeCompare(b.getAttribute('data-sort'), undefined, { numeric: true });
24
+ }
25
+
26
+ function apply(head) {
27
+ var lists = document.querySelectorAll(head.getAttribute('data-sort-list'));
28
+ var item = head.querySelector('.sort-button-item');
29
+ if (!lists.length || !item) { return; }
30
+ var way = order(head), newest = way === 'newest';
31
+ Array.prototype.forEach.call(lists, function (list) {
32
+ Array.prototype.slice.call(list.children).filter(function (row) { return row.hasAttribute('data-sort'); })
33
+ .sort(function (a, b) { return newest ? compared(b, a) : compared(a, b); })
34
+ .forEach(function (row) { list.appendChild(row); });
35
+ });
36
+ item.hidden = false;
37
+ item.querySelector('.js-sort-label').textContent = newest ? 'Newest first' : 'Oldest first';
38
+ var icon = item.querySelector('use'), ref = icon.getAttribute('href').split('#')[0] + '#i-' + way;
39
+ icon.setAttribute('href', ref);
40
+ icon.setAttribute('xlink:href', ref);
41
+ }
42
+
43
+ window.easyReportSort = function () {
44
+ Array.prototype.forEach.call(document.querySelectorAll('[data-sort-list]'), apply);
45
+ };
46
+
47
+ document.addEventListener('click', function (event) {
48
+ var button = event.target.closest && event.target.closest('.js-sort');
49
+ var head = button && button.closest('[data-sort-list]');
50
+ if (!head) { return; }
51
+ head.easyOrder = order(head) === 'newest' ? 'oldest' : 'newest';
52
+ save(head, head.easyOrder);
53
+ apply(head);
54
+ });
55
+
56
+ window.easyReportSort();
57
+ }());
@@ -0,0 +1,35 @@
1
+ // The search box's placeholder types what the box finds, one example after another, until the visitor types their
2
+ // own: each app gives its examples on the field, <input data-rotating-placeholder='["N714AD", "O-320-H2AD"]'>, and
3
+ // its resting placeholder, which comes back once something is typed. Where motion is turned off, it holds still.
4
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
5
+ (function () {
6
+ if (!document.querySelectorAll || !window.JSON) { return; }
7
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; }
8
+
9
+ function rotate(input) {
10
+ var examples;
11
+ try { examples = JSON.parse(input.getAttribute('data-rotating-placeholder')); } catch (err) { return; }
12
+ if (!examples || !examples.length) { return; }
13
+ var resting = input.getAttribute('placeholder') || examples[0], ex = 0, at = 0, typing = null;
14
+ function tick() {
15
+ var word = examples[ex];
16
+ if (at <= word.length) {
17
+ input.setAttribute('placeholder', word.slice(0, at) || word.charAt(0));
18
+ at++;
19
+ typing = setTimeout(tick, at > word.length ? 1600 : 140);
20
+ return;
21
+ }
22
+ ex = (ex + 1) % examples.length; at = 1; typing = setTimeout(tick, 140);
23
+ }
24
+ // Typing stops the show; being in the field doesn't, nor a key that typed nothing (the Tab that got there).
25
+ function typed() {
26
+ if (input.value && typing) { clearTimeout(typing); typing = null; input.setAttribute('placeholder', resting); }
27
+ }
28
+ input.addEventListener('input', typed);
29
+ input.addEventListener('keyup', typed);
30
+ typing = setTimeout(tick, 900);
31
+ }
32
+
33
+ var inputs = document.querySelectorAll('[data-rotating-placeholder]');
34
+ for (var i = 0; i < inputs.length; i++) { rotate(inputs[i]); }
35
+ })();
@@ -0,0 +1,17 @@
1
+ // A step's foot (step-foot) is in the page's flow, at the step's end. Once the step's top has left the window (the
2
+ // steps bar, or what data-top names) the foot also keeps to the window's foot (is-stuck), coming up from below
3
+ // (is-rising, until it has risen) unless its own place is already in view, as at the end of a short step; back at the
4
+ // top it lets go.
5
+ // Without this script the foot stays at the step's end. ES5 alone, for the browsers that can watch an element.
6
+ (function () {
7
+ var foot = document.querySelector && document.querySelector('.step-foot');
8
+ var top = foot && document.querySelector(foot.getAttribute('data-top') || '.steps-bar');
9
+ if (!top || !window.IntersectionObserver) { return; }
10
+ new IntersectionObserver(function (entries) {
11
+ var gone = !entries[0].isIntersecting && entries[0].boundingClientRect.top < 0;
12
+ var below = foot.getBoundingClientRect().top >= window.innerHeight;
13
+ foot.className = foot.className.replace(/ ?is-(stuck|rising)/g, '') + (gone ? ' is-stuck' + (below ? ' is-rising' : '') : '');
14
+ }).observe(top);
15
+ // Risen, it draws nothing below itself any longer: at the page's end the site's footer is there.
16
+ foot.addEventListener('animationend', function () { foot.className = foot.className.replace(/ ?is-rising/g, ''); });
17
+ })();
@@ -0,0 +1,96 @@
1
+ // Switches over a list: inside js-switches, each count of a switches row (li[data-state]) shows or hides the rows of
2
+ // that state, anything else carrying the same data-state; one with data-off="true" starts off. Words typed in
3
+ // js-words keep the rows that have them all outside their forms, and are marked in them (easy/highlight). A js-none line shows while
4
+ // nothing beside it is left to show, a js-group hides while nothing inside it is, and a tab (js-tabs) whose number is
5
+ // a js-count counts the rows left in its panel that carry data-counted. A row carrying data-keep shows whatever its
6
+ // switch says, until a switch is flipped or a word typed. A page that redraws its rows or its switches calls
7
+ // window.easySwitches() after. Without the script every row shows, the counts are plain and the search hidden.
8
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
9
+ (function () {
10
+ // A page with two heads links the script twice: its boxes are wired once.
11
+ if (window.easySwitches || !document.querySelectorAll || !document.addEventListener) { return; }
12
+ var drawn = [];
13
+
14
+ function each(list, callback) { for (var i = 0; i < list.length; i++) { callback(list[i]); } }
15
+ function has(el, name) { return (' ' + el.className + ' ').indexOf(' ' + name + ' ') >= 0; }
16
+ function within(el, name, stop) {
17
+ for (; el && el !== stop; el = el.parentNode) { if (el.className && has(el, name)) { return el; } }
18
+ return null;
19
+ }
20
+ // What a row says, without what its forms ask: "Complied with" in every card's form is no reason to show them all.
21
+ function said(node) {
22
+ var text = '';
23
+ for (var child = node.firstChild; child; child = child.nextSibling) {
24
+ if (child.nodeType === 3) { text += child.nodeValue; } else if (child.nodeType === 1 && child.nodeName !== 'FORM') { text += said(child); }
25
+ }
26
+ return text;
27
+ }
28
+ function holds(el, rows) {
29
+ for (var i = 0; i < rows.length; i++) { if (el.contains(rows[i])) { return true; } }
30
+ return false;
31
+ }
32
+
33
+ function wire(box) {
34
+ var on = box.easySwitches = {}, field = box.querySelector('.js-words'), input = field && field.querySelector('input');
35
+ function switches() { return box.querySelectorAll('.switches li[data-state]'); }
36
+ function toggle(target) {
37
+ for (; target && target !== box; target = target.parentNode) {
38
+ if (target.getAttribute && target.getAttribute('data-state') && within(target, 'switches', box)) { return target; }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function shown(row, want) {
44
+ if (on[row.getAttribute('data-state')] === false && !row.hasAttribute('data-keep')) { return false; }
45
+ var text = want.join('') ? said(row).toLowerCase() : '';
46
+ for (var w = 0; w < want.length; w++) { if (text.indexOf(want[w]) < 0) { return false; } }
47
+ return true;
48
+ }
49
+
50
+ function draw() {
51
+ var want = (input ? input.value : '').toLowerCase().split(/\s+/), left = [];
52
+ each(switches(), function (li) {
53
+ var state = li.getAttribute('data-state');
54
+ if (on[state] === undefined) { on[state] = li.getAttribute('data-off') !== 'true'; }
55
+ li.className = li.className.replace(/ ?(switches-toggle|is-off)\b/g, '') + ' switches-toggle' + (on[state] ? '' : ' is-off');
56
+ li.setAttribute('role', 'button');
57
+ li.setAttribute('tabindex', '0');
58
+ li.setAttribute('aria-pressed', on[state] ? 'true' : 'false');
59
+ });
60
+ each(box.querySelectorAll('[data-state]'), function (row) {
61
+ if (within(row, 'switches', box)) { return; }
62
+ row.hidden = !shown(row, want);
63
+ if (!row.hidden) { left.push(row); }
64
+ });
65
+ each(box.querySelectorAll('.js-none'), function (none) { none.hidden = holds(none.parentNode, left); });
66
+ each(box.querySelectorAll('.js-group'), function (group) { group.hidden = !holds(group, left); });
67
+ each(box.querySelectorAll('.js-tabs a'), function (tab) {
68
+ var count = tab.querySelector('.js-count'), panel = document.getElementById(tab.getAttribute('href').slice(1)), n = 0;
69
+ if (!count || !panel) { return; }
70
+ each(left, function (row) { if (row.hasAttribute('data-counted') && panel.contains(row)) { n++; } });
71
+ count.textContent = n;
72
+ });
73
+ if (window.easyHighlight) { window.easyHighlight(box, left, want); }
74
+ }
75
+
76
+ function changed() {
77
+ each(box.querySelectorAll('[data-keep]'), function (row) { row.removeAttribute('data-keep'); });
78
+ draw();
79
+ }
80
+ function flip(li) { on[li.getAttribute('data-state')] = !on[li.getAttribute('data-state')]; changed(); }
81
+
82
+ box.addEventListener('click', function (event) { var li = toggle(event.target); if (li) { flip(li); } });
83
+ box.addEventListener('keydown', function (event) {
84
+ var li = toggle(event.target);
85
+ if (li && (event.key === 'Enter' || event.key === ' ' || event.keyCode === 13 || event.keyCode === 32)) {
86
+ event.preventDefault(); flip(li);
87
+ }
88
+ });
89
+ if (input) { field.hidden = false; input.addEventListener('input', changed); }
90
+ drawn.push(draw);
91
+ draw();
92
+ }
93
+
94
+ window.easySwitches = function () { each(drawn, function (draw) { draw(); }); };
95
+ each(document.querySelectorAll('.js-switches'), wire);
96
+ })();
@@ -0,0 +1,39 @@
1
+ // Keeps what a js-switches box carrying data-address is set to in the page's address, so the page can be reloaded or
2
+ // shared as it is: ?q= the words typed, ?off= the switches that are off, when they are not the ones that start off
3
+ // ("off=" alone, then, for all of them on). Linked after easy/switches. ES5 alone.
4
+ (function () {
5
+ var box = document.querySelector('.js-switches[data-address]');
6
+ if (!box || !box.easySwitches || !window.history || !history.replaceState) { return; }
7
+ var on = box.easySwitches, input = box.querySelector('.js-words input'), starts = [];
8
+
9
+ function params() {
10
+ var all = {}, pairs = location.search.replace(/^\?/, '').split('&');
11
+ for (var i = 0; i < pairs.length; i++) {
12
+ var pair = pairs[i].split('=');
13
+ if (pairs[i]) { all[decodeURIComponent(pair[0])] = decodeURIComponent((pair[1] || '').replace(/\+/g, ' ')); }
14
+ }
15
+ return all;
16
+ }
17
+ function off() {
18
+ var states = [];
19
+ for (var state in on) { if (on.hasOwnProperty(state) && !on[state]) { states.push(state); } }
20
+ return states.sort().join(',');
21
+ }
22
+ function remember() {
23
+ var all = params(), words = input ? input.value.replace(/^\s+|\s+$/g, '') : '', pairs = [];
24
+ delete all.q; delete all.off;
25
+ if (words) { all.q = words; }
26
+ if (off() !== starts) { all.off = off(); }
27
+ for (var key in all) { if (all.hasOwnProperty(key)) { pairs.push(encodeURIComponent(key) + '=' + encodeURIComponent(all[key])); } }
28
+ history.replaceState(history.state, '', location.pathname + (pairs.length ? '?' + pairs.join('&') : '') + location.hash);
29
+ }
30
+
31
+ starts = off();
32
+ var asked = params();
33
+ if (asked.off !== undefined) { for (var state in on) { if (on.hasOwnProperty(state)) { on[state] = (',' + asked.off + ',').indexOf(',' + state + ',') < 0; } } }
34
+ if (asked.q && input) { input.value = asked.q; }
35
+ window.easySwitches();
36
+ box.addEventListener('click', remember);
37
+ box.addEventListener('keydown', remember);
38
+ box.addEventListener('input', remember);
39
+ })();
@@ -0,0 +1,35 @@
1
+ // Tabs over panels (part-tabs with js-tabs): each tab is a link to its panel's #id. A click shows that panel and hides
2
+ // the others (is-on on the tab, is-off on a panel), and the address takes the #id, which opens the same tab when the
3
+ // page is loaded with it, as does the #id of anything inside a panel. Without the script the panels show one under the other and the tabs jump to each.
4
+ // ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
5
+ (function () {
6
+ if (!document.querySelectorAll || !document.addEventListener) { return; }
7
+
8
+ function strip(el, name) { el.className = el.className.replace(new RegExp('\\s*\\b' + name + '\\b', 'g'), ''); }
9
+ function id(link) { return link.getAttribute('href').slice(1); }
10
+
11
+ function wire(links) {
12
+ function show(shown) {
13
+ for (var i = 0; i < links.length; i++) {
14
+ var panel = document.getElementById(id(links[i]));
15
+ strip(links[i], 'is-on'); strip(panel, 'is-off');
16
+ if (id(links[i]) === shown) { links[i].className += ' is-on'; } else { panel.className += ' is-off'; }
17
+ }
18
+ }
19
+ for (var i = 0; i < links.length; i++) {
20
+ links[i].addEventListener('click', function (event) {
21
+ event.preventDefault();
22
+ show(id(this));
23
+ if (window.history && history.replaceState) { history.replaceState(null, '', '#' + id(this)); }
24
+ });
25
+ // The tab the address names, or the one whose panel holds what it names, brought into view: a hidden panel is
26
+ // nowhere to scroll to. Any other address leaves the tab the page was drawn with.
27
+ var named = window.location.hash.slice(1), at = named && document.getElementById(named);
28
+ if (id(links[i]) === named) { show(named); }
29
+ if (at && id(links[i]) !== named && document.getElementById(id(links[i])).contains(at)) { show(id(links[i])); at.scrollIntoView(); }
30
+ }
31
+ }
32
+
33
+ var lists = document.querySelectorAll('.js-tabs');
34
+ for (var l = 0; l < lists.length; l++) { wire(lists[l].getElementsByTagName('a')); }
35
+ })();
@@ -0,0 +1,41 @@
1
+ // The theme a reader picks on this browser, light or dark, kept in localStorage and set on <html> before the page
2
+ // paints (layouts/easy/site). Two ways to pick: the header's button (js-theme), which turns to the other one, and
3
+ // radio buttons (inside js-theme-choice, valued light, dark or device), where device forgets the choice and follows
4
+ // the system again. ES5 alone: every easy app has to work on old iPhones, Androids and Windows browsers.
5
+ (function () {
6
+ if (!document.querySelectorAll || !document.addEventListener) { return; }
7
+ var root = document.documentElement;
8
+
9
+ function stored() { var theme = root.getAttribute('data-theme'); return theme === 'dark' || theme === 'light' ? theme : null; }
10
+ function system() { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }
11
+ function each(selector, callback) {
12
+ var list = document.querySelectorAll(selector);
13
+ for (var i = 0; i < list.length; i++) { callback(list[i]); }
14
+ }
15
+
16
+ function paint() {
17
+ var dark = (stored() || system()) === 'dark';
18
+ each('.js-theme', function (button) {
19
+ var label = button.querySelector('.js-theme-label, .theme-toggle-label');
20
+ button.setAttribute('aria-pressed', dark ? 'true' : 'false');
21
+ if (label) { label.textContent = dark ? 'Light theme' : 'Dark theme'; }
22
+ });
23
+ each('.js-theme-choice input', function (radio) { radio.checked = radio.value === (stored() || 'device'); });
24
+ }
25
+
26
+ function pick(theme) {
27
+ if (theme === 'device') { root.removeAttribute('data-theme'); } else { root.setAttribute('data-theme', theme); }
28
+ try {
29
+ if (theme === 'device') { localStorage.removeItem('theme'); } else { localStorage.setItem('theme', theme); }
30
+ } catch (e) {}
31
+ paint();
32
+ }
33
+
34
+ each('.js-theme', function (button) {
35
+ button.addEventListener('click', function () { pick((stored() || system()) === 'dark' ? 'light' : 'dark'); });
36
+ });
37
+ each('.js-theme-choice input', function (radio) {
38
+ radio.addEventListener('change', function () { pick(radio.value); });
39
+ });
40
+ paint();
41
+ })();