WME State DOT Reports

Display state transportation department reports in WME.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();