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