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.

// ==UserScript==
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/pako.min.js
// @name         Overpass Turbo to JSON
// @name         Overpass Turbo Export To Converter
// @namespace    http://tampermonkey.net/
// @version      2.1
// @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.
// @author       Parma
// @icon         https://geojson-converter.vercel.app/favicon.ico
// @match        *://overpass-turbo.eu/*
// @match        *://maps.mail.ru/osm/tools/overpass/*
// @license      MIT
// ==/UserScript==

(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;
    }

    // Creates a standardized element ID
    function createElementId(elementType, id) {
        return elementType + '/' + id;
    }

    // Maps point coordinates from lon/lat objects to coordinate arrays
    function mapPointCoordinates(points) {
        return points.map(point => [point.lon, point.lat]);
    }

    // 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);
        // Modern approach for binary data to base64
        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');
    }

    // Creates a point geometry from coordinates
    function createPointGeometry(lon, lat) {
        return {
            type: 'Point',
            coordinates: [parseFloat(lon), parseFloat(lat)]
        };
    }

    // Creates a point geometry from a node object
    function createPointFromNode(node) {
        return createPointGeometry(node.lon, node.lat);
    }

    // Creates a point geometry from XML element attributes
    function createPointFromXmlElement(element, lonAttr, latAttr) {
        return createPointGeometry(
            element.getAttribute(lonAttr || 'lon'),
            element.getAttribute(latAttr || 'lat')
        );
    }

    // Extracts coordinates from GeoJSON geometry, handling nested arrays
    function extractCoordinates(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 coord of coords) {
            const result = extractCoordinates(coord);
            if (result) return result;
        }
        return null;
    }

    // Gets geometry for an element based on its type and available data
    function getGeometry(element, nodeMap) {
        // Handle center points first (for ways/relations with center)
        if (element.center && element.center.lon && element.center.lat) {
            return createPointFromNode(element.center);
        }

        // For relations - handle multipolygons
        if (element.type === 'relation' && element.members && element.tags && element.tags.type === 'multipolygon') {
            if (element.geometry) {
                const coordinates = [];
                element.geometry.forEach(part => {
                    const partCoords = mapPointCoordinates(part);
                    // Close the ring if needed
                    if (partCoords.length > 0 &&
                        (partCoords[0][0] !== partCoords[partCoords.length - 1][0] ||
                            partCoords[0][1] !== partCoords[partCoords.length - 1][1])) {
                        partCoords.push([partCoords[0][0], partCoords[0][1]]);
                    }
                    coordinates.push(partCoords);
                });
                return {
                    type: 'MultiPolygon',
                    coordinates: coordinates
                };
            }
        }

        // For nodes - simple point
        if (element.type === 'node' && element.lat && element.lon) {
            return createPointFromNode(element);
        }

        // For ways - try to get full geometry if available
        if (element.type === 'way' && element.nodes) {
            if (element.geometry) {
                const coordinates = mapPointCoordinates(element.geometry);

                // Close the polygon if it's an area
                if (coordinates.length > 1 &&
                    coordinates[0][0] === coordinates[coordinates.length - 1][0] &&
                    coordinates[0][1] === coordinates[coordinates.length - 1][1]) {
                    return {
                        type: 'Polygon',
                        coordinates: [coordinates]
                    };
                }

                return {
                    type: 'LineString',
                    coordinates: coordinates
                };
            }

            // Fallback to just the first node if no full geometry
            if (nodeMap[element.nodes[0]]) {
                return createPointFromNode(nodeMap[element.nodes[0]]);
            }
        }

        // For relations - try to get representative point
        if (element.type === 'relation' && element.members) {
            for (const member of element.members) {
                if (member.type === 'node' && nodeMap[member.ref]) {
                    return createPointFromNode(nodeMap[member.ref]);
                }
                if (member.type === 'way' && member.ref && nodeMap[member.ref]) {
                    return createPointFromNode(nodeMap[member.ref]);
                }
            }
        }

        return null;
    }

    // Extracts tags from an XML element
    function extractTags(element) {
        const tags = {};
        const tagElements = element.getElementsByTagName('tag');
        for (const tag of tagElements) {
            tags[tag.getAttribute('k')] = tag.getAttribute('v');
        }
        return tags;
    }

    // Sorts properties with @id first, @geometry last, others alphabetically
    function sortProperties(properties) {
        const sortedProps = { '@id': properties['@id'] };

        Object.keys(properties)
            .filter(k => k !== '@id' && k !== '@geometry')
            .sort()
            .forEach(k => sortedProps[k] = properties[k]);

        if (properties['@geometry']) {
            sortedProps['@geometry'] = properties['@geometry'];
        }

        return sortedProps;
    }

    // Creates a GeoJSON feature from XML element data
    function createFeatureFromXmlElement(element, elementType, tags, geometry, extraProps) {
        const id = createElementId(elementType, element.getAttribute('id'));
        const properties = Object.assign({ '@id': id }, extraProps || {}, tags);

        return {
            type: 'Feature',
            properties: sortProperties(properties),
            geometry: geometry,
            id: id
        };
    }

    // 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
        };
    }

    // Processes XML elements of a specific type using a processor function
    function processXmlElements(xmlDoc, elementType, processor) {
        const elements = xmlDoc.getElementsByTagName(elementType);
        const features = [];
        for (const element of elements) {
            const feature = processor(element);
            if (feature) features.push(feature);
        }
        return features;
    }

    // Categorizes elements into node, way, and relation maps
    function categorizeElements(elements, nodeMap, wayMap, relationMap) {
        elements.forEach(element => {
            if (element.type === 'node' && element.lat && element.lon) {
                nodeMap[element.id] = { lon: element.lon, lat: element.lat };
            } else if (element.type === 'way') {
                wayMap[element.id] = element;
            } else if (element.type === 'relation') {
                relationMap[element.id] = element;
            }
        });
    }

    // Processes and categorizes features by type
    function processAndCategorizeFeatures(elements, nodeMap) {
        const featuresByType = { node: [], way: [], relation: [] };

        elements.forEach(element => {
            // Skip elements without relevant tags (except for relations)
            if (element.type !== 'relation' && (!element.tags || Object.keys(element.tags).length === 0)) {
                return;
            }

            const geometry = getGeometry(element, nodeMap);
            if (!geometry) return;

            const id = createElementId(element.type, element.id);
            const properties = Object.assign({ '@id': id }, element.tags || {});

            const feature = {
                type: 'Feature',
                properties: sortProperties(properties),
                geometry: geometry,
                id: id
            };

            featuresByType[element.type].push(feature);
        });

        return featuresByType.node.concat(featuresByType.way, featuresByType.relation);
    }

    // Creates a processor function for XML elements with center points
    function createCenterPointProcessor(elementType) {
        return element => {
            const center = element.getElementsByTagName('center')[0];
            if (!center) return null;

            const tags = extractTags(element);
            const geometry = createPointFromXmlElement(center);
            return createFeatureFromXmlElement(element, elementType, tags, geometry, { '@geometry': 'center' });
        };
    }

    // Parses XML response from Overpass Turbo and converts to GeoJSON
    function parseXmlResponse(xmlText) {
        const parser = new DOMParser();
        const xmlDoc = parser.parseFromString(xmlText, "text/xml");
        const features = [];

        // Process nodes
        features.push(...processXmlElements(xmlDoc, 'node', node => {
            const tags = extractTags(node);
            const geometry = createPointFromXmlElement(node);
            return createFeatureFromXmlElement(node, 'node', tags, geometry);
        }));

        // Process ways and relations with center points
        features.push(...processXmlElements(xmlDoc, 'way', createCenterPointProcessor('way')));
        features.push(...processXmlElements(xmlDoc, 'relation', createCenterPointProcessor('relation')));

        return createGeoJsonFeatureCollection(features);
    }

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

        features.forEach((feature, index) => {
            const coords = extractCoordinates(feature.geometry && feature.geometry.coordinates);
            if (coords) {
                customCoordinates.push({
                    lat: coords[1],
                    lng: coords[0],
                    extra: { id: index }
                });
            }
        });

        return {
            name: "converted json",
            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 GeoJSON directly 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 GeoJSON 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 the GeoJSON data to the GeoJSON to JSON Converter Tool',
            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 data;
                        try {
                            data = JSON.parse(this.responseText);
                            //console.log('Raw Overpass JSON:', data);
                        } catch (e) {
                            //console.log("Response is not JSON, trying XML parse");
                            data = parseXmlResponse(this.responseText);
                            //console.log('Raw Overpass XML as GeoJSON:', data);
                        }

                        if (!data) return;

                        // If data is already GeoJSON (from XML), just assign
                        if (data.type === 'FeatureCollection') {
                            lastGeoJson = data;
                            //console.log('Generated GeoJSON:', lastGeoJson);
                            return;
                        }

                        const nodeMap = {};
                        const wayMap = {};
                        const relationMap = {};

                        categorizeElements(data.elements, nodeMap, wayMap, relationMap);
                        const features = processAndCategorizeFeatures(data.elements, nodeMap);
                        lastGeoJson = createGeoJsonFeatureCollection(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);
    }
})();