diff options
Diffstat (limited to 'app/assets/javascripts/utilities.js.erb')
-rw-r--r-- | app/assets/javascripts/utilities.js.erb | 498 |
1 files changed, 498 insertions, 0 deletions
diff --git a/app/assets/javascripts/utilities.js.erb b/app/assets/javascripts/utilities.js.erb new file mode 100644 index 0000000..0414ce8 --- /dev/null +++ b/app/assets/javascripts/utilities.js.erb | |||
@@ -0,0 +1,498 @@ | |||
1 | function namespace(code) { | ||
2 | code() | ||
3 | } | ||
4 | |||
5 | namespace(function() { | ||
6 | |||
7 | /*** Start cross-compatibility ***/ | ||
8 | // Used to detect if IDs include a direction, e.g. resize-top-left | ||
9 | if (!String.prototype.includes) { | ||
10 | String.prototype.includes = function() { | ||
11 | return String.prototype.indexOf.apply(this, arguments) !== -1 | ||
12 | } | ||
13 | } | ||
14 | Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX | ||
15 | Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY | ||
16 | Event.prototype.isRightClick = function() { | ||
17 | return this.which === 3 || (this.touches && this.touches.length > 1) | ||
18 | } | ||
19 | Element.prototype.disable = function() { | ||
20 | this.disabled = true | ||
21 | this.style.pointerEvents = 'none' | ||
22 | this.className = 'noselect' | ||
23 | } | ||
24 | Element.prototype.enable = function() { | ||
25 | this.disabled = false | ||
26 | this.style.pointerEvents = null | ||
27 | this.className = null | ||
28 | } | ||
29 | Object.defineProperty(Event.prototype, 'position', { | ||
30 | 'get': function() { | ||
31 | return { | ||
32 | 'x': event.pageX || event.clientX || (event.touches && event.touches[0].pageX) || null, | ||
33 | 'y': event.pageY || event.clientY || (event.touches && event.touches[0].pageY) || null, | ||
34 | } | ||
35 | } | ||
36 | }) | ||
37 | /*** End cross-compatibility ***/ | ||
38 | |||
39 | var proxy = { | ||
40 | 'get': function(_, key) { | ||
41 | try { | ||
42 | return this._map[key] | ||
43 | } catch (e) { | ||
44 | return null | ||
45 | } | ||
46 | }, | ||
47 | 'set': function(_, key, value) { | ||
48 | if (value == null) { | ||
49 | delete this._map[key] | ||
50 | } else { | ||
51 | this._map[key] = value.toString() | ||
52 | window.localStorage.setItem('settings', JSON.stringify(this._map)) | ||
53 | } | ||
54 | }, | ||
55 | 'init': function() { | ||
56 | this._map = {} | ||
57 | try { | ||
58 | var j = window.localStorage.getItem('settings') | ||
59 | if (j != null) this._map = JSON.parse(j) | ||
60 | } catch (e) {/* Do nothing */} | ||
61 | |||
62 | function setIfNull(map, key, value) { | ||
63 | if (map[key] == null) map[key] = value | ||
64 | } | ||
65 | |||
66 | // Set any values which are not defined | ||
67 | setIfNull(this._map, 'theme', 'light') | ||
68 | setIfNull(this._map, 'volume', '0.12') | ||
69 | setIfNull(this._map, 'sensitivity', '0.7') | ||
70 | setIfNull(this._map, 'expanded', 'false') | ||
71 | setIfNull(this._map, 'customMechanics', 'false') | ||
72 | return this | ||
73 | }, | ||
74 | } | ||
75 | window.settings = new Proxy({}, proxy.init()) | ||
76 | |||
77 | var tracks = { | ||
78 | 'start': new Audio(src = '<%= asset_url("panel_start_tracing.aac") %>'), | ||
79 | 'success': new Audio(src = '<%= asset_url("panel_success.aac") %>'), | ||
80 | 'fail': new Audio(src = '<%= asset_url("panel_failure.aac") %>'), | ||
81 | 'abort': new Audio(src = '<%= asset_url("panel_abort_tracing.aac") %>'), | ||
82 | } | ||
83 | |||
84 | var currentAudio = null | ||
85 | window.PLAY_SOUND = function(name) { | ||
86 | if (currentAudio) currentAudio.pause() | ||
87 | var audio = tracks[name] | ||
88 | audio.load() | ||
89 | audio.volume = parseFloat(window.settings.volume) | ||
90 | audio.play().then(function() { | ||
91 | currentAudio = audio | ||
92 | }).catch(function() { | ||
93 | // Do nothing. | ||
94 | }) | ||
95 | } | ||
96 | |||
97 | window.LINE_PRIMARY = '#8FF' | ||
98 | window.LINE_SECONDARY = '#FF2' | ||
99 | |||
100 | if (window.settings.theme == 'night') { | ||
101 | window.BACKGROUND = '#221' | ||
102 | window.OUTER_BACKGROUND = '#070704' | ||
103 | window.FOREGROUND = '#751' | ||
104 | window.BORDER = '#666' | ||
105 | window.LINE_DEFAULT = '#888' | ||
106 | window.LINE_SUCCESS = '#BBB' | ||
107 | window.LINE_FAIL = '#000' | ||
108 | window.CURSOR = '#FFF' | ||
109 | window.TEXT_COLOR = '#AAA' | ||
110 | window.PAGE_BACKGROUND = '#000' | ||
111 | window.ALT_BACKGROUND = '#333' // An off-black. Good for mild contrast. | ||
112 | window.ACTIVE_COLOR = '#555' // Color for 'while the element is being pressed' | ||
113 | } else if (window.settings.theme == 'light') { | ||
114 | window.BACKGROUND = '#0A8' | ||
115 | window.OUTER_BACKGROUND = '#113833' | ||
116 | window.FOREGROUND = '#344' | ||
117 | window.BORDER = '#000' | ||
118 | window.LINE_DEFAULT = '#AAA' | ||
119 | window.LINE_SUCCESS = '#FFF' | ||
120 | window.LINE_FAIL = '#000' | ||
121 | window.CURSOR = '#FFF' | ||
122 | window.TEXT_COLOR = '#000' | ||
123 | window.PAGE_BACKGROUND = '#FFF' | ||
124 | window.ALT_BACKGROUND = '#EEE' // An off-white. Good for mild contrast. | ||
125 | window.ACTIVE_COLOR = '#DDD' // Color for 'while the element is being pressed' | ||
126 | } | ||
127 | |||
128 | window.LINE_NONE = 0 | ||
129 | window.LINE_BLACK = 1 | ||
130 | window.LINE_BLUE = 2 | ||
131 | window.LINE_YELLOW = 3 | ||
132 | window.LINE_OVERLAP = 4 | ||
133 | window.DOT_NONE = 0 | ||
134 | window.DOT_BLACK = 1 | ||
135 | window.DOT_BLUE = 2 | ||
136 | window.DOT_YELLOW = 3 | ||
137 | window.DOT_INVISIBLE = 4 | ||
138 | window.GAP_NONE = 0 | ||
139 | window.GAP_BREAK = 1 | ||
140 | window.GAP_FULL = 2 | ||
141 | |||
142 | var animations = '' | ||
143 | var l = function(line) {animations += line + '\n'} | ||
144 | // pointer-events: none; allows for events to bubble up (so that editor hooks still work) | ||
145 | l('.line-1 {') | ||
146 | l(' fill: ' + window.LINE_DEFAULT + ';') | ||
147 | l(' pointer-events: none;') | ||
148 | l('}') | ||
149 | l('.line-2 {') | ||
150 | l(' fill: ' + window.LINE_PRIMARY + ';') | ||
151 | l(' pointer-events: none;') | ||
152 | l('}') | ||
153 | l('.line-3 {') | ||
154 | l(' fill: ' + window.LINE_SECONDARY + ';') | ||
155 | l(' pointer-events: none;') | ||
156 | l('}') | ||
157 | l('.line-4 {') | ||
158 | l(' display: none;') | ||
159 | l(' pointer-events: none;') | ||
160 | l('}') | ||
161 | l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}') | ||
162 | l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}') | ||
163 | l('@keyframes error {to {fill: red;}}') | ||
164 | l('@keyframes fade {to {opacity: 0.35;}}') | ||
165 | l('@keyframes start-grow {from {r:12;} to {r:24;}}') | ||
166 | // Neutral button style | ||
167 | l('button {') | ||
168 | l(' background-color: ' + window.ALT_BACKGROUND + ';') | ||
169 | l(' border: 1px solid ' + window.BORDER + ';') | ||
170 | l(' border-radius: 2px;') | ||
171 | l(' color: ' + window.TEXT_COLOR + ';') | ||
172 | l(' display: inline-block;') | ||
173 | l(' margin: 0px;') | ||
174 | l(' outline: none;') | ||
175 | l(' opacity: 1.0;') | ||
176 | l(' padding: 1px 6px;') | ||
177 | l(' -moz-appearance: none;') | ||
178 | l(' -webkit-appearance: none;') | ||
179 | l('}') | ||
180 | // Active (while held down) button style | ||
181 | l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}') | ||
182 | // Disabled button style | ||
183 | l('button:disabled {opacity: 0.5;}') | ||
184 | // Selected button style (see https://stackoverflow.com/a/63108630) | ||
185 | l('button:focus {outline: none;}') | ||
186 | l = null | ||
187 | |||
188 | var style = document.createElement('style') | ||
189 | style.type = 'text/css' | ||
190 | style.title = 'animations' | ||
191 | style.appendChild(document.createTextNode(animations)) | ||
192 | document.head.appendChild(style) | ||
193 | |||
194 | // Custom logging to allow leveling | ||
195 | var consoleError = console.error | ||
196 | var consoleWarn = console.warn | ||
197 | var consoleInfo = console.log | ||
198 | var consoleLog = console.log | ||
199 | var consoleDebug = console.log | ||
200 | var consoleSpam = console.log | ||
201 | var consoleGroup = console.group | ||
202 | var consoleGroupEnd = console.groupEnd | ||
203 | |||
204 | window.setLogLevel = function(level) { | ||
205 | console.error = function() {} | ||
206 | console.warn = function() {} | ||
207 | console.info = function() {} | ||
208 | console.log = function() {} | ||
209 | console.debug = function() {} | ||
210 | console.spam = function() {} | ||
211 | console.group = function() {} | ||
212 | console.groupEnd = function() {} | ||
213 | |||
214 | if (level === 'none') return | ||
215 | |||
216 | // Instead of throw, but still red flags and is easy to find | ||
217 | console.error = consoleError | ||
218 | if (level === 'error') return | ||
219 | |||
220 | // Less serious than error, but flagged nonetheless | ||
221 | console.warn = consoleWarn | ||
222 | if (level === 'warn') return | ||
223 | |||
224 | // Default visible, important information | ||
225 | console.info = consoleInfo | ||
226 | if (level === 'info') return | ||
227 | |||
228 | // Useful for debugging (mainly validation) | ||
229 | console.log = consoleLog | ||
230 | if (level === 'log') return | ||
231 | |||
232 | // Useful for serious debugging (mainly graphics/misc) | ||
233 | console.debug = consoleDebug | ||
234 | if (level === 'debug') return | ||
235 | |||
236 | // Useful for insane debugging (mainly tracing/recursion) | ||
237 | console.spam = consoleSpam | ||
238 | console.group = consoleGroup | ||
239 | console.groupEnd = consoleGroupEnd | ||
240 | if (level === 'spam') return | ||
241 | } | ||
242 | setLogLevel('info') | ||
243 | |||
244 | window.deleteElementsByClassName = function(rootElem, className) { | ||
245 | var elems = [] | ||
246 | while (true) { | ||
247 | elems = rootElem.getElementsByClassName(className) | ||
248 | if (elems.length === 0) break | ||
249 | elems[0].remove() | ||
250 | } | ||
251 | } | ||
252 | |||
253 | // Automatically solve the puzzle | ||
254 | window.solvePuzzle = function() { | ||
255 | if (window.setSolveMode) window.setSolveMode(false) | ||
256 | document.getElementById('solutionViewer').style.display = 'none' | ||
257 | document.getElementById('progressBox').style.display = null | ||
258 | document.getElementById('solveAuto').innerText = 'Cancel Solving' | ||
259 | document.getElementById('solveAuto').onpointerdown = function() { | ||
260 | this.innerText = 'Cancelling...' | ||
261 | this.onpointerdown = null | ||
262 | window.setTimeout(window.cancelSolving, 0) | ||
263 | } | ||
264 | |||
265 | window.solve(window.puzzle, function(percent) { | ||
266 | document.getElementById('progressPercent').innerText = percent + '%' | ||
267 | document.getElementById('progress').style.width = percent + '%' | ||
268 | }, function(paths) { | ||
269 | document.getElementById('progressBox').style.display = 'none' | ||
270 | document.getElementById('solutionViewer').style.display = null | ||
271 | document.getElementById('progressPercent').innerText = '0%' | ||
272 | document.getElementById('progress').style.width = '0%' | ||
273 | document.getElementById('solveAuto').innerText = 'Solve (automatically)' | ||
274 | document.getElementById('solveAuto').onpointerdown = solvePuzzle | ||
275 | |||
276 | window.puzzle.autoSolved = true | ||
277 | paths = window.onSolvedPuzzle(paths) | ||
278 | window.showSolution(window.puzzle, paths, 0) | ||
279 | }) | ||
280 | } | ||
281 | |||
282 | window.showSolution = function(puzzle, paths, num, suffix) { | ||
283 | if (suffix == null) { | ||
284 | var previousSolution = document.getElementById('previousSolution') | ||
285 | var solutionCount = document.getElementById('solutionCount') | ||
286 | var nextSolution = document.getElementById('nextSolution') | ||
287 | } else if (suffix instanceof Array) { | ||
288 | var previousSolution = document.getElementById('previousSolution-' + suffix[0]) | ||
289 | var solutionCount = document.getElementById('solutionCount-' + suffix[0]) | ||
290 | var nextSolution = document.getElementById('nextSolution-' + suffix[0]) | ||
291 | } else { | ||
292 | var previousSolution = document.getElementById('previousSolution-' + suffix) | ||
293 | var solutionCount = document.getElementById('solutionCount-' + suffix) | ||
294 | var nextSolution = document.getElementById('nextSolution-' + suffix) | ||
295 | } | ||
296 | |||
297 | if (paths.length === 0) { // 0 paths, arrows are useless | ||
298 | solutionCount.innerText = '0 of 0' | ||
299 | previousSolution.disable() | ||
300 | nextSolution.disable() | ||
301 | return | ||
302 | } | ||
303 | |||
304 | while (num < 0) num = paths.length + num | ||
305 | while (num >= paths.length) num = num - paths.length | ||
306 | |||
307 | if (paths.length === 1) { // 1 path, arrows are useless | ||
308 | solutionCount.innerText = '1 of 1' | ||
309 | if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' | ||
310 | previousSolution.disable() | ||
311 | nextSolution.disable() | ||
312 | } else { | ||
313 | solutionCount.innerText = (num + 1) + ' of ' + paths.length | ||
314 | if (paths.length >= window.MAX_SOLUTIONS) solutionCount.innerText += '+' | ||
315 | previousSolution.enable() | ||
316 | nextSolution.enable() | ||
317 | previousSolution.onpointerdown = function(event) { | ||
318 | if (event.shiftKey) { | ||
319 | window.showSolution(puzzle, paths, num - 10, suffix) | ||
320 | } else { | ||
321 | window.showSolution(puzzle, paths, num - 1, suffix) | ||
322 | } | ||
323 | } | ||
324 | nextSolution.onpointerdown = function(event) { | ||
325 | if (event.shiftKey) { | ||
326 | window.showSolution(puzzle, paths, num + 10, suffix) | ||
327 | } else { | ||
328 | window.showSolution(puzzle, paths, num + 1, suffix) | ||
329 | } | ||
330 | } | ||
331 | } | ||
332 | |||
333 | if (paths[num] != null) { | ||
334 | if (puzzle instanceof Array) { // Special case for multiple related panels | ||
335 | for (var i = 0; i < puzzle.length; i++) { | ||
336 | // Save the current path on the puzzle object (so that we can pass it along with publishing) | ||
337 | puzzle.path = paths[num][i] | ||
338 | // Draws the given path, and also updates the puzzle to have path annotations on it. | ||
339 | window.drawPath(puzzle[i], paths[num][i], suffix[i]) | ||
340 | } | ||
341 | } else { // Default case for a single panel | ||
342 | // Save the current path on the puzzle object (so that we can pass it along with publishing) | ||
343 | puzzle.path = paths[num] | ||
344 | // Draws the given path, and also updates the puzzle to have path annotations on it. | ||
345 | window.drawPath(puzzle, paths[num], suffix) | ||
346 | } | ||
347 | } | ||
348 | } | ||
349 | |||
350 | window.createCheckbox = function() { | ||
351 | var checkbox = document.createElement('div') | ||
352 | checkbox.style.width = '22px' | ||
353 | checkbox.style.height = '22px' | ||
354 | checkbox.style.borderRadius = '6px' | ||
355 | checkbox.style.display = 'inline-block' | ||
356 | checkbox.style.verticalAlign = 'text-bottom' | ||
357 | checkbox.style.marginRight = '6px' | ||
358 | checkbox.style.borderWidth = '1.5px' | ||
359 | checkbox.style.borderStyle = 'solid' | ||
360 | checkbox.style.borderColor = window.BORDER | ||
361 | checkbox.style.background = window.PAGE_BACKGROUND | ||
362 | checkbox.style.color = window.TEXT_COLOR | ||
363 | return checkbox | ||
364 | } | ||
365 | |||
366 | // Required global variables/functions: <-- HINT: This means you're writing bad code. | ||
367 | // window.puzzle | ||
368 | // window.onSolvedPuzzle() | ||
369 | // window.MAX_SOLUTIONS // defined by solve.js | ||
370 | window.addSolveButtons = function() { | ||
371 | var parent = document.currentScript.parentElement | ||
372 | |||
373 | var solveMode = createCheckbox() | ||
374 | solveMode.id = 'solveMode' | ||
375 | parent.appendChild(solveMode) | ||
376 | |||
377 | solveMode.onpointerdown = function() { | ||
378 | this.checked = !this.checked | ||
379 | this.style.background = (this.checked ? window.BORDER : window.PAGE_BACKGROUND) | ||
380 | document.getElementById('solutionViewer').style.display = 'none' | ||
381 | if (window.setSolveMode) window.setSolveMode(this.checked) | ||
382 | } | ||
383 | |||
384 | var solveManual = document.createElement('label') | ||
385 | parent.appendChild(solveManual) | ||
386 | solveManual.id = 'solveManual' | ||
387 | solveManual.onpointerdown = function() {solveMode.onpointerdown()} | ||
388 | solveManual.innerText = 'Solve (manually)' | ||
389 | solveManual.style = 'margin-right: 8px' | ||
390 | |||
391 | var solveAuto = document.createElement('button') | ||
392 | parent.appendChild(solveAuto) | ||
393 | solveAuto.id = 'solveAuto' | ||
394 | solveAuto.innerText = 'Solve (automatically)' | ||
395 | solveAuto.onpointerdown = solvePuzzle | ||
396 | solveAuto.style = 'margin-right: 8px' | ||
397 | |||
398 | var div = document.createElement('div') | ||
399 | parent.appendChild(div) | ||
400 | div.style = 'display: inline-block; vertical-align:top' | ||
401 | |||
402 | var progressBox = document.createElement('div') | ||
403 | div.appendChild(progressBox) | ||
404 | progressBox.id = 'progressBox' | ||
405 | progressBox.style = 'display: none; width: 220px; border: 1px solid black; margin-top: 2px' | ||
406 | |||
407 | var progressPercent = document.createElement('label') | ||
408 | progressBox.appendChild(progressPercent) | ||
409 | progressPercent.id = 'progressPercent' | ||
410 | progressPercent.style = 'float: left; margin-left: 4px' | ||
411 | progressPercent.innerText = '0%' | ||
412 | |||
413 | var progress = document.createElement('div') | ||
414 | progressBox.appendChild(progress) | ||
415 | progress.id = 'progress' | ||
416 | progress.style = 'z-index: -1; height: 38px; width: 0%; background-color: #390' | ||
417 | |||
418 | var solutionViewer = document.createElement('div') | ||
419 | div.appendChild(solutionViewer) | ||
420 | solutionViewer.id = 'solutionViewer' | ||
421 | solutionViewer.style = 'display: none' | ||
422 | |||
423 | var previousSolution = document.createElement('button') | ||
424 | solutionViewer.appendChild(previousSolution) | ||
425 | previousSolution.id = 'previousSolution' | ||
426 | previousSolution.innerHTML = '←' | ||
427 | |||
428 | var solutionCount = document.createElement('label') | ||
429 | solutionViewer.appendChild(solutionCount) | ||
430 | solutionCount.id = 'solutionCount' | ||
431 | solutionCount.style = 'padding: 6px' | ||
432 | |||
433 | var nextSolution = document.createElement('button') | ||
434 | solutionViewer.appendChild(nextSolution) | ||
435 | nextSolution.id = 'nextSolution' | ||
436 | nextSolution.innerHTML = '→' | ||
437 | } | ||
438 | |||
439 | var SECONDS_PER_LOOP = 1 | ||
440 | window.httpGetLoop = function(url, maxTimeout, action, onError, onSuccess) { | ||
441 | if (maxTimeout <= 0) { | ||
442 | onError() | ||
443 | return | ||
444 | } | ||
445 | |||
446 | sendHttpRequest('GET', url, SECONDS_PER_LOOP, null, function(httpCode, response) { | ||
447 | if (httpCode >= 200 && httpCode <= 299) { | ||
448 | var output = action(JSON.parse(response)) | ||
449 | if (output) { | ||
450 | onSuccess(output) | ||
451 | return | ||
452 | } // Retry if action returns null | ||
453 | } // Retry on non-success HTTP codes | ||
454 | |||
455 | window.setTimeout(function() { | ||
456 | httpGetLoop(url, maxTimeout - SECONDS_PER_LOOP, action, onError, onSuccess) | ||
457 | }, 1000) | ||
458 | }) | ||
459 | } | ||
460 | |||
461 | window.fireAndForget = function(verb, url, body) { | ||
462 | sendHttpRequest(verb, url, 600, body, function() {}) | ||
463 | } | ||
464 | |||
465 | // Only used for errors | ||
466 | var HTTP_STATUS = { | ||
467 | 401: '401 unauthorized', 403: '403 forbidden', 404: '404 not found', 409: '409 conflict', 413: '413 payload too large', | ||
468 | 500: '500 internal server error', | ||
469 | } | ||
470 | |||
471 | var etagCache = {} | ||
472 | function sendHttpRequest(verb, url, timeoutSeconds, data, onResponse) { | ||
473 | currentHttpRequest = new XMLHttpRequest() | ||
474 | currentHttpRequest.onreadystatechange = function() { | ||
475 | if (this.readyState != XMLHttpRequest.DONE) return | ||
476 | etagCache[url] = this.getResponseHeader('ETag') | ||
477 | currentHttpRequest = null | ||
478 | onResponse(this.status, this.responseText || HTTP_STATUS[this.status]) | ||
479 | } | ||
480 | currentHttpRequest.ontimeout = function() { | ||
481 | currentHttpRequest = null | ||
482 | onResponse(0, 'Request timed out') | ||
483 | } | ||
484 | currentHttpRequest.timeout = timeoutSeconds * 1000 | ||
485 | currentHttpRequest.open(verb, url, true) | ||
486 | currentHttpRequest.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') | ||
487 | |||
488 | var etag = etagCache[url] | ||
489 | if (etag != null) currentHttpRequest.setRequestHeader('If-None-Match', etag) | ||
490 | |||
491 | currentHttpRequest.send(data) | ||
492 | } | ||
493 | |||
494 | function sendFeedback(feedback) { | ||
495 | console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.') | ||
496 | } | ||
497 | |||
498 | }) | ||