Overpass Turbo to JSON

Adds a JSON export panel to Overpass Turbo. Download data, copy it, or send it to a converter tool that allows keeping properties as tags. Uses pako and osmtogeojson (MIT).

// ==UserScript==
// @name         Overpass Turbo to JSON
// @name         Overpass Turbo Export To Converter
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  Adds a JSON export panel to Overpass Turbo. Download data, copy it, or send it to a converter tool that allows keeping properties as tags. Uses pako and osmtogeojson (MIT).
// @author       Parma
// @icon         https://geojson-converter.vercel.app/favicon.ico
// @match        *://overpass-turbo.eu/*
// @match        *://maps.mail.ru/osm/tools/overpass/*
// @license      MIT
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js
// @require      https://unpkg.com/[email protected]/osmtogeojson.js
// ==/UserScript==

/*! ATTRIBUTION:
This userscript uses the following third-party libraries:
------------------------------------------------------------------------------
- pako (https://github.com/nodeca/pako)
  Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn
  Released under the MIT license.
- osmtogeojson (https://github.com/tyrasd/osmtogeojson)
  Copyright (C) 2012-2019 Martin Raifer
  Released under the MIT license.
------------------------------------------------------------------------------
*/

(function () {
    'use strict';

    let lastGeoJson = null;

    // Creates a DOM element with specified properties and children
    function createElement(tag, properties, children) {
        const element = document.createElement(tag);
        if (properties) {
            Object.keys(properties).forEach(key => {
                if (key === 'textContent') {
                    element.textContent = properties[key];
                } else {
                    element[key] = properties[key];
                }
            });
        }
        if (children) {
            children.forEach(child => element.appendChild(child));
        }
        return element;
    }


    // Resets button state after a timeout
    function resetButtonAfterTimeout(button, originalText, timeout) {
        setTimeout(() => {
            button.textContent = originalText;
            button.disabled = false;
        }, timeout || 2000);
    }

    // Checks if GeoJSON data is available and shows alert if not
    function checkGeoJsonAvailable() {
        if (!lastGeoJson) {
            alert('No GeoJSON data available yet. Please wait for the Overpass query to finish.');
            return false;
        }
        return true;
    }

    // Downloads JSON data as a file
    function downloadJson(data, filename) {
        const jsonString = JSON.stringify(data, null, 2);
        const blob = new Blob([jsonString], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    // Sends compressed GeoJSON to the converter tool
    function sendCompressedGeoJson(appWindow, geoJson) {
        const jsonStr = JSON.stringify(geoJson);
        const compressed = pako.deflate(jsonStr);
        // Convert Uint8Array to Base64 string for safe transfer
        const base64 = btoa(new Uint8Array(compressed).reduce((data, byte) => data + String.fromCharCode(byte), ''));

        appWindow.postMessage({
            type: 'OVERPASS_DIRECT_DATA_COMPRESSED',
            payload: base64
        }, 'https://geojson-converter.vercel.app');
    }


    // Sorts properties with @id first, @geometry last, others alphabetically
    function sortProperties(properties) {
        const result = {};
        if (properties && Object.prototype.hasOwnProperty.call(properties, '@id')) {
            result['@id'] = properties['@id'];
        }
        const middleKeys = Object.keys(properties || {})
            .filter(k => k !== '@id' && k !== '@geometry')
            .sort();
        middleKeys.forEach(k => { result[k] = properties[k]; });
        if (properties && Object.prototype.hasOwnProperty.call(properties, '@geometry')) {
            result['@geometry'] = properties['@geometry'];
        }
        return result;
    }

    // osmtogeojson options tuned to match Overpass Turbo defaults as closely as possible
    function osmtogeojsonOptions() {
        // uninterestingTags and polygonFeatures match the library defaults
        // we keep the hook to adjust in future if needed
        return {
            flatProperties: true
            // uninterestingTags: undefined,
            // polygonFeatures: undefined
        };
    }

    // Flattens osmtogeojson feature properties into Overpass-like flat tags with ordering
    function flattenAndOrderFeatureProperties(feature) {
        const p = feature && feature.properties ? feature.properties : {};
        const type = p.type;
        const nid = p.id;
        const idStr = feature.id || (type != null && nid != null ? `${type}/${nid}` : undefined);
        // When flatProperties is true, tags are top-level on properties; otherwise in p.tags
        const reserved = new Set(['type', 'id', 'meta', 'timestamp', 'version', 'changeset', 'uid', 'user', 'relations', 'geometry', 'tainted']);
        let tags = {};
        if (p && p.tags && typeof p.tags === 'object') {
            tags = p.tags;
        } else {
            Object.keys(p || {}).forEach(k => {
                if (!reserved.has(k)) tags[k] = p[k];
            });
        }
        const flat = Object.assign({ '@id': idStr }, tags);
        if (p.geometry) flat['@geometry'] = p.geometry; // preserve placeholder info like 'center' or 'bounds'
        return sortProperties(flat);
    }

    // Wraps FeatureCollection with metadata and applies property flattening/ordering
    function finalizeGeoJson(geojson) {
        const features = (geojson && geojson.features ? geojson.features : []).map(f => ({
            type: 'Feature',
            properties: flattenAndOrderFeatureProperties(f),
            geometry: f.geometry,
            id: f.id || (f.properties && f.properties.type != null && f.properties.id != null ? `${f.properties.type}/${f.properties.id}` : undefined)
        }));
        return createGeoJsonFeatureCollection(features);
    }

    // Creates a GeoJSON FeatureCollection with metadata
    function createGeoJsonFeatureCollection(features) {
        return {
            type: 'FeatureCollection',
            generator: 'overpass-turbo',
            copyright: 'The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.',
            timestamp: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
            features: features
        };
    }

    // Parses XML response from Overpass Turbo and converts to GeoJSON using osmtogeojson
    function parseXmlResponse(xmlText) {
        const parser = new DOMParser();
        const xmlDoc = parser.parseFromString(xmlText, "text/xml");
        try {
            return finalizeGeoJson(osmtogeojson(xmlDoc, osmtogeojsonOptions()));
        } catch (err) {
            console.error('osmtogeojson XML conversion failed:', err);
            return { type: 'FeatureCollection', features: [] };
        }
    }

    // Converts GeoJSON to custom JSON format    
    function convertGeoJsonToJSONFormat(geoJson) {
        const features = geoJson.features || [];
        const customCoordinates = [];

        features.forEach((feature, index) => {
            const coords = (function getFirstCoordinate(coords) {
                if (!Array.isArray(coords)) return null;
                if (coords.length >= 2 && typeof coords[0] === 'number' && typeof coords[1] === 'number') return [coords[0], coords[1]];
                for (const c of coords) { const r = getFirstCoordinate(c); if (r) return r; }
                return null;
            })(feature.geometry && feature.geometry.coordinates);
            if (coords) {
                customCoordinates.push({
                    lat: coords[1],
                    lng: coords[0],
                    extra: { id: index } // indexed ids for compatibility with other tools
                });
            }
        });

        return {
            name: 'overpass-json-export',
            customCoordinates: customCoordinates
        };
    }

    // Handles common conversion logic for buttons
    function handleConversion(button, originalText, successCallback, errorMessage) {
        try {
            button.textContent = 'Converting...';
            button.disabled = true;

            const convertedData = convertGeoJsonToJSONFormat(lastGeoJson);
            successCallback(convertedData, button, originalText);

        } catch (e) {
            button.textContent = errorMessage || 'Conversion failed!';
            button.disabled = false;
            console.error("Conversion failed", e);
            resetButtonAfterTimeout(button, originalText);
        }
    }

    // Creates a standardized button class name
    function createButtonClassName(type, extraClass) {
        const baseClass = 'button is-small is-link is-outlined';
        return `${type}-json-btn ${extraClass || type} ${baseClass}`;
    }

    // Sets button success state with timeout reset
    function setButtonSuccess(btn, originalText, successText) {
        btn.textContent = successText;
        resetButtonAfterTimeout(btn, originalText);
    }

    // Button configurations for different actions
    const buttonConfigs = {
        download: {
            className: createButtonClassName('download', 'export'),
            text: 'download',
            title: 'Convert to JSON format and download',
            action: (convertedData, btn, originalText) => {
                const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
                const filename = `overpass-export-${timestamp}.json`;
                downloadJson(convertedData, filename);
                setButtonSuccess(btn, originalText, 'Downloaded!');
            }
        },
        copy: {
            className: createButtonClassName('copy'),
            text: 'copy',
            title: 'Convert to JSON format and copy to clipboard',
            action: (convertedData, btn, originalText) => {
                const jsonString = JSON.stringify(convertedData, null, 2);
                navigator.clipboard.writeText(jsonString).then(() => {
                    setButtonSuccess(btn, originalText, 'Copied!');
                }).catch(err => {
                    console.error('Failed to copy to clipboard:', err);
                    btn.textContent = 'Copy failed!';
                    resetButtonAfterTimeout(btn, originalText);
                });
            }
        },
        send: {
            className: createButtonClassName('send-converter', 'copy'),
            text: 'send to converter',
            title: 'Send GeoJSON to the GeoJSON Converter Tool to select tags to keep',
            action: (_convertedData, btn, originalText) => {
                const appWindow = window.open('https://geojson-converter.vercel.app', '_blank');
                setTimeout(() => {
                    try {
                        sendCompressedGeoJson(appWindow, lastGeoJson);
                        setButtonSuccess(btn, originalText, 'Sent successfully!');
                    } catch (e) {
                        btn.textContent = 'Failed to send!';
                        console.error("Transfer failed", e);
                        resetButtonAfterTimeout(btn, originalText);
                    }
                }, 750);
            }
        }
    };

    // Creates a generic button with specified configuration
    function createButton(config) {
        const button = document.createElement('a');
        button.className = config.className;
        button.textContent = config.text;
        button.title = config.title;
        button.href = '';
        button.addEventListener('click', config.clickHandler);
        return button;
    }

    // Creates an action button using configuration
    function createActionButton(config) {
        return createButton({
            className: config.className,
            text: config.text,
            title: config.title,
            clickHandler: function (e) {
                e.preventDefault();
                if (!checkGeoJsonAvailable()) return;

                const button = this;
                if (config.text === 'send to converter') {
                    // Special handling for send button (no conversion needed)
                    config.action(null, button, config.text);
                } else {
                    handleConversion(button, config.text, config.action);
                }
            }
        });
    }

    // Updates the disabled state of all buttons based on data availability
    function updateButtonStates() {
        const buttonSelectors = ['.download-json-btn', '.copy-json-btn', '.send-converter-btn'];
        buttonSelectors.forEach(selector => {
            const button = document.querySelector(selector);
            if (button) {
                button.disabled = !lastGeoJson;
            }
        });
    }

    // Creates and injects the JSON export panel into the page
    function injectExportPanel() {
        const exportGeoJSON = document.getElementById('export-geoJSON');

        if (!exportGeoJSON) {
            return setTimeout(injectExportPanel, 300);
        }

        // Check if we already injected our JSON panel
        if (document.getElementById('export-JSON')) {
            return;
        }

        // Create buttons
        const buttons = [
            createActionButton(buttonConfigs.download),
            createActionButton(buttonConfigs.copy),
            createActionButton(buttonConfigs.send)
        ];
        buttons.forEach(btn => { btn.disabled = !lastGeoJson; });

        // Create UI structure
        const formatSpan = createElement('span', { className: 'format', textContent: 'JSON' });
        const fieldLabel = createElement('div', { className: 'field-label is-normal' }, [formatSpan]);
        const buttonsContainer = createElement('span', { className: 'buttons has-addons' }, buttons);
        const fieldBody = createElement('div', { className: 'field-body' }, [buttonsContainer]);
        const jsonPanel = createElement('p', {
            className: 'panel-block',
            id: 'export-JSON'
        }, [fieldLabel, fieldBody]);

        // Insert the JSON panel before the GeoJSON panel
        exportGeoJSON.parentNode.insertBefore(jsonPanel, exportGeoJSON);
    }

    // Monitors API requests and processes Overpass responses
    function monitorApiRequests() {
        const originalSend = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.send = function (body) {

            this.addEventListener('load', function () {
                if (this.responseURL.includes('interpreter')) {
                    try {
                        let responseData;
                        // Try to parse as JSON first
                        try {
                            responseData = JSON.parse(this.responseText);
                            // console.log('Raw Overpass JSON:', responseData);
                        }
                        // If JSON parse fails, try XML parse
                        catch (e) {
                            responseData = parseXmlResponse(this.responseText);
                            // console.log('Raw Overpass XML:', responseData);
                        }

                        // If data is already GeoJSON, use it; else convert Overpass JSON using osmtogeojson
                        if (responseData && responseData.type === 'FeatureCollection') {
                            lastGeoJson = finalizeGeoJson(responseData);
                        } else if (responseData && responseData.elements) {
                            try {
                                lastGeoJson = finalizeGeoJson(osmtogeojson(responseData, osmtogeojsonOptions()));
                            } catch (err) {
                                console.error('osmtogeojson JSON conversion failed:', err);
                                lastGeoJson = { type: 'FeatureCollection', features: [] };
                            }
                        }
                        // console.log('Generated GeoJSON:', lastGeoJson);
                    } catch (error) {
                        console.error('Error processing Overpass response:', error);
                    }
                }
            });
            return originalSend.apply(this, arguments);
        };
    }

    function init() {
        monitorApiRequests();
        injectExportPanel();

        const exportButton = document.querySelector('[data-ide-handler="click:onExportClick"]');
        if (exportButton) {
            exportButton.addEventListener('click', () => {
                setTimeout(injectExportPanel, 100);
            });
        }

        setInterval(updateButtonStates, 500);
    }

    // Start the script when DOM is ready
    if (document.readyState === 'complete') {
        init();
    } else {
        window.addEventListener('load', init);
    }
})();