· 6 years ago · Oct 16, 2019, 05:18 PM
1// ==UserScript==
2// @name Wayfarer-Tools
3// @version 2.0.1
4// @description formerly known as OPR-Tools
5// @homepageURL https://gitlab.com/1110101/opr-tools
6// @author 1110101, https://gitlab.com/1110101/opr-tools/graphs/master
7// @match https://wayfarer.nianticlabs.com/review
8// @grant unsafeWindow
9// @grant GM_notification
10// @grant GM_addStyle
11
12
13// @supportURL https://gitlab.com/1110101/opr-tools/issues
14// @require https://cdnjs.cloudflare.com/ajax/libs/alertifyjs-alertify.js/1.0.11/js/alertify.js
15// @require https://cdnjs.cloudflare.com/ajax/libs/proj4js/2.4.4/proj4.js
16
17// ==/UserScript==
18
19// source https://gitlab.com/1110101/opr-tools
20// merge-requests welcome
21
22/*
23MIT License
24
25Permission is hereby granted, free of charge, to any person obtaining a copy
26of this software and associated documentation files (the "Software"), to deal
27in the Software without restriction, including without limitation the rights
28to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
29copies of the Software, and to permit persons to whom the Software is
30furnished to do so, subject to the following conditions:
31
32The above copyright notice and this permission notice shall be included in all
33copies or substantial portions of the Software.
34
35THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
36IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
37FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
38AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
39LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
40OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
41SOFTWARE.
42
43*/
44
45/* globals screen, MutationObserver, addEventListener, localStorage, MutationObserver, GM_addStyle, GM_notification, unsafeWindow, angular, google, alertify, proj4 */
46
47const WFRT = {
48
49 VERSION: 20000,
50
51 PREFERENCES: 'wfrt_prefs',
52
53 OPTIONS: {
54 KEYBOARD_NAV: 'keyboard_nav',
55 NORWAY_MAP_LAYER: 'norway_map_layer',
56 PRESET_FEATURE: 'preset_feature',
57 SCANNER_OFFSET_FEATURE: 'scanner_offset_feature',
58 SCANNER_OFFSET_UI: 'scanner_offset_ui',
59 COMMENT_TEMPLATES: 'comment_templates',
60 MAP_CIRCLE_20: 'map_circle_20',
61 MAP_CIRCLE_40: 'map_circle_40',
62
63 REFRESH: 'refresh',
64 REFRESH_NOTI_DESKTOP: 'refresh_noti_desktop'
65 },
66
67 PREFIX: 'wfrt_',
68 VAR_PREFIX: 'wfrt_var', // used in import/export **only**
69 VAR: { // will be included in import/export
70 SCANNER_OFFSET: 'scanner_offset',
71 MAP_TYPE_1: 'map_type_1',
72 MAP_TYPE_2: 'map_type_2',
73 CUSTOM_PRESETS: 'custom_presets'
74 },
75
76 VERSION_CHECK: 'version_check', // outside var, because it should not get Exported
77
78 FROM_REFRESH: 'from_refresh' // sessionStorage
79}
80
81function addGlobalStyle (css) {
82 GM_addStyle(css)
83 // noop after first run
84 addGlobalStyle = () => {} // eslint-disable-line no-func-assign
85}
86
87class Preferences {
88 constructor () {
89 this.options = {}
90 this.defaults = {
91 [WFRT.OPTIONS.KEYBOARD_NAV]: true,
92 [WFRT.OPTIONS.NORWAY_MAP_LAYER]: false,
93 [WFRT.OPTIONS.PRESET_FEATURE]: true,
94 [WFRT.OPTIONS.SCANNER_OFFSET_FEATURE]: false,
95 [WFRT.OPTIONS.SCANNER_OFFSET_UI]: false,
96 [WFRT.OPTIONS.COMMENT_TEMPLATES]: true,
97 [WFRT.OPTIONS.REFRESH]: true,
98 [WFRT.OPTIONS.REFRESH_NOTI_DESKTOP]: true,
99 [WFRT.OPTIONS.MAP_CIRCLE_20]: false,
100 [WFRT.OPTIONS.MAP_CIRCLE_40]: true
101 }
102 this.loadOptions()
103 }
104
105 showPreferencesUI (w) {
106 let inout = new InOut(this)
107 let pageContainer = w.document.querySelector('#content-container')
108 let wfrtPreferences = w.document.querySelector('#wfrt_sidepanel_container')
109
110 if (wfrtPreferences !== null) wfrtPreferences.classList.toggle('hide')
111 else {
112 pageContainer.insertAdjacentHTML('afterbegin', `
113<section id="wfrt_sidepanel_container" style="
114 background: black;
115 border-left: 2px gold inset;
116 border-top: 2px gold inset;
117 border-bottom: 2px gold inset;
118 position: absolute;
119 right: 0;
120 height: 90%;
121 padding: 0 20px;
122 z-index: 10;
123 width: 400px;
124 ">
125 <div class="row">
126 <div class="col-lg-12">
127 <h4 class="gold">Wayfarer-Tools Preferences</h4>
128 </div>
129 <div class="col-lg-12">
130 <div class="btn-group" role="group">
131 <button id="import_all" class="btn btn-success">Import</button>
132 <button id="export_all" class="btn btn-success">Export</button>
133 </div>
134 </div>
135 </div>
136 <div id="wfrt_options"></div>
137 <a id="wfrt_reload" class="btn btn-warning hide"><span class="glyphicon glyphicon-refresh"></span>
138 Reload to apply changes</a>
139
140 <div style="position: absolute; bottom: 0; left: 0; margin:20px;"><a href="https://t.me/oprtools">${TG_SVG} Wayfarer-Tools Telegram Channel</a></div>
141</section>`)
142
143 let optionsContainer = w.document.getElementById('wfrt_options')
144 let reloadButton = w.document.getElementById('wfrt_reload')
145
146 for (let item in this.options) {
147 // remove unknown or removed options
148 if (strings.options[item] === undefined) {
149 this.remove(item)
150 continue
151 }
152
153 const div = w.document.createElement('div')
154 div.classList.add('checkbox')
155 const label = w.document.createElement('label')
156 const input = w.document.createElement('input')
157 input.type = 'checkbox'
158 input.name = item
159 input.checked = this.options[item]
160 div.appendChild(label)
161 label.appendChild(input)
162 label.appendChild(w.document.createTextNode(strings.options[item]))
163 optionsContainer.insertAdjacentElement('beforeEnd', div)
164 }
165
166 optionsContainer.addEventListener('change', (event) => {
167 this.set(event.target.name, event.target.checked)
168 reloadButton.classList.remove('hide')
169 })
170
171 reloadButton.addEventListener('click', () => {
172 window.location.reload()
173 })
174
175 w.document.getElementById('import_all').addEventListener('click', () => {
176 alertify.okBtn('Import').prompt('Paste here:',
177 (value, event) => {
178 event.preventDefault()
179 if (value === 'undefined' || value === '') {
180 return
181 }
182 inout.importFromString(value)
183 alertify.success(`✔ Imported preferences`)
184 }, event => {
185 event.preventDefault()
186 }
187 )
188 })
189
190 w.document.getElementById('export_all').addEventListener('click', () => {
191 if (navigator.clipboard !== undefined) {
192 navigator.clipboard.writeText(inout.exportAll()).then(() => {
193 alertify.success(`✔ Exported preferences to your clipboard!`)
194 }, () => {
195 // ugly alert as fallback
196 alertify.alert(inout.exportAll())
197 })
198 } else {
199 alertify.alert(inout.exportAll())
200 }
201 }
202 )
203 }
204 }
205
206 loadOptions () {
207 Object.assign(this.options, this.defaults, JSON.parse(localStorage.getItem(WFRT.PREFERENCES)))
208 }
209
210 set (key, value) {
211 this.options[key] = value
212 localStorage.setItem(WFRT.PREFERENCES, JSON.stringify(this.options))
213 }
214
215 get (key) {
216 return this.options[key]
217 }
218
219 remove (key) {
220 delete this.options[key]
221 localStorage.setItem(WFRT.PREFERENCES, JSON.stringify(this.options))
222 }
223
224 exportPrefs () {
225 return JSON.stringify(this.options)
226 }
227
228 importPrefs (string) {
229 try {
230 this.options = JSON.parse(string)
231 localStorage.setItem(WFRT.PREFERENCES, JSON.stringify(this.options))
232 } catch (e) {
233 throw new Error('Could not import preferences!')
234 }
235 }
236}
237
238class InOut {
239 constructor (preferences) {
240 this.preferences = preferences
241 }
242
243 static exportVars () {
244 let exportObject = {}
245 for (const item in WFRT.VAR) {
246 exportObject[WFRT.VAR[item]] = localStorage.getItem(WFRT.PREFIX + WFRT.VAR[item])
247 }
248 return exportObject
249 }
250
251 static importVars (string) {
252 let importObject = JSON.parse(string)
253 for (const item of importObject) {
254 localStorage.setItem(WFRT.PREFIX + item, importObject[item])
255 }
256 }
257
258 importFromString (string) {
259 try {
260 let json = JSON.parse(string)
261
262 if (json.hasOwnProperty(WFRT.PREFERENCES)) { this.preferences.importPrefs(json[WFRT.PREFERENCES]) }
263
264 if (json.hasOwnProperty(WFRT.VAR)) { InOut.importVars(json[WFRT.VAR]) }
265 } catch (e) {
266 throw new Error('Import failed')
267 }
268 }
269
270 exportAll () {
271 return JSON.stringify(Object.assign({}, { [WFRT.PREFERENCES]: this.preferences.exportPrefs() }, { [WFRT.VAR_PREFIX]: InOut.exportVars() }))
272 }
273}
274
275function nacistUrl(url_load) {
276
277 url_load = decodeURIComponent(url_load);
278
279 var pano_id;
280
281 if(url_load.indexOf('imagekey') !== -1) {
282 var s1 = url_load.indexOf('!2s') + 3;
283
284 var s2 = url_load.indexOf('&', s1);
285 if(s2 === -1) {
286 pano_id = url_load.substring(s1);
287 } else {
288
289 pano_id = url_load.substring(s1, s2)
290 }
291
292 } else {
293 var s3 = url_load.indexOf('!1s') + 3;
294 pano_id = url_load.substring(s3, url_load.indexOf('!2e', s3));
295 }
296
297 if (!pano_id) pano_id = ' ';
298 //if(pano_id.charAt(0) == '-') pano_id = ''+ pano_id.replace(/%2F/g, '/');
299 if(pano_id.indexOf('/') > -1 && pano_id.substring(0, 2) != 'F:' && pano_id.charAt(0) == '-') {
300 pano_id = 'F:' + pano_id;
301 } else if(pano_id.substring(0, 2) == 'AF' && pano_id.length === 44) {
302 pano_id = 'CAoSLE' + btoa('CAoS' + pano_id).substr(6);
303 }
304 return pano_id;
305
306 }
307
308function getSVLink() {
309for (let e of document.getElementsByTagName("a")) {
310 if (e.target==="_blank" && e.href.includes('apiv3') && e.innerHTML.includes('Zobacz w Mapach Google')) {
311 return e;
312 }
313}
314}
315
316function init () {
317
318
319
320
321
322 const w = typeof unsafeWindow === 'undefined' ? window : unsafeWindow
323 let tryNumber = 15
324
325 let wfrtCustomPresets
326
327 let browserLocale = window.navigator.languages[0] || window.navigator.language || 'en'
328
329 let preferences = new Preferences()
330
331 const initWatcher = setInterval(() => {
332 if (tryNumber === 0) {
333 clearInterval(initWatcher)
334 w.document.getElementById('NewSubmissionController')
335 .insertAdjacentHTML('afterBegin', `
336<div id="wfrt_init_failed" class='alert alert-danger'><strong><span class='glyphicon glyphicon-remove'></span> Wayfarer-Tools initialization failed, refresh page</strong></div>
337`)
338 addRefreshContainer()
339 return
340 }
341 if (w.angular) {
342 let err = false
343 try {
344 initAngular()
345 } catch (error) {
346 err = error
347 // console.log(error);
348 }
349 if (!err) {
350 try {
351 initScript()
352 clearInterval(initWatcher)
353 } catch (error) {
354 console.log(error)
355 if (error.message === '41') {
356 addRefreshContainer()
357 }
358 if (error.message !== '42') {
359 clearInterval(initWatcher)
360 }
361 }
362 }
363 }
364 tryNumber--
365 }, 1000)
366
367 function initAngular () {
368 const el = w.document.querySelector('[ng-app="portalApp"]')
369 w.$app = w.angular.element(el)
370 w.$injector = w.$app.injector()
371 w.inject = w.$injector.invoke
372 w.$rootScope = w.$app.scope()
373
374 w.getService = function getService (serviceName) {
375 w.inject([serviceName, function (s) { w[serviceName] = s }])
376 }
377
378 w.$scope = element => w.angular.element(element).scope()
379 }
380
381 function initScript () {
382 // adding CSS
383 addGlobalStyle(GLOBAL_CSS)
384
385 // modifyHeader()
386
387 addOptionsButton()
388
389 const subMissionDiv = w.document.getElementById('NewSubmissionController')
390
391 // check if subCtrl exists (should exists if we're on /review)
392 if (subMissionDiv !== null && w.$scope(subMissionDiv).subCtrl !== null) {
393 const subController = w.$scope(subMissionDiv).subCtrl
394 const newPortalData = subController.pageData
395
396 const whatController = w.$scope(w.document.getElementById('WhatIsItController')).whatCtrl
397
398 const answerDiv = w.document.getElementById('AnswersController')
399 const ansController = w.$scope(answerDiv).answerCtrl
400
401 if (subController.errorMessage !== '') {
402 // no portal analysis data available
403 throw new Error(41) // @todo better error code
404 }
405
406 if (typeof newPortalData === 'undefined') {
407 // no submission data present
408 throw new Error(42) // @todo better error code
409 }
410
411 // detect portal edit
412 if (subController.reviewType === 'NEW') {
413 modifyNewPage(ansController, subController, whatController, newPortalData)
414 } else if (subController.reviewType === 'EDIT') {
415 modifyEditPage(ansController, subController, newPortalData)
416 }
417
418 checkIfAutorefresh()
419
420 startExpirationTimer(subController)
421
422 versionCheck()
423 }
424 }
425
426 function modifyNewPage (ansController, subController, whatController, newPortalData) {
427 let skipDialog = false
428
429 mapButtons(newPortalData, w.document.querySelector('#map-card .card__footer'), 'afterBegin')
430
431 // mutation observer
432 const bodyObserver = new MutationObserver(mutationList => {
433 for (let mutationRecord of mutationList) {
434 // we just want added nodes with (class:modal). null and undefined check for performance reasons
435 if (mutationRecord.addedNodes.length > 0 && mutationRecord.addedNodes[0].className === 'modal fade ng-isolate-scope') {
436 // adds keyboard-numbers to low quality sub-sub-lists
437 let sublistItems = mutationRecord.addedNodes[0].querySelectorAll('ul.sub-group-list')
438 if (sublistItems !== undefined) {
439 sublistItems.forEach(el => {
440 let i = 1
441 el.querySelectorAll('li > a').forEach(el2 => { el2.insertAdjacentHTML('afterbegin', `<kbd>${i++}</kbd> `) })
442 })
443 let i = 1
444 // adds keyboard numbers to low quality sub-list
445 mutationRecord.addedNodes[0].querySelectorAll('label.sub-group')
446 .forEach(el2 => { el2.insertAdjacentHTML('beforeend', `<kbd class="pull-right ">${i++}</kbd>`) })
447 }
448 // skip "Your analysis has been recorded" dialog
449 if (skipDialog) {
450 if (mutationRecord.addedNodes[0].querySelector('.modal-body a[href=\'/review\']') !== null) {
451 w.document.location.href = '/review'
452 return
453 }
454 }
455 }
456 }
457 })
458 bodyObserver.observe(w.document.body, { childList: true })
459
460 // let newSubmitDiv = moveSubmitButton()
461 let newSubmitDiv = w.document.querySelector('.answer-btn-container.bottom-btns')
462 let { submitButton, submitAndNext } = quickSubmitButton(newSubmitDiv, ansController, bodyObserver)
463
464 if (preferences.get(WFRT.OPTIONS.COMMENT_TEMPLATES)) { commentTemplates() }
465
466 /* region presets start */
467 if (preferences.get(WFRT.OPTIONS.PRESET_FEATURE)) {
468 const customPresetUI = `<div class="card" id="wfrt_custom_presets_card" style="
469 width: 100%;
470 height: auto;
471 min-height: unset;
472 margin-left: 15px;
473"><div class="card__body"><div>Presets <button class="button btn btn-default btn-xs" id="addPreset">+</button>
474<button id="getSVCode">Get SV Code</button></div>
475<input type="text" id="getSVCodeText">
476
477 <div class='btn-group' id="wfrt_custom_presets"></div></div></div>
478`
479
480 w.document.querySelector('.card-row-container').insertAdjacentHTML('afterbegin', customPresetUI)
481
482 addCustomPresetButtons()
483
484 // we have to inject the tooltip to angular
485 w.$injector.invoke(['$compile', ($compile) => {
486 let compiledSubmit = $compile(`<span class="glyphicon glyphicon-info-sign darkgray" uib-tooltip-trigger="outsideclick" uib-tooltip-placement="left" tooltip-class="goldBorder" uib-tooltip="(Wayfarer-Tools) Create your own presets for stuff like churches, playgrounds or crosses'.\nHowto: Answer every question you want included and click on the +Button.\n\nTo delete a preset shift-click it."></span> `)(w.$scope(document.getElementById('descriptionDiv')))
487 w.document.getElementById('addPreset').insertAdjacentElement('beforebegin', compiledSubmit[0])
488 }])
489
490 // click listener for +preset button
491 w.document.getElementById('addPreset').addEventListener('click', event => {
492 alertify.okBtn('Save').prompt('New preset name:',
493 (value, event) => {
494 event.preventDefault()
495 if (value === 'undefined' || value === '') {
496 return
497 }
498 saveCustomPreset(value, ansController, whatController)
499 alertify.success(`✔ Created preset <i>${value}</i>`)
500 addCustomPresetButtons()
501 }, event => {
502 event.preventDefault()
503 }
504 )
505 })
506
507 w.document.getElementById('getSVCode').addEventListener('click', event => {
508 var copyText = document.getElementById('getSVCodeText');
509 copyText.value = nacistUrl(getSVLink());
510 copyText.select();
511 copyText.setSelectionRange(0, 99999); /*For mobile devices*/
512
513 /* Copy the text inside the text field */
514 document.execCommand("copy");
515
516 }
517 )
518
519
520 let clickListener = event => {
521 const source = event.target || event.srcElement
522 let value = source.id
523 if (value === '' || event.target.nodeName !== 'BUTTON') {
524 return
525 }
526
527 let preset = wfrtCustomPresets.find(item => item.uid === value)
528
529 if (event.shiftKey) {
530 alertify.log(`Deleted preset <i>${preset.label}</i>`)
531 w.document.getElementById(preset.uid).remove()
532 deleteCustomPreset(preset)
533 return
534 }
535
536 ansController.formData.quality = preset.quality
537 ansController.formData.description = preset.description
538 ansController.formData.cultural = preset.cultural
539 ansController.formData.uniqueness = preset.uniqueness
540 ansController.formData.location = preset.location
541 ansController.formData.safety = preset.safety
542
543 // the controller's set by ID function doesn't work
544 // and autocomplete breaks if there are any spaces
545 // so set the field to the first word from name and match autocomplete by ID
546 // at the very least, I know this will set it and leave the UI looking like it was manually set.
547 whatController.whatInput = preset.nodeName.split(' ')[0]
548 let nodes = whatController.getWhatAutocomplete()
549 for (let i = 0; i < nodes.length; i++) {
550 if (nodes[i].id === preset.nodeId) {
551 whatController.setWhatAutocompleteNode(nodes[i])
552 break
553 }
554 }
555 whatController.whatInput = ''
556
557 // update ui
558 event.target.blur()
559 w.$rootScope.$apply()
560
561 alertify.success(`✔ Applied <i>${preset.label}</i>`)
562 }
563
564 w.document.getElementById('wfrt_custom_presets').addEventListener('click', clickListener, false)
565 }
566 /* endregion presets end */
567
568 // make photo filmstrip scrollable
569 const filmstrip = w.document.getElementById('map-filmstrip')
570 let lastScrollLeft = filmstrip.scrollLeft
571
572 function scrollHorizontally (e) {
573 e = window.event || e
574 if ((('deltaY' in e && e.deltaY !== 0) || ('wheelDeltaY' in e && e.wheelDeltaY !== 0)) && lastScrollLeft === filmstrip.scrollLeft) {
575 e.preventDefault()
576 const delta = (e.wheelDeltaY || -e.deltaY * 25 || -e.detail)
577 filmstrip.scrollLeft -= (delta)
578 lastScrollLeft = filmstrip.scrollLeft
579 }
580 }
581
582 filmstrip.addEventListener('wheel', scrollHorizontally, false)
583 filmstrip.addEventListener('DOMMouseScroll', scrollHorizontally, false)
584
585 let _initMap = subController.initMap
586 subController.initMap = () => {
587 _initMap()
588 mapMarker(subController.markers)
589 }
590
591 mapOriginCircle(subController.map2)
592 mapMarker(subController.markers)
593 mapTypes(subController.map, false)
594 mapTypes(subController.map2, true)
595
596 // hook resetStreetView() and re-apply map types and options to first map. not needed for duplicates because resetMap() just resets the position
597 let _resetStreetView = subController.resetStreetView
598 subController.resetStreetView = () => {
599 _resetStreetView()
600 mapOriginCircle(subController.map2)
601 mapTypes(subController.map2, true)
602 }
603
604 // adding a green 40m circle and a smaller 20m circle around the new location marker that updates on dragEnd
605 let draggableMarkerCircle
606 let draggableMarkerCircleSmall
607 let _showDraggableMarker = subController.showDraggableMarker
608 subController.showDraggableMarker = () => {
609 _showDraggableMarker()
610
611 w.getService('NewSubmissionDataService')
612 let newLocMarker = w.NewSubmissionDataService.getNewLocationMarker()
613 if (preferences.get(WFRT.OPTIONS.MAP_CIRCLE_40)) {
614 google.maps.event.addListener(newLocMarker, 'dragend', function () {
615 if (draggableMarkerCircle == null) {
616 draggableMarkerCircle = new google.maps.Circle({
617 map: subController.map2,
618 center: newLocMarker.position,
619 radius: 40,
620 strokeColor: '#4CAF50', // material green 500
621 strokeOpacity: 1,
622 strokeWeight: 2,
623 fillOpacity: 0
624 })
625 } else draggableMarkerCircle.setCenter(newLocMarker.position)
626 })
627 }
628
629 if (preferences.get(WFRT.OPTIONS.MAP_CIRCLE_20)) {
630 google.maps.event.addListener(newLocMarker, 'dragend', function () {
631 if (draggableMarkerCircleSmall == null) {
632 draggableMarkerCircleSmall = new google.maps.Circle({
633 map: subController.map2,
634 center: newLocMarker.position,
635 radius: 20,
636 strokeColor: '#4CCF50',
637 strokeOpacity: 1,
638 strokeWeight: 2,
639 fillOpacity: 0
640 })
641 } else draggableMarkerCircleSmall.setCenter(newLocMarker.position)
642 })
643 }
644 }
645
646 if (preferences.get(WFRT.OPTIONS.MAP_CIRCLE_40) || preferences.get(WFRT.OPTIONS.MAP_CIRCLE_20)) {
647 document.querySelector('.flex-map-row').insertAdjacentHTML('beforeEnd',
648 `<small id="wfrt_map_legend">
649 ${preferences.get(WFRT.OPTIONS.MAP_CIRCLE_40) ? '<span style="color:#ebbc4a">outer circle:</span> 40m' : ''}
650 ${preferences.get(WFRT.OPTIONS.MAP_CIRCLE_20) ? '<span style="color:#effc4a">inner circle:</span> 20m' : ''}
651 </small>`)
652 }
653
654 // // move portal rating to the right side. don't move on mobile devices / small width
655 // if (screen.availWidth > 768) {
656 // let nodeToMove = w.document.querySelector('div[class="btn-group"]').parentElement
657 // if (subController.hasSupportingImageOrStatement) {
658 // const descDiv = w.document.getElementById('descriptionDiv')
659 // const scorePanel = descDiv.querySelector('div.text-center.hidden-xs')
660 // scorePanel.insertBefore(nodeToMove, scorePanel.firstChild)
661 // } else {
662 // const scorePanel = w.document.querySelector('div[class~="pull-right"]')
663 // scorePanel.insertBefore(nodeToMove, scorePanel.firstChild)
664 // }
665 // }
666
667 // bind click-event to Dup-Images-Filmstrip. result: a click to the detail-image the large version is loaded in another tab
668 const imgDups = w.document.querySelectorAll('#map-filmstrip > ul > li > img')
669 const openFullImage = function () {
670 w.open(`${this.src}=s0`, 'fulldupimage')
671 }
672 for (let imgSep in imgDups) {
673 if (imgDups.hasOwnProperty(imgSep)) {
674 imgDups[imgSep].addEventListener('click', () => {
675 const imgDup = w.document.querySelector('#content > img')
676 if (imgDup !== null) {
677 imgDup.removeEventListener('click', openFullImage)
678 imgDup.addEventListener('click', openFullImage)
679 imgDup.setAttribute('style', 'cursor: pointer;')
680 }
681 })
682 }
683 }
684
685 // add translate buttons to title and description (if existing)
686 let lang = 'en'
687 try { lang = browserLocale.split('-')[0] } catch (e) {}
688 const titleContainer = w.document.querySelector('h1.title-description')
689 const content = titleContainer.innerText.trim()
690 let a = w.document.createElement('a')
691 let span = w.document.createElement('span')
692 span.className = 'glyphicon glyphicon-book'
693 span.innerHTML = ' '
694 a.appendChild(span)
695 a.className = 'translate-title button btn btn-default pull-right'
696 a.target = 'translate'
697 a.style.setProperty('padding', '0px 4px')
698 a.href = `https://translate.google.com/#auto/${lang}/${encodeURIComponent(content)}`
699 a.id = 'wfrt_translate_title'
700 titleContainer.insertAdjacentElement('beforeend', a)
701
702 const descContainer = w.document.querySelector('h4.title-description')
703 if (descContainer.innerText !== '<No description>' && descContainer.innerText !== '') {
704 a = w.document.createElement('a')
705 span = w.document.createElement('span')
706 span.className = 'glyphicon glyphicon-book'
707 span.innerHTML = ' '
708 a.appendChild(span)
709 a.className = 'translate-description button btn btn-default pull-right'
710 a.target = 'translate'
711 a.style.setProperty('padding', '0px 4px')
712 a.href = `https://translate.google.com/#auto/${lang}/${encodeURIComponent(descContainer)}`
713 a.id = 'wfrt_translate_desc'
714 // const br = w.document.querySelectorAll('#descriptionDiv br')
715 descContainer.insertAdjacentElement('beforeend', a)
716 descContainer.insertAdjacentHTML('beforebegin', '<hr>')
717 }
718
719 const supportingStatement = w.document.querySelector('p[ng-click="subCtrl.showSupportingStatementModal()"]')
720 if (supportingStatement != null && supportingStatement.innerText !== '') {
721 a = w.document.createElement('a')
722 span = w.document.createElement('span')
723 span.className = 'glyphicon glyphicon-book'
724 span.innerHTML = ' '
725 a.appendChild(span)
726 a.className = 'translate-supporting button btn btn-default pull-right'
727 a.target = 'translate'
728 a.style.setProperty('padding', '0px 4px')
729 a.href = `https://translate.google.com/#auto/${lang}/${encodeURIComponent(supportingStatement.innerText)}`
730 a.id = 'wfrt_translate_support'
731 supportingStatement.insertAdjacentElement('beforebegin', a)
732 }
733
734 // automatically open the first listed possible duplicate
735 try {
736 const e = w.document.querySelector('#map-filmstrip > ul > li:nth-child(1) > img')
737 if (e !== null) {
738 setTimeout(() => {
739 e.click()
740 }, 500)
741 }
742 } catch (err) {}
743
744 expandWhatIsItBox()
745
746 // Fix rejectComment width
747 let _showLowQualityModal = ansController.showLowQualityModal
748 ansController.showLowQualityModal = () => {
749 _showLowQualityModal()
750 setTimeout(() => {
751 let rejectReasonTA = w.document.querySelector('textarea[ng-model="answerCtrl2.rejectComment"]')
752 rejectReasonTA.style.setProperty('max-width', '100%')
753
754 w.$injector.invoke(['$compile', ($compile) => {
755 let target = w.document.querySelector('.modal-body button:last-child')
756 let compiledSubmit = $compile(`<button id="submitAndSkipLowQuality" class="button" ng-click="answerCtrl2.confirmLowQuality()" ng-disabled="!(answerCtrl2.readyToSubmitSpam())" disabled="disabled">
757 <span class="glyphicon glyphicon-floppy-disk"></span> <span class="glyphicon glyphicon-forward"></span></button>`)(w.$scope(target))
758 target.insertAdjacentElement('beforebegin', compiledSubmit[0])
759 w.document.getElementById('submitAndSkipLowQuality').addEventListener('click', () => {
760 skipDialog = true
761 })
762 }])
763 }, 10)
764 }
765
766 /* global markDuplicatePressed */
767 let _markDuplicatePressed = markDuplicatePressed
768 markDuplicatePressed = (guid) => { // eslint-disable-line no-global-assign
769 _markDuplicatePressed(guid)
770 setTimeout(() => {
771 w.$injector.invoke(['$compile', ($compile) => {
772 let target = w.document.querySelector('.modal-body button:last-child')
773 let compiledSubmit = $compile(`<button id="submitAndSkipDuplicate" class="button" ng-click="answerCtrl2.confirmDuplicate()">
774 <span class="glyphicon glyphicon-floppy-disk"></span> <span class="glyphicon glyphicon-forward"></span></button>`)(w.$scope(target))
775 target.insertAdjacentElement('beforebegin', compiledSubmit[0])
776 w.document.getElementById('submitAndSkipDuplicate').addEventListener('click', () => {
777 skipDialog = true
778 })
779 }])
780 }, 10)
781 }
782
783 /* region keyboard nav */
784 if (preferences.get(WFRT.OPTIONS.KEYBOARD_NAV)) {
785 activateShortcuts()
786 }
787
788 function activateShortcuts () {
789 // keyboard navigation
790 // documentation: https://gitlab.com/1110101/opr-tools#keyboard-navigation
791
792 let currentSelectable = 0
793 let maxItems = 7
794 let selectedReasonGroup = -1
795 let selectedReasonSubGroup = -1
796
797 // Reset when modal is closed
798 let _resetLowQuality = ansController.resetLowQuality
799 ansController.resetLowQuality = () => {
800 _resetLowQuality()
801 selectedReasonGroup = -1
802 selectedReasonSubGroup = -1
803 currentSelectable = 0
804 highlight()
805 }
806
807 // a list of all 6 star button rows, and the two submit buttons
808 let starsAndSubmitButtons
809 // if (subController.hasSupportingImageOrStatement) {
810 // starsAndSubmitButtons = w.document.querySelectorAll('.col-sm-6 .btn-group, .text-center.hidden-xs:not(.ng-hide) .btn-group, .big-submit-button')
811 // } else {
812 starsAndSubmitButtons = w.document.querySelectorAll('.five-stars, #submitFF')
813
814 // }
815
816 function highlight () {
817 starsAndSubmitButtons.forEach((element) => { element.style.setProperty('border', 'none') })
818 if (currentSelectable <= maxItems - 2) {
819 starsAndSubmitButtons[currentSelectable].style.setProperty('border', '2px dashed #E47252')
820 submitAndNext.blur()
821 submitButton.blur()
822 } else if (currentSelectable === 6) {
823 submitAndNext.focus()
824 } else if (currentSelectable === 7) {
825 submitButton.focus()
826 }
827 }
828
829 addEventListener('keydown', (event) => {
830 /*
831 keycodes:
832
833 8: Backspace
834 9: TAB
835 13: Enter
836 16: Shift
837 27: Escape
838 32: Space
839 68: D
840 107: NUMPAD +
841 109: NUMPAD -
842 111: NUMPAD /
843
844 49 - 53: Keys 1-5
845 97 - 101: NUMPAD 1-5
846
847 */
848
849 let numkey = null
850 if (event.keyCode >= 49 && event.keyCode <= 55) {
851 numkey = event.keyCode - 48
852 } else if (event.keyCode >= 97 && event.keyCode <= 103) {
853 numkey = event.keyCode - 96
854 }
855
856 // do not do anything if a text area or a input with type text has focus
857 if (w.document.querySelector('input[type=text]:focus') || w.document.querySelector('textarea:focus')) {
858 return
859 } else if ((event.keyCode === 13 || event.keyCode === 32) && w.document.querySelector('a.button[href="/review"]')) {
860 // "analyze next" button
861 w.document.location.href = '/review'
862 event.preventDefault()
863 } else if ((event.keyCode === 13 || event.keyCode === 32) && w.document.querySelector('[ng-click="answerCtrl2.confirmLowQuality()"]')) {
864 // submit low quality rating
865 w.document.querySelector('[ng-click="answerCtrl2.confirmLowQuality()"]').click()
866 currentSelectable = 0
867 event.preventDefault()
868 } else if ((event.keyCode === 13 || event.keyCode === 32) && w.document.querySelector('[ng-click="answerCtrl2.confirmLowQualityOld()"]')) {
869 // submit low quality rating alternate
870 w.document.querySelector('[ng-click="answerCtrl2.confirmLowQualityOld()"]').click()
871 currentSelectable = 0
872 event.preventDefault()
873 } else if ((event.keyCode === 68) && w.document.querySelector('#content > button')) {
874 // click first/selected duplicate (key D)
875 w.document.querySelector('#content > button').click()
876 currentSelectable = 0
877 event.preventDefault()
878 } else if (event.keyCode === 84) {
879 // click on translate title link (key T)
880 const link = w.document.querySelector('#descriptionDiv > .translate-title')
881 if (link) {
882 link.click()
883 event.preventDefault()
884 }
885 } else if (event.keyCode === 89) {
886 // click on translate description link (key Y)
887 const link = w.document.querySelector('#descriptionDiv > .translate-description')
888 if (link) {
889 link.click()
890 event.preventDefault()
891 }
892 } else if ((event.keyCode === 13 || event.keyCode === 32) && w.document.querySelector('[ng-click="answerCtrl2.confirmDuplicate()"]')) {
893 // submit duplicate
894 w.document.querySelector('[ng-click="answerCtrl2.confirmDuplicate()"]').click()
895 currentSelectable = 0
896 event.preventDefault()
897 } else if ((event.keyCode === 13 || event.keyCode === 32) && currentSelectable === maxItems) {
898 // submit normal rating
899 w.document.querySelector('[ng-click="answerCtrl.submitForm()"]').click()
900 event.preventDefault()
901 } else if ((event.keyCode === 27 || event.keyCode === 111) && w.document.querySelector('[ng-click="answerCtrl2.resetDuplicate()"]')) {
902 // close duplicate dialog
903 w.document.querySelector('[ng-click="answerCtrl2.resetDuplicate()"]').click()
904 currentSelectable = 0
905 event.preventDefault()
906 } else if ((event.keyCode === 27 || event.keyCode === 111) && w.document.querySelector('[ng-click="answerCtrl2.resetLowQuality()"]')) {
907 // close low quality ration dialog
908 w.document.querySelector('[ng-click="answerCtrl2.resetLowQuality()"]').click()
909 currentSelectable = 0
910 event.preventDefault()
911 } else if (event.keyCode === 27 || event.keyCode === 111) {
912 // return to first selection (should this be a portal)
913 currentSelectable = 0
914 event.preventDefault()
915 } else if (event.keyCode === 106 || event.keyCode === 220) {
916 // skip portal if possible
917 if (newPortalData.canSkip) {
918 ansController.skipToNext()
919 }
920 } else if (event.keyCode === 72) {
921 showHelp() // @todo
922 } else if (w.document.querySelector('[ng-click="answerCtrl2.confirmLowQuality()"]')) {
923 // Reject reason shortcuts
924 if (numkey != null) {
925 if (selectedReasonGroup === -1) {
926 try {
927 w.document.getElementById('sub-group-' + numkey).click()
928 selectedReasonGroup = numkey - 1
929 w.document.querySelectorAll('label.sub-group kbd').forEach(el => el.classList.add('hide'))
930 } catch (err) {}
931 } else {
932 if (selectedReasonSubGroup === -1) {
933 try {
934 w.document.querySelectorAll('#reject-reason ul ul')[selectedReasonGroup].children[numkey - 1].children[0].click()
935 selectedReasonSubGroup = numkey - 1
936 } catch (err) {}
937 } else {
938 w.document.getElementById('root-label').click()
939 selectedReasonGroup = -1
940 selectedReasonSubGroup = -1
941 w.document.querySelectorAll('label.sub-group kbd').forEach(el => el.classList.remove('hide'))
942 }
943 }
944 event.preventDefault()
945 }
946 } else if ((event.keyCode === 107 || event.keyCode === 9) && currentSelectable < maxItems) {
947 // select next rating
948 currentSelectable++
949 event.preventDefault()
950 } else if ((event.keyCode === 109 || event.keyCode === 16 || event.keyCode === 8) && currentSelectable > 0) {
951 // select previous rating
952 currentSelectable--
953 event.preventDefault()
954 } else if (numkey === null || currentSelectable > maxItems - 2) {
955 return
956 } else if (numkey !== null && event.shiftKey) {
957 try {
958 w.document.getElementsByClassName('customPresetButton')[numkey - 1].click()
959 if (!document.getElementById('submitFF').disabled) {
960 currentSelectable = 6
961 highlight()
962 }
963 } catch (e) {
964 // ignore
965 }
966 } else {
967 // rating 1-5
968 starsAndSubmitButtons[currentSelectable].querySelectorAll('button.button-star')[numkey - 1].click()
969 currentSelectable++
970 }
971 highlight()
972 })
973
974 highlight()
975 }
976
977 /* endregion keyboard nav */
978
979 modifyNewPage = () => {} // eslint-disable-line
980 }
981
982 function modifyEditPage (ansController, subController, newPortalData) {
983 let editDiv = w.document.querySelector('div[ng-show="subCtrl.reviewType===\'EDIT\'"]')
984
985 mapButtons(newPortalData, editDiv, 'afterEnd')
986
987 // mutation observer
988 const bodyObserver = new MutationObserver(mutationList => {
989 for (let mutationRecord of mutationList) {
990 // we just want addednodes with (class:modal). null and undefined check for performance reasons
991 if (mutationRecord.addedNodes.length > 0 &&
992 mutationRecord.addedNodes[0].className === 'modal fade ng-isolate-scope' &&
993 mutationRecord.addedNodes[0].querySelector('.modal-body a[href=\'/review\']') !== null) {
994 w.document.location.href = '/review'
995 }
996 }
997 })
998 bodyObserver.observe(w.document.body, { childList: true })
999
1000 let newSubmitDiv = w.document.querySelector('.answer-btn-container.bottom-btns')
1001 let { submitButton, submitAndNext } = quickSubmitButton(newSubmitDiv, ansController, bodyObserver)
1002
1003 if (preferences.get(WFRT.OPTIONS.COMMENT_TEMPLATES)) { commentTemplates() }
1004
1005 mapTypes(subController.locationEditsMap, true)
1006
1007 // add translation links to title and description edits
1008 if (newPortalData.titleEdits.length > 1 || newPortalData.descriptionEdits.length > 1) {
1009 for (const titleEditBox of editDiv.querySelectorAll('.titleEditBox.ng-scope')) {
1010 const contentSpan = titleEditBox.querySelector('.poi-edit-text')
1011 let a = w.document.createElement('a')
1012 let span = w.document.createElement('span')
1013 span.className = 'glyphicon glyphicon-book'
1014 span.innerHTML = ' '
1015 a.appendChild(span)
1016 a.className = 'translate-title button btn btn-default pull-right'
1017 a.target = 'translate'
1018 a.style.setProperty('padding', '0px 4px')
1019 a.href = `https://translate.google.com/#auto/${browserLocale.split('-')[0]}/${encodeURIComponent(contentSpan.innerText.trim())}`
1020 contentSpan.style.setProperty('display', 'inline-block')
1021 contentSpan.insertAdjacentElement('beforeEnd', a)
1022 }
1023 }
1024
1025 if (newPortalData.titleEdits.length <= 1) {
1026 let titleDiv = editDiv.querySelector('div[ng-if="!answerCtrl.needsTitleEdit"]')
1027 let a = w.document.createElement('a')
1028 let span = w.document.createElement('span')
1029 span.className = 'glyphicon glyphicon-book'
1030 span.innerHTML = ' '
1031 a.appendChild(span)
1032 a.className = 'translate-title button btn btn-default'
1033 a.target = 'translate'
1034 a.style.setProperty('padding', '0px 4px')
1035 a.style.setProperty('margin-left', '14px')
1036 a.href = `https://translate.google.com/#auto/${browserLocale.split('-')[0]}/${encodeURIComponent(titleDiv.innerText.trim())}`
1037 titleDiv.insertAdjacentElement('beforeend', a)
1038 }
1039
1040 if (newPortalData.descriptionEdits.length <= 1) {
1041 let titleDiv = editDiv.querySelector('div[ng-if="!answerCtrl.needsDescriptionEdit"]')
1042 const content = titleDiv.innerText.trim() || ''
1043 if (content !== '<No description>' && content !== '') {
1044 let a = w.document.createElement('a')
1045 let span = w.document.createElement('span')
1046 span.className = 'glyphicon glyphicon-book'
1047 span.innerHTML = ' '
1048 a.appendChild(span)
1049 a.className = 'translate-title button btn btn-default'
1050 a.target = 'translate'
1051 a.style.setProperty('padding', '0px 4px')
1052 a.style.setProperty('margin-left', '14px')
1053 a.href = `https://translate.google.com/#auto/${browserLocale.split('-')[0]}/${encodeURIComponent(content)}`
1054 titleDiv.insertAdjacentElement('beforeEnd', a)
1055 }
1056 }
1057
1058 expandWhatIsItBox()
1059
1060 // fix locationEditsMap if only one location edit exists
1061 if (newPortalData.locationEdits.length <= 1 || subController.locationEditsMap.getZoom() > 19) {
1062 subController.locationEditsMap.setZoom(19)
1063 }
1064
1065 /* EDIT PORTAL */
1066 /* region keyboard navigation */
1067
1068 if (preferences.get(WFRT.OPTIONS.KEYBOARD_NAV)) {
1069 activateShortcuts()
1070 }
1071
1072 function activateShortcuts () {
1073 let currentSelectable = 0
1074 let hasLocationEdit = (newPortalData.locationEdits.length > 1)
1075 // counting *true*, please don't shoot me
1076 let maxItems = (newPortalData.descriptionEdits.length > 1) + (newPortalData.titleEdits.length > 1) + (hasLocationEdit) + 2
1077
1078 let mapMarkers
1079 if (hasLocationEdit) mapMarkers = subController.allLocationMarkers
1080 else mapMarkers = []
1081
1082 // a list of all 6 star button rows, and the two submit buttons
1083 let starsAndSubmitButtons = w.document.querySelectorAll(
1084 '.edit-container div[ng-show="subCtrl.pageData.titleEdits.length > 1"]:not(.ng-hide),' +
1085 '.edit-container div[ng-show="subCtrl.pageData.descriptionEdits.length > 1"]:not(.ng-hide),' +
1086 '.edit-container div[ng-show="subCtrl.pageData.locationEdits.length > 1"]:not(.ng-hide),' +
1087 '#submitFF')
1088
1089 /* EDIT PORTAL */
1090 function highlight () {
1091 let el = editDiv.querySelector('.poi-edit-map-unable')
1092 el.style.setProperty('border', 'none')
1093
1094 starsAndSubmitButtons.forEach((element) => { element.style.setProperty('border', 'none') })
1095 if (hasLocationEdit && currentSelectable === maxItems - 3) {
1096 el.style.setProperty('border-left', '4px dashed #ebbc4a')
1097 el.style.setProperty('border-top', '4px dashed #ebbc4a')
1098 el.style.setProperty('border-bottom', '4px dashed #ebbc4a')
1099 el.style.setProperty('border-right', '4px dashed #ebbc4a')
1100 el.style.setProperty('padding', '8px')
1101 // el.style.setProperty('margin-bottom', '0')
1102 submitAndNext.blur()
1103 submitButton.blur()
1104 } else if (currentSelectable < maxItems - 2) {
1105 starsAndSubmitButtons[currentSelectable].style.setProperty('border-left', '4px dashed #ebbc4a')
1106 starsAndSubmitButtons[currentSelectable].style.setProperty('padding-left', '16px')
1107 submitAndNext.blur()
1108 submitButton.blur()
1109 } else if (currentSelectable === maxItems - 2) {
1110 submitAndNext.focus()
1111 } else if (currentSelectable === maxItems) {
1112 submitButton.focus()
1113 }
1114 }
1115
1116 /* EDIT PORTAL */
1117 addEventListener('keydown', (event) => {
1118 /*
1119 Keycodes:
1120
1121 8: Backspace
1122 9: TAB
1123 13: Enter
1124 16: Shift
1125 27: Escape
1126 32: Space
1127 68: D
1128 107: NUMPAD +
1129 109: NUMPAD -
1130 111: NUMPAD /
1131
1132 49 - 53: Keys 1-5
1133 97 - 101: NUMPAD 1-5
1134 */
1135
1136 let numkey = null
1137 if (event.keyCode >= 49 && event.keyCode <= 53) {
1138 numkey = event.keyCode - 48
1139 } else if (event.keyCode >= 97 && event.keyCode <= 101) {
1140 numkey = event.keyCode - 96
1141 }
1142
1143 // do not do anything if a text area or a input with type text has focus
1144 if (w.document.querySelector('input[type=text]:focus') || w.document.querySelector('textarea:focus')) {
1145 return
1146 } else if ((event.keyCode === 13 || event.keyCode === 32) && w.document.querySelector('a.button[href="/review"]')) {
1147 // "analyze next" button
1148 w.document.location.href = '/review'
1149 event.preventDefault()
1150 } else if ((event.keyCode === 13 || event.keyCode === 32) && currentSelectable === maxItems) {
1151 // submit normal rating
1152 w.document.querySelector('[ng-click="answerCtrl.submitForm()"]').click()
1153 event.preventDefault()
1154 } else if (event.keyCode === 27 || event.keyCode === 111) {
1155 // return to first selection (should this be a portal)
1156 currentSelectable = 0
1157 } else if ((event.keyCode === 107 || event.keyCode === 9) && currentSelectable < maxItems) {
1158 // select next rating
1159 currentSelectable++
1160 event.preventDefault()
1161 } else if ((event.keyCode === 109 || event.keyCode === 16 || event.keyCode === 8) && currentSelectable > 0) {
1162 // select previous rating
1163 currentSelectable--
1164 event.preventDefault()
1165 } else if (numkey === null || currentSelectable > maxItems - 2) {
1166 return
1167 } else {
1168 // rating 1-5
1169 if (hasLocationEdit && currentSelectable === maxItems - 3 && numkey <= mapMarkers.length) {
1170 google.maps.event.trigger(angular.element(document.getElementById('NewSubmissionController')).scope().getAllLocationMarkers()[numkey - 1], 'click')
1171 } else {
1172 if (hasLocationEdit) {
1173 numkey = 1
1174 }
1175 starsAndSubmitButtons[currentSelectable].querySelectorAll('.titleEditBox, input[type="radio"]')[numkey - 1].click()
1176 currentSelectable++
1177 }
1178 }
1179 highlight()
1180 })
1181
1182 highlight()
1183 }
1184 }
1185
1186 // add map buttons
1187 function mapButtons (newPortalData, targetElement, where) {
1188 // coordinate format conversion
1189 const coordUtm33 = proj4('+proj=longlat', '+proj=utm +zone=33', [newPortalData.lng, newPortalData.lat])
1190 const coordUtm35 = proj4('+proj=longlat', '+proj=utm +zone=35', [newPortalData.lng, newPortalData.lat])
1191 const coordPuwg92 = proj4('+proj=longlat', '+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +units=m +no_defs', [newPortalData.lng, newPortalData.lat])
1192
1193 const mapButtons = `
1194<a class='button btn btn-default' target='intel' href='https://intel.ingress.com/intel?ll=${newPortalData.lat},${newPortalData.lng}&z=17'>Intel</a>
1195<a class='button btn btn-default' target='gmaps' href='https://www.google.com/maps/place/${newPortalData.lat},${newPortalData.lng}'>GMaps</a>
1196`
1197 // more map buttons in a dropdown menu
1198 const mapDropdown = `
1199<li><a target='osm' href='https://www.openstreetmap.org/?mlat=${newPortalData.lat}&mlon=${newPortalData.lng}&zoom=16'>OSM</a></li>
1200<li><a target='bing' href='https://bing.com/maps/default.aspx?cp=${newPortalData.lat}~${newPortalData.lng}&lvl=16&style=a'>bing</a></li>
1201<li><a target='heremaps' href='https://wego.here.com/?map=${newPortalData.lat},${newPortalData.lng},17,satellite'>HERE maps</a></li>
1202<li><a targeT='zoomearth' href='https://zoom.earth/#${newPortalData.lat},${newPortalData.lng},18z,sat'>Zoom Earth</a></li>
1203<li role='separator' class='divider'></li>
1204<li><a target='swissgeo' href='http://map.geo.admin.ch/?swisssearch=${newPortalData.lat},${newPortalData.lng}'>CH - Swiss Geo Map</a></li>
1205<li><a target='mapycz' href='https://mapy.cz/zakladni?x=${newPortalData.lng}&y=${newPortalData.lat}&z=17&base=ophoto&source=coor&id=${newPortalData.lng}%2C${newPortalData.lat}&q=${newPortalData.lng}%20${newPortalData.lat}'>CZ-mapy.cz (ortofoto)</a></li>
1206<li><a target='mapycz' href='https://mapy.cz/zakladni?x=${newPortalData.lng}&y=${newPortalData.lat}&z=17&base=ophoto&m3d=1&height=180&yaw=-279.39&pitch=-40.7&source=coor&id=${newPortalData.lng}%2C${newPortalData.lat}&q=${newPortalData.lng}%20${newPortalData.lat}'>CZ-mapy.cz (orto+3D)</a></li>
1207<li><a target='kompass' href='http://maps.kompass.de/#lat=${newPortalData.lat}&lon=${newPortalData.lng}&z=17'>DE - Kompass.maps</a></li>
1208<li><a target='bayernatlas' href='https://geoportal.bayern.de/bayernatlas/index.html?X=${newPortalData.lat}&Y=${newPortalData.lng}&zoom=14&lang=de&bgLayer=luftbild&topic=ba&catalogNodes=122'>DE - BayernAtlas</a></li>
1209<li><a target='pegel' href='http://opr.pegel.dk/?17/${newPortalData.lat}/${newPortalData.lng}'>DK - SDFE Orthophotos</a></li>
1210<li><a target='kortforsyningen' href='https://skraafoto.kortforsyningen.dk/oblivisionjsoff/index.aspx?project=Denmark&lon=${newPortalData.lng}&lat=${newPortalData.lat}'>DK - Kortforsyningen Skråfoto</a></li>
1211<li><a target='maanmittauslaitos' href='https://asiointi.maanmittauslaitos.fi/karttapaikka/?lang=en&share=customMarker&n=${coordUtm35[1].toFixed(3)}&e=${coordUtm35[0].toFixed(3)}&title=${encodeURIComponent(newPortalData.title)}&desc=&zoom=11&layers=%5B%7B%22id%22%3A2%2C%22opacity%22%3A100%7D%5D'>FI - Maanmittauslaitos</a></li>
1212<li><a target='paikkatietoikkuna' href='https://kartta.paikkatietoikkuna.fi/?zoomLevel=11&coord=${coordUtm35[0].toFixed(3)}_${coordUtm35[1].toFixed(3)}&mapLayers=801+100+default&uuid=90246d84-3958-fd8c-cb2c-2510cccca1d3&showMarker=true'>FI - Paikkatietoikkuna</a></li>
1213<li><a target='kakao' href='http://map.daum.net/link/map/${newPortalData.lat},${newPortalData.lng}'>KR - Kakao map</a></li>
1214<li><a target='naver' href='http://map.naver.com/?menu=location&lat=${newPortalData.lat}&lng=${newPortalData.lng}&dLevel=14&title=CandidatePortalLocation'>KR - Naver map</a></li>
1215<li><a target='kartverket' href='http://norgeskart.no/#!?project=seeiendom&layers=1002,1014&zoom=17&lat=${coordUtm33[1].toFixed(2)}&lon=${coordUtm33[0].toFixed(2)}&sok=${newPortalData.lat},${newPortalData.lng}'>NO - Kartverket</a></li>
1216<li><a target='norgeibilder' href='https://norgeibilder.no/?x=${Math.round(coordUtm33[0])}&y=${Math.round(coordUtm33[1])}&level=16&utm=33'>NO - Norge i Bilder</a></li>
1217<li><a target='finnno' href='http://kart.finn.no/?lng=${newPortalData.lng}&lat=${newPortalData.lat}&zoom=17&mapType=normap&markers=${newPortalData.lng},${newPortalData.lat},r,'>NO - Finn Kart</a></li>
1218<li><a target='toposvalbard' href='http://toposvalbard.npolar.no/?lat=${newPortalData.lat}&long=${newPortalData.lng}&zoom=17&layer=map'>NO - Polarinstituttet, Svalbard</a></li>
1219<li><a target='geoportal_pl' href='http://mapy.geoportal.gov.pl/imap/?actions=acShowWgButtonPanel_kraj_ORTO&bbox=${coordPuwg92[0] - 127},${coordPuwg92[1] - 63},${coordPuwg92[0] + 127},${coordPuwg92[1] + 63}'>PL - GeoPortal</a></li>
1220<li><a target='yandex' href='https://yandex.ru/maps/?ll=${newPortalData.lng},${newPortalData.lat}&z=18&mode=whatshere&whatshere%5Bpoint%5D=${newPortalData.lng},${newPortalData.lat}&whatshere%5Bzoom%5D=18'>RU - Yandex</a></li>
1221<li><a target='2GIS' href='https://2gis.ru/geo/${newPortalData.lng},${newPortalData.lat}?queryState=center/${newPortalData.lng},${newPortalData.lat}/zoom/13'>RU - 2GIS</a></li>
1222<li><a target='lantmateriet' href='https://kso.etjanster.lantmateriet.se/?e=${Math.round(coordUtm33[0])}&n=${Math.round(coordUtm33[1])}&z=13'>SE - Läntmateriet</a></li>
1223<li><a target='hitta' href='https://www.hitta.se/kartan!~${newPortalData.lat},${newPortalData.lng},18z/tileLayer!l=1'>SE - Hitta.se</a></li>
1224<li><a target='eniro' href='https://kartor.eniro.se/?c=${newPortalData.lat},${newPortalData.lng}&z=17&l=nautical'>SE - Eniro Sjökort</a></li>
1225`
1226 targetElement.insertAdjacentHTML(where, `<div id="wfrt_map_button_group" class='btn-group dropup'>${mapButtons}<div class='button btn btn-default dropdown'><span class='caret'></span><ul id="wfrt_map_dropdown" class='dropdown-content dropdown-menu'>${mapDropdown}</div></div>`)
1227 }
1228
1229 // add new button "Submit and reload", skipping "Your analysis has been recorded." dialog
1230 function quickSubmitButton (submitDiv, ansController, bodyObserver) {
1231 let submitButton = submitDiv.querySelector('button.button-primary')
1232 // submitButton.classList.add('btn', 'btn-warning')
1233
1234 let submitAndNext = submitButton.cloneNode(false)
1235 submitButton.addEventListener('click', () => {
1236 bodyObserver.disconnect()
1237 })
1238 submitAndNext.innerHTML = `<span class="glyphicon glyphicon-floppy-disk"></span> <span class="glyphicon glyphicon-forward"></span>`
1239 submitAndNext.title = 'Submit and go to next review'
1240 submitAndNext.id = 'submitFF'
1241 submitAndNext.style.setProperty('margin-right', '16px')
1242 submitAndNext.addEventListener('click', () => {
1243 ansController.openSubmissionCompleteModal = () => {
1244 window.location.assign('/review')
1245 }
1246 })
1247
1248 w.$injector.invoke(['$compile', ($compile) => {
1249 let compiledSubmit = $compile(submitAndNext)(w.$scope(submitDiv))
1250 submitDiv.querySelector('#submitDiv button').insertAdjacentElement('beforeBegin', compiledSubmit[0])
1251 }])
1252 return { submitButton, submitAndNext }
1253 }
1254
1255 function commentTemplates () {
1256 // add text buttons
1257 const textButtons = `
1258<button id='photo' class='button btn btn-default textButton' data-tooltip='Indicates a low quality photo'>Photo</button>
1259<button id='private' class='button btn btn-default textButton' data-tooltip='Located on private residential property'>Private</button>`
1260 const textDropdown = `
1261<li><a class='textButton' id='school' data-tooltip='Located on school property'>School</a></li>
1262<li><a class='textButton' id='person' data-tooltip='Photo contains 1 or more people'>Person</a></li>
1263<li><a class='textButton' id='perm' data-tooltip='Seasonal or temporary display or item'>Temporary</a></li>
1264<li><a class='textButton' id='location' data-tooltip='Location wrong'>Location</a></li>
1265<li><a class='textButton' id='natural' data-tooltip='Candidate is a natural feature'>Natural</a></li>
1266<li><a class='textButton' id='emergencyway' data-tooltip='Obstructing emergency way'>Emergency Way</a></li>
1267`
1268
1269 const cardAdditionalText = w.document.getElementById('additional-comments-card')
1270 const cardTextBox = cardAdditionalText.querySelector('textarea')
1271
1272 cardAdditionalText.insertAdjacentHTML('beforeend', `<div class="card__footer">
1273<span id="wfrt_comment_button_group" class='btn-group dropup pull-left'>${textButtons}
1274<span class='button btn btn-default dropdown'><span class='caret'></span><ul id="wfrt_comment_button_dropdown" class='dropdown-content dropdown-menu'>${textDropdown}</ul>
1275</span></span><span class="hidden-xs pull-right"><button id='clear' class='button btn btn-default textButton' data-tooltip='clears the comment box'>Clear</button></span></div>
1276`)
1277
1278 const buttons = w.document.getElementsByClassName('textButton')
1279 for (let b in buttons) {
1280 if (buttons.hasOwnProperty(b)) {
1281 buttons[b].addEventListener('click', event => {
1282 const source = event.target || event.srcElement
1283 let text = cardTextBox.value
1284 if (text.length > 0) {
1285 text += ', '
1286 }
1287 switch (source.id) {
1288 case 'photo':
1289 text += 'Low quality photo'
1290 break
1291 case 'private':
1292 text += 'Private residential property'
1293 break
1294 case 'duplicate':
1295 text += 'Duplicate of previously reviewed portal candidate'
1296 break
1297 case 'school':
1298 text += 'Located on primary or secondary school grounds'
1299 break
1300 case 'person':
1301 text += 'Picture contains one or more people'
1302 break
1303 case 'perm':
1304 text += 'Portal candidate is seasonal or temporary'
1305 break
1306 case 'location':
1307 text += 'Portal candidate\'s location is not on object'
1308 break
1309 case 'emergencyway':
1310 text += 'Portal candidate is obstructing the path of emergency vehicles'
1311 break
1312 case 'natural':
1313 text += 'Portal candidate is a natural feature'
1314 break
1315 case 'clear':
1316 text = ''
1317 break
1318 }
1319
1320 cardTextBox.value = text
1321 cardTextBox.dispatchEvent(new Event('change')) // eslint-disable-line no-undef
1322
1323 event.target.blur()
1324 }, false)
1325 }
1326 }
1327 }
1328
1329 // adding a 40m circle and a smaller 20m circle around the portal (capture range)
1330 function mapOriginCircle (map) {
1331 // noinspection JSUnusedLocalSymbols
1332 if (preferences.get(WFRT.OPTIONS.MAP_CIRCLE_40)) {
1333 const circle40 = new google.maps.Circle({ // eslint-disable-line no-unused-vars
1334 map: map,
1335 center: map.center,
1336 radius: 40,
1337 strokeColor: '#ebbc4a',
1338 strokeOpacity: 0.8,
1339 strokeWeight: 1.5,
1340 fillOpacity: 0
1341 })
1342 }
1343
1344 if (preferences.get(WFRT.OPTIONS.MAP_CIRCLE_20)) {
1345 const circle20 = new google.maps.Circle({ // eslint-disable-line no-unused-vars
1346 map: map,
1347 center: map.center,
1348 radius: 20,
1349 strokeColor: '#eddc4a',
1350 strokeOpacity: 0.8,
1351 strokeWeight: 1.5,
1352 fillOpacity: 0
1353 })
1354 }
1355 }
1356
1357 // replace map markers with a nice circle
1358 function mapMarker (markers) {
1359 for (let i = 0; i < markers.length; ++i) {
1360 const marker = markers[i]
1361 marker.setIcon(PORTAL_MARKER)
1362 }
1363 }
1364
1365 // set available map types
1366 function mapTypes (map, isMainMap) {
1367 const PROVIDERS = {
1368 GOOGLE: 'google',
1369 KARTVERKET: 'kartverket'
1370 }
1371
1372 const types = [
1373 { provider: PROVIDERS.GOOGLE, id: 'roadmap' },
1374 { provider: PROVIDERS.GOOGLE, id: 'terrain' },
1375 { provider: PROVIDERS.GOOGLE, id: 'satellite' },
1376 { provider: PROVIDERS.GOOGLE, id: 'hybrid' }]
1377
1378 if (preferences.get(WFRT.OPTIONS.NORWAY_MAP_LAYER)) {
1379 types.push({ provider: PROVIDERS.KARTVERKET, id: `${PROVIDERS.KARTVERKET}_topo`, code: 'topo4', label: 'NO - Topo' },
1380 { provider: PROVIDERS.KARTVERKET, id: `${PROVIDERS.KARTVERKET}_raster`, code: 'toporaster3', label: 'NO - Raster' },
1381 { provider: PROVIDERS.KARTVERKET, id: `${PROVIDERS.KARTVERKET}_sjo`, code: 'sjokartraster', label: 'NO - Sjøkart' }
1382 )
1383 }
1384
1385 const defaultMapType = 'hybrid'
1386
1387 const mapOptions = {
1388 // re-enabling map scroll zoom and allow zoom with out holding ctrl
1389 scrollwheel: true,
1390 gestureHandling: 'greedy',
1391 // map type selection
1392 mapTypeControl: true,
1393 mapTypeControlOptions: {
1394 mapTypeIds: types.map(t => t.id),
1395 style: google.maps.MapTypeControlStyle.DROPDOWN_MENU
1396 }
1397 }
1398 map.setOptions(mapOptions)
1399
1400 // register custom map types
1401 types.forEach(t => {
1402 switch (t.provider) {
1403 case PROVIDERS.KARTVERKET:
1404 map.mapTypes.set(t.id, new google.maps.ImageMapType({
1405 layer: t.code,
1406 name: t.label,
1407 alt: t.label,
1408 maxZoom: 19,
1409 tileSize: new google.maps.Size(256, 256),
1410 getTileUrl: function (coord, zoom) {
1411 return `//opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=${this.layer}&zoom=${zoom}&x=${coord.x}&y=${coord.y}`
1412 }
1413 }))
1414 break
1415 }
1416 })
1417
1418 // track current selection for position map
1419 let mapType
1420 if (isMainMap) {
1421 mapType = WFRT.PREFIX + WFRT.VAR.MAP_TYPE_1
1422 } else {
1423 mapType = WFRT.PREFIX + WFRT.VAR.MAP_TYPE_2
1424 }
1425
1426 // save selection when changed
1427 map.addListener('maptypeid_changed', function () {
1428 w.localStorage.setItem(mapType, map.getMapTypeId())
1429 })
1430
1431 // get map type saved from last use or fall back to default
1432 map.setMapTypeId(w.localStorage.getItem(mapType) || defaultMapType)
1433 }
1434
1435 // move submit button to right side of classification-div. don't move on mobile devices / small width
1436 function moveSubmitButton () {
1437 const submitDiv = w.document.querySelectorAll('#submitDiv, #submitDiv + .text-center')
1438
1439 if (screen.availWidth > 768) {
1440 let newSubmitDiv = w.document.createElement('div')
1441 const classificationRow = w.document.querySelector('.classification-row')
1442 newSubmitDiv.className = 'col-xs-12 col-sm-6'
1443 submitDiv[0].style.setProperty('margin-top', '16px')
1444 newSubmitDiv.appendChild(submitDiv[0])
1445 newSubmitDiv.appendChild(submitDiv[1])
1446 classificationRow.insertAdjacentElement('afterend', newSubmitDiv)
1447
1448 // edit-page - remove .col-sm-offset-3 from .classification-row (why did you add this, niantic?
1449 classificationRow.classList.remove('col-sm-offset-3')
1450 return newSubmitDiv
1451 } else {
1452 return submitDiv[0]
1453 }
1454 }
1455
1456 // expand automatically the "What is it?" filter text box
1457 function expandWhatIsItBox () {
1458 try {
1459 const whatController = w.$scope(w.document.getElementById('WhatIsItController')).whatCtrl
1460 setTimeout(() => {
1461 whatController.showWhat = true
1462 w.$rootScope.$apply()
1463 }, 50)
1464 } catch (err) {}
1465 }
1466
1467 function modifyHeader () {
1468 // stats enhancements: add processed by nia, percent processed, progress to next recon badge numbers
1469
1470 let wfrtScannerOffset = 0
1471 if (preferences.get(WFRT.OPTIONS.SCANNER_OFFSET_FEATURE)) {
1472 // get scanner offset from localStorage
1473 wfrtScannerOffset = parseInt(w.localStorage.getItem(WFRT.SCANNER_OFFSET)) || 0
1474 }
1475 const lastPlayerStatLine = w.document.querySelector('#player_stats:not(.visible-xs) div')
1476 const stats = w.document.querySelector('#player_stats:not(.visible-xs) div')
1477
1478 // move upgrade button to the right
1479 const upgradeIcon = w.document.querySelector('.upgrades-icon')
1480 if (upgradeIcon !== undefined) {
1481 upgradeIcon.parentElement.removeChild(upgradeIcon)
1482
1483 upgradeIcon.style.setProperty('margin-right', '20px')
1484 upgradeIcon.style.setProperty('color', '#9d9d9d')
1485 upgradeIcon.classList.add('pull-right')
1486
1487 stats.parentElement.insertAdjacentElement('beforebegin', upgradeIcon)
1488 }
1489
1490 let perfBadge = null
1491 const imgSrc = stats.children[1].src
1492
1493 if (imgSrc.indexOf('great.png') !== -1) {
1494 perfBadge = PERF_GREAT
1495 } else if (imgSrc.indexOf('good.png') !== -1) {
1496 perfBadge = PERF_GOOD
1497 } else if (imgSrc.indexOf('poor.png') !== -1) {
1498 perfBadge = PERF_POOR
1499 }
1500
1501 if (perfBadge != null) {
1502 stats.removeChild(stats.children[1])
1503 stats.children[1].insertAdjacentHTML('beforeBegin', '<img style="float: right !important; margin-top: -2px;" src="' + perfBadge + '">')
1504 stats.children[2].setAttribute('style', 'clear: both; margin-bottom: 10px;')
1505 }
1506
1507 const reviewed = parseInt(stats.children[3].children[2].innerText)
1508 const accepted = parseInt(stats.children[5].children[2].innerText)
1509 const rejected = parseInt(stats.children[7].children[2].innerText)
1510
1511 const processed = accepted + rejected - wfrtScannerOffset
1512 const processedPercent = roundToPrecision(processed / reviewed * 100, 1)
1513
1514 const acceptedPercent = roundToPrecision(accepted / (reviewed) * 100, 1)
1515 const rejectedPercent = roundToPrecision(rejected / (reviewed) * 100, 1)
1516
1517 const reconBadge = { 100: 'Bronze', 750: 'Silver', 2500: 'Gold', 5000: 'Platin', 10000: 'Black' }
1518 let nextBadgeName, nextBadgeCount
1519
1520 for (const key in reconBadge) {
1521 if (processed <= key) {
1522 nextBadgeCount = key
1523 nextBadgeName = reconBadge[key]
1524 break
1525 }
1526 }
1527 const nextBadgeProcess = processed / nextBadgeCount * 100
1528
1529 const numberSpans = stats.querySelectorAll('p span.gold')
1530
1531 numberSpans[0].insertAdjacentHTML('beforeend', `, <span class='ingress-gray'>100%</span>`)
1532 numberSpans[1].insertAdjacentHTML('beforeend', `, <span class='opr-yellow'>${acceptedPercent}%</span>`)
1533 numberSpans[2].insertAdjacentHTML('beforeend', `, <span class='opr-yellow'>${rejectedPercent}%</span>`)
1534
1535 stats.querySelectorAll('p')[1].insertAdjacentHTML('afterend', `<br>
1536<p><span class="glyphicon glyphicon-info-sign ingress-gray pull-left"></span><span style="margin-left: 5px;" class="ingress-mid-blue pull-left">Processed <u>and</u> accepted analyses:</span> <span class="gold pull-right">${processed}, <span class="ingress-gray">${processedPercent}%</span></span></p>`)
1537
1538 if (processed < 10000) {
1539 lastPlayerStatLine.insertAdjacentHTML('beforeEnd', `
1540<br><div>Next recon badge tier: <b>${nextBadgeName} (${nextBadgeCount})</b><span class='pull-right'></span>
1541<div class='progress'>
1542<div class='progress-bar progress-bar-warning'
1543role='progressbar'
1544aria-valuenow='${nextBadgeProcess}'
1545aria-valuemin='0'
1546aria-valuemax='100'
1547style='width: ${Math.round(nextBadgeProcess)}%;'
1548title='${nextBadgeCount - processed} to go'>
1549${Math.round(nextBadgeProcess)}%
1550</div></div></div>
1551`)
1552 } else lastPlayerStatLine.insertAdjacentHTML('beforeEnd', `<hr>`)
1553 lastPlayerStatLine.insertAdjacentHTML('beforeEnd', `<p><i class="glyphicon glyphicon-share"></i> <input readonly onFocus="this.select();" style="width: 90%;" type="text"
1554value="Reviewed: ${reviewed} / Processed: ${accepted + rejected} (Created: ${accepted}/ Rejected: ${rejected}) / ${Math.round(processedPercent)}%"/></p>`)
1555
1556 // ** wayfarer-scanner offset
1557 if (accepted < 10000 && preferences.get(WFRT.OPTIONS.SCANNER_OFFSET_UI)) {
1558 lastPlayerStatLine.insertAdjacentHTML('beforeEnd', `
1559<p id='scannerOffsetContainer'>
1560<span style="margin-left: 5px" class="ingress-mid-blue pull-left">Scanner offset:</span>
1561<input id="scannerOffset" onFocus="this.select();" type="text" name="scannerOffset" size="8" class="pull-right" value="${wfrtScannerOffset}">
1562</p>`)
1563
1564 // we have to inject the tooltip to angular
1565 w.$injector.invoke(['$compile', ($compile) => {
1566 let compiledSubmit = $compile(`<span class="glyphicon glyphicon-info-sign ingress-gray pull-left" uib-tooltip-trigger="outsideclick" uib-tooltip-placement="left" tooltip-class="goldBorder" uib-tooltip="Use negative values, if scanner is ahead of Wayfarer"></span>`)(w.$scope(stats))
1567 w.document.getElementById('scannerOffsetContainer').insertAdjacentElement('afterbegin', compiledSubmit[0])
1568 }]);
1569
1570 ['change', 'keyup', 'cut', 'paste', 'input'].forEach(e => {
1571 w.document.getElementById('scannerOffset').addEventListener(e, (event) => {
1572 w.localStorage.setItem(WFRT.SCANNER_OFFSET, event.target.value)
1573 })
1574 })
1575 // **
1576 }
1577
1578 modifyHeader = () => {} // eslint-disable-line
1579 }
1580
1581 function addOptionsButton () {
1582 // Add preferences button only once
1583 if (w.document.getElementById('wfrt_preferences_button') !== null) {
1584 return
1585 }
1586
1587 // add wayfarer-tools preferences button
1588 let wfrtPreferencesButton = w.document.createElement('a')
1589 wfrtPreferencesButton.classList.add('brand', 'upgrades-icon', 'pull-right')
1590 wfrtPreferencesButton.style.setProperty('cursor', 'pointer')
1591 wfrtPreferencesButton.style.setProperty('margin-right', '20px')
1592 wfrtPreferencesButton.style.setProperty('margin-left', '20px')
1593 wfrtPreferencesButton.style.setProperty('color', 'rgb(157, 157, 157)')
1594 wfrtPreferencesButton.addEventListener('click', () => preferences.showPreferencesUI(w))
1595 wfrtPreferencesButton.title = 'Wayfarer-Tools Preferences'
1596 wfrtPreferencesButton.setAttribute('id', 'wfrt_preferences_button')
1597
1598 const prefCog = w.document.createElement('span')
1599 prefCog.classList.add('glyphicon', 'glyphicon-cog')
1600 wfrtPreferencesButton.appendChild(prefCog)
1601
1602 w.document.querySelector('.header .inner-container:last-of-type').insertAdjacentElement('afterbegin', wfrtPreferencesButton)
1603 }
1604
1605 function addRefreshContainer () {
1606 let cbxRefresh = w.document.createElement('input')
1607 let cbxRefreshDesktop = w.document.createElement('input')
1608
1609 cbxRefresh.id = WFRT.OPTIONS.REFRESH
1610 cbxRefresh.type = 'checkbox'
1611 cbxRefresh.checked = preferences.get(WFRT.OPTIONS.REFRESH) === 'true'
1612
1613 cbxRefreshDesktop.id = WFRT.OPTIONS.REFRESH_NOTI_DESKTOP
1614 cbxRefreshDesktop.type = 'checkbox'
1615 cbxRefreshDesktop.checked = preferences.get(WFRT.OPTIONS.REFRESH_NOTI_DESKTOP) === 'true'
1616
1617 let refreshPanel = w.document.createElement('div')
1618 refreshPanel.className = 'panel panel-ingress'
1619
1620 refreshPanel.addEventListener('change', (event) => {
1621 preferences.set(event.target.id, event.target.checked)
1622 if (event.target.checked) {
1623 startRefresh()
1624 } else {
1625 stopRefresh()
1626 }
1627 })
1628
1629 refreshPanel.innerHTML = `
1630<div class='panel-heading'><span class='glyphicon glyphicon-refresh'></span> Refresh <sup>beta</sup> <a href='https://gitlab.com/1110101/opr-tools'><span class='label label-success pull-right'>Wayfarer-Tools</span></a></div>
1631<div id='cbxDiv' class='panel-body bg-primary' style='background:black;'></div>`
1632
1633 refreshPanel.querySelector('#cbxDiv').insertAdjacentElement('afterbegin', appendCheckbox(cbxRefreshDesktop, 'Desktop notification'))
1634 refreshPanel.querySelector('#cbxDiv').insertAdjacentElement('afterbegin', appendCheckbox(cbxRefresh, 'Refresh every 5-10 minutes'))
1635
1636 let colDiv = w.document.createElement('div')
1637 colDiv.className = 'col-md-4 col-md-offset-4'
1638 colDiv.appendChild(refreshPanel)
1639
1640 let rowDiv = w.document.createElement('div')
1641 rowDiv.className = 'row'
1642 rowDiv.appendChild(colDiv)
1643
1644 w.document.getElementById('NewSubmissionController').insertAdjacentElement('beforeend', rowDiv)
1645
1646 cbxRefresh.checked === true ? startRefresh() : stopRefresh()
1647
1648 function appendCheckbox (checkbox, text) {
1649 let label = w.document.createElement('label')
1650 let div = w.document.createElement('div')
1651 div.className = 'checkbox'
1652 label.appendChild(checkbox)
1653 label.appendChild(w.document.createTextNode(text))
1654 div.appendChild(label)
1655 return div
1656 }
1657
1658 addRefreshContainer = () => {} // eslint-disable-line
1659 }
1660
1661 let refreshIntervalID
1662
1663 function startRefresh () {
1664 let time = getRandomIntInclusive(5, 10) * 60000
1665
1666 refreshIntervalID = setInterval(() => {
1667 reloadWayfarer()
1668 }, time)
1669
1670 function reloadWayfarer () {
1671 clearInterval(refreshIntervalID)
1672 w.sessionStorage.setItem(WFRT.FROM_REFRESH, 'true')
1673 w.document.location.reload()
1674 }
1675
1676 // source https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random
1677 function getRandomIntInclusive (min, max) {
1678 min = Math.ceil(min)
1679 max = Math.floor(max)
1680 return Math.floor(Math.random() * (max - min + 1)) + min
1681 }
1682 }
1683
1684 function stopRefresh () {
1685 clearInterval(refreshIntervalID)
1686 }
1687
1688 function checkIfAutorefresh () {
1689 if (w.sessionStorage.getItem(WFRT.FROM_REFRESH)) {
1690 // reset flag
1691 w.sessionStorage.removeItem(WFRT.FROM_REFRESH)
1692
1693 if (w.document.hidden) { // if tab in background: flash favicon
1694 let flag = true
1695
1696 if (preferences.get(WFRT.OPTIONS.REFRESH_NOTI_DESKTOP) === 'true') {
1697 GM_notification({
1698 'title': 'Wayfarer - New Wayspot Analysis Available',
1699 'text': 'by Wayfarer-Tools',
1700 'image': 'https://gitlab.com/uploads/-/system/project/avatar/3311015/opr-tools.png'
1701 })
1702 }
1703
1704 let flashId = setInterval(() => {
1705 flag = !flag
1706 changeFavicon(`${flag ? PORTAL_MARKER : '/imgpub/favicon.ico'}`)
1707 }, 1000)
1708
1709 // stop flashing if tab in foreground
1710 addEventListener('visibilitychange', () => {
1711 if (!w.document.hidden) {
1712 changeFavicon('/imgpub/favicon.ico')
1713 clearInterval(flashId)
1714 }
1715 })
1716 }
1717 }
1718 }
1719
1720 function changeFavicon (src) {
1721 let link = w.document.querySelector('link[rel="shortcut icon"]')
1722 link.href = src
1723 }
1724
1725 function startExpirationTimer (subController) {
1726 w.document.querySelector('.header .inner-container:last-of-type').insertAdjacentHTML('afterbegin', '<span id="countdownDisplay"></span>')
1727
1728 let countdownEnd = subController.countdownDate
1729 let countdownDisplay = document.getElementById('countdownDisplay')
1730 countdownDisplay.style.setProperty('color', 'black')
1731
1732 // Update the count down every 1 second
1733 let counterInterval = setInterval(function () {
1734 // Get todays date and time
1735 let now = new Date().getTime()
1736 // Find the distance between now an the count down date
1737 let distance = countdownEnd - now
1738 // Time calculations for minutes and seconds
1739 let minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60))
1740 let seconds = Math.floor((distance % (1000 * 60)) / 1000)
1741
1742 // Display the result in the element
1743 countdownDisplay.innerText = `${minutes}m ${seconds}s `
1744
1745 if (distance < 0) {
1746 // If the count down is finished, write some text
1747 clearInterval(counterInterval)
1748 countdownDisplay.innerText = 'EXPIRED'
1749 countdownDisplay.style.setProperty('color', 'red')
1750 } else if (distance < 90000) {
1751 countdownDisplay.style.setProperty('color', 'red')
1752 }
1753 }, 1000)
1754 }
1755
1756 function versionCheck () {
1757 if (WFRT.VERSION > (parseInt(w.localStorage.getItem(WFRT.PREFIX + WFRT.VERSION_CHECK)) || WFRT.VERSION - 1)) {
1758 w.localStorage.setItem(WFRT.PREFIX + WFRT.VERSION_CHECK, WFRT.VERSION)
1759
1760 const changelogString = `
1761 <h4><span class="glyphicon glyphicon-asterisk"></span> Wayfarer-Tools was updated:</h4>
1762 <div>${strings.changelog}</div>
1763 `
1764 // show changelog
1765 alertify.closeLogOnClick(false).logPosition('bottom right').delay(0).log(changelogString, (ev) => {
1766 ev.preventDefault()
1767 ev.target.closest('div.default.show').remove()
1768 }).reset()
1769 }
1770 }
1771
1772 function addCustomPresetButtons () {
1773 // add customPreset UI
1774 wfrtCustomPresets = getCustomPresets(w)
1775 let customPresetOptions = ''
1776 for (const customPreset of wfrtCustomPresets) {
1777 customPresetOptions += `<button class='button btn btn-default customPresetButton' id='${customPreset.uid}'>${customPreset.label}</button>`
1778 }
1779 w.document.getElementById('wfrt_custom_presets').innerHTML = customPresetOptions
1780 }
1781
1782 function getCustomPresets (w) {
1783 // simply to scope the string we don't need after JSON.parse
1784 let presetsJSON = w.localStorage.getItem(WFRT.PREFIX + WFRT.VAR.CUSTOM_PRESETS)
1785 if (presetsJSON != null && presetsJSON !== '') {
1786 return JSON.parse(presetsJSON)
1787 }
1788 return []
1789 }
1790
1791 function saveCustomPreset (label, ansController, whatController) {
1792 // uid snippet from https://stackoverflow.com/a/47496558/6447397
1793 let preset = {
1794 uid: [...Array(5)].map(() => Math.random().toString(36)[3]).join(''),
1795 label: label,
1796 nodeName: whatController.whatNode.name,
1797 nodeId: whatController.whatNode.id,
1798 quality: ansController.formData.quality,
1799 description: ansController.formData.description,
1800 cultural: ansController.formData.cultural,
1801 uniqueness: ansController.formData.uniqueness,
1802 location: ansController.formData.location,
1803 safety: ansController.formData.safety
1804 }
1805 wfrtCustomPresets.push(preset)
1806 w.localStorage.setItem(WFRT.PREFIX + WFRT.VAR.CUSTOM_PRESETS, JSON.stringify(wfrtCustomPresets))
1807 }
1808
1809 function deleteCustomPreset (preset) {
1810 wfrtCustomPresets = wfrtCustomPresets.filter(item => item.uid !== preset.uid)
1811 w.localStorage.setItem(WFRT.PREFIX + WFRT.VAR.CUSTOM_PRESETS, JSON.stringify(wfrtCustomPresets))
1812 }
1813
1814 function showHelp () {
1815 let helpString = `<a href='https://gitlab.com/1110101/opr-tools'><span class='label label-success'>Wayfarer-Tools</span></a> Key shortcuts<br>
1816 <table class="table table-condensed ">
1817 <thead>
1818 <tr>
1819 <th>Keys</th>
1820 <th>Function</th>
1821 </tr>
1822 </thead>
1823 <tbody>
1824 <tr>
1825 <td><kbd>Keys 1-5</kbd> / <kbd>Numpad 1-5</kbd></td>
1826 <td>Valuate current selected field (the yellow highlighted one)</td>
1827 </tr>
1828 <tr>
1829 <td><kbd>Shift</kbd> + <kbd>Keys 1-5</kbd></td>
1830 <td>Apply custom preset (if exists)</td>
1831 </tr>
1832 <tr>
1833 <td><kbd>Keys 1-7</kbd> / <kbd>Numpad 1-7</kbd></td>
1834 <td>Rejection popup: Select list element</td>
1835 </tr>
1836 <tr>
1837 <td><kbd>D</kbd></td>
1838 <td>Mark current candidate as a duplicate of the opened portal in "duplicates"</td>
1839 </tr>
1840 <tr>
1841 <td><kbd>T</kbd></td>
1842 <td>Open title translation</td>
1843 </tr>
1844 <tr>
1845 <td><kbd>Y</kbd></td>
1846 <td>Open description translation</td>
1847 </tr>
1848 <tr>
1849 <td><kbd>Space</kbd> / <kbd>Enter</kbd> / <kbd>Numpad Enter</kbd></td>
1850 <td>Confirm dialog / Send valuation</td>
1851 </tr>
1852 <tr>
1853 <td><kbd>Tab</kbd> / <kbd>Numpad +</kbd></td>
1854 <td>Next field</td>
1855 </tr>
1856 <tr>
1857 <td><kbd>Shift</kbd> / <kbd>Backspace</kbd> / <kbd>Numpad -</kbd></td>
1858 <td>Previous field</td>
1859 </tr>
1860 <tr>
1861 <td><kbd>Esc</kbd> / <kbd>Numpad /</kbd></td>
1862 <td>First field</td>
1863 </tr>
1864 <tr>
1865 <td><kbd>^</kbd> / <kbd>Numpad *</kbd></td>
1866 <td>Skip Portal (if possible)</td>
1867 </tr>
1868 </tbody>
1869 </table>`
1870
1871 alertify.closeLogOnClick(false).logPosition('bottom right').delay(0).log(helpString, (ev) => {
1872 ev.preventDefault()
1873 ev.target.closest('div.default.show').remove()
1874 }).reset()
1875 }
1876
1877 function roundToPrecision (num, precision) {
1878 let shifter
1879 precision = Number(precision || 0)
1880 if (precision % 1 !== 0) throw new RangeError('precision must be an integer')
1881 shifter = Math.pow(10, precision)
1882 return Math.round(num * shifter) / shifter
1883 }
1884}
1885
1886setTimeout(() => {
1887 init()
1888}, 250)
1889
1890// region const
1891
1892const strings = {
1893 options: {
1894 [WFRT.OPTIONS.COMMENT_TEMPLATES]: 'Comment templates',
1895 [WFRT.OPTIONS.KEYBOARD_NAV]: 'Keyboard navigation',
1896 [WFRT.OPTIONS.NORWAY_MAP_LAYER]: 'Norwegian map layer',
1897 [WFRT.OPTIONS.PRESET_FEATURE]: 'Rating presets',
1898 [WFRT.OPTIONS.REFRESH]: 'Periodically refresh wayfarer if no analysis is available',
1899 [WFRT.OPTIONS.REFRESH_NOTI_DESKTOP]: '↳ With desktop notification',
1900 [WFRT.OPTIONS.SCANNER_OFFSET_FEATURE]: 'Scanner offset',
1901 [WFRT.OPTIONS.SCANNER_OFFSET_UI]: '↳ Display offset input field',
1902 [WFRT.OPTIONS.MAP_CIRCLE_20]: 'Show 20 meter circle around candidate location (minimum portal distance)',
1903 [WFRT.OPTIONS.MAP_CIRCLE_40]: 'Show 40 meter circle around candidate location (capture range)'
1904 },
1905 changelog:
1906 `
1907Version 2.0.0
1908<br>* Initial Wayfarer-Tools release
1909`
1910}
1911
1912const GLOBAL_CSS = `
1913.dropdown {
1914position: relative;
1915display: inline-block;
1916}
1917
1918.dropdown-content {
1919display: none;
1920position: absolute;
1921z-index: 1;
1922margin: 0;
1923}
1924.dropdown-menu li a {
1925color: #ddd !important;
1926}
1927.dropdown:hover .dropdown-content {
1928display: block;
1929background-color: #004746 !important;
1930border: 1px solid #0ff !important;
1931border-radius: 0px !important;
1932
1933}
1934.dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover {
1935background-color: #008780;
1936}
1937.modal-sm {
1938width: 350px !important;
1939}
1940
1941/**
1942* Ingress Panel Style
1943*/
1944
1945.panel-ingress {
1946background-color: #004746;
1947border: 1px solid #0ff;
1948border-radius: 1px;
1949box-shadow: inset 0 0 6px rgba(255, 255, 255, 1);
1950color: #0ff;
1951}
1952
1953/**
1954* Tooltip Styles
1955*/
1956
1957/* Add this attribute to the element that needs a tooltip */
1958[data-tooltip] {
1959position: relative;
1960cursor: pointer;
1961}
1962
1963/* Hide the tooltip content by default */
1964[data-tooltip]:before,
1965[data-tooltip]:after {
1966visibility: hidden;
1967-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";
1968filter: progid: DXImageTransform.Microsoft.Alpha(Opacity=0);
1969opacity: 0;
1970pointer-events: none;
1971}
1972
1973/* Position tooltip above the element */
1974[data-tooltip]:before {
1975position: absolute;
1976top: 150%;
1977left: 50%;
1978margin-bottom: 5px;
1979margin-left: -80px;
1980padding: 7px;
1981width: relative;
1982-webkit-border-radius: 3px;
1983-moz-border-radius: 3px;
1984border-radius: 3px;
1985background-color: #000;
1986background-color: hsla(0, 0%, 20%, 0.9);
1987color: #fff;
1988content: attr(data-tooltip);
1989text-align: center;
1990font-size: 14px;
1991line-height: 1.2;
1992z-index: 100;
1993}
1994
1995/* Triangle hack to make tooltip look like a speech bubble */
1996[data-tooltip]:after {
1997position: absolute;
1998top: 132%;
1999left: relative;
2000width: 0;
2001border-bottom: 5px solid #000;
2002border-bottom: 5px solid hsla(0, 0%, 20%, 0.9);
2003border-right: 5px solid transparent;
2004border-left: 5px solid transparent;
2005content: " ";
2006font-size: 0;
2007line-height: 0;
2008}
2009
2010/* Show tooltip content on hover */
2011[data-tooltip]:hover:before,
2012[data-tooltip]:hover:after {
2013visibility: visible;
2014-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)";
2015filter: progid: DXImageTransform.Microsoft.Alpha(Opacity=100);
2016opacity: 1;
2017}
2018
2019.titleEditBox:hover {
2020 box-shadow: inset 0 0 20px #ebbc4a;
2021}
2022
2023.titleEditBox:active {
2024 box-shadow: inset 0 0 15px 2px white;
2025}
2026
2027.group-list li label:hover, ul.sub-group-list a:hover, #root-label:hover {
2028 box-shadow: inset 0 0 5px #ffffff !important;
2029}
2030
2031.group-list li label:active, ul.sub-group-list a:active, #root-label:active {
2032 box-shadow: inset 0 0 10px 2px #ffffff !important;
2033}
2034
2035.modal-body .button:focus, .modal-body textarea:focus {
2036 outline: 2px dashed #ebbc4a;
2037}
2038
2039.modal-body .button:hover, .gm-style-iw button.button:hover {
2040 filter: brightness(150%);
2041}
2042
2043.alertify .dialog .msg {
2044color: black;
2045}
2046.alertify-logs > .default {
2047 background-image: url(/img/ingress-background-dark.png) !important;
2048}
2049
2050.btn-xs {
2051 padding: 0px 7px 1px !important;
2052 box-shadow: inset 0 0 4px rgba(255, 255, 255, 1);
2053 -webkit-box-shadow: inset 0 0 4px rgba(255, 255, 255, 1);
2054 -moz-box-shadow: inset 0 0 4px rgba(255, 255, 255, 1);
2055}
2056
2057kbd {
2058 display: inline-block;
2059 padding: 3px 5px;
2060 font: 11px SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace;
2061 line-height: 10px;
2062 color: #444d56;
2063 vertical-align: middle;
2064 background-color: #fafbfc;
2065 border: 1px solid #d1d5da;
2066 border-bottom-color: #c6cbd1;
2067 border-radius: 3px;
2068 box-shadow: inset 0 -1px 0 #c6cbd1;
2069}
2070
2071.dropdown-menu {
2072 margin: 0 !important;
2073}
2074
2075.opr-yellow {
2076 color: #F3EADA;
2077}
2078
2079
2080@media(min-width:768px) {
2081 div.modal-custom1 {
2082 width: 500px
2083 }
2084}
2085
2086`
2087
2088const PORTAL_MARKER = `data:image/png;base64,
2089iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACx
2090jwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAu
2091OWwzfk4AAADlSURBVDhPY/j//z8CTw3U/V8lcvx/MfPX/2Xcd//XyWwDYxAbJAaS63c2Q9aD0Nyg
2092UPS/hPXt/3bD5f93LI7DwFvnJILlSlg//K+XrUc1AKS5jOvx/wU55Vg1I2OQmlKOpzBDIM4G2UyM
2093ZhgGqQW5BOgdBrC/cDkbHwbpAeplAAcONgWEMChMgHoZwCGMTQExGKiXARxN2CSJwUC9VDCAYi9Q
2094HIhVQicpi0ZQ2gYlCrITEigpg5IlqUm5VrILkRdghoBMxeUd5MwE1YxqAAiDvAMKE1DAgmIHFMUg
2095DGKDxDCy838GAPWFoAEBs2EvAAAAAElFTkSuQmCC`
2096
2097const PERF_GREAT = `data:image/png;base64,
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
2127WH1hTgAAAABJRU5ErkJggg==`
2128
2129const PERF_GOOD = `data:image/png;base64,
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
2156YII=`
2157
2158const PERF_POOR = `data:image/png;base64,
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
2185AAAAAElFTkSuQmCC`
2186
2187// TG SVG Icon from https://commons.wikimedia.org/wiki/File:Telegram_logo.svg
2188const TG_SVG = `
2189<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" style="
2190 width: 16px;
2191 height: 16px;
2192">
2193<defs>
2194 <linearGradient id="b" x1="0.6667" y1="0.1667" x2="0.4167" y2="0.75">
2195 <stop stop-color="#37aee2" offset="0"></stop>
2196 <stop stop-color="#1e96c8" offset="1"></stop>
2197 </linearGradient>
2198 <linearGradient id="w" x1="0.6597" y1="0.4369" x2="0.8512" y2="0.8024">
2199 <stop stop-color="#eff7fc" offset="0"></stop>
2200 <stop stop-color="#fff" offset="1"></stop>
2201 </linearGradient>
2202</defs>
2203<circle cx="120" cy="120" r="120" fill="url(#b)"></circle>
2204<path fill="#c8daea" d="m98 175c-3.8876 0-3.227-1.4679-4.5678-5.1695L82
2205132.2059 170 80"></path>
2206<path fill="#a9c9dd" d="m98 175c3 0 4.3255-1.372
22076-3l16-15.558-19.958-12.035"></path>
2208<path fill="url(#w)" d="m100.04 144.41 48.36 35.729c5.5185 3.0449 9.5014
22091.4684
221010.876-5.1235l19.685-92.763c2.0154-8.0802-3.0801-11.745-8.3594-9.3482l-115.59
2211 44.571c-7.8901 3.1647-7.8441 7.5666-1.4382 9.528l29.663
2212 9.2583 68.673-43.325c3.2419-1.9659 6.2173-0.90899 3.7752 1.2584"></path>
2213</svg>`
2214// endregion