Greasy Fork is available in English.

WME State DOT Reports

Display state transportation department reports in WME.

// ==UserScript==
// @name         WME State DOT Reports
// @namespace    https://greasyfork.org/users/45389
// @version      2020.11.02.002
// @description  Display state transportation department reports in WME.
// @author       MapOMatic
// @license      GNU GPLv3
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// @include      /^https:\/\/(www|beta)\.waze\.com\/(?!user\/)(.{2,6}\/)?editor\/?.*$/
// @require      https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @grant        GM_xmlhttpRequest
// @connect      indot.carsprogram.org
// @connect      hb.511ia.org
// @connect      ohgo.com
// @connect      hb.511.nebraska.gov
// @connect      hb.511.idaho.gov
// @connect      hb.511mn.org
// ==/UserScript==

/* global $ */
/* global OpenLayers */
/* global GM_info */
/* global W */
/* global unsafeWindow */
/* global WazeWrap */
/* global GM_xmlhttpRequest */

const SETTINGS_STORE_NAME = 'dot_report_settings';
const ALERT_UPDATE = false;
const SCRIPT_VERSION = GM_info.script.version;
const SCRIPT_VERSION_CHANGES = [
    `${GM_info.script.name}\nv${SCRIPT_VERSION}\n\nWhat's New\n------------------------------\n`,
    '\n- Added Copy To Clipboard button on report popups.'
].join('');
const IMAGES_PATH = 'https://raw.githubusercontent.com/WazeDev/WME-State-DOT-Reports/master/images';
const DOT_INFO = {
    ID: {
        stateName: 'Idaho',
        mapType: 'cars',
        baseUrl: 'https://hb.511.idaho.gov',
        reportUrl: '/#roadReports/eventAlbum/',
        reportsFeedUrl: '/tgevents/api/eventReports'
    },
    IN: {
        stateName: 'Indiana',
        mapType: 'cars',
        baseUrl: 'https://indot.carsprogram.org',
        reportUrl: '/#roadReports/eventAlbum/',
        reportsFeedUrl: '/tgevents/api/eventReports'
    },
    IA: {
        stateName: 'Iowa',
        mapType: 'cars',
        baseUrl: 'https://hb.511ia.org',
        reportUrl: '/#allReports/eventAlbum/',
        reportsFeedUrl: '/tgevents/api/eventReports'
    },
    MN: {
        stateName: 'Minnesota',
        mapType: 'cars',
        baseUrl: 'https://hb.511mn.org',
        reportUrl: '/#roadReports/eventAlbum/',
        reportsFeedUrl: '/tgevents/api/eventReports'
    },
    NE: {
        stateName: 'Nebraska',
        mapType: 'cars',
        baseUrl: 'https://hb.511.nebraska.gov',
        reportUrl: '/#roadReports/eventAlbum/',
        reportsFeedUrl: '/tgevents/api/eventReports'
    }
};
const _columnSortOrder = ['priority', 'beginTime.time', 'eventDescription.descriptionHeader', 'icon.image', 'archived'];
let _reports = [];
let _previousZoom;
let _mapLayer = null;
let _settings = {};

function log(message) {
    console.log('DOT Reports: ', message);
}

function logDebug(message) {
    console.debug('DOT Reports:', message);
}
function logError(message) {
    console.error('DOT Reports:', message);
}

function copyToClipboard(report) {
    // create hidden text element, if it doesn't already exist
    const targetId = '_hiddenCopyText_';

    // must use a temporary form element for the selection and copy
    let target = document.getElementById(targetId);
    if (!target) {
        target = document.createElement('textarea');
        target.style.position = 'absolute';
        target.style.left = '-9999px';
        target.style.top = '0';
        target.id = targetId;
        document.body.appendChild(target);
    }
    const startTime = new Date(report.beginTime.time);
    const lastUpdateTime = new Date(report.updateTime.time);

    const $content = $('<div>').html(
        `${report.eventDescription.descriptionHeader}<br/><br/>
${report.eventDescription.descriptionFull}<br/><br/>
Start Time: ${startTime.toString('MMM d, y @ h:mm tt')}<br/>
Updated: ${lastUpdateTime.toString('MMM d, y @ h:mm tt')}`
    );

    $(target).val($content[0].innerText || $content[0].textContent);

    // select the content
    const currentFocus = document.activeElement;
    target.focus();
    target.setSelectionRange(0, target.value.length);

    // copy the selection
    let succeed = false;
    try {
        succeed = document.execCommand('copy');
    } catch (e) {
        // do nothing
    }
    // restore original focus
    if (currentFocus && typeof currentFocus.focus === 'function') {
        currentFocus.focus();
    }

    target.textContent = '';
    return succeed;
}

// I believe this should return the bounds that Waze uses to load its data model.
// It's wider than the visible bounds of the map, to reduce data loading frequency.
function getExpandedDataBounds() {
    return W.controller.descartesClient.getExpandedDataBounds(W.map.calculateBounds());
}

function createSavableReport(reportIn) {
    const attributesToCopy = ['agencyAttribution', 'archived', 'beginTime', 'editorIdentifier', 'eventDescription', 'headlinePhrase',
        'icon', 'id', 'location', 'priority', 'situationUpdateKey', 'starred', 'updateTime'];

    const reportOut = {};
    attributesToCopy.forEach(attr => (reportOut[attr] = reportIn[attr]));

    return reportOut;
}
function copyToSavableReports(reportsIn) {
    const reportsOut = {};
    Object.keys(reportsIn).forEach(id => (reportsOut[id] = createSavableReport(reportsIn[id])));
    return reportsOut;
}

function saveSettingsToStorage() {
    if (localStorage) {
        const settings = {
            lastVersion: SCRIPT_VERSION,
            layerVisible: _mapLayer.visibility,
            state: _settings.state,
            hideArchivedReports: $('#hideDotArchivedReports').is(':checked'),
            hideWazeReports: $('#hideDotWazeReports').is(':checked'),
            hideNormalReports: $('#hideDotNormalReports').is(':checked'),
            hideWeatherReports: $('#hideDotWeatherReports').is(':checked'),
            hideCrashReports: $('#hideDotCrashReports').is(':checked'),
            hideWarningReports: $('#hideDotWarningReports').is(':checked'),
            hideClosureReports: $('#hideDotClosureReports').is(':checked'),
            hideRestrictionReports: $('#hideDotRestrictionReports').is(':checked'),
            hideFutureReports: $('#hideDotFutureReports').is(':checked'),
            hideCurrentReports: $('#hideDotCurrentReports').is(':checked'),
            archivedReports: _settings.archivedReports,
            starredReports: copyToSavableReports(_settings.starredReports)
        };
        localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(settings));
        logDebug('Settings saved');
    }
}

function dynamicSort(property) {
    let sortOrder = 1;
    if (property[0] === '-') {
        sortOrder = -1;
        property = property.substr(1);
    }
    return (a, b) => {
        const props = property.split('.');
        props.forEach(prop => {
            a = a[prop];
            b = b[prop];
        });
        let result = 0;
        if (a < b) {
            result = -1;
        } else if (a > b) {
            result = 1;
        }
        return result * sortOrder;
    };
}

function dynamicSortMultiple(...args) {
    /*
    * save the arguments object as it will be overwritten
    * note that arguments object is an array-like object
    * consisting of the names of the properties to sort by
    */
    let props = args;
    if (args[0] && Array.isArray(args[0])) {
        [props] = args;
    }
    return (obj1, obj2) => {
        let i = 0;
        let result = 0;
        const numberOfProperties = props.length;
        /* try getting a different result from 0 (equal)
        * as long as we have extra properties to compare
        */
        while (result === 0 && i < numberOfProperties) {
            result = dynamicSort(props[i])(obj1, obj2);
            i++;
        }
        return result;
    };
}

function getReport(reportId) {
    return _reports.find(report => report.id === reportId);
}

function isHideOptionChecked(reportType) {
    return $(`#hideDot${reportType}Reports`).is(':checked');
}

function updateReportsVisibility() {
    hideAllReportPopovers();
    const hideArchived = isHideOptionChecked('Archived');
    const hideWaze = isHideOptionChecked('Waze');
    const hideNormal = isHideOptionChecked('Normal');
    const hideWeather = isHideOptionChecked('Weather');
    const hideCrash = isHideOptionChecked('Crash');
    const hideWarning = isHideOptionChecked('Warning');
    const hideRestriction = isHideOptionChecked('Restriction');
    const hideClosure = isHideOptionChecked('Closure');
    const hideFuture = isHideOptionChecked('Future');
    const hideCurrent = isHideOptionChecked('Current');
    let visibleCount = 0;
    _reports.forEach(report => {
        const img = report.icon.image;
        const now = Date.now();
        const start = new Date(report.beginTime.time);
        const hide = (hideArchived && report.archived)
            || (hideWaze && img.indexOf('waze') > -1)
            || (hideNormal && img.includes('driving'))
            || (hideWeather && (img.indexOf('weather') > -1 || img.indexOf('flooding') > -1))
            || (hideCrash && img.indexOf('crash') > -1)
            || (hideWarning && (img.indexOf('warning') > -1 || img.indexOf('lane_closure') > -1))
            || (hideRestriction && img.indexOf('restriction') > -1)
            || (hideClosure && img.indexOf('closure') > -1)
            || (hideFuture && start > now)
            || (hideCurrent && start <= now);
        if (hide) {
            report.dataRow.hide();
            if (report.imageDiv) { report.imageDiv.hide(); }
        } else {
            visibleCount += 1;
            report.dataRow.show();
            if (report.imageDiv) { report.imageDiv.show(); }
        }
    });
    $('.dot-report-count').text(`${visibleCount} of ${_reports.length} reports`);
}

function hideAllPopovers($excludeDiv) {
    _reports.forEach(rpt => {
        const $div = rpt.imageDiv;
        if ((!$excludeDiv || $div[0] !== $excludeDiv[0]) && $div.data('state') === 'pinned') {
            $div.data('state', '');
            $div.popover('hide');
        }
    });
}

function deselectAllDataRows() {
    _reports.forEach(rpt => rpt.dataRow.css('background-color', 'white'));
}

function toggleMarkerPopover($div, forcePin = false) {
    hideAllPopovers($div);
    if ($div.data('state') !== 'pinned' || forcePin) {
        const id = $div.data('reportId');
        const report = getReport(id);
        $div.data('state', 'pinned');
        $div.popover('show');
        _mapLayer.setZIndex(100000); // this is to help make sure the report shows on top of the turn restriction arrow layer
        if (report.archived) {
            $('.btn-archive-dot-report').text('Un-Archive');
        }
        $('.btn-archive-dot-report').click(() => { setArchiveReport(report, !report.archived, true); buildTable(); });
        $('.btn-open-dot-report').click(evt => {
            evt.stopPropagation();
            window.open($(evt.currentTarget).data('dot-report-url'), '_blank');
        });
        $('.btn-zoom-dot-report').click(evt => {
            evt.stopPropagation();
            W.map.setCenter(getReport($(evt.currentTarget).data('dot-report-id')).marker.lonlat);
            W.map.olMap.zoomTo(4);
        });
        $('.btn-copy-dot-report').click(evt => {
            evt.stopPropagation();
            copyToClipboard(getReport($(evt.currentTarget).data('dot-report-id')));
        });
        $('.reportPopover,.close-popover').click(evt => {
            evt.stopPropagation();
            hideAllReportPopovers();
        });
        // $(".close-popover").click(function() {hideAllReportPopovers();});
        $div.data('report').dataRow.css('background-color', 'beige');
    } else {
        $div.data('state', '');
        $div.popover('hide');
    }
}

function toggleReportPopover($div) {
    deselectAllDataRows();
    toggleMarkerPopover($div);
}

function hideAllReportPopovers() {
    deselectAllDataRows();
    hideAllPopovers();
}

function setArchiveReport(report, archive, updateUi) {
    report.archived = archive;
    if (archive) {
        _settings.archivedReports[report.id] = { updateNumber: report.situationUpdateKey.updateNumber };
        report.imageDiv.addClass('dot-archived-marker');
    } else {
        delete _settings.archivedReports[report.id];
        report.imageDiv.removeClass('dot-archived-marker');
    }
    if (updateUi) {
        saveSettingsToStorage();
        updateReportsVisibility();
        hideAllReportPopovers();
    }
}

function setStarReport(report, star, updateUi) {
    report.starred = star;
    if (star) {
        if (!_settings.starredReports) { _settings.starredReports = {}; }
        _settings.starredReports[report.id] = report;
        report.imageDiv.addClass('dot-starred-marker');
    } else {
        delete _settings.starredReports[report.id];
        report.imageDiv.removeClass('dot-starred-marker');
    }
    if (updateUi) {
        saveSettingsToStorage();
        updateReportsVisibility();
        hideAllReportPopovers();
    }
}

function archiveAllReports(unarchive) {
    _reports.forEach(report => setArchiveReport(report, !unarchive, false));
    saveSettingsToStorage();
    buildTable();
    hideAllReportPopovers();
}

function addRow($table, report) {
    const $img = $('<img>', { src: report.imgUrl, class: 'table-img' });
    const $row = $('<tr> class="clickable"', { id: `dot-row-${report.id}` }).append(
        $('<td class="centered">').append(
            $('<span>', {
                class: `star ${(report.starred ? 'star-filled' : 'star-empty')}`,
                title: 'Star if you want notification when this report is removed by the DOT.\nFor instance, if a map change needs to be undone after a closure report is removed.'
            }).click(evt => {
                evt.stopPropagation();
                setStarReport(report, !report.starred, true);
                const $target = $(evt.currentTarget);
                $target.removeClass(report.starred ? 'star-empty' : 'star-filled');
                $target.addClass(report.starred ? 'star-filled' : 'star-empty');
            })
        ),
        $('<td>', { class: 'centered' }).append(
            $('<input>', {
                type: 'checkbox',
                title: 'Archive (will automatically un-archive if report is updated by DOT)',
                id: `archive-${report.id}`,
                'data-report-id': report.id
            }).prop('checked', report.archived).click(evt => {
                evt.stopPropagation();
                const $target = $(evt.currentTarget);
                const id = $target.data('reportId');
                const thisReport = getReport(id);
                setArchiveReport(thisReport, $target.is(':checked'), true);
            })
        ),
        $('<td>', { class: 'clickable' }).append($img),
        $('<td>', { class: 'centered' }).text(report.priority),
        $('<td>', { class: (report.wasRemoved ? 'removed-report' : '') }).text(report.eventDescription.descriptionHeader),
        $('<td>', { class: 'centered' }).text(new Date(report.beginTime.time).toString('M/d/y h:mm tt'))
    ).click(evt => {
        const $thisRow = $(evt.currentTarget);
        const id = $thisRow.data('reportId');
        const { marker } = getReport(id);
        const $imageDiv = report.imageDiv;

        if ($imageDiv.data('state') !== 'pinned') {
            W.map.setCenter(marker.lonlat);
        }

        toggleReportPopover($imageDiv);
    }).data('reportId', report.id);
    report.dataRow = $row;
    $table.append($row);
    $row.report = report;
}

function onClickColumnHeader(evt) {
    const obj = evt.currentTarget;
    let prop;
    switch (/dot-table-(.*)-header/.exec(obj.id)[1]) {
        case 'category':
            prop = 'icon.image';
            break;
        case 'begins':
            prop = 'beginTime.time';
            break;
        case 'desc':
            prop = 'eventDescription.descriptionHeader';
            break;
        case 'priority':
            prop = 'priority';
            break;
        case 'archive':
            prop = 'archived';
            break;
        default:
            return;
    }
    const idx = _columnSortOrder.indexOf(prop);
    if (idx > -1) {
        _columnSortOrder.splice(idx, 1);
        _columnSortOrder.reverse();
        _columnSortOrder.push(prop);
        _columnSortOrder.reverse();
        buildTable();
    }
}

function buildTable() {
    logDebug('Building table');
    const $table = $('<table>', { class: 'dot-table' });
    $table.append(
        $('<thead>').append(
            $('<tr>').append(
                $('<th>', { id: 'dot-table-star-header', title: 'Favorites' }),
                $('<th>', { id: 'dot-table-archive-header', class: 'centered' }).append(
                    $('<span>', { class: 'fa fa-archive', style: 'font-size:120%', title: 'Sort by archived' })
                ),
                $('<th>', { id: 'dot-table-category-header', title: 'Sort by report type' }),
                $('<th>', { id: 'dot-table-priority-header', title: 'Sort by priority' }).append(
                    $('<span>', { class: 'fa fa-exclamation-circle', style: 'font-size:120%' })
                ),
                $('<th>', { id: 'dot-table-desc-header', title: 'Sort by description' }).text('Description'),
                $('<th>', { id: 'dot-table-begins-header', title: 'Sort by starting date' }).text('Starts')
            )
        )
    );
    _reports.sort(dynamicSortMultiple(_columnSortOrder));
    _reports.forEach(report => addRow($table, report));
    $('.dot-table').remove();
    $('#dot-report-table').append($table);
    $('.dot-table th').click(onClickColumnHeader);

    updateReportsVisibility();
}

function getUrgencyString(imagePath) {
    const i1 = imagePath.lastIndexOf('_');
    const i2 = imagePath.lastIndexOf('.');
    return imagePath.substring(i1 + 1, i2);
}

function updateReportImageUrl(report) {
    const startTime = new Date(report.beginTime.time);
    let imgName = report.icon.image;

    if (imgName.indexOf('flooding') !== -1) {
        imgName = imgName.replace('flooding', 'weather').replace('.png', '.gif');
    } else if (report.headlinePhrase.category === 5 && report.headlinePhrase.code === 21) {
        imgName = '/tg_flooding_urgent.png';
    }

    const now = new Date(Date.now());
    if (startTime > now) {
        let futureValue;
        if (startTime > now.clone().addMonths(2)) {
            futureValue = 'pp';
        } else if (startTime > now.clone().addMonths(1)) {
            futureValue = 'p';
        } else {
            futureValue = startTime.getDate();
        }
        imgName = `/tg_future_${futureValue}_${getUrgencyString(imgName)}.gif`;
    }
    report.imgUrl = IMAGES_PATH + imgName;
}

function updateReportGeometry(report) {
    const coord = report.location.primaryPoint;
    report.location.openLayers = {
        primaryPointLonLat: new OpenLayers.LonLat(coord.lon, coord.lat).transform('EPSG:4326', 'EPSG:900913')
    };
}

function processReport(report) {
    if (report.location && report.location.primaryPoint && report.icon) {
        const size = new OpenLayers.Size(report.icon.width, report.icon.height);
        const icon = new OpenLayers.Icon(report.imgUrl, size, null);
        const marker = new OpenLayers.Marker(report.location.openLayers.primaryPointLonLat, icon);
        marker.report = report;
        // marker.events.register('click', marker, onMarkerClick);
        // _mapLayer.addMarker(marker);

        const dot = DOT_INFO[_settings.state];
        const lastUpdateTime = new Date(report.updateTime.time);
        const startTime = new Date(report.beginTime.time);
        const content = $('<div>').append(
            report.eventDescription.descriptionFull,
            $('<div>', { style: 'margin-top: 10px;' }).append(
                $('<span>', { style: 'font-weight: bold; margin-right: 8px;' }).text('Start Time:'),
                startTime.toString('MMM d, y @ h:mm tt'),
            ),
            $('<div>').append(
                $('<span>', { style: 'font-weight: bold; margin-right: 8px;' }).text('Updated:'),
                `${lastUpdateTime.toString('MMM d, y @ h:mm tt')}&nbsp;&nbsp;(update #${report.situationUpdateKey.updateNumber})`
            ),
            $('<div>').append(
                $('<hr>', { style: 'margin-bottom: 5px; margin-top: 5px; border-color: gainsboro' }),
                $('<div>', { style: 'display: table; width: 100%' }).append(
                    $('<button>', {
                        class: 'btn btn-primary, btn-open-dot-report',
                        style: 'float: left;',
                        'data-dot-report-url': dot.baseUrl + dot.reportUrl + report.id
                    }).text('Open in DOT website'),
                    $('<button>', {
                        class: 'btn btn-primary, btn-zoom-dot-report',
                        style: 'float: left; margin-left: 6px;',
                        'data-dot-report-id': report.id
                    }).text('Zoom'),
                    $('<button>', {
                        class: 'btn btn-primary, btn-copy-dot-report',
                        style: 'float: left; margin-left: 6px;',
                        'data-dot-report-id': report.id
                    }).append('<span class="fa fa-copy">'),
                    $('<button>', {
                        class: 'btn btn-primary, btn-archive-dot-report',
                        style: 'float: right;',
                        'data-dot-report-id': report.id
                    }).text('Archive'),
                )
            )
        ).html();

        const title = $('<div>', { style: 'width: 100%;' }).append(
            $('<div>', { style: 'float: left; max-width: 330px; color: #5989af; font-size: 120%;' }).text(report.eventDescription.descriptionHeader),
            $('<div>', { style: 'float: right;' }).append(
                // eslint-disable-next-line no-script-url
                $('<span>', { class: 'close-popover fa fa-window-close' })
            ),
            $('<div>', { style: 'clear: both;' })
        ).html();

        const popoverTemplate = $('<div>', { class: 'reportPopover popover', style: 'max-width: 500px; width: 500px;' }).append(
            $('<div>', { class: 'arrow' }),
            $('<div>', { class: 'popover-title' }),
            $('<div>', { class: 'popover-content' })
        );

        const $imageDiv = $(marker.icon.imageDiv)
            .css('cursor', 'pointer')
            .addClass('dotReport')
            .attr({
                'data-toggle': 'popover',
                title: '',
                'data-content': content,
                'data-original-title': title
            }).popover({
                trigger: 'manual',
                html: true,
                placement: 'auto top',
                template: popoverTemplate
            }).on('click', () => toggleReportPopover($imageDiv))
            .data('reportId', report.id)
            .data('state', '')
            .data('report', report);

        if (report.agencyAttribution && report.agencyAttribution.agencyName.toLowerCase().includes('waze')) {
            $imageDiv.addClass('wazeReport');
        }
        if (report.archived) {
            $imageDiv.addClass('dot-archived-marker');
        }
        report.imageDiv = $imageDiv;
        report.marker = marker;
    }
}

function processReports(reports) {
    let settingsUpdated = false;
    _reports = [];
    _mapLayer.clearMarkers();
    logDebug('Adding reports to map...');
    reports.forEach(report => {
        // Exclude pandemic reports (e.g. required social distancing, masks, etc)
        const isPandemicReport = report.icon.image.includes('pandemic');
        if (!isPandemicReport && report.location && report.location.primaryPoint) {
            report.archived = false;
            if (_settings.archivedReports.hasOwnProperty(report.id)) {
                if (_settings.archivedReports[report.id].updateNumber < report.situationUpdateKey.updateNumber) {
                    delete _settings.archivedReports[report.id];
                } else {
                    report.archived = true;
                }
            }
            _reports.push(report);
        }
    });

    // Check saved starred reports.
    Object.keys(_settings.starredReports).forEach(reportId => {
        const starredReport = _settings.starredReports[reportId];
        const report = getReport(reportId);
        if (report) {
            report.starred = true;
            if (report.situationUpdateKey.updateNumber !== starredReport.situationUpdateKey.updateNumber) {
                _settings.starredReports[report.id] = report;
                settingsUpdated = true;
            }
        } else {
            // Report has been removed by DOT.
            if (!starredReport.wasRemoved) {
                starredReport.archived = false;
                starredReport.wasRemoved = true;
                settingsUpdated = true;
            }
            _reports.push(starredReport);
        }
    });
    _reports.forEach(report => {
        updateReportImageUrl(report);
        updateReportGeometry(report);
        processReport(report);
    });
    if (settingsUpdated) {
        saveSettingsToStorage();
    }
    buildTable();
}

// This function returns a Promise so that it can be used with async/await.
function makeRequest(url) {
    // GM_xmlhttpRequest is necessary to avoid CORS issues on some sites.
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: 'GET',
            url,
            onload: res => {
                if (res.status >= 200 && res.status < 300) {
                    resolve(res.responseText);
                } else {
                    reject(new Error(`(${this.status}) ${this.statusText}`));
                }
            },
            onerror: res => {
                let msg;
                if (res.status === 0) {
                    msg = 'An unknown error occurred while attempting to download DOT data.';
                } else {
                    msg = `Status code ${this.status} - ${this.statusText}`;
                }
                reject(new Error(msg));
            }
        });
    });
}

async function fetchReports() {
    const dot = DOT_INFO[_settings.state];
    let json;
    try {
        const url = dot.baseUrl + dot.reportsFeedUrl;
        const text = await makeRequest(url);
        json = $.parseJSON(text);
    } catch (ex) {
        logError(new Error(ex.message));
        json = [];
    }
    processReports(json);
}

function onLayerVisibilityChanged() {
    saveSettingsToStorage();
}

/* eslint-disable */
function installIcon() {
    OpenLayers.Icon = OpenLayers.Class({
        url: null,
        size: null,
        offset: null,
        calculateOffset: null,
        imageDiv: null,
        px: null,
        initialize: function(a, b, c, d){
            this.url=a;
            this.size=b||{w: 20, h: 20};
            this.offset=c||{x: -(this.size.w/2), y: -(this.size.h/2)};
            this.calculateOffset=d;
            a=OpenLayers.Util.createUniqueID("OL_Icon_");
            var div = this.imageDiv=OpenLayers.Util.createAlphaImageDiv(a);
            
            // LEAVE THE FOLLOWING LINE TO PREVENT WME-HARDHATS SCRIPT FROM TURNING ALL ICONS INTO HARDHAT WAZERS --MAPOMATIC
            $(div.firstChild).removeClass('olAlphaImg');
        },
        destroy: function(){ this.erase();OpenLayers.Event.stopObservingElement(this.imageDiv.firstChild);this.imageDiv.innerHTML="";this.imageDiv=null; },
        clone: function(){ return new OpenLayers.Icon(this.url, this.size, this.offset, this.calculateOffset); },
        setSize: function(a){ null!==a&&(this.size=a); this.draw(); },
        setUrl: function(a){ null!==a&&(this.url=a); this.draw(); },
        draw: function(a){
            OpenLayers.Util.modifyAlphaImageDiv(this.imageDiv, null, null, this.size, this.url, "absolute");
            this.moveTo(a);
            return this.imageDiv;
        },
        erase: function(){ null!==this.imageDiv&&null!==this.imageDiv.parentNode&&OpenLayers.Element.remove(this.imageDiv); },
        setOpacity: function(a){ OpenLayers.Util.modifyAlphaImageDiv(this.imageDiv, null, null, null, null, null, null, null, a); },
        moveTo: function(a){
            null!==a&&(this.px=a);
            null!==this.imageDiv&&(null===this.px?this.display(!1): (
                this.calculateOffset&&(this.offset=this.calculateOffset(this.size)),
                OpenLayers.Util.modifyAlphaImageDiv(this.imageDiv, null, {x: this.px.x+this.offset.x, y: this.px.y+this.offset.y})
            ));
        },
        display: function(a){ this.imageDiv.style.display=a?"": "none"; },
        isDrawn: function(){ return this.imageDiv&&this.imageDiv.parentNode&&11!=this.imageDiv.parentNode.nodeType; },
        CLASS_NAME: "OpenLayers.Icon"
    });
}
/* eslint-enable */

function onStateSelectChange(evt) {
    hideAllReportPopovers();
    _settings.state = evt.currentTarget.value;
    saveSettingsToStorage();
    fetchReports();
}

function onHideReportTypeCheckChange() {
    saveSettingsToStorage();
    updateReportsVisibility();
}

function isLoading() {
    return $('.dot-refresh-reports').hasClass('fa-spin');
}
function beforeLoading() {
    const spinner = $('.dot-refresh-reports');
    spinner.addClass('fa-spin').css({ cursor: 'auto' });
    hideAllReportPopovers();
}
function afterLoading() {
    const spinner = $('.dot-refresh-reports');
    spinner.removeClass('fa-spin').css({ cursor: 'pointer' });
    WazeWrap.Alerts.success(null, 'DOT reports refreshed');
}

async function onRefreshReportsClick(evt) {
    evt.stopPropagation();
    if (!isLoading()) {
        beforeLoading();
        await fetchReports();
        afterLoading();
    }
}

function init511ReportsOverlay() {
    installIcon();
    _mapLayer = new OpenLayers.Layer.Markers('State DOT Reports', {
        displayInLayerSwitcher: true,
        uniqueName: '__stateDotReports'
    });

    W.map.addLayer(_mapLayer);
    _mapLayer.setVisibility(_settings.layerVisible);
    _mapLayer.setZIndex(100000);
    _mapLayer.events.register('visibilitychanged', null, onLayerVisibilityChanged);
}

function initSideTab() {
    $('#stateDotStateSelect').change(onStateSelectChange);
    $('[id^=hideDot]').change(onHideReportTypeCheckChange);
    $('#stateDotStateSelect').val(_settings.state);

    ['ArchivedReports', 'WazeReports', 'NormalReports', 'WeatherReports',
        'TrafficReports', 'CrashReports', 'WarningReports', 'RestrictionReports',
        'ClosureReports', 'FutureReports', 'CurrentReports'].forEach(name => {
        const settingsPropName = `hide${name}`;
        const checkboxId = `hideDot${name}`;
        if (_settings[settingsPropName]) {
            $(`#${checkboxId}`).prop('checked', true);
        }
    });

    $('<span>', {
        title: 'Click to refresh DOT reports',
        class: 'fa fa-refresh refreshIcon dot-tab-icon dot-refresh-reports',
        style: 'cursor:pointer;'
    }).appendTo($('a[href="#sidepanel-dot"]'));

    $('.dot-refresh-reports').click(onRefreshReportsClick);
}

function buildSideTab() {
    // Helper template functions to create elements
    const createCheckbox = (id, text) => $('<div>', { class: 'controls-container' }).append(
        $('<input>', { type: 'checkbox', id }),
        $('<label>', { for: id }).text(text)
    );
    const createOption = (value, text) => $('<option>', { value }).text(text);

    const panel = $('<div>').append(
        $('<div>', { class: 'side-panel-section>' }).append(
            $('<div>', { class: 'form-group' }).append(
                $('<label>', { class: 'control-label' }).text('Select your state'),
                $('<div>', { class: 'controls', id: 'state-select' }).append(
                    $('<div>').append(
                        $('<select>', { id: 'stateDotStateSelect', class: 'form-control' }).append(
                            Object.keys(DOT_INFO).map(abbr => createOption(abbr, DOT_INFO[abbr].stateName))
                        )
                    )
                ),
                $('<label style="width:100%; cursor:pointer; border-bottom: 1px solid #e0e0e0; margin-top:9px;" data-toggle="collapse" data-target="#dotSettingsCollapse"><span class="fa fa-caret-down" style="margin-right:5px;font-size:120%;"></span>Hide reports...</label>'),
                $('<div>', { id: 'dotSettingsCollapse', class: 'collapse' }).append(
                    createCheckbox('hideDotArchivedReports', 'Archived'),
                    createCheckbox('hideDotWazeReports', 'Waze (if supported by DOT)'),
                    createCheckbox('hideDotNormalReports', 'Driving conditions'),
                    createCheckbox('hideDotWeatherReports', 'Weather'),
                    createCheckbox('hideDotCrashReports', 'Crash'),
                    createCheckbox('hideDotWarningReports', 'Warning'),
                    createCheckbox('hideDotRestrictionReports', 'Restriction'),
                    createCheckbox('hideDotClosureReports', 'Closure'),
                    createCheckbox('hideDotFutureReports', 'Future'),
                    createCheckbox('hideDotCurrentReports', 'Current/Past')
                )
            )
        ),
        $('<div>', { class: 'side-panel-section>', id: 'dot-report-table' }).append(
            $('<div>').append(
                $('<span>', {
                    title: 'Click to refresh DOT reports',
                    class: 'fa fa-refresh refreshIcon dot-refresh-reports dot-table-label',
                    style: 'cursor:pointer;'
                }),
                $('<span>', { class: 'dot-table-label dot-report-count count' }),
                $('<span>', { class: 'dot-table-label dot-table-action right' }).text('Archive all').click(() => {
                    if (confirm(`Archive all reports for ${_settings.state}?`)) {
                        archiveAllReports(false);
                    }
                }),
                $('<span>', { class: 'dot-table-label right' }).text('|'),
                $('<span>', { class: 'dot-table-label dot-table-action right' }).text('Un-Archive all').click(() => {
                    if (confirm(`Un-archive all reports for ${_settings.state}?`)) {
                        archiveAllReports(true);
                    }
                })
            )
        )
    );

    new WazeWrap.Interface.Tab('DOT', panel.html(), initSideTab, null);
}

function showScriptInfoAlert() {
    /* Check version and alert on update */
    if (ALERT_UPDATE && SCRIPT_VERSION !== _settings.lastVersion) {
        alert(SCRIPT_VERSION_CHANGES);
    }
}

function initGui() {
    init511ReportsOverlay();
    buildSideTab();
    showScriptInfoAlert();

    $(`<style type="text/css">
.dot-table th,td,tr {cursor: default;}
.dot-table .centered {text-align:center;}
.dot-table th:hover,tr:hover {background-color: aliceblue;outline: -webkit-focus-ring-color auto 5px;}
.dot-table th:hover {color: blue;border-color: whitesmoke; }
.dot-table {border: 1px solid gray;border-collapse: collapse;width: 100%;font-size: 83%;margin: 0px 0px 0px 0px}
.dot-table th,td {border: 1px solid gainsboro;}
.dot-table td,th {color: black;padding: 1px 4px;}
.dot-table th {background-color: gainsboro;}
.dot-table .table-img {max-width: 24px;max-height: 24px;}
.tooltip.top > .tooltip-arrow {border-top-color: white;}
.tooltip.bottom > .tooltip-arrow {border-bottom-color: white;}
.close-popover { cursor: pointer;font-size: 20px; }
.close-popover:hover { color: #f35252; }
.refreshIcon:hover {color:blue;text-shadow: 2px 2px #aaa;}
.refreshIcon:active { text-shadow: 0px 0px; }
.dot-tab-icon { margin-left: 10px; }
.dot-archived-marker {opacity: 0.5;}
.dot-table-label {font-size: 85%;}
.dot-table-action:hover {color: blue;cursor: pointer}
.dot-table-label.right {float: right}
.dot-table-label.count {margin-left: 4px;}
.dot-table .star {cursor: pointer;width: 18px;height: 18px;margin-top: 3px;}
.dot-table .star-empty {content: url(${IMAGES_PATH}/star-empty.png);}
.dot-table .star-filled {content: url(${IMAGES_PATH}/star-filled.png);}
.dot-table .removed-report {text-decoration: line-through;color: #bbb}
</style>`).appendTo('head');

    _previousZoom = W.map.zoom;
    W.map.events.register('zoomend', null, () => {
        if (_previousZoom !== W.map.zoom) {
            hideAllReportPopovers();
        }
        _previousZoom = W.map.zoom;
    });
}

function loadSettingsFromStorage() {
    let settings = $.parseJSON(localStorage.getItem(SETTINGS_STORE_NAME));
    if (!settings) {
        settings = {
            lastVersion: null,
            layerVisible: true,
            state: 'ID',
            hideArchivedReports: true,
            archivedReports: {}
        };
    } else {
        settings.layerVisible = (settings.layerVisible === true);
        settings.state = settings.state ? settings.state : Object.keys(DOT_INFO)[0];
        if (typeof settings.hideArchivedReports === 'undefined') {
            settings.hideArchivedReports = true;
        }
        settings.archivedReports = settings.archivedReports ? settings.archivedReports : {};
        settings.starredReports = settings.starredReports ? settings.starredReports : {};
    }
    _settings = settings;
}

function addMarkers() {
    _mapLayer.clearMarkers();
    const dataBounds = getExpandedDataBounds();
    _reports.forEach(report => {
        if (dataBounds.containsLonLat(report.location.openLayers.primaryPointLonLat)) {
            _mapLayer.addMarker(report.marker);
        }
    });
}

function onMoveEnd() {
    addMarkers();
}

async function init() {
    loadSettingsFromStorage();
    W.map.events.register('moveend', null, onMoveEnd);
    unsafeWindow.addEventListener('beforeunload', saveSettingsToStorage, false);
    initGui();
    await fetchReports();
    addMarkers();
    log('Initialized');
}

function bootstrap() {
    if (W && W.loginManager
        && W.loginManager.events.register
        && W.map && W.loginManager.user
        && WazeWrap.Ready) {
        log('Initializing...');
        init();
    } else {
        log('Bootstrap failed. Trying again...');
        setTimeout(bootstrap, 1000);
    }
}

bootstrap();