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