about summary refs log tree commit diff stats
path: root/app/assets/javascripts/utilities.js.erb
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/utilities.js.erb')
-rw-r--r--app/assets/javascripts/utilities.js.erb498
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 @@
1function namespace(code) {
2 code()
3}
4
5namespace(function() {
6
7/*** Start cross-compatibility ***/
8// Used to detect if IDs include a direction, e.g. resize-top-left
9if (!String.prototype.includes) {
10 String.prototype.includes = function() {
11 return String.prototype.indexOf.apply(this, arguments) !== -1
12 }
13}
14Event.prototype.movementX = Event.prototype.movementX || Event.prototype.mozMovementX
15Event.prototype.movementY = Event.prototype.movementY || Event.prototype.mozMovementY
16Event.prototype.isRightClick = function() {
17 return this.which === 3 || (this.touches && this.touches.length > 1)
18}
19Element.prototype.disable = function() {
20 this.disabled = true
21 this.style.pointerEvents = 'none'
22 this.className = 'noselect'
23}
24Element.prototype.enable = function() {
25 this.disabled = false
26 this.style.pointerEvents = null
27 this.className = null
28}
29Object.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
39var 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}
75window.settings = new Proxy({}, proxy.init())
76
77var 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
84var currentAudio = null
85window.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
97window.LINE_PRIMARY = '#8FF'
98window.LINE_SECONDARY = '#FF2'
99
100if (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
128window.LINE_NONE = 0
129window.LINE_BLACK = 1
130window.LINE_BLUE = 2
131window.LINE_YELLOW = 3
132window.LINE_OVERLAP = 4
133window.DOT_NONE = 0
134window.DOT_BLACK = 1
135window.DOT_BLUE = 2
136window.DOT_YELLOW = 3
137window.DOT_INVISIBLE = 4
138window.GAP_NONE = 0
139window.GAP_BREAK = 1
140window.GAP_FULL = 2
141
142var animations = ''
143var l = function(line) {animations += line + '\n'}
144// pointer-events: none; allows for events to bubble up (so that editor hooks still work)
145l('.line-1 {')
146l(' fill: ' + window.LINE_DEFAULT + ';')
147l(' pointer-events: none;')
148l('}')
149l('.line-2 {')
150l(' fill: ' + window.LINE_PRIMARY + ';')
151l(' pointer-events: none;')
152l('}')
153l('.line-3 {')
154l(' fill: ' + window.LINE_SECONDARY + ';')
155l(' pointer-events: none;')
156l('}')
157l('.line-4 {')
158l(' display: none;')
159l(' pointer-events: none;')
160l('}')
161l('@keyframes line-success {to {fill: ' + window.LINE_SUCCESS + ';}}')
162l('@keyframes line-fail {to {fill: ' + window.LINE_FAIL + ';}}')
163l('@keyframes error {to {fill: red;}}')
164l('@keyframes fade {to {opacity: 0.35;}}')
165l('@keyframes start-grow {from {r:12;} to {r:24;}}')
166// Neutral button style
167l('button {')
168l(' background-color: ' + window.ALT_BACKGROUND + ';')
169l(' border: 1px solid ' + window.BORDER + ';')
170l(' border-radius: 2px;')
171l(' color: ' + window.TEXT_COLOR + ';')
172l(' display: inline-block;')
173l(' margin: 0px;')
174l(' outline: none;')
175l(' opacity: 1.0;')
176l(' padding: 1px 6px;')
177l(' -moz-appearance: none;')
178l(' -webkit-appearance: none;')
179l('}')
180// Active (while held down) button style
181l('button:active {background-color: ' + window.ACTIVE_COLOR + ';}')
182// Disabled button style
183l('button:disabled {opacity: 0.5;}')
184// Selected button style (see https://stackoverflow.com/a/63108630)
185l('button:focus {outline: none;}')
186l = null
187
188var style = document.createElement('style')
189style.type = 'text/css'
190style.title = 'animations'
191style.appendChild(document.createTextNode(animations))
192document.head.appendChild(style)
193
194// Custom logging to allow leveling
195var consoleError = console.error
196var consoleWarn = console.warn
197var consoleInfo = console.log
198var consoleLog = console.log
199var consoleDebug = console.log
200var consoleSpam = console.log
201var consoleGroup = console.group
202var consoleGroupEnd = console.groupEnd
203
204window.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}
242setLogLevel('info')
243
244window.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
254window.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
282window.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
350window.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
370window.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 = '&larr;'
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 = '&rarr;'
437}
438
439var SECONDS_PER_LOOP = 1
440window.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
461window.fireAndForget = function(verb, url, body) {
462 sendHttpRequest(verb, url, 600, body, function() {})
463}
464
465// Only used for errors
466var 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
471var etagCache = {}
472function 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
494function sendFeedback(feedback) {
495 console.error('Please disregard the following CORS exception. It is expected and the request will succeed regardless.')
496}
497
498})