// ==UserScript==
// @name Better osm.org
// @name:ru Better osm.org
// @version 0.9.9.10
// @changelog v0.9.9: Button for 3D view building in OSMBuilding, F4map and other viewers
// @changelog v0.9.9: Key1 for open first user's changeset, add poweruser=true in Rapid link
// @changelog v0.9.9: Restore navigation links on changeset page of deleted user
// @changelog v0.9.9: KeyR select object for partial revert or open in JOSM/Level0 (KeyJ)
// @changelog v0.9.9: You can replace iD with JOSM (click on extension icon)
// @changelog v0.9.8: Hover for nodes/members in nodes way or relation members list, better RTL support
// @changelog v0.9.8: Show past usernames of user, click for copy ID from header, adoption to updates osm.org
// @changelog v0.9.6: Filter by editor for edits heatmap
// @changelog v0.9.5: Adoption to updates osm.org, render camera:direction=*
// @changelog v0.9.1: script should work more stably in Chrome
// @changelog v0.9.1: display prev value in history diff cell
// @changelog v0.9.1: Alt + click by <time> for open augmented diffs
// @changelog v0.9.1: adapting to changes on the page /history
// @changelog v0.8.9: Satellite layer in Chrome
// @changelog v0.8.9: Support Mapillary images in tags
// @changelog v0.8.9: KeyJ — open in JOSM current state of objects from changeset, alt + J — in Level0
// @changelog v0.8.9: Ctrl + click by <time> for open state of the map as of the selected date
// @changelog v0.8.9: Shift + / for simple search and editor via Overpass
// @changelog v0.8: https://osm.org/user/TrickyFoxy/diary/406061
// @changelog v0.8: Images from Panoramax, StreetComplete, Wikipedia Commons in changeset and notes
// @changelog v0.8: GPX-tracks render (also in StreetComplete notes)
// @changelog v0.8: Show first comment in changesets history, user badge for your friends
// @changelog v0.8: T — toggle between compact and full tags diff mode, U — open user profile from changeset, note, ...
// @changelog v0.8: Hotkeys on user profile Page (H — user changesets, T — tracks, D — Diary, C — comments, N — notes)
// @changelog v0.8: Drag&Drop for geotagged photos, GeoJSON and GPX files
// @changelog New: Comments templates, support ways render in relation members list
// @changelog New: Q for close sidebar, shift + Z for real bbox of changeset
// @changelog New: displaying the full history of ways (You can disable it in settings)
// @changelog https://c.osm.org/t/better-osm-org-a-script-that-adds-useful-little-things-to-osm-org/121670/112
// @description Several improvements for advanced users of openstreetmap.org
// @description:ru Скрипт, добавляющий на openstreetmap.org полезные картографам функции
// @author deevroman
// @match https://www.openstreetmap.org/*
// @exclude https://www.openstreetmap.org/api*
// @exclude https://www.openstreetmap.org/diary/new
// @exclude https://www.openstreetmap.org/message/new/*
// @exclude https://www.openstreetmap.org/reports/new/*
// @exclude https://www.openstreetmap.org/profile/*
// @exclude https://www.openstreetmap.org/messages/*
// @exclude https://www.openstreetmap.org/diary/*
// @exclude https://www.openstreetmap.org/account*
// @exclude https://www.openstreetmap.org/oauth2/*
// @match https://master.apis.dev.openstreetmap.org/*
// @exclude https://master.apis.dev.openstreetmap.org/api/*
// @exclude https://master.apis.dev.openstreetmap.org/account*
// @exclude https://master.apis.dev.openstreetmap.org/messages/*
// @exclude https://master.apis.dev.openstreetmap.org/diary/*
// @exclude https://master.apis.dev.openstreetmap.org/oauth2/*
// @match https://taginfo.openstreetmap.org/*
// @match https://taginfo.geofabrik.de/*
// @match https://www.hdyc.neis-one.org/*
// @match https://hdyc.neis-one.org/*
// @match https://osmcha.org/*
// @exclude https://taginfo.openstreetmap.org/embed/*
// @license WTFPL
// @namespace https://github.com/deevroman/better-osm-org
// @supportURL https://github.com/deevroman/better-osm-org/issues
// @icon https://www.openstreetmap.org/favicon.ico
// @require https://github.com/deevroman/GM_config/raw/fixed-for-chromium/gm_config.js#sha256=ea04cb4254619543f8bca102756beee3e45e861077a75a5e74d72a5c131c580b
// @require https://raw.githubusercontent.com/deevroman/osm-auth/ad63c40d376593d63ee2d35f60664e28769bf1ba/dist/osm-auth.iife.js#sha256=6f0401639929ca5de4c98e69c07665a82c93a2aa9e3f138ffa8429cecd0f900d
// @require https://raw.githubusercontent.com/deevroman/exif-js/53b0c7c1951a23d255e37ed0a883462218a71b6f/exif.js#sha256=2235967d47deadccd9976244743e3a9be5ca5e41803cda65a40b8686ec713b74
// @require https://raw.githubusercontent.com/deevroman/osmtogeojson/c97381a0c86c0a021641dd47d7bea01fb5514716/osmtogeojson.js#sha256=663bb5bbae47d5d12bff9cf1c87b8f973e85fab4b1f83453810aae99add54592
// @require https://gist.githubusercontent.com/deevroman/d8b7a2176446e321fa6b0b47d0615d6e/raw/57c11b07432890b9a066ef6b6aba970dda9cb5ad/snow-animation.js#sha256=3b6cd76818c5575ea49aceb7bf4dc528eb8a7cb228b701329a41bb50f0800a5d
// @require https://openingh.openstreetmap.de/opening_hours.js/opening_hours+deps.min.js#sha256=e9a3213aba77dcf79ff1da9f828532acf1ebf7107ed1ce5f9370b922e023baff
// @incompatible safari https://github.com/deevroman/better-osm-org/issues/13
// @grant GM.getValue
// @grant GM.setValue
// @grant GM_registerMenuCommand
// @grant GM_listValues
// @grant GM_deleteValue
// @grant GM_getResourceURL
// @grant GM_getResourceText
// @grant GM_addElement
// @grant GM.xmlHttpRequest
// @grant GM.fetch
// @grant GM_info
// @comment for get diffs for finding deleted users
// @connect planet.openstreetmap.org
// @connect planet.maps.mail.ru
// @comment overpass instances
// @connect maps.mail.ru
// @connect overpass.private.coffee
// @connect turbo.overpass.private.coffee
// @connect overpass-api.de
// @connect www.hdyc.neis-one.org
// @connect hdyc.neis-one.org
// @connect resultmaps.neis-one.org
// @connect www.openstreetmap.org
// @connect osmcha.org
// @connect raw.githubusercontent.com
// @connect en.wikipedia.org
// @connect graph.mapillary.com
// @connect api.panoramax.xyz
// @comment for downloading gps-tracks — osm stores tracks in AWS
// @connect amazonaws.com
// @comment for satellite images
// @connect server.arcgisonline.com
// @connect clarity.maptiles.arcgis.com
// @connect wayback.maptiles.arcgis.com
// @comment geocoder
// @connect photon.komoot.io
// @connect whosthat.osmz.ru
// @connect content-a.strava.com
// @sandbox JavaScript
// @resource OAUTH_HTML https://github.com/deevroman/better-osm-org/raw/master/finish-oauth.html
// @resource OSMCHA_ICON https://github.com/deevroman/better-osm-org/raw/master/icons/osmcha.ico
// @resource NODE_ICON https://github.com/deevroman/better-osm-org/raw/master/icons/Osm_element_node.svg
// @resource WAY_ICON https://github.com/deevroman/better-osm-org/raw/master/icons/Osm_element_way.svg
// @resource RELATION_ICON https://github.com/deevroman/better-osm-org/raw/master/icons/Taginfo_element_relation.svg
// @resource OSMCHA_LIKE https://github.com/OSMCha/osmcha-frontend/raw/94f091d01ce5ea2f42eb41e70cdb9f3b2d67db88/src/assets/thumbs-up.svg
// @resource OSMCHA_DISLIKE https://github.com/OSMCha/osmcha-frontend/raw/94f091d01ce5ea2f42eb41e70cdb9f3b2d67db88/src/assets/thumbs-down.svg
// @resource DARK_THEME_FOR_ID_CSS https://gist.githubusercontent.com/deevroman/55f35da68ab1efb57b7ba4636bdf013d/raw/7b94e3b7db91d023f1570ae415acd7ac989fffe0/dark.css
// @run-at document-end
// ==/UserScript==
//<editor-fold desc="init" defaultstate="collapsed">
/*global osmAuth*/
/*global GM*/
/*global GM_info*/
/*global GM_config*/
/*global GM_addElement*/
/*global GM_listValues*/
/*global GM_deleteValue*/
/*global GM_getResourceURL*/
/*global GM_getResourceText*/
/*global GM_registerMenuCommand*/
/*global unsafeWindow*/
/*global exportFunction*/
/*global cloneInto*/
/*global EXIF*/
/*global osmtogeojson*/
/*global opening_hours*/
/*global runSnowAnimation*/
if (GM_info.scriptHandler === "Userscripts" || GM_info.scriptHandler === "Greasemonkey" || GM_info.scriptHandler === "Firemonkey") {
console.error("YOU ARE USING AN UNSUPPORTED SCRIPT MANAGER")
}
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const isFirefox = navigator.userAgent.includes("Firefox");
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
if (isSafari) {
console.error("YOU ARE USING AN UNSUPPORTED BROWSER")
}
if (GM_info.scriptHandler === "Userscripts" || GM_info.scriptHandler === "Greasemonkey") {
if (typeof GM_getResourceURL === "undefined") {
const resources = {}
setTimeout(async () => {
const resourcesName = {
"OAUTH_HTML": "https://github.com/deevroman/better-osm-org/raw/master/finish-oauth.html",
"OSMCHA_ICON": "https://github.com/deevroman/better-osm-org/raw/master/icons/osmcha.ico",
"NODE_ICON": "https://github.com/deevroman/better-osm-org/raw/master/icons/Osm_element_node.svg",
"WAY_ICON": "https://github.com/deevroman/better-osm-org/raw/master/icons/Osm_element_way.svg",
"RELATION_ICON": "https://github.com/deevroman/better-osm-org/raw/master/icons/Taginfo_element_relation.svg",
"OSMCHA_LIKE": "https://github.com/OSMCha/osmcha-frontend/raw/94f091d01ce5ea2f42eb41e70cdb9f3b2d67db88/src/assets/thumbs-up.svg",
"OSMCHA_DISLIKE": "https://github.com/OSMCha/osmcha-frontend/raw/94f091d01ce5ea2f42eb41e70cdb9f3b2d67db88/src/assets/thumbs-down.svg",
"DARK_THEME_FOR_ID_CSS": "https://gist.githubusercontent.com/deevroman/55f35da68ab1efb57b7ba4636bdf013d/raw/7b94e3b7db91d023f1570ae415acd7ac989fffe0/dark.css"
}
for (let resource in resourcesName) {
GM.xmlHttpRequest({
method: "GET",
url: resourcesName[resource],
responseType: "blob",
onload: res => {
const a = new FileReader();
a.onload = function (e) {
resources[resource] = e.target.result
}
a.readAsDataURL(res.response);
}
})
}
})
window.GM_getResourceURL = (name) => {
console.log(resources);
return resources[name]
}
}
if (typeof GM_addElement === "undefined") {
window.GM_addElement = function () {
let parent, type, attrs;
if (arguments.length === 3) {
[parent, type, attrs] = arguments
} else if (arguments.length === 2) {
[type, attrs] = arguments
} else {
return
}
const elem = document.createElement(type)
Object.entries(attrs).forEach(([key, value]) => {
elem.setAttribute(key, value)
})
if (parent) {
parent.appendChild(elem)
}
return elem
}
}
}
const accountForceLightTheme = document.querySelector("html")?.getAttribute("data-bs-theme") === "light";
const accountForceDarkTheme = document.querySelector("html")?.getAttribute("data-bs-theme") === "dark";
const mediaQueryForWebsiteTheme = `${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""}`;
function isDarkMode() {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && !accountForceLightTheme || accountForceDarkTheme;
}
const isRTLLayout = document.querySelector("html").dir === "rtl";
const arrowSymbolForChanges = !isRTLLayout ? " → " : " ← ";
const SCRIPT_UPDATE_URL = "https://raw.githubusercontent.com/deevroman/better-osm-org/master/better-osm-org.user.js"
const DEV_SCRIPT_UPDATE_URL = "https://raw.githubusercontent.com/deevroman/better-osm-org/dev/better-osm-org.user.js"
const prod_server = {
apiBase: "https://www.openstreetmap.org/api/0.6/",
apiUrl: "https://www.openstreetmap.org/api/0.6",
url: "https://www.openstreetmap.org",
origin: "https://www.openstreetmap.org"
}
const ohm_prod_server = {
apiBase: "https://www.openhistoricalmap.org/api/0.6/",
apiUrl: "https://www.openhistoricalmap.org/api/0.6",
url: "https://www.openhistoricalmap.org",
origin: "https://www.openhistoricalmap.org"
}
const dev_server = {
apiBase: "https://master.apis.dev.openstreetmap.org/api/0.6/",
apiUrl: "https://master.apis.dev.openstreetmap.org/api/0.6",
url: "https://master.apis.dev.openstreetmap.org",
origin: "https://master.apis.dev.openstreetmap.org",
}
const local_server = {
apiBase: "http://localhost:3000/api/0.6/",
apiUrl: "http://localhost:3000/api/0.6",
url: "http://localhost:3000",
origin: "http://localhost:3000",
}
let osm_server = dev_server;
const planetOrigin = "https://planet.maps.mail.ru"
const MAIN_OVERPASS_INSTANCE = {
name: "overpass-api.de",
apiUrl: "https://overpass-api.de/api",
url: "https://overpass-turbo.eu/",
}
const MAILRU_OVERPASS_INSTANCE = {
name: "maps.mail.ru/osm/tools/overpass",
apiUrl: "https://maps.mail.ru/osm/tools/overpass/api",
url: "https://maps.mail.ru/osm/tools/overpass/",
}
const PRIVATECOFFEE_OVERPASS_INSTANCE = {
name: "overpass.private.coffee",
apiUrl: "https://overpass.private.coffee/api",
url: "https://turbo.overpass.private.coffee/",
}
let overpass_server = MAIN_OVERPASS_INSTANCE
const instancesOf3DViewers = [
{
name: "OSM Building Viewer",
url: "https://deevroman.github.io/OSMBuilding/",
makeURL: function ({type: type, id: id}) {
return `${this.url}?id=${id}&type=${type}`// TODO osmAPIurl
}
},
{
name: "F4Map",
url: "https://demo.f4map.com/",
makeURL: function ({x: x, y: y, z: z}) {
return `${this.url}#lat=${x}&lon=${y}&zoom=${z}`;
}
},
{
name: "streets.gl",
url: "https://streets.gl/",
makeURL: function ({x: x, y: y, z: z}) {
return `${this.url}#${x},${y},45.00,0.00,2000.00`
}
},
{
name: "OSM go",
url: "https://www.osmgo.org/go.html",
makeURL: function ({x: x, y: y, z: z}) {
return `${this.url}?lat=${x}&lon=${y}&view=-50&ele=150`
}
},
{
name: "osmbuildings.org",
url: "https://osmbuildings.org/",
makeURL: function ({x: x, y: y, z: z}) {
return `${this.url}?lat=${x}&lon=${y}&zoom=${z}`
}
},
{
name: "labs.mapbox.com",
url: "https://labs.mapbox.com/standard-style?lightPreset=day#",
makeURL: function ({x: x, y: y, z: z}) {
// z-1 looks better
return `${this.url}${z-1}/${x}/${y}/0/50`
}
},
// {
// name: "OSM Building Viewer (fork)",
// url: "https://deevroman.github.io/OSMBuilding/",
// makeURL: function ({type: type, id: id}) {
// return `${this.url}/?id=${id}&type=${type}`
// }
// }
]
const boWindowObject = typeof window.wrappedJSObject !== "undefined" ? /** {unsafeWindow} **/ window.wrappedJSObject : unsafeWindow;
const boGlobalThis = typeof boWindowObject.globalThis !== "undefined" ? boWindowObject.globalThis : boWindowObject;
/** @type {null|(function(): null|import('leaflet').Map)}*/
let getMap = null
/**
* @typedef {{
* OSM: {
* router: { stateChange: function(args: Object): Object },
* i18n: {t: Function},
* formatHash: function(hash: string): Object,
* parseHash: function(args: Object): string,
* },
* L: {},
* }} windowOSM
*/
/** @type {null|(function(): null|(boWindowObject & windowOSM)|(window & windowOSM))}*/
let getWindow = null
if ([prod_server.origin, dev_server.origin, local_server.origin].includes(location.origin)
&& !["/edit", "/id"].includes(location.pathname)) {
function mapHook() {
console.log("start map intercepting")
if (boWindowObject.L && boWindowObject.L.Map) {
boWindowObject.L.Map.addInitHook(exportFunction((function () {
if (this._container?.id === "map") {
boGlobalThis.map = this;
boGlobalThis.mapIntercepted = true
console.log("%cMap intercepted", 'background: #000; color: #0f0')
}
}), boWindowObject)
)
} else {
console.error("the script could not access the L.Map object. Some of the functions will not work")
console.log(GM_info)
console.log(window?.wrappedJSObject)
console.log(unsafeWindow)
}
}
if (isSafari && GM_info.scriptHandler === "Userscripts") {
getMap = () => null
getWindow = () => window
} else {
boWindowObject.mapHook = exportFunction(mapHook, boWindowObject)
boWindowObject.mapHook()
if (boWindowObject.map instanceof HTMLElement) {
console.error("Please, reload page, if something doesn't work")
}
getMap = () => boWindowObject.map
getWindow = () => boWindowObject
}
// try {
// interceptRectangle()
// } catch (e) {
// }
setTimeout(() => {
console.debug("Settings:", Object.entries(GM_config.fields).map(i => {
if (typeof i[1].value === "boolean") {
return [i[0], i[1].value]
} else {
return [i[0], `length: ${i[1].value.length}`]
}
}))
}, 1500)
} else if ([prod_server.origin, dev_server.origin, local_server.origin].includes(location.origin)
&& ["/edit", "/id"].includes(location.pathname)) {
if (isDarkMode()) {
if (location.pathname === "/edit") {
// document.querySelector("#id-embed").style.visibility = "hidden"
// window.addEventListener("message", (event) => {
// console.log("making iD visible")
// if (event.origin !== location.origin)
// return;
// if (event.data === "kek") {
// document.querySelector("#id-embed").style.visibility = "visible"
// }
// });
injectCSSIntoOSMPage(
`@media ${mediaQueryForWebsiteTheme} {
#id-embed {
background: #212529 !important;
}
}`
)
} else {
injectCSSIntoOSMPage(
`@media ${mediaQueryForWebsiteTheme} {
html {
background: #212529 !important;
}
body {
background: #212529 !important;
}
#id-embed {
background: #212529 !important;
}
#id-container {
background: #212529 !important;
}
}`
)
// if (location.pathname === "/id") {
// console.log("post")
// window.parent.postMessage("kek", location.origin);
// }
}
}
GM_registerMenuCommand("JOSM!", function () {
const iframe = GM_addElement("iframe", {
"src": "https://deevroman.github.io/web-josm",
"width": "100%",
"height": "100%",
"id": "josmembed",
})
document.querySelector("#id-embed").replaceWith(iframe)
});
}
function makeRow(label, text, without_delete = false) {
const tr = document.createElement("tr")
const th = document.createElement("th")
const td = document.createElement("td")
const td2 = document.createElement("td")
th.setAttribute("contenteditable", "true")
td.setAttribute("contenteditable", "true")
th.textContent = label
td.textContent = text
td.style.paddingLeft = "4px"
td.style.paddingRight = "4px"
td.style.wordWrap = "anywhere"
td.setAttribute("placeholder", "comment that will be added when clicked")
td2.textContent = "×"
td2.title = "remove"
td2.style.width = "21px"
td2.style.cursor = "pointer"
td2.style.textAlign = "center"
td2.onclick = () => {
if (label === "" && text === "" || confirm(`Remove "${label}"?`)) {
tr.remove()
}
}
th.style.width = "30px"
th.appendChild(document.createElement("br"))
tr.appendChild(th)
tr.appendChild(td)
if (!without_delete) {
tr.appendChild(td2)
}
return tr
}
GM_config.init(
{
'id': 'Config',
'title': ' ',
'fields':
{
'DarkModeForMap': {
'label': 'Invert map colors in dark mode',
'type': 'checkbox',
'default': false,
'labelPos': 'right'
},
'DarkModeForID': {
'label': 'Dark mode for iD (<a href="https://userstyles.world/style/15596/openstreetmap-dark-theme" target="_blank">Thanks AlexPS</a>)',
'type': 'checkbox',
'default': false,
'labelPos': 'right'
},
'CompactChangesetsHistory':
{
'section': ["Viewing edits"],
'label': 'Compact changesets history',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right',
},
'VersionsDiff':
{
'label': 'Add tags diff in history',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right',
},
'ShowPreviousTagValue':
{
'label': 'Show previous tag value',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right',
},
'FullVersionsDiff':
{
'label': 'Add diff with intermediate versions in way history',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right',
},
'ChangesetQuickLook':
{
'label': 'Add QuickLook for small changesets ',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'ShowChangesetGeometry':
{
'label': 'Show geometry of objects in changeset',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'MassChangesetsActions':
{
'label': 'Add actions for changesets list (mass revert, filtering, ...)',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'ImagesAndLinksInTags':
{
'label': 'Make some tags clickable, shorter and display photos',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right',
},
'HideNoteHighlight':
{
'section': ["Working with notes"],
'label': 'Hide note highlight',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'SatelliteLayers':
{
'label': 'Add satellite layers for notes page',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'ResolveNotesButton':
{
'label': 'Addition resolve buttons:',
'type': 'menu',
'default': '[{"label": "👌", "text": ""}]'
},
'RevertButton':
{
'section': ["New actions"],
'label': 'Revert&Osmcha changeset button',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'Deletor':
{
'label': 'Button for node deletion',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'OneClickDeletor':
{
'label': 'Delete node without confirmation',
'type': 'checkbox',
'default': false,
'labelPos': 'right'
},
'ChangesetsTemplates':
{
'label': 'Changesets comments templates <a id="last-comments-link" target="_blank">(your last comments)</a>',
'type': 'menu',
'default': '[{"label": "👋", "text": ""}]'
},
'HDYCInProfile':
{
'section': ["Other"],
'label': 'Add HDYC to user profile',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'BetterProfileStat':
{
'label': 'Add filters to profile statistics',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'NavigationViaHotkeys':
{
'label': 'Add hotkeys <a href="https://github.com/deevroman/better-osm-org#Hotkeys" target="_blank">(List)</a>', // add help button with list
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'NewEditorsLinks':
{
'label': 'Add new editors (Rapid, ... ?)',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'ResetSearchFormFocus': {
'label': 'Reset search form focus',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'Swipes': {
'label': 'Add swipes between user changesets',
'type': 'checkbox',
'default': false,
'labelPos': 'right'
},
'ResizableSidebar': {
'label': 'Slider for sidebar width',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'ClickableAvatar': {
'label': 'Click by avatar for open changesets',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'OverzoomForDataLayer': {
'label': 'Allow overzoom when data/satellite layer enabled β',
'type': 'checkbox',
'default': false,
'labelPos': 'right'
},
'DragAndDropViewers': {
'label': 'Drag&Drop for .geojson, .jpg, .gpx β',
'type': 'checkbox',
'default': 'checked',
'labelPos': 'right'
},
'3DViewerInNewTab': {
'label': 'Open buildings 3D viewers always in new tab',
'type': 'checkbox',
'default': isMobile ? 'checked' : false,
'labelPos': 'right'
},
'OverpassInstance': {
'label': '<a href="https://wiki.openstreetmap.org/wiki/Overpass_API#Public_Overpass_API_instances">Overpass API server</a>',
'labelPos': 'left',
'type': 'select',
'options': [
MAIN_OVERPASS_INSTANCE.name,
MAILRU_OVERPASS_INSTANCE.name,
PRIVATECOFFEE_OVERPASS_INSTANCE.name
],
},
},
'types': {
'menu': {
'default': '',
toNode: function () {
const templates = this.value || this.settings.default;
const settingNode = this.create('div', {
className: 'config_var',
id: this.configId + '_' + this.id + '_var',
});
this.templates = templates;
settingNode.appendChild(this.create('input', {
innerHTML: this.settings.label,
id: this.configId + '_' + this.id + '_field_filler',
className: 'filler',
type: "checkbox",
}));
const label = this.create('label', {
innerHTML: this.settings.label,
id: this.configId + '_' + this.id + '_field_label',
for: this.configId + '_field_' + this.id,
className: 'field_label'
})
if (label.querySelector("#last-comments-link")) {
const userId = document.querySelector("head")?.getAttribute("data-user")
if (userId) {
label.querySelector("#last-comments-link").setAttribute("href", `https://resultmaps.neis-one.org/osm-discussion-comments?uid=${userId}&commented`)
} else {
label.querySelector("#last-comments-link").remove()
}
}
settingNode.appendChild(label);
const table = document.createElement("table")
table.setAttribute("cellspacing", "0")
table.setAttribute("cellpadding", "0")
table.style.width = "100%"
settingNode.appendChild(table)
const tbody = document.createElement("tbody")
table.appendChild(tbody)
JSON.parse(templates).forEach(row => {
tbody.appendChild(makeRow(row['label'], row['text']))
})
const tr = document.createElement("tr")
tr.classList.add("add-tag-row")
tbody.appendChild(tr)
const th = document.createElement("th")
th.textContent = "+"
th.colSpan = 3
th.style.textAlign = "center"
th.style.cursor = "pointer"
tr.appendChild(th)
th.onclick = () => {
tbody.lastElementChild.before(makeRow("", ""))
}
return settingNode;
},
toValue: function () {
let templates = [];
if (this.wrapper) {
for (let row of Array.from(this.wrapper.getElementsByTagName('tr')).slice(0, -1)) {
const forPush = {
label: row.querySelector("th").textContent,
text: row.querySelector("td").textContent
}
if (!(forPush.label.trim() === "" && forPush.text.trim() === "")) {
templates.push(forPush)
}
}
}
return JSON.stringify(templates);
},
reset: function () {
if (this.wrapper) {
for (let row of Array.from(this.wrapper.getElementsByTagName('tr')).slice(0, -1)) {
row.remove()
}
JSON.parse(this.settings.default).forEach(i => {
this.wrapper.querySelector(`#${this.configId}_${this.id}_var table`).lastElementChild.before(makeRow(i['label'], i['text']));
})
}
}
}
},
frameStyle: `
border: 1px solid #000;
height: min(85%, 760px);
width: max(25%, 380px);
z-index: 9999;
opacity: 0;
position: absolute;
margin-left: auto;
margin-right: auto;
`,
css: `
#Config_saveBtn {
cursor: pointer;
}
#Config_closeBtn {
cursor: pointer;
}
#Config_field_ResolveNotesButton {
width: 100%;
max-width: 100%;
}
#Config table {
border-collapse: collapse;
}
th, td {
border: 1px solid black;
min-height: 21px;
}
#Config [placeholder]:empty::before {
content: attr(placeholder);
color: #555;
}
#Config [placeholder]:empty:focus::before {
content: "";
}
#Config .filler {
visibility: hidden;
}
@media ${mediaQueryForWebsiteTheme} {
#Config {
background: #232528;
color: white;
}
#Config a {
color: darkgray;
}
#Config_field_OverpassInstance {
filter: invert(0.9);
}
#Config_saveBtn {
filter: invert(0.9);
}
#Config_closeBtn {
filter: invert(0.9);
}
#Config_resetLink {
color: gray !important;
}
#Config textarea {
background: #232528;
color: white;
background: rgb(31, 32, 35);
border: 1px solid rgb(60, 63, 68);
border-radius: 4px;
font-size: 13px;
color: rgb(247, 248, 248);
appearance: none;
}
#Config input:focus-visible {
outline-style: none;
}
th, td {
border-color: white;
}
}
`,
'events':
{
'init': main,
'save': function () {
GM_config.close()
}
}
});
//</editor-fold>
function tagsToXml(doc, node, tags) {
for (const [k, v] of Object.entries(tags)) {
let tag = doc.createElement('tag');
tag.setAttribute('k', k);
tag.setAttribute('v', v);
node.appendChild(tag);
}
}
function makeAuth() {
return osmAuth.osmAuth({
apiUrl: osm_server.apiUrl,
url: osm_server.url,
client_id: "FwA",
client_secret: "ZUq",
redirect_uri: GM_getResourceURL("OAUTH_HTML"),
scope: "write_api",
auto: true
});
}
function makeHashtagsClickable() {
if (!GM_config.get("ImagesAndLinksInTags")) return;
const comment = document.querySelector(".browse-section p")
comment?.childNodes?.forEach(node => {
if (node.nodeType !== Node.TEXT_NODE) return
const span = document.createElement("span")
span.textContent = node.textContent
span.innerHTML = span.innerHTML.replaceAll(/\B(#[\p{L}\d_-]+)\b/gu, function (match) {
const osmchaFilter = {"comment": [{"label": match, "value": match}]}
const osmchaLink = "https://osmcha.org?" + new URLSearchParams({filters: JSON.stringify(osmchaFilter)}).toString()
const a = document.createElement("a")
a.href = osmchaLink
a.target = "_blank"
a.title = "Search this hashtags in OSMCha"
a.textContent = match
return a.outerHTML
})
node.replaceWith(span)
})
}
function shortOsmOrgLinksInText(text) {
return text.replaceAll("https://www.openstreetmap.org", "osm.org")
.replaceAll("https://wiki.openstreetmap.org/wiki/", "osm.wiki/")
.replaceAll("https://wiki.openstreetmap.org", "osm.wiki")
.replaceAll("https://community.openstreetmap.org", "c.osm.org")
.replaceAll("https://openstreetmap.org", "osm.org")
}
function shortOsmOrgLinks(elem) {
if (!GM_config.get("ImagesAndLinksInTags")) return;
elem?.querySelectorAll('a[href^="https://www.openstreetmap.org"], a[href^="https://wiki.openstreetmap.org"], a[href^="https://community.openstreetmap.org"], a[href^="https://openstreetmap.org"]')?.forEach(i => {
i.textContent = shortOsmOrgLinksInText(i.textContent)
})
}
// todo remove this
const mainTags = ["shop", "building", "amenity", "man_made", "highway", "natural", "aeroway", "historic", "railway", "tourism", "landuse", "leisure"]
async function getPrevNextChangesetsIDs(changeset_id) {
const changesetMetadata = await loadChangesetMetadata(changeset_id)
if (!changesetMetadata.uid) return
const prevChangesetsPromise = fetchJSONWithCache(osm_server.apiBase + "changesets.json?" + new URLSearchParams({
user: changesetMetadata.uid,
order: 'newest',
from: "2005-01-01T00:00:00Z",
to: new Date(new Date(changesetMetadata.created_at).getTime() + 1000).toISOString(), // на случай если в одну секунду создано несколько пакетов правок
}).toString())
/*** @type {{changesets: ChangesetMetadata[]}}*/
const nextChangesets = await fetchJSONWithCache(osm_server.apiBase + "changesets.json?" + new URLSearchParams({
user: changesetMetadata.uid,
order: 'oldest',
from: changesetMetadata.created_at,
to: new Date().toISOString(),
}).toString())
/*** @type {{changesets: ChangesetMetadata[]}}*/
const prevChangesets = await prevChangesetsPromise;
return [prevChangesets.changesets.find(i => i.id < changeset_id)?.id, nextChangesets.changesets.find(i => i.id > changeset_id)?.id];
}
async function restorePrevNextChangesetButtons(changeset_id) {
if (document.querySelector(".restored-secondary-actions")) return;
console.log("try restore prev/next deleted user's changesets")
const [prevID, nextID] = await getPrevNextChangesetsIDs(changeset_id);
if (!prevID && !nextID) return
const secondaryActions = document.querySelector("#sidebar_content .secondary-actions")
const secondarySecondaryActions = document.createElement("div")
secondarySecondaryActions.classList.add("secondary-actions", "restored-secondary-actions")
if (prevID) {
const prevLink = document.createElement("a")
prevLink.classList.add("icon-link")
prevLink.href = "/changeset/" + prevID
prevLink.innerHTML = '<svg width="8" height="11" viewBox="-8 0 8 11"><path d="M-2,2 l-3.5,3.5 l3.5,3.5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>'
prevLink.appendChild(document.createTextNode(prevID))
secondarySecondaryActions.appendChild(prevLink)
}
secondarySecondaryActions.appendChild(document.createTextNode(` · ${(await loadChangesetMetadata(changeset_id)).user} · `))
if (nextID) {
const nextLink = document.createElement("a")
nextLink.classList.add("icon-link")
nextLink.href = "/changeset/" + nextID
nextLink.innerHTML = '<svg width="8" height="11"><path d="M2,2 l3.5,3.5 l-3.5,3.5" fill="none" stroke="currentColor" stroke-width="1.5"></path></svg>'
nextLink.prepend(document.createTextNode(nextID))
secondarySecondaryActions.appendChild(nextLink)
}
secondaryActions.after(secondarySecondaryActions)
}
let changesetObjectsSelectionModeEnabled = false;
function addRevertButton() {
if (!location.pathname.startsWith("/changeset")) return
if (document.querySelector('#revert_button_class')) return true;
const sidebar = document.querySelector("#sidebar_content h2");
if (sidebar) {
hideSearchForm();
// sidebar.classList.add("changeset-header")
const changeset_id = sidebar.innerHTML.match(/([0-9]+)/)[0];
sidebar.innerHTML += ` <a href="https://revert.monicz.dev/?changesets=${changeset_id}" target=_blank rel="noreferrer" id=revert_button_class title="Open osm-revert\nShift + click for revert via JOSM\nPress R for partial revert">↩️</a>
<a href="https://osmcha.org/changesets/${changeset_id}" target="_blank" rel="noreferrer"><img src="${GM_getResourceURL("OSMCHA_ICON", false)}" id="osmcha_link"></a>`;
changesetObjectsSelectionModeEnabled = false
document.querySelector("#revert_button_class").onclick = (e) => {
if (changesetObjectsSelectionModeEnabled) {
e.preventDefault()
if (osm_server !== prod_server) {
e.preventDefault()
alert("osm-revert works only for www.openstreetmap.org")
return
}
let selector = ""
const nodes = Array.from(document.querySelectorAll("#changeset_nodes input[type=checkbox]:checked"));
if (nodes.length) {
selector += "node(id:" + nodes.map(n => {
return n.parentElement.nextElementSibling.id.match(/[0-9]+n([0-9]+)/)[1]
}).join(",") + ");\n"
}
const ways = Array.from(document.querySelectorAll("#changeset_ways input[type=checkbox]:checked"));
if (ways.length) {
selector += "way(id:" + ways.map(w => {
return w.parentElement.nextElementSibling.id.match(/[0-9]+w([0-9]+)/)[1]
}).join(",") + ");\n"
}
const relations = Array.from(document.querySelectorAll("#changeset_relations input[type=checkbox]:checked"));
if (relations.length) {
selector += "rel(id:" + relations.map(r => {
return r.parentElement.nextElementSibling.id.match(/[0-9]+r([0-9]+)/)[1]
}).join(",") + ");"
}
window.open("https://revert.monicz.dev/?" + new URLSearchParams({
changesets: changeset_id,
"query-filter": selector
}).toString(), "_blank")
return
}
if (!e.shiftKey) {
if (osm_server !== prod_server) {
e.preventDefault()
alert("osm-revert works only for www.openstreetmap.org\n\nBut you can install reverter plugin in JOSM and use shift+click for other OSM servers.\n\n⚠️Change the osm server in the josm settings!")
}
return
}
if (osm_server !== prod_server) {
if (!confirm("⚠️This is not the main OSM server!\n\n⚠️To change the OSM server in the JOSM settings!")) {
e.preventDefault()
return
}
}
e.preventDefault()
window.location = "http://127.0.0.1:8111/revert_changeset?id=" + changeset_id // todo open in new tab. It's broken in Fifefox and open new window
}
document.querySelector("#revert_button_class").style.textDecoration = "none"
const osmcha_link = document.querySelector("#osmcha_link");
osmcha_link.style.height = "1em";
osmcha_link.style.cursor = "pointer";
osmcha_link.style.marginTop = "-3px";
osmcha_link.title = "Open changeset in OSMCha (or press O)\n(shift + O for open Achavi)";
if (isDarkMode()) {
osmcha_link.style.filter = "invert(0.7)";
}
// find deleted user
// todo extract
let metainfoHTML = document.querySelector(".browse-section > .details")
let time = Array.from(metainfoHTML.children).find(i => i.localName === "time")
if (Array.from(metainfoHTML.children).some(e => e.localName === "a")) {
let usernameA = Array.from(metainfoHTML.children).find(i => i.localName === "a")
metainfoHTML.innerHTML = ""
metainfoHTML.appendChild(time)
metainfoHTML.appendChild(document.createTextNode(" "))
metainfoHTML.appendChild(usernameA)
metainfoHTML.appendChild(document.createTextNode(" "))
getCachedUserInfo(usernameA.textContent).then((res) => {
usernameA.before(makeBadge(res, new Date(time.getAttribute("datetime"))))
usernameA.before(document.createTextNode(" "))
usernameA.title = `changesets_count: ${res['changesets']['count']}\naccount_created: ${res['account_created']}`
document.querySelectorAll(".browse-tag-list tr").forEach(i => {
const key = i.querySelector("th")
if (!key) return
if (key.textContent === "changesets_count") {
function insertAllChangesets(info) {
const allChangesets = document.createElement("span")
allChangesets.textContent = `/${info['changesets']['count']}`
allChangesets.style.color = "gray"
allChangesets.title = "how many changesets does the user have in total"
i.querySelector("td").appendChild(allChangesets)
}
if (parseInt(i.querySelector("td").textContent) >= res['changesets']['count']) {
updateUserInfo(usernameA.textContent).then((res) => {
insertAllChangesets(res)
})
} else {
insertAllChangesets(res)
}
}
})
})
//<link rel="alternate" type="application/atom+xml" title="ATOM" href="https://www.openstreetmap.org/user/Elizen/history/feed">
const rssfeed = document.createElement("link")
rssfeed.id = "fixed-rss-feed"
rssfeed.type = "application/atom+xml"
rssfeed.title = "ATOM"
rssfeed.rel = "alternate"
rssfeed.href = `https://www.openstreetmap.org/user/${encodeURI(usernameA.textContent)}/history/feed`
document.head.appendChild(rssfeed)
} else {
let time = Array.from(metainfoHTML.children).find(i => i.localName === "time")
metainfoHTML.innerHTML = ""
metainfoHTML.appendChild(time)
const findBtn = document.createElement("span")
findBtn.title = "Try find deleted user"
findBtn.textContent = " 🔍 "
findBtn.value = changeset_id
findBtn.datetime = time.dateTime
findBtn.style.cursor = "pointer"
findBtn.onclick = findChangesetInDiff
metainfoHTML.appendChild(findBtn)
void restorePrevNextChangesetButtons(parseInt(changeset_id))
}
// compact changeset tags
if (!document.querySelector(".browse-tag-list[compacted]")) {
makeHashtagsClickable()
shortOsmOrgLinks(document.querySelector(".browse-section p"));
let needUnhide = false
document.querySelectorAll(".browse-tag-list tr").forEach(i => {
const key = i.querySelector("th")
if (!key) return
i.querySelectorAll("a").forEach(i => i.tabIndex = -1)
if (key.textContent === "host") {
if (i.querySelector("td").textContent === "https://www.openstreetmap.org/edit") {
i.style.display = "none"
i.classList.add("hidden-tag")
}
} else if (key.textContent.startsWith("ideditor:")) {
key.title = key.textContent
key.textContent = key.textContent.replace("ideditor:", "iD:")
} else if (key.textContent === "revert:id") {
if (i.querySelector("td").textContent.match(/^((\d+(;|…?$))+$)/)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/(\d+)(;|$)/g,
`<a href="/changeset/$1" class="changeset_link_in_changeset_tags">$1</a>$2`)
} else if (i.querySelector("td").textContent.match(/https:\/\/(www\.)?openstreetmap.org\/changeset\//g)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/>https:\/\/(www\.)?openstreetmap.org\/changeset\//g, ">")
}
} else if (key.textContent === "revert:dmp:relation:id" || key.textContent === "revert:dmp:fail:relation:id") {
if (i.querySelector("td").textContent.match(/^((\d+(;|…?$))+$)/)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/(\d+)(;|$)/g,
`<a href="/relation/$1" class="relation_link_in_changeset_tags">$1</a>$2`)
}
} else if (key.textContent === "revert:dmp:way:id" || key.textContent === "revert:dmp:fail:way:id") {
if (i.querySelector("td").textContent.match(/^((\d+(;|…?$))+$)/)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/(\d+)(;|$)/g,
`<a href="/way/$1" class="way_link_in_changeset_tags">$1</a>$2`)
}
} else if (key.textContent === "redacted_changesets") {
if (i.querySelector("td").textContent.match(/^((\d+(,|$))+$)/)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/(\d+)/g,
`<a href="/changeset/$1" class="changeset_link_in_changeset_tags">$1</a>`)
} else if (i.querySelector("td").textContent.match(/https:\/\/(www\.)?openstreetmap.org\/changeset\//g)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/>https:\/\/(www\.)?openstreetmap.org\/changeset\//g, ">")
}
} else if (key.textContent === "closed:note") {
if (i.querySelector("td").textContent.match(/^((\d+(;|$))+$)/)) {
i.querySelector("td").innerHTML = i.querySelector("td").innerHTML.replaceAll(/(\d+)/g,
`<a href="/note/$1" class="note_link_in_changeset_tags">$1</a>`)
}
} else if (key.textContent.startsWith("v:") && GM_config.get("ChangesetQuickLook")) {
i.style.display = "none"
i.classList.add("hidden-tag")
needUnhide = true
} else if (key.textContent === "hashtags" && i.querySelector("td").textContent.includes("#") && document.querySelector(".browse-section p")?.textContent?.includes(i.querySelector("td").textContent)) {
i.style.display = "none"
i.classList.add("hidden-tag")
}
})
if (needUnhide) {
const expander = document.createElement("td")
expander.onclick = e => {
document.querySelectorAll(".hidden-tag").forEach(i => {
i.style.display = ""
})
e.target.remove()
}
expander.colSpan = 2
expander.textContent = "∇"
expander.style.textAlign = "center"
expander.style.cursor = "pointer"
expander.title = "Show hidden tags"
document.querySelector(".browse-tag-list").appendChild(expander)
}
document.querySelector(".browse-tag-list")?.setAttribute("compacted", "true")
}
}
const textarea = document.querySelector("#sidebar_content textarea");
if (textarea) {
textarea.rows = 1;
let comment = document.querySelector("#sidebar_content button[name=comment]")
if (comment) {
comment.parentElement.hidden = true
textarea.addEventListener("input", () => {
comment.hidden = false
}
)
textarea.addEventListener("click", () => {
textarea.rows = textarea.rows + 5
comment.parentElement.hidden = false
}, {once: true}
)
comment.onclick = () => {
[500, 1000, 2000, 4000, 6000].map(i => setTimeout(setupRevertButton, i));
}
const templates = GM_config.get("ChangesetsTemplates")
if (templates) {
JSON.parse(templates).forEach(row => {
const label = row['label']
let text = label
if (row['text'] !== "") {
text = row['text']
}
let b = document.createElement("span");
b.classList.add("comment-template", "btn", "btn-primary");
b.textContent = label;
b.title = `Add into the comment "${text}".\nYou can change text in userscript settings`
document.querySelectorAll("form.mb-3 [name=comment]")[0].parentElement.appendChild(b);
b.after(document.createTextNode("\xA0"));
b.onclick = (e) => {
e.stopImmediatePropagation()
const textarea = document.querySelector("form.mb-3 textarea")
const prev = textarea.value;
const cursor = textarea.selectionEnd
textarea.value = prev.substring(0, cursor) + text + prev.substring(cursor)
textarea.focus()
}
})
}
}
}
const tagsHeader = document.querySelector("#sidebar_content h4");
if (tagsHeader) {
tagsHeader.remove()
}
const primaryButtons = document.querySelector("[name=subscribe], [name=unsubscribe]")
if (primaryButtons?.getAttribute("name") === "subscribe") {
primaryButtons.tabIndex = -1
}
if (primaryButtons && osm_server.url === prod_server.url) {
const changeset_id = sidebar.innerHTML.match(/(\d+)/)[0];
async function uncheck(changeset_id) {
return await GM.xmlHttpRequest({
url: `https://osmcha.org/api/v1/changesets/${changeset_id}/uncheck/`,
headers: {
"Authorization": "Token " + await GM.getValue("OSMCHA_TOKEN"),
},
method: "PUT",
});
}
const likeImgRes = GM_getResourceURL("OSMCHA_LIKE", false)
const dislikeImgRes = GM_getResourceURL("OSMCHA_DISLIKE", false)
const likeBtn = document.createElement("span")
likeBtn.title = "OSMCha review like"
const likeImg = document.createElement("img")
likeImg.title = "OSMCha review like"
likeImg.src = likeImgRes
likeImg.style.height = "1.1em"
likeImg.style.cursor = "pointer"
likeImg.style.filter = "grayscale(1)"
likeImg.style.marginTop = "-8px"
likeBtn.onclick = async e => {
const osmchaToken = await GM.getValue("OSMCHA_TOKEN")
if (!osmchaToken) {
alert("Please, login into OSMCha")
window.open("https://osmcha.org")
return;
}
if (e.target.hasAttribute("active")) {
await uncheck(changeset_id)
await updateReactions()
return
}
if (document.querySelector(".check_user")) {
await uncheck(changeset_id)
await updateReactions()
}
await GM.xmlHttpRequest({
url: `https://osmcha.org/api/v1/changesets/${changeset_id}/set-good/`,
headers: {
"Authorization": "Token " + await GM.getValue("OSMCHA_TOKEN"),
},
method: "PUT",
});
await updateReactions()
}
likeBtn.appendChild(likeImg)
const dislikeBtn = document.createElement("span")
dislikeBtn.title = "OSMCha review dislike"
const dislikeImg = document.createElement("img")
dislikeImg.title = "OSMCha review dislike"
dislikeImg.src = likeImgRes // dirty hack for different graystyle colors
dislikeImg.style.height = "1.1em"
dislikeImg.style.cursor = "pointer"
dislikeImg.style.filter = "grayscale(1)"
dislikeImg.style.transform = "rotate(180deg)"
dislikeImg.style.marginTop = "3px"
dislikeBtn.appendChild(dislikeImg)
dislikeBtn.onclick = async e => {
const osmchaToken = await GM.getValue("OSMCHA_TOKEN")
if (!osmchaToken) {
alert("Please, login into OSMCha")
window.open("https://osmcha.org")
return;
}
if (e.target.hasAttribute("active")) {
await uncheck(changeset_id)
await updateReactions()
return
}
if (document.querySelector(".check_user")) {
await uncheck(changeset_id)
await updateReactions()
}
await GM.xmlHttpRequest({
url: `https://osmcha.org/api/v1/changesets/${changeset_id}/set-harmful/`,
headers: {
"Authorization": "Token " + await GM.getValue("OSMCHA_TOKEN"),
},
method: "PUT",
});
await updateReactions()
}
async function updateReactions() {
const res = await GM.xmlHttpRequest({
url: "https://osmcha.org/api/v1/changesets/" + changeset_id + "/",
method: "GET",
headers: {
"Authorization": "Token " + await GM.getValue("OSMCHA_TOKEN"),
},
responseType: "json"
})
if (res.status === 404) {
likeImg.title = "Changeset not found in OSMCha database.\nEither OSMCha did not have time to process this changeset, or it is too old."
dislikeImg.title = "Changeset not found in OSMCha database.\nEither OSMCha did not have time to process this changeset, or it is too old."
console.warn("Changeset not found in OSMCha database") // todo show alert/title
return;
}
const json = res.response;
if (json['properties']['check_user']) {
document.querySelector(".check_user")?.remove()
likeImg.style.filter = "grayscale(1)"
dislikeImg.style.filter = "grayscale(1)"
const username = document.createElement("span")
username.classList.add("check_user")
username.textContent = json['properties']['check_user']
if (json['properties']['harmful'] === true) {
dislikeImg.style.filter = ""
dislikeImg.style.transform = ""
dislikeImg.src = dislikeImgRes
dislikeImg.setAttribute("active", "true")
dislikeImg.title = "OSMCha review dislike"
username.style.color = "red"
dislikeBtn.after(username)
} else {
likeImg.style.filter = ""
likeImg.setAttribute("active", "true")
likeImg.title = "OSMCha review like"
username.style.color = "green"
likeBtn.after(username)
}
} else {
likeImg.style.filter = "grayscale(1)"
dislikeImg.style.filter = "grayscale(1)"
dislikeImg.style.transform = "rotate(180deg)"
dislikeImg.src = likeImgRes
dislikeImg.title = "OSMCha review dislike"
likeImg.title = "OSMCha review like"
likeImg.removeAttribute("active")
dislikeImg.removeAttribute("active")
document.querySelector(".check_user")?.remove()
}
}
setTimeout(updateReactions, 0);
primaryButtons.before(likeBtn)
primaryButtons.before(document.createTextNode("\xA0"))
primaryButtons.before(dislikeBtn)
primaryButtons.before(document.createTextNode("\xA0"))
}
document.querySelectorAll('#sidebar_content article[id^=c] small > a[href^="/user/"]').forEach(elem => {
getCachedUserInfo(elem.textContent).then(info => {
elem.before(makeBadge(info, new Date(elem.nextElementSibling.getAttribute("datetime"))))
elem.title = `changesets_count: ${info['changesets']['count']}\naccount_created: ${info['account_created']}`
})
})
// fixme dont work loggined
document.querySelectorAll(".browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ article div").forEach(c => {
c.innerHTML = c.innerHTML.replaceAll(/((changesets )((\d+)([,. ])(\s|$|<\/))+|changeset \d+)/gm, (match) => {
return match.replaceAll(/(\d+)/g, `<a href="/changeset/$1" class="changeset_link_in_comment">$1</a>`)
}).replaceAll(/>https:\/\/(www\.)?openstreetmap.org\//g, ">osm.org/")
})
makeHeaderPartsClickable()
}
function setupRevertButton() {
if (!location.pathname.startsWith("/changeset")) return;
let timerId = setInterval(() => {
if (addRevertButton()) clearInterval(timerId)
}, 100);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add revert button');
}, 3000);
addRevertButton();
}
function showSearchForm() {
document.querySelector("#sidebar .search_forms")?.removeAttribute("hidden");
}
function hideSearchForm() {
if (location.pathname.startsWith("/search") || location.pathname.startsWith("/directions")) return;
if (!document.querySelector("#sidebar .search_forms")?.hasAttribute("hidden")) {
document.querySelector("#sidebar .search_forms")?.setAttribute("hidden", "true")
}
document.querySelector(".sidebar-close-controls .btn-close:not(.hotkeyed)")?.addEventListener("click", () => {
showSearchForm()
cleanAllObjects()
})
document.querySelector(".sidebar-close-controls .btn-close:not(.hotkeyed)")?.classList?.add("hotkeyed")
document.querySelector("h1 .icon-link:not(.hotkeyed)")?.addEventListener("click", () => {
showSearchForm()
cleanAllObjects()
})
document.querySelector("h1 .icon-link:not(.hotkeyed)")?.classList?.add("hotkeyed")
}
let sidebarObserver = null;
let timestampMode = "natural_text"
function makeTimesSwitchable() {
document.querySelectorAll("time:not([natural_text])").forEach(j => {
j.setAttribute("natural_text", j.textContent)
if (timestampMode !== "natural_text") {
j.textContent = j.getAttribute("datetime")
}
})
function switchTimestamp() {
if (window.getSelection().type === "Range") {
return
}
document.querySelectorAll("time:not([natural_text])").forEach(j => {
j.setAttribute("natural_text", j.textContent)
})
function switchElement(j) {
if (j.childNodes[0].textContent === j.getAttribute("natural_text")) {
j.childNodes[0].textContent = j.getAttribute("datetime")
timestampMode = "datetime"
} else {
j.childNodes[0].textContent = j.getAttribute("natural_text")
timestampMode = "natural_text"
}
if (j.querySelector(".timeback-btn") && j.nextElementSibling?.id !== "timeback-btn") {
j.querySelector(".timeback-btn").style.display = ""
}
}
document.querySelectorAll("time").forEach(switchElement)
}
const isObjectPage = location.pathname.includes("node") || location.pathname.includes("way") || location.pathname.includes("relation")
const isNotePage = location.pathname.includes("note")
function openMapStateInOverpass(elem, adiff = false) {
const {lng: lon, lat: lat} = getMap().getCenter()
const zoom = getMap().getZoom();
const query = `// via changeset closing time
[${adiff ? "adiff" : "date"}:"${elem.getAttribute("datetime")}"];
(
node({{bbox}});
way({{bbox}});
//relation({{bbox}});
);
(._;>;);
out meta;
`;
window.open(`${overpass_server.url}?Q=${encodeURI(query)}&C=${lat};${lon};${zoom}${zoom > 15 ? "&R" : ""}`, "_blank")
}
document.querySelectorAll("time:not([switchable])").forEach(i => {
if (i.title !== "") {
i.title += `\n\n`
}
i.title += `Click for change time format`
i.title += `\nClick with ctrl for open the map state at the time of ${isObjectPage ? "version was created" : (isNotePage ? "note was created" : "changeset was closed")}\nClick with Alt for view adiff`
function clickEvent(e) {
if (e.metaKey || e.ctrlKey || e.altKey) {
if (window.getSelection().type === "Range") {
return
}
openMapStateInOverpass(i, e.altKey)
} else {
switchTimestamp()
}
}
i.addEventListener("click", clickEvent);
})
document.querySelectorAll("time:not([switchable])").forEach(i => {
i.setAttribute("switchable", "true")
const btn = document.createElement("a")
btn.classList.add("timeback-btn");
btn.title = `Open the map state at the time of ${isObjectPage ? "version was created" : "changeset was closed"}`
btn.textContent = " 🕰";
btn.style.cursor = "pointer"
btn.style.display = "none"
btn.style.userSelect = "none"
btn.onclick = (e) => {
e.stopPropagation()
openMapStateInOverpass(i, e.altKey)
}
i.appendChild(btn);
})
}
// noinspection CssUnusedSymbol,CssUnresolvedCustomProperty
const compactSidebarStyleText = `
.changesets p {
margin-bottom: 0;
font-weight: 788;
font-style: italic;
font-size: 14px !important;
font-synthesis: none;
}
@media ${mediaQueryForWebsiteTheme} {
.changesets time {
color: darkgray;
}
.changesets p {
font-weight: 400;
}
.changeset_id.custom-changeset-id-click {
color: #767676 !important;
}
}
.browse-section > p:nth-of-type(1) {
font-size: 14px !important;
font-style: italic;
font-synthesis: none;
}
.hidden-comments-badge {
display: none !important;
}
.better-diff-icon {
position: relative;
top: 2px;
}
.map-layout #sidebar {
width: 450px;
}
turbo-frame {
word-wrap: anywhere;
}
turbo-frame th {
min-width: max-content;
word-wrap: break-word;
}
/*for id copied*/
.copied {
background-color: rgba(9,238,9,0.6);
transition:all 0.3s;
}
.was-copied {
background-color: initial;
transition:all 0.3s;
}
@media ${mediaQueryForWebsiteTheme} {
.copied {
background-color: rgba(0,255,101,0.6);
transition: all 0.3s;
}
.was-copied {
background-color: initial;
transition: all 0.3s;
}
}
#sidebar_content h2:not(.changeset-header) {
font-size: 1rem;
}
#sidebar {
border-top: solid;
border-top-width: 1px;
border-top-color: rgba(var(--bs-secondary-bg-rgb), var(--bs-bg-opacity)) !important;
}
.fixme-tag {
color: red !important;
font-weight: bold;
}
.note-tag {
font-weight: bold;
}
@media ${mediaQueryForWebsiteTheme} {
.fixme-tag {
color: #ff5454 !important;
font-weight: unset;
}
.note-tag {
background: black !important;
font-weight: unset;
}
}
@media ${mediaQueryForWebsiteTheme} {
table.browse-tag-list tr td[colspan="2"]{
background: var(--bs-body-bg) !important;
}
}
.sidebar-close-controls.position-relative .position-absolute {
padding: 1.5rem !important;
padding-bottom: 1.3rem !important;
padding-top: 1.4rem !important;
}
.way-last-version-node:hover, .relation-last-version-member:hover, .node-last-version-parent:hover {
background-color: rgba(244, 244, 244);
}
@media ${mediaQueryForWebsiteTheme} {
.way-last-version-node:hover, .relation-last-version-member:hover, .node-last-version-parent:hover {
background-color: rgb(14, 17, 19);
}
}
.copy-changesets-ids-btn {
padding-left: 5px;
padding-right: 5px;
}
#mouse-trap {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: auto;
cursor: none;
z-index: 999999;
/* background: rgba(255, 0, 0, 0.3); */
background: transparent;
}
`;
let styleForSidebarApplied = false
function addCompactSidebarStyle() {
if (styleForSidebarApplied) return;
styleForSidebarApplied = true;
injectCSSIntoOSMPage(compactSidebarStyleText);
}
async function getChangesetComments(changeset_id) {
const res = await fetchJSONWithCache(osm_server.apiBase + "changeset" + "/" + changeset_id + ".json?include_discussion=true")
if (res.status === 509) {
error509Handler(res)
} else {
return res['changeset']['comments'];
}
}
function setupCompactChangesetsHistory() {
if (!location.pathname.includes("/history") && !location.pathname.startsWith("/changeset")) {
if (!styleForSidebarApplied && (location.pathname.startsWith("/node")
|| location.pathname.startsWith("/way")
|| location.pathname.startsWith("/relation"))) {
addCompactSidebarStyle();
}
return;
}
if (location.pathname.startsWith("/changeset")) {
if (document.querySelector("#sidebar_content ul")) {
document.querySelector("#sidebar_content ul").querySelectorAll("a:not(.page-link)").forEach(i => i.setAttribute("target", "_blank"));
}
}
addCompactSidebarStyle();
// увы, инвалидация в этом месте ломает зум при загрузке объекте самим сайтом
// try {
// getMap()?.invalidateSize()
// } catch (e) {
// }
function handleNewChangesets() {
if (!location.pathname.includes("/history")) {
return
}
// remove useless
document.querySelectorAll("#sidebar ol .pt-3").forEach((e) => {
e.childNodes[0].textContent = ""
e.classList.remove("pt-3")
e.nextElementSibling.classList.remove("flex-column")
e.nextElementSibling.classList.add("flex-row")
e.nextElementSibling.style.gap = "5px"
const changesBadges = e.nextElementSibling.querySelectorAll("svg")
if (changesBadges.length >= 2 && !changesBadges[1].classList.contains("better-diff-icon")) {
changesBadges[1].outerHTML = "<svg class=\"lucide lucide-diff better-diff-icon\" xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3v14\"/><path d=\"M5 10h14\"/><path d=\"M5 21h14\"/></svg>"
changesBadges[1].style.position = "relative";
changesBadges[1].style.top = "3px";
}
})
makeTimesSwitchable();
hideSearchForm();
document.querySelectorAll("ol li a.changeset_id bdi:not(.compacted)").forEach(description => {
description.classList.add("compacted")
description.textContent = shortOsmOrgLinksInText(description.textContent)
})
setTimeout(async () => {
for (const elem of document.querySelectorAll("ol li:not(:has(.comment)):not(.comments-loaded)")) {
elem.classList.add("comments-loaded")
const commentsBadge = elem.querySelector(".flex-row.text-body-secondary")
commentsBadge.querySelector("svg").outerHTML = `<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M2.678 11.894a1 1 0 0 1 .287.801 11 11 0 0 1-.398 2c1.395-.323 2.247-.697 2.634-.893a1 1 0 0 1 .71-.074A8 8 0 0 0 8 14c3.996 0 7-2.807 7-6s-3.004-6-7-6-7 2.808-7 6c0 1.468.617 2.83 1.678 3.894m-.493 3.905a22 22 0 0 1-.713.129c-.2.032-.352-.176-.273-.362a10 10 0 0 0 .244-.637l.003-.01c.248-.72.45-1.548.524-2.319C.743 11.37 0 9.76 0 8c0-3.866 3.582-7 8-7s8 3.134 8 7-3.582 7-8 7a9 9 0 0 1-2.347-.306c-.52.263-1.639.742-3.468 1.105"></path></svg>`
const commentsCount = parseInt(commentsBadge.firstElementChild.firstChild.textContent.trim());
if (commentsCount) {
if (commentsCount > 3) {
commentsBadge.firstElementChild.style.setProperty("color", "red", "important")
} else if (commentsCount > 1) {
commentsBadge.firstElementChild.style.setProperty("color", "#ff7800", "important")
} else if (commentsCount > 0) {
commentsBadge.firstElementChild.style.setProperty("color", "#ffae00", "important")
}
const changeset_id = elem.querySelector(".changeset_id").href.match(/\/(\d+)/)[1];
getChangesetComments(changeset_id).then(res => {
res.forEach((comment, idx) => {
const commentElem = document.createElement("div");
commentElem.classList.add("comment")
commentElem.style.fontSize = "0.7rem"
commentElem.style.borderTopColor = "#0000"
commentElem.style.borderTopStyle = "solid"
commentElem.style.borderTopWidth = "1px"
if (idx !== 0) {
commentElem.style.display = "none"
}
elem.appendChild(commentElem)
const userLink = document.createElement("a")
userLink.href = osm_server.url + "/user/" + encodeURI(comment["user"]);
userLink.textContent = comment["user"];
commentElem.appendChild(userLink);
getCachedUserInfo(comment["user"]).then((res) => {
const badge = makeBadge(res /* fixme */)
const svg = badge.querySelector("svg")
if (svg) {
badge.style.marginLeft = "-4px"
badge.style.height = "1rem";
badge.style.float = "left";
svg.style.transform = "scale(0.7)"
}
userLink.before(badge)
})
let shortText = shortOsmOrgLinksInText(comment["text"])
if (shortText.length > 500) {
const text = document.createElement("span")
text.textContent = " " + shortText.slice(0, 500)
commentElem.appendChild(text);
const more = document.createElement("span")
more.textContent = "..."
more.title = "Click for view more"
more.style.cursor = "pointer"
more.style.color = "rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1))"
more.onclick = () => {
more.remove()
text.textContent = " " + shortText
}
commentElem.appendChild(more);
} else {
commentElem.appendChild(document.createTextNode(" " + shortText));
}
})
commentsBadge.firstElementChild.style.cursor = "pointer"
let state = (commentsCount === 1 ? "" : "none")
commentsBadge.firstElementChild.onclick = () => {
elem.querySelectorAll(".comment").forEach(comment => {
if (state === "none") {
comment.style.display = ""
} else {
comment.style.display = "none"
}
})
state = (state === "none") ? "" : "none"
}
commentsBadge.firstElementChild.title = ""
res.forEach(comment => {
const shortText = shortOsmOrgLinksInText(comment["text"])
commentsBadge.firstElementChild.title += `${comment["user"]}:\n${shortText}\n\n`
})
commentsBadge.firstElementChild.title = commentsBadge.firstElementChild.title.trimEnd()
});
} else {
commentsBadge.firstElementChild.classList.add("hidden-comments-badge")
}
}
}, 0);
}
handleNewChangesets();
sidebarObserver?.disconnect();
sidebarObserver = new MutationObserver(handleNewChangesets);
if (document.querySelector('#sidebar_content') && !location.pathname.startsWith("/changeset")) {
sidebarObserver.observe(document.querySelector('#sidebar_content'), {childList: true, subtree: true});
}
}
/**
*
* @param {string} text
* @param {boolean} strict
* @return {Object.<string, string>}
*/
function buildTags(text, strict=false) {
const lines = text.split('\n');
let json = {};
for (let i = 0; i < lines.length; i++){
const line = lines[i];
let eqPos = line.indexOf('=');
if (eqPos <= 0 || eqPos === line.length - 1) {
eqPos = line.indexOf("\t");
if (eqPos <= 0 || eqPos === line.length - 1) {
if (strict && line.trim() !== '') {
throw `Empty key or value in line №${i}: ${line}`;
}
continue;
}
}
const k = line.substring(0, eqPos).trim();
const v = line.substring(eqPos + 1).trim();
if (v === '' || k === '') {
if (strict && line.trim() !== '') {
throw `Empty key or value in line №${i+1}: ${line}`;
}
continue;
}
json[k] = v.replaceAll('\\\\', '\n');
}
return json;
}
function makeTextareaFromTagsTable(table) {
const textarea = document.createElement("textarea")
table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
if (i.querySelector("th").textContent.trim() === "" || i.querySelector("td").textContent.trim() === "") return
textarea.value += `${i.querySelector("th").textContent}=${i.querySelector("td").textContent.replaceAll('\\\\', '\n')}\n`
})
textarea.value = textarea.value.trim()
textarea.rows = 5
return textarea
}
function addResolveNotesButton() {
if (!location.pathname.includes("/note")) return
if (location.pathname.includes("/note/new")) {
if (!document.querySelector("#sidebar_content form")) {
return
}
if (newNotePlaceholder && document.querySelector(".note form textarea")) {
document.querySelector(".note form textarea").textContent = newNotePlaceholder
document.querySelector(".note form textarea").selectionEnd = 0
newNotePlaceholder = null
}
if (document.querySelector(".add-new-object-btn")) return
let b = document.createElement("span");
b.classList.add("add-new-object-btn", "btn", "btn-primary");
b.textContent = "➕";
if (!getMap() || !getMap().getZoom) {
b.style.display = "none"
interceptMapManually().then(() => {
b.style.display = ""
})
}
b.title = `Add new object on map\nPaste tags in textarea\nkey=value\nkey2=value2\n...`
document.querySelector("#sidebar_content form div:has(input)").appendChild(b);
b.before(document.createTextNode("\xA0"));
b.onclick = (e) => {
e.stopImmediatePropagation()
const auth = makeAuth();
console.log("Opening changeset");
let tagsHint = ""
let tags;
try {
tags = buildTags(document.querySelector("#sidebar_content form textarea").value, true)
} catch (e) {
alert(e)
return
}
if (Object.entries(tags).length === 0) {
alert("Textarea not contains any tag")
return
}
for (const i of Object.entries(tags)) {
if (mainTags.includes(i[0])) {
tagsHint = tagsHint + ` ${i[0]}=${i[1]}`;
break
}
}
for (const i of Object.entries(tags)) {
if (i[0] === "name") {
tagsHint = tagsHint + ` ${i[0]}=${i[1]}`;
break
}
}
const changesetTags = {
'created_by': `better osm.org v${GM_info.script.version}`,
'comment': tagsHint !== "" ? `Create${tagsHint}` : `Create node`
};
let changesetPayload = document.implementation.createDocument(null, 'osm');
let cs = changesetPayload.createElement('changeset');
changesetPayload.documentElement.appendChild(cs);
tagsToXml(changesetPayload, cs, changesetTags);
const chPayloadStr = new XMLSerializer().serializeToString(changesetPayload);
auth.xhr({
method: 'PUT',
path: osm_server.apiBase + 'changeset/create',
prefix: false,
content: chPayloadStr
}, function (err1, result) {
const changesetId = result;
console.log(changesetId);
const nodePayload = document.createElement('osm');
const node = document.createElement("node")
nodePayload.appendChild(node)
node.setAttribute("changeset", changesetId)
const l = [];
getMap().eachLayer(intoPageWithFun(i => l.push(i)))
const {
lat: lat,
lng: lng
} = l.find(i => !!i._icon && i._icon.classList.contains("leaflet-marker-draggable"))._latlng
node.setAttribute("lat", lat)
node.setAttribute("lon", lng)
for (const tag of Object.entries(tags)) {
let tagElem = document.createElement("tag")
tagElem.setAttribute("k", tag[0])
tagElem.setAttribute("v", tag[1])
node.appendChild(tagElem)
}
const nodeStr = new XMLSerializer().serializeToString(nodePayload).replace(/xmlns="[^"]+"/, '');
auth.xhr({
method: 'POST',
path: osm_server.apiBase + "nodes",
prefix: false,
content: nodeStr
}, function (err2) {
if (err2) {
console.log({changesetError: err2});
}
auth.xhr({
method: 'PUT',
path: osm_server.apiBase + 'changeset/' + changesetId + '/close',
prefix: false
}, function (err3) {
if (!err3) {
window.location = osm_server.url + '/changeset/' + changesetId
}
});
});
});
}
return
}
if (document.querySelector('.resolve-note-done')) return true;
if (document.querySelector('#timeback-btn')) return true;
resetSearchFormFocus();
document.querySelectorAll('#sidebar_content a[href^="/user/"]').forEach(elem => {
getCachedUserInfo(elem.textContent).then(info => {
elem.before(makeBadge(info, new Date(elem.parentElement.querySelector("time")?.getAttribute("datetime") ?? new Date())))
elem.title = `changesets_count: ${info['changesets']['count']}\naccount_created: ${info['account_created']}`
})
})
document.querySelectorAll(".overflow-hidden a").forEach(i => {
i.setAttribute("target", "_blank")
})
makeTimesSwitchable()
try {
// timeback button
let timestamp = document.querySelector("#sidebar_content time").dateTime;
let timeSource = "note creation date"
const mapsmeDate = document.querySelector(".overflow-hidden")?.textContent?.match(/OSM data version: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
if (mapsmeDate) {
timestamp = mapsmeDate[1];
timeSource = "MAPS.ME snapshot date"
}
const organicmapsDate = document.querySelector(".overflow-hidden")?.textContent?.match(/OSM snapshot date: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)/);
if (organicmapsDate) {
timestamp = organicmapsDate[1];
timeSource = "Organic Maps snapshot date"
}
const lat = document.querySelector("#sidebar_content .latitude").textContent.replace(",", ".");
const lon = document.querySelector("#sidebar_content .longitude").textContent.replace(",", ".");
const zoom = 18;
const query =
`// via ${timeSource}
[date:"${timestamp}"];
(
node({{bbox}});
way({{bbox}});
//relation({{bbox}});
);
(._;>;);
out meta;
`;
let btn = document.createElement("a")
btn.id = "timeback-btn";
if (organicmapsDate || mapsmeDate) {
btn.title = "Open the map state at the time of map snapshot"
} else {
btn.title = "Open the map state at the time of note creation"
}
btn.textContent = " 🕰";
btn.style.cursor = "pointer"
document.querySelector("#sidebar_content time").after(btn);
btn.onclick = () => {
window.open(`${overpass_server.url}?Q=${encodeURI(query)}&C=${lat};${lon};${zoom}&R`)
}
} catch {
console.error("setup timeback button fail");
}
document.querySelectorAll('#sidebar_content div:has(h4) a:not(.gpx-displayed)').forEach(i => {
i.classList.add("gpx-displayed")
const m = i.href.match(new RegExp(`${osm_server.url}/user/.+/traces/(\\d+)`))
if (m) {
GM.xmlHttpRequest({
url: `${osm_server.url}/traces/${m[1]}/data`,
}).then(res => displayGPXTrack(res.response))
}
})
if (!document.querySelector("#sidebar_content textarea.form-control")) {
return;
}
const auth = makeAuth();
let note_id = location.pathname.match(/note\/(\d+)/)[1];
/** @type {string} */
const resolveButtonsText = GM_config.get("ResolveNotesButton")
if (resolveButtonsText) {
JSON.parse(resolveButtonsText).forEach(row => {
const label = row['label']
let text = label
if (row['text'] !== "") {
text = row['text']
}
let b = document.createElement("button");
b.classList.add("resolve-note-done", "btn", "btn-primary");
b.textContent = label;
b.title = `Add to the comment "${text}" and close the note.\nYou can change emoji in userscript settings`
document.querySelectorAll("form.mb-3")[0].before(b);
b.after(document.createTextNode("\xA0"));
b.onclick = () => {
try {
getWindow().OSM.router.stateChange(getWindow().OSM.parseHash(getWindow().OSM.formatHash(getMap())))
} catch (e) {
console.error(e)
}
auth.xhr({
method: 'POST',
path: osm_server.apiBase + 'notes/' + note_id + "/close.json?" + new URLSearchParams({
text: text
}).toString(),
prefix: false,
}, (err) => {
if (err) {
alert(err);
}
window.location.reload();
}
);
}
})
document.querySelectorAll("form.mb-3")[0].before(document.createElement("p"));
document.querySelector("form.mb-3 .form-control").rows = 3;
}
document.querySelectorAll('#sidebar_content div:has(h4) a').forEach(i => {
if (i.href.match(/^(https:\/\/streetcomplete\.app\/|https:\/\/westnordost\.de\/).+\.jpg$/)) {
const img = GM_addElement("img", {
src: i.href,
// crossorigin: "anonymous"
})
img.style.width = "100%"
i.after(img)
document.querySelector("#sidebar").style.resize = "horizontal"
document.querySelector("#sidebar").style.width = "450px"
// hideSearchForm()
}
})
}
function setupResolveNotesButton(path) {
if (!path.startsWith("/note")) return;
let timerId = setInterval(addResolveNotesButton, 100);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add resolve note button');
}, 3000);
addResolveNotesButton();
}
function addDeleteButton() {
if (!location.pathname.startsWith("/node/")) return;
if (location.pathname.includes("/history")) return;
if (document.querySelector('.delete_object_button_class')) return true;
let match = location.pathname.match(/(node|way)\/(\d+)/);
if (!match) return;
let object_type = match[1];
let object_id = match[2];
const auth = makeAuth();
let link = document.createElement('a');
link.text = ['ru-RU', 'ru'].includes(navigator.language) ? "Выпилить!" : "Delete";
link.href = "";
link.classList.add("delete_object_button_class");
// skip deleted
if (document.querySelectorAll(".browse-section h4").length < 2 && document.querySelector(".browse-section .latitude") === null) {
link.setAttribute("hidden", true);
return;
}
// skip having a parent
if (document.querySelectorAll(".browse-section details").length !== 0) {
return;
}
if (!document.querySelector(".secondary-actions")) return;
document.querySelector(".secondary-actions").appendChild(link);
link.after(document.createTextNode("\xA0"));
link.before(document.createTextNode("\xA0· "));
if (!document.querySelector(".secondary-actions .edit_tags_class")) {
const tagsEditorExtensionWaiter = new MutationObserver(() => {
if (document.querySelector(".secondary-actions .edit_tags_class")) {
tagsEditorExtensionWaiter.disconnect()
const tmp = document.createComment('')
const node1 = document.querySelector(".delete_object_button_class")
const node2 = document.querySelector(".edit_tags_class")
node2.replaceWith(tmp)
node1.replaceWith(node2)
tmp.replaceWith(node1)
console.log("Delete button replaced for Tags editor extension capability")
}
})
tagsEditorExtensionWaiter.observe(document.querySelector(".secondary-actions"), {
childList: true,
subtree: true
})
setTimeout(() => tagsEditorExtensionWaiter.disconnect(), 3000)
}
function deleteObject(e) {
e.preventDefault();
link.classList.add("dbclicked");
console.log("Opening changeset");
auth.xhr({
method: 'GET',
path: osm_server.apiBase + object_type + '/' + object_id,
prefix: false,
}, function (err, objectInfo) {
if (err) {
console.log(err);
return;
}
let tagsHint = ""
const tags = Array.from(objectInfo.children[0].children[0]?.children)
for (const i of tags) {
if (mainTags.includes(i.getAttribute("k"))) {
tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`;
break
}
}
for (const i of tags) {
if (i.getAttribute("k") === "name") {
tagsHint = tagsHint + ` ${i.getAttribute("k")}=${i.getAttribute("v")}`;
break
}
}
const changesetTags = {
'created_by': `better osm.org v${GM_info.script.version}`,
'comment': tagsHint !== "" ? `Delete${tagsHint}` : `Delete ${object_type} ${object_id}`
};
let changesetPayload = document.implementation.createDocument(null, 'osm');
let cs = changesetPayload.createElement('changeset');
changesetPayload.documentElement.appendChild(cs);
tagsToXml(changesetPayload, cs, changesetTags);
const chPayloadStr = new XMLSerializer().serializeToString(changesetPayload);
auth.xhr({
method: 'PUT',
path: osm_server.apiBase + 'changeset/create',
prefix: false,
content: chPayloadStr
}, function (err1, result) {
const changesetId = result;
console.log(changesetId);
objectInfo.children[0].children[0].setAttribute('changeset', changesetId);
auth.xhr({
method: 'DELETE',
path: osm_server.apiBase + object_type + '/' + object_id,
prefix: false,
content: objectInfo
}, function (err2) {
if (err2) {
console.log({changesetError: err2});
}
auth.xhr({
method: 'PUT',
path: osm_server.apiBase + 'changeset/' + changesetId + '/close',
prefix: false
}, function (err3) {
if (!err3) {
window.location.reload();
}
});
});
});
});
}
if (GM_config.get("OneClickDeletor")) {
link.onclick = deleteObject;
} else {
link.onclick = (e) => {
e.preventDefault();
setTimeout(() => {
if (!link.classList.contains("dbclicked")) {
link.text = "Double click please";
}
}, 200);
}
link.ondblclick = deleteObject
}
}
function setupDeletor(path) {
if (!path.startsWith("/node/") /*&& !url.includes("/way/")*/) return;
let timerId = setInterval(addDeleteButton, 100);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add delete button');
}, 3000);
addDeleteButton();
}
let mapDataSwitcherUnderSupervision = false
function hideNoteHighlight() {
let g = document.querySelector("#map g");
if (!g || g.childElementCount === 0) return;
let mapDataCheckbox = document.querySelector(".layers-ui li:nth-child(2) > label:nth-child(1) > input:nth-child(1)")
if (!mapDataCheckbox.checked) {
if (mapDataSwitcherUnderSupervision) return;
mapDataSwitcherUnderSupervision = true
mapDataCheckbox.addEventListener("click", () => {
mapDataSwitcherUnderSupervision = false
hideNoteHighlight();
}, {once: true})
return;
}
if (g.childNodes[g.childElementCount - 1].getAttribute("stroke") === "#FF6200"
&& g.childNodes[g.childElementCount - 1].getAttribute("d").includes("a20,20 0 1,0 -40,0 ")) {
g.childNodes[g.childElementCount - 1].remove();
document.querySelector("img.leaflet-marker-icon:last-child").style.filter = "contrast(120%)";
}
}
function setupHideNoteHighlight() {
if (!location.pathname.startsWith("/note/")) return;
let timerId = setInterval(hideNoteHighlight, 1000);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop removing note highlight');
}, 5000);
hideNoteHighlight();
}
//<editor-fold desc="satellite switching">
const OSMPrefix = "https://tile.openstreetmap.org/"
const ESRIPrefix = "https://server.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
const ESRIBetaPrefix = "https://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/"
let SatellitePrefix = ESRIPrefix
let SAT_MODE = "🛰"
let MAPNIK_MODE = "🗺️"
let currentTilesMode = MAPNIK_MODE;
let tilesObserver = undefined;
const OSMGPSPrefix = "https://gps.tile.openstreetmap.org/lines/"
const StravaPrefix = "https://content-a.strava.com/identified/globalheat/all/blue/"
let currentOverlayModeIsStrava = false;
let overlayTilesObserver = undefined;
function invertTilesMode(mode) {
return mode === "🛰" ? "🗺️" : "🛰";
}
function invertOverlayMode(mode) { // fixme
return currentOverlayModeIsStrava = !currentOverlayModeIsStrava;
}
function parseOSMTileURL(url) {
let match = url.match(new RegExp(`${OSMPrefix}(\\d+)\\/(\\d+)\\/(\\d+)\\.png`))
if (!match) {
return false
}
return {
x: match[2],
y: match[3],
z: match[1],
}
}
function parseOSMGPSTileURL(url) {
let match = url.match(new RegExp(`${OSMGPSPrefix}(\\d+)\\/(\\d+)\\/(\\d+)\\.png`))
if (!match) {
return false
}
return {
x: match[2],
y: match[3],
z: match[1],
}
}
function parseESRITileURL(url) {
let match = url.match(new RegExp(`${SatellitePrefix}(\\d+)\\/(\\d+)\\/(\\d+)`))
if (!match) {
return false
}
return {
x: match[3],
y: match[2],
z: match[1],
}
}
function parseStravaTileURL(url) {
let match = url.match(new RegExp(`${StravaPrefix}(\\d+)\\/(\\d+)\\/(\\d+)`))
if (!match) {
return false
}
return {
x: match[3],
y: match[2],
z: match[1],
}
}
const fetchBlobWithCache = (() => {
const cache = new Map();
return async url => {
if (cache.has(url)) {
return cache.get(url);
}
const promise = GM.xmlHttpRequest({
url: url,
responseType: "blob"
})
cache.set(url, promise);
try {
const result = await promise;
cache.set(url, result);
return result;
} catch (error) {
cache.delete(url);
throw error;
}
};
})();
let needStravaAuth = false;
async function bypassChromeCSPForImagesSrc(imgElem, url, isStrava=true) {
const res = await fetchBlobWithCache(url);
if (res.status !== 200) {
if (!GM_config.get("OverzoomForDataLayer")) {
return
}
if (isStrava && res.status === 403) {
if (!needStravaAuth) {
needStravaAuth = true;
alert("Need login in Strava for access to heatmap")
window.open("https://www.strava.com/maps/global-heatmap", "_blank")
}
return
}
if (getMap().getZoom() >= 18) {
const zoomStr = url.match(/(tile|org)\/([0-9]+)/)[2]
if (zoomStr) {
const tileZoom = parseInt(zoomStr)
console.log(tileZoom);
getMap().setZoom(Math.min(19, Math.min(getMap().getZoom(), tileZoom - 1)))
}
}
tileErrorHandler({currentTarget: imgElem}, url)
imgElem.src = "/dev/null";
return;
}
const blob = res.response;
const satTile = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
if (currentTilesMode === SAT_MODE || currentOverlayModeIsStrava) {
imgElem.src = satTile;
}
}
let blankSuffix = "";
function switchESRIbeta() {
const NewSatellitePrefix = SatellitePrefix === ESRIPrefix ? ESRIBetaPrefix : ESRIPrefix;
document.querySelectorAll(".leaflet-tile").forEach(i => {
if (i.nodeName !== 'IMG') {
return;
}
let xyz = parseESRITileURL(!needBypassSatellite ? i.src : i.getAttribute("real-url") ?? "")
if (!xyz) return
const newUrl = NewSatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix
if (!needBypassSatellite) {
i.src = newUrl;
} else {
bypassChromeCSPForImagesSrc(i, newUrl);
}
})
SatellitePrefix = NewSatellitePrefix
if (SatellitePrefix === ESRIBetaPrefix) {
getMap()?.attributionControl?.setPrefix("ESRI Beta")
} else {
getMap()?.attributionControl?.setPrefix("ESRI")
}
}
const needBypassSatellite = !isFirefox || GM_info.scriptHandler === "Violentmonkey";
function tileErrorHandler(e, url = null) {
if (!GM_config.get("OverzoomForDataLayer")) {
return
}
if (getMap().getZoom() >= 18) {
if (e?.currentTarget?.src?.endsWith("/dev/null") && !url) {
return
}
let tileURL = e?.currentTarget?.src?.match(/(tile|org)\/([0-9]+)/)
if (!tileURL) {
tileURL = e.currentTarget.getAttribute("real-url").match(/(tile|org)\/([0-9]+)/)
console.log(e.currentTarget.getAttribute("real-url"));
}
const zoomStr = tileURL[2]
if (zoomStr) {
const tileZoom = parseInt(zoomStr)
console.log(tileZoom);
getMap().setZoom(Math.min(19, Math.min(getMap().getZoom(), tileZoom - 1)))
}
}
}
function makeSatelliteURL(x, y, z) {
return SatellitePrefix + z + "/" + y + "/" + x + blankSuffix;
}
const retina = (window.retina || window.devicePixelRatio > 1);
function makeStravaURL(x, y, z) {
return StravaPrefix + z + "/" + x + "/" + y + (retina ? "@2x" : "") + ".png";
}
function makeOSMURL(x, y, z) {
return OSMPrefix + z + "/" + x + "/" + y + ".png";
}
function makeOSMGPSURL(x, y, z) {
return OSMGPSPrefix + z + "/" + x + "/" + y + ".png";
}
function switchTiles() {
if (tilesObserver) {
tilesObserver.disconnect();
}
currentTilesMode = invertTilesMode(currentTilesMode);
if (currentTilesMode === SAT_MODE) {
if (SatellitePrefix === ESRIBetaPrefix) {
getMap()?.attributionControl?.setPrefix("ESRI Beta")
} else {
getMap()?.attributionControl?.setPrefix("ESRI")
}
} else {
getMap()?.attributionControl?.setPrefix("")
}
document.querySelectorAll(".leaflet-tile").forEach(i => {
if (i.nodeName !== 'IMG') {
return;
}
if (currentTilesMode === SAT_MODE) {
let xyz = parseOSMTileURL(i.src)
if (!xyz) return
// unsafeWindow.L.DomEvent.off(i, "error") // todo добавить перехватчик 404
try {
i.onerror = tileErrorHandler
} catch { /* empty */
}
const newUrl = makeSatelliteURL(xyz.x, xyz.y, xyz.z)
if (!needBypassSatellite) {
i.src = newUrl;
} else {
i.setAttribute("real-url", newUrl);
}
if (needBypassSatellite) {
bypassChromeCSPForImagesSrc(i, newUrl);
}
if (i.complete && !needBypassSatellite) {
i.classList.add("no-invert");
} else {
i.addEventListener("load", e => {
e.target.classList.add("no-invert");
}, {once: true})
}
} else {
let xyz = parseESRITileURL(!needBypassSatellite ? i.src : i.getAttribute("real-url") ?? "")
if (!xyz) return
i.src = makeOSMURL(xyz.x, xyz.y, xyz.z);
if (i.complete) {
i.classList.remove("no-invert");
} else {
i.addEventListener("load", e => {
e.target.classList.remove("no-invert");
}, {once: true})
}
}
})
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeName !== 'IMG') {
return;
}
if (currentTilesMode === SAT_MODE) {
let xyz = parseOSMTileURL(node.src);
if (!xyz) return
// unsafeWindow.L.DomEvent.off(node, "error")
try {
node.onerror = tileErrorHandler
} catch { /* empty */
}
const newURL = makeSatelliteURL(xyz.x, xyz.y, xyz.z)
if (!needBypassSatellite) {
node.src = newURL;
} else {
node.src = "/dev/null";
}
node.setAttribute("real-url", newURL);
if (needBypassSatellite) {
bypassChromeCSPForImagesSrc(node, newURL)
}
if (node.complete) {
node.classList.add("no-invert");
} else {
node.addEventListener("load", e => {
e.target.classList.add("no-invert");
}, {once: true})
}
} else {
let xyz = parseESRITileURL(!needBypassSatellite ? node.src : node.getAttribute("real-url"))
if (!xyz) return
node.src = makeOSMURL(xyz.x, xyz.y, xyz.z);
if (node.complete) {
node.classList.remove("no-invert");
} else {
node.addEventListener("load", e => {
e.target.classList.remove("no-invert");
}, {once: true})
}
}
});
});
});
tilesObserver = observer;
observer.observe(document.body, {childList: true, subtree: true});
}
let osmTilesObserver = undefined;
function bypassCaches() {
if (osmTilesObserver) {
osmTilesObserver.disconnect();
}
document.querySelectorAll(".leaflet-tile").forEach(i => {
if (i.nodeName !== 'IMG') {
return;
}
let xyz = parseOSMTileURL(i.src)
if (!xyz) return
const newUrl = makeOSMURL(xyz.x, xyz.y, xyz.z)
i.src = newUrl + "?bypassCache=" + new Date().getUTCSeconds();
// fetch(newUrl, intoPage({cache: "reload"}))
// const img = new Image()
// img.src = newUrl + "?bypassCache=" + new Date().getUTCSeconds();
// img.onload = () => {
// i.src = img.src
// }
})
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeName !== 'IMG') {
return;
}
let xyz = parseOSMTileURL(node.src)
if (!xyz) return
const newUrl = makeOSMURL(xyz.x, xyz.y, xyz.z)
node.src = newUrl + "?bypassCache=" + new Date().getUTCSeconds();
// const img = new Image()
// img.src = newUrl + "?bypassCache=" + new Date().getUTCSeconds();
// img.onload = () => {
// node.src = img.src
// }
});
});
});
osmTilesObserver = observer;
observer.observe(document.body, {childList: true, subtree: true});
}
function switchOverlayTiles() {
if (overlayTilesObserver) {
overlayTilesObserver.disconnect();
}
currentOverlayModeIsStrava = invertOverlayMode(currentOverlayModeIsStrava);
if (currentOverlayModeIsStrava) {
getMap()?.attributionControl?.setPrefix("Strava")
} else {
getMap()?.attributionControl?.setPrefix("")
}
document.querySelectorAll(".leaflet-tile").forEach(i => {
if (i.nodeName !== 'IMG') {
return;
}
if (currentOverlayModeIsStrava) {
let xyz = parseOSMGPSTileURL(i.src)
if (!xyz) return
// unsafeWindow.L.DomEvent.off(i, "error") // todo добавить перехватчик 404
try {
i.onerror = tileErrorHandler
} catch { /* empty */
}
const newUrl = makeStravaURL(xyz.x, xyz.y, xyz.z)
i.setAttribute("real-url", newUrl);
bypassChromeCSPForImagesSrc(i, newUrl, true);
if (i.complete) {
i.classList.add("no-invert");
} else {
i.addEventListener("load", e => {
e.target.classList.add("no-invert");
}, {once: true})
}
} else {
let xyz = parseStravaTileURL(i.getAttribute("real-url") ?? "")
if (!xyz) return
i.src = makeOSMGPSURL(xyz.x, xyz.y, xyz.z);
if (i.complete) {
i.classList.remove("no-invert");
} else {
i.addEventListener("load", e => {
e.target.classList.remove("no-invert");
}, {once: true})
}
}
})
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeName !== 'IMG') {
return;
}
if (currentOverlayModeIsStrava) {
let xyz = parseOSMGPSTileURL(node.src);
if (!xyz) return
// unsafeWindow.L.DomEvent.off(node, "error")
try {
node.onerror = tileErrorHandler
} catch { /* empty */
}
const newURL = makeStravaURL(xyz.x, xyz.y, xyz.z)
node.src = "/dev/null";
node.setAttribute("real-url", newURL);
bypassChromeCSPForImagesSrc(node, newURL, true)
if (node.complete) {
node.classList.add("no-invert");
} else {
node.addEventListener("load", e => {
e.target.classList.add("no-invert");
}, {once: true})
}
} else {
let xyz = parseStravaTileURL(node.getAttribute("real-url"))
if (!xyz) return
node.src = makeOSMGPSURL(xyz.x, xyz.y, xyz.z);
if (node.complete) {
node.classList.remove("no-invert");
} else {
node.addEventListener("load", e => {
e.target.classList.remove("no-invert");
}, {once: true})
}
}
});
});
});
overlayTilesObserver = observer;
observer.observe(document.body, {childList: true, subtree: true});
}
function addSatelliteLayers() {
const btnOnPane = document.createElement("span");
let btnOnNotePage = document.createElement("span");
if (!document.querySelector('.turn-on-satellite-from-pane')) {
const mapnikBtn = document.querySelector(".layers-ui label span")
if (mapnikBtn) {
if (!tilesObserver) {
btnOnPane.textContent = "🛰";
} else {
btnOnPane.textContent = invertTilesMode(currentTilesMode);
}
btnOnPane.style.cursor = "pointer";
btnOnPane.classList.add("turn-on-satellite-from-pane");
btnOnPane.title = "Switch between map and satellite images.\nAlso you press key S, press with Shift for ESRI beta"
mapnikBtn.appendChild(document.createTextNode("\xA0"));
mapnikBtn.appendChild(btnOnPane);
btnOnPane.onclick = (e) => {
e.stopImmediatePropagation()
enableOverzoom()
if (e.shiftKey) {
switchESRIbeta()
return
}
switchTiles();
btnOnNotePage.textContent = invertTilesMode(currentTilesMode);
btnOnPane.textContent = invertTilesMode(currentTilesMode);
}
}
}
if (!location.pathname.includes("/note")) return;
if (document.querySelector('.turn-on-satellite')) return true;
if (!document.querySelector("#sidebar_content h4")) {
return;
}
if (!tilesObserver) {
btnOnNotePage.textContent = "🛰";
} else {
btnOnNotePage.textContent = invertTilesMode(currentTilesMode);
}
btnOnNotePage.style.cursor = "pointer";
btnOnNotePage.classList.add("turn-on-satellite");
btnOnNotePage.title = "Switch between map and satellite images"
document.querySelectorAll("h4")[0].appendChild(document.createTextNode("\xA0"));
document.querySelectorAll("h4")[0].appendChild(btnOnNotePage);
btnOnNotePage.onclick = () => {
enableOverzoom()
switchTiles();
btnOnNotePage.textContent = invertTilesMode(currentTilesMode);
btnOnPane.textContent = invertTilesMode(currentTilesMode);
}
}
function setupSatelliteLayers() {
let timerId = setInterval(addSatelliteLayers, 100);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add resolve note button');
}, 3000);
addSatelliteLayers();
}
//</editor-fold>
function makeElementHistoryCompact() {
const shouldBeCompact = document.querySelector(".compact-toggle-btn").getAttribute("value") === "><";
const forToggle = Array.from(document.querySelectorAll("table.browse-tag-list"))
// workaround for https://github.com/deevroman/better-osm-org/pull/273#issuecomment-2830047660
forToggle.slice(0, 8).forEach((el) => {
el.classList.toggle("hide-non-modified-tags", shouldBeCompact)
})
setTimeout(() => {
forToggle.slice(8).forEach((el) => {
el.classList.toggle("hide-non-modified-tags", shouldBeCompact)
})
})
document.querySelectorAll(".empty-version").forEach((el) => {
el.classList.toggle("d-none", shouldBeCompact)
})
document.querySelectorAll(".preview-img-link img").forEach(i => {
i.classList.toggle("d-none", shouldBeCompact)
})
document.querySelector(".compact-toggle-btn").setAttribute("value", shouldBeCompact ? "<>" : "><")
document.querySelector(".compact-toggle-btn").innerHTML = shouldBeCompact ? expandModeSvg : compactModeSvg
}
/**
* @param {Event} e
* @param {string} text
*/
function copyAnimation(e, text) {
console.log(`Copying ${text} to clipboard was successful!`);
e.target.classList.add("copied");
setTimeout(() => {
e.target.classList.remove("copied");
e.target.classList.add("was-copied");
setTimeout(() => e.target.classList.remove("was-copied"), 300);
}, 300);
}
//<editor-fold desc="find deleted user in diffs" defaultstate="collapsed">
async function decompressBlob(blob) {
let ds = new DecompressionStream("gzip");
let decompressedStream = blob.stream().pipeThrough(ds);
return await new Response(decompressedStream).blob();
}
async function tryFindChangesetInDiffGZ(gzURL, changesetId) {
const diffGZ = await GM.xmlHttpRequest({
method: "GET",
url: gzURL,
responseType: "blob"
});
let blob = await decompressBlob(diffGZ.response);
let diffXML = await blob.text()
const diffParser = new DOMParser();
const doc = diffParser.parseFromString(diffXML, "application/xml");
return doc.querySelector(`osm changeset[id='${changesetId}']`)
}
async function parseBBB(target, url) {
const response = await GM.xmlHttpRequest({
method: "GET",
url: planetOrigin + "/replication/changesets/" + url,
});
const parser = new DOMParser();
const BBBHTML = parser.parseFromString(response.responseText, "text/html");
let a = Array.from(BBBHTML.querySelector("pre").childNodes).slice(2)
let x = 0;
let found = false;
for (x; x < a.length; x += 2) {
let d = new Date(a[x + 1].textContent.trim().slice(0, -1).trim())
if (target < d) {
found = true;
break
}
}
if (x === 0) {
return found ? [a[x].getAttribute("href"), a[x].getAttribute("href")] : false
} else {
return found ? [a[x].getAttribute("href"), a[x - 2].getAttribute("href")] : false
}
}
async function parseCCC(target, url) {
const response = await GM.xmlHttpRequest({
method: "GET",
url: planetOrigin + "/replication/changesets/" + url,
});
const parser = new DOMParser();
const CCCHTML = parser.parseFromString(response.responseText, "text/html");
let a = Array.from(CCCHTML.querySelector("pre").childNodes).slice(2)
let x = 0;
let found = false;
/**
* HTML format:
* xxx.ext datetime
* xxx.state.txt datetime <for new changesets>
* file.tmp datetime <sometimes>
* yyy.ext ....
*/
for (x; x < a.length; x += 2) {
if (!a[x].textContent.match(/^\d+\.osm\.gz$/)) {
continue
}
let d = new Date(a[x + 1].textContent
.trim().slice(0, -1).trim()
.split(" ").slice(0, -1).join(" ").trim() + ' UTC')
if (target <= d) {
found = true;
break
}
}
if (!found) {
return false
}
if (x + 2 >= a.length) {
return [a[x].getAttribute("href"), a[x].getAttribute("href")]
}
try {
// state files are missing in old diffs folders
if (a[x + 2].getAttribute("href")?.match(/^\d+\.osm\.gz$/)) {
return [a[x].getAttribute("href"), a[x + 2].getAttribute("href")]
}
} catch { /* empty */
}
if (x + 4 >= a.length) {
return [a[x].getAttribute("href"), a[x].getAttribute("href")]
}
return [a[x].getAttribute("href"), a[x + 4].getAttribute("href")]
}
async function checkBBB(AAA, BBB, targetTime, targetChangesetID) {
let CCC = await parseCCC(targetTime, AAA + BBB);
if (!CCC) {
return;
}
let gzURL = planetOrigin + "/replication/changesets/" + AAA + BBB;
let foundedChangeset = await tryFindChangesetInDiffGZ(gzURL + CCC[0], targetChangesetID)
if (!foundedChangeset) {
foundedChangeset = await tryFindChangesetInDiffGZ(gzURL + CCC[1], targetChangesetID)
}
return foundedChangeset
}
async function checkAAA(AAA, targetTime, targetChangesetID) {
let BBBs = await parseBBB(targetTime, AAA)
if (!BBBs) {
return
}
let foundedChangeset = await checkBBB(AAA, BBBs[0], targetTime, targetChangesetID);
if (!foundedChangeset) {
foundedChangeset = await checkBBB(AAA, BBBs[1], targetTime, targetChangesetID);
}
return foundedChangeset
}
// tests
// https://osm.org/way/488322838/history
// https://osm.org/way/74034517/history
// https://osm.org/relation/17425783/history
// https://osm.org/way/554280669/history
// https://osm.org/node/4122049406 (/replication/changesets/005/638/ contains .tmp files)
// https://osm.org/node/2/history (very hard)
async function findChangesetInDiff(e) {
e.preventDefault()
e.stopPropagation()
e.target.style.cursor = "progress"
let foundedChangeset;
try {
const match = location.pathname.match(/\/(node|way|relation)\/(\d+)/)
const [, type, objID] = match
if (type === "node") {
foundedChangeset = await getNodeViaOverpassXML(objID, e.target.datetime)
} else if (type === "way") {
foundedChangeset = await getWayViaOverpassXML(objID, e.target.datetime)
} else if (type === "relation") {
foundedChangeset = await getRelationViaOverpassXML(objID, e.target.datetime)
}
if (!foundedChangeset.getAttribute("user")) {
foundedChangeset = null
console.log("Loading via overpass failed. Try via diffs")
throw ""
}
} catch {
const response = await GM.xmlHttpRequest({
method: "GET",
url: planetOrigin + "/replication/changesets/",
});
const parser = new DOMParser();
const AAAHTML = parser.parseFromString(response.responseText, "text/html");
const targetTime = new Date(e.target.datetime)
targetTime.setSeconds(0)
const targetChangesetID = e.target.value;
let a = Array.from(AAAHTML.querySelector("pre").childNodes).slice(2).slice(0, -4)
a.push(...a.slice(-2))
let x = 0;
for (x; x < a.length - 2; x += 2) {
let d = new Date(a[x + 1].textContent.trim().slice(0, -1).trim())
if (targetTime < d) break
}
let AAAs;
if (x === 0) {
AAAs = [a[x].getAttribute("href"), a[x].getAttribute("href")]
} else {
AAAs = [a[x - 2].getAttribute("href"), a[x].getAttribute("href")]
}
foundedChangeset = await checkAAA(AAAs[0], targetTime, targetChangesetID);
if (!foundedChangeset) {
foundedChangeset = await checkAAA(AAAs[1], targetTime, targetChangesetID);
}
if (!foundedChangeset) {
alert(":(")
return
}
}
let userInfo = document.createElement("span")
userInfo.style.cursor = "pointer"
userInfo.style.background = "#fff181"
userInfo.title = "Click for copy username"
if (isDarkMode()) {
userInfo.style.color = "black"
}
userInfo.textContent = foundedChangeset.getAttribute("user")
function clickForCopy(e) {
e.preventDefault();
let id = e.target.innerText;
navigator.clipboard.writeText(id).then(() => copyAnimation(e, id));
}
userInfo.onclick = clickForCopy
e.target.before(document.createTextNode("\xA0"))
e.target.before(userInfo)
e.target.before(document.createTextNode("\xA0"))
let uid = document.createElement("span")
uid.style.background = "#9cff81"
uid.style.cursor = "pointer"
uid.title = "Click for copy user ID"
if (isDarkMode()) {
uid.style.color = "black"
}
uid.onclick = clickForCopy
uid.textContent = `${foundedChangeset.getAttribute("uid")}`
e.target.before(uid)
e.target.before(document.createTextNode("\xA0"))
const webArchiveLink = document.createElement("a")
webArchiveLink.textContent = "WebArchive"
webArchiveLink.target = "_blank"
webArchiveLink.href = "https://web.archive.org/web/*/https://www.openstreetmap.org/user/" + foundedChangeset.getAttribute("user")
e.target.before(webArchiveLink)
e.target.before(document.createTextNode("\xA0"))
e.target.remove()
}
//</editor-fold>
/**
* @param {number} lat1
* @param {number} lon1
* @param {number} lat2
* @param {number} lon2
*/
function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) {
function deg2rad(deg) {
return deg * (Math.PI / 180)
}
const R = 6371;
const dLat = deg2rad(lat2 - lat1);
const dLon = deg2rad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2)
;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function yetAnotherWizard(s) {
// const [k, v] = s.split("=")
if (s[0] === "[") {
return `nwr${s};`
} else if (s.match(/^(node|way|rel|nwr|nw|nr|wr)/)) {
return `${s}` + (s.slice(-1) === ";" ? "" : ";")
} else {
return `nwr[${s}];`
}
}
let searchResultBBOX = null;
async function processOverpassQuery(query) {
if (!query.length) return
await GM.setValue("lastOverpassQuery", query)
const bound = getMap().getBounds().wrap()
const bboxString = [bound.getSouth(), bound.getWest(), bound.getNorth(), bound.getEast()]
const bboxExpr = query[query.length - 1] !== "!" ? "[bbox:" + bboxString + "]" : ""
if (query[query.length - 1] === "!") {
query = query.slice(0, -1)
}
const prevTitle = document.title
const newTitle = "◴" + prevTitle
document.title = newTitle
try {
const overpassQuery = `[out:xml]${bboxExpr};
${yetAnotherWizard(query)}
//(._;>;);
out geom;
`
console.log(overpassQuery);
console.time("download overpass data")
const res = await GM.xmlHttpRequest({
// todo switcher
url: overpass_server.apiUrl + "/interpreter?" + new URLSearchParams({
data: overpassQuery
}),
responseType: "xml"
})
console.timeEnd("download overpass data")
const xml = new DOMParser().parseFromString(res.response, "text/xml");
const data_age = new Date(xml.querySelector("meta").getAttribute("osm_base"))
console.log(data_age);
getMap()?.invalidateSize()
const bbox = searchResultBBOX = combineBBOXes(Array.from(xml.querySelectorAll("bounds")).map(i => {
return {
min_lat: i.getAttribute("minlat"),
min_lon: i.getAttribute("minlon"),
max_lat: i.getAttribute("maxlat"),
max_lon: i.getAttribute("maxlon")
}
}))
// const points = []
Array.from(xml.querySelectorAll("node")).forEach(n => {
const lat = parseFloat(n.getAttribute("lat"))
const lon = parseFloat(n.getAttribute("lon"))
// points.push([lon, lat])
bbox.min_lat = min(bbox.min_lat, lat);
bbox.min_lon = min(bbox.min_lon, lon);
bbox.max_lat = max(bbox.max_lat, lat);
bbox.max_lon = max(bbox.max_lon, lon);
})
console.log(bbox)
if (bbox.min_lon === 10000000) {
alert("invalid query")
} else {
console.time("render overpass response")
fitBounds([[bbox.min_lat, bbox.min_lon], [bbox.max_lat, bbox.max_lon]])
cleanAllObjects()
getWindow().jsonLayer?.remove()
jsonLayer?.remove()
renderOSMGeoJSON(xml, true)
console.timeEnd("render overpass response")
let statusPrefix = ""
if (!xml.querySelector("node,way,relation")) {
statusPrefix += "Empty result"
}
if ((new Date().getTime() - data_age.getTime()) / 1000 / 60 > 5) {
if (statusPrefix === "") {
statusPrefix += "Currentless of the data: " + data_age.toLocaleDateString() + " " + data_age.toLocaleTimeString()
} else {
statusPrefix += " | " + "Currentless of the data: " + data_age.toLocaleDateString() + " " + data_age.toLocaleTimeString()
}
}
getMap()?.attributionControl?.setPrefix(statusPrefix)
/*
const centroid = [...points.map(i => turf.point(i)), ...Array.from(xml.querySelectorAll("way")).map(w => {
return turf.center(
turf.polygon([Array.from(w.querySelectorAll("nd")).map(n => {
return [
parseFloat(n.getAttribute("lon")),
parseFloat(n.getAttribute("lat")),
]
})])
)
})]
const voronoiPolygons = turf.voronoi(turf.featureCollection(centroid),
{
bbox: [bbox.min_lon, bbox.min_lat, bbox.max_lon, bbox.max_lat]
}
);
renderGeoJSONwrapper(voronoiPolygons)
getWindow().jsonLayer.bringToBack()
*/
}
} finally {
if (document.title === newTitle) {
document.title = prevTitle
}
}
}
function blurSearchField() {
if (document.querySelector("#sidebar #query") && !document.querySelector("#sidebar #query").getAttribute("blured")) {
document.querySelector("#sidebar #query").setAttribute("blured", "true")
document.querySelector("#sidebar #query").removeAttribute("autofocus")
document.activeElement?.blur()
}
}
function resetSearchFormFocus() {
if (!GM_config.get("ResetSearchFormFocus")) {
return;
}
blurSearchField();
}
// https://osm.org/node/12559772251
// https://osm.org/node/10588878341
function makePanoramaxValue(elem) {
if (!GM_config.get("ImagesAndLinksInTags")) return;
// extracting uuid
elem.innerHTML = elem.innerHTML.replaceAll(/(?<=(^|;))([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(&xyz=-?[0-9]+(\.[0-9]+)?\/-?[0-9]+(\.[0-9]+)?\/-?[0-9]+(\.[0-9]+)?)?/gi, function (match) {
const a = document.createElement("a")
a.textContent = match.replaceAll("&", "&")
a.classList.add("preview-img-link")
a.target = "_blank"
const browseSection = elem?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
const lat = browseSection?.querySelector(".latitude")?.textContent?.replace(",", ".")
const lon = browseSection?.querySelector(".longitude")?.textContent?.replace(",", ".")
a.href = "https://api.panoramax.xyz/#focus=pic&pic=" + match.replaceAll("&", "&") + (lat ? (`&map=16/${lat}/${lon}`) : "")
return a.outerHTML
})
elem.querySelectorAll('a.preview-img-link').forEach(a => {
const uuid = a.textContent.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)
const img = GM_addElement("img", {
src: `https://api.panoramax.xyz/api/pictures/${uuid}/sd.jpg`,
// crossorigin: "anonymous"
})
img.onerror = () => {
img.style.display = "none"
}
img.onload = () => {
img.style.width = "100%"
}
a.appendChild(img)
setTimeout(async () => {
const res = (await GM.xmlHttpRequest({
url: `https://api.panoramax.xyz/api/search?limit=1&ids=${uuid}`,
responseType: "json"
})).response
if (!res['error']) {
a.onmouseenter = () => {
const lat = res['features'][0]['geometry']["coordinates"][1]
const lon = res['features'][0]['geometry']["coordinates"][0]
const angle = parseFloat(res['features'][0]["properties"]["exif"]["Exif.GPSInfo.GPSImgDirection"])
showActiveNodeMarker(lat, lon, "#0022ff", true)
if (angle) {
drawRay(lat, lon, angle - 30, "#0022ff")
drawRay(lat, lon, angle + 30, "#0022ff")
}
}
}
})
})
elem.onclick = e => {
e.stopImmediatePropagation()
}
}
function drawRay(lat, lon, angle, color) {
const earthRadius = 6378137;
const rad = (angle * Math.PI) / 180;
const length = 7;
const latOffset = (length * Math.cos(rad)) / earthRadius;
const lonOffset = (length * Math.sin(rad)) / (earthRadius * Math.cos((lat * Math.PI) / 180));
showActiveWay([[lat, lon], [lat + (latOffset * 180) / Math.PI, lon + (lonOffset * 180) / Math.PI]], color, false, null, false)
}
// https://www.mapillary.com/dashboard/developers
const MAPILLARY_CLIENT_KEY = "MLY|23980706878196295|56711819158553348b8159429530d931"
const MAPILLARY_URL_PARAMS = new URLSearchParams({
access_token: MAPILLARY_CLIENT_KEY,
fields: "id,geometry,computed_geometry,compass_angle,computed_compass_angle,thumb_1024_url"
})
// https://osm.org/node/7417065297
// https://osm.org/node/6257534611
function makeMapillaryValue(elem) {
if (!GM_config.get("ImagesAndLinksInTags")) return;
elem.innerHTML = elem.innerHTML.replaceAll(/(?<=(^|;))([0-9]+)(&x=-?[0-9]+(\.[0-9]+)?&y=-?[0-9]+(\.[0-9]+)?&zoom=-?[0-9]+(\.[0-9]+)?)?/g, function (match) {
const a = document.createElement("a")
a.textContent = match.replaceAll("&", "&")
a.classList.add("preview-mapillary-img-link")
a.target = "_blank"
const browseSection = elem?.parentElement?.parentElement?.parentElement?.parentElement?.parentElement
const lat = browseSection?.querySelector(".latitude")?.textContent?.replace(",", ".")
const lon = browseSection?.querySelector(".longitude")?.textContent?.replace(",", ".")
a.href = `https://www.mapillary.com/app/?focus=photo${lat ? ("&lat=" + lat + "&lng=" + lon + "&z=16") : ""}&pKey=` + arguments[0].replaceAll("&", "&")
return a.outerHTML
})
setTimeout(async () => {
for (const a of elem.querySelectorAll('a.preview-mapillary-img-link')) {
const res = (await GM.xmlHttpRequest({
url: `https://graph.mapillary.com/${a.textContent.match(/[0-9]+/)}?${MAPILLARY_URL_PARAMS.toString()}`,
responseType: "json"
})).response
if (!res['error']) {
const img = GM_addElement("img", {
src: res['thumb_1024_url'],
alt: "image from Mapillary",
title: "Blue — position from GPS tracker\nOrange — estimated real postion"
// crossorigin: "anonymous"
})
img.onerror = () => {
img.style.display = "none"
}
img.onload = () => {
img.style.width = "100%"
}
a.appendChild(img)
a.onmouseenter = () => {
const lat = res['geometry']["coordinates"][1]
const lon = res['geometry']["coordinates"][0]
const angle = res["compass_angle"]
const computed_lat = res['computed_geometry']["coordinates"][1]
const computed_lon = res['computed_geometry']["coordinates"][0]
const computed_angle = res["computed_compass_angle"]
showActiveNodeMarker(lat, lon, "#0022ff", true)
showActiveNodeMarker(computed_lat, computed_lon, "#ee9209", false)
drawRay(lat, lon, angle - 30, "#0022ff")
drawRay(computed_lat, computed_lon, computed_angle - 25, "#ee9209")
drawRay(lat, lon, angle + 30, "#0022ff")
drawRay(computed_lat, computed_lon, computed_angle + 25, "#ee9209")
}
} else {
a.classList.add("broken-mapillary-link")
}
}
})
elem.onclick = e => {
e.stopImmediatePropagation()
}
}
function makeWikimediaCommonsValue(elem) {
if (!GM_config.get("ImagesAndLinksInTags")) return;
elem.querySelectorAll('a[href^="//commons.wikimedia.org/wiki/"]:not(.preview-img-link)').forEach(a => {
a.classList.add("preview-img-link")
setTimeout(async () => {
const wikimediaResponse = (await GM.xmlHttpRequest({
url: `https://en.wikipedia.org/w/api.php?` + new URLSearchParams({
action: "query",
iiprop: "url",
prop: "imageinfo",
titles: a.textContent,
format: "json"
}).toString(),
responseType: "json"
})).response
const img = GM_addElement("img", {
src: wikimediaResponse['query']['pages']["-1"]["imageinfo"][0]['url'],
// crossorigin: "anonymous"
})
img.style.width = "100%"
a.appendChild(img)
})
})
}
let buildingViewerIframe = null;
// example https://osm.org/node/6506618057
function makeLinksInTagsClickable() {
document.querySelectorAll(".browse-tag-list tr").forEach(row => {
const key = row.querySelector("th")?.textContent?.toLowerCase()
if (!key) return
const valueCell = row.querySelector("td .current-value-span") ? row.querySelector("td .current-value-span") : row.querySelector("td")
if (key === "fixme") {
valueCell.classList.add("fixme-tag")
} else if (key === "note") {
valueCell.classList.add("note-tag")
} else if (key.startsWith("panoramax")) {
if (!row.querySelector("td a")) {
makePanoramaxValue(valueCell)
}
} else if (key.startsWith("mapillary")) {
if (!row.querySelector("td a")) {
makeMapillaryValue(valueCell)
}
} else if (key === "xmas:feature" && !document.querySelector(".egg-snow-tag") || valueCell.textContent.includes("snow")) {
const curDate = new Date()
if (curDate.getMonth() === 11 && curDate.getDate() >= 18 || curDate.getMonth() === 0 && curDate.getDate() < 10) {
const snowBtn = document.createElement("span")
snowBtn.classList.add("egg-snow-tag")
snowBtn.textContent = " ❄️"
snowBtn.style.cursor = "pointer"
snowBtn.title = "better-osm-org easter egg"
snowBtn.addEventListener("click", (e) => {
e.target.style.display = "none"
runSnowAnimation()
}, {
once: true
})
document.querySelector(".browse-tag-list").parentElement.previousElementSibling.appendChild(snowBtn)
}
} else if (key === "wikimedia_commons") {
makeWikimediaCommonsValue(valueCell);
} else if (key === "direction" || key === "camera:direction") {
const coords = row.parentElement.parentElement.parentElement.parentElement.querySelector("span.latitude")
if (coords) {
const lat = coords.textContent.replace(",", ".")
const lon = coords.nextElementSibling.textContent.replace(",", ".")
const match = location.pathname.match(/(node|way|relation)\/(\d+)\/history\/(\d+)\/?$/)
if (match || document.querySelector(".browse-tag-list") === row.parentElement.parentElement) {
cleanObjectsByKey("activeObjects")
renderDirectionTag(parseFloat(lat), parseFloat(lon), valueCell.textContent, "#ff00e3")
}
row.onmouseenter = () => {
cleanObjectsByKey("activeObjects")
renderDirectionTag(parseFloat(lat), parseFloat(lon), valueCell.textContent, "#ff00e3")
}
}
} else if (key.startsWith("opening_hours") // https://github.com/opening-hours/opening_hours.js/blob/master/scripts/related_tags.txt
|| ["happy_hours", "delivery_hours", "smoking_hours", "collection_times", "service_times"].includes(key)) {
if (key !== "opening_hours:signed" && typeof opening_hours !== "undefined") {
try {
new opening_hours(valueCell.textContent, null, {tag_key: key});
valueCell.title = "opening_hours.js not found errors👍"
} catch (e) {
valueCell.title = e
valueCell.classList.add("fixme-tag")
}
}
} else if (["building", "building:part"].includes(key) || (key === "type" && valueCell.textContent === "building")) {
if (document.querySelector(".view-3d-link")) {
return;
}
const [, type, id] = location.pathname.match(/\/(way|relation)\/(\d+)/)
if (!type) {
return
}
if (type === "way" && ["building", "building:part"].includes(key) &&
!Array.from(document.querySelectorAll(".browse-tag-list tr th"))
.some(i => i.textContent.includes("level") || i.textContent.includes("height")
|| i.textContent.includes("roof") || i.textContent.includes("name")
)) {
return
}
injectCSSIntoOSMPage(`
#building-3d-view {
position: absolute !important;
height: 120% !important;
z-index: 9999 !important;
}
#contextMenuFor3DViewers {
position: absolute;
display: "block";
background: var(--bs-body-bg);
border: 1px solid var(--bs-border-color);
box-shadow: 0 2px 5px var(--bs-body-bg);
z-index: 1000;
padding: 5px 0;
border-radius: 4px;
min-width: 150px;
}
#contextMenuFor3DViewers div {
cursor: pointer;
display: flex;
}
#contextMenuFor3DViewers div:hover {
cursor: pointer;
background: var(--bs-secondary-bg);
}
#contextMenuFor3DViewers div a {
display: block;
padding-top: 6px;
padding-right: 12px;
padding-bottom: 6px;
padding-left: 6px;
}
#contextMenuFor3DViewers div .pin {
align-content: center;
padding-right: 8px;
}
#contextMenuFor3DViewers div .pin:hover {
cursor: pointer;
background: var(--bs-secondary-bg);
}
#contextMenuFor3DViewers div .pin {
display: none;
}
#contextMenuFor3DViewers div .pin + label {
align-self: center;
padding-left: 6px;
}
#contextMenuFor3DViewers div .pin:not(:checked) + label {
filter: grayscale(100%);
opacity: 20%;
cursor: pointer;
}
#contextMenuFor3DViewers div .pin:not(:checked) + label:hover {
filter: initial;
opacity: initial;
}
`)
const viewIn3D = document.createElement("span")
viewIn3D.classList.add("view-3d-link")
viewIn3D.textContent = " 🏯"
viewIn3D.style.cursor = "pointer"
viewIn3D.title = "Click for show embedded 3D Viewer.\nRight click for select viewer\nClick with CTRL for open viewer in new tab\nIn userscript setting you can set open in tab by default"
async function contextMenuHandler(e) {
const buildingViewer = await GM.getValue("3DViewer") ?? "OSM Building Viewer";
e.preventDefault();
const menu = document.createElement("div");
menu.id = "contextMenuFor3DViewers";
instancesOf3DViewers.forEach(i => {
const listItem = document.createElement("div");
const a = document.createElement("a");
const [x, y, z] = getCurrentXYZ();
a.href = i.makeURL({x, y, z, type, id});
a.textContent = i.name;
a.target = "_blank";
const pin = document.createElement("input")
pin.id = i.name
pin.type = "radio"
pin.classList.add("pin")
pin.setAttribute("name", "viewer-selector")
const pinLabel = document.createElement("label");
pinLabel.setAttribute("for", i.name)
pinLabel.classList.add("pin-label")
pinLabel.textContent = "📌"
pinLabel.title = "Set as default for click"
if (i.name === buildingViewer) {
pin.checked = true
pinLabel.title = "It's default viewer"
}
pin.onchange = async () => {
if (pin.checked) {
await GM.setValue("3DViewer", i.name)
}
}
listItem.appendChild(pin);
listItem.appendChild(pinLabel);
listItem.appendChild(a);
document.addEventListener("click", function fn(e) {
if (e.target.classList.contains("pin-label") || e.target.classList.contains("pin")) {
document.addEventListener("click", fn, {once: true});
return
}
menu.remove()
}, {once: true})
menu.appendChild(listItem);
});
menu.style.left = `${e.pageX - 30}px`;
menu.style.top = `${e.pageY}px`;
document.body.appendChild(menu);
}
viewIn3D.addEventListener("contextmenu", contextMenuHandler);
async function clickHandler(e) {
if (buildingViewerIframe) {
buildingViewerIframe.remove()
buildingViewerIframe = null
return
}
const [x, y, z] = getCurrentXYZ();
const buildingViewer = await GM.getValue("3DViewer") ?? "OSM Building Viewer";
const viewer = instancesOf3DViewers.find(i => i.name === buildingViewer)
const url = viewer.makeURL({x, y, z, type, id})
if (isMobile || e.ctrlKey || e.metaKey || e.which === 2 || GM_config.get("3DViewerInNewTab")) {
window.open(url, "_blank")
return
}
buildingViewerIframe = GM_addElement("iframe", {
src: url,
width: document.querySelector("#map").clientWidth,
height: "100%",
id: "building-3d-view"
})
document.querySelector("#map").before(buildingViewerIframe)
}
viewIn3D.addEventListener("click", clickHandler)
viewIn3D.addEventListener("auxclick", e => {
if (e.which !== 2) return;
clickHandler(e);
})
document.querySelector(".browse-tag-list").parentElement.previousElementSibling.appendChild(viewIn3D)
}
})
const tagsTable = document.querySelector(".browse-tag-list")
if (tagsTable) {
tagsTable.parentElement.previousElementSibling.title = tagsTable.querySelectorAll("tr th").length + " tags"
}
}
function addHistoryLink() {
if (!location.pathname.startsWith("/node")
&& !location.pathname.startsWith("/way")
&& !location.pathname.startsWith("/relation")
|| location.pathname.includes("/history")
) return;
if (document.querySelector('.history_button_class')) return true;
let versionInSidebar = document.querySelector("#sidebar_content h4 a")
if (!versionInSidebar) {
return
}
let a = document.createElement("a")
let curHref = document.querySelector("#sidebar_content h4 a").href.match(/(.*)\/(\d+)$/)
a.href = curHref[1]
a.textContent = "🕒"
a.title = "Click for open object history page\nOr press key H"
a.classList.add("history_button_class")
if (curHref[2] !== "1") {
versionInSidebar.after(a)
versionInSidebar.after(document.createTextNode("\xA0"))
}
resetSearchFormFocus();
makeTimesSwitchable();
if (GM_config.get("ResizableSidebar")) {
document.querySelector("#sidebar").style.resize = "horizontal"
}
makeLinksInTagsClickable()
makeHashtagsClickable()
shortOsmOrgLinks(document.querySelector(".browse-section p"))
setTimeout(() => {
injectCSSIntoOSMPage(`
table.browse-tag-list tr td[colspan="2"] {
background: var(--bs-body-bg) !important;
}
`);
}, 0)
}
//<editor-fold desc="render functions" defaultstate="collapsed">
// For WebStorm: Settings | Editor | Language Injections
// Places Patterns + jsLiteralExpression(jsArgument(jsReferenceExpression().withQualifiedName("injectJSIntoPage"), 0))
/**
* @param {string} text
*/
function injectJSIntoPage(text) {
GM_addElement("script", {
textContent: text
})
}
/**
* @param {string} text
*/
function injectCSSIntoOSMPage(text) {
if (GM_info.scriptHandler === "FireMonkey" || GM_info.scriptHandler === "Userscripts"
|| GM_info.scriptHandler === "Greasemonkey" || isSafari) {
document.querySelector("style").innerHTML += text;
if (!isSafari) {
document.querySelector("style").parentElement.appendChild(document.querySelector("style"))
}
} else {
return GM_addElement(document.head, "style", {
textContent: text,
});
}
}
/**
* @param {string} text
*/
function injectCSSIntoSimplePage(text) {
return GM_addElement(document.head, "style", {
textContent: text,
});
}
const layers = {
customObjects: [],
activeObjects: []
}
function intoPage(obj) {
return cloneInto(obj, getWindow())
}
function intoPageWithFun(obj) {
return cloneInto(obj, getWindow(), {cloneFunctions: true})
}
/**
* @name showWay
* @memberof unsafeWindow
* @param {[]} nodesList
* @param {string=} color
* @param {boolean} needFly
* @param {boolean} addStroke
*/
function showWay(nodesList, color = "#000000", needFly = false, addStroke = false) {
cleanCustomObjects()
const line = getWindow().L.polyline(
intoPage(nodesList.map(elem => getWindow().L.latLng(intoPage(elem)))),
intoPage({
color: color,
weight: 4,
clickable: false,
opacity: 1,
fillOpacity: 1
})
).addTo(getMap());
if (addStroke) {
line._path.classList.add("stroke-polyline")
}
layers["customObjects"].push(line);
if (needFly) {
if (nodesList.length) {
fitBounds(get4Bounds(line))
}
}
}
/**
* @name showWays
* @memberof unsafeWindow
* @param {[][]} ListOfNodesList
* @param {string=} layerName
* @param {string=} color
*/
function showWays(ListOfNodesList, layerName = "customObjects", color = "#000000") {
cleanObjectsByKey(layerName)
ListOfNodesList.forEach(nodesList => {
const line = getWindow().L.polyline(
intoPage(nodesList.map(elem => getWindow().L.latLng(intoPage(elem)))),
intoPage({
color: color,
weight: 4,
clickable: false,
opacity: 1,
fillOpacity: 1
})
).addTo(getMap());
layers[layerName].push(line);
})
}
/**
* @name displayWay
* @memberof unsafeWindow
* @param {[]} nodesList
* @param {boolean=} needFly
* @param {string=} color
* @param {number=} width
* @param {string|null=} infoElemID
* @param {string=null} layerName
* @param {string|null=} dashArray
* @param {string|null=} popupContent
* @param {boolean|null=} addStroke
* @param {boolean=} geometryCached
*/
function displayWay(nodesList, needFly = false, color = "#000000", width = 4, infoElemID = null, layerName = "customObjects", dashArray = null, popupContent = null, addStroke = null, geometryCached = false) {
if (!layers[layerName]) {
layers[layerName] = []
}
function bindPopup(line, popup) {
if (popup) return line.bindPopup(popup)
return line
}
const line = bindPopup(getWindow().L.polyline(
geometryCached ? nodesList : intoPage(nodesList),
intoPage({
color: color,
weight: width,
clickable: false,
opacity: 1,
fillOpacity: 1,
dashArray: dashArray
})
), popupContent).addTo(getMap());
layers[layerName].push(line);
if (needFly) {
getMap().flyTo(intoPage(line.getBounds().getCenter()), 18, intoPage({
animate: false,
duration: 0.5
}));
}
if (infoElemID) {
layers[layerName][layers[layerName].length - 1].on('click', cloneInto(function () {
const elementById = document.getElementById(infoElemID);
elementById?.scrollIntoView()
resetMapHover()
elementById?.parentElement?.parentElement?.classList.add("map-hover")
cleanObjectsByKey("activeObjects")
}, getWindow(), {cloneFunctions: true}))
}
if (addStroke) {
line._path.classList.add("stroke-polyline");
}
return line
}
/**
* @name showNodeMarker
* @memberof unsafeWindow
* @param {string|float} a
* @param {string|float} b
* @param {string=} color
* @param {string|null=null} infoElemID
* @param {string=} layerName
* @param {number=} radius
*/
function showNodeMarker(a, b, color = "#00a500", infoElemID = null, layerName = 'customObjects', radius = 5) {
const haloStyle = {
weight: 2.5,
radius: radius,
fillOpacity: 0,
color: color
};
const marker = getWindow().L.circleMarker(getWindow().L.latLng(a, b), intoPage(haloStyle)).addTo(getMap())
layers[layerName].push(marker);
if (infoElemID) {
marker.on('click', cloneInto(function () {
const elementById = document.getElementById(infoElemID);
elementById?.scrollIntoView()
resetMapHover()
elementById?.parentElement?.parentElement.classList?.add("map-hover")
}, getWindow(), {cloneFunctions: true}))
}
return marker
}
/**
* @name showActiveNodeMarker
* @memberof unsafeWindow
* @param {string} lat
* @param {string} lon
* @param {string} color
* @param {boolean=true} removeActiveObjects
* @param {number} radius
* @param {number=} weight
*/
function showActiveNodeMarker(lat, lon, color, removeActiveObjects = true, radius = 5, weight = 2.5) {
const haloStyle = {
weight: weight,
radius: radius,
fillOpacity: 0,
color: color
};
if (removeActiveObjects) {
cleanObjectsByKey("activeObjects")
}
layers["activeObjects"].push(getWindow().L.circleMarker(getWindow().L.latLng(lat, lon), intoPage(haloStyle)).addTo(getMap()));
}
/**
* @name showActiveWay
* @memberof unsafeWindow
* @param {[]} nodesList
* @param {string=} color
* @param {boolean=} needFly
* @param {string|null=} infoElemID
* @param {boolean=true} removeActiveObjects
* @param {number=} weight
* @param {string=} dashArray
*/
function showActiveWay(nodesList, color = "#ff00e3", needFly = false, infoElemID = null, removeActiveObjects = true, weight = 4, dashArray = null) {
const line = getWindow().L.polyline(
intoPage(nodesList.map(elem => intoPage(getWindow().L.latLng(intoPage(elem))))),
intoPage({
color: color,
weight: weight,
clickable: false,
opacity: 1,
fillOpacity: 1,
dashArray: dashArray
})
).addTo(getMap());
if (removeActiveObjects) {
cleanObjectsByKey("activeObjects")
}
layers["activeObjects"].push(line);
if (needFly) {
fitBounds(get4Bounds(line))
}
if (infoElemID) {
layers["activeObjects"][layers["activeObjects"].length - 1].on('click', cloneInto(function () {
const elementById = document.getElementById(infoElemID);
elementById?.scrollIntoView()
resetMapHover()
elementById.classList.add("map-hover")
}, getWindow(), {cloneFunctions: true}))
}
}
/**
* @name cleanObjectsByKey
* @param {string} key
* @memberof unsafeWindow
*/
function cleanObjectsByKey(key) {
if (layers[key]) {
layers[key]?.forEach(i => i.remove())
layers[key] = []
}
}
/**
* @name cleanCustomObjects
* @memberof unsafeWindow
*/
function cleanCustomObjects() {
layers["customObjects"].forEach(i => i.remove())
layers["customObjects"] = []
}
/**
* @name panTo
* @memberof unsafeWindow
* @param {string} lat
* @param {string} lon
* @param {number=} zoom
* @param {boolean=} animate
*/
function panTo(lat, lon, zoom = 18, animate = false) {
getMap().flyTo(intoPage([lat, lon]), zoom, intoPage({animate: animate}));
}
/**
* @name panInside
* @memberof unsafeWindow
* @param {string} lat
* @param {string} lon
* @param {boolean=} animate
* @param {[]=} padding
*/
function panInside(lat, lon, animate = false, padding = [0, 0]) {
getMap().panInside(intoPage([lat, lon]), intoPage({animate: animate, padding: padding}));
}
function get4Bounds(b) {
try {
return [
[b.getBounds().getSouth(), b.getBounds().getWest()],
[b.getBounds().getNorth(), b.getBounds().getEast()]
]
} catch {
console.error("Please, reload page")
}
}
/**
* @name fitBounds
* @memberof unsafeWindow
*/
function fitBounds(bound, maxZoom = 19) {
getMap().fitBounds(intoPageWithFun(bound), intoPage({maxZoom: maxZoom}));
}
/**
* @name fitBoundsWithPadding
* @memberof unsafeWindow
*/
function fitBoundsWithPadding(bound, padding, maxZoom = 19) {
getMap().fitBounds(intoPageWithFun(bound), intoPage({padding: [padding, padding], maxZoom: maxZoom}));
}
/**
* @name setZoom
* @memberof unsafeWindow
*/
function setZoom(zoomLevel) {
getMap().setZoom(zoomLevel);
}
function cleanAllObjects() {
for (let member in layers) {
layers[member].forEach((i) => {
i.remove();
})
layers[member] = []
}
}
//</editor-fold>
/**
*
* @type {Object.<string, AbortController>}
*/
let abortDownloadingControllers = {};
function getAbortController() {
if (abortDownloadingControllers[location.pathname]) {
return abortDownloadingControllers[location.pathname]
}
return abortDownloadingControllers[location.pathname] = new AbortController()
}
function abortPrevControllers(reason = null) {
console.log("abort prev controllers")
Object.entries(abortDownloadingControllers).forEach(([path, controller]) => {
if (path !== location.pathname) {
console.log("abort for", path)
controller.abort(reason)
delete abortDownloadingControllers[path]
}
})
}
/**
* @typedef {Object} ObjectVersion
* @property {number} version
* @property {number} id
* @property {boolean} visible
* @property {string} timestamp
*/
/**
* @typedef {Object} NodeVersion
* @extends ObjectVersion
* @property {number} version
* @property {number} id
* @property {number} changeset
* @property {number} uid
* @property {string} user
* @property {boolean} visible
* @property {string} timestamp
* @property {'node'|'way'|'relation'} type
* @property {float} lat
* @property {float} lon
* @property {Object.<string, string>=} tags
*/
/**
* @type {Object.<string, NodeVersion[]>}
*/
const nodesHistories = {}
/**
* @type {Object.<string, WayVersion[]>}
*/
const waysRedactedVersions = {}
/**
* @type {Object.<string, WayVersion[]>}
*/
const waysHistories = {}
/**
* @type {Object.<string, RelationVersion[]>}
*/
const relationsHistories = {}
const histories = {
node: nodesHistories,
way: waysHistories,
relation: relationsHistories
}
const fetchTextWithCache = (() => {
/**@type {Map<string, string | undefined | Promise<string | undefined> >}*/
const cache = new Map();
return async (url, init) => {
if (cache.has(url)) {
return cache.get(url);
}
const promise = fetch(url, init).then((res) => {
if (!res) console.trace()
if (res.status === 509) {
error509Handler(res)
} else {
return res.text()
}
});
cache.set(url, promise);
try {
const result = await promise;
cache.set(url, result);
return result;
} catch (error) {
cache.delete(url);
throw error;
}
};
})();
/**
*
* @type {Object.<number, {
* data: XMLDocument,
* nodesWithParentWays: Set<number>,
* nodesWithOldParentWays: Set<number>
* }>}
*/
const changesetsCache = {}
/**
* @param {string|number} id
*/
async function getChangeset(id) {
if (changesetsCache[id]) {
return changesetsCache[id];
}
const text = await fetchTextWithCache(osm_server.apiBase + "changeset" + "/" + id + "/download", {signal: getAbortController().signal});
const parser = new DOMParser();
const data = /** @type {XMLDocument} **/ parser.parseFromString(text, "application/xml");
return changesetsCache[id] = {
data: data,
nodesWithParentWays: new Set(Array.from(data.querySelectorAll("way > nd")).map(i => parseInt(i.getAttribute("ref")))),
nodesWithOldParentWays: new Set(Array.from(data.querySelectorAll("way:not([version='1']) > nd")).map(i => parseInt(i.getAttribute("ref"))))
}
}
/**
*
* @param {float} lat
* @param {float} lon
* @param {string} values
* @param {string} color
*/
function renderDirectionTag(lat, lon, values, color = "#ff00e3") {
const cardinalToAngle = {
"N": 0.0,
"north": 0.0,
"NNE": 22.0,
"NE": 45.0,
"ENE": 67.0,
"E": 90.0,
"east": 90.0,
"ESE": 112.0,
"SE": 135.0,
"SSE": 157.0,
"S": 180.0,
"south": 180.0,
"SSW": 202.0,
"SW": 225.0,
"WSW": 247.0,
"W": 270.0,
"west": 270.0,
"WNW": 292.0,
"NW": 315.0,
"NNW": 337.0,
}
values.split(";").forEach(angleStr => {
const angle = cardinalToAngle[angleStr] !== undefined ? cardinalToAngle[angleStr] : parseFloat(angleStr)
if (!isNaN(angle)) {
drawRay(lat, lon, angle - 30, color)
drawRay(lat, lon, angle + 30, color)
}
})
}
function setupNodeVersionView() {
const match = location.pathname.match(/\/node\/(\d+)\//);
if (match === null) return;
let nodeHistoryPath = []
document.querySelectorAll(".browse-node span.latitude").forEach(i => {
let lat = i.textContent.replace(",", ".")
let lon = i.nextElementSibling.textContent.replace(",", ".")
nodeHistoryPath.push([lat, lon])
i.parentElement.parentElement.onmouseenter = () => {
showActiveNodeMarker(lat, lon, "#ff00e3");
i.parentElement.parentElement.parentElement.parentElement.querySelectorAll(".browse-tag-list tr").forEach(row => {
const key = row.querySelector("th")?.textContent?.toLowerCase()
if (!key) return
if (key === "direction" || key === "camera:direction") {
renderDirectionTag(parseFloat(lat), parseFloat(lon), row.querySelector("td").textContent, "#ff00e3")
row.onmouseenter = () => {
renderDirectionTag(parseFloat(lat), parseFloat(lon), row.querySelector("td").textContent, "#ff00e3")
}
}
})
}
i.parentElement.parentElement.parentElement.parentElement.onclick = (e) => {
if (e.altKey) return;
if (e.target.tagName === "A" || e.target.tagName === "TIME" || e.target.tagName === "SUMMARY") {
return
}
panTo(lat, lon);
showActiveNodeMarker(lat, lon, "#ff00e3");
}
})
interceptMapManually().then(() => {
displayWay(cloneInto(nodeHistoryPath, unsafeWindow), false, "rgba(251,156,112,0.86)", 2);
})
}
/**
* @param {number[]} nodes
* @return {Promise<NodeVersion[][]>}
*/
async function loadNodesViaHistoryCalls(nodes) {
async function _do(nodes) {
const targetNodesHistory = []
for (const nodeID of nodes) {
if (nodesHistories[nodeID]) {
targetNodesHistory.push(nodesHistories[nodeID]);
} else {
const res = await fetch(osm_server.apiBase + "node" + "/" + nodeID + "/history.json", {signal: getAbortController().signal});
nodesHistories[nodeID] = (await res.json()).elements
targetNodesHistory.push(nodesHistories[nodeID]);
}
}
return targetNodesHistory
}
return (await Promise.all(arraySplit(nodes, 5).map(_do))).flat()
}
/**
* @param {number|string} nodeID
* @return {Promise<NodeVersion[]>}
*/
async function getNodeHistory(nodeID) {
if (nodesHistories[nodeID]) {
console.count("Node history hit")
return nodesHistories[nodeID];
} else {
console.count("Node history miss")
const res = await fetch(osm_server.apiBase + "node" + "/" + nodeID + "/history.json", {signal: getAbortController().signal});
return nodesHistories[nodeID] = (await res.json()).elements;
}
}
/**
* @typedef {Object} WayVersion
* @property {number} id
* @property {number} changeset
* @property {number} uid
* @property {string} user
* @property {number[]=} [nodes]
* @property {number} version
* @property {boolean} visible
* @property {string} timestamp
* @property {'node'|'way'|'relation'} type
* @property {Object.<string, string>=} [tags]
*/
/**
* @param {number|string} wayID
* @return {Promise<WayVersion[]>}
*/
async function getWayHistory(wayID) {
if (waysHistories[wayID]) {
return waysHistories[wayID];
} else {
const res = await fetch(osm_server.apiBase + "way" + "/" + wayID + "/history.json", {signal: getAbortController().signal});
return waysHistories[wayID] = (await res.json()).elements;
}
}
/**
* @param {string|number} wayID
* @param {number} version
* @param {string|number|null=} changesetID
* @return {Promise<[WayVersion, NodeVersion[][]]>}
*/
async function loadWayVersionNodes(wayID, version, changesetID = null) {
console.debug("Loading way", wayID, version)
const wayHistory = await getWayHistory(wayID)
const targetVersion = Array.from(wayHistory).find(v => v.version === version)
if (!targetVersion) {
throw `loadWayVersionNodes failed ${wayID}, ${version}`
}
if (!targetVersion.nodes || targetVersion.nodes.length === 0) {
return [targetVersion, []]
}
const notCached = targetVersion.nodes.filter(nodeID => !nodesHistories[nodeID])
console.debug("Not cached nodes histories for download:", notCached.length, "/", targetVersion.nodes)
if (notCached.length < 2 || osm_server === local_server) { // https://github.com/openstreetmap/openstreetmap-website/issues/5183
return [targetVersion, await loadNodesViaHistoryCalls(targetVersion.nodes)]
}
// todo batchSize должен быть динамический
// Максимальная длина урла 8213 символов.
// 400 взято с запасом, что для точки нужно 20 символов
// пример точки: 123456789012v1234,
const batchSize = 410
const lastVersions = []
const batches = []
for (let i = 0; i < notCached.length; i += batchSize) {
console.debug(`Batch #${i}/${notCached.length}`)
batches.push(notCached.slice(i, i + batchSize))
}
await Promise.all(batches.map(async (batch) => {
const res = await fetch(osm_server.apiBase + "nodes.json?nodes=" + batch.join(","), {signal: getAbortController().signal});
if (!res.ok) {
console.trace(res)
}
const nodes = (await res.json()).elements
lastVersions.push(...nodes)
nodes.forEach(n => {
if (n.version === 1) {
nodesHistories[n.id] = [n]
}
})
}))
const longHistoryNodes = lastVersions.filter(n => n?.version !== 1)
const lastVersionsMap = Object.groupBy(lastVersions, ({id}) => id)
console.debug("Nodes with multiple versions: ", longHistoryNodes.length);
if (longHistoryNodes.length === 0) {
return [targetVersion, targetVersion.nodes.map(nodeID => nodesHistories[nodeID])]
}
const queryArgs = [""]
const maxQueryArgLen = 8213 - (osm_server.apiBase.length + "nodes.json?nodes=".length)
for (const lastVersion of longHistoryNodes) {
for (let v = 1; v < lastVersion.version; v++) { // todo не нужно загружать версию, которая в текущем пакете правок (если уже успели его загрузить)
const arg = lastVersion.id + "v" + v
if (queryArgs[queryArgs.length - 1].length + arg.length + 1 < maxQueryArgLen) {
if (queryArgs[queryArgs.length - 1] === "") {
queryArgs[queryArgs.length - 1] += arg
} else {
queryArgs[queryArgs.length - 1] += "," + arg
}
} else {
queryArgs.push(arg)
}
}
}
// https://github.com/openstreetmap/openstreetmap-website/issues/5005
/**
* @type {NodeVersion[]}
*/
let versions = []
// console.debug(`w${wayID}v${version}`)
// console.groupCollapsed(`w${wayID}v${version}`)
await Promise.all(queryArgs.map(async args => {
const res = await fetch(osm_server.apiBase + "nodes.json?nodes=" + args, {signal: getAbortController().signal});
if (res.status === 404) {
console.log('%c Some nodes was hidden. Start slow fetching :(', 'background: #222; color: #bada55')
let newArgs = args.split(",").map(i => parseInt(i.match(/(\d+)v(\d+)/)[1]));
// это нарушает инвариант, что versions не содержит последней версии
// важно также сохранять отсортированность,
// иначе loadNodesViaHistoryCalls сделает внутри несколько вызовов для одной и той же точки
(await loadNodesViaHistoryCalls(newArgs)).forEach(i => {
versions.push(...i)
})
} else if (res.status === 414) {
console.error("hmm, the maximum length of the URI is incorrectly calculated")
console.trace();
} else {
if (!res.ok) {
console.trace(res)
}
versions.push(...(await res.json()).elements)
}
}))
// console.debug(`end w${wayID}v${version}`)
// console.groupEnd()
// из-за возможной ручной докачки историй, нужна дедупликация
const seen = {};
versions = versions.filter(function ({id: id, version: version}) {
return Object.prototype.hasOwnProperty.call(seen, [id, version]) ? false : (seen[[id, version]] = true);
});
Object.entries(Object.groupBy(versions, ({id}) => id)).forEach(([id, history]) => {
history.sort((a, b) => {
if (a.version < b.version) return -1
if (a.version > b.version) return 1;
return 0
})
if (history.length && history[history.length - 1].version !== lastVersionsMap[id][0].version) {
history.push(lastVersionsMap[id][0])
}
nodesHistories[id] = history
})
return [targetVersion, targetVersion.nodes.map(nodeID => nodesHistories[nodeID])]
}
/**
* @template {NodeVersion|WayVersion|RelationVersion} T
* @param {T[]} history
* @param {string} timestamp
* @param {boolean=} alwaysReturn
* @return {T|null}
*/
function searchVersionByTimestamp(history, timestamp, alwaysReturn = false) {
const targetTime = new Date(timestamp)
let cur = history[0]
if (targetTime < new Date(cur.timestamp) && !alwaysReturn) {
return null
}
for (const v of history) {
if (new Date(v.timestamp) <= targetTime) {
cur = v;
}
}
return cur
}
/**
* @template T
* @param {T[][]} objectList
* @param {string} timestamp
* @param {boolean=} alwaysReturn
* @return {T[]}
*/
function filterObjectListByTimestamp(objectList, timestamp, alwaysReturn = false) {
return objectList.map(i => searchVersionByTimestamp(i, timestamp, alwaysReturn))
}
async function sortWayNodesByTimestamp(wayID) {
/** @type {(NodeVersion|WayVersion)[]} */
const objectsBag = []
/** @type {Set<string>} */
const objectsSet = new Set()
for (const i of document.querySelectorAll(`.way-version-view`)) {
const [targetVersion, nodesHistory] = await loadWayVersionNodes(wayID, parseInt(i.getAttribute("way-version")));
objectsBag.push(targetVersion)
nodesHistory.forEach(v => {
if (v.length === 0) {
console.error(`${wayID}, v${parseInt(i.getAttribute("way-version"))} has node with empty history`)
}
const uniq_key = `${v[0].type} ${v[0].id}`
if (!objectsSet.has(uniq_key)) {
objectsBag.push(...v)
objectsSet.add(uniq_key)
}
})
}
objectsBag.sort((a, b) => {
if (a.timestamp < b.timestamp) return -1;
if (a.timestamp > b.timestamp) return 1;
if (a.type < b.type) return -1;
if (a.type > b.type) return 1;
return 0
})
return objectsBag;
}
/**
* @template T
* @param {T[]} history
* @return {Object.<number, T>}
*/
function makeObjectVersionsIndex(history) {
const wayVersionsIndex = {};
history.forEach(i => {
wayVersionsIndex[i.version] = i;
});
return wayVersionsIndex
}
/**
* @param {NodeVersion} v1
* @param {NodeVersion} v2
* @return {boolean}
*/
function locationChanged(v1, v2) {
return v1.lat !== v2.lat || v1.lon !== v2.lon;
}
/**
* @param {NodeVersion|WayVersion} v1
* @param {NodeVersion|WayVersion} v2
* @return {boolean}
*/
function tagsChanged(v1, v2) {
return JSON.stringify(v1.tags) !== JSON.stringify(v2.tags);
}
async function replaceDownloadWayButton(btn, wayID) {
const objectsBag = await sortWayNodesByTimestamp(wayID);
const wayVersionsIndex = makeObjectVersionsIndex(await getWayHistory(wayID));
/** @type {Object.<string, NodeVersion|WayVersion>}*/
const objectStates = {};
/** @type {Object.<string, [string, NodeVersion|WayVersion, NodeVersion|WayVersion]>} */
let currentChanges = {}
/**
* @param {string} key
* @param {NodeVersion|WayVersion} newVersion
*/
function storeChanges(key, newVersion) {
const prev = objectStates[key];
if (prev === undefined) {
currentChanges[key] = ["new", prev, newVersion]
} else {
if (locationChanged(prev, newVersion) && tagsChanged(prev, newVersion)) {
currentChanges[key] = ["new", prev, newVersion]
} else if (locationChanged(prev, newVersion)) {
currentChanges[key] = ["location", prev, newVersion]
} else if (tagsChanged(prev, newVersion)) {
currentChanges[key] = ["tags", prev, newVersion]
} else {
currentChanges[key] = ["", prev, newVersion]
}
}
}
/** @type {number|null} */
let currentChangeset = null
/** @type {string|null} */
let currentUser = null
/** @type {string|null} */
let currentTimestamp = null
/** @type {WayVersion}*/
let currentWayVersion = {version: 0, nodes: []}
let currentWayNodesSet = new Set()
function renderInterVersion() {
const currentNodes = [];
wayVersionsIndex[currentWayVersion.version].nodes.forEach(nodeID => {
currentNodes.push(objectStates[`node ${nodeID}`])
const uniq_key = `node ${nodeID}`
if (currentChanges[uniq_key] !== undefined) return;
const curV = objectStates[uniq_key]
if (curV) {
currentChanges[uniq_key] = ["", curV, curV]
} else {
console.warn(`${uniq_key} not found in states`)
}
});
const interVersionDiv = document.createElement("div")
interVersionDiv.setAttribute("way-version", "inter")
interVersionDiv.classList.add("browse-section")
const interVersionDivHeader = document.createElement("h4")
const interVersionDivAbbr = document.createElement("abbr")
interVersionDivAbbr.textContent = ['ru-RU', 'ru'].includes(navigator.language) ? "Промежуточная версия" : "Intermediate version"
interVersionDivAbbr.title = ['ru-RU', 'ru'].includes(navigator.language)
? "Произошли изменения тегов или координат точек в линии,\nкоторые не увеличили версию линии"
: "There have been changes to the tags or coordinates of nodes in the way that have not increased the way version"
interVersionDivHeader.appendChild(interVersionDivAbbr)
interVersionDiv.appendChild(interVersionDivHeader)
const p = document.createElement("p")
interVersionDiv.appendChild(p)
fetch(osm_server.apiBase + "changeset" + "/" + currentChangeset + ".json").then(async res => {
const jsonRes = await res.json();
/** @type {ChangesetMetadata} */
const changesetMetadata = jsonRes.changeset ? jsonRes.changeset : jsonRes.elements[0]
p.textContent = changesetMetadata.tags['comment'];
})
const ul = document.createElement("ul")
ul.classList.add("list-unstyled")
const li = document.createElement("li")
ul.appendChild(li)
const time = document.createElement("time")
time.setAttribute("datetime", currentTimestamp)
time.setAttribute("natural_text", currentTimestamp) // it should server side string :(
time.setAttribute("title", currentTimestamp) // it should server side string :(
time.textContent = (new Date(currentTimestamp).toISOString()).slice(0, -5) + "Z"
li.appendChild(time)
li.appendChild(document.createTextNode("\xA0"))
const user_link = document.createElement("a")
user_link.href = location.origin + "/user/" + currentUser
user_link.textContent = currentUser
li.appendChild(user_link)
li.appendChild(document.createTextNode("\xA0"))
const changeset_link = document.createElement("a")
changeset_link.href = location.origin + "/changeset/" + currentChangeset
changeset_link.textContent = "#" + currentChangeset
li.appendChild(changeset_link)
interVersionDiv.appendChild(ul)
const nodesDetails = document.createElement("details")
nodesDetails.classList.add("way-version-nodes")
nodesDetails.onclick = (e) => {
e.stopImmediatePropagation()
}
const summary = document.createElement("summary")
summary.textContent = currentNodes.length
summary.classList.add("history-diff-modified-tag")
nodesDetails.appendChild(summary)
const ulNodes = document.createElement("ul")
ulNodes.classList.add("list-unstyled")
currentNodes.forEach(i => {
if (i === undefined) {
console.trace()
console.log(currentNodes)
btn.style.background = "yellow"
btn.title = "Some nodes was hidden by moderators"
return
}
const nodeLi = document.createElement("li")
const div = document.createElement("div")
div.classList.add("d-flex", "gap-1")
const div2 = document.createElement("div")
div2.classList.add("align-self-center")
div.appendChild(div2)
const aHistory = document.createElement("a")
aHistory.classList.add("node")
aHistory.href = "/node/" + i.id + "/history"
aHistory.textContent = i.id
div2.appendChild(aHistory)
div2.appendChild(document.createTextNode(", "))
const aVersion = document.createElement("a")
aVersion.classList.add("node")
aVersion.href = "/node/" + i.id + "/history/" + i.version
aVersion.textContent = "v" + i.version
div2.appendChild(aVersion)
nodeLi.appendChild(div)
const curChange = currentChanges[`node ${i.id}`]
const nodesHistory = nodesHistories[i.id]
const tagsTable = processObject(div2, "node", curChange[1] ?? curChange[2], curChange[2], nodesHistory[nodesHistory.length - 1], nodesHistory)
setTimeout(async () => {
const nodesLinksInComments = document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="node/"]`)
await processObjectInteractions("", "node", {nodes: nodesLinksInComments}, div2, ...getPrevTargetLastVersions(...await getHistoryAndVersionByElem(div2)))
}, 0)
tagsTable.then((table) => {
if (nodeLi.classList.contains("tags-non-modified")) {
div2.appendChild(table)
}
// table.style.borderColor = "var(--bs-body-color)";
// table.style.borderStyle = "solid";
// table.style.borderWidth = "1px";
})
ulNodes.appendChild(nodeLi)
})
nodesDetails.appendChild(ulNodes)
interVersionDiv.appendChild(nodesDetails)
const tmpChangedNodes = Object.values(currentChanges).filter(i => i[2].type === "node")
if (tmpChangedNodes.every(i => i[0] === "tags")) {
interVersionDiv.classList.add("only-tags-changed")
}
const changedNodes = tmpChangedNodes.filter(i => i[0] !== "location")
interVersionDiv.onmouseenter = () => {
resetMapHover()
cleanAllObjects()
showWay(cloneInto(currentNodes, unsafeWindow), "#000000", false, darkModeForMap && isDarkMode())
currentNodes.forEach(node => {
if (node.tags && Object.keys(node.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(node.lat.toString(), node.lon.toString(), "rgb(161,161,161)", null, 'customObjects', 3)
}
})
changedNodes.forEach(i => {
if (i[0] === "") return
if (i[2].visible === false) {
if (i[1].visible !== false) {
showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, 'customObjects', 3)
}
} else if (i[0] === "new") {
if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
}
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
} else {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, 'customObjects', 3)
}
})
}
interVersionDiv.onclick = (e) => {
resetMapHover()
cleanAllObjects()
showWay(cloneInto(currentNodes, unsafeWindow), "#000000", e.isTrusted, darkModeForMap && isDarkMode())
currentNodes.forEach(node => {
if (node.tags && Object.keys(node.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(node.lat.toString(), node.lon.toString(), "rgb(161,161,161)", null, 'customObjects', 3)
}
})
changedNodes.forEach(i => {
if (i[0] === "") return
if (i[2].visible === false) {
if (i[1].visible !== false) {
showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, 'customObjects', 3)
}
} else if (i[0] === "new") {
if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
}
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
} else {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, 'customObjects', 3)
}
})
}
let insertBeforeThat = document.querySelector(`.browse-way[way-version="${currentWayVersion.version}"]`)
while (insertBeforeThat.previousElementSibling.getAttribute("way-version") === "inter") { // fixme O(n^2)
insertBeforeThat = insertBeforeThat.previousElementSibling
}
insertBeforeThat.before(interVersionDiv)
}
for (const it of objectsBag) {
console.debug(it);
const uniq_key = `${it.type} ${it.id}`
if (it.type === "node" && currentWayVersion.version > 0 && !currentWayNodesSet.has(it.id)) {
objectStates[uniq_key] = it
continue
}
if (it.changeset === currentChangeset) {
storeChanges(uniq_key, it) // todo split if new way version
} else if (currentChangeset === null) {
currentChangeset = it.changeset
currentUser = it.user
currentTimestamp = it.timestamp
storeChanges(uniq_key, it)
} else {
if (currentWayVersion.version !== 0) {
renderInterVersion()
}
currentChanges = {}
storeChanges(uniq_key, it)
currentChangeset = it.changeset
currentUser = it.user
currentTimestamp = it.timestamp
}
objectStates[uniq_key] = it
// для настоящей версии линии
if (it.type === "way") {
let forNodesReplace = document.querySelector(`.browse-way[way-version="${it.version}"]`)
if (Object.keys(currentChanges).length > 1 && forNodesReplace.classList?.contains("empty-version")) {
forNodesReplace.querySelector("summary")?.remove()
const div = document.createElement("div")
div.innerHTML = forNodesReplace.innerHTML
div.classList.add("browse-section")
div.classList.add("browse-way")
div.setAttribute("way-version", forNodesReplace.getAttribute("way-version"))
forNodesReplace.replaceWith(div)
forNodesReplace = div
}
currentWayVersion = it
currentWayNodesSet = new Set()
currentWayVersion.nodes?.forEach(nodeID => {
currentWayNodesSet.add(nodeID)
const uniq_key = `node ${nodeID}`
if (currentChanges[uniq_key] === undefined) {
const curV = objectStates[uniq_key]
if (curV) {
if (curV.version === 1 && currentWayVersion.changeset === curV.changeset) {
currentChanges[uniq_key] = ["new", emptyVersion, curV]
} else {
currentChanges[uniq_key] = ["", curV, curV]
}
} else {
console.warn(`${uniq_key} not found in states`)
}
}
})
if (forNodesReplace && currentWayVersion.nodes) {
const currentNodes = [];
const ulNodes = forNodesReplace.querySelector("details:not(.empty-version) ul")
ulNodes.parentElement.classList.add("way-version-nodes")
ulNodes.querySelectorAll("li").forEach(li => {
li.style.display = "none"
const id = li.querySelector("div div a").href.match(/node\/(\d+)/)[1]
currentNodes.push([li.querySelector("img"), objectStates[`node ${id}`]])
})
if (it.version !== 1) {
const changedNodes = Object.values(currentChanges).filter(i => i[2].type === "node" && i[0] !== "location" && i[0] !== "")
document.querySelector(`.browse-way[way-version="${it.version}"]`)?.addEventListener("mouseenter", () => {
changedNodes.forEach(i => {
if (i[2].visible === false) {
if (i[1].visible !== false) {
showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, 'customObjects', 3)
}
} else if (i[0] === "new") {
if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
}
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
} else {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, 'customObjects', 3)
}
})
})
document.querySelector(`.browse-way[way-version="${it.version}"]`)?.addEventListener("click", () => {
changedNodes.forEach(i => {
if (i[2].visible === false) {
if (i[1].visible !== false) {
showNodeMarker(i[1].lat.toString(), i[1].lon.toString(), "#ff0000", null, 'customObjects', 3)
}
} else if (i[0] === "new") {
if (i[2].tags && Object.keys(i[2].tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
}
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "#00a500", null, 'customObjects', 3)
} else {
showNodeMarker(i[2].lat.toString(), i[2].lon.toString(), "rgb(255,245,41)", null, 'customObjects', 3)
}
})
})
}
currentNodes.forEach(([img, i]) => {
if (i === undefined) {
console.trace()
console.log(currentNodes)
btn.style.background = "yellow"
btn.title = "Please try reload page.\nIf the error persists, a message about it in the better-osm-org repository"
forNodesReplace.classList.add("broken-version")
forNodesReplace.title = "Some nodes was hidden by moderators :\\"
forNodesReplace.style.cursor = "auto"
return
}
const nodeLi = document.createElement("li")
const div = document.createElement("div")
div.classList.add("d-flex", "gap-1")
const div2 = document.createElement("div")
div2.classList.add("align-self-center")
div.appendChild(div2)
div2.before(img.cloneNode(true))
const aHistory = document.createElement("a")
aHistory.classList.add("node")
aHistory.href = "/node/" + i.id + "/history"
aHistory.textContent = i.id
div2.appendChild(aHistory)
nodeLi.appendChild(div)
div2.appendChild(document.createTextNode(", "))
const aVersion = document.createElement("a")
aVersion.classList.add("node")
aVersion.href = "/node/" + i.id + "/history/" + i.version
aVersion.textContent = "v" + i.version
div2.appendChild(aVersion)
nodeLi.appendChild(div)
const curChange = currentChanges[`node ${i.id}`]
const nodesHistory = nodesHistories[i.id]
const tagsTable = processObject(div2, "node", curChange[1] ?? curChange[2], curChange[2], nodesHistory[nodesHistory.length - 1], nodesHistory)
setTimeout(async () => {
const nodesLinksInComments = document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="node/"]`)
await processObjectInteractions("", "node", {nodes: nodesLinksInComments}, div2, ...getPrevTargetLastVersions(...await getHistoryAndVersionByElem(div2)))
}, 0)
tagsTable.then((table) => {
if (nodeLi.classList.contains("tags-non-modified")) {
div2.appendChild(table)
}
// table.style.borderColor = "var(--bs-body-color)";
// table.style.borderStyle = "solid";
// table.style.borderWidth = "1px";
})
ulNodes.appendChild(nodeLi)
})
}
currentChanges = {}
currentChangeset = null
}
}
if (Object.entries(currentChanges).length) {
renderInterVersion()
}
document.querySelector("#sidebar_content h2").addEventListener("mouseleave", () => {
document.querySelector("#sidebar_content h2").onmouseenter = () => {
cleanAllObjects()
}
}, {
once: true
})
// making version filter
if (document.querySelectorAll('[way-version="inter"]').length > 20) {
const select = document.createElement("select")
select.id = "versions-filter"
select.title = "Filter for intermediate changes"
const allVersions = document.createElement("option")
allVersions.value = "all-versions"
allVersions.textContent = ['ru-RU', 'ru'].includes(navigator.language) ? "Все версии" : "All versions"
select.appendChild(allVersions)
const withGeom = document.createElement("option")
withGeom.value = "with-geom"
withGeom.textContent = ['ru-RU', 'ru'].includes(navigator.language) ? "Все изменения геометрии" : "With geometry changes"
withGeom.setAttribute("selected", "selected")
select.appendChild(withGeom)
const withoutInter = document.createElement("option")
withoutInter.value = "without-inter"
withoutInter.textContent = ['ru-RU', 'ru'].includes(navigator.language) ? "Без промежуточных" : "Without intermediate"
select.appendChild(withoutInter)
select.onchange = (e) => {
if (e.target.value === "all-versions") {
document.querySelectorAll('[way-version="inter"]').forEach(i => {
i.removeAttribute("hidden")
})
} else if (e.target.value === "with-geom") {
document.querySelectorAll('.only-tags-changed[way-version="inter"]').forEach(i => {
i.setAttribute("hidden", "true")
})
document.querySelectorAll('[way-version="inter"]:not(.only-tags-changed)').forEach(i => {
i.removeAttribute("hidden")
})
} else if (e.target.value === "without-inter") {
document.querySelectorAll('[way-version="inter"]').forEach(i => {
i.setAttribute("hidden", "true")
})
}
}
document.querySelectorAll('.only-tags-changed[way-version="inter"]').forEach(i => {
i.setAttribute("hidden", "true")
})
btn.after(select)
}
btn.remove()
}
async function showFullWayHistory(wayID) {
const btn = document.querySelector("#download-all-versions-btn")
try {
await replaceDownloadWayButton(btn, wayID)
} catch (err) {
console.error(err)
btn.title = "Please try reload page.\nIf the error persists, a message about it in the better-osm-org repository"
btn.style.background = "red"
btn.style.cursor = "auto"
}
}
function setupWayVersionView() {
const match = location.pathname.match(/\/way\/(\d+)\//);
if (match === null) return;
const wayID = match[1]
async function loadWayVersion(e, loadMore = true, needShowWay = true, needFly = false) {
const htmlElem = e.target ? e.target : e
htmlElem.style.cursor = "progress"
const version = parseInt(htmlElem.getAttribute("way-version"))
const [targetVersion, nodesHistory] = await loadWayVersionNodes(wayID, version);
const nodesList = filterObjectListByTimestamp(nodesHistory, targetVersion.timestamp)
if (nodesList.some(i => i === null)) {
htmlElem.parentElement.parentElement.classList.add("broken-version")
htmlElem.title = "Some nodes was hidden by moderators"
htmlElem.style.cursor = "auto"
} else {
if (needShowWay) {
cleanAllObjects()
showWay(cloneInto(nodesList, unsafeWindow), "#000000", needFly, darkModeForMap && isDarkMode())
nodesList.forEach(node => {
if (node.tags && Object.keys(node.tags).filter(k => k !== "created_by" && k !== "source").length > 0) {
showNodeMarker(node.lat.toString(), node.lon.toString(), "rgb(161,161,161)", null, 'customObjects', 3)
}
})
}
}
if (htmlElem.nodeName === "A") {
const versionDiv = htmlElem.parentNode.parentNode
versionDiv.onmouseenter = (e) => {
resetMapHover()
loadWayVersion(e);
}
versionDiv.onclick = async e => {
if (e.target.tagName === "A" || e.target.tagName === "TIME" || e.target.tagName === "SUMMARY") {
return
}
await loadWayVersion(versionDiv, true, true, true)
}
versionDiv.setAttribute("way-version", version.toString())
htmlElem.style.cursor = "pointer" // todo finally{}
htmlElem.setAttribute("hidden", "true")
// preload next
if (version !== 1) {
let prevVersionNum = version - 1;
while (prevVersionNum > 0) {
try {
console.log(`preloading v${prevVersionNum}`);
await loadWayVersionNodes(wayID, prevVersionNum)
console.log(`preloaded v${prevVersionNum}`);
break
} catch {
console.log(`Skip v${prevVersionNum}`)
prevVersionNum--;
}
}
const loadBtn = document.querySelector(`#sidebar_content a[way-version="${prevVersionNum}"]`)
if (loadMore && document.querySelector(`#sidebar_content a[way-version="${prevVersionNum}"]`)) {
const nodesCount = waysHistories[wayID].filter(v => v.version === prevVersionNum)[0].nodes?.length
if (!nodesCount || nodesCount <= 123) {
await loadWayVersion(loadBtn, true, false)
} else {
await loadWayVersion(loadBtn, false, false)
if (prevVersionNum > 1) {
console.log(`preloading2 v${prevVersionNum - 1}`);
await loadWayVersionNodes(wayID, (prevVersionNum - 1))
console.log(`preloaded v${prevVersionNum - 1}`);
}
}
}
}
} else {
try {
e.target.style.cursor = "auto"
} catch {
e.style.cursor = "auto"
}
}
}
document.querySelectorAll(".browse-way h4:nth-of-type(1) a").forEach(i => {
const version = i.href.match(/\/(\d+)$/)[1];
const btn = document.createElement("a")
btn.classList.add("way-version-view")
btn.textContent = "📥"
btn.style.cursor = "pointer"
btn.setAttribute("way-version", version)
// fixme mouseenter должен начинать загрузку в фоне
// но только при клике должна начинаться анимация
btn.addEventListener("mouseenter", loadWayVersion, {
once: true,
})
i.after(btn)
i.after(document.createTextNode("\xA0"))
})
const downloadAllVersionsBtn = document.createElement("a")
downloadAllVersionsBtn.id = "download-all-versions-btn"
downloadAllVersionsBtn.textContent = "⏬"
downloadAllVersionsBtn.style.cursor = "pointer"
downloadAllVersionsBtn.title = "Download all versions (with intermediate versions)"
downloadAllVersionsBtn.addEventListener("click", async () => {
downloadAllVersionsBtn.style.cursor = "progress"
for (const i of document.querySelectorAll(`.way-version-view:not([hidden])`)) {
try {
await loadWayVersion(i)
} catch (e) {
console.error(e)
console.log("redacted version")
}
}
if (GM_config.get("FullVersionsDiff")) {
console.time("full history")
addQuickLookStyles()
await showFullWayHistory(wayID)
console.timeEnd("full history")
}
}, {
once: true,
})
document.querySelector(".compact-toggle-btn")?.after(downloadAllVersionsBtn)
document.querySelector(".compact-toggle-btn")?.after(document.createTextNode("\xA0"))
}
/**
* @typedef {Object} RelationMember
* @property {number} ref
* @property {'node'|'way'|'relation'} type
* @property {string} role
*/
/**
* @typedef {Object} RelationVersion
* @property {number} id
* @property {number} changeset
* @property {number} uid
* @property {string} user
* @property {RelationMember[]} members
* @property {number} version
* @property {boolean} visible
* @property {string} timestamp
* @property {'node'|'way'|'relation'} type
* @property {Object.<string, string>=} tags
*/
/**
* @param {number|string} relationID
* @return {Promise<RelationVersion[]>}
*/
async function getRelationHistory(relationID) {
if (relationsHistories[relationID]) {
return relationsHistories[relationID];
} else {
const res = await fetch(osm_server.apiBase + "relation" + "/" + relationID + "/history.json");
if (res.status === 509) {
error509Handler(res)
} else {
return relationsHistories[relationID] = (await res.json()).elements;
}
}
}
const overpassCache = {}
const bboxCache = {}
const cachedRelationsGeometry = {}
/**
*
* @param {number} id
* @param {string} timestamp
* @param {boolean=true} cleanPrevObjects=true
* @param {string=} color=
* @param {string=} layer=
* @param {boolean=} addStroke
* @return {Promise<{}>}
*/
async function loadRelationVersionMembersViaOverpass(id, timestamp, cleanPrevObjects = true, color = "#000000", layer = "activeObjects", addStroke = null) {
console.time(`Render ${id} relation`)
console.log(id, timestamp)
async function getRelationViaOverpass(id, timestamp) {
if (overpassCache[[id, timestamp]]) {
return overpassCache[[id, timestamp]]
} else {
const res = await GM.xmlHttpRequest({
url: `${overpass_server.apiUrl}/interpreter?` + new URLSearchParams({
data: `
[out:json][date:"${timestamp}"];
relation(${id});
//(._;>;);
out geom;
`
}),
responseType: "json"
});
return overpassCache[[id, timestamp]] = res.response
}
}
const overpassGeom = await getRelationViaOverpass(id, timestamp)
console.log("Data downloaded")
if (cleanPrevObjects) {
cleanCustomObjects()
}
cleanObjectsByKey("activeObjects")
if (!layers[layer]) {
layers[layer] = []
}
// нужен видимо веш геометрии
// GC больно
let cache = cachedRelationsGeometry[[id, timestamp]];
if (!cache) {
let wayCounts = 0
const mergedGeometry = []
overpassGeom.elements[0]?.members?.forEach(i => {
if (i.type === "way") {
wayCounts++
if (i.geometry === undefined || !i.geometry.length) {
return
}
const nodesList = i.geometry.map(p => [p.lat, p.lon])
if (mergedGeometry.length === 0) {
mergedGeometry.push(nodesList)
} else {
const lastWay = mergedGeometry[mergedGeometry.length - 1]
const [lastLat, lastLon] = lastWay[lastWay.length - 1]
if (lastLat === nodesList[0][0] && lastLon === nodesList[0][1]) {
mergedGeometry[mergedGeometry.length - 1].push(...nodesList.slice(1))
} else {
mergedGeometry.push(nodesList)
}
}
} else if (i.type === "node") {
showNodeMarker(i.lat, i.lon, color, null, layer)
} else if (i.type === "relation") {
// todo
}
})
cache = cachedRelationsGeometry[[id, timestamp]] = mergedGeometry.map(i => intoPage(i))
console.log(`${cache.length}/${wayCounts} for render`)
} else {
overpassGeom.elements[0]?.members?.forEach(i => {
if (i.type === "node") {
showNodeMarker(i.lat, i.lon, color, null, layer)
}
})
}
cache.forEach(nodesList => {
displayWay(nodesList, false, color, 4, null, layer, null, null, addStroke, true)
})
console.timeEnd(`Render ${id} relation`)
function getBbox(id, timestamp) {
if (bboxCache[[id, timestamp]]) {
return bboxCache[[id, timestamp]]
}
const nodesBag = []
overpassGeom.elements[0]?.members?.forEach(i => {
if (i.type === "way") {
nodesBag.push(...i.geometry.map(p => {
return {lat: p.lat, lon: p.lon}
}))
} else if (i.type === "node") {
nodesBag.push({lat: i.lat, lon: i.lon})
} else {
// ну нинада пожалуйста
}
})
const relationInfo = {}
relationInfo.bbox = {
min_lat: 10000000, min_lon: 10000000, max_lat: -10000000, max_lon: -100000000,
}
for (const i of nodesBag) {
if (i?.lat) {
relationInfo.bbox.min_lat = min(relationInfo.bbox.min_lat, i.lat)
relationInfo.bbox.min_lon = min(relationInfo.bbox.min_lon, i.lon)
relationInfo.bbox.max_lat = max(relationInfo.bbox.max_lat, i.lat)
relationInfo.bbox.max_lon = max(relationInfo.bbox.max_lon, i.lon)
}
}
return bboxCache[[id, timestamp]] = relationInfo
}
console.log("relation loaded")
return getBbox(id, timestamp)
}
async function getNodeViaOverpassXML(id, timestamp) {
const res = await GM.xmlHttpRequest({
url: `${overpass_server.apiUrl}/interpreter?` + new URLSearchParams({
data: `
[out:xml][date:"${timestamp}"];
node(${id});
out meta;
`
}),
responseType: "xml"
});
return new DOMParser().parseFromString(res.response, "text/xml").querySelector("node")
}
async function getWayViaOverpassXML(id, timestamp) {
const res = await GM.xmlHttpRequest({
url: `${overpass_server.apiUrl}/interpreter?` + new URLSearchParams({
data: `
[out:xml][date:"${timestamp}"];
way(${id});
//(._;>;);
out meta;
`
}),
responseType: "xml"
});
return new DOMParser().parseFromString(res.response, "text/xml").querySelector("way")
}
async function getRelationViaOverpassXML(id, timestamp) {
const res = await GM.xmlHttpRequest({
url: `${overpass_server.apiUrl}/interpreter?` + new URLSearchParams({
data: `
[out:xml][date:"${timestamp}"];
relation(${id});
//(._;>;);
out meta;
`
}),
responseType: "xml"
});
return new DOMParser().parseFromString(res.response, "text/xml").querySelector("relation")
}
/**
* @typedef {{nodes: NodeVersion[][], ways: [WayVersion, NodeVersion[][]][], relations: RelationVersion[][]}}
* @name RelationMembersVersions
*/
/**
*
* @param {string|number} relationID
* @param {number} version
* @throws {string}
* @returns {Promise<{
* targetVersion: RelationVersion,
* membersHistory: RelationMembersVersions
* }>}
*/
async function loadRelationVersionMembers(relationID, version) {
console.debug("Loading relation", relationID, version)
const relationHistory = await getRelationHistory(relationID)
const targetVersion = relationHistory.filter(v => v.version === version)[0]
if (!targetVersion) {
throw `loadWayVersionNodes failed ${relationID}, ${version}`
}
/**
* @type {{nodes: NodeVersion[][], ways: [WayVersion, NodeVersion[][]][]|Promise<[WayVersion, NodeVersion[][]]>[], relations: RelationVersion[][]}}
*/
const membersHistory = {
nodes: [],
ways: [],
relations: []
}
for (const member of targetVersion.members ?? []) {
if (member.type === "node") {
const nodeHistory = await getNodeHistory(member.ref)
const targetTime = new Date(targetVersion.timestamp)
let targetWayVersion = nodeHistory[0]
nodeHistory.forEach(history => {
if (new Date(history.timestamp) <= targetTime) {
targetWayVersion = history;
}
})
membersHistory.nodes.push(targetWayVersion)
} else if (member.type === "way") {
async function loadWay() {
let wayHistory = await getWayHistory(member.ref);
const targetTime = new Date(targetVersion.timestamp)
let targetWayVersion = wayHistory[0]
wayHistory.forEach(history => {
if (new Date(history.timestamp) <= targetTime) {
targetWayVersion = history;
}
})
return await loadWayVersionNodes(member.ref, targetWayVersion.version)
}
membersHistory.ways.push(loadWay())
} else if (member.type === "relation") {
// TODO может нинада? :(
}
}
membersHistory.ways = await Promise.all(membersHistory.ways)
return {targetVersion: targetVersion, membersHistory: membersHistory}
}
function setupRelationVersionView() {
const match = location.pathname.match(/\/relation\/(\d+)\//);
if (match === null) return;
const relationID = match[1];
async function loadRelationVersion(e, showWay = true) {
const htmlElem = e.target ? e.target : e
htmlElem.style.cursor = "progress"
const version = parseInt(htmlElem.getAttribute("relation-version"))
console.time(`r${relationID} v${version}`)
const {
targetVersion: targetVersion,
membersHistory: membersHistory
} = await loadRelationVersionMembers(relationID, version);
console.timeEnd(`r${relationID} v${version}`)
if (showWay) {
cleanCustomObjects()
let hasBrokenMembers = false
membersHistory.nodes.forEach(n => {
showNodeMarker(n.lat, n.lon, "#000")
})
membersHistory.ways.forEach(([, nodesVersionsList]) => {
try {
const nodesList = nodesVersionsList.map(n => {
const {lat: lat, lon: lon} = searchVersionByTimestamp(n, targetVersion.timestamp)
return [lat, lon]
})
displayWay(cloneInto(nodesList, unsafeWindow), false, "#000000", 4, null, "customObjects", null, null, darkModeForMap && isDarkMode())
} catch {
hasBrokenMembers = true
// TODO highlight in member list
}
})
if (hasBrokenMembers) {
htmlElem.classList.add("broken-version")
if (htmlElem.parentElement?.parentElement.classList.contains("browse-section")) {
htmlElem.parentElement.parentElement.classList.add("broken-version")
}
}
}
if (htmlElem.nodeName === "A") {
const versionDiv = htmlElem.parentNode.parentNode
versionDiv.onmouseenter = loadRelationVersion
versionDiv.onclick = async (e) => {
if (e.target.tagName === "A" || e.target.tagName === "TIME" || e.target.tagName === "SUMMARY") {
return
}
await loadRelationVersion(e) // todo params
}
versionDiv.setAttribute("relation-version", version.toString())
htmlElem.style.cursor = "pointer" // todo finally{}
htmlElem.setAttribute("hidden", "true")
} else {
e.target.style.cursor = "auto"
}
}
document.querySelectorAll(".browse-relation h4:nth-of-type(1) a").forEach((i) => {
const version = i.href.match(/\/(\d+)$/)[1];
const btn = document.createElement("a")
btn.classList.add("relation-version-view")
btn.textContent = "📥"
btn.style.cursor = "pointer"
btn.setAttribute("relation-version", version)
btn.addEventListener("mouseenter", async e => {
await loadRelationVersion(e)
}, {
once: true,
})
i.after(btn)
i.after(document.createTextNode("\xA0"))
})
if (document.querySelectorAll(`.relation-version-view:not([hidden])`).length > 1) { // todo remove check after when would full history
const downloadAllVersionsBtn = document.createElement("a")
downloadAllVersionsBtn.id = "download-all-versions-btn"
downloadAllVersionsBtn.textContent = "⏬"
downloadAllVersionsBtn.style.cursor = "pointer"
downloadAllVersionsBtn.title = "Download all versions (with intermediate versions)"
downloadAllVersionsBtn.addEventListener("click", async e => {
downloadAllVersionsBtn.style.cursor = "progress"
for (const i of document.querySelectorAll(`.relation-version-view:not([hidden])`)) {
await loadRelationVersion(i)
}
e.target.remove()
}, {
once: true,
})
document.querySelector(".compact-toggle-btn")?.after(downloadAllVersionsBtn)
document.querySelector(".compact-toggle-btn")?.after(document.createTextNode("\xA0"))
}
}
// tests
// https://www.openstreetmap.org/relation/100742/history
function setupViewRedactions() {
// TODO дозагрузку нужно делать только если есть аргументы в URL?
// if (!location.pathname.includes("/node")) {
// return;
// }
if (document.getElementById("show-unredacted-btn")) {
return
}
let showUnredactedBtn = document.createElement("a")
showUnredactedBtn.id = "show-unredacted-btn"
showUnredactedBtn.textContent = ['ru-RU', 'ru'].includes(navigator.language) ? "Просмотр неотредактированной истории β" : "View Unredacted History β"
showUnredactedBtn.style.cursor = "pointer"
showUnredactedBtn.href = ""
showUnredactedBtn.onmouseenter = async () => {
resetMapHover()
}
showUnredactedBtn.onclick = async e => {
e.preventDefault()
showUnredactedBtn.style.cursor = "progress"
const type = location.pathname.match(/\/(node|way|relation)/)[1]
const objID = parseInt(location.pathname.match(/\/(node|way|relation)\/(\d+)/)[2]);
let id_prefix = objID;
if (type === "node") {
id_prefix = Math.floor(id_prefix / 10000);
} else if (type === "way") {
id_prefix = Math.floor(id_prefix / 1000);
} else if (type === "relation") {
id_prefix = Math.floor(id_prefix / 10);
}
async function downloadArchiveData(url, objID, needUnzip = false) {
try {
const diffGZ = await GM.xmlHttpRequest({
method: "GET",
url: url,
responseType: "blob"
});
let blob = needUnzip ? await decompressBlob(diffGZ.response) : diffGZ.response;
let diffXML = await blob.text()
const diffParser = new DOMParser();
const doc = diffParser.parseFromString(diffXML, "application/xml");
return doc.querySelectorAll(`osm [id='${objID}']`)
} catch {
return null
}
}
const url = `https://raw.githubusercontent.com/osm-cc-by-sa/data/refs/heads/main/versions_affected_by_disagreed_users_and_all_after_with_redaction_period/${type}/${id_prefix}.osm` + (type === "relation" ? ".gz" : "")
const data = await downloadArchiveData(url, objID, type === "relation")
const keysLinks = new Map()
document.querySelectorAll(".browse-section table th a").forEach(a => {
keysLinks.set(a.textContent, a.href)
})
const valuesLinks = new Map()
document.querySelectorAll(".browse-section table td a").forEach(a => {
valuesLinks.set(a.textContent, a.href)
})
const versionPrefix = document.querySelector(`.browse-${type} h4`)?.textContent?.match(/(^.*#)/gms)?.at(0)
for (const elem of Array.from(document.getElementsByClassName("browse-section browse-redacted"))) {
const version = elem.textContent.match(/(\d+).*(\d+)/)[1]
console.log(`Downloading v${version}`)
elem.childNodes[0].textContent = elem.childNodes[0].textContent.match(/(\..*$)/gm)[0].slice(1)
let target;
try {
target = Array.from(data).find(i => i.getAttribute("version") === version)
} catch { /* empty */
}
if (!target) {
const prevDatetime = elem.previousElementSibling.querySelector("time").getAttribute("datetime")
const targetDatetime = new Date(new Date(prevDatetime).getTime() - 1).toISOString()
if (type === "node") {
target = await getNodeViaOverpassXML(objID, targetDatetime)
} else if (type === "way") {
target = await getWayViaOverpassXML(objID, targetDatetime)
} else if (type === "relation") {
target = await getRelationViaOverpassXML(objID, targetDatetime)
}
if (!target) {
console.error(`v${version} not founded`)
continue
}
// todo попробовать заменить на оператор timeline в overpass api
}
const h4 = document.createElement("h4")
h4.textContent = versionPrefix ?? "#"
const versionLink = document.createElement("a")
versionLink.textContent = version
versionLink.href = `/${type}/${objID}/history/${version}`
h4.appendChild(versionLink)
const comment = document.createElement("p")
comment.classList.add("fs-6", "overflow-x-auto")
setTimeout(async () => {
if (!target) return
const res = await fetch(osm_server.apiBase + "changeset" + "/" + target.getAttribute("changeset") + ".json",);
const jsonRes = await res.json();
comment.textContent = jsonRes.tags?.comment
}, 0)
const ul = document.createElement("ul")
ul.classList.add("list-unstyled")
const timeLi = document.createElement("li")
ul.appendChild(timeLi)
const time = document.createElement("time")
time.setAttribute("datetime", target.getAttribute("timestamp"))
time.setAttribute("natural_text", target.getAttribute("timestamp")) // it should server side string :(
time.setAttribute("title", target.getAttribute("timestamp")) // it should server side string :(
time.textContent = (new Date(target.getAttribute("timestamp")).toISOString()).slice(0, -5) + "Z"
timeLi.appendChild(time)
timeLi.appendChild(document.createTextNode(" "))
const user = document.createElement("a")
user.href = "/user/" + target.getAttribute("user")
user.textContent = target.getAttribute("user")
timeLi.appendChild(user)
const changesetLi = document.createElement("li")
const changeset = document.createElement("a")
changeset.href = "/changeset/" + target.getAttribute("changeset")
changeset.textContent = target.getAttribute("changeset")
changesetLi.appendChild(document.createTextNode(" #"))
changesetLi.appendChild(changeset)
ul.appendChild(changesetLi)
if (type === "node") {
const locationLi = document.createElement("li")
ul.appendChild(locationLi)
const locationA = document.createElement("a")
locationA.href = "/#map=18/" + target.getAttribute("lat") + "/" + target.getAttribute("lon")
const latSpan = document.createElement("span")
latSpan.classList.add("latitude")
latSpan.textContent = target.getAttribute("lat")
locationA.appendChild(latSpan)
locationA.appendChild(document.createTextNode(", "))
const lonSpan = document.createElement("span")
lonSpan.classList.add("longitude")
lonSpan.textContent = target.getAttribute("lon")
locationA.appendChild(lonSpan)
locationLi.appendChild(locationA)
}
const tags = document.createElement("div")
tags.classList.add("mb-3", "border", "border-secondary-subtle", "rounded", "overflow-hidden")
const table = document.createElement("table")
table.classList.add("mb-0", "browse-tag-list", "table", "align-middle")
const tbody = document.createElement("tbody")
table.appendChild(tbody)
target.querySelectorAll("tag").forEach(tag => {
const tr = document.createElement("tr")
const th = document.createElement("th")
th.classList.add("py-1", "border-secondary-subtle", "table-secondary", "fw-normal", "history-diff-modified-key")
const k = tag.getAttribute("k")
if (keysLinks.has(k)) {
const wikiLink = document.createElement("a")
wikiLink.textContent = k
wikiLink.href = keysLinks.get(k)
th.appendChild(wikiLink)
} else {
th.textContent = k
}
const td = document.createElement("td")
td.classList.add("py-1", "border-secondary-subtle", "border-start")
const v = tag.getAttribute("v")
if (valuesLinks.has(v)) {
const wikiLink = document.createElement("a")
wikiLink.textContent = v
wikiLink.href = valuesLinks.get(v)
td.appendChild(wikiLink)
} else {
td.textContent = v
}
tr.appendChild(th)
tr.appendChild(td)
tbody.appendChild(tr)
})
tags.appendChild(table)
elem.prepend(h4)
elem.appendChild(comment)
elem.appendChild(ul)
elem.appendChild(tags)
if (type === "way") {
const nodes = Array.from(target.querySelectorAll("nd")).map(i => i.getAttribute("ref"))
const nodesDetails = document.createElement("details")
const summary = document.createElement("summary")
summary.textContent = nodes.length
nodesDetails.appendChild(summary)
const ulNodes = document.createElement("ul")
ulNodes.classList.add("list-unstyled")
nodes.forEach(i => {
const nodeLi = document.createElement("li")
const a = document.createElement("a")
a.classList.add("node")
a.href = "/node/" + i
a.textContent = i
nodeLi.appendChild(a)
ulNodes.appendChild(nodeLi)
})
nodesDetails.appendChild(ulNodes)
elem.appendChild(nodesDetails)
} else if (type === "relation") {
const members = Array.from(target.querySelectorAll("member")).map(i => {
return {
ref: i.getAttribute("ref"),
type: i.getAttribute("type"),
role: i.getAttribute("role")
}
})
const membersDetails = document.createElement("details")
const summary = document.createElement("summary")
summary.textContent = members.length
membersDetails.appendChild(summary)
const ulMembers = document.createElement("ul")
ulMembers.classList.add("list-unstyled")
members.forEach(i => {
const memberLi = document.createElement("li")
const a = document.createElement("a")
a.classList.add(type)
a.href = "/node/" + i.ref
a.textContent = i.ref
memberLi.appendChild(a)
a.before(document.createTextNode(i.type + " "))
a.after(document.createTextNode(" " + i.role))
ulMembers.appendChild(memberLi)
})
membersDetails.appendChild(ulMembers)
elem.appendChild(membersDetails)
}
elem.classList.remove("hidden-version")
elem.classList.remove("browse-redacted")
elem.classList.add("browse-unredacted")
elem.classList.add("browse-node")
}
showUnredactedBtn.remove()
const classesForClean = ["history-diff-new-tag", "history-diff-modified-tag", "non-modified-tag", ".empty-version"]
classesForClean.forEach(className => {
Array.from(document.getElementsByClassName(className)).forEach(i => {
i.classList.remove(className)
})
})
const elementClassesForRemove = ["history-diff-deleted-tag-tr", "history-diff-modified-location", "find-user-btn", "way-version-view", "relation-version-view"]
elementClassesForRemove.forEach(elemClass => {
Array.from(document.getElementsByClassName(elemClass)).forEach(i => {
i.remove()
})
})
Array.from(["browse-node", "browse-way", "browse-relation"]).forEach(typeClass => {
Array.from(document.querySelectorAll("details." + typeClass)).forEach(i => {
i.querySelector("summary")?.remove()
const div = document.createElement("div")
div.innerHTML = i.innerHTML
div.classList.add("browse-section", typeClass)
i.replaceWith(div)
})
})
cleanAllObjects()
document.querySelector(".compact-toggle-btn")?.remove()
setTimeout(async () => {
await addDiffInHistory();
addCommentsCount()
}, 0)
}
if (!document.querySelector('#sidebar .secondary-actions a[href$="show_redactions=true"]')) {
document.querySelector("#sidebar .secondary-actions").appendChild(document.createElement("br"))
document.querySelector("#sidebar .secondary-actions").appendChild(showUnredactedBtn)
}
}
function extractChangesetID(s) {
return s.match(/\/changeset\/([0-9]+)/)[1];
}
function addCommentsCount() {
setTimeout(async () => {
const links = document.querySelectorAll(`#sidebar_content .browse-section li a[href^="/changeset"]:not(.comments-loaded):not(.comments-link)`)
await loadChangesetMetadatas(
Array.from(links).map(i => {
i.classList.add("comments-loaded")
return parseInt(extractChangesetID(i.getAttribute("href")))
})
)
links.forEach(i => {
const changesetID = extractChangesetID(i.getAttribute("href"))
const comments_count = changesetMetadatas[changesetID].comments_count
if (comments_count) {
const a = document.createElement("a")
a.classList.add("comments-link")
a.textContent = `${comments_count} 💬`
a.href = i.getAttribute("href")
a.tabIndex = 0
a.style.cursor = "pointer"
a.style.color = "var(--bs-body-color)"
i.after(a)
i.after(document.createTextNode("\xA0"))
setTimeout(async () => {
getChangesetComments(changesetID).then(res => {
res.forEach(comment => {
const shortText = shortOsmOrgLinksInText(comment["text"])
a.title += `${comment["user"]}:\n${shortText}\n\n`
})
a.title = a.title.trimEnd()
});
})
}
setTimeout(async () => {
await loadChangesetMetadata(changesetID)
Object.entries(changesetMetadatas[changesetID]?.["tags"] ?? {}).forEach(([k, v]) => { // тегов может не быть
if (k === "comment") return;
i.parentElement.title += `${k}: ${v}\n`
})
const user_link = i.parentElement.parentElement.querySelector(`a[href^="/user/"]`)
if (user_link) {
getCachedUserInfo(user_link.textContent).then((res) => {
user_link.title = `changesets_count: ${res['changesets']['count']}\naccount_created: ${res['account_created']}`
})
}
})
})
})
}
const compactModeSvg = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" height="17" width="17" viewBox="0 0 17 17"><path d="M15 12 l-5-4 5-4"></path><path d="M2 12 l 5-4 -5-4"></path></svg>'
const expandModeSvg = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" height="17" width="17" viewBox="0 0 17 17"><path d="M7 12 l-5-4 5-4"></path><path d="M10 12 l 5-4 -5-4"></path></svg>'
// hard cases:
// https://www.openstreetmap.org/node/1/history
// https://www.openstreetmap.org/node/2/history
// https://www.openstreetmap.org/node/9286365017/history
// https://www.openstreetmap.org/relation/72639/history
// https://www.openstreetmap.org/node/10173297169/history
// https://www.openstreetmap.org/relation/16022751/history
// https://www.openstreetmap.org/node/12084992837/history
// https://www.openstreetmap.org/way/1329437422/history
function addDiffInHistory() {
makeHeaderPartsClickable();
addHistoryLink();
if (document.querySelector("#sidebar_content table")) {
document.querySelector("#sidebar_content table").querySelectorAll("a").forEach(i => i.setAttribute("target", "_blank"));
}
if (!location.pathname.includes("/history")
|| location.pathname === "/history"
|| location.pathname.includes("/history/")
|| location.pathname.includes("/user/")
) return;
if (document.querySelector(".compact-toggle-btn")) {
return;
}
cleanAllObjects()
hideSearchForm();
// в хроме фокус не выставляется
document.querySelector("#sidebar").focus({focusVisible: false}) // focusVisible работает только в Firefox
document.querySelector("#sidebar").blur()
makeLinksInTagsClickable();
if (!location.pathname.includes("/user/")) {
let compactToggle = document.createElement("button")
compactToggle.title = "Toggle between full and compact tags diff.\nYou can also use the T key."
compactToggle.setAttribute("value", "><")
compactToggle.innerHTML = compactModeSvg
compactToggle.classList.add("compact-toggle-btn")
compactToggle.classList.add("btn", "btn-primary", "btn-sm")
compactToggle.onclick = makeElementHistoryCompact
let sidebar = document.querySelector("#sidebar_content h2")
if (!sidebar) {
return
}
sidebar.appendChild(document.createTextNode("\xA0"))
sidebar.appendChild(compactToggle)
}
const styleText = `
.compact-toggle-btn {
position: relative;
top: -2px;
cursor: pointer;
padding: 0px;
padding-left: 2px;
padding-right: 2px;
line-height: initial;
border-top: none;
border-bottom: none;
height: 1rem;
}
@media ${mediaQueryForWebsiteTheme} {
.compact-toggle-btn {
background: var(--bs-gray-800);
border-color: var(--bs-gray-800);
}
.compact-toggle-btn:hover {
background: var(--bs-gray-700);
border-color: var(--bs-gray-700);
}
.compact-toggle-btn:active {
background: var(--bs-gray-700) !important;
border-color: var(--bs-gray-700) !important;
}
}
.compact-toggle-btn svg {
display: flex;
}
.history-diff-new-tag {
background: rgba(17,238,9,0.6) !important;
}
.history-diff-modified-tag {
background: rgba(223,238,9,0.6) !important;
}
.history-diff-deleted-tag {
background: rgba(238,51,9,0.6) !important;
}
#sidebar_content div.map-hover {
background-color: rgba(223, 223, 223, 0.6);
}
.new-letter {
background: rgba(25, 223, 25, 0.6);
}
.deleted-letter {
background: rgba(255, 144, 144, 0.6);
}
@media ${mediaQueryForWebsiteTheme} {
.history-diff-new-tag {
background: rgba(4, 123, 0, 0.6) !important;
}
.history-diff-modified-tag {
color: black !important;
}
.history-diff-modified-tag a {
color: #052894;
}
.history-diff-deleted-tag {
color: lightgray !important;
background: rgba(238,51,9,0.4) !important;
}
summary.history-diff-modified-tag {
background: rgba(223,238,9,0.2) !important;
}
/*li.history-diff-modified-tag {*/
/* background: rgba(223,238,9,0.2) !important;*/
/*}*/
#sidebar_content div.map-hover {
background-color: rgb(14, 17, 19);
}
.new-letter {
background: rgba(25, 223, 25, 0.9);
}
.deleted-letter {
background: rgba(253, 83, 83, 0.8);
}
}
.non-modified-tag .empty-version {
}
.hidden-version, .hidden-h4 {
display: none;
}
table.browse-tag-list.hide-non-modified-tags > tbody > .non-modified-tag {
display: none;
}
#sidebar_content h2:not(.changeset-header){
font-size: 1rem;
}
h4 {
font-size: 1rem;
}
.copied {
background-color: rgba(9,238,9,0.6);
transition:all 0.3s;
}
.was-copied {
background-color: initial;
transition:all 0.3s;
}
@media ${mediaQueryForWebsiteTheme} {
.copied {
background-color: rgba(0,255,101,0.6);
transition:all 0.3s;
}
.was-copied {
background-color: initial;
transition:all 0.3s;
}
}
@media (max-device-width: 640px) and ${mediaQueryForWebsiteTheme} {
td.history-diff-new-tag::selection, /*td.history-diff-modified-tag::selection,*/ td.history-diff-deleted-tag::selection {
background: black;
}
th.history-diff-new-tag::selection, /*th.history-diff-modified-tag::selection,*/ th.history-diff-deleted-tag::selection {
background: black;
}
td a.history-diff-new-tag::selection, td a.history-diff-modified-tag::selection, td a.history-diff-deleted-tag::selection {
background: black;
}
th a.history-diff-new-tag::selection, th a.history-diff-modified-tag::selection, th a.history-diff-deleted-tag::selection {
background: black;
}
}
table.browse-tag-list tr td[colspan="2"] {
background: var(--bs-body-bg) !important;
}
.prev-value-span.hidden {
display: none !important;
}
` + (GM_config.get("ShowChangesetGeometry") ? `
.way-version-view:hover {
background-color: yellow;
}
[way-version]:hover {
background-color: rgba(244, 244, 244);
}
@media ${mediaQueryForWebsiteTheme} {
[way-version]:hover {
background-color: rgb(14, 17, 19);
}
}
[way-version].broken-version details:before {
color: var(--bs-body-color);
content: "Some nodes were hidden by moderators";
font-style: italic;
font-weight: normal;
font-size: small;
}
.relation-version-view:hover {
background-color: yellow;
}
[relation-version]:hover {
background-color: rgba(244, 244, 244);
}
@media ${mediaQueryForWebsiteTheme} {
[relation-version]:hover {
background-color: rgb(14, 17, 19);
}
}
[relation-version].broken-version details:before {
color: var(--bs-body-color);
content: "Some members were hidden by moderators";
font-style: italic;
font-weight: normal;
font-size: small;
}
@media ${mediaQueryForWebsiteTheme} {
path.stroke-polyline {
filter: drop-shadow(1px 1px 0 #7a7a7a) drop-shadow(-1px -1px 0 #7a7a7a) drop-shadow(1px -1px 0 #7a7a7a) drop-shadow(-1px 1px 0 #7a7a7a);
}
}
` : ``);
injectCSSIntoOSMPage(styleText)
let versions = [{tags: [], coordinates: "", wasModified: false, nodes: [], members: [], visible: true}];
// add/modification
let versionsHTML = Array.from(document.querySelectorAll(".browse-section.browse-node, .browse-section.browse-way, .browse-section.browse-relation"))
for (let ver of versionsHTML.toReversed()) {
let wasModifiedObject = false;
let version = ver.children[0].childNodes[1].href.match(/\/(\d+)$/)[1]
let kv = ver.querySelectorAll("tbody > tr") ?? [];
let tags = [];
let metainfoHTML = ver.querySelector('ul > li:nth-child(1)');
let changesetHTML = ver.querySelector('ul > li:nth-child(2)');
let changesetA = ver.querySelector('ul a[href^="/changeset"]');
const changesetID = changesetA.textContent
let time = Array.from(metainfoHTML.children).find(i => i.localName === "time")
if (Array.from(metainfoHTML.children).some(e => e.localName === "a" && e.href.includes("/user/"))) {
let a = Array.from(metainfoHTML.children).find(i => i.localName === "a")
metainfoHTML.innerHTML = ""
metainfoHTML.appendChild(time)
metainfoHTML.appendChild(document.createTextNode(" "))
metainfoHTML.appendChild(a)
metainfoHTML.appendChild(document.createTextNode(" "))
} else {
metainfoHTML.innerHTML = ""
metainfoHTML.appendChild(time)
let findBtn = document.createElement("span")
findBtn.classList.add("find-user-btn")
findBtn.title = "Try find deleted user"
findBtn.textContent = " 🔍 "
findBtn.value = changesetID
findBtn.datetime = time.dateTime
findBtn.style.cursor = "pointer"
findBtn.onclick = findChangesetInDiff
metainfoHTML.appendChild(findBtn)
}
changesetHTML.innerHTML = ''
let hashtag = document.createTextNode("#")
metainfoHTML.appendChild(hashtag)
metainfoHTML.appendChild(changesetA)
let visible = true
let coordinates = null
if (location.pathname.startsWith("/node")) {
coordinates = ver.querySelector("li:nth-child(3) > a")
if (coordinates) {
let locationHTML = ver.querySelector('ul > li:nth-child(3)');
let locationA = ver.querySelector('ul > li:nth-child(3) > a');
locationHTML.innerHTML = ''
locationHTML.appendChild(locationA)
} else {
visible = false
wasModifiedObject = true // because sometimes deleted object has tags
time.before(document.createTextNode("🗑 "))
}
} else if (location.pathname.startsWith("/way")) {
if (!ver.querySelector("details")) {
time.before(document.createTextNode("🗑 "))
}
} else if (location.pathname.startsWith("/relation")) {
if (!ver.querySelector("details")) {
time.before(document.createTextNode("🗑 "))
}
}
const valuesLinks = new Map()
document.querySelectorAll(".browse-section table td a").forEach(a => {
valuesLinks.set(a.textContent, a.href)
})
const showPreviousTagValue = GM_config.get("ShowPreviousTagValue", true)
kv.forEach(
(i) => {
let k = i.querySelector("th > a")?.textContent ?? i.querySelector("th")?.textContent;
i.querySelector("td .prev-value-span")?.remove()
if (i.querySelector("td .current-value-span")) {
i.querySelector("td .current-value-span").classList.remove("current-value-span")
}
let v = i.querySelector("td .wdplugin")?.textContent ?? i.querySelector("td")?.textContent;
if (k === undefined) {
// todo support multiple wikidata
// Human-readable Wikidata extension compatibility
return
}
if (k.includes("colour")) {
const tmpV = i.querySelector("td").cloneNode(true)
tmpV.querySelector("svg")?.remove()
v = tmpV.textContent
}
tags.push([k, v])
let lastTags = versions.slice(-1)[0].tags
let tagWasModified = false
if (!lastTags.some((elem) => elem[0] === k)) {
i.querySelector("th").classList.add("history-diff-new-tag")
i.querySelector("td").classList.add("history-diff-new-tag")
wasModifiedObject = tagWasModified = true
} else if (lastTags.some((elem) => elem[0] === k)) {
lastTags.forEach((el) => {
if (el[0] === k && el[1] !== v) {
i.querySelector("th").classList.add("history-diff-modified-key")
const valCell = i.querySelector("td")
if (isRTLLayout) {
valCell.dir = ""
}
valCell.classList.add("history-diff-modified-tag")
valCell.innerHTML = "<span class='current-value-span'>" + valCell.innerHTML + "</span>"
valCell.onclick = e => {
if (e.altKey) return
if (window.getSelection().type === "Range") return
if (e.target.nodeName === "A") return
e.preventDefault()
e.stopPropagation()
if (valCell.querySelector(".prev-value-span").classList.contains("hidden")) {
document.querySelectorAll(".prev-value-span").forEach(span => span.classList.remove("hidden"))
} else {
document.querySelectorAll(".prev-value-span").forEach(span => span.classList.add("hidden"))
}
}
const currentValueSpan = i.querySelector("td .current-value-span")
const prevValueSpan = document.createElement("span")
prevValueSpan.classList.add("prev-value-span")
const diff = arraysDiff(Array.from(el[1]).toReversed(), Array.from(v).toReversed(), 1).toReversed()
// todo unify with diff in changesets
// todo detect asci -> unicode or less strict cond
if (!i.querySelector("td a") && v.length > 1 && el[1].length > 1
&& (
diff.length === v.length && el[1].length === v.length
&& diff.reduce((cnt, b) => cnt + (b[0] !== b[1]), 0) === 1
|| diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[0] !== null), 0) === 0
|| diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[1] !== null), 0) === 0
)) {
let prevText = document.createElement("span")
let newText = document.createElement("span")
diff.forEach(c => {
if (c[0] !== c[1]) {
if (c[1]) {
const colored = document.createElement("span")
colored.classList.add("new-letter")
colored.textContent = c[1]
newText.appendChild(colored)
}
if (c[0]) {
const colored = document.createElement("span")
colored.classList.add("deleted-letter")
colored.textContent = c[0]
prevText.appendChild(colored)
}
} else {
prevText.appendChild(document.createTextNode(c[0]))
newText.appendChild(document.createTextNode(c[1]))
}
})
prevText.normalize()
newText.normalize()
prevValueSpan.appendChild(prevText)
prevValueSpan.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
newText.classList.add("current-value-span")
newText.style.display = "inline-block"
if (showPreviousTagValue) {
currentValueSpan.replaceWith(newText)
} else {
currentValueSpan.replaceWith(v)
}
prevText.dir = "auto"
newText.dir = "auto"
} else {
if (valuesLinks.has(el[1])) {
const valueLink = document.createElement("a")
valueLink.href = valuesLinks.get(el[1])
valueLink.target = "_blank"
valueLink.title = ""
valueLink.textContent = `${el[1]}`
prevValueSpan.appendChild(valueLink)
prevValueSpan.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
} else {
let prevText = document.createElement("span")
prevText.appendChild(document.createTextNode(el[1]))
let newText = document.createElement("span")
newText.appendChild(document.createTextNode(v))
prevValueSpan.appendChild(prevText)
prevValueSpan.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
newText.classList.add("current-value-span")
newText.style.display = "inline-block"
if (showPreviousTagValue) {
currentValueSpan.replaceWith(newText)
} else {
currentValueSpan.replaceWith(v)
}
}
}
currentValueSpan.setAttribute("value", v)
currentValueSpan.classList.add("current-value-span")
currentValueSpan.style.display = "inline-block"
prevValueSpan.style.display = "inline-block"
valCell.prepend(prevValueSpan)
valCell.removeAttribute("dir")
if (!showPreviousTagValue) {
prevValueSpan.classList.add("hidden")
}
i.title = `Click for hide previous value`;
// i.title = `was: "${el[1]}"`;
wasModifiedObject = tagWasModified = true
}
})
}
if (!tagWasModified) {
i.classList.add("non-modified-tag")
i.querySelector("th").classList.add("non-modified-tag")
i.querySelector("td").classList.add("non-modified-tag")
}
}
)
const lastCoordinates = versions.slice(-1)[0].coordinates
const lastVisible = versions.slice(-1)[0].visible
if (visible && coordinates && versions.length > 1 && coordinates.href !== lastCoordinates) {
if (lastCoordinates) {
const curLat = coordinates.querySelector(".latitude").textContent.replace(",", ".");
const curLon = coordinates.querySelector(".longitude").textContent.replace(",", ".");
const lastLat = lastCoordinates.match(/#map=.+\/(.+)\/(.+)$/)[1];
const lastLon = lastCoordinates.match(/#map=.+\/(.+)\/(.+)$/)[2];
const distInMeters = getDistanceFromLatLonInKm(
Number.parseFloat(lastLat),
Number.parseFloat(lastLon),
Number.parseFloat(curLat),
Number.parseFloat(curLon)
) * 1000;
const distTxt = document.createElement("span")
distTxt.textContent = `${distInMeters.toFixed(1)}m`
distTxt.classList.add("history-diff-modified-tag")
distTxt.classList.add("history-diff-modified-location")
coordinates.after(distTxt);
coordinates.after(document.createTextNode(" "));
}
wasModifiedObject = true
}
let childNodes = null
if (location.pathname.startsWith("/way")) {
childNodes = Array.from(ver.querySelectorAll("details ul.list-unstyled li")).map(el => el.textContent.match(/\d+/)[0])
let lastChildNodes = versions.slice(-1)[0].nodes
if (version > 1 &&
(childNodes.length !== lastChildNodes.length
|| childNodes.some((el, index) => lastChildNodes[index] !== childNodes[index]))) {
ver.querySelector("details > summary")?.classList.add("history-diff-modified-tag")
wasModifiedObject = true
}
ver.querySelector("details")?.removeAttribute("open")
} else if (location.pathname.startsWith("/relation")) {
childNodes = Array.from(ver.querySelectorAll("details ul.list-unstyled li")).map(el => el.textContent)
let lastChildMembers = versions.slice(-1)[0].members
if (version > 1 &&
(childNodes.length !== lastChildMembers.length
|| childNodes.some((el, index) => lastChildMembers[index] !== childNodes[index]))) {
// todo непонятно как подружить отображением редакшнов
ver.querySelector("details > summary")?.classList.add("history-diff-modified-tag")
wasModifiedObject = true
}
ver.querySelector("details")?.removeAttribute("open")
}
versions.push({
tags: tags,
coordinates: coordinates?.href ?? lastCoordinates,
wasModified: wasModifiedObject || (visible && !lastVisible),
nodes: childNodes,
members: childNodes,
visible: visible
})
ver.querySelectorAll("h4").forEach((el, index) => (index !== 0) ? el.classList.add("hidden-h4") : null)
if (tags.length === 1) { // fixme after adding locationzation
ver.title = tags.length + (['ru-RU', 'ru'].includes(navigator.language) ? " тег" : " tag")
} else if (tags.length < 10 && tags.length > 20 && ([2, 3, 4].includes(tags.length % 10))) {
ver.title = tags.length + (['ru-RU', 'ru'].includes(navigator.language) ? " тега" : " tags")
} else {
ver.title = tags.length + (['ru-RU', 'ru'].includes(navigator.language) ? " тегов" : " tags")
}
}
// deletion
Array.from(versionsHTML).forEach((x, index) => {
if (versionsHTML.length <= index + 1) return;
versions.toReversed()[index + 1].tags.forEach((tag) => {
let k = tag[0]
let v = tag[1]
if (!versions.toReversed()[index].tags.some((elem) => elem[0] === k)) {
let tr = document.createElement("tr")
tr.classList.add("history-diff-deleted-tag-tr")
let th = document.createElement("th")
th.textContent = k
th.classList.add("history-diff-deleted-tag", "py-1", "border-grey", "table-light", "fw-normal")
let td = document.createElement("td")
if (k.includes("colour")) {
td.innerHTML = `<svg width="14" height="14" class="float-end m-1"><title></title><rect x="0.5" y="0.5" width="13" height="13" fill="" stroke="#2222"></rect></svg>`
td.querySelector("svg rect").setAttribute("fill", v)
td.appendChild(document.createTextNode(v))
} else {
td.textContent = v
}
td.classList.add("history-diff-deleted-tag", "py-1", "border-grey", "table-light", "fw-normal")
tr.appendChild(th)
tr.appendChild(td)
if (!x.querySelector("tbody")) {
let tableDiv = document.createElement("table")
tableDiv.classList.add("mb-3", "border", "border-secondary-subtle", "rounded", "overflow-hidden")
let table = document.createElement("table")
table.classList.add("mb-0", "browse-tag-list", "table", "align-middle")
let tbody = document.createElement("tbody")
table.appendChild(tbody)
tableDiv.appendChild(table)
x.appendChild(tableDiv)
}
const firstNonDeletedTag = x.querySelector("th:not(.history-diff-deleted-tag)")?.parentElement
if (firstNonDeletedTag) {
firstNonDeletedTag.before(tr)
} else {
x.querySelector("tbody").appendChild(tr)
}
versions[versions.length - index - 1].wasModified = true
}
})
if (!versions[versions.length - index - 1].wasModified) {
let spoiler = document.createElement("details")
let summary = document.createElement("summary")
summary.textContent = x.querySelector("a").textContent
spoiler.innerHTML = x.innerHTML
spoiler.prepend(summary)
spoiler.classList.add("empty-version")
spoiler.classList.add("browse-" + location.pathname.match(/(node|way|relation)/)[1])
x.replaceWith(spoiler)
}
})
let hasRedacted = false
Array.from(document.getElementsByClassName("browse-section browse-redacted")).forEach(
x => {
x.classList.add("hidden-version")
hasRedacted = true
}
)
if (hasRedacted) {
try {
setupViewRedactions();
} catch (e) {
console.error(e)
}
}
makeElementHistoryCompact();
makeHashtagsClickable();
shortOsmOrgLinks(document.querySelector(".browse-section p"));
addCommentsCount();
setupNodeVersionView();
setupWayVersionView();
setupRelationVersionView();
}
function setupVersionsDiff(path) {
if (!path.includes("/history")
&& !path.startsWith("/node")
&& !path.startsWith("/way")
&& !path.startsWith("/relation")) {
return;
}
let timerId = setInterval(addDiffInHistory, 500);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop adding diff in history');
}, 25000);
addDiffInHistory();
}
function addRelationVersionView() {
const match = location.pathname.match(/relation\/(\d+)\/history\/(\d+)\/?$/)
if (!match) {
return
}
if (document.querySelector("#load-relation-version")) return
const btn = document.createElement("a")
btn.textContent = "📥"
btn.id = "load-relation-version"
btn.title = "Load relation version via Overpass API"
btn.tabIndex = 0
btn.style.cursor = "pointer"
async function clickHandler(e) {
if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
e.preventDefault()
} else if (e.type === "keypress") {
return
}
btn.style.cursor = "progress"
const match = location.pathname.match(/relation\/(\d+)\/history\/(\d+)\/?$/)
const id = parseInt(match[1])
const timestamp = document.querySelector("time").getAttribute("datetime")
try {
await loadRelationVersionMembersViaOverpass(id, timestamp)
} catch (e) {
btn.style.cursor = "pointer"
throw e
}
btn.style.visibility = "hidden"
}
btn.addEventListener("click", clickHandler)
btn.addEventListener("keypress", clickHandler)
document.querySelector(".browse-relation h4")?.appendChild(btn)
}
function setupRelationVersionViewer() {
const match = location.pathname.match(/relation\/(\d+)\/history\/(\d+)\/?$/)
if (!match) {
return
}
let timerId = setInterval(addRelationVersionView, 500);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop adding RelationVersionView');
}, 25000);
addRelationVersionView();
}
async function addHoverForNodesParents() {
if (!location.pathname.match(/^\/node\/(\d+)\/?$/)) {
return
}
document.querySelectorAll(`details [href^="/way/"]:not(.hover-added)`).forEach(elem => {
elem.classList.add("hover-added")
setTimeout(async () => {
const wayID = parseInt(elem.href.match(/way\/(\d+)/)[1])
const wayData = await loadWayMetadata(wayID)
const wayInfo = wayData.elements.find(i => i.id === wayID)
/*** @type {Map<string, NodeVersion>}*/
const nodesMap = new Map(Object.entries(Object.groupBy(wayData.elements.filter(i => i.type === "node"), i => i.id)).map(([k, v]) => [k, v[0]]));
const wayLi = elem?.parentElement?.parentElement?.parentElement;
wayLi.classList.add("node-last-version-parent");
wayLi.onmouseenter = () => {
const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon]);
showActiveWay(cloneInto(currentNodesList, unsafeWindow), darkModeForMap ? "#ff00e3" : "#000000");
}
wayLi.onclick = (e) => {
if (e.altKey) return;
if (e.target.tagName === "A") return;
const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon]);
showActiveWay(cloneInto(currentNodesList, unsafeWindow), darkModeForMap ? "#ff00e3" : "#000000", true);
}
wayLi.ondblclick = zoomToCurrentObject
if (wayInfo.tags) {
Object.entries(wayInfo.tags).forEach(([k, v]) => {
wayLi.title += `\n${k}=${v}`;
})
wayLi.title = wayLi.title.trim()
}
})
})
document.querySelector(".node-last-version-parent")?.parentElement?.parentElement?.querySelector("summary")?.addEventListener("mouseenter", () => {
cleanObjectsByKey("activeObjects")
})
document.querySelector(".secondary-actions")?.addEventListener("mouseenter", () => {
cleanObjectsByKey("activeObjects")
})
console.log("addHoverForWayNodes finished");
}
async function addHoverForWayNodes() {
if (!location.pathname.match(/^\/way\/(\d+)\/?$/)) {
return
}
const wayData = await loadWayMetadata();
if (!wayData) return
/*** @type {Map<string, NodeVersion>}*/
const nodesMap = new Map(Object.entries(Object.groupBy(wayData.elements.filter(i => i.type === "node"), i => i.id)).map(([k, v]) => [k, v[0]]));
document.querySelectorAll(`details [href^="/node/"]:not(.hover-added)`).forEach(elem => {
elem.classList.add("hover-added")
const nodeInfo = nodesMap.get(elem.href.match(/node\/(\d+)/)[1]);
const nodeLi = elem?.parentElement?.parentElement?.parentElement;
nodeLi.classList.add("way-last-version-node");
nodeLi.onmouseenter = () => {
showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), darkModeForMap ? "#ff00e3" : "#000000", true, 6, 3)
}
nodeLi.onclick = (e) => {
if (e.altKey) return;
panTo(nodeInfo.lat.toString(), nodeInfo.lon.toString());
showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), darkModeForMap ? "#ff00e3" : "#000000", true, 6, 3);
}
nodeLi.ondblclick = zoomToCurrentObject
if (nodeInfo.tags) {
Object.entries(nodeInfo.tags).forEach(([k, v]) => {
nodeLi.title += `\n${k}=${v}`;
})
nodeLi.title = nodeLi.title.trim()
}
})
document.querySelector(".way-last-version-node")?.parentElement?.parentElement?.querySelector("summary")?.addEventListener("mouseenter", () => {
cleanObjectsByKey("activeObjects")
})
document.querySelector(".secondary-actions")?.addEventListener("mouseenter", () => {
cleanObjectsByKey("activeObjects")
})
console.log("addHoverForWayNodes finished");
}
async function addHoverForRelationMembers() {
if (!location.pathname.match(/^\/relation\/(\d+)\/?$/)) {
return
}
const relationData = await loadRelationMetadata();
if (!relationData) return
/*** @type {Map<string, NodeVersion>}*/
const nodesMap = new Map(Object.entries(Object.groupBy(relationData.elements.filter(i => i.type === "node"), i => i.id)).map(([k, v]) => [k, v[0]]));
/*** @type {Map<string, WayVersion>}*/
const waysMap = new Map(Object.entries(Object.groupBy(relationData.elements.filter(i => i.type === "way"), i => i.id)).map(([k, v]) => [k, v[0]]));
/*** @type {Map<string, RelationVersion>}*/
const relationsMap = new Map(Object.entries(Object.groupBy(relationData.elements.filter(i => i.type === "relation"), i => i.id)).map(([k, v]) => [k, v[0]]));
document.querySelectorAll(`details [href^="/node/"]:not(.hover-added)`).forEach(elem => {
elem.classList.add("hover-added")
const nodeInfo = nodesMap.get(elem.href.match(/node\/(\d+)/)[1]);
const nodeLi = elem?.parentElement?.parentElement?.parentElement;
nodeLi.classList.add("relation-last-version-member");
nodeLi.onmouseenter = () => {
showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), darkModeForMap ? "#ff00e3" : "#000000", true, 6, 3)
}
nodeLi.onclick = (e) => {
if (e.altKey) return;
panTo(nodeInfo.lat.toString(), nodeInfo.lon.toString());
showActiveNodeMarker(nodeInfo.lat.toString(), nodeInfo.lon.toString(), darkModeForMap ? "#ff00e3" : "#000000", true, 6, 3);
}
nodeLi.ondblclick = zoomToCurrentObject
if (nodeInfo.tags) {
Object.entries(nodeInfo.tags).forEach(([k, v]) => {
nodeLi.title += `\n${k}=${v}`;
})
nodeLi.title = nodeLi.title.trim()
}
})
document.querySelectorAll(`details [href^="/way/"]:not(.hover-added)`).forEach(elem => {
elem.classList.add("hover-added")
const wayInfo = waysMap.get(elem.href.match(/way\/(\d+)/)[1]);
const wayLi = elem?.parentElement?.parentElement?.parentElement;
wayLi.classList.add("relation-last-version-member");
wayLi.onmouseenter = () => {
const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon]);
showActiveWay(cloneInto(currentNodesList, unsafeWindow), darkModeForMap ? "#ff00e3" : "#000000");
}
wayLi.onclick = (e) => {
if (e.altKey) return;
if (e.target.tagName === "A") return;
const currentNodesList = wayInfo.nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon]);
showActiveWay(cloneInto(currentNodesList, unsafeWindow), darkModeForMap ? "#ff00e3" : "#000000", true);
}
wayLi.ondblclick = zoomToCurrentObject
if (wayInfo.tags) {
Object.entries(wayInfo.tags).forEach(([k, v]) => {
wayLi.title += `\n${k}=${v}`;
})
wayLi.title = wayLi.title.trim()
}
})
document.querySelectorAll(`details:last-of-type [href^="/relation/"]:not(.hover-added)`).forEach(elem => {
elem.classList.add("hover-added")
const relationInfo = relationsMap.get(elem.href.match(/relation\/(\d+)/)[1]);
const relationLi = elem?.parentElement?.parentElement?.parentElement;
relationLi.classList.add("relation-last-version-member");
relationLi.onmouseenter = () => {
relationInfo.members.forEach(i => {
if (i.type === "node") {
showActiveNodeMarker(nodesMap[i.id].lat.toString(), nodesMap[i.id].lon.toString(), darkModeForMap ? "#ff00e3" : "#000000", true, 6, 3)
} else if (i.type === "way") {
const currentNodesList = waysMap[i.id].nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon]);
showActiveWay(cloneInto(currentNodesList, unsafeWindow), darkModeForMap ? "#ff00e3" : "#000000");
} else {
// todo
}
})
}
relationLi.onclick = (e) => {
if (e.altKey) return;
if (e.target.tagName === "A") return;
relationInfo.members.forEach(i => {
if (i.type === "node") {
if (e.altKey) return;
panTo(nodesMap[i.id].lat.toString(), nodesMap[i.id].lon.toString());
showActiveNodeMarker(nodesMap[i.id].lat.toString(), nodesMap[i.id].lon.toString(), darkModeForMap ? "#ff00e3" : "#000000", true, 6, 3);
} else if (i.type === "way") {
const currentNodesList = waysMap[i.id].nodes.map(i => [nodesMap.get(i.toString()).lat, nodesMap.get(i.toString()).lon]);
showActiveWay(cloneInto(currentNodesList, unsafeWindow), darkModeForMap ? "#ff00e3" : "#000000", true);
} else {
// todo
}
})
}
relationLi.ondblclick = zoomToCurrentObject
if (relationInfo.tags) {
Object.entries(relationInfo.tags).forEach(([k, v]) => {
relationLi.title += `\n${k}=${v}`;
})
relationLi.title = relationLi.title.trim()
}
})
document.querySelector(".relation-last-version-member")?.parentElement?.parentElement?.querySelector("summary")?.addEventListener("mouseenter", () => {
cleanObjectsByKey("activeObjects")
})
document.querySelector(".secondary-actions")?.addEventListener("mouseenter", () => {
cleanObjectsByKey("activeObjects")
})
console.log("addHoverForRelationMembers finished");
}
function makeHeaderPartsClickable() {
function makeElemCopyable(elem, url = "") {
if (/^\d+$/.test(elem.textContent)) {
elem.title = "Click for copy ID\nCtrl + click for copy url"
} else {
elem.title = "Click for copy"
}
elem.style.cursor = "pointer";
elem.classList.add("copyable")
elem.addEventListener("click", e => {
if (e.altKey) return;
if (window.getSelection().type === "Range") return;
if ((e.ctrlKey || e.metaKey) && url !== "") {
navigator.clipboard.writeText(url).then(() => copyAnimation(e, url));
} else {
navigator.clipboard.writeText(elem.textContent).then(() => copyAnimation(e, elem.textContent));
}
})
}
document.querySelectorAll("#sidebar_content h2 bdi:not(.copyable)").forEach(i => {
if (i.textContent.match(/^\d+$/)) {
const url = shortOsmOrgLinksInText(location.origin + location.pathname)
if (url.startsWith("http")) {
makeElemCopyable(i, url)
} else {
makeElemCopyable(i, "https://" + url)
}
} else {
makeElemCopyable(i)
}
})
document.querySelectorAll("#sidebar_content h2:not(:has(.copyable))").forEach(i => {
if (i.childNodes.length >= 1 && i.childNodes[0].nodeName === "#text") {
const match = /\d+/g.exec(i.childNodes[0].textContent);
if (!match) return;
i.childNodes[0].splitText(match.index + match[0].length)
i.childNodes[0].splitText(match.index)
const span = document.createElement("bdi")
span.textContent = i.childNodes[1].textContent
i.childNodes[1].replaceWith(span)
const url = shortOsmOrgLinksInText(location.origin + location.pathname)
if (url.startsWith("http")) {
makeElemCopyable(span, url)
} else {
makeElemCopyable(span, "https://" + url)
}
}
})
}
function makeVersionPageBetter() {
const match = location.pathname.match(/(node|way|relation)\/(\d+)(\/history\/(\d+)\/?$|\/?$)/)
if (!match) {
return
}
addCompactSidebarStyle()
if (!document.querySelector(".find-user-btn")) {
try {
const ver = document.querySelector(".browse-section.browse-node, .browse-section.browse-way, .browse-section.browse-relation")
const metainfoHTML = ver?.querySelector('ul > li:nth-child(1)');
if (metainfoHTML && !Array.from(metainfoHTML.children).some(e => e.localName === "a" && e.href.includes("/user/"))) {
const time = Array.from(metainfoHTML.children).find(i => i.localName === "time")
const changesetID = ver.querySelector('ul a[href^="/changeset"]').textContent;
metainfoHTML.lastChild.remove()
const findBtn = document.createElement("span")
findBtn.classList.add("find-user-btn")
findBtn.title = "Try find deleted user"
findBtn.textContent = " 🔍 "
findBtn.value = changesetID
findBtn.datetime = time.dateTime
findBtn.style.cursor = "pointer"
findBtn.onclick = findChangesetInDiff
metainfoHTML.appendChild(findBtn)
}
} catch { /* empty */
}
}
makeHeaderPartsClickable()
addHistoryLink()
makeLinksInTagsClickable()
makeHashtagsClickable();
makeTimesSwitchable()
shortOsmOrgLinks(document.querySelector(".browse-section p"));
addCommentsCount();
void addHoverForNodesParents();
void addHoverForWayNodes();
void addHoverForRelationMembers();
}
function setupMakeVersionPageBetter() {
const match = location.pathname.match(/(node|way|relation)\/(\d+)(\/history\/(\d+)\/?$|\/?$)/)
if (!match) {
return
}
let timerId = setInterval(makeVersionPageBetter, 500);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop adding MakeVersionPageBetter');
}, 2000);
makeVersionPageBetter();
}
// Модули должны стать классами
// - поддерживается всеми браузерами, в которых есть TM
// - изоляция функций и глобальных переменных
// - для модулей, которые внедряются черзе setInterval можно сохранить таймер, чтобы предотвратить дублирование вызовов
// - возможность сохранить результат внедрения
let quickLookInjectingStarted = false
let tagsOfObjectsVisible = true
// Perf test: https://osm.org/changeset/155712128
// Check way 695574090: https://osm.org/changeset/71014890
// Check deleted relation https://osm.org/changeset/155923052
// Heavy ways and deleted relation https://osm.org/changeset/153431079
// Downloading parents: https://osm.org/changeset/156331065
// Restored objects https://osm.org/changeset/156515722
// Check ways with version=1 https://osm.org/changeset/155689740
// Many changes in the coordinates of the intersections https://osm.org/changeset/156331065
// Deleted and restored objects https://osm.org/changeset/155160344
// Old edits with unusual objects https://osm.org/changeset/1000
// Parent ways only in future https://osm.org/changeset/156525401
// Restored tags https://osm.org/changeset/141362243
/**
* Get editorial prescription via modified Levenshtein distance finding algorithm
* @template T
* @param {T[]} arg_a
* @param {T[]} arg_b
* @param {number} one_replace_cost
* @return {[T, T][]}
*/
function arraysDiff(arg_a, arg_b, one_replace_cost = 2) {
let a = arg_a.map(i => JSON.stringify(i))
let b = arg_b.map(i => JSON.stringify(i))
const dp = []
for (let i = 0; i < a.length + 1; i++) {
dp[i] = new Uint32Array(b.length + 1);
}
for (let i = 0; i <= a.length; i++) {
dp[i][0] = i
}
for (let i = 0; i <= b.length; i++) {
dp[0][i] = i
}
const min = Math.min; // fuck Tampermonkey
// for some fucking reason every math.min call goes through TM wrapper code
// that is not optimised by the JIT compiler
if (arg_a.length && Object.prototype.hasOwnProperty.call(arg_a[0], "role")) {
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const del_cost = dp[i - 1][j]
const ins_cost = dp[i][j - 1]
const replace_cost = dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1]) * one_replace_cost // replacement is not very desirable
const replace_role_cost = dp[i - 1][j - 1] +
((!(arg_a[i - 1].type === arg_b[j - 1].type && arg_a[i - 1].ref === arg_b[j - 1].ref)) || arg_a[i - 1].role === arg_b[j - 1].role) * one_replace_cost
dp[i][j] = min(min(del_cost, ins_cost) + 1, min(replace_cost, replace_role_cost))
}
}
} else {
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const del_cost = dp[i - 1][j]
const ins_cost = dp[i][j - 1]
const replace_cost = dp[i - 1][j - 1] + (a[i - 1] !== b[j - 1]) * one_replace_cost // replacement is not very desirable
dp[i][j] = min(min(del_cost, ins_cost) + 1, replace_cost)
}
}
}
a = a.map(i => JSON.parse(i))
b = b.map(i => JSON.parse(i))
const answer = []
let i = a.length
let j = b.length
while (true) {
if (i === 0 || j === 0) {
if (i === 0 && j === 0) {
break;
} else if (i === 0) {
answer.push([null, b[j - 1]])
j = j - 1
continue;
} else {
answer.push([a[i - 1], null])
i = i - 1
continue;
}
}
const del_cost = dp[i - 1][j]
const ins_cost = dp[i][j - 1]
let replace_cost = dp[i - 1][j - 1] + (JSON.stringify(a[i - 1]) !== JSON.stringify(b[j - 1])) * one_replace_cost
if (arg_a.length && Object.prototype.hasOwnProperty.call(arg_a[0], "role")) {
replace_cost = min(replace_cost, dp[i - 1][j - 1] + ((!(arg_a[i - 1].type === arg_b[j - 1].type && arg_a[i - 1].ref === arg_b[j - 1].ref)) || arg_a[i - 1].role === arg_b[j - 1].role) * one_replace_cost)
}
if (del_cost <= ins_cost && del_cost + 1 <= replace_cost) {
answer.push([a[i - 1], null])
i = i - 1
} else if (ins_cost <= del_cost && ins_cost + 1 <= replace_cost) {
answer.push([null, b[j - 1]])
j = j - 1
} else {
answer.push([a[i - 1], b[j - 1]])
i = i - 1
j = j - 1
}
}
return answer.toReversed();
}
/**
* @param {[]} arr
* @param N
* @return {[]}
*/
function arraySplit(arr, N = 2) {
const chunkSize = Math.max(1, Math.floor(arr.length / N)); // todo это неправильно, но и так сойдёт
const res = [];
for (let i = 0; i < arr.length; i += chunkSize) {
res.push(arr.slice(i, i + chunkSize));
}
return res;
}
/**
* @typedef {{
* closed_at: string,
* max_lon: number,
* maxlon: number,
* created_at: string,
* type: string,
* changes_count: number,
* tags: {},
* min_lon: number,
* minlon: number,
* uid: number,
* max_lat: number,
* maxlat: number,
* minlat: number,
* comments_count: number,
* id: number,
* min_lat: number,
* user: string,
* open: boolean}}
* @name ChangesetMetadata
*/
// /**
// * @type ChangesetMetadata|null
// **/
// let prevChangesetMetadata = null;
/**
* @type {Object.<string, ChangesetMetadata>}|null
**/
let changesetMetadatas = {};
let startTouch = null;
let touchMove = null;
let touchEnd = null;
function addSwipes() {
if (!GM_config.get("Swipes")) {
return;
}
let startX = 0
let startY = 0
let direction = null
const sidebar = document.querySelector("#sidebar_content")
sidebar.style.transform = 'translateX(var(--touch-diff, 0px))'
if (!location.pathname.startsWith("/changeset/")) {
sidebar.removeEventListener('touchstart', startTouch)
sidebar.removeEventListener('touchmove', touchMove)
sidebar.removeEventListener('touchend', touchEnd)
startTouch = null;
touchMove = null;
touchEnd = null;
} else {
if (startTouch !== null) return
startTouch = e => {
startX = e.touches[0].clientX
startY = e.touches[0].clientY
};
touchMove = e => {
const diffY = e.changedTouches[0].clientY - startY;
const diffX = e.changedTouches[0].clientX - startX;
if (direction == null) {
if (diffY >= 10 || diffY <= -10) {
direction = "v"
} else if (diffX >= 10 || diffX <= -10) {
direction = "h"
startX = e.touches[0].clientX
}
} else if (direction === "h") {
e.preventDefault()
sidebar.style.setProperty('--touch-diff', `${diffX}px`)
}
};
touchEnd = e => {
const diffX = startX - e.changedTouches[0].clientX
sidebar.style.removeProperty('--touch-diff')
if (direction === "h") {
if (diffX > sidebar.offsetWidth / 3) {
const navigationLinks = document.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
if (navigationLinks && Array.from(navigationLinks).at(-1).href.includes("/changeset/")) {
getAbortController().abort(ABORT_ERROR_PREV)
Array.from(navigationLinks).at(-1).click()
}
} else if (diffX < -sidebar.offsetWidth / 3) {
const navigationLinks = document.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
if (navigationLinks && navigationLinks[0].href.includes("/changeset/")) {
getAbortController().abort(ABORT_ERROR_NEXT)
navigationLinks[0].click()
}
}
}
direction = null
};
sidebar.addEventListener('touchstart', startTouch)
sidebar.addEventListener('touchmove', touchMove)
sidebar.addEventListener('touchend', touchEnd)
}
}
let rateLimitBan = false
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function error509Handler(res) {
rateLimitBan = true
console.error("oops, DOS block")
setTimeout(async () => {
getMap()?.attributionControl?.setPrefix(escapeHtml(await res.text()))
})
// todo sleep
}
function addRegionForFirstChangeset(attempts = 5) {
if (location.search.includes("changesets")) return;
setTimeout(async () => {
if (rateLimitBan) {
return
}
await interceptMapManually()
if (getMap().getZoom() <= 10) {
getMap().attributionControl.setPrefix("")
if (attempts > 0) {
console.log(`Attempt №${7 - attempts} for geocoding`)
setTimeout(() => {
addRegionForFirstChangeset(attempts - 1)
}, 100)
} else {
console.log("Skip geocoding")
}
return
}
const center = getMap().getCenter()
console.time(`Geocoding changeset ${center.lng},${center.lat}`)
fetch(`https://nominatim.openstreetmap.org/reverse.php?lon=${center.lng}&lat=${center.lat}&format=jsonv2&zoom=10`, {signal: getAbortController().signal}).then((res) => {
res.json().then((r) => {
if (r?.address?.state) {
getMap().attributionControl.setPrefix(`${r.address.state}`)
console.timeEnd(`Geocoding changeset ${center.lng},${center.lat}`)
}
})
}).catch(e => {
console.debug("Nominatim fail")
console.debug(e)
})
})
}
let iconsList = null
async function loadIconsList() {
let yml;
const url = `https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/refs/heads/master/config/browse_icons.yml`;
if (GM_info.scriptHandler !== "FireMonkey") {
yml = (await GM.xmlHttpRequest({
url: url,
})).responseText
} else {
yml = await (await GM.fetch(url)).text
}
iconsList = {}
// не, ну а почему бы и нет
yml.match(/[\w_-]+:\s*(([\w_-]|:\*)+:(\s+{.*}\s+))*/g).forEach(tags => {
const lines = tags.split("\n")
lines.slice(1).forEach(i => {
const line = i.trim()
if (line === "") return;
const [, value, jsonValue] = line.match(/(:\*|\w+): (\{.*})/)
iconsList[lines[0].slice(0, -1) + "=" + value] = JSON.parse(jsonValue.replaceAll(/(\w+):/g, '"$1":'))
})
})
await GM.setValue("poi-icons", JSON.stringify({icons: iconsList, cacheTime: new Date()}))
return iconsList
}
async function initPOIIcons() {
const cache = await GM.getValue("poi-icons", "")
if (cache) {
console.log("poi icons cached")
const cacheTime = new Date(cache['cacheTime'])
if (cacheTime.setUTCDate(cacheTime.getUTCDate() + 1) < new Date()) {
console.log("but cache outdated")
setTimeout(loadIconsList, 0)
}
iconsList = JSON.parse(cache)['icons']
return
}
console.log("loading icons")
await loadIconsList()
}
const nodeFallback = "https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/node.svg"
const wayFallback = "https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/way.svg"
const relationFallback = "https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/relation.svg"
/**
*
* @param {string} type
* @param {[[string, string]]}tags
* @return {[string, boolean]}
*/
function getPOIIconURL(type, tags) {
if (!iconsList) {
return ["", false]
}
function getFallback(type) {
if (type === "node") {
return nodeFallback
} else if (type === "way") {
return wayFallback
} else if (type === "relation") {
return relationFallback
}
}
let result = undefined
tags.forEach(([key, value]) => {
function makeIconURL(filename) {
return `https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/master/app/assets/images/browse/` + filename
}
if (iconsList[key + "=" + value] === undefined) {
if (iconsList[key + "=:*"] && !result) {
result = [makeIconURL(iconsList[key + "=:*"]["filename"]), iconsList[key + "=:*"]["invert"]]
}
} else {
result = [makeIconURL(iconsList[key + "=" + value]["filename"]), iconsList[key + "=" + value]["invert"]]
}
})
return result ?? [getFallback(type), false]
}
function makeTagRow(key, value, addTd = false) {
const tagRow = document.createElement("tr")
const tagTh = document.createElement("th")
const tagTd = document.createElement("td")
tagRow.appendChild(tagTh)
tagRow.appendChild(tagTd)
if (addTd) {
const td = document.createElement("td")
td.classList.add("tag-flag")
tagRow.appendChild(td)
}
tagTh.textContent = key
tagTd.textContent = value
return tagRow
}
function makeLinksInRowClickable(row) {
if (row.querySelector("td").textContent.match(/^https?:\/\//)) {
const a = document.createElement("a")
a.textContent = row.querySelector("td").textContent
a.href = row.querySelector("td").textContent
row.querySelector("td").textContent = ""
a.target = "_blank"
a.onclick = e => {
e.stopPropagation()
e.stopImmediatePropagation()
}
row.querySelector("td").appendChild(a)
} else {
const key = row.querySelector("th").textContent
const valueCell = row.querySelector("td")
if (key.startsWith("panoramax")) {
makePanoramaxValue(valueCell)
} else if (key.startsWith("mapillary")) {
makeMapillaryValue(valueCell)
} else if (key.startsWith("wikimedia_commons")) {
makeWikimediaCommonsValue(valueCell)
} else if (key.startsWith("opening_hours") // https://github.com/opening-hours/opening_hours.js/blob/master/scripts/related_tags.txt
|| ["happy_hours", "delivery_hours", "smoking_hours", "collection_times", "service_times"].includes(key)) {
if (key !== "opening_hours:signed" && typeof opening_hours !== "undefined") {
try {
new opening_hours(valueCell.textContent, null, {tag_key: key});
} catch (e) {
valueCell.title = e
valueCell.classList.add("fixme-tag")
}
}
}
}
}
function detectEditsWars(prevVersion, targetVersion, objHistory, row, key) {
let revertsCounter = 0
let warLog = document.createElement("table")
warLog.style.borderColor = "var(--bs-body-color)";
warLog.style.borderStyle = "solid";
warLog.style.borderWidth = "1px";
warLog.title = ""
warLog.classList.add("edits-wars-log")
for (let j = 0; j < objHistory.length; j++) {
const it = objHistory[j];
const prevIt = (objHistory[j - 1]?.tags ?? {})[key]
const targetIt = (it.tags ?? {})[key]
const prevTag = (prevVersion.tags ?? {})[key]
// const targetTag = (targetVersion.tags ?? {})[key]
if (prevIt === targetIt) {
continue
}
if (prevTag === targetIt) {
revertsCounter++
}
if (targetIt === undefined) {
const tr = document.createElement("tr")
tr.classList.add("quick-look-deleted-tag")
const th_ver = document.createElement("th")
const ver_link = document.createElement("a")
ver_link.textContent = `v${it.version}`
ver_link.href = `/${it.type}/${it.id}/history/${it.version}`
ver_link.target = "_blank"
ver_link.style.color = "unset"
th_ver.appendChild(ver_link)
const td_user = document.createElement("td")
const user_link = document.createElement("a")
user_link.textContent = `${it.user}`
user_link.href = `/user/${it.user}`
user_link.target = "_blank"
user_link.style.color = "unset"
td_user.appendChild(user_link)
const td_tag = document.createElement("td")
td_tag.textContent = "<deleted>"
tr.appendChild(th_ver)
tr.appendChild(td_user)
tr.appendChild(td_tag)
warLog.appendChild(tr)
} else {
const tr = document.createElement("tr")
const th_ver = document.createElement("th")
const ver_link = document.createElement("a")
ver_link.textContent = `v${it.version}`
ver_link.href = `/${it.type}/${it.id}/history/${it.version}`
ver_link.target = "_blank"
ver_link.style.color = "unset"
th_ver.appendChild(ver_link)
const td_user = document.createElement("td")
const user_link = document.createElement("a")
user_link.textContent = `${it.user}`
user_link.href = `/user/${it.user}`
user_link.target = "_blank"
user_link.style.color = "unset"
td_user.appendChild(user_link)
const td_tag = document.createElement("td")
td_tag.textContent = it.tags[key]
tr.appendChild(th_ver)
tr.appendChild(td_user)
tr.appendChild(td_tag)
warLog.appendChild(tr)
}
}
if (revertsCounter > 3) {
row.classList.add("edits-wars-tag")
row.title = `Edits war. ${row.title}\nClick for details`
}
const tr = document.createElement("tr")
const td = document.createElement("td")
td.appendChild(warLog)
td.colSpan = 3
tr.style.display = "none"
tr.appendChild(td)
row.after(tr)
row.querySelector("td.tag-flag").style.cursor = "pointer"
row.querySelector("td.tag-flag").onclick = (e) => {
e.stopPropagation()
e.stopImmediatePropagation()
if (e.target.getAttribute("open")) {
tr.style.display = "none"
e.target.removeAttribute("open")
} else {
tr.style.removeProperty("display")
e.target.setAttribute("open", "true")
}
}
}
const emptyVersion = {
tags: {},
version: 0,
lat: null,
lon: null,
visible: false
}
/**
* @param {string} targetTimestamp
* @param {string|number} wayID
* @return {Promise<(*[])[]>}
*/
async function getWayNodesByTimestamp(targetTimestamp, wayID) {
const targetVersion = searchVersionByTimestamp(await getWayHistory(wayID), targetTimestamp);
if (targetVersion === null) {
return
}
const [, wayNodesHistories] = await loadWayVersionNodes(wayID, targetVersion.version)
const targetNodes = filterObjectListByTimestamp(wayNodesHistories, targetTimestamp)
const nodesMap = {}
targetNodes.forEach(elem => {
nodesMap[elem.id] = [elem.lat, elem.lon]
})
let currentNodesList = []
targetVersion.nodes?.forEach(node => {
if (node in nodesMap) {
currentNodesList.push(nodesMap[node])
} else {
console.error(wayID, node)
console.trace()
}
})
return [targetVersion, currentNodesList]
}
let pinnedRelations = new Set();
/**
* @param {Element} i
* @param {string} objType
* @param {NodeVersion|WayVersion|RelationVersion} prevVersion
* @param {NodeVersion|WayVersion|RelationVersion} targetVersion
* @param {NodeVersion|WayVersion|RelationVersion} lastVersion
* @param {NodeVersion[]|WayVersion[]|RelationVersion[]} objHistory
*/
async function processObject(i, objType, prevVersion, targetVersion, lastVersion, objHistory) {
const tagsTable = document.createElement("table")
tagsTable.classList.add("quick-look")
const tbody = document.createElement("tbody")
tagsTable.appendChild(tbody)
let tagsWasChanged = false;
let changedOnlyUninterestedTags = true;
const uninterestedTags = new Set([
'check_date', 'check_date:opening_hours',
'check_date:opening_hours', 'survey:date'
]);
// tags deletion
if (prevVersion.version !== 0) {
for (const [key, value] of Object.entries(prevVersion?.tags ?? {})) {
if (targetVersion.tags === undefined || targetVersion.tags[key] === undefined) {
const row = makeTagRow(key, value, true)
row.classList.add("quick-look-deleted-tag")
tbody.appendChild(row)
tagsWasChanged = true
changedOnlyUninterestedTags = false
if (lastVersion.tags && lastVersion.tags[key] === prevVersion.tags[key]) {
row.classList.add("restored-tag")
row.title = row.title + "The tag is now restored"
}
makeLinksInRowClickable(row)
detectEditsWars(prevVersion, targetVersion, objHistory, row, key)
}
}
}
// tags add/modification
for (const [key, value] of Object.entries(targetVersion.tags ?? {})) {
const row = makeTagRow(key, value, true)
if (prevVersion.tags === undefined || prevVersion.tags[key] === undefined) {
tagsWasChanged = true
if (!uninterestedTags.has(key)) {
changedOnlyUninterestedTags = false;
}
row.classList.add("quick-look-new-tag")
if (!lastVersion.tags || lastVersion.tags[key] !== targetVersion.tags[key]) {
if (lastVersion.tags && lastVersion.tags[key]) {
row.classList.add("replaced-tag")
row.title = `Now is ${key}=${lastVersion.tags[key]}`
} else if (lastVersion.visible !== false) {
row.classList.add("removed-tag")
row.title = `The tag is now deleted`
}
}
makeLinksInRowClickable(row)
tbody.appendChild(row)
detectEditsWars(prevVersion, targetVersion, objHistory, row, key)
} else if (prevVersion.tags[key] !== value) {
// todo reverted changes
const valCell = row.querySelector("td")
if (!uninterestedTags.has(key)) {
changedOnlyUninterestedTags = false;
}
row.classList.add("quick-look-modified-tag")
// toReversed is dirty hack for group inserted/deleted symbols https://osm.org/changeset/157338007
const diff = arraysDiff(Array.from(prevVersion.tags[key]).toReversed(), Array.from(valCell.textContent).toReversed(), 1).toReversed()
// for one character diff
// example: https://osm.org/changeset/157002657
if (valCell.textContent.length > 1 && prevVersion.tags[key].length > 1
&& (
diff.length === valCell.textContent.length && prevVersion.tags[key].length === valCell.textContent.length
&& diff.reduce((cnt, b) => cnt + (b[0] !== b[1]), 0) === 1
|| diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[0] !== null), 0) === 0
|| diff.reduce((cnt, b) => cnt + (b[0] !== b[1] && b[1] !== null), 0) === 0
)) {
let prevText = document.createElement("span")
let newText = document.createElement("span")
prevText.dir = "auto"
newText.dir = "auto"
diff.forEach(c => {
if (c[0] !== c[1]) {
if (c[1]) {
const colored = document.createElement("span")
colored.classList.add("new-letter")
colored.textContent = c[1]
newText.appendChild(colored)
}
if (c[0]) {
const colored = document.createElement("span")
colored.classList.add("deleted-letter")
colored.textContent = c[0]
prevText.appendChild(colored)
}
} else {
prevText.appendChild(document.createTextNode(c[0]))
newText.appendChild(document.createTextNode(c[1]))
}
})
prevText.normalize()
newText.normalize()
valCell.textContent = ""
valCell.appendChild(prevText)
valCell.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
valCell.appendChild(newText)
} else {
const prevSpan = document.createElement("span")
prevSpan.dir = "auto"
prevSpan.textContent = prevVersion.tags[key]
const newSpan = document.createElement("span")
newSpan.dir = "auto"
newSpan.textContent = valCell.textContent
valCell.textContent = ""
valCell.appendChild(prevSpan)
valCell.appendChild(document.createTextNode(` ${arrowSymbolForChanges} `))
valCell.appendChild(newSpan)
}
valCell.title = "was: " + prevVersion.tags[key]
tagsWasChanged = true
if (!lastVersion.tags || lastVersion.tags[key] !== targetVersion.tags[key]) {
if (lastVersion.tags && prevVersion.tags && lastVersion.tags[key] === prevVersion.tags[key]) {
row.classList.add("reverted-tag")
row.title = `The tag is now reverted`
} else if (lastVersion.tags && lastVersion.tags[key]) {
row.classList.add("replaced-tag")
row.title = `Now is ${key}=${lastVersion.tags[key]}`
} else if (lastVersion.visible !== false) {
row.classList.add("removed-tag")
row.title = `The tag is now deleted`
}
}
tbody.appendChild(row)
detectEditsWars(prevVersion, targetVersion, objHistory, row, key)
} else {
row.classList.add("non-modified-tag-in-quick-view")
if (!tagsOfObjectsVisible) {
row.setAttribute("hidden", "true")
}
makeLinksInRowClickable(row)
tbody.appendChild(row)
}
}
if (targetVersion.visible !== false && prevVersion?.nodes && prevVersion.nodes.toString() !== targetVersion.nodes?.toString()) {
let geomChangedFlag = document.createElement("span")
geomChangedFlag.textContent = " 📐"
geomChangedFlag.tabIndex = 0
geomChangedFlag.classList.add("nodes-changed")
geomChangedFlag.title = "List of way nodes has been changed"
geomChangedFlag.style.userSelect = "none"
geomChangedFlag.style.background = "rgba(223,238,9,0.6)"
geomChangedFlag.style.cursor = "pointer"
const nodesTable = document.createElement("table")
nodesTable.classList.add("way-nodes-table")
nodesTable.style.display = "none"
const tbody = document.createElement("tbody")
nodesTable.style.borderWidth = "2px"
nodesTable.onclick = e => {
e.stopPropagation()
}
tbody.style.borderWidth = "2px"
nodesTable.appendChild(tbody)
function makeWayDiffRow(left, right) {
const row = document.createElement("tr")
const tagTd = document.createElement("td")
const tagTd2 = document.createElement("td")
tagTd.style.borderWidth = "2px"
tagTd2.style.borderWidth = "2px"
row.style.borderWidth = "2px"
row.appendChild(tagTd)
row.appendChild(tagTd2)
tagTd.textContent = left
tagTd2.textContent = right
tagTd.style.textAlign = "right"
tagTd2.style.textAlign = "right"
if (typeof left === "number") {
tagTd.onmouseenter = async e => {
e.stopPropagation() // fixme
e.target.classList.add("way-version-node")
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
const version = searchVersionByTimestamp(await getNodeHistory(left), targetTimestamp)
showActiveNodeMarker(version.lat.toString(), version.lon.toString(), "#ff00e3")
}
tagTd.onclick = async e => {
e.stopPropagation() // fixme
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
const version = searchVersionByTimestamp(await getNodeHistory(left), targetTimestamp)
panTo(version.lat.toString(), version.lon.toString())
}
tagTd.onmouseleave = e => {
e.target.classList.remove("way-version-node")
}
} else {
tagTd.onclick = e => {
e.stopPropagation()
}
}
if (typeof right === "number") {
tagTd2.onmouseenter = async e => {
e.stopPropagation() // fixme
e.target.classList.add("way-version-node")
const version = searchVersionByTimestamp(await getNodeHistory(right), changesetMetadatas[targetVersion.changeset].closed_at ?? new Date().toISOString())
showActiveNodeMarker(version.lat.toString(), version.lon.toString(), "#ff00e3")
}
tagTd2.onclick = async e => {
e.stopPropagation() // fixme
e.target.classList.add("way-version-node")
const version = searchVersionByTimestamp(await getNodeHistory(right), changesetMetadatas[targetVersion.changeset].closed_at ?? new Date().toISOString())
panTo(version.lat.toString(), version.lon.toString())
}
tagTd2.onmouseleave = e => {
e.target.classList.remove("way-version-node")
}
} else {
tagTd2.onclick = e => {
e.stopPropagation()
}
}
return row
}
let haveOnlyInsertion = true
let haveOnlyDeletion = true
const lineWasReversed = JSON.stringify(prevVersion.nodes.toReversed()) === JSON.stringify(targetVersion.nodes)
if (lineWasReversed) {
const row = makeWayDiffRow("", "🔃")
row.querySelectorAll("td").forEach(i => i.style.textAlign = "center")
row.querySelector("td:nth-of-type(2)").title = "Nodes of the way were reversed"
tbody.appendChild(row)
prevVersion.nodes.forEach((i, index) => {
const row = makeWayDiffRow(i, targetVersion.nodes[index])
row.querySelector("td:nth-of-type(2)").style.background = "rgba(223,238,9,0.6)"
row.style.fontFamily = "monospace"
tbody.appendChild(row)
})
haveOnlyInsertion = false
haveOnlyDeletion = false
} else {
arraysDiff(prevVersion.nodes ?? [], targetVersion.nodes ?? []).forEach(i => {
const row = makeWayDiffRow(i[0], i[1])
if (i[0] === null) {
row.style.background = "rgba(17,238,9,0.6)"
haveOnlyDeletion = false
} else if (i[1] === null) {
row.style.background = "rgba(238,51,9,0.6)"
haveOnlyInsertion = false
} else if (i[0] !== i[1]) {
row.style.background = "rgba(223,238,9,0.6)" // never executed?
haveOnlyInsertion = false
haveOnlyDeletion = false
}
row.style.fontFamily = "monospace"
tbody.appendChild(row)
})
}
if (haveOnlyInsertion) {
if (isDarkMode()) {
geomChangedFlag.style.background = "rgba(17, 238, 9, 0.3)"
} else {
geomChangedFlag.style.background = "rgba(101,238,9,0.6)"
}
} else if (haveOnlyDeletion) {
if (isDarkMode()) {
geomChangedFlag.style.background = "rgba(238, 51, 9, 0.4)"
} else {
geomChangedFlag.style.background = "rgba(238, 9, 9, 0.42)"
}
}
const tagsTable = document.createElement("table")
tagsTable.style.display = "none"
const tbodyForTags = document.createElement("tbody")
tagsTable.appendChild(tbodyForTags)
Object.entries(targetVersion.tags ?? {}).forEach(([k, v]) => {
const row = makeTagRow(k, v)
makeLinksInRowClickable(row)
tbodyForTags.appendChild(row)
})
geomChangedFlag.onkeypress = geomChangedFlag.onclick = e => {
if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
e.preventDefault()
} else if (e.type === "keypress") {
return
}
e.stopPropagation()
if (nodesTable.style.display === "none") {
nodesTable.style.display = ""
tagsTable.style.display = ""
} else {
nodesTable.style.display = "none"
tagsTable.style.display = "none"
}
}
i.appendChild(geomChangedFlag)
geomChangedFlag.after(nodesTable)
geomChangedFlag.after(tagsTable)
if (lineWasReversed) {
geomChangedFlag.after(document.createTextNode(['ru-RU', 'ru'].includes(navigator.language) ? " ⓘ Линию перевернули" : "ⓘ The line has been reversed"))
}
}
if (objType === "way" && targetVersion.visible !== false) {
if (prevVersion.nodes && prevVersion.nodes.length !== targetVersion.nodes?.length) {
i.title += (i.title === "" ? "" : "\n") + `Nodes count: ${prevVersion.nodes.length} → ${targetVersion.nodes.length}`
} else {
i.title += (i.title === "" ? "" : "\n") + `Nodes count: ${targetVersion.nodes.length}`
}
}
if (prevVersion.visible === false && targetVersion?.visible !== false && targetVersion.version !== 1) {
let restoredElemFlag = document.createElement("span")
restoredElemFlag.textContent = " ♻️"
restoredElemFlag.title = "Object was restored"
restoredElemFlag.style.userSelect = "none"
i.appendChild(restoredElemFlag)
}
if (objType === "relation") {
let memChangedFlag = document.createElement("span")
memChangedFlag.textContent = " 👥"
memChangedFlag.tabIndex = 0
memChangedFlag.classList.add("members-changed")
memChangedFlag.style.userSelect = "none"
let membersChanged = false
if (JSON.stringify(prevVersion?.members ?? []) !== JSON.stringify(targetVersion.members) && targetVersion.version !== 1) {
memChangedFlag.style.background = "rgba(223,238,9,0.6)"
memChangedFlag.title = "List of relation members has been changed.\nСlick to see more details"
membersChanged = true
} else {
memChangedFlag.title = "Show list of relation members"
}
memChangedFlag.style.cursor = "pointer"
const membersTable = document.createElement("table")
membersTable.classList.add("relation-members-table")
membersTable.style.display = "none"
const tbody = document.createElement("tbody")
membersTable.style.borderWidth = "2px"
tbody.style.borderWidth = "2px"
membersTable.appendChild(tbody)
const nodeIcon = GM_getResourceURL("NODE_ICON", false)
const wayIcon = GM_getResourceURL("WAY_ICON", false)
const relationIcon = GM_getResourceURL("RELATION_ICON", false)
// const nodeIcon = nodeFallback
// const wayIcon = wayFallback
// const relationIcon = relationFallback
/**
* @param {RelationMember} member
*/
function getIcon(member) {
if (member?.type === "node") {
return nodeIcon
} else if (member?.type === "way") {
return wayIcon
} else if (member?.type === "relation") {
return relationIcon
} else {
console.error(member);
console.trace();
}
}
/**
* @param {string|RelationMember} left
* @param {string|RelationMember} right
*/
function makeRelationDiffRow(left, right) {
const row = document.createElement("tr")
const tagTd = document.createElement("td")
const tagTd2 = document.createElement("td")
tagTd.style.borderWidth = "2px"
tagTd2.style.borderWidth = "2px"
row.style.borderWidth = "2px"
row.appendChild(tagTd)
row.appendChild(tagTd2)
const leftRefSpan = document.createElement("span")
leftRefSpan.classList.add("rel-ref")
leftRefSpan.textContent = `${left?.ref ?? ""} `
const leftRoleSpan = document.createElement("span")
leftRoleSpan.classList.add("rel-role")
leftRoleSpan.textContent = `${left?.role ?? ""}`
tagTd.appendChild(leftRefSpan)
tagTd.appendChild(leftRoleSpan)
if (left && typeof left === "object") {
const icon = document.createElement("img")
icon.src = getIcon(left)
icon.style.height = "1em"
icon.style.marginLeft = "1px"
icon.style.marginTop = "-3px"
tagTd.appendChild(icon)
}
const rightRefSpan = document.createElement("span")
rightRefSpan.textContent = `${right?.ref ?? ""} `
rightRefSpan.classList.add("rel-ref")
const rightRoleSpan = document.createElement("span")
rightRoleSpan.textContent = `${right?.role ?? ""}`
rightRoleSpan.classList.add("rel-role")
tagTd2.appendChild(rightRefSpan)
tagTd2.appendChild(rightRoleSpan)
if (right && typeof right === "object") {
const icon = document.createElement("img")
icon.src = getIcon(right)
icon.style.height = "1em"
icon.style.marginLeft = "1px"
icon.style.marginTop = "-3px"
tagTd2.appendChild(icon)
}
tagTd2.style.cursor = "";
tagTd.style.textAlign = "right"
tagTd2.style.textAlign = "right"
if (left && typeof left === "object") {
tagTd.onmouseenter = async e => {
e.stopPropagation()
e.target.classList.add("relation-version-node")
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
if (left.type === "node") {
const version = searchVersionByTimestamp(await getNodeHistory(left.ref), targetTimestamp)
showActiveNodeMarker(version.lat.toString(), version.lon.toString(), "#ff00e3")
tagTd.title = ""
for (let tagsKey in version.tags ?? {}) {
tagTd.title += `${tagsKey}=${version.tags[tagsKey]}\n`;
}
} else if (left.type === "way") {
const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, left.ref)
showActiveWay(cloneInto(currentNodesList, unsafeWindow))
const version = searchVersionByTimestamp(await getWayHistory(left.ref), targetTimestamp)
tagTd.title = ""
for (let tagsKey in version.tags ?? {}) {
tagTd.title += `${tagsKey}=${version.tags[tagsKey]}\n`;
}
} else if (left.type === "relation") {
// todo
}
}
tagTd.onmouseleave = e => {
e.target.classList.remove("relation-version-node")
}
tagTd.onclick = async e => {
e.stopPropagation()
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
if (left.type === "node") {
const version = searchVersionByTimestamp(await getNodeHistory(left.ref), targetTimestamp)
panTo(version.lat.toString(), version.lon.toString())
} else if (left.type === "way") {
const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, left.ref)
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", true)
}
}
}
if (right && typeof right === "object") {
tagTd2.onmouseenter = async e => {
e.stopPropagation() // fixme
e.target.classList.add("relation-version-node")
const targetTimestamp = (new Date(changesetMetadatas[targetVersion.changeset].closed_at ?? new Date())).toISOString()
if (right.type === "node") {
const version = searchVersionByTimestamp(await getNodeHistory(right.ref), targetTimestamp)
showActiveNodeMarker(version.lat.toString(), version.lon.toString(), "#ff00e3")
tagTd2.title = ""
for (let tagsKey in version.tags ?? {}) {
tagTd2.title += `${tagsKey}=${version.tags[tagsKey]}\n`;
}
} else if (right.type === "way") {
const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, right.ref)
showActiveWay(cloneInto(currentNodesList, unsafeWindow))
const version = searchVersionByTimestamp(await getWayHistory(right.ref), targetTimestamp)
tagTd2.title = ""
for (let tagsKey in version.tags ?? {}) {
tagTd2.title += `${tagsKey}=${version.tags[tagsKey]}\n`;
}
} else if (right.type === "relation") {
// todo
}
}
tagTd2.onmouseleave = e => {
e.target.classList.remove("relation-version-node")
}
tagTd2.onclick = async e => {
e.stopPropagation()
const targetTimestamp = (new Date(changesetMetadatas[targetVersion.changeset].closed_at ?? new Date())).toISOString()
if (right.type === "node") {
const version = searchVersionByTimestamp(await getNodeHistory(right.ref), targetTimestamp)
panTo(version.lat.toString(), version.lon.toString())
} else if (right.type === "way") {
const [, currentNodesList] = await getWayNodesByTimestamp(targetTimestamp, right.ref)
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", true)
}
}
}
return row
}
let haveOnlyInsertion = true
let haveOnlyDeletion = true
function colorizeFlag() {
if (haveOnlyInsertion && membersChanged && targetVersion.version !== 1) {
if (isDarkMode()) {
memChangedFlag.style.background = "rgba(17, 238, 9, 0.3)"
} else {
memChangedFlag.style.background = "rgba(101,238,9,0.6)"
}
} else if (haveOnlyDeletion && membersChanged) {
if (isDarkMode()) {
memChangedFlag.style.background = "rgba(238, 51, 9, 0.4)"
} else {
memChangedFlag.style.background = "rgba(238, 9, 9, 0.42)"
}
}
}
if (JSON.stringify((prevVersion?.members ?? []).toReversed()) === JSON.stringify(targetVersion.members)) {
// members reversed
const row = makeRelationDiffRow("", "🔃")
row.querySelectorAll("td").forEach(i => i.style.textAlign = "center")
row.querySelector("td:nth-of-type(2)").title = "Members of the relation were reversed"
tbody.appendChild(row)
prevVersion?.members?.forEach((i, index) => {
const row = makeRelationDiffRow(i, targetVersion.members[index])
row.querySelector("td:nth-of-type(2)").style.background = "rgba(223,238,9,0.6)"
row.style.fontFamily = "monospace"
tbody.appendChild(row)
})
haveOnlyInsertion = false
haveOnlyDeletion = false
colorizeFlag()
} else {
memChangedFlag.style.display = "none"
setTimeout(() => {
arraysDiff(prevVersion?.members ?? [], targetVersion.members ?? []).forEach(i => {
const row = makeRelationDiffRow(i[0], i[1])
if (i[0] === null) {
row.style.background = "rgba(17,238,9,0.6)"
haveOnlyDeletion = false
} else if (i[1] === null) {
row.style.background = "rgba(238,51,9,0.6)"
haveOnlyInsertion = false
} else if (JSON.stringify(i[0]) !== JSON.stringify(i[1])) {
if (i[0].ref === i[1].ref && i[0].type === i[1].type) {
row.querySelectorAll(".rel-role").forEach(i => {
i.style.background = "rgba(223,238,9,0.6)"
if (isDarkMode()) {
i.style.color = "black"
}
})
} else {
row.style.background = "rgba(223,238,9,0.6)"
if (isDarkMode()) {
row.style.color = "black"
}
}
haveOnlyInsertion = false
haveOnlyDeletion = false
}
row.style.fontFamily = "monospace"
tbody.appendChild(row)
})
memChangedFlag.style.display = ""
colorizeFlag()
})
}
const tagsTable = document.createElement("table")
tagsTable.style.display = "none"
const tbodyForTags = document.createElement("tbody")
tagsTable.appendChild(tbodyForTags)
Object.entries(targetVersion.tags ?? {}).forEach(([k, v]) => {
const row = makeTagRow(k, v)
makeLinksInRowClickable(row)
tbodyForTags.appendChild(row)
})
memChangedFlag.onkeypress = memChangedFlag.onclick = e => {
if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
e.preventDefault()
} else if (e.type === "keypress") {
return
}
// todo preload first elements
e.stopPropagation()
if (membersTable.style.display === "none") {
membersTable.style.display = ""
tagsTable.style.display = ""
} else {
membersTable.style.display = "none"
tagsTable.style.display = "none"
}
}
i.appendChild(memChangedFlag)
const pinRelation = document.createElement("span")
pinRelation.textContent = "📌"
pinRelation.tabIndex = 0
pinRelation.classList.add("pin-relation")
pinRelation.style.cursor = "pointer"
pinRelation.style.display = "none"
pinRelation.title = "Pin relation on map"
pinRelation.onkeypress = pinRelation.onclick = async (e) => {
if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
e.preventDefault()
} else if (e.type === "keypress") {
return
}
e.stopImmediatePropagation()
if (!pinRelation.classList.contains("pinned")) {
pinnedRelations.add(targetVersion.id)
pinRelation.style.cursor = "progress"
const color = (darkModeForMap && isDarkMode()) ? "#000" : "#373737";
await loadRelationVersionMembersViaOverpass(targetVersion.id, targetVersion.timestamp, false, color, `customObjects/${targetVersion.id}`, darkModeForMap && isDarkMode())
pinRelation.style.cursor = "pointer"
pinRelation.classList.add("pinned")
pinRelation.textContent = "📍"
pinRelation.title = "Unpin relation from map"
} else {
pinRelation.title = "Pin relation on map"
pinRelation.classList.remove("pinned")
pinRelation.textContent = "📌"
cleanObjectsByKey(`customObjects/${targetVersion.id}`)
pinnedRelations.delete(targetVersion.id)
}
}
memChangedFlag.after(pinRelation)
pinRelation.after(membersTable)
if (membersChanged) {
pinRelation.after(tagsTable)
}
}
if (targetVersion.lat && prevVersion.lat && (prevVersion.lat !== targetVersion.lat || prevVersion.lon !== targetVersion.lon)) {
i.parentElement.parentElement.classList.add("location-modified")
const locationChangedFlag = document.createElement("span")
const distInMeters = getDistanceFromLatLonInKm(
prevVersion.lat,
prevVersion.lon,
targetVersion.lat,
targetVersion.lon,
) * 1000;
locationChangedFlag.textContent = ` 📍${distInMeters.toFixed(1)}m`
locationChangedFlag.title = "Coordinates of node has been changed"
locationChangedFlag.classList.add("location-modified-marker")
// if (distInMeters > 100) {
// locationChangedFlag.classList.add("location-modified-marker-warn")
// }
locationChangedFlag.style.userSelect = "none"
if (isDarkMode()) {
locationChangedFlag.style.background = "rgba(223, 238, 9, 0.6)"
locationChangedFlag.style.color = "black"
} else {
locationChangedFlag.style.background = "rgba(223,238,9,0.6)"
}
i.appendChild(locationChangedFlag)
locationChangedFlag.onmouseover = e => {
e.stopPropagation()
e.stopImmediatePropagation()
showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "#ff00e3")
showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#0022ff", false)
}
locationChangedFlag.onclick = (e) => {
e.stopPropagation()
e.stopImmediatePropagation()
fitBoundsWithPadding([
[prevVersion.lat.toString(), prevVersion.lon.toString()],
[targetVersion.lat.toString(), targetVersion.lon.toString()]
], 30)
}
if (lastVersion.visible !== false && (prevVersion.lat === lastVersion.lat && prevVersion.lon === lastVersion.lon)) {
locationChangedFlag.classList.add("reverted-coordinates")
locationChangedFlag.title += ",\nbut now they have been restored."
}
}
if (targetVersion.visible === false) {
i.parentElement.parentElement.classList.add("removed-object")
}
if (targetVersion.version !== lastVersion.version && lastVersion.visible === false) {
i.appendChild(document.createTextNode(['ru-RU', 'ru'].includes(navigator.language) ? " ⓘ Объект уже удалён" : " ⓘ The object is now deleted"))
}
if (targetVersion.visible === false && lastVersion.visible !== false) {
i.appendChild(document.createTextNode(['ru-RU', 'ru'].includes(navigator.language) ? " ⓘ Объект сейчас восстановлен" : " ⓘ The object is now restored"))
}
// if (objType === "node") {
// i.appendChild(tagsTable)
// }
if (tagsWasChanged) {
i.appendChild(tagsTable)
if (changedOnlyUninterestedTags) {
i.parentElement.parentElement.classList.add("tags-uninterested-modified")
}
} else {
i.parentElement.parentElement.classList.add("tags-non-modified")
}
i.parentElement.parentElement.classList.add("tags-processed-object")
return tagsTable
}
/**
* @typedef {{
* nodes: [],
* ways: [],
* relations: []
* }}
* @name ObjectsInComments
*/
/**
* @param {string} changesetID
* @param {string} objType
* @param {ObjectsInComments} objectsInComments
* @param {Element} i
* @param {NodeVersion|WayVersion|RelationVersion} prevVersion
* @param {NodeVersion|WayVersion|RelationVersion} targetVersion
*/
async function processObjectInteractions(changesetID, objType, objectsInComments, i, prevVersion, targetVersion) {
let changesetMetadata = changesetMetadatas[targetVersion.changeset];
if (!GM_config.get("ShowChangesetGeometry")) {
i.parentElement.parentElement.classList.add("processed-object")
return
}
/**
* @type {[string, string, string, string]}
*/
const m = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/);
const [, , objID, strVersion] = m
const version = parseInt(strVersion)
i.parentElement.parentElement.ondblclick = (e) => {
if (e.altKey) return
if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return
if (changesetMetadatas[targetVersion.changeset]) {
fitBounds([
[changesetMetadatas[targetVersion.changeset].min_lat, changesetMetadatas[targetVersion.changeset].min_lon],
[changesetMetadatas[targetVersion.changeset].max_lat, changesetMetadatas[targetVersion.changeset].max_lon]
])
}
}
function processNode() {
i.id = `${changesetID}n${objID}`
function mouseoverHandler(e) {
if (e.relatedTarget?.parentElement === e.target) {
return
}
if (targetVersion.visible === false) {
if (prevVersion.visible !== false) {
showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#0022ff")
const direction = prevVersion.tags?.['direction'] ?? prevVersion.tags?.['camera:direction']
if (direction) {
renderDirectionTag(prevVersion.lat, prevVersion.lon, direction, "#0022ff")
}
}
} else {
showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "#ff00e3")
const direction = targetVersion.tags?.['direction'] ?? targetVersion.tags?.['camera:direction']
if (direction) {
renderDirectionTag(targetVersion.lat, targetVersion.lon, direction, "#ff00e3")
}
}
resetMapHover()
}
i.parentElement.parentElement.onmouseover = mouseoverHandler
if ((prevVersion.tags && Object.keys(prevVersion.tags).length) || (targetVersion.tags && Object.keys(targetVersion.tags).length)) { // todo temp hack for potential speed up // fixme remove comment
objectsInComments.nodes.filter(i => i.href.includes(`node/${objID}`)).forEach(link => {
// link.title = "Alt + click for scroll into object list"
link.onmouseenter = mouseoverHandler
link.onclick = (e) => {
if (!e.altKey) return
i.scrollIntoView()
}
})
}
i.parentElement.parentElement.onclick = (e) => {
if (e.altKey) return
if (window.getSelection().type === "Range") return
if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return
document.querySelector(".browse-section.active-object")?.classList?.remove()
i.parentElement.parentElement.classList.add("active-object")
if (prevVersion.visible !== false && targetVersion.visible !== false) {
fitBoundsWithPadding([
[prevVersion.lat.toString(), prevVersion.lon.toString()],
[targetVersion.lat.toString(), targetVersion.lon.toString()]
], 30)
showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#0022ff", true)
showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "#ff00e3", false)
const direction = prevVersion.tags?.['direction'] ?? prevVersion.tags?.['camera:direction']
if (direction) {
renderDirectionTag(prevVersion.lat, prevVersion.lon, direction, "#0022ff")
}
const newDirection = targetVersion.tags?.['direction'] ?? targetVersion.tags?.['camera:direction']
if (direction) {
renderDirectionTag(targetVersion.lat, targetVersion.lon, newDirection, "#ff00e3")
}
} else if (targetVersion.visible === false) {
panTo(prevVersion.lat.toString(), prevVersion.lon.toString(), 18, false)
showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#0022ff", true)
const direction = prevVersion.tags?.['direction'] ?? prevVersion.tags?.['camera:direction']
if (direction) {
renderDirectionTag(prevVersion.lat, prevVersion.lon, direction, "#0022ff")
}
} else {
if (!repeatedEvent && trustedEvent) { // todo
panTo(targetVersion.lat.toString(), targetVersion.lon.toString(), 18, false)
} else {
/*
const bounds = getMap().getBounds()
const lat1 = bounds.getNorthWest().lat
const lng1 = bounds.getNorthWest().lng
const lat2 = bounds.getSouthEast().lat
const lng2 = bounds.getSouthEast().lng
const delta_lat = (lat2 - lat1) / 5.0
const delta_lng = (lng2 - lng1) / 5.0
const newBounds = getWindow().L.latLngBounds(
intoPage([lat1 + delta_lat, lng1 + delta_lng]),
intoPage([lat2 - delta_lat, lng2 - delta_lng])
)
getWindow().L.rectangle(
intoPage([
[newBounds.getSouth(), newBounds.getWest()],
[newBounds.getNorth(), newBounds.getEast()]
]),
intoPage({color: "#0022ff", weight: 3, fillOpacity: 0})
).addTo(getMap());
if (!newBounds.contains(getWindow().L.latLng(intoPage([targetVersion.lat.toString(), targetVersion.lon.toString()])))) {
panTo(targetVersion.lat.toString(), targetVersion.lon.toString(), 18, false)
}
*/
// panInside(targetVersion.lat.toString(), targetVersion.lon.toString(), false, [70, 70])
panTo(targetVersion.lat.toString(), targetVersion.lon.toString(), 18, false)
}
showActiveNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "#ff00e3", true)
const direction = targetVersion.tags?.['direction'] ?? targetVersion.tags?.['camera:direction']
if (direction) {
renderDirectionTag(targetVersion.lat, targetVersion.lon, direction, "#ff00e3")
}
}
}
if (!location.pathname.includes("changeset")) {
return
}
if (targetVersion.visible === false) {
if (targetVersion.version !== 1 && prevVersion.visible !== false) { // даа, такое есть https://www.openstreetmap.org/node/300524/history
if (prevVersion.tags) {
showNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#FF0000", changesetID + "n" + prevVersion.id)
} else {
showNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#FF0000", changesetID + "n" + prevVersion.id, "customObjects", 2)
// todo show prev parent ways
}
}
} else if (targetVersion.version === 1) {
if (targetVersion.tags) {
showNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "#00a500", changesetID + "n" + targetVersion.id)
}
setTimeout(async () => {
if ((await getChangeset(parseInt(changesetID))).nodesWithOldParentWays.has(parseInt(objID))) {
showNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "#00a500", changesetID + "n" + targetVersion.id)
}
}, 0); // dirty hack for https://osm.org/changeset/162017882
} else if (prevVersion?.visible === false && targetVersion?.visible !== false) {
showNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "rgba(89,170,9,0.6)", changesetID + "n" + targetVersion.id, 'customObjects', 2)
} else {
showNodeMarker(targetVersion.lat.toString(), targetVersion.lon.toString(), "rgb(255,245,41)", changesetID + "n" + targetVersion.id)
}
}
async function processWay() {
i.id = `${changesetID}w${objID}`
const res = await fetch(osm_server.apiBase + objType + "/" + objID + "/full.json", {signal: getAbortController().signal});
// todo по-хорошему нужно проверять, а не успела ли измениться история линии
// будет более актуально после добавление предзагрузки
const nowDeleted = !res.ok;
const dashArray = nowDeleted ? "4, 4" : null;
let lineWidth = nowDeleted ? 4 : 3
if (!nowDeleted) {
const lastElements = (await res.json()).elements
lastElements.forEach(n => {
if (n.type !== "node") return
if (n.version === 1) {
nodesHistories[n.id] = [n]
}
})
let attempts = 0
while (!changesetMetadata && attempts < 60) {
attempts++
console.log(`changesetMetadata[${targetVersion.changeset}] not ready. Wait second...`)
await abortableSleep(1000, getAbortController()) // todo нужно поретраить
changesetMetadata = changesetMetadatas[targetVersion.changeset]
}
}
const [, wayNodesHistories] = await loadWayVersionNodes(objID, version)
const targetNodes = filterObjectListByTimestamp(wayNodesHistories, targetVersion.timestamp) // fixme what if changeset was long opened anf nodes changed after way?
let nodesMap = {}
targetNodes.forEach(elem => {
nodesMap[elem.id] = [elem.lat, elem.lon]
})
let currentNodesList = []
if (targetVersion.visible !== false) {
targetVersion.nodes?.forEach(node => {
if (node in nodesMap) {
currentNodesList.push(nodesMap[node])
} else {
console.error(objID, node)
console.trace()
}
})
}
i.parentElement.parentElement.onclick = async (e) => {
if (e.altKey) return
if (window.getSelection().type === "Range") return
if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return
document.querySelector(".browse-section.active-object")?.classList?.remove()
i.parentElement.parentElement.classList.add("active-object")
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", currentNodesList.length !== 0, changesetID + "w" + objID)
if (version > 1) {
// show prev version
const [, nodesHistory] = await loadWayVersionNodes(objID, version - 1);
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
showActiveWay(cloneInto(nodesList, unsafeWindow), "rgb(238,146,9)", currentNodesList.length === 0, changesetID + "w" + objID, false, 4, "4, 4")
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", false, changesetID + "w" + objID, false)
} else {
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
const prevVersion = searchVersionByTimestamp(await getWayHistory(objID), targetTimestamp);
if (prevVersion) {
const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version);
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
showActiveWay(cloneInto(nodesList, unsafeWindow), "rgb(238,146,9)", currentNodesList.length === 0, changesetID + "w" + objID, false, 4, "4, 4")
}
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", false, changesetID + "w" + objID, false)
}
}
let attempts = 0
while (!changesetMetadata && attempts < 60) {
attempts++
console.log(`changesetMetadata[${targetVersion.changeset}] not ready. Wait second...`)
await abortableSleep(1000, getAbortController()) // todo нужно поретраить
changesetMetadata = changesetMetadatas[targetVersion.changeset]
}
if (targetVersion.visible === false) {
const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version);
const targetTimestamp = (new Date(new Date(changesetMetadata.created_at).getTime() - 1)).toISOString()
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
if (!nodesList.some(i => i.visible === false)) {
const closedTime = (new Date(changesetMetadata.closed_at ?? new Date())).toISOString()
const nodesAfterChangeset = filterObjectListByTimestamp(nodesHistory, closedTime)
if (nodesAfterChangeset.some(i => i.visible === false)) {
displayWay(cloneInto(nodesList, unsafeWindow), false, "#ff0000", 3, changesetID + "w" + objID, "customObjects", dashArray)
} else {
// скорее всего это объединение линий, поэтому эту удаление линии нужно отправить на задний план
const layer = displayWay(cloneInto(nodesList, unsafeWindow), false, "#ff0000", 7, changesetID + "w" + objID, "customObjects", dashArray)
layer.bringToBack()
lineWidth = 8
}
} else {
console.error(`broken way: ${objID}`, nodesList) // todo retray
}
} else if (version === 1 && targetVersion.changeset === parseInt(changesetID)) {
displayWay(cloneInto(currentNodesList, unsafeWindow), false, "rgba(0,128,0,0.6)", lineWidth, changesetID + "w" + objID, "customObjects", dashArray)
} else if (prevVersion?.visible === false) {
displayWay(cloneInto(currentNodesList, unsafeWindow), false, "rgba(120,238,9,0.6)", lineWidth, changesetID + "w" + objID, "customObjects", dashArray)
} else {
displayWay(cloneInto(currentNodesList, unsafeWindow), false, nowDeleted ? "rgb(0,0,0)" : "#373737", lineWidth, changesetID + "w" + objID, "customObjects", null, null, darkModeForMap && isDarkMode())
}
async function mouseenterHandler() {
showActiveWay(cloneInto(currentNodesList, unsafeWindow))
resetMapHover()
if (version > 1) {
// show prev version
const [, nodesHistory] = await loadWayVersionNodes(objID, version - 1);
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
showActiveWay(cloneInto(nodesList, unsafeWindow), "rgb(238,146,9)", false, changesetID + "w" + objID, false, 4, "4, 4")
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", false, changesetID + "w" + objID, false, lineWidth)
} else {
const targetTimestamp = (new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1)).toISOString()
const prevVersion = searchVersionByTimestamp(await getWayHistory(objID), targetTimestamp);
if (prevVersion) {
const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version);
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
showActiveWay(cloneInto(nodesList, unsafeWindow), "rgb(238,146,9)", false, changesetID + "w" + objID, false, 4, "4, 4")
}
showActiveWay(cloneInto(currentNodesList, unsafeWindow), "#ff00e3", false, changesetID + "w" + objID, false, lineWidth)
}
}
i.parentElement.parentElement.onmouseenter = mouseenterHandler
objectsInComments.ways.filter(i => i.href.includes(`way/${objID}`)).forEach(link => {
// link.title = "Alt + click for scroll into object list"
link.onmouseenter = mouseenterHandler
link.onclick = (e) => {
if (!e.altKey) return
i.scrollIntoView()
}
})
}
function processRelation() {
i.id = `${changesetID}r${objID}`
const btn = document.createElement("a")
btn.textContent = "📥"
btn.classList.add("load-relation-version")
btn.title = "Download this relation"
btn.tabIndex = 0
btn.style.cursor = "pointer"
async function clickHandler(e) {
if (e.altKey) return
if (window.getSelection().type === "Range") return
if ((e.target.nodeName === "TH" || e.target.nodeName === "TD") && i.querySelector("[contenteditable]")) return
if (e.type === "keypress" && (e.code === "Space" || e.code === "Enter")) {
e.preventDefault()
} else if (e.type === "keypress") {
return
}
document.querySelector(".browse-section.active-object")?.classList?.remove()
i.parentElement.parentElement.classList.add("active-object")
btn.style.cursor = "progress"
let targetTimestamp = (new Date(changesetMetadatas[targetVersion.changeset].closed_at ?? new Date())).toISOString()
if (targetVersion.visible === false) {
targetTimestamp = new Date(new Date(changesetMetadatas[targetVersion.changeset].created_at).getTime() - 1).toISOString();
}
try {
const relationMetadata = await loadRelationVersionMembersViaOverpass(parseInt(objID), targetTimestamp, false, "#ff00e3")
i.parentElement.parentElement.onclick = (e) => {
if (e.altKey) return
fitBounds([
[relationMetadata.bbox.min_lat, relationMetadata.bbox.min_lon],
[relationMetadata.bbox.max_lat, relationMetadata.bbox.max_lon]
])
}
async function mouseenterHandler() {
if (!pinnedRelations.has(parseInt(objID))) {
await loadRelationVersionMembersViaOverpass(parseInt(objID), targetTimestamp, false, "#ff00e3")
}
}
i.parentElement.parentElement.onmouseenter = mouseenterHandler
objectsInComments.relations.filter(i => i.href.includes(`relation/${objID}`)).forEach(link => {
// link.title = "Alt + click for scroll into object list"
link.onmouseenter = mouseenterHandler
link.onclick = (e) => {
if (!e.altKey) return
i.scrollIntoView()
}
})
i.parentElement.parentElement.classList.add("downloaded")
i.parentElement.querySelector(".pin-relation").style.display = ""
} catch (e) {
btn.style.cursor = "pointer"
throw e
}
btn.style.visibility = "hidden"
// todo нужна кнопка с глазом чтобы можно было скрывать
}
btn.addEventListener("click", clickHandler)
btn.addEventListener("keypress", clickHandler)
i.querySelector("a:nth-of-type(2)").after(btn)
i.querySelector("a:nth-of-type(2)").after(document.createTextNode("\xA0"))
}
if (objType === "node") {
processNode()
} else if (objType === "way") {
await processWay()
} else if (objType === "relation") {
processRelation()
}
i.parentElement.parentElement.classList.add("processed-object")
}
async function processObjectsInteractions(objType, uniqTypes, changesetID) {
const objects = document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object):not(.object-in-process)`)
if (objects.length === 0) {
return;
}
objects.forEach(i => {
i.classList.add("object-in-process")
})
const objectsLinksInComments = { // todo can be optimaized
nodes: Array.from(document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="node/"]`)),
ways: Array.from(document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="way/"]`)),
relations: Array.from(document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ article div a[href*="relation/"]`))
}
try {
const needFetch = []
if (objType === "relation" && objects.length >= 2) {
for (let i of document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)) {
const [, , objID, strVersion] = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/);
const version = parseInt(strVersion)
if (version === 1) {
needFetch.push(objID + "v" + version)
needFetch.push(objID)
} else {
needFetch.push(objID + "v" + (version - 1))
needFetch.push(objID + "v" + version)
needFetch.push(objID)
}
}
const res = await fetch(osm_server.apiBase + `${objType}s.json?${objType}s=` + needFetch.join(","), {signal: getAbortController().signal});
if (res.status === 404) {
for (let i of document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)) {
await processObjectInteractions(changesetID, objType, objectsLinksInComments, i, ...getPrevTargetLastVersions(...await getHistoryAndVersionByElem(i)))
}
} else {
/**
* @type {RelationVersion[]}
*/
const versions = (await res.json()).elements
/**
* @type {Object.<number, Object.<number, RelationVersion>>}
*/
const objectsVersions = {}
Object.entries(Object.groupBy(Array.from(versions), i => i.id)).forEach(([id, history]) => {
objectsVersions[id] = Object.fromEntries(Object.entries(Object.groupBy(history, i => i.version)).map(([version, val]) => [version, val[0]]))
}
)
for (let i of document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)) {
const [, , objID, strVersion] = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/);
const version = parseInt(strVersion)
await processObjectInteractions(changesetID, objType, objectsLinksInComments, i, ...getPrevTargetLastVersions(Object.values(objectsVersions[objID]), version))
}
}
} else {
await Promise.all(Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)).map(async function (i) {
await processObjectInteractions(changesetID, objType, objectsLinksInComments, i, ...getPrevTargetLastVersions(...await getHistoryAndVersionByElem(i)))
}))
}
} finally {
objects.forEach(i => {
i.classList.remove("object-in-process")
})
}
await getChangeset(changesetID)
}
async function getHistoryAndVersionByElem(elem) {
const [, objType, objID, version] = elem.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/);
if (histories[objType][objID]) {
return [histories[objType][objID], parseInt(version)]
}
const res = await fetch(osm_server.apiBase + objType + "/" + objID + "/history.json", {signal: getAbortController().signal});
if (!res.ok) {
console.trace(objType, objID, version)
}
if (res.status === 509) {
error509Handler(res)
} else {
return [histories[objType][objID] = (await res.json()).elements, parseInt(version)];
}
}
/**
* @param {[]} objHistory
* @param {number} version
*/
function getPrevTargetLastVersions(objHistory, version) {
let prevVersion = emptyVersion;
let targetVersion = prevVersion;
let lastVersion = objHistory.at(-1);
for (const objVersion of objHistory) {
prevVersion = targetVersion
targetVersion = objVersion
if (objVersion.version === version) {
break
}
}
return [prevVersion, targetVersion, lastVersion, objHistory]
}
let quickLookStylesInjected = false
function addQuickLookStyles() {
if (quickLookStylesInjected) return
quickLookStylesInjected = true
try {
const styleText = `
.edits-wars-log tr:nth-child(even) td, .edits-wars-log tr:nth-child(even) th {
background-color: color-mix(in srgb, var(--bs-body-bg), black 25%);
}
@media ${mediaQueryForWebsiteTheme} {
.edits-wars-log tr:nth-child(even) td, .edits-wars-log tr:nth-child(even) th {
background-color: color-mix(in srgb, var(--bs-body-bg), white 5%);
}
}
tr.quick-look-new-tag th {
background: rgba(17,238,9,0.6);
}
table[contenteditable] th:not(.tag-flag) {
border: solid 2px black;
}
table[contenteditable] td:not(.tag-flag) {
border: solid 2px black;
}
tr.quick-look-modified-tag td:nth-of-type(1){
background: rgba(223,238,9,0.6);
}
tr.quick-look-deleted-tag th {
background: rgba(238,51,9,0.6);
}
tr.quick-look-new-tag td:not(.tag-flag) {
background: rgba(17,238,9,0.6);
}
tr.quick-look-deleted-tag td:not(.tag-flag) {
background: rgba(238,51,9,0.6);
}
table.quick-look.hide-non-modified-tags > tbody > .non-modified-tag-in-quick-view {
display: none;
}
.new-letter {
background: rgba(25, 223, 25, 0.6);
}
.deleted-letter {
background: rgba(255, 144, 144, 0.6);
}
@media ${mediaQueryForWebsiteTheme} {
tr.quick-look-new-tag th{
/*background: #0f540fde;*/
background: rgba(17,238,9,0.3);
/*background: rgba(87, 171, 90, 0.3);*/
}
tr.quick-look-new-tag td:not(.tag-flag){
/*background: #0f540fde;*/
background: rgba(17,238,9,0.3);
/*background: rgba(87, 171, 90, 0.3);*/
}
tr.quick-look-modified-tag td {
color: black;
}
tr.quick-look-deleted-tag th:not(.tag-flag) { /* dirty hack for zebra colors override */
/*background: #692113;*/
background: rgba(238,51,9,0.4);
/*background: rgba(229, 83, 75, 0.3);*/
}
tr.quick-look-deleted-tag td:not(.tag-flag) {
/*background: #692113;*/
background: rgba(238,51,9,0.4);
/*background: rgba(229, 83, 75, 0.3);*/
}
tr.quick-look-new-tag th::selection {
background: black !important;
}
tr.quick-look-modified-tag th::selection {
background: black !important;
}
tr.quick-look-deleted-tag th::selection {
background: black !important;
}
tr.quick-look-new-tag td::selection {
background: black !important;
}
/*tr.quick-look-modified-tag td::selection {*/
/* background: black !important;*/
/*}*/
tr.quick-look-deleted-tag td::selection {
background: black !important;
}
.new-letter {
background: rgba(25, 223, 25, 0.9);
}
.deleted-letter {
background: rgba(253, 83, 83, 0.8);
}
}
.edits-wars-tag td:nth-of-type(2)::after{
content: " ⚔️";
margin-top: 2px
}
tr.restored-tag td:nth-of-type(2)::after {
content: " ♻️";
margin-top: 2px
}
tr.restored-tag.edits-wars-tag td:nth-of-type(2)::after {
content: " ♻️⚔️";
margin-top: 2px
}
tr.removed-tag td:nth-of-type(2)::after {
content: " 🗑";
margin-top: 2px
}
tr.removed-tag.edits-wars-tag td:nth-of-type(2)::after {
content: " 🗑⚔️";
margin-top: 2px
}
tr.replaced-tag td:nth-of-type(2)::after {
content: " ⇄";
color: var(--bs-body-color);
}
tr.replaced-tag.edits-wars-tag td:nth-of-type(2)::after {
content: " ⇄⚔️";
color: var(--bs-body-color);
}
tr.reverted-tag td:nth-of-type(2)::after {
content: " ↻";
color: var(--bs-body-color);
}
tr.reverted-tag.edits-wars-tag td:nth-of-type(2)::after {
content: " ↻⚔️";
color: var(--bs-body-color);
}
span.reverted-coordinates::after {
content: " ↻";
position: absolute;
color: var(--bs-body-color);
}
table.browse-tag-list tr td[colspan="2"]{
background: var(--bs-body-bg) !important;
}
ul:has(li[hidden]):after {
color: var(--bs-body-color);
content: attr(hidden-nodes-count) ' uninteresting nodes hidden';
font-style: italic;
font-weight: normal;
font-size: small;
opacity: 0.5;
}
`
+ ((GM_config.get("ShowChangesetGeometry")) ? `
#sidebar_content #changeset_nodes li:hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content #changeset_ways li:hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content #changeset_nodes li.map-hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content #changeset_ways li.map-hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content #changeset_relations li.map-hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content #changeset_relations li.downloaded:hover {
background-color: rgba(223, 223, 223, 0.6);
}
.location-modified-marker-warn::after:hover {
background-color: rgba(223, 223, 223, 0.6);;
}
#sidebar_content .browse-section details.way-version-nodes li:hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content .browse-section details.way-version-nodes li:hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content .browse-section details.way-version-nodes li.map-hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content .browse-section details.way-version-nodes li.map-hover {
background-color: rgba(223, 223, 223, 0.6);
}
#sidebar_content .browse-section details.way-version-nodes li.downloaded:hover {
background-color: rgba(223, 223, 223, 0.6);
}
.location-modified-marker-warn::after:hover {
background-color: rgba(223, 223, 223, 0.6);;
}
@media ${mediaQueryForWebsiteTheme} {
#sidebar_content #changeset_nodes li:hover {
background-color: rgb(14, 17, 19);
}
#sidebar_content #changeset_ways li:hover {
background-color: rgb(14, 17, 19);
}
#sidebar_content #changeset_nodes li.map-hover {
background-color: rgb(14, 17, 19);
}
#sidebar_content #changeset_ways li.map-hover {
background-color: rgb(14, 17, 19);
}
#sidebar_content #changeset_relations li.map-hover {
background-color: rgb(14, 17, 19);
}
#sidebar_content #changeset_relations li.downloaded:hover {
background-color: rgb(14, 17, 19);
}
#sidebar_content .browse-section details.way-version-nodes li:hover {
background-color: rgb(52,61,67);
}
#sidebar_content .browse-section details.way-version-nodes li:hover {
background-color: rgb(52,61,67);
}
#sidebar_content .browse-section details.way-version-nodes li.map-hover {
background-color: rgb(52,61,67);
}
#sidebar_content .browse-section details.way-version-nodes li.map-hover {
background-color: rgb(52,61,67);
}
#sidebar_content .browse-section details.way-version-nodes li.downloaded:hover {
background-color: rgb(52,61,67);
}
.location-modified-marker-warn::after:hover {
background-color: rgb(14, 17, 19);
}
}
.location-modified-marker-warn::after {
content: " ⚠️";
background: var(--bs-body-bg);
}
.location-modified-marker:hover {
background: #0022ff82 !important;
}
.way-version-node:hover {
background-color: #ff00e3 !important;
}
.relation-version-node:hover {
background-color: #ff00e3 !important;
}
.leaflet-fade-anim .leaflet-popup {
transition: none;
}
@media (prefers-color-scheme: dark) {
path.stroke-polyline {
filter: drop-shadow(1px 1px 0 #7a7a7a) drop-shadow(-1px -1px 0 #7a7a7a) drop-shadow(1px -1px 0 #7a7a7a) drop-shadow(-1px 1px 0 #7a7a7a);
}
}
` : "");
injectCSSIntoOSMPage(styleText)
} catch { /* empty */
}
}
function removeEditTagsButton() {
if (location.pathname.startsWith("/changeset/")) {
if (!document.querySelector(".secondary-actions .edit_tags_class")) {
const tagsEditorExtensionWaiter = new MutationObserver(() => {
document.querySelector(".edit_tags_class")?.previousSibling?.remove()
document.querySelector(".edit_tags_class")?.remove()
})
tagsEditorExtensionWaiter.observe(document.querySelector(".secondary-actions"), {
childList: true,
subtree: true
})
setTimeout(() => tagsEditorExtensionWaiter.disconnect(), 3000)
} else {
document.querySelector(".edit_tags_class")?.previousSibling?.remove()
document.querySelector(".edit_tags_class")?.remove()
}
}
}
async function preloadChangeset(changesetID) {
console.log(`c${changesetID} preloading`)
performance.mark("PRELOADING_" + changesetID)
const ways = (await getChangeset(changesetID)).data.querySelectorAll("way")
Array.from(ways).slice(0, 5).forEach(way => {
getWayHistory(way.id)
})
performance.mark("END_PRELOADING_" + changesetID)
console.log(`c${changesetID} preloaded`)
}
function preloadPrevNextChangesets() {
console.debug("Preloading changesets")
const prevLink = getPrevChangesetLink()
if (prevLink) {
const changesetID = prevLink.href.match(/\/changeset\/(\d+)/)[1]
setTimeout(preloadChangeset, 0, changesetID)
}
const nextLink = getNextChangesetLink()
if (nextLink) {
const changesetID = nextLink.href.match(/\/changeset\/(\d+)/)[1]
setTimeout(preloadChangeset, 0, changesetID)
}
needPreloadChangesets = false
}
/**
* @param {number|string} nodeID
* @return {Promise<WayVersion[]>}
*/
async function getParentWays(nodeID) {
const rawRes = await fetch(osm_server.apiBase + "node/" + nodeID + "/ways.json", {signal: getAbortController().signal});
if (rawRes.status === 509) {
error509Handler(rawRes)
} else {
if (!rawRes.ok) {
console.warn(`fetching parent ways for ${nodeID} failed`)
console.trace()
return []
}
return (await rawRes.json()).elements;
}
}
async function processQuickLookInSidebar(changesetID) {
const interceptMapManuallyPromise = interceptMapManually()
const multipleChangesets = location.search.includes("changesets=")
async function processObjects(objType, uniqTypes) {
pinnedRelations = new Set()
const objects = document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object):not(.object-in-process)`)
if (objects.length === 0) {
return;
}
objects.forEach(i => {
i.classList.add("object-in-process")
})
const needFetch = []
try {
if (objType === "relation") {
for (let i of document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)) {
const [, , objID, strVersion] = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/);
const version = parseInt(strVersion)
if (version === 1) {
needFetch.push(objID + "v" + version)
needFetch.push(objID)
} else {
needFetch.push(objID + "v" + (version - 1))
needFetch.push(objID + "v" + version)
needFetch.push(objID)
}
}
const res = await fetch(osm_server.apiBase + `${objType}s.json?${objType}s=` + needFetch.join(","), {signal: getAbortController().signal});
if (res.status === 404) {
for (let i of document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)) {
await processObject(i, objType, ...getPrevTargetLastVersions(...await getHistoryAndVersionByElem(i)))
}
} else {
/**
* @type {RelationVersion[]}
*/
const versions = (await res.json()).elements
/**
* @type {Object.<number, Object.<number, RelationVersion>>}
*/
const objectsVersions = {}
Object.entries(Object.groupBy(Array.from(versions), i => i.id)).forEach(([id, history]) => {
objectsVersions[id] = Object.fromEntries(Object.entries(Object.groupBy(history, i => i.version)).map(([version, val]) => [version, val[0]]))
}
)
for (let i of document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`)) {
const [, , objID, strVersion] = i.querySelector("a:nth-of-type(2)").href.match(/(node|way|relation)\/(\d+)\/history\/(\d+)$/);
const version = parseInt(strVersion)
await processObject(i, objType, ...getPrevTargetLastVersions(Object.values(objectsVersions[objID]), version))
}
}
} else {
const elems = Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li:not(.processed-object) div div`));
for (const elem of arraySplit(elems, elems.length > 520 ? 10 : 1)) {
await Promise.all(elem.map(async function (i) {
await processObject(i, objType, ...getPrevTargetLastVersions(...await getHistoryAndVersionByElem(i)))
}))
}
}
} finally {
objects.forEach(i => {
i.classList.remove("object-in-process")
})
}
// reorder non-interesting-objects
const objectsList = document.querySelector(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li`).parentElement
Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-uninterested-modified.location-modified`)).forEach(i => {
objectsList.appendChild(i)
})
Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-uninterested-modified:not(.location-modified)`)).forEach(i => {
objectsList.appendChild(i)
})
Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-non-modified`)).forEach(i => {
objectsList.appendChild(i)
})
Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-non-modified:not(.location-modified)`)).forEach(i => {
objectsList.appendChild(i)
})
if (multipleChangesets) {
document.querySelectorAll("#changeset_nodes .tags-non-modified:not(.location-modified)").forEach(i => {
i.setAttribute("hidden", "true")
})
}
//<editor-fold desc="setup compact mode toggles">
let compactToggle = document.createElement("button")
compactToggle.title = "Toggle between full and compact tags diff.\nYou can also use the T key."
compactToggle.textContent = tagsOfObjectsVisible ? "><" : "<>"
compactToggle.classList.add("quick-look-compact-toggle-btn")
compactToggle.classList.add("btn", "btn-sm", "btn-primary")
compactToggle.classList.add("quick-look")
compactToggle.onclick = (e) => {
const needHideNodes = location.search.includes("changesets=")
const state = e.target.textContent === "><"
document.querySelectorAll(".quick-look-compact-toggle-btn").forEach(i => {
if (state) {
i.textContent = "<>"
} else {
i.textContent = "><"
}
})
tagsOfObjectsVisible = !tagsOfObjectsVisible
const shouldBeHidden = e.target.textContent === "<>"
document.querySelectorAll("table.quick-look").forEach(el => {
el.classList.toggle("hide-non-modified-tags", shouldBeHidden)
});
if (needHideNodes) {
if (e.target.textContent === "><") {
document.querySelectorAll("#changeset_nodes .tags-non-modified:not(.location-modified)").forEach(i => {
i.setAttribute("hidden", "true")
})
document.querySelectorAll("#changeset_nodes").forEach(i => {
if (!i.querySelector("li:not([hidden])")) {
i.setAttribute("hidden", "true")
}
})
} else {
document.querySelectorAll("#changeset_nodes .tags-non-modified:not(.location-modified)").forEach(i => {
i.removeAttribute("hidden")
})
document.querySelectorAll("#changeset_nodes").forEach(i => {
i.removeAttribute("hidden")
})
}
}
if (e.target.textContent === "><") {
if (!e.altKey) {
document.querySelectorAll(".preview-img-link img").forEach(i => {
i.style.display = ""
})
}
} else {
if (!e.altKey) {
document.querySelectorAll(".preview-img-link img").forEach(i => {
i.style.display = "none"
})
}
}
}
const objectListSection = document.querySelector(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li`).parentElement.parentElement.querySelector("h4")
if (!objectListSection.querySelector(".quick-look-compact-toggle-btn")) {
objectListSection.appendChild(compactToggle)
}
compactToggle.before(document.createTextNode("\xA0"))
if (uniqTypes === 1 && document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li .non-modified-tag-in-quick-view`).length < 5) {
compactToggle.style.display = "none"
document.querySelectorAll(".non-modified-tag-in-quick-view").forEach(i => {
i.removeAttribute("hidden")
});
}
if (multipleChangesets && compactToggle.style.display !== "none") {
document.querySelectorAll(`[changeset-id="${changesetID}"]`).forEach(changeset => {
const forHide = document.querySelectorAll(`[changeset-id="${changeset.getAttribute("changeset-id")}"]#changeset_nodes .tags-non-modified:not(.location-modified)`);
forHide.forEach(i => {
i.setAttribute("hidden", "true")
})
document.querySelectorAll(`[changeset-id="${changeset.getAttribute("changeset-id")}"]#changeset_nodes`).forEach(i => {
if (!i.querySelector("li:not([hidden])")) {
i.setAttribute("hidden", "true")
}
})
})
}
//</editor-fold>
}
try {
console.time(`QuickLook ${changesetID}`)
console.log(`%cQuickLook for ${changesetID}`, 'background: #222; color: #bada55')
let uniqTypes = 0
for (const objType of ["way", "node", "relation"]) {
if (document.querySelectorAll(`.list-unstyled li.${objType}`).length > 0) {
uniqTypes++;
}
}
for (const objType of ["way", "node", "relation"]) {
await processObjects(objType, uniqTypes);
}
const changesetDataPromise = getChangeset(changesetID);
await interceptMapManuallyPromise;
for (const objType of ["way", "node", "relation"]) {
await processObjectsInteractions(objType, uniqTypes, changesetID);
}
const changesetData = (await changesetDataPromise).data;
function replaceNodes(changesetData) {
const pagination = Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_nodes .pagination`)).find(i => {
return Array.from(i.querySelectorAll("a.page-link")).some(a => a.href?.includes("node"))
})
if (!pagination) return
const ul = pagination.parentElement.querySelector("ul.list-unstyled")
const nodes = changesetData.querySelectorAll("node")
const other = changesetData.querySelectorAll("way,relation").length
if (nodes.length > 1200) {
if (nodes.length > 3500 || other > 10 || isMobile) { // fixme bump
return;
}
}
pagination.remove();
try {
document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes h4 .count-number`).textContent = `1-${nodes.length}`;
} catch (e) {
console.error(e)
}
nodes.forEach(node => {
if (document.getElementById(`${changesetID}n${node.id}`)) {
return
}
const ulItem = document.createElement("li");
const div1 = document.createElement("div")
div1.classList.add("d-flex", "gap-1")
ulItem.appendChild(div1)
try {
const [iconSrc, invert] = getPOIIconURL("node", Array.from(node.querySelectorAll('tag[k]')).map(i => [i.getAttribute("k"), i.getAttribute("v")]))
div1.appendChild(GM_addElement("img", {
src: iconSrc,
height: 20,
width: 20,
class: "align-bottom object-fit-none browse-icon" + (invert ? " browse-icon-invertible" : "")
})
)
} catch (e) {
console.error(e)
const img = document.createElement("img")
img.height = 20
img.width = 20
img.style.visibility = "hidden"
div1.appendChild(img)
}
const div2 = document.createElement("div")
div2.classList.add("align-self-center")
div1.appendChild(div2)
div2.classList.add("node");
div2.id = `${changesetID}n${node.id}`
const nodeLink = document.createElement("a")
nodeLink.rel = "nofollow"
nodeLink.href = `/node/${node.id}`
if (node.querySelector('tag[k="name"]')?.getAttribute("v")) {
nodeLink.textContent = `${node.querySelector('tag[k="name"]')?.getAttribute("v")} (${node.id})`
} else {
nodeLink.textContent = node.id
}
div2.appendChild(nodeLink)
div2.appendChild(document.createTextNode(", "))
const versionLink = document.createElement("a")
versionLink.rel = "nofollow"
versionLink.href = `/node/${node.id}/history/${node.getAttribute("version")}`
versionLink.textContent = "v" + node.getAttribute("version")
div2.appendChild(versionLink)
Array.from(node.children).forEach(i => {
// todo
if (mainTags.includes(i.getAttribute("k"))) {
div2.classList.add(i.getAttribute("k"))
div2.classList.add(i.getAttribute("v"))
}
})
if (node.getAttribute("visible") === "false") {
div2.innerHTML = "<s>" + div2.innerHTML + "</s>"
}
ul.appendChild(ulItem)
})
}
// todo unify
function replaceWays(changesetData) {
const pagination = Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_ways .pagination`)).find(i => {
return Array.from(i.querySelectorAll("a.page-link")).some(a => a.href?.includes("way"))
})
if (!pagination) return
const ul = pagination.parentElement.querySelector("ul.list-unstyled")
const ways = changesetData.querySelectorAll("way")
if (ways.length > 50) {
if (ways.length > 200 && changesetData.querySelectorAll("node") > 40) {
return;
}
if (ways.length > 520 && isMobile) {
return;
}
if (ways.length > 1520) {
return
}
}
pagination.remove();
try {
document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways h4 .count-number`).textContent = `1-${ways.length}`
} catch (e) {
console.error(e)
}
ways.forEach(way => {
if (document.getElementById(`${changesetID}w${way.id}`)) {
return
}
const ulItem = document.createElement("li");
const div1 = document.createElement("div")
div1.classList.add("d-flex", "gap-1")
ulItem.appendChild(div1)
try {
const [iconSrc, invert] = getPOIIconURL("way", Array.from(way.querySelectorAll('tag[k]')).map(i => [i.getAttribute("k"), i.getAttribute("v")]))
div1.appendChild(GM_addElement("img", {
src: iconSrc,
height: 20,
width: 20,
class: "align-bottom object-fit-none browse-icon" + (invert ? " browse-icon-invertible" : "")
})
)
} catch (e) {
console.error(e)
const img = document.createElement("img")
img.height = 20
img.width = 20
img.style.visibility = "hidden"
div1.appendChild(img)
}
const div2 = document.createElement("div")
div2.classList.add("align-self-center")
div1.appendChild(div2)
div2.classList.add("way");
div2.id = `${changesetID}w${way.id}`
const wayLink = document.createElement("a")
wayLink.rel = "nofollow"
wayLink.href = `/way/${way.id}`
if (way.querySelector('tag[k="name"]')?.getAttribute("v")) {
wayLink.textContent = `${way.querySelector('tag[k="name"]')?.getAttribute("v")} (${way.id})`
} else {
wayLink.textContent = way.id
}
div2.appendChild(wayLink)
div2.appendChild(document.createTextNode(", "))
const versionLink = document.createElement("a")
versionLink.rel = "nofollow"
versionLink.href = `/way/${way.id}/history/${way.getAttribute("version")}`
versionLink.textContent = "v" + way.getAttribute("version")
div2.appendChild(versionLink)
Array.from(way.children).forEach(i => {
// todo
if (["shop", "building", "amenity", "man_made", "highway", "natural"].includes(i.getAttribute("k"))) {
div2.classList.add(i.getAttribute("k"))
div2.classList.add(i.getAttribute("v"))
}
})
if (way.getAttribute("visible") === "false") {
div2.innerHTML = "<s>" + div2.innerHTML + "</s>"
}
ul.appendChild(ulItem)
})
}
try {
await initPOIIcons()
} catch (e) {
console.log(e)
console.trace()
}
replaceWays(changesetData)
await processObjects("way", uniqTypes);
await processObjectsInteractions("way", uniqTypes, changesetID);
replaceNodes(changesetData)
await processObjects("node", uniqTypes);
await processObjectsInteractions("node", uniqTypes, changesetID);
function observePagination(obs) {
if (document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes .pagination`)) {
obs.observe(document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes`), {
attributes: true
})
}
if (document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways .pagination`)) {
obs.observe(document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways`), {
attributes: true
})
}
if (document.querySelector(`[changeset-id="${changesetID}"]#changeset_relations .pagination`)) {
obs.observe(document.querySelector(`[changeset-id="${changesetID}"]#changeset_relations`), {
attributes: true
})
}
}
const obs = new MutationObserver(async (mutationList, observer) => {
observer.disconnect()
observer.takeRecords()
for (const objType of ["way", "node", "relation"]) {
await processObjects(objType, uniqTypes);
}
for (const objType of ["way", "node", "relation"]) {
await processObjectsInteractions(objType, uniqTypes, changesetID);
}
observePagination(obs)
})
observePagination(obs)
// try find parent ways
async function findParents() {
performance.mark("FIND_PARENTS_BEGIN_" + changesetID)
const nodesCount = changesetData.querySelectorAll(`node`)
for (const i of changesetData.querySelectorAll(`node[version="1"]`)) {
const nodeID = i.getAttribute("id")
if (!i.querySelector("tag")) {
if (i.getAttribute("visible") === "false") {
// todo
} else if (i.getAttribute("version") === "1" && !(await getChangeset(parseInt(changesetID))).nodesWithParentWays.has(parseInt(nodeID))) {
showNodeMarker(i.getAttribute("lat"), i.getAttribute("lon"), "#00a500", changesetID + "n" + nodeID)
}
}
}
/**
* @type {Set<number>}
*/
const processedNodes = new Set();
/**
* @type {Set<number>}
*/
const processedWays = new Set();
// fixme
const changesetWaysSet = new Set(Array.from(changesetData.querySelectorAll(`way`)).map(i => parseInt(i.id)))
const loadNodesParents = async nodes => {
for (const nodeID of nodes) {
if ((await getChangeset(parseInt(changesetID))).nodesWithParentWays.has(nodeID) && nodesCount > 30 || processedNodes.has(parseInt(nodeID))) {
continue;
}
const parents = await getParentWays(nodeID)
await Promise.all(parents.map(
async way => {
if (processedWays.has(way.id) || changesetWaysSet.has(way.id)) {
return
}
processedWays.add(way.id)
way.nodes.forEach(node => {
processedNodes.add(node)
})
const objID = way.id
const res = await fetch(osm_server.apiBase + "way" + "/" + way.id + "/full.json", {signal: getAbortController().signal});
if (!res.ok) {
// крааайне маловероятно
return;
}
const lastElements = (await res.json()).elements
lastElements.forEach(n => {
if (n.type !== "node") return
if (n.version === 1) {
nodesHistories[n.id] = [n]
}
})
if (!changesetMetadatas[changesetID]) {
console.log(`changesetMetadata[${changesetID}] not ready. Wait second...`)
await abortableSleep(1000, getAbortController()) // todo нужно поретраить
}
const res2 = await getWayNodesByTimestamp(changesetMetadatas[changesetID].closed_at, objID)
if (!res2) {
// если линия создана после правки
console.log(`skip parent w${objID} for ${nodeID}`);
return
}
const [targetVersion, currentNodesList] = res2
if (targetVersion.visible === false) {
console.log(`skip parent w${objID} for ${nodeID} because version not visible`);
return
}
const popup = document.createElement("span")
const link = document.createElement("a")
link.href = `/way/${way.id}`
link.target = "_blank"
link.textContent = "w" + way.id
const tagsTable = document.createElement("table")
const tbody = document.createElement("tbody")
Object.entries(way.tags ?? {}).forEach(tag => {
const row = document.createElement("tr")
const tagTd = document.createElement("th")
const tagTd2 = document.createElement("td")
tagTd.style.borderWidth = "2px"
tagTd2.style.borderWidth = "2px"
row.style.borderWidth = "2px"
row.appendChild(tagTd)
row.appendChild(tagTd2)
tagTd.textContent = tag[0]
tagTd2.textContent = tag[1]
tagTd.style.textAlign = "right"
tagTd2.style.textAlign = "right"
tbody.appendChild(row)
})
tagsTable.appendChild(tbody)
popup.appendChild(link)
popup.appendChild(tagsTable)
// todo показать по ховеру прошлую версию?
const line = displayWay(cloneInto(currentNodesList, unsafeWindow), false, "rgba(55,55,55,0.5)", 4, changesetID + "n" + nodeID, "customObjects", null, popup.outerHTML, darkModeForMap && isDarkMode())
if (layersHidden) {
line.getElement().style.visibility = "hidden"
}
// ховер в списке объектов, который показывает родительскую линию
way.nodes.forEach(n => {
if (!document.getElementById(`${changesetID}n${n}`)) return
document.getElementById(`${changesetID}n${n}`).parentElement.parentElement.addEventListener('mouseover', async (e) => {
if (e.relatedTarget?.parentElement === e.target) {
return
}
showActiveWay(cloneInto(currentNodesList, unsafeWindow))
resetMapHover()
const targetTimestamp = (new Date(new Date(changesetMetadatas[changesetID].created_at).getTime() - 1)).toISOString()
if (targetVersion.version > 1) {
// show prev version
const prevVersion = searchVersionByTimestamp(await getWayHistory(way.id), targetTimestamp);
const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version);
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
showActiveWay(cloneInto(nodesList, unsafeWindow), "rgb(238,146,9)", false, changesetID + "w" + objID, false, 4, "4, 4")
// showActiveWay(cloneInto(currentNodesList, unsafeWindow), "rgba(55,55,55,0.5)", false, objID, false)
} else {
const prevVersion = searchVersionByTimestamp(await getWayHistory(way.id), targetTimestamp);
if (prevVersion) {
const [, nodesHistory] = await loadWayVersionNodes(objID, prevVersion.version);
const nodesList = filterObjectListByTimestamp(nodesHistory, targetTimestamp)
showActiveWay(cloneInto(nodesList, unsafeWindow), "rgb(238,146,9)", false, changesetID + "w" + objID, false, 4, "4, 4")
// showActiveWay(cloneInto(currentNodesList, unsafeWindow), "rgba(55,55,55,0.5)", false, objID, false)
}
}
const curVersion = searchVersionByTimestamp(await getNodeHistory(n), changesetMetadatas[changesetID].closed_at ?? new Date())
if (curVersion.version > 1) {
const prevVersion = searchVersionByTimestamp(await getNodeHistory(n), targetTimestamp)
showActiveNodeMarker(prevVersion.lat.toString(), prevVersion.lon.toString(), "#0022ff", false)
}
showActiveNodeMarker(curVersion.lat.toString(), curVersion.lon.toString(), "#ff00e3", false)
})
})
}
)
)
}
};
const nodesWithModifiedLocation = Array.from(document.querySelectorAll("#changeset_nodes .location-modified div div")).map(i => parseInt(i.id.match(/n(\d+)/)[1]))
await Promise.all(arraySplit(nodesWithModifiedLocation, 4).map(loadNodesParents))
// fast hack
// const someInterestingNodes = Array.from(changesetData.querySelectorAll("node")).filter(i => i.querySelector("tag[k=power],tag[k=entrance]")).map(i => parseInt(i.id))
// await Promise.all(arraySplit(someInterestingNodes, 4).map(loadNodesParents))
performance.mark("FIND_PARENTS_END_" + changesetID)
console.debug(performance.measure("FIND_PARENTS", {
start: "FIND_PARENTS_BEGIN_" + changesetID
}), "FIND_PARENTS_END_" + changesetID);
}
if (GM_config.get("ShowChangesetGeometry")) {
console.log("%cTry find parents ways", 'background: #222; color: #bada55')
if (multipleChangesets) { // todo не стоит если пакетов мало?
findParents().then(() => {
console.log(`Parents with ${changesetID} loaded`);
})
} else {
await findParents()
}
}
} catch (err) { // TODO notify user
if (err.name === "AbortError") {
console.debug("Some requests was aborted")
} else {
console.error(err)
console.log("%cSetup QuickLook finished with error ⚠️", 'background: #222; color: #bada55')
function makeGithubIssueLink(text) {
const a = document.createElement("a")
a.classList.add("crash-report-link")
a.href = "https://github.com/deevroman/better-osm-org/issues/new?"
+ new URLSearchParams({
body: text,
title: "Crash Report",
labels: "bug,crash"
}).toString()
a.target = "_blank"
a.appendChild(document.createTextNode("Send Bug Report"))
a.title = "better-osm-org was unable to display some data"
return a
}
if (![ABORT_ERROR_PREV, ABORT_ERROR_NEXT, ABORT_ERROR_USER_CHANGESETS, ABORT_ERROR_WHEN_PAGE_CHANGED].includes(err) && getMap().getZoom) {
// eslint-disable-next-line no-debugger
debugger
try {
const reportText = `
**Page:** ${location.origin}${location.pathname}
**Error:** \`${err.toString().replace("`", "\\`")}\`
**StackTrace:**
\`\`\`
${err.stack.replace("`", "\\`").replaceAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gm, "<hidden>")}
\`\`\`
**Script handler:** \`${GM_info.scriptHandler} v${GM_info.version}\`
**UserAgent:** \`${JSON.stringify(GM_info.userAgentData)}\`
`
if (!document.querySelector(".crash-report-link")) {
document.querySelector("#sidebar_content .secondary-actions").appendChild(document.createTextNode(" · "))
document.querySelector("#sidebar_content .secondary-actions").appendChild(makeGithubIssueLink(reportText))
}
if (isDebug()) {
getMap()?.attributionControl?.setPrefix("⚠️")
}
} catch { /* empty */
}
if (isDebug()) {
alert("⚠ read logs.\nOnly the script developer should see this message")
}
// eslint-disable-next-line no-debugger
debugger
throw err
}
}
} finally {
quickLookInjectingStarted = false
console.timeEnd(`QuickLook ${changesetID}`)
console.log("%cSetup QuickLook finished", 'background: #222; color: #bada55')
// todo mark changeset as reviewed
}
}
const currentChangesets = [];
function drawBBox(bbox, options = {color: "#ff7800", weight: 1, fillOpacity: 0}) {
try {
const bottomLeft = getMap().project(getWindow().L.latLng(bbox.min_lat, bbox.min_lon));
const topRight = getMap().project(getWindow().L.latLng(bbox.max_lat, bbox.max_lon));
const width = topRight.x - bottomLeft.x;
const height = bottomLeft.y - topRight.y;
const minSize = 10;
if (width < minSize) {
bottomLeft.x -= ((minSize - width) / 2);
topRight.x += ((minSize - width) / 2);
}
if (height < minSize) {
bottomLeft.y += ((minSize - height) / 2);
topRight.y -= ((minSize - height) / 2);
}
const b = getWindow().L.latLngBounds(
getMap().unproject(intoPage(bottomLeft)),
getMap().unproject(intoPage(topRight))
)
const bound = getWindow().L.rectangle(
intoPage([
[b.getSouth(), b.getWest()],
[b.getNorth(), b.getEast()]
]),
intoPage(options)
);
bound.on('click', intoPageWithFun(function () {
const elementById = document.getElementById(bbox.id);
elementById?.scrollIntoView()
resetMapHover()
elementById?.parentElement?.parentElement?.classList.add("map-hover")
cleanObjectsByKey("activeObjects")
}))
bound.addTo(getMap());
bound.bringToBack()
layers['changesetBounds'].push(bound)
return bound
} catch (err) {
console.trace(err)
}
}
async function processQuickLookForCombinedChangesets(changesetID, changesetIDs) {
await loadChangesetMetadatas(changesetIDs)
await zoomToChangesets()
for (let curID of changesetIDs) {
currentChangesets.push(changesetMetadatas[curID]);
}
if (!layers['changesetBounds']) {
layers['changesetBounds'] = []
}
for (let bbox of currentChangesets) {
drawBBox(bbox)
}
drawBBox(changesetMetadatas[changesetID])
getMap().on("moveend zoomend", intoPageWithFun(function () {
if (layersHidden) return
for (let bound of layers["changesetBounds"]) {
bound.remove()
}
layers["changesetBounds"] = []
for (let bbox of currentChangesets) {
drawBBox(bbox)
}
drawBBox(changesetMetadatas[changesetID])
}))
const step = 10
const changesetsQueue = []
if (changesetIDs.length) {
// preloading
changesetIDs.slice(0, step).forEach(i => {
changesetsQueue.push(GM.xmlHttpRequest({
url: osm_server.url + "/changeset/" + i
}))
})
}
// MORE PRELOADING
const waysForPreload = []
await Promise.all(changesetIDs.map(async i => {
const data = (await getChangeset(i)).data
Array.from(data.querySelectorAll("way:not([version='1'])")).map(i => waysForPreload.push(parseInt(i.getAttribute("id"))))
}))
await Promise.all(Array.from(new Set(waysForPreload)).map(i => getWayHistory(i)))
for (let i = 0; i < changesetIDs.length; i++) {
console.log(`${i + 1} / ${changesetIDs.length}`)
let curID = changesetIDs[i];
let res = await changesetsQueue.shift()
const parser = new DOMParser()
const doc = parser.parseFromString(res.response, "text/html")
const newPrevLink = getPrevChangesetLink(doc)
if (newPrevLink) {
const prevLink = getPrevChangesetLink()
const prevID = extractChangesetID(prevLink.href)
const newPrevID = extractChangesetID(newPrevLink.href)
prevLink.childNodes[2].textContent = prevLink.childNodes[2].textContent.replace(prevID, newPrevID)
prevLink.href = "/changeset/" + newPrevID
}
const divID = document.createElement("a")
divID.id = curID
divID.textContent = "#" + curID
divID.href = "/changeset/" + curID
divID.style.color = "var(--bs-body-color)"
// todo add comment
document.querySelector("turbo-frame:last-of-type").after(divID)
let prevFrame = null;
doc.querySelectorAll("turbo-frame").forEach(frame => {
frame.setAttribute("changeset-id", curID)
if (prevFrame) {
prevFrame.after(frame)
} else {
divID.after(frame)
prevFrame = frame
}
})
setTimeout(async () => {
await loadChangesetMetadata(parseInt(curID))
const span = document.createElement("span")
span.textContent = " " + shortOsmOrgLinksInText(changesetMetadatas[curID].tags["comment"] ?? "") // todo trim
span.title = " " + (changesetMetadatas[curID].tags["comment"] ?? "")
span.style.color = "gray"
divID.after(span)
})
const promise = processQuickLookInSidebar(curID);
if (i + step < changesetIDs.length) {
changesetsQueue.push(GM.xmlHttpRequest({
url: osm_server.url + "/changeset/" + changesetIDs[i + step]
}))
}
await promise;
}
}
/*
function interceptRectangle() {
console.log("intercept rectangle");
injectJSIntoPage(`
var layers = {}
function makeColor(username) {
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + 42 + ((hash << 5) - hash);
}
return '#' + ((hash >> 16) & 0xFFFFFF).toString(16).padStart(6, '0');
}
if (!window.rectangleIntercepted) {
L.Rectangle.addInitHook((function () {
return
this.better_id = -1
const layer = this
Object.defineProperty(
this,
'id',
{
get: function () {
try {
const username = document.querySelector('#changeset_' + this.better_id + ' a[href^="/user/"]').textContent
// debugger
this.options.color = makeColor(username)
} catch (e) {
}
return this.better_id
},
set: function (val) {
if (location.pathname !== "/history") {
this.better_id = val;
return;
}
const username = document.querySelector('#changeset_' + val + ' a[href^="/user/"]').textContent
this.options.color = makeColor(username)
this.better_id = val
}
}
);
Object.defineProperty(
this.options,
'color',
{
get: function () {
if (location.pathname !== "/history") {
this.better_id = val;
return;
}
const username = document.querySelector('#changeset_' + layer.better_id + ' a[href^="/user/"]')?.textContent
if (!username) return "#000"
return makeColor(username)
// return this.better_options
},
set: function (color) {
// debugger
const username = document.querySelector('#changeset_' + layer.better_id + ' a[href^="/user/"]')?.textContent
if (!username) return color
return makeColor(username)
}
}
);
})
)
window.rectangleIntercepted = true
}
`)
}
*/
async function interceptMapManually() {
// if (!getWindow().rectangleIntercepted) {
// interceptRectangle()
// }
if (getWindow().mapIntercepted) return
try {
console.warn("try intercept map manually")
injectJSIntoPage(`
L.Layer.addInitHook(function () {
if (window.mapIntercepted) return
try {
this.addEventListener("add", (e) => {
if (window.mapIntercepted) return;
console.log("%cMap intercepted with workaround", 'background: #000; color: #0f0')
window.mapIntercepted = true
window.map = e.target._map;
})
} catch (e) {
console.error(e)
}
}
)
`)
// trigger Layer creation
let exportImageBtn = document.querySelector("#export-image #image_filter");
if (!exportImageBtn) {
await sleep(10)
exportImageBtn = document.querySelector("#export-image #image_filter")
}
exportImageBtn.click()
exportImageBtn.click()
if (!getWindow().mapIntercepted) {
console.warn("wait for map intercepting")
await sleep(9)
}
} catch (e) {
console.error(e)
}
}
async function addChangesetQuickLook() {
if (quickLookInjectingStarted) return
if (!location.pathname.startsWith("/changeset")) {
tagsOfObjectsVisible = true
return
}
addQuickLookStyles();
if (document.querySelector('.quick-look')) return true;
const sidebar = document.querySelector("#sidebar_content h2");
if (!sidebar) {
return;
}
if (!document.querySelector("turbo-frame")) {
console.log("changeset is empty")
return
}
quickLookInjectingStarted = true
resetSearchFormFocus();
addRegionForFirstChangeset();
makeTimesSwitchable()
if (GM_config.get("ResizableSidebar")) {
document.querySelector("#sidebar").style.resize = "horizontal"
}
addSwipes();
removeEditTagsButton();
const changesetID = location.pathname.match(/changeset\/(\d+)/)[1]
document.querySelectorAll("turbo-frame").forEach(i => i.setAttribute("changeset-id", changesetID))
const params = new URLSearchParams(location.search)
let changesetIDs = [];
if (params.get("changesets")) {
changesetIDs = params.get("changesets")?.split(",")?.filter(i => i !== changesetID) ?? []
}
if (!GM_config.get("NavigationViaHotkeys")) {
setTimeout(loadChangesetMetadata, 0)
}
await processQuickLookInSidebar(changesetID);
if (changesetIDs.length) {
await processQuickLookForCombinedChangesets(changesetID, changesetIDs);
}
if (needPreloadChangesets) {
preloadPrevNextChangesets(changesetID);
}
}
function setupChangesetQuickLook(path) {
if (!path.startsWith("/changeset")) return;
let timerId = setInterval(addChangesetQuickLook, 100);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add QuickLook');
}, 3000);
void addChangesetQuickLook();
}
const rapidLink = "https://mapwith.ai/rapid#poweruser=true&map="
let coordinatesObserver = null;
function setupNewEditorsLinks() {
const firstRun = document.getElementsByClassName("custom_editors").length === 0
let editorsList = document.querySelector("#edit_tab ul");
if (!editorsList) {
return;
}
const curURL = editorsList.querySelector("li a").href
const match = curURL.match(/map=(\d+)\/([-\d.]+)\/([-\d.]+)(&|$)/)
if (!match && !curURL.includes("edit?editor=id")) {
return
}
try {
coordinatesObserver?.disconnect()
if (!curURL.includes("edit?editor=id#") || !match) {
return;
}
const zoom = match[1]
const lat = match[2]
const lon = match[3]
{
// Rapid
let newElem;
if (firstRun) {
newElem = editorsList.querySelector("li").cloneNode(true);
newElem.classList.add("custom_editors", "rapid_btn")
newElem.querySelector("a").textContent = "Edit with Rapid"
} else {
newElem = document.querySelector(".rapid_btn")
}
newElem.querySelector("a").href = `${rapidLink}${zoom}/${lat}/${lon}`
if (firstRun) {
editorsList.appendChild(newElem)
}
}
/*
{
// geo:
let newElem;
if (firstRun) {
newElem = editorsList.querySelector("li").cloneNode(true);
newElem.classList.add("custom_editors", "geo_btn")
newElem.querySelector("a").textContent = "Open geo:"
} else {
newElem = document.querySelector(".geo_btn")
}
newElem.querySelector("a").href = `geo:${lat},${lon}?z=${zoom}`
if (firstRun) {
editorsList.appendChild(newElem)
}
}
*/
} finally {
coordinatesObserver = new MutationObserver(setupNewEditorsLinks);
coordinatesObserver.observe(editorsList, {subtree: true, childList: true, attributes: true});
}
}
let darkModeForMap = false;
let darkMapStyleElement = false;
function injectDarkMapStyle() {
darkMapStyleElement = injectCSSIntoOSMPage(`
@media (prefers-color-scheme: dark) {
.leaflet-tile-container, .mapkey-table-entry td:first-child > * {
filter: none !important;
}
.leaflet-tile-container * {
filter: none !important;
}
.leaflet-tile-container .leaflet-tile:not(.no-invert), .mapkey-table-entry td:first-child > * {
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%) !important;
}
}
`)
}
function setupDarkModeForMap() {
if (!GM_config.get("DarkModeForMap") || darkModeForMap) {
return;
}
injectDarkMapStyle()
darkModeForMap = true
}
async function loadChangesetsBetween(user, fromTime, toTime) {
let curTime = fromTime
const processedChangesets = new Set()
/*** @type {ChangesetMetadata[]}*/
const changesets = []
while (true) {
/*** @type {{changesets: ChangesetMetadata[]}}*/
const res = await fetchJSONWithCache(osm_server.apiBase + "changesets.json?" + new URLSearchParams({
display_name: user,
order: 'oldest',
from: curTime.toISOString(),
to: toTime.toISOString()
}).toString())
console.log(res);
res.changesets = res.changesets.filter(i => !processedChangesets.has(i.id))
if (res.changesets.length === 0) break
res.changesets.forEach(i => {
changesets.push(i)
processedChangesets.add(i.id)
})
curTime = new Date(res.changesets[res.changesets.length - 1].created_at)
}
console.log(`${changesets.length} changesets from ${fromTime} to ${toTime} fetched`)
changesets.forEach(i => {
if (i.comments_count) {
void getChangesetComments(i.id)
}
})
return changesets
}
/**
* @param user {string}
* @return {Promise<ChangesetMetadata[]>}
*/
async function loadChangesets(user) {
console.time(`stat-for-${user}`)
let startTime = new Date((new Date().getTime()) - 1000 * 60 * 60 * 24 * 365)
let startTime2 = new Date((new Date().getTime()) - 1000 * 60 * 60 * 24 * 365 * 3 / 4)
let startTime3 = new Date((new Date().getTime()) - 1000 * 60 * 60 * 24 * 365 * 2 / 4)
let startTime4 = new Date((new Date().getTime()) - 1000 * 60 * 60 * 24 * 365 / 4)
let endTime = new Date((new Date().getTime()) + 1000 * 60 * 60 * 24)
const parts = await Promise.all([
loadChangesetsBetween(user, startTime, startTime2),
loadChangesetsBetween(user, startTime2, startTime3),
loadChangesetsBetween(user, startTime3, startTime4),
loadChangesetsBetween(user, startTime4, endTime)
])
const uniqChangesets = new Set()
const changesets = []
parts.forEach(part => {
part.forEach(ch => {
if (!uniqChangesets.has(ch.id)) {
uniqChangesets.add(ch.id)
changesets.push(ch)
}
})
})
console.timeEnd(`stat-for-${user}`)
console.log("Changesets for the last year:", changesets.length);
return changesets
}
/**
* @param {ChangesetMetadata[]} changesets
* @param filter
* @return {[Object.<string, [number, number, {id: number, comment: string, comments_count: number}]>, number]}
*/
function makeChangesetsStat(changesets, filter) {
const datesStat = {}
let changesets_count = 0
changesets.forEach(i => {
if (!filter(i)) return
changesets_count++
const date = new Date(i.created_at)
const key = `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`
if (datesStat[key] === undefined) {
datesStat[key] = [0, 0, []]
}
datesStat[key][0] += i.changes_count
datesStat[key][1] = max(datesStat[key][1], i.id)
datesStat[key][2].push({
id: i.id,
comment: i?.tags?.['comment'] ?? "",
comments_count: i.comments_count
})
})
return [Object.fromEntries(Object.entries(datesStat).sort((a, b) => {
if (a[0] < b[0]) {
return -1;
}
if (a[0] > b[0]) {
return 1;
}
return 0
})), changesets_count]
}
async function makeEditorNormalizer() {
let rawReplaceRules;
const url = "https://raw.githubusercontent.com/deevroman/openstreetmap-statistics/refs/heads/master/src/replace_rules_created_by.json";
if (GM_info.scriptHandler !== "FireMonkey") {
rawReplaceRules = (await GM.xmlHttpRequest({
url: url,
})).responseText
} else {
rawReplaceRules = await (await GM.fetch(url)).text
}
console.log("replace rules loaded")
const tag_to_name = {}
const starts_with_list = []
const ends_with_list = []
const contains_list = []
Object.entries(JSON.parse(rawReplaceRules)).forEach(([name, name_infos]) => {
if (name_infos["aliases"]) {
for (let alias in name_infos["aliases"]) {
tag_to_name[alias] = name
}
}
if (name_infos["starts_with"]) {
for (const starts_with of name_infos["starts_with"]) {
starts_with_list.push([starts_with.length, starts_with, name])
}
}
if (name_infos["ends_with"]) {
for (const ends_with of name_infos["ends_with"]) {
ends_with_list.push([ends_with.length, ends_with, name])
}
}
if (name_infos["contains"]) {
for (const compare_str of name_infos["contains"]) {
contains_list.push([compare_str, name])
}
}
})
return (tag) => {
if (!tag) return tag
if (tag_to_name[tag]) {
return tag_to_name[tag]
}
for (let [compare_str_length, compare_str, replace_str] of starts_with_list) {
if (tag.slice(0, compare_str_length) === compare_str) {
return replace_str
}
}
for (let [compare_str_length, compare_str, replace_str] of ends_with_list) {
if (tag.slice(-compare_str_length) === compare_str) {
return replace_str
}
}
for (let [compare_str, replace_str] of contains_list) {
if (tag.includes(compare_str)) {
return replace_str
}
}
return tag
}
}
async function betterUserStat(user) {
if (!GM_config.get("BetterProfileStat") || !location.pathname.match(/^\/user\/([^/]+)\/?$/)) {
return
}
if (document.getElementById("filter-bar")) {
return
}
const filterBar = document.createElement("div")
filterBar.id = "filter-bar"
filterBar.style.display = "flex"
filterBar.style.gap = "3px"
const filterInputByEditor = document.createElement("select")
filterInputByEditor.style.flex = "1"
filterInputByEditor.id = "filter-input-by-editor"
filterInputByEditor.setAttribute("disabled", true)
filterInputByEditor.title = "Please wait while user changesets loading"
const item = document.createElement("option")
item.value = ""
item.setAttribute("all-editors", "yes")
item.textContent = "All editors"
filterInputByEditor.appendChild(item)
const calHeatmap = document.querySelector(".heatmap")
if (!calHeatmap) {
console.log("osm.org don't show heatmap for this user")
return;
}
injectCSSIntoOSMPage(`
.tooltip-inner {
white-space: pre-wrap;
text-align: left;
}
`)
calHeatmap.parentElement.parentElement.after(filterBar)
filterBar.appendChild(filterInputByEditor)
const searchByComment = document.createElement("input")
searchByComment.type = "search"
searchByComment.placeholder = "Regex search by comments"
searchByComment.title = "Please wait while user changesets loading"
searchByComment.setAttribute("disabled", true)
searchByComment.style.flex = "1"
searchByComment.style.height = "1.5rem";
searchByComment.style.boxSizing = "border-box";
filterInputByEditor.before(searchByComment)
const _replace_with_rules = makeEditorNormalizer()
const changesets = await loadChangesets(user)
const replace_with_rules = await _replace_with_rules
const editorOfChangesets = {}
changesets.forEach(ch => editorOfChangesets[ch.id] = replace_with_rules(ch.tags?.["created_by"]))
filterInputByEditor.removeAttribute("disabled")
searchByComment.removeAttribute("disabled")
filterInputByEditor.title = "Alt + O for open selected changesets on one page"
searchByComment.title = "Not case-sensitive regex search"
async function inputHandler() {
let filter = (_) => true
const selected = Array.from(filterInputByEditor.options).filter(i => i.selected)
let regex;
try {
regex = new RegExp(searchByComment.value.toLowerCase());
searchByComment.style.color = ""
} catch {
searchByComment.style.color = "red"
}
filter = (ch) => {
return selected.some(option => {
if (option.getAttribute("all-editors") === "yes") {
return (ch.tags?.["comment"] ?? "").toLowerCase().match(regex);
} else if (option.getAttribute("is-editor-name") === "yes") {
return editorOfChangesets[ch.id] === option.value && (ch.tags?.["comment"] ?? "").toLowerCase().match(regex);
} else {
return ch.tags?.["created_by"] === option.value && (ch.tags?.["comment"] ?? "").toLowerCase().match(regex);
}
})
}
const [newHeatmapData, changesets_count] = makeChangesetsStat(changesets, filter)
const maxPerDay = Object.values(newHeatmapData).map(i => i[0]).reduce((a, b) => max(a, b), 0);
searchByComment.title = `${changesets_count} changesets filtered`
function replaceElementTag(oldElement, newTagName) {
const attrs = {}
for (const attr of oldElement.attributes) {
attrs[attr.name] = attr.value;
}
const newElement = GM_addElement(newTagName, attrs);
while (oldElement.firstChild) {
newElement.appendChild(oldElement.firstChild);
}
oldElement.parentNode.replaceChild(newElement, oldElement);
return newElement;
}
function getTooltipSummary(date, value) {
const localizedDate = getWindow().OSM.i18n.l("date.formats.long", intoPageWithFun(date));
if (value > 0) {
return getWindow().OSM.i18n.t("javascripts.heatmap.tooltip.contributions", intoPage({ count: value, date: localizedDate }));
}
return getWindow().OSM.i18n.t("javascripts.heatmap.tooltip.no_contributions", intoPage({date: localizedDate}));
}
getWindow().$('[rel=tooltip]').tooltip('dispose')
document.querySelectorAll(".tooltip").forEach(i => i.remove())
const hrefPrefix = location.href.endsWith("/") ? location.href.slice(0, -1) : location.href
for (let day of Array.from(document.querySelectorAll("[data-date]"))) {
day = replaceElementTag(day, "a")
const newData = newHeatmapData[day.getAttribute("data-date")];
if (newData) {
day.setAttribute("data-count", newData[0])
day.setAttribute("href", hrefPrefix + "/history?before=" + (newData[1] + 1))
day.innerHTML = ""
const colorDiff = document.createElement("span")
colorDiff.style.opacity = `${Math.sqrt(newData[0] / maxPerDay)}`
let tooltipText = getTooltipSummary(new Date(day.getAttribute("data-date")), newData[0]);
if (newData[0]) {
tooltipText += "\n";
for (let changeset of newData[2]) {
let changesetComment = "";
if (changeset.comments_count) {
colorDiff.style.opacity = `${Math.min(0.7, Math.max(0.35, Math.sqrt(newData[0] / maxPerDay)))}`
colorDiff.style.background = "red"
changesetComment = "💬 " + changeset.comment;
(await getChangesetComments(changeset.id)).forEach(mapperCommentText => {
changesetComment += "\n - " + mapperCommentText['user'] + ": " + shortOsmOrgLinksInText(mapperCommentText['text'])?.slice(0, 500)
if (mapperCommentText['text'].length > 500) {
changesetComment += "..."
}
});
} else {
changesetComment = "• " + changeset.comment
}
tooltipText += changesetComment + "\n";
}
}
day.appendChild(colorDiff)
getWindow().$(day).tooltip(intoPage({
title: tooltipText,
customClass: "wide",
delay: { show: 0, hide: 0 }
}));
} else {
day.removeAttribute("data-count")
day.setAttribute("href", "")
day.innerHTML = ""
if (day.nodeName === "A") {
day = replaceElementTag(day, "span")
}
getWindow().$(day).tooltip('disable')
}
}
}
filterInputByEditor.oninput = inputHandler
searchByComment.oninput = inputHandler
document.addEventListener("keydown", (e) => {
if (e.altKey && e.code === "KeyO") {
const selected = Array.from(filterInputByEditor.options).filter(i => i.selected)
const ids = changesets.filter((ch) => {
return selected.some(i => ch?.tags?.["created_by"]?.includes(i.value))
}).map(i => i.id)
const idsStr = ids.join(",")
open(osm_server.url + `/changeset/${ids[0]}?changesets=` + idsStr, "_blank")
}
})
filterInputByEditor.id = "editors"
filterInputByEditor.addEventListener("mousedown", function (e) {
e.preventDefault()
e.target.focus()
filterInputByEditor.setAttribute("size", 7)
filterInputByEditor.setAttribute("multiple", true)
inputHandler()
}, {"once": true})
searchByComment.addEventListener("mousedown", function (e) {
inputHandler()
}, {"once": true})
const counts = {};
changesets.forEach(i => {
const editor = editorOfChangesets[i.id]
counts[editor] = counts[editor] ? counts[editor] + i.changes_count : i.changes_count;
})
Array.from(new Set(changesets.map(i => editorOfChangesets[i.id]))).sort((a, b) => {
if (counts[a] < counts[b]) {
return 1
}
if (counts[a] > counts[b]) {
return -1
}
return 0
}).forEach(i => {
const item = document.createElement("option")
item.value = i
item.setAttribute("is-editor-name", "yes")
if (i === 1) {
item.textContent = ` ${i} (${counts[i]} contribution)`
} else {
item.textContent = ` ${i} (${counts[i]} contributions)`
}
filterInputByEditor.appendChild(item)
})
Array.from(new Set(changesets.map(i => i.tags?.["created_by"]))).sort().forEach(i => {
const item = document.createElement("option")
item.value = i
item.textContent = i
filterInputByEditor.appendChild(item)
})
filterInputByEditor.after(filterInputByEditor)
console.log("setuping filters finished")
}
async function setupHDYCInProfile(path) {
let match = path.match(/^\/user\/([^/]+)(\/|\/notes)?$/);
if (!match || path.includes("/history")) {
return;
}
if (document.getElementById("hdyc-iframe")) {
return
}
const user = match[1];
if (user === "forgot-password" || user === "new") return;
document.querySelector(".content-body > .content-inner").style.paddingBottom = "0px";
if (isDarkMode()) {
GM_addElement(document.querySelector("#content"), "iframe", {
src: "https://www.hdyc.neis-one.org/?" + user + "#forcedarktheme",
width: "100%",
id: "hdyc-iframe",
scrolling: "no",
background: "rgb(49, 54, 59)",
style: "visibility:hidden;background-color: rgb(49, 54, 59);",
});
setTimeout(() => {
document.getElementById("hdyc-iframe").style.visibility = 'visible';
}, 1500)
} else {
GM_addElement(document.querySelector("#content"), "iframe", {
src: "https://www.hdyc.neis-one.org/?" + user + "#forcelighttheme",
width: "100%",
id: "hdyc-iframe",
scrolling: "no",
});
}
if (document.querySelector('a[href$="/blocks"]')?.nextElementSibling?.textContent > 0) {
document.querySelector('a[href$="/blocks"]').nextElementSibling.style.background = "rgba(255, 0, 0, 0.3)"
if (isDarkMode()) {
document.querySelector('a[href$="/blocks"]').nextElementSibling.style.color = "white"
}
getCachedUserInfo(decodeURI(user)).then(userInfo => {
if (userInfo['blocks']['received']['active'] === 0) {
updateUserInfo(decodeURI(user))
}
})
} else if (document.querySelector('a[href$="/blocks"]')?.nextElementSibling?.textContent === "0") {
getCachedUserInfo(decodeURI(user)).then(userInfo => {
if (userInfo['blocks']['received']['active'] !== 0) {
updateUserInfo(decodeURI(user))
}
})
}
setTimeout(async () => {
if (document.querySelector(".prev-usernames")) return
const userDetails = document.querySelector(".content-inner small dl")
if (!userDetails) return;
// https://www.openstreetmap.org/reports/new?reportable_id=12345&reportable_type=User
let userID = document.querySelector('[href*="reportable_id="]')?.getAttribute("href")?.match(/reportable_id=(\d+)/)?.[1]
if (!userID) {
const res = await fetchJSONWithCache(osm_server.apiBase + "changesets.json?" + new URLSearchParams({
display_name: decodeURI(user),
limit: 1,
order: 'oldest'
}).toString());
userID = res['changesets'][0]['uid']
if (!userID) {
return;
}
}
async function updateUserIDInfo(userID) {
const res = await GM.xmlHttpRequest({
url: "https://whosthat.osmz.ru/whosthat.php?action=names&id=" + userID,
responseType: "json"
})
// FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
// but here need resolve problem with return promise
const userInfo = {
data: structuredClone(res.response)
}
if (userInfo.data[0]['names'].length > 1) {
userInfo['cacheTime'] = new Date()
await GM.setValue("useridinfo-" + userID, JSON.stringify(userInfo))
const usernames = userInfo.data[0]['names'].filter(i => i !== decodeURI(user)).join(", ")
if (document.querySelector(".prev-usernames")) {
document.querySelector(".prev-usernames").textContent = usernames
}
}
return userInfo
}
async function getCachedUserIDInfo(userID) {
const localUserInfo = await GM.getValue("useridinfo-" + userID, "")
if (localUserInfo) {
setTimeout(updateUserIDInfo, 0, userID)
return JSON.parse(localUserInfo)
}
return await updateUserIDInfo(userID)
}
const userIDInfo = await getCachedUserIDInfo(userID)
if (userIDInfo.data[0]['names'].length <= 1) {
console.log("prev user's usernames not found")
return
}
const usernames = userIDInfo.data[0]['names'].filter(i => i !== decodeURI(user)).join(", ")
const dt = document.createElement("dt")
dt.textContent = "Past usernames: "
dt.classList.add("list-inline-item", "m-0")
const dd = document.createElement("dd")
dd.classList.add("list-inline-item", "prev-usernames")
dd.textContent = usernames
userDetails.appendChild(dt)
userDetails.appendChild(document.createTextNode("\xA0"))
userDetails.appendChild(dd)
})
const iframe = document.getElementById('hdyc-iframe');
window.addEventListener('message', function (event) {
if (event.origin === "https://www.hdyc.neis-one.org") {
iframe.height = event.data.height + 'px';
}
});
}
function setupBetterProfileStat() {
let match = location.pathname.match(/^\/user\/([^/]+)\/?$/);
if (!match) {
return;
}
const user = match[1];
let timerId = setInterval(betterUserStat, 300, decodeURI(user));
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add heatmap filters');
}, 5000);
void betterUserStat(decodeURI(user))
}
function inFrame() {
return window.location !== window.parent.location;
}
function simplifyHDCYIframe() {
if (!inFrame()) {
return
}
const forceLightTheme = location.hash.includes("forcelighttheme")
const forceDarkTheme = location.hash.includes("forcedarktheme")
injectCSSIntoSimplePage(`
html, body {
overflow-x: auto;
}
@media ${forceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${forceLightTheme ? "and (not all)" : ""} {
body {
background-color: #181a1b;
color: #e8e6e3;
}
#header a {
color: lightgray !important;
}
#activitymap .leaflet-tile,
#mapwrapper .leaflet-tile {
filter: invert(100%) hue-rotate(180deg) contrast(90%);
}
#activitymap path {
stroke: #0088ff;
fill: #0088ff;
stroke-opacity: 0.7;
}
#activitymapswitcher {
background-color: rgba(24, 26, 27, 0.8);
}
.leaflet-popup-content {
color: lightgray;
}
.leaflet-popup-content-wrapper, .leaflet-popup-tip {
background: #222;
}
a, .leaflet-container a {
color: #1c84fd;
}
a:visited, .leaflet-container a:visited {
color: #c94bff;
}
a[style*="black"] {
color: lightgray !important;
}
.day-cell[fill="#e8e8e8"] {
fill: #262a2b;
}
#result th {
background-color: rgba(24, 26, 27, 0.8);
}
#result td {
border-color: #363659;
}
td[style*="purple"] {
color: #ff72ff !important;
}
td[style*="green"] {
color: limegreen !important;
}
#graph_years canvas,
#graph_editors canvas,
#graph_days canvas,
#graph_hours canvas {
filter: saturate(4);
}
.tickLabel {
color: #b3aca2;
}
.editors_wrapper th, .editors_wrapper td {
border-bottom-color: #8c8273;
}
}
`
);
const loginLink = document.getElementById("loginLink")
if (loginLink) {
let warn = document.createElement("div")
warn.id = "hdyc-warn"
injectCSSIntoSimplePage(`
#hdyc-warn, #hdycLink {
text-align: left !important;
width: 50%;
position: relative;
left: 35%;
right: 33%;
}
`,
);
if (isFirefox) {
warn.textContent = "Please disable tracking protection so that the HDYC account login works"
document.getElementById("authenticate").before(warn)
let hdycLink = document.createElement("a")
const match = location.pathname.match(/^\/user\/([^/]+)$/);
hdycLink.href = "https://www.hdyc.neis-one.org/" + (match ? match[1] : "")
hdycLink.textContent = "Go to https://www.hdyc.neis-one.org/"
hdycLink.target = "_blank"
hdycLink.id = "hdycLink"
document.getElementById("authenticate").before(document.createElement("br"))
document.getElementById("authenticate").before(hdycLink)
document.getElementById("authenticate").remove()
window.parent.postMessage({height: document.body.scrollHeight}, '*')
} else {
warn.innerHTML = `To see more than just public profiles, do the following:<br/>
0. Turn off tracking protection if your browser has it (for example in Brave or Vivaldi)<br/>
1. <a href="https://www.hdyc.neis-one.org/" target="_blank"> Log in to HDYC</a> <br/>
2. Open the browser console (F12) <br/>
3. Open the Application tab <br/>
4. In the left panel, select <i>Storage</i>→<i>Cookies</i>→<i>https://www.hdyc.neis-one.org</i><br/>
5. Click on the cell with the name <i>SameSite</i> and type <i>None</i> in it`
document.getElementById("authenticate").before(warn)
const img_help = document.createElement("img")
img_help.onload = () => {
window.parent.postMessage({height: document.body.scrollHeight}, '*')
}
img_help.src = "https://raw.githubusercontent.com/deevroman/better-osm-org/master/img/hdyc-fix-in-chrome.png"
img_help.style.width = "90%"
warn.after(img_help)
document.getElementById("authenticate").remove()
}
// var xhr = XPCNativeWrapper(new window.wrappedJSObject.XMLHttpRequest());
// let res = await GM.xmlHttpRequest({
// method: "GET",
// url: document.querySelector("#loginLink").href,
// withCredentials: true
// })
// debugger
return
}
document.getElementById("header").remove()
document.getElementById("user").remove()
document.getElementById("searchfield").remove()
document.querySelector(".mapper_img").remove()
let bCnt = 0
for (let childNodesKey of Array.from(document.querySelector(".since").childNodes)) {
if (childNodesKey.nodeName === "#text") {
childNodesKey.remove()
continue
}
if (childNodesKey.classList.contains("image")) {
continue
}
if (childNodesKey.localName === "b") {
if (bCnt === 2) {
break
}
bCnt++
}
childNodesKey.remove()
}
window.parent.postMessage({height: document.body.scrollHeight}, '*');
}
//<editor-fold desc="/history, /user/*/history">
async function updateUserInfo(username) {
const res = await fetchJSONWithCache(osm_server.apiBase + "changesets.json?" + new URLSearchParams({
display_name: username,
limit: 1,
order: 'oldest'
}).toString());
let uid;
let firstObjectCreationTime;
let firstChangesetID;
if (res['changesets'].length === 0) {
const res = await fetchJSONWithCache(osm_server.apiBase + "notes/search.json?" + new URLSearchParams({
display_name: username,
limit: 1,
closed: -1,
order: "oldest"
}).toString());
uid = res['features'][0]['properties']['comments'].find(i => i['user'] === username)['uid']
firstObjectCreationTime = res['features'][0]['properties']['comments'].find(i => i['user'] === username)['date']
} else {
uid = res['changesets'][0]['uid']
firstObjectCreationTime = res['changesets'][0]['created_at']
firstChangesetID = res['changesets'][0]['id']
}
const res2 = await fetchJSONWithCache(osm_server.apiBase + "user/" + uid + ".json");
const userInfo = structuredClone(res2.user) // FireMonkey compatibility https://github.com/erosman/firemonkey/issues/8
userInfo['cacheTime'] = new Date()
if (firstObjectCreationTime) {
userInfo['firstChangesetCreationTime'] = new Date(firstObjectCreationTime)
}
if (firstChangesetID) {
userInfo['firstChangesetID'] = firstChangesetID
}
await GM.setValue("userinfo-" + username, JSON.stringify(userInfo))
return userInfo
}
/**
* @param {string} username
* @return {Promise<*>}
*/
async function getCachedUserInfo(username) {
const localUserInfo = await GM.getValue("userinfo-" + username, "")
if (localUserInfo) {
const cacheTime = new Date(localUserInfo['cacheTime'])
if (cacheTime.setUTCDate(cacheTime.getUTCDate() + 3) < new Date()) {
setTimeout(updateUserInfo, 0, username)
}
return JSON.parse(localUserInfo)
}
return await updateUserInfo(username)
}
let sidebarObserverForMassActions = null;
let massModeForUserChangesetsActive = null;
let massModeActive = null;
let currentMassDownloadedPages = null;
let needClearLoadMoreRequest = 0;
let needPatchLoadMoreRequest = null;
let needHideBigChangesets = true;
let hiddenChangesetsCount = null;
let lastLoadMoreURL = "";
function openCombinedChangesetsMap() {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value)
if (ids.length) {
const idsStr = ids.join(",")
open(osm_server.url + `/changeset/${ids[0]}?changesets=` + idsStr, "_blank")
} else {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox")).map(i => i.value)
if (ids.length) {
const idsStr = ids.join(",")
open(osm_server.url + `/changeset/${ids[0]}?changesets=` + idsStr, "_blank")
} else {
const ids = Array.from(document.querySelectorAll(`a[href^="/changeset/"].custom-changeset-id-click`)).map(i => i.getAttribute("href").match(/\/changeset\/([0-9]+)/)[1])
const idsStr = ids.join(",")
open(osm_server.url + `/changeset/${ids[0]}?changesets=` + idsStr, "_blank")
}
}
}
function makeTopActionBar() {
const actionsBar = document.createElement("div")
actionsBar.classList.add("actions-bar")
const copyIds = document.createElement("button")
copyIds.textContent = "Copy IDs"
copyIds.classList.add("copy-changesets-ids-btn")
copyIds.onclick = () => {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value).join(",")
if (ids !== "") {
navigator.clipboard.writeText(ids).then(() => {
console.log(ids, "ids copied")
});
} else {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox")).map(i => i.value).join(",")
navigator.clipboard.writeText(ids).then(() => {
console.log(ids, "ids copied")
});
}
}
const revertButton = document.createElement("button")
revertButton.textContent = "↩️"
revertButton.title = "revert via osm-revert"
revertButton.onclick = () => {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value).join(",")
open("https://revert.monicz.dev/?changesets=" + ids, "_blank")
}
const revertViaJOSMButton = document.createElement("button")
revertViaJOSMButton.textContent = "↩️ via JOSM"
revertViaJOSMButton.onclick = () => {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value).join(",")
open("http://127.0.0.1:8111/revert_changeset?id=" + ids, "_blank")
}
const viewChangesetsButton = document.createElement("button")
viewChangesetsButton.textContent = "🔍"
viewChangesetsButton.title = "Display on one map\nif nothing is checked, all uploaded non hidden changesets will open"
viewChangesetsButton.onclick = openCombinedChangesetsMap
actionsBar.appendChild(copyIds)
actionsBar.appendChild(document.createTextNode("\xA0"))
actionsBar.appendChild(revertButton)
actionsBar.appendChild(document.createTextNode("\xA0"))
actionsBar.appendChild(revertViaJOSMButton)
actionsBar.appendChild(document.createTextNode("\xA0"))
actionsBar.appendChild(viewChangesetsButton)
return actionsBar;
}
function makeBottomActionBar() {
if (document.querySelector(".buttom-btn")) return
const copyIds = document.createElement("button")
const selectedIDsCount = document.querySelectorAll(".mass-action-checkbox:checked").length
if (selectedIDsCount) {
copyIds.textContent = `Copy ${selectedIDsCount} IDs`
} else {
copyIds.textContent = "Copy IDs"
}
copyIds.classList.add("copy-changesets-ids-btn")
copyIds.classList.add("buttom-btn")
copyIds.classList.add("page-link")
copyIds.onclick = () => {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value).join(",")
if (ids !== "") {
navigator.clipboard.writeText(ids).then(() => {
console.log(ids, "ids copied")
});
} else {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox")).map(i => i.value).join(",")
navigator.clipboard.writeText(ids).then(() => {
console.log(ids, "ids copied")
});
}
}
const revertButton = document.createElement("button")
revertButton.textContent = "↩️"
revertButton.title = "revert via osm-revert"
revertButton.onclick = () => {
const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value).join(",")
window.location = "https://revert.monicz.dev/?changesets=" + ids
}
revertButton.classList.add("page-link")
const viewChangesetsButton = document.createElement("button")
viewChangesetsButton.textContent = "🔍"
viewChangesetsButton.title = "Display on one map"
viewChangesetsButton.onclick = openCombinedChangesetsMap
viewChangesetsButton.classList.add("page-link")
const changesetMore = document.querySelector('#sidebar_content div.changeset_more:has([href*="before"]) li')
if (changesetMore) {
changesetMore.style.display = "inline-flex"
changesetMore.appendChild(copyIds)
changesetMore.appendChild(document.createTextNode("\xA0"))
changesetMore.appendChild(revertButton)
changesetMore.appendChild(document.createTextNode("\xA0"))
changesetMore.appendChild(viewChangesetsButton)
} else {
const changesetsList = document.querySelector("#sidebar_content ol");
const actionBarWrapper = document.createElement("ul")
actionBarWrapper.classList.add("pagination", "justify-content-center")
const actionBarWrapperLi = document.createElement("li")
actionBarWrapperLi.classList.add("page-item")
actionBarWrapperLi.style.display = "inline-flex"
actionBarWrapper.appendChild(actionBarWrapperLi)
actionBarWrapperLi.classList.add("action-bar-wrapper")
actionBarWrapperLi.classList.add("text-center")
actionBarWrapperLi.appendChild(copyIds)
actionBarWrapperLi.appendChild(document.createTextNode("\xA0"))
actionBarWrapperLi.appendChild(revertButton)
actionBarWrapperLi.appendChild(document.createTextNode("\xA0"))
actionBarWrapperLi.appendChild(viewChangesetsButton)
changesetsList.after(actionBarWrapper)
}
}
function makeOsmchaLinkForUsername(username) {
// example: https://osmcha.org?filters={"users":[{"label":"TrickyFoxy","value":"TrickyFoxy"}]}
const osmchaFilter = {
"users": [{"label": username, "value": username}],
"date__gte": [{"label": "", "value": ""}]
}
return "https://osmcha.org?" + new URLSearchParams({filters: JSON.stringify(osmchaFilter)}).toString()
}
function addMassActionForUserChangesets() {
if (!location.pathname.includes("/user/") || document.querySelector("#mass-action-btn")) {
return;
}
const a = document.createElement("a")
a.title = "Add checkboxes for mass actions with changesets"
a.textContent = " 📋"
a.style.cursor = "pointer"
a.id = "mass-action-btn"
a.onclick = () => {
if (massModeForUserChangesetsActive === null) {
massModeForUserChangesetsActive = true
document.querySelector("#sidebar div.changesets").before(makeTopActionBar())
document.querySelector('#sidebar div.changeset_more:has([href*="before"])').after(document.createTextNode(" "))
makeBottomActionBar()
document.querySelectorAll("ol li").forEach(addChangesetCheckbox)
} else {
massModeForUserChangesetsActive = !massModeForUserChangesetsActive
document.querySelectorAll(".actions-bar").forEach(i => i.toggleAttribute("hidden"))
document.querySelectorAll(".mass-action-checkbox").forEach(i => {
i.toggleAttribute("hidden")
})
}
}
const username = decodeURI(location.pathname.match(/\/user\/(.*)\/history$/)[1])
const osmchaLink = document.createElement("a");
osmchaLink.id = "osmcha_link"
osmchaLink.title = "Open profile in OSMCha.org"
osmchaLink.href = makeOsmchaLinkForUsername(username)
osmchaLink.target = "_blank"
osmchaLink.rel = "noreferrer"
const osmchaIcon = document.createElement("img")
osmchaIcon.src = GM_getResourceURL("OSMCHA_ICON", false)
osmchaIcon.style.height = "1em";
osmchaIcon.style.cursor = "pointer";
osmchaIcon.style.marginTop = "-3px";
if (isDarkMode()) {
osmchaIcon.style.filter = "invert(0.7)";
}
osmchaLink.appendChild(osmchaIcon)
document.querySelector("#sidebar_content h2").appendChild(a)
document.querySelector("#sidebar_content h2").appendChild(document.createTextNode("\xA0"))
document.querySelector("#sidebar_content h2").appendChild(osmchaLink)
}
function addChangesetCheckbox(chagesetElem) {
if (chagesetElem.querySelector(".mass-action-checkbox")) {
return;
}
const a = document.createElement("a");
a.classList.add("mass-action-wrapper")
const checkbox = document.createElement("input")
checkbox.type = "checkbox"
checkbox.classList.add("mass-action-checkbox")
checkbox.value = chagesetElem.querySelector(".changeset_id").href.match(/\/(\d+)/)[1]
checkbox.style.cursor = "pointer"
checkbox.title = "Shift + click for select a range of empty checkboxes"
checkbox.onclick = e => {
if (e.shiftKey) {
let currentCheckboxFound = false
for (const cBox of Array.from(document.querySelectorAll(".mass-action-checkbox")).toReversed()) {
if (!currentCheckboxFound) {
if (cBox.value === checkbox.value) {
currentCheckboxFound = true
}
} else {
if (cBox.checked) {
break
}
cBox.checked = true
}
}
}
const selectedIDsCount = document.querySelectorAll(".mass-action-checkbox:checked").length
document.querySelectorAll(".copy-changesets-ids-btn").forEach(i => {
if (selectedIDsCount) {
i.textContent = `Copy ${selectedIDsCount} IDs`
} else {
i.textContent = `Copy IDs`
}
})
}
a.appendChild(checkbox)
chagesetElem.querySelector("p").prepend(a)
chagesetElem.querySelectorAll("a.changeset_id").forEach((i) => {
i.onclick = (e) => {
if (massModeActive) {
e.preventDefault()
}
}
})
}
function filterChangesets(htmlDocument = document) {
const usernameFilters = document.querySelector("#filter-by-user-input").value.trim().split(",").filter(i => i.trim() !== "")
const commentFilters = document.querySelector("#filter-by-comment-input").value.trim().split(",").filter(i => i.trim() !== "")
let newHiddenChangesetsCount = 0;
htmlDocument.querySelectorAll("ol li").forEach(i => {
const changesetComment = i.querySelector("p a bdi").textContent
const changesetAuthor = i.querySelector("div > a").textContent
let bbox;
if (i.getAttribute("data-changeset")) {
bbox = Object.values(JSON.parse(i.getAttribute("data-changeset")).bbox)
} else {
bbox = Object.values(JSON.parse(i.getAttribute("hidden-data-changeset")).bbox)
}
bbox = bbox.map(parseFloat)
const deltaLon = bbox[2] - bbox[0]
const deltaLat = bbox[3] - bbox[1]
const bboxSizeLimit = 1
let wasHidden = false
if (needHideBigChangesets && (deltaLat > bboxSizeLimit || deltaLon > bboxSizeLimit)) {
wasHidden = true
if (i.getAttribute("data-changeset")) {
i.setAttribute("hidden-data-changeset", i.getAttribute("data-changeset"))
i.removeAttribute("data-changeset")
i.setAttribute("hidden", true)
} else {
// FIXME
}
}
if (!wasHidden) {
let invert = document.querySelector("#invert-user-filter-checkbox").getAttribute("checked") === "true"
usernameFilters.forEach(username => {
if (changesetAuthor.includes(username.trim()) ^ invert) {
if (i.getAttribute("data-changeset")) {
i.setAttribute("hidden-data-changeset", i.getAttribute("data-changeset"))
i.removeAttribute("data-changeset")
i.setAttribute("hidden", true)
} else {
// FIXME
}
wasHidden = true
}
})
}
if (!wasHidden) {
let invert = document.querySelector("#invert-comment-filter-checkbox").getAttribute("checked") === "true"
commentFilters.forEach(comment => {
if (changesetComment.includes(comment.trim()) ^ invert) {
if (i.getAttribute("data-changeset")) {
i.setAttribute("hidden-data-changeset", i.getAttribute("data-changeset"))
i.removeAttribute("data-changeset")
i.setAttribute("hidden", true)
} else {
// FIXME
}
wasHidden = true
}
})
}
if (!wasHidden) {
if (i.getAttribute("hidden-data-changeset")) {
i.setAttribute("data-changeset", i.getAttribute("hidden-data-changeset"))
i.removeAttribute("hidden-data-changeset")
i.removeAttribute("hidden")
} else {
// FIXME
}
} else {
newHiddenChangesetsCount++;
}
})
if (hiddenChangesetsCount !== newHiddenChangesetsCount && htmlDocument === document) {
hiddenChangesetsCount = newHiddenChangesetsCount
const changesetsCount = document.querySelectorAll("ol > li").length
document.querySelector("#hidden-changeset-counter").textContent = ` Displayed ${changesetsCount - newHiddenChangesetsCount}/${changesetsCount}`
console.log(changesetsCount);
}
}
function updateMap() {
needClearLoadMoreRequest++
lastLoadMoreURL = document.querySelector('.changeset_more:has([href*="before"]) a.page-link').href
document.querySelector('.changeset_more:has([href*="before"]) a.page-link').click()
}
/**
* @param {HTMLAnchorElement} i
*/
function makeUsernamesFilterable(i) {
if (i.classList.contains("listen-for-filters")) {
return
}
i.classList.add("listen-for-filters")
i.onclick = async (e) => {
if (massModeActive && (!e.metaKey && !e.ctrlKey && e.isTrusted)) {
e.preventDefault()
const filterByUsersInput = document.querySelector("#filter-by-user-input")
if (filterByUsersInput.value === "") {
filterByUsersInput.value = e.target.textContent
} else {
filterByUsersInput.value = filterByUsersInput.value + "," + e.target.textContent
filterByUsersInput.setSelectionRange(filterByUsersInput.value.length, filterByUsersInput.value.length)
}
filterChangesets()
updateMap()
await GM.setValue("last-user-filter", document.getElementById("filter-by-user-input")?.value)
}
}
i.title = "Click for hide this user changesets. Ctrl + click for open user profile"
// i.style.border = "solid"
// i.style.borderColor = getWindow()?.makeColor(i.textContent)
// i.onmouseover = () => {
//
// }
}
let queriesCache = {
cacheTime: Date.now(),
elems: {}
}
function addMassActionForGlobalChangesets() {
if ((location.pathname === "/history" || location.pathname === "/history/friends")
&& document.querySelector("#sidebar_content h2")
&& !document.querySelector("#changesets-filter-btn")) {
const a = document.createElement("a")
a.textContent = " 🔎"
a.style.cursor = "pointer"
a.id = "changesets-filter-btn"
a.title = "Changesets filter via better-osm-org"
a.onclick = async () => {
document.querySelector("#sidebar .search_forms")?.setAttribute("hidden", "true")
async function makeTopFilterBar() {
const filterBar = document.createElement("div")
filterBar.classList.add("filter-bar")
const hideBigChangesetsCheckbox = document.createElement("input")
hideBigChangesetsCheckbox.checked = needHideBigChangesets = await GM.getValue("last-big-changesets-filter")
hideBigChangesetsCheckbox.type = "checkbox"
hideBigChangesetsCheckbox.style.cursor = "pointer"
hideBigChangesetsCheckbox.id = "hide-big-changesets-checkbox"
const hideBigChangesetLabel = document.createElement("label")
hideBigChangesetLabel.textContent = "Hide big changesets"
hideBigChangesetLabel.htmlFor = "hide-big-changesets-checkbox"
hideBigChangesetLabel.style.marginLeft = "1px"
hideBigChangesetLabel.style.marginBottom = "4px"
hideBigChangesetLabel.style.cursor = "pointer"
hideBigChangesetsCheckbox.onchange = async () => {
needHideBigChangesets = hideBigChangesetsCheckbox.checked;
filterChangesets()
updateMap()
await GM.setValue("last-big-changesets-filter", hideBigChangesetsCheckbox.checked)
}
filterBar.appendChild(hideBigChangesetsCheckbox)
filterBar.appendChild(hideBigChangesetLabel)
filterBar.appendChild(document.createElement("br"))
const label = document.createElement("span")
label.textContent = "🔄Hide changesets from "
label.title = "Click for invert"
label.style.minWidth = "165px";
label.style.display = "inline-block";
label.style.cursor = "pointer"
label.setAttribute("checked", false)
label.id = "invert-user-filter-checkbox"
label.onclick = e => {
if (e.target.textContent === "🔄Hide changesets from ") {
e.target.textContent = "🔄Show changesets from "
} else {
e.target.textContent = "🔄Hide changesets from "
}
if (e.target.getAttribute("checked") === "false") {
e.target.setAttribute("checked", true)
} else {
e.target.setAttribute("checked", false)
}
if (document.querySelector("#filter-by-user-input").value !== "") {
filterChangesets();
updateMap();
}
}
filterBar.appendChild(label)
const filterByUsersInput = document.createElement("input")
filterByUsersInput.placeholder = "user1,user2,... and press Enter"
filterByUsersInput.id = "filter-by-user-input"
filterByUsersInput.style.width = "250px"
filterByUsersInput.style.marginBottom = "3px"
filterByUsersInput.addEventListener("keypress", async function (event) {
if (event.key === "Enter") {
event.preventDefault();
filterChangesets();
updateMap()
await GM.setValue("last-user-filter", filterByUsersInput.value)
await GM.setValue("last-comment-filter", filterByCommentInput.value)
}
});
filterByUsersInput.value = await GM.getValue("last-user-filter", "")
filterBar.appendChild(filterByUsersInput)
const label2 = document.createElement("span")
label2.textContent = "🔄Hide changesets with "
label2.title = "Click for invert"
label2.style.minWidth = "165px";
label2.style.display = "inline-block";
label2.style.cursor = "pointer"
label2.id = "invert-comment-filter-checkbox"
label2.setAttribute("checked", false)
label2.onclick = e => {
if (e.target.textContent === "🔄Hide changesets with ") {
e.target.textContent = "🔄Show changesets with "
} else {
e.target.textContent = "🔄Hide changesets with "
}
if (e.target.getAttribute("checked") === "false") {
e.target.setAttribute("checked", true)
} else {
e.target.setAttribute("checked", false)
}
if (document.querySelector("#filter-by-comment-input").value !== "") {
filterChangesets();
updateMap()
}
}
filterBar.appendChild(label2)
const filterByCommentInput = document.createElement("input")
filterByCommentInput.id = "filter-by-comment-input"
filterByCommentInput.placeholder = "words1,words2,... and press Enter"
filterByCommentInput.title = "Filter by substring in changesets comments"
filterByCommentInput.style.width = "250px"
filterByCommentInput.addEventListener("keypress", async function (event) {
if (event.key === "Enter") {
event.preventDefault();
filterChangesets();
updateMap()
await GM.setValue("last-user-filter", filterByUsersInput.value)
await GM.setValue("last-comment-filter", filterByCommentInput.value)
}
});
filterByCommentInput.value = await GM.getValue("last-comment-filter", "")
filterBar.appendChild(filterByCommentInput)
return filterBar
}
needPatchLoadMoreRequest = true
if (massModeActive === null) {
massModeActive = true
document.querySelector("#sidebar div.changesets").before(await makeTopFilterBar())
document.querySelectorAll('ol li div > a[href^="/user/"]').forEach(makeUsernamesFilterable)
} else {
massModeActive = !massModeActive
document.querySelectorAll(".filter-bar").forEach(i => i.toggleAttribute("hidden"))
document.querySelector("#hidden-changeset-counter")?.toggleAttribute("hidden")
// document.querySelectorAll(".mass-action-checkbox").forEach(i => {
// i.toggleAttribute("hidden")
// })
}
filterChangesets()
updateMap()
}
document.querySelector("#sidebar_content h2").appendChild(a)
const hiddenChangesetsCounter = document.createElement("span")
hiddenChangesetsCounter.id = "hidden-changeset-counter"
document.querySelector("#sidebar_content h2").appendChild(hiddenChangesetsCounter)
}
if (needPatchLoadMoreRequest === null) {
// double dirty hack
// override XMLHttpRequest.getResponseText
// caching queries since .responseText can be called multiple times
needPatchLoadMoreRequest = false
if (!unsafeWindow.XMLHttpRequest.prototype.getResponseText) {
unsafeWindow.XMLHttpRequest.prototype.getResponseText = Object.getOwnPropertyDescriptor(unsafeWindow.XMLHttpRequest.prototype, 'responseText').get;
}
Object.defineProperty(unsafeWindow.XMLHttpRequest.prototype, 'responseText', {
get: exportFunction(function () {
if (queriesCache.cacheTime + 2 > Date.now()) {
if (queriesCache.elems[this.responseURL]) {
return queriesCache.elems[this.responseURL]
}
} else {
queriesCache.cacheTime = Date.now()
queriesCache.elems = {}
}
let responseText = unsafeWindow.XMLHttpRequest.prototype.getResponseText.call(this);
if (location.pathname !== "/history"
&& !(location.pathname.includes("/history") && location.pathname.includes("/user/"))) {
// todo also for node/123/history
// off patching
Object.defineProperty(unsafeWindow.XMLHttpRequest.prototype, 'responseText', {
get: unsafeWindow.XMLHttpRequest.prototype.getResponseText,
enumerable: true,
configurable: true
})
return responseText;
}
if (needClearLoadMoreRequest) {
console.log("new changesets cleared")
needClearLoadMoreRequest--;
const docParser = new DOMParser();
const doc = docParser.parseFromString(responseText, "text/html");
doc.querySelectorAll("ol > li").forEach(i => i.remove())
doc.querySelector('.changeset_more:has([href*="before"]) a.page-link').href = lastLoadMoreURL
queriesCache.elems[lastLoadMoreURL] = doc.documentElement.outerHTML;
queriesCache.elems[this.responseURL] = doc.documentElement.outerHTML;
lastLoadMoreURL = ""
} else if (needPatchLoadMoreRequest) {
console.log("new changesets patched")
const docParser = new DOMParser();
const doc = docParser.parseFromString(responseText, "text/html");
filterChangesets(doc)
setTimeout(() => {
const changesetsCount = document.querySelectorAll("ol > li").length
document.querySelector("#hidden-changeset-counter").textContent = ` Displayed ${changesetsCount - hiddenChangesetsCount}/${changesetsCount}` // hiddenChangesetsCount?
}, 100)
queriesCache.elems[this.responseURL] = doc.documentElement.outerHTML;
} else {
queriesCache.elems[this.responseURL] = responseText
}
return queriesCache.elems[this.responseURL]
}, unsafeWindow),
enumerable: true,
configurable: true
});
}
}
function makeBadge(userInfo, changesetDate = new Date()) { // todo make changesetDate required
let userBadge = document.createElement("span")
userBadge.classList.add("user-badge")
if (userInfo['roles'].some(i => i === "moderator")) {
userBadge.style.position = "relative"
userBadge.style.bottom = "2px"
userBadge.title = "This user is a moderator"
userBadge.innerHTML = '<svg width="20" height="20"><path d="M 10,2 8.125,8 2,8 6.96875,11.71875 5,18 10,14 15,18 13.03125,11.71875 18,8 11.875,8 10,2 z" fill="#447eff" stroke="#447eff" stroke-width="2" stroke-linejoin="round"></path></svg>'
userBadge.querySelector("svg").style.transform = "scale(0.9)"
} else if (userInfo['roles'].some(i => i === "importer")) {
userBadge.style.position = "relative"
userBadge.style.bottom = "2px"
userBadge.title = "This user is a importer"
userBadge.innerHTML = '<svg width="20" height="20"><path d="M 10,2 8.125,8 2,8 6.96875,11.71875 5,18 10,14 15,18 13.03125,11.71875 18,8 11.875,8 10,2 z" fill="#38e13a" stroke="#38e13a" stroke-width="2" stroke-linejoin="round"></path></svg>'
userBadge.querySelector("svg").style.transform = "scale(0.9)"
} else if (userInfo['blocks']['received']['active']) {
userBadge.title = "The user was banned"
userBadge.textContent = "⛔️"
} else if (
new Date(userInfo['firstChangesetCreationTime'] ?? userInfo['account_created']).setUTCDate(new Date(userInfo['firstChangesetCreationTime'] ?? userInfo['account_created']).getUTCDate() + 30)
> changesetDate
) {
userBadge.title = "At the time of creating the changeset/note, the user had been editing OpenStreetMap for less than a month"
userBadge.textContent = "🍼"
} else {
getFriends().then(res => {
if (res.includes(userInfo['display_name'])) { // todo warn if username startsWith 🫂 or use svg
userBadge.title = "You are following this user"
userBadge.textContent = "🫂 "
}
})
}
return userBadge
}
function addMassChangesetsActions() {
if (!location.pathname.includes("/history")) return;
if (!document.querySelector("#sidebar_content h2")) return
addMassActionForUserChangesets();
addMassActionForGlobalChangesets();
const MAX_PAGE_FOR_LOAD = 15;
sidebarObserverForMassActions?.disconnect()
function observerHandler(mutationsList, observer) {
// console.log(mutationsList)
// debugger
if (!location.pathname.includes("/history")) {
massModeActive = null
needClearLoadMoreRequest = 0
needPatchLoadMoreRequest = false
needHideBigChangesets = false
currentMassDownloadedPages = null
observer.disconnect()
sidebarObserverForMassActions = null
return;
}
if (massModeForUserChangesetsActive && location.pathname !== "/history" && location.pathname !== "/history/friends") {
document.querySelectorAll("ol li").forEach(addChangesetCheckbox)
makeBottomActionBar()
}
if (massModeActive && (location.pathname === "/history" || location.pathname === "/history/friends")) {
document.querySelectorAll('ol li div > a[href^="/user/"]').forEach(makeUsernamesFilterable)
// sidebarObserverForMassActions?.disconnect()
filterChangesets()
// todo
// sidebarObserverForMassActions.observe(document.querySelector('#sidebar'), {childList: true, subtree: true});
}
document.querySelectorAll('#sidebar ol li div .changeset_id').forEach((item) => {
if (item.classList.contains("custom-changeset-id-click")) return
item.classList.add("custom-changeset-id-click")
item.onclick = (e) => {
if (!e.isTrusted) return
e.preventDefault();
let id = e.target.innerText.slice(1);
navigator.clipboard.writeText(id).then(() => copyAnimation(e, id));
}
item.title = "Click for copy changeset id"
if (location.pathname.match(/^\/history(\/?|\/friends)$/)) {
getCachedUserInfo(item.previousSibling.previousSibling.textContent).then((res) => {
item.previousSibling.previousSibling.title = `changesets_count: ${res['changesets']['count']}\naccount_created: ${res['account_created']}`
item.previousSibling.previousSibling.before(makeBadge(res,
new Date(item.parentElement.querySelector("time")?.getAttribute("datetime") ?? new Date())))
})
}
})
if (currentMassDownloadedPages && currentMassDownloadedPages <= MAX_PAGE_FOR_LOAD) {
const loader = document.querySelector('.changeset_more:has([href*="before"]) > [hidden]')
if (loader === null) {
makeBottomActionBar()
} else if (loader.style.display === "") {
document.querySelector('.changeset_more:has([href*="before"]) a.page-link').click()
console.log(`Loading page #${currentMassDownloadedPages}`)
currentMassDownloadedPages++
}
} else if (currentMassDownloadedPages > MAX_PAGE_FOR_LOAD) {
currentMassDownloadedPages = null
const changesetsCount = document.querySelectorAll("ol > li").length
document.querySelector("#hidden-changeset-counter").textContent = ` Displayed ${changesetsCount - hiddenChangesetsCount}/${changesetsCount}`
} else {
if (!document.querySelector("#infinity-list-btn")) {
let moreButton = document.querySelector('.changeset_more:has([href*="before"]) a.page-link')
if (!moreButton) return
moreButton.parentElement.style.display = "inline-flex"
const infinityList = document.createElement("button")
infinityList.classList.add("page-link")
infinityList.textContent = `Load ${20 * MAX_PAGE_FOR_LOAD}`
infinityList.id = "infinity-list-btn"
infinityList.onclick = () => {
currentMassDownloadedPages = 1;
moreButton.click()
infinityList.remove()
}
moreButton.after(infinityList)
moreButton.after(document.createTextNode("\xA0"))
}
}
}
sidebarObserverForMassActions = new MutationObserver(observerHandler)
sidebarObserverForMassActions.observe(document.querySelector('#sidebar'), {childList: true, subtree: true});
}
function setupMassChangesetsActions() {
if (location.pathname !== "/history" && location.pathname !== "/history/friends"
&& !(location.pathname.includes("/history") && location.pathname.includes("/user/"))) return;
let timerId = setInterval(addMassChangesetsActions, 300);
setTimeout(() => {
clearInterval(timerId);
console.debug('stop try add mass changesets actions');
}, 5000);
addMassChangesetsActions();
}
//</editor-fold>
//<editor-fold desc="hotkeys">
let hotkeysConfigured = false
async function getChangesetMetadata(changeset_id) {
return await fetch(osm_server.apiBase + "changeset" + "/" + changeset_id + ".json");
}
const _isDebug = document.querySelector("head")?.getAttribute("data-user") === "11528195";
function isDebug() {
return _isDebug;
}
function debug_alert() {
if (!isDebug()) return
alert(arguments)
// eslint-disable-next-line
debugger
}
/**
* @param {number|null=} changeset_id
* @return {Promise<ChangesetMetadata|void>}
*/
async function loadChangesetMetadata(changeset_id = null) {
console.log(`Loading changeset metadata`)
if (!changeset_id) {
const match = location.pathname.match(/changeset\/(\d+)/)
if (!match) {
return;
}
changeset_id = parseInt(match[1]);
}
console.log(`Loading metadata of changeset #${changeset_id}`)
if (changesetMetadatas[changeset_id] && changesetMetadatas[changeset_id].id === changeset_id) {
return changesetMetadatas[changeset_id];
}
// prevChangesetMetadata = changesetMetadatas[changeset_id]
const res = await getChangesetMetadata(changeset_id);
if (res.status === 509) {
error509Handler(res)
} else if (res.status !== 200) {
console.error(res)
debug_alert("metadatas failed")
} else {
const jsonRes = await res.json();
if (jsonRes.changeset) {
return changesetMetadatas[changeset_id] = jsonRes.changeset
}
changesetMetadatas[changeset_id] = jsonRes.elements[0]
changesetMetadatas[changeset_id].min_lat = changesetMetadatas[changeset_id].minlat
changesetMetadatas[changeset_id].min_lon = changesetMetadatas[changeset_id].minlon
changesetMetadatas[changeset_id].max_lat = changesetMetadatas[changeset_id].maxlat
changesetMetadatas[changeset_id].max_lon = changesetMetadatas[changeset_id].maxlon
return changesetMetadatas[changeset_id]
}
}
/**
* @param {number[]} changeset_ids
*/
async function loadChangesetMetadatas(changeset_ids) {
if (!changeset_ids.length) {
return
}
const batchSize = 100
for (let i = 0; i < changeset_ids.length; i += batchSize) {
const res = await fetch(osm_server.apiBase + "changesets.json?changesets=" + changeset_ids.slice(i, i + batchSize).join(","));
if (res.status === 509) {
error509Handler(res)
} else {
const jsonRes = await res.json();
jsonRes["changesets"].forEach(i => {
changesetMetadatas[i.id] = i
})
}
}
}
let noteMetadata = null
async function loadNoteMetadata() {
const match = location.pathname.match(/note\/(\d+)/)
if (!match) {
return;
}
const note_id = parseInt(match[1]);
if (noteMetadata !== null && noteMetadata.id === note_id) {
return;
}
const res = await fetch(osm_server.apiBase + "notes" + "/" + note_id + ".json", {signal: getAbortController().signal});
if (res.status === 509) {
error509Handler(res)
} else {
noteMetadata = await res.json()
}
}
let nodeMetadata = null
async function loadNodeMetadata() {
const match = location.pathname.match(/node\/(\d+)/)
if (!match) {
return;
}
const node_id = parseInt(match[1]);
if (nodeMetadata !== null && nodeMetadata.id === node_id) {
return;
}
const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "node" + "/" + node_id + ".json", (res) => {
if (res.status === 509) {
error509Handler(res)
} else if (res.status === 410) {
console.warn(`node ${node_id} was deleted`);
} else {
return true
}
});
if (!jsonRes) return;
nodeMetadata = jsonRes.elements[0];
return jsonRes;
}
let wayMetadata = null
/**
* @param {number|null=} way_id
* @return {Promise<void|{elements: []}>}
*/
async function loadWayMetadata(way_id = null) {
console.log(`Loading way metadata`)
if (!way_id) {
const match = location.pathname.match(/way\/(\d+)/)
if (!match) {
return;
}
way_id = parseInt(match[1]);
}
if (wayMetadata !== null && wayMetadata.id === way_id) {
return;
}
/*** @type {{elements: []}|undefined}*/
const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "way" + "/" + way_id + "/full.json", (res) => {
if (res.status === 509) {
error509Handler(res)
} else if (res.status === 410) {
console.warn(`way ${way_id} was deleted`);
} else {
return true
}
});
if (!jsonRes) return;
wayMetadata = jsonRes.elements.filter(i => i.type === "node")
wayMetadata.bbox = {
min_lat: Math.min(...wayMetadata.map(i => i.lat)),
min_lon: Math.min(...wayMetadata.map(i => i.lon)),
max_lat: Math.max(...wayMetadata.map(i => i.lat)),
max_lon: Math.max(...wayMetadata.map(i => i.lon))
}
return jsonRes
}
let relationMetadata = null
async function loadRelationMetadata() {
const match = location.pathname.match(/relation\/(\d+)/)
if (!match) {
return;
}
const relation_id = parseInt(match[1]);
if (relationMetadata !== null && relationMetadata.id === relation_id) {
return;
}
const jsonRes = await fetchJSONWithCache(osm_server.apiBase + "relation" + "/" + relation_id + "/full.json", (res) => {
if (res.status === 509) {
error509Handler(res)
} else if (res.status === 410) {
console.warn(`relation ${relation_id} was deleted`);
} else {
return true
}
});
if (!jsonRes) return;
relationMetadata = jsonRes.elements.filter(i => i.type === "node")
relationMetadata.bbox = {
min_lat: Math.min(...relationMetadata.map(i => i.lat)),
min_lon: Math.min(...relationMetadata.map(i => i.lon)),
max_lat: Math.max(...relationMetadata.map(i => i.lat)),
max_lon: Math.max(...relationMetadata.map(i => i.lon))
}
return jsonRes;
}
function updateCurrentObjectMetadata() {
setTimeout(loadChangesetMetadata, 0)
setTimeout(loadNoteMetadata, 0)
setTimeout(loadNodeMetadata, 0)
setTimeout(loadWayMetadata, 0)
setTimeout(loadRelationMetadata, 0)
}
async function abortableSleep(ms, {signal}) {
console.debug(`sleep ${ms}ms`)
await new Promise((resolve, reject) => {
signal?.throwIfAborted();
function done() {
resolve();
signal?.removeEventListener("abort", stop);
}
function stop() {
console.debug("sleep aborted")
reject(this.reason);
clearTimeout(timer);
}
const timer = setTimeout(done, ms);
signal?.addEventListener("abort", stop);
});
}
async function sleep(ms) {
console.debug(`sleep ${ms}ms`)
await new Promise(r => setTimeout(r, ms))
}
async function loadFriends() {
console.debug("Loading friends list")
const res = await ((await fetch(osm_server.url + "/dashboard")).text())
const parser = new DOMParser()
const doc = parser.parseFromString(res, "text/html")
const friends = []
doc.querySelectorAll('a[data-method="delete"][href*="/follow"]').forEach(a => {
const username = a.getAttribute("href").match(/\/user\/(.+)\/follow/)[1]
friends.push(decodeURI(username))
})
await GM.setValue("friends", JSON.stringify(friends))
console.debug("Friends list updated")
return friends
}
let friendsLoadingLock = false;
async function getFriends() {
const friendsStr = await GM.getValue("friends")
if (friendsStr) {
return JSON.parse(friendsStr)
} else {
while (friendsLoadingLock) {
await sleep(500)
}
friendsLoadingLock = true
const res = await loadFriends()
friendsLoadingLock = false
return res
}
}
const mapPositionsHistory = []
const mapPositionsNextHistory = []
function runPositionTracker() {
setInterval(() => {
if (!getMap()) return
const bound = get4Bounds(getMap())
if (JSON.stringify(mapPositionsHistory[mapPositionsHistory.length - 1]) === JSON.stringify(bound)) {
return;
}
// in case of a transition between positions
// via timeout?
if (JSON.stringify(mapPositionsNextHistory[mapPositionsNextHistory.length - 1]) === JSON.stringify(bound)) {
return;
}
mapPositionsNextHistory.length = 0
mapPositionsHistory.push(bound)
if (mapPositionsHistory.length > 100) {
mapPositionsHistory.shift()
mapPositionsHistory.shift()
}
}, 1000);
}
let newNotePlaceholder = null
function resetMapHover() {
document.querySelectorAll(".map-hover").forEach(el => {
el.classList.remove("map-hover")
})
}
function resetSelectedChangesets() {
document.querySelectorAll(".selected").forEach(el => {
el.classList.remove("selected")
})
}
let overzoomObserver = null
function enableOverzoom() {
if (!GM_config.get("OverzoomForDataLayer")) {
return
}
blankSuffix = "?blankTile=false"
console.log("Enabling overzoom for map layer")
if (overzoomObserver) {
overzoomObserver.disconnect()
}
injectJSIntoPage(`
(function () {
map.options.maxZoom = 22
const layers = [];
map.eachLayer(i => layers.push(i))
layers[0].options.maxZoom = 22
})()
`)
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeName !== 'IMG') {
return;
}
getWindow().L.DomEvent.off(node, "error")
});
});
});
overzoomObserver = observer;
observer.observe(document.body, {childList: true, subtree: true});
// it's unstable
console.log("Overzoom enabled")
}
function disableOverzoom() {
if (!GM_config.get("OverzoomForDataLayer")) {
return
}
injectJSIntoPage(`
(function () {
map.options.maxZoom = 19
const layers = [];
map.eachLayer(i => layers.push(i))
layers[0].options.maxZoom = 19
})()
`)
}
const ABORT_ERROR_PREV = "Abort requests for moving to prev changeset";
const ABORT_ERROR_NEXT = "Abort requests for moving to next changeset";
const ABORT_ERROR_USER_CHANGESETS = "Abort requests for moving to user changesets";
const ABORT_ERROR_WHEN_PAGE_CHANGED = "Abort requests. Reason: page changed";
let layersHidden = false;
let needPreloadChangesets = false;
function getPrevChangesetLink(doc = document) {
const navigationLinks = doc.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
if (navigationLinks && navigationLinks[0].href.includes("/changeset/")) {
return navigationLinks[0]
}
}
function getNextChangesetLink(doc = document) {
const navigationLinks = doc.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
if (navigationLinks && Array.from(navigationLinks).at(-1).href.includes("/changeset/")) {
return Array.from(navigationLinks).at(-1);
}
}
let repeatedEvent = false
let trustedEvent = true
const smoothScroll = "auto"
function goToPrevChangesetObject(e) {
repeatedEvent = e.repeat
if (!document.querySelector("ul .active-object")) {
return;
}
if (document.querySelector("#sidebar").matches(":hover")) {
preventHoverEvents()
}
const prev = document.querySelector("ul .active-object")
for (let i = 0; i < 10000; i++) {
const cur = document.querySelector("ul .active-object")
if (cur.previousElementSibling) {
cur.previousElementSibling.classList.add("active-object")
cur.classList.remove("active-object")
if (!cur.previousElementSibling.classList.contains('tags-non-modified')
|| cur.previousElementSibling.classList.contains('location-modified')
|| cur.previousElementSibling.querySelector('.nodes-changed, .members-changed')
|| e.altKey
|| !location.search.includes("changesets=")) {
trustedEvent = false
cur.previousElementSibling.click()
if (changesetObjectsSelectionModeEnabled) {
cur.previousElementSibling.querySelector("input")?.focus()
}
trustedEvent = true
cur.previousElementSibling.scrollIntoView({block: "center", behavior: smoothScroll})
resetMapHover()
cur.previousElementSibling.classList.add("map-hover")
if (cur.previousElementSibling.querySelector(".load-relation-version")) {
cur.previousElementSibling.querySelector(".load-relation-version").focus()
}
return
}
} else {
const curFrame = cur.parentElement.parentElement
if (curFrame.id === "changeset_nodes" && ["changeset_ways", "changeset_relations"].includes(curFrame.previousElementSibling?.id)
|| curFrame.id === "changeset_relations" && ["changeset_ways"].includes(curFrame.previousElementSibling?.id)) {
cur.classList.remove("active-object")
curFrame.previousElementSibling.querySelector("#changeset_ways li:last-of-type, #changeset_relations li:last-of-type").classList.add("active-object")
if (!curFrame.previousElementSibling.querySelector(".active-object").classList.contains('tags-non-modified')
|| curFrame.previousElementSibling.querySelector(".active-object").classList.contains('location-modified')
|| curFrame.previousElementSibling.querySelector(".active-object").querySelector('.nodes-changed, .members-changed')
|| e.altKey
|| !location.search.includes("changesets=")) {
trustedEvent = false
curFrame.previousElementSibling.querySelector(".active-object").click()
if (changesetObjectsSelectionModeEnabled) {
curFrame.previousElementSibling.querySelector(".active-object input")?.focus()
}
trustedEvent = true
curFrame.previousElementSibling.querySelector(".active-object").scrollIntoView({
block: "center",
behavior: smoothScroll
})
resetMapHover()
curFrame.previousElementSibling.querySelector(".active-object").classList.add("map-hover")
if (curFrame.previousElementSibling?.querySelector(".load-relation-version")) {
curFrame.previousElementSibling.querySelector(".load-relation-version").focus()
}
if (curFrame.id === "changeset_relations") {
document.activeElement.blur()
}
return
}
} else {
let prev = curFrame?.previousElementSibling?.previousElementSibling
if (prev?.nodeName !== "TURBO-FRAME" && prev?.previousElementSibling?.nodeName === "TURBO-FRAME") {
prev = prev.previousElementSibling;
}
if (prev?.nodeName === "TURBO-FRAME") {
cur.classList.remove("active-object")
prev.querySelector("li:last-of-type").classList.add("active-object")
if (!prev.querySelector("li:last-of-type").classList.contains('tags-non-modified')
|| prev.querySelector("li:last-of-type").classList.contains('location-modified')
|| prev.querySelector("li:last-of-type")?.querySelector('.nodes-changed, .members-changed')
|| e.altKey
|| !location.search.includes("changesets=")) {
trustedEvent = false
prev.querySelector("li:last-of-type").click()
if (changesetObjectsSelectionModeEnabled) {
prev.querySelector("li:last-of-type input")?.focus()
}
trustedEvent = true
prev.querySelector("li:last-of-type").scrollIntoView({
block: "center",
behavior: smoothScroll
})
resetMapHover()
prev.querySelector("li:last-of-type").classList.add("map-hover")
if (prev.querySelector("li:last-of-type").querySelector(".load-relation-version")) {
prev.querySelector("li:last-of-type").querySelector(".load-relation-version").focus()
}
return
}
}
}
}
if (cur === document.querySelector("ul .active-object")) {
cur.classList.remove("active-object")
prev.classList.add("active-object")
return;
}
}
}
function goToNextChangesetObject(e) {
repeatedEvent = e.repeat
if (document.querySelector("#sidebar").matches(":hover")) {
preventHoverEvents()
}
if (!document.querySelector("ul .active-object")) {
document.querySelector("#changeset_nodes li:not(.page-item), #changeset_ways li:not(.page-item), #changeset_relations li:not(.page-item)").classList.add("active-object")
trustedEvent = false
document.querySelector("ul .active-object").click()
if (changesetObjectsSelectionModeEnabled) {
document.querySelector("ul .active-object input")?.focus()
}
trustedEvent = true
resetMapHover()
document.querySelector("ul .active-object").classList.add("map-hover")
return
}
const prev = document.querySelector("ul .active-object")
for (let i = 0; i < 10000; i++) {
const cur = document.querySelector("ul .active-object")
if (cur.nextElementSibling) {
cur.nextElementSibling.classList.add("active-object")
cur.classList.remove("active-object")
if (!cur.nextElementSibling.classList.contains('tags-non-modified')
|| cur.nextElementSibling.classList.contains('location-modified')
|| cur.nextElementSibling.querySelector('.nodes-changed, .members-changed')
|| e.altKey
|| !location.search.includes("changesets=")) {
trustedEvent = false
cur.nextElementSibling.click()
if (changesetObjectsSelectionModeEnabled) {
cur.nextElementSibling.querySelector("input")?.focus()
}
trustedEvent = true
cur.nextElementSibling.scrollIntoView({block: "center", behavior: smoothScroll})
resetMapHover()
cur.nextElementSibling.classList.add("map-hover")
if (cur.nextElementSibling.querySelector(".load-relation-version")) {
cur.nextElementSibling.querySelector(".load-relation-version").focus()
}
return
}
} else {
const curFrame = cur.parentElement.parentElement
if (curFrame.id === "changeset_ways" && ["changeset_nodes", "changeset_relations"].includes(curFrame.nextElementSibling?.id)
|| curFrame.id === "changeset_relations" && ["changeset_nodes"].includes(curFrame.nextElementSibling?.id)) {
cur.classList.remove("active-object")
curFrame.nextElementSibling.querySelector("#changeset_nodes li, #changeset_relations li").classList.add("active-object")
if (!curFrame.nextElementSibling.querySelector(".active-object").classList.contains('tags-non-modified')
|| curFrame.nextElementSibling.querySelector(".active-object").classList.contains('location-modified')
|| curFrame.nextElementSibling.querySelector(".active-object").querySelector('.nodes-changed, .members-changed')
|| e.altKey
|| !location.search.includes("changesets=")) {
trustedEvent = false
curFrame.nextElementSibling.querySelector(".active-object").click()
if (changesetObjectsSelectionModeEnabled) {
curFrame.nextElementSibling.querySelector(".active-object input")?.focus()
}
trustedEvent = true
curFrame.nextElementSibling.querySelector(".active-object").scrollIntoView({
block: "center",
behavior: smoothScroll
})
resetMapHover();
curFrame.nextElementSibling.querySelector(".active-object").classList.add("map-hover")
if (curFrame.nextElementSibling?.querySelector(".load-relation-version")) {
curFrame.nextElementSibling.querySelector(".load-relation-version").focus()
}
if (curFrame.id === "changeset_relations") {
document.activeElement.blur()
}
return;
}
} else {
let next = curFrame?.nextElementSibling?.nextElementSibling
if (next?.nodeName !== "TURBO-FRAME" && next?.nextElementSibling?.nodeName === "TURBO-FRAME") {
next = next.nextElementSibling;
}
if (next?.nodeName === "TURBO-FRAME") {
cur.classList.remove("active-object")
next.querySelector("li").classList.add("active-object")
if (!next.querySelector("li").classList.contains('tags-non-modified')
|| next.querySelector("li").classList.contains('location-modified')
|| next.querySelector("li")?.querySelector('.nodes-changed, .members-changed')
|| e.altKey
|| !location.search.includes("changesets=")) {
trustedEvent = false
next.querySelector("li").click()
if (changesetObjectsSelectionModeEnabled) {
next.querySelector("li input")?.focus()
}
trustedEvent = true
next.querySelector("li").scrollIntoView({block: "center", behavior: smoothScroll})
resetMapHover()
next.querySelector("li").classList.add("map-hover")
if (next.querySelector("li").querySelector(".load-relation-version")) {
next.querySelector("li").querySelector(".load-relation-version").focus()
}
return
}
}
}
}
if (cur === document.querySelector("ul .active-object")) {
cur.classList.remove("active-object")
prev.classList.add("active-object")
return;
}
}
console.log("KeyL not found next elem")
}
function extractBboxFromElem(elem) {
const bbox = JSON.parse(elem.getAttribute("data-changeset")).bbox
bbox.min_lon = bbox.minlon
bbox.min_lat = bbox.minlat
bbox.max_lon = bbox.maxlon
bbox.max_lat = bbox.maxlat
return bbox
}
function preventHoverEvents() {
if (document.querySelector("#mouse-trap")) return;
console.log("add mouse trap");
const trap = document.createElement('div');
trap.id = 'mouse-trap';
document.body.appendChild(trap);
window.addEventListener('mousemove', () => {
trap.remove();
console.log("remove mouse trap")
}, {once: true});
}
function goToPrevChangeset(e) {
if (!layers['changesetBounds']) {
layers['changesetBounds'] = []
}
if (!document.querySelector("ol .active-object")) {
return;
}
preventHoverEvents()
const cur = document.querySelector("ol .active-object")
let prev = cur.previousElementSibling
while (true) {
if (!prev) break
if (prev.getAttribute("hidden") === "true") {
prev = prev.previousElementSibling
} else {
break
}
}
if (prev) {
prev.classList.add("active-object")
cur.classList.remove("active-object")
let focused = prev.querySelector("a")
focused.focus()
if (massModeForUserChangesetsActive) {
focused = prev.querySelector("input")
focused?.focus()
}
resetSelectedChangesets()
prev.classList.add("selected")
prev.scrollIntoView({block: "center", behavior: "instant"})
cleanObjectsByKey('changesetBounds')
setTimeout(() => {
const bound = drawBBox(extractBboxFromElem(prev), {color: "#000000", weight: 4, fillOpacity: 0})
bound.bringToFront()
focused.addEventListener("focusout", () => {
bound.remove()
}, {once: true})
setTimeout(() => {
bound.bringToFront()
}, 20)
})
} else {
document.querySelector('.changeset_more a[href*="after"]')?.click()
}
}
function goToNextChangeset(e) {
preventHoverEvents()
if (!layers['changesetBounds']) {
layers['changesetBounds'] = []
}
if (!document.querySelector("ol .active-object")) {
let next = document.querySelector("ol li")
while (true) {
if (next?.getAttribute("hidden") === "true") {
next = next.nextElementSibling
} else {
break
}
}
if (!next) {
document.querySelector('.changeset_more a[href*="before"]')?.click()
return
}
next.classList.add("active-object")
document.querySelector("ol .active-object a").tabIndex = 0
let focused = document.querySelector("ol .active-object a")
focused.focus()
if (massModeForUserChangesetsActive) {
focused = document.querySelector("ol .active-object input")
focused?.focus()
}
resetSelectedChangesets()
document.querySelector("ol .active-object").classList.add("selected")
cleanObjectsByKey('changesetBounds')
const bound = drawBBox(extractBboxFromElem(document.querySelector("ol .active-object")), {
color: "#000000",
weight: 4,
fillOpacity: 0
})
bound.bringToFront()
focused.addEventListener("focusout", () => {
bound.remove()
}, {once: true})
return
}
const cur = document.querySelector("ol .active-object")
let next = cur.nextElementSibling
while (true) {
if (!next) break
if (next.getAttribute("hidden") === "true") {
next = next.nextElementSibling
} else {
break
}
}
if (next) {
next.classList.add("active-object")
cur.classList.remove("active-object")
let focused = next.querySelector("a")
focused.focus()
if (massModeForUserChangesetsActive) {
focused = next.querySelector("input")
focused?.focus()
}
resetSelectedChangesets()
next.classList.add("selected")
next.scrollIntoView({block: "center", behavior: "instant"})
setTimeout(() => {
const bound = drawBBox(extractBboxFromElem(next), {color: "#000000", weight: 4, fillOpacity: 0})
bound.bringToFront()
focused.addEventListener("focusout", () => {
bound.remove()
}, {once: true})
setTimeout(() => {
bound.bringToFront()
}, 20)
})
} else {
document.querySelector('.changeset_more a[href*="before"]')?.click()
}
}
function extractLatLonFromElem(elem) {
return [elem.getAttribute("data-lat"), elem.getAttribute("data-lon")]
}
function goToPrevSearchResult(e) {
if (!document.querySelector("#sidebar_content ul .active-object")) {
return;
}
preventHoverEvents()
const cur = document.querySelector("#sidebar_content ul .active-object")
let prev = cur.previousElementSibling
while (true) {
if (!prev) break
if (prev.getAttribute("hidden") === "true") {
prev = prev.previousElementSibling
} else {
break
}
}
if (!prev) {
if (cur.parentElement.previousElementSibling.tagName === "UL"){
prev = cur.parentElement.previousElementSibling?.querySelector("li:last-of-type")
}
}
if (prev) {
prev.classList.add("active-object")
cur.classList.remove("active-object")
let focused = prev.querySelector("a")
focused.focus()
prev.scrollIntoView({block: "center", behavior: "instant"})
const [lat, lon] = extractLatLonFromElem(document.querySelector("#sidebar_content ul .active-object a"))
const marker = showNodeMarker(lat, lon)
panTo(lat, lon)
focused.addEventListener("focusout", () => {
marker.remove()
}, {once: true})
}
}
function goToNextSearchResult(e) {
preventHoverEvents()
if (!document.querySelector("#sidebar_content ul .active-object")) {
document.querySelector("#sidebar_content ul li").classList.add("active-object")
document.querySelector("#sidebar_content ul .active-object a").tabIndex = 0
let focused = document.querySelector("#sidebar_content .active-object a")
focused.focus()
const [lat, lon] = extractLatLonFromElem(document.querySelector("#sidebar_content ul .active-object a"))
const marker = showNodeMarker(lat, lon)
panTo(lat, lon)
focused.addEventListener("focusout", () => {
marker.remove()
}, {once: true})
return
}
const cur = document.querySelector("#sidebar_content ul .active-object")
let next = cur.nextElementSibling
while (true) {
if (!next) break
if (next.getAttribute("hidden") === "true") {
next = next.nextElementSibling
} else {
break
}
}
if (!next) {
if (cur.parentElement.nextElementSibling.tagName === "UL"){
next = cur.parentElement.nextElementSibling?.querySelector("li")
}
}
if (next) {
next.classList.add("active-object")
cur.classList.remove("active-object")
let focused = next.querySelector("a")
focused.focus()
resetSelectedChangesets()
next.scrollIntoView({block: "center", behavior: "instant"})
const [lat, lon] = extractLatLonFromElem(document.querySelector("#sidebar_content ul .active-object a"))
const marker = showNodeMarker(lat, lon)
panTo(lat, lon)
focused.addEventListener("focusout", () => {
marker.remove()
}, {once: true})
} else {
document.querySelector('.search_more a')?.click()
}
}
function goToPrevObjectVersion() {
if (document.querySelector("#sidebar").matches(":hover")) {
preventHoverEvents()
}
if (!document.querySelector("#sidebar_content .active-object")) {
getMap()?.invalidateSize()
document.querySelector(".browse-section:not(.hidden-version)").classList.add("active-object")
document.querySelector(".browse-section:not(.hidden-version)").click()
resetMapHover()
document.querySelector(".browse-section:not(.hidden-version)").classList.add("map-hover")
} else {
const old = document.querySelector(".browse-section.active-object")
let cur = old?.previousElementSibling
while (cur && (!cur.classList.contains("browse-section") || cur.classList.contains("hidden-version"))) {
cur = cur.previousElementSibling
}
if (cur) {
cur.classList.add("active-object")
old.classList.remove("active-object")
cur.click()
cur.scrollIntoView()
resetMapHover()
cur.classList.add("map-hover")
}
}
}
function gotNextObjectVersion() {
if (document.querySelector("#sidebar").matches(":hover")) {
preventHoverEvents()
}
if (!document.querySelector("#sidebar_content .active-object")) {
getMap()?.invalidateSize()
document.querySelector(".browse-section").classList.add("active-object")
document.querySelector(".browse-section.active-object").click()
resetMapHover()
document.querySelector(".browse-section.active-object").classList.add("map-hover")
} else {
const old = document.querySelector(".browse-section.active-object")
let cur = old?.nextElementSibling
while (cur && (!cur.classList.contains("browse-section") || cur.classList.contains("hidden-version"))) {
cur = cur.nextElementSibling
}
if (cur) {
cur.classList.add("active-object")
old.classList.remove("active-object")
cur.click()
cur.scrollIntoView()
resetMapHover()
cur.classList.add("map-hover")
}
}
}
const min = Math.min;
const max = Math.max;
function combineBBOXes(bboxes) {
const bbox = {
min_lat: 10000000, min_lon: 10000000, max_lat: -10000000, max_lon: -100000000,
}
for (const i of bboxes) {
if (i?.min_lat) {
bbox.min_lat = min(bbox.min_lat, i.min_lat);
bbox.min_lon = min(bbox.min_lon, i.min_lon);
bbox.max_lat = max(bbox.max_lat, i.max_lat);
bbox.max_lon = max(bbox.max_lon, i.max_lon);
}
}
return bbox;
}
async function zoomToChangesets() {
const params = new URLSearchParams(location.search)
let changesetIDs = params.get("changesets")?.split(",")
if (!changesetIDs) {
return;
}
for (const i of changesetIDs) {
await loadChangesetMetadata(parseInt(i));
}
getMap()?.invalidateSize()
const bbox = combineBBOXes(changesetIDs.map(i => changesetMetadatas[i]))
fitBounds([[bbox.min_lat, bbox.min_lon], [bbox.max_lat, bbox.max_lon]])
}
function zoomToCurrentObject(e) {
if (new URLSearchParams(location.search).has("changesets")) {
void zoomToChangesets()
} else if (location.pathname.startsWith("/changeset")) {
const changesetMetadata = changesetMetadatas[location.pathname.match(/changeset\/(\d+)/)[1]]
if (e.shiftKey && changesetMetadata) {
setTimeout(async () => {
// todo changesetID => merged BBOX
const changesetID = parseInt(location.pathname.match(/changeset\/(\d+)/)[1])
const nodesBag = [];
for (const node of Array.from((await getChangeset(changesetID)).data.querySelectorAll('node'))) {
if (node.getAttribute("visible") !== "false") {
nodesBag.push({
lat: parseFloat(node.getAttribute("lat")),
lon: parseFloat(node.getAttribute("lon"))
});
} else {
const version = searchVersionByTimestamp(
await getNodeHistory(node.getAttribute("id")),
new Date(new Date(changesetMetadata.created_at).getTime() - 1).toISOString()
)
if (version.visible !== false) {
nodesBag.push({
lat: version.lat,
lon: version.lon
});
}
}
}
if ((await getChangeset(changesetID)).data.querySelectorAll("relation").length) {
for (const way of (await getChangeset(changesetID)).data.querySelectorAll("way")) {
const targetTime = way.getAttribute("visible") === "false"
? new Date(new Date(changesetMetadata.created_at).getTime() - 1).toISOString()
: changesetMetadata.closed_at
const [, currentNodesList] = await getWayNodesByTimestamp(targetTime, way.getAttribute("id"))
currentNodesList.forEach(coords => {
nodesBag.push({
lat: coords[0],
lon: coords[1]
})
})
}
}
getMap()?.invalidateSize()
if (nodesBag.length) {
const bbox = {
min_lat: Math.min(...nodesBag.map(i => i.lat)),
min_lon: Math.min(...nodesBag.map(i => i.lon)),
max_lat: Math.max(...nodesBag.map(i => i.lat)),
max_lon: Math.max(...nodesBag.map(i => i.lon))
}
fitBounds([
[bbox.min_lat, bbox.min_lon],
[bbox.max_lat, bbox.max_lon]
]) // todo max zoom
} else {
fitBounds([
[changesetMetadata.min_lat, changesetMetadata.min_lon],
[changesetMetadata.max_lat, changesetMetadata.max_lon]
])
}
})
} else {
getMap()?.invalidateSize()
if (changesetMetadata) {
fitBounds([
[changesetMetadata.min_lat, changesetMetadata.min_lon],
[changesetMetadata.max_lat, changesetMetadata.max_lon]
])
} else {
console.warn("Changeset metadata not downloaded")
}
}
} else if (location.pathname.match(/(node|way|relation|note)\/\d+/)) {
if (location.pathname.includes("node")) {
if (nodeMetadata) {
panTo(nodeMetadata.lat, nodeMetadata.lon)
} else {
if (location.pathname.includes("history")) {
// panTo last visible version
panTo(
document.querySelector(".browse-node span.latitude").textContent.replace(",", "."),
document.querySelector(".browse-node span.longitude").textContent.replace(",", ".")
)
}
}
} else if (location.pathname.includes("note")) {
if (!document.querySelector('#sidebar_content a[href*="/traces/"]') || !trackMetadata) {
if (noteMetadata) {
panTo(noteMetadata.geometry.coordinates[1], noteMetadata.geometry.coordinates[0], Math.max(17, getMap().getZoom()))
}
} else if (trackMetadata) {
fitBounds([
[trackMetadata.min_lat, trackMetadata.min_lon],
[trackMetadata.max_lat, trackMetadata.max_lon]
])
}
} else if (location.pathname.includes("way")) {
if (wayMetadata) {
fitBounds([
[wayMetadata.bbox.min_lat, wayMetadata.bbox.min_lon],
[wayMetadata.bbox.max_lat, wayMetadata.bbox.max_lon]
])
}
} else if (location.pathname.includes("relation")) {
if (relationMetadata) {
fitBounds([
[relationMetadata.bbox.min_lat, relationMetadata.bbox.min_lon],
[relationMetadata.bbox.max_lat, relationMetadata.bbox.max_lon]
])
}
}
} else if (location.search.includes("&display-gpx=")) {
if (trackMetadata) {
fitBounds([
[trackMetadata.min_lat, trackMetadata.min_lon],
[trackMetadata.max_lat, trackMetadata.max_lon]
])
}
} else if (searchResultBBOX) {
fitBounds([
[searchResultBBOX.min_lat, searchResultBBOX.min_lon],
[searchResultBBOX.max_lat, searchResultBBOX.max_lon]
])
}
}
function getCurrentXYZ() {
const [, z, x, y] = new URL(document.querySelector("#editanchor").href).hash.match(/map=(\d+)\/([0-9.-]+)\/([0-9.-]+)/);
return [x, y, z];
}
function setupNavigationViaHotkeys() {
if (["/edit", "/id"].includes(location.pathname)) return
updateCurrentObjectMetadata()
// if (!location.pathname.startsWith("/changeset")) return;
if (hotkeysConfigured) return
hotkeysConfigured = true
runPositionTracker()
function keydownHandler(e) {
if (e.repeat && !["KeyK", "KeyL"].includes(e.code)) return
if (document.activeElement?.name === "text") return
if (document.activeElement?.name === "query") { // todo расширить для любого поля
if (e.code === "Escape") {
document.activeElement.blur()
}
return
}
if (["TEXTAREA", "INPUT", "SELECT"].includes(document.activeElement?.nodeName) && document.activeElement?.getAttribute("type") !== "checkbox") {
return;
}
if (document.activeElement.getAttribute("contenteditable")) {
return
}
if (["TH", "TD"].includes(document.activeElement?.nodeName)
&& document.activeElement?.parentElement?.parentElement?.parentElement?.hasAttribute("contenteditable")) {
return
}
if (["TR"].includes(document.activeElement?.nodeName)
&& document.activeElement?.parentElement?.parentElement?.hasAttribute("contenteditable")) {
return
}
if (e.metaKey || e.ctrlKey) {
return;
}
console.log("Key: ", e.key)
console.log("Key code: ", e.code)
if (e.code === "KeyN") {
if (location.pathname.includes("/user/") && !location.pathname.includes("/history")) {
document.querySelector('a[href^="/user/"][href$="/notes"]')?.click()
} else if (e.altKey && location.pathname.match(/note\/[0-9]+/)) {
window.open(document.querySelector('#sidebar_content a[href^="/user/"]').getAttribute("href") + "/notes", "_blank")
} else {
// notes
if (e.shiftKey) {
if (location.pathname.includes("/node") || location.pathname.includes("/way") || location.pathname.includes("/relation")) {
newNotePlaceholder = "\n \n" + location.href
}
document.querySelector(".control-note .control-button").click()
} else {
Array.from(document.querySelectorAll(".overlay-layers label"))[0].click()
}
}
} else if (e.code === "KeyD") {
if (e.altKey && isDebug()) {
// eslint-disable-next-line no-debugger
debugger
throw "debug"
}
if (location.pathname.includes("/user/") && !location.pathname.includes("/history")) {
document.querySelector('a[href^="/user/"][href$="/diary"]')?.click()
} else {
// map data
Array.from(document.querySelectorAll(".overlay-layers label"))[1].click()
if (!location.hash.includes("D")) {
disableOverzoom()
} else {
enableOverzoom()
}
}
} else if (e.code === "KeyG") { // gps tracks
if (e.shiftKey || e.altKey) {
enableOverzoom()
getMap().setZoom(Math.min(14, getMap().getZoom()))
if (!document.querySelectorAll(".overlay-layers label")[2].querySelector("input").checked) {
Array.from(document.querySelectorAll(".overlay-layers label"))[2].click()
}
switchOverlayTiles()
} else {
Array.from(document.querySelectorAll(".overlay-layers label"))[2].click()
}
} else if (e.code === "KeyS") { // satellite
enableOverzoom()
if (e.shiftKey) {
switchESRIbeta()
return
} else if (e.altKey) {
bypassCaches()
} else {
switchTiles()
if (document.querySelector(".turn-on-satellite")) {
document.querySelector(".turn-on-satellite").textContent = invertTilesMode(currentTilesMode)
}
if (document.querySelector(".turn-on-satellite-from-pane")) {
document.querySelector(".turn-on-satellite-from-pane").textContent = invertTilesMode(currentTilesMode)
}
}
} else if (e.code === "KeyE") {
if (!location.pathname.match(/^\/user\/([^/]+)\/?$/)) {
if (e.shiftKey) {
if (document.querySelector("#editanchor").getAttribute("data-editor") === "id") {
document.querySelectorAll("#edit_tab .dropdown-menu .editlink")[1]?.click()
} else {
document.querySelectorAll("#edit_tab .dropdown-menu .editlink")[0]?.click()
}
} else if (e.altKey && isDebug()) {
document.querySelectorAll("table.quick-look, table.geojson-props-table:not(.metainfo-table)").forEach(i => {
i.setAttribute("contenteditable", "true")
})
} else {
document.querySelector("#editanchor")?.click()
}
} else {
document.querySelector('a[href^="/user/"][href$="/history"]')?.click()
}
} else if (e.code === "KeyR") {
if (changesetObjectsSelectionModeEnabled || e.altKey) {
document.querySelector("#revert_button_class").click()
return
}
changesetObjectsSelectionModeEnabled = true;
document.querySelectorAll("#changeset_nodes, #changeset_ways, #changeset_relations").forEach(i => {
i.querySelectorAll('button, input, a, textarea, select, [tabindex]').forEach(el => {
el.setAttribute('tabindex', '-1');
})
});
["#changeset_nodes", "#changeset_ways", "#changeset_relations"].forEach(selector => {
document.querySelectorAll(`${selector} :not(.pagination) li`).forEach(obj => {
const checkbox = document.createElement("input")
checkbox.type = "checkbox"
checkbox.title = "Click with Shift for select range\nPress R for revert via osm-revert\nPress J for open objects in JOSM\nPress alt + J for open objects in Level0"
checkbox.tabIndex = 0
checkbox.style.width = "18px"
checkbox.style.height = "18px"
checkbox.style.margin = "1px"
checkbox.classList.add("align-bottom", "object-fit-none", "browse-icon")
function selectRange() {
let currentCheckboxFound = false
for (const cBox of Array.from(document.querySelectorAll(`#changeset_nodes li input[type=checkbox], #changeset_ways li input[type=checkbox], #changeset_relations li input[type=checkbox]`)).toReversed()) {
if (!currentCheckboxFound) {
if (cBox === checkbox) {
currentCheckboxFound = true
}
} else {
if (cBox.checked) {
break
}
cBox.checked = true
}
}
}
checkbox.onclick = e => {
e.stopPropagation()
e.stopImmediatePropagation()
if (e.shiftKey) {
selectRange()
}
}
checkbox.onkeydown = e => {
if (e.shiftKey && e.code === "Space") {
selectRange()
} else if (e.code === "Enter") {
e.target.click()
if (e.shiftKey) {
selectRange()
}
}
}
const icon = obj.querySelector("img")
icon.style.display = "none"
const label = document.createElement("label")
label.appendChild(checkbox)
label.onclick = e => {
e.stopPropagation()
e.stopImmediatePropagation()
}
icon.after(label)
})
})
document.querySelector("#changeset_nodes input[type=checkbox], #changeset_ways input[type=checkbox], #changeset_relations input[type=checkbox]").focus()
} else if (e.code === "KeyJ") {
setTimeout(async () => {
if (!location.pathname.includes("changeset")) {
const m = location.pathname.match(/\/(node|way|relation)\/([0-9]+)/)
if (!m) return
const [, type, id] = m
const shortType = type === "node" ? "n" : (type === "way" ? "w" : "r")
if (e.altKey) {
window.open("https://level0.osmz.ru/?" + new URLSearchParams({
url: shortType + id + "!"
}).toString())
} else {
window.open("http://localhost:8111/load_object?" + new URLSearchParams({
objects: [shortType + id],
relation_members: true,
}).toString())
}
return
}
const nodes = new Set()
const ways = new Set()
const relations = new Set()
let changesetID = parseInt(location.pathname.match(/changeset\/(\d+)/)[1])
const changesetData = (await getChangeset(changesetID)).data
function processChangeset(data) {
if (changesetObjectsSelectionModeEnabled) {
document.querySelectorAll("#changeset_nodes input[type=checkbox]:checked").forEach(n => {
nodes.add(parseInt(n.parentElement.nextElementSibling.id.match(/[0-9]+n([0-9]+)/)[1]))
})
document.querySelectorAll("#changeset_ways input[type=checkbox]:checked").forEach(w => {
ways.add(parseInt(w.parentElement.nextElementSibling.id.match(/[0-9]+w([0-9]+)/)[1]))
})
document.querySelectorAll("#changeset_relations input[type=checkbox]:checked").forEach(r => {
relations.add(parseInt(r.parentElement.nextElementSibling.id.match(/[0-9]+r([0-9]+)/)[1]))
})
} else {
Array.from(data.querySelectorAll("node")).map(i => nodes.add(parseInt(i.getAttribute("id"))))
Array.from(data.querySelectorAll("way")).map(i => ways.add(parseInt(i.getAttribute("id"))))
Array.from(data.querySelectorAll("relation")).map(i => relations.add(parseInt(i.getAttribute("id"))))
}
}
processChangeset(changesetData)
if (location.search.includes("changesets=")) {
const params = new URLSearchParams(location.search)
const changesetIDs = params.get("changesets")?.split(",")?.filter(i => i !== changesetID) ?? []
await Promise.all(changesetIDs.map(async i => {
if (i === changesetID) return
processChangeset((await getChangeset(i)).data)
}))
}
if (e.altKey) {
window.open("https://level0.osmz.ru/?" + new URLSearchParams({
url: [
Array.from(nodes).map(i => "n" + i).join(","),
Array.from(ways).map(i => "w" + i).join(","),
Array.from(relations).map(i => "r" + i).join(",")
].join(",").replace(/,,/, ",").replace(/,$/, "").replace(/^,/, "")
}).toString())
} else {
window.open("http://localhost:8111/load_object?" + new URLSearchParams({
new_layer: "true",
objects: [
Array.from(nodes).map(i => "n" + i).join(","),
Array.from(ways).map(i => "w" + i).join(","),
Array.from(relations).map(i => "r" + i).join(",")
].join(",")
}).toString())
}
})
} else if (e.code === "KeyH") {
if (e.shiftKey) {
const targetURL = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href") + "/history"
if (targetURL !== location.pathname) {
try {
getWindow().OSM.router.route(targetURL)
} catch {
window.location = targetURL
}
}
} else {
if (location.pathname.match(/(node|way|relation)\/\d+/)) {
if (location.pathname.match(/(node|way|relation)\/\d+\/?$/)) {
getWindow().OSM.router.route(window.location.pathname + "/history")
} else if (location.pathname.match(/(node|way|relation)\/\d+\/history\/\d+\/?$/)) {
const historyPath = window.location.pathname.match(/(\/(node|way|relation)\/\d+\/history)\/\d+/)[1]
getWindow().OSM.router.route(historyPath)
} else {
console.debug("skip H")
}
} else if (location.pathname === "/" || location.pathname.includes("/note")) {
// document.querySelector("#history_tab")?.click()
document.querySelector('.nav-link[href^="/history"]')?.click()
} else if (location.pathname.includes("/user/")) {
document.querySelector('a[href^="/user/"][href$="/history"]')?.click()
}
}
} else if (e.code === "KeyY") {
const [x, y, z] = getCurrentXYZ();
window.open(`https://yandex.ru/maps/?l=stv,sta&ll=${y},${x}&z=${z}`, "_blank", "noreferrer");
} else if (e.key === "1") {
if (location.pathname.match(/\/(node|way|relation)\/\d+/)) {
if (location.pathname.match(/\/(node|way|relation)\/\d+/)) {
getWindow().OSM.router.route(location.pathname.match(/\/(node|way|relation)\/\d+/)[0] + "/history/1")
} else {
console.debug("skip 1")
}
} else if (location.pathname.startsWith("/changeset")) {
const user_link = document.querySelector('#sidebar_content a[href^="/user/"]')
if (user_link) {
const username = decodeURI(user_link.getAttribute("href").match(/\/user\/([^/]+)/)[1])
getCachedUserInfo(username).then(res => {
if (res["firstChangesetID"]) {
getWindow().OSM.router.route(`/changeset/${res["firstChangesetID"]}`)
} else {
console.warn("not found first changeset for " + username)
}
})
}
}
} else if (e.key === "0") {
const center = getMap().getCenter()
setZoom(2)
fetch(`https://nominatim.openstreetmap.org/reverse.php?lon=${center.lng}&lat=${center.lat}&format=jsonv2`).then((res) => {
res.json().then((r) => {
if (r?.address?.state) {
getMap().attributionControl.setPrefix(`${r.address.state}`)
}
})
})
} else if (e.code === "KeyZ") {
zoomToCurrentObject(e)
} else if (e.key === "8") {
if (mapPositionsHistory.length > 1) {
mapPositionsNextHistory.push(mapPositionsHistory[mapPositionsHistory.length - 1])
mapPositionsHistory.pop()
fitBounds(mapPositionsHistory[mapPositionsHistory.length - 1])
}
} else if (e.key === "9") {
if (mapPositionsNextHistory.length) {
mapPositionsHistory.push(mapPositionsNextHistory.pop())
fitBounds(mapPositionsHistory[mapPositionsHistory.length - 1])
}
} else if (e.code === "Minus") {
if (document.activeElement?.id !== "map") {
if (!e.altKey) {
getMap().setZoom(getMap().getZoom() - 2)
} else {
getMap().setZoom(getMap().getZoom() - 1)
}
}
} else if (e.code === "Equal") {
if (document.activeElement?.id !== "map") {
if (!e.altKey) {
getMap().setZoom(getMap().getZoom() + 2)
} else {
getMap().setZoom(getMap().getZoom() + 1)
}
}
} else if (e.code === "KeyO") {
if (e.shiftKey) {
window.open("https://overpass-api.de/achavi/?changeset=" + location.pathname.match(/\/changeset\/(\d+)/)[1])
} else if (!e.altKey) {
const usernameMatch = location.pathname.match(/^\/user\/([^/]+)\/?$/)
if (usernameMatch) {
window.open(makeOsmchaLinkForUsername(decodeURI(usernameMatch[1])))
} else {
document.querySelector("#osmcha_link")?.click()
}
}
} else if (e.code === "Escape") {
cleanObjectsByKey("activeObjects")
} else if (e.code === "KeyL" && e.shiftKey) {
document.querySelector(".control-locate .control-button").click()
} else if (e.code === "KeyK" && location.pathname.match(/^(\/user\/.+)?\/history\/?$/)) {
goToPrevChangeset(e);
} else if (e.code === "KeyL" && location.pathname.match(/^(\/user\/.+)?\/history\/?$/)) {
goToNextChangeset(e);
} else if (e.code === "KeyK" && location.pathname === "/search") {
goToPrevSearchResult(e);
} else if (e.code === "KeyL" && location.pathname === "/search") {
goToNextSearchResult(e);
} else if (e.code === "KeyC") {
if (location.pathname.includes("/user/") && !location.pathname.includes("/history")) {
if (location.pathname.includes("/diary_comments")) {
document.querySelector('a[href^="/user/"][href$="changeset_comments"]')?.click()
} else {
document.querySelector('a[href^="/user/"][href$="_comments"]')?.click()
}
} else {
const activeObject = document.querySelector(".browse-section.active-object")
if (activeObject) {
activeObject.querySelector('a[href^="/changeset/"]')?.click()
} else {
const changesetsLinks = document.querySelectorAll('a[href^="/changeset/"]')
if (changesetsLinks.length === 1) {
changesetsLinks[0].click()
}
}
}
} else if (e.code === "KeyQ" && !e.altKey && !e.metaKey && !e.shiftKey && !e.ctrlKey) {
buildingViewerIframe?.remove()
buildingViewerIframe = null
document.querySelectorAll(".sidebar-close-controls .btn-close").forEach(i => i?.click())
document.querySelector(".welcome .btn-close")?.click()
} else if (e.code === "KeyT" && !e.altKey && !e.metaKey && !e.shiftKey && !e.ctrlKey) {
if (location.pathname.includes("/user/") && !location.pathname.includes("/history")) {
document.querySelector('a[href="/traces/mine"], a[href$="/traces"]:not(.nav-link):not(.dropdown-item)')?.click()
} else {
document.querySelector(".quick-look-compact-toggle-btn")?.click()
document.querySelector(".compact-toggle-btn")?.click()
}
} else if (e.code === "KeyU" && !e.altKey && !e.metaKey && !e.ctrlKey) {
if (e.shiftKey) {
window.location = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href")
} else {
const user_link = document.querySelector('#sidebar_content a[href^="/user/"]')
if (user_link) {
if (user_link.checkVisibility()) {
user_link?.click()
} else {
document.querySelector('#sidebar_content li:not([hidden-data-changeset]) a[href^="/user/"]')?.click()
}
// todo fixme on changesets page with filter
} else {
document.querySelector('#content a[href^="/user/"]:not([href$=rss]):not([href*="/diary"]):not([href*="/traces"])')?.click()
}
}
} else if ((e.code === "Backquote" || e.code === "Quote" || e.key === "`" || e.key === "~") && !e.altKey && !e.metaKey && !e.ctrlKey) {
if (!getWindow().mapIntercepted) return
e.preventDefault()
for (let member in layers) {
layers[member].forEach((i) => {
if (layersHidden) {
i.getElement().style.visibility = ""
} else {
i.getElement().style.visibility = "hidden"
}
})
}
if (getWindow()?.jsonLayer) {
if (layersHidden) {
injectJSIntoPage(`jsonLayer.eachLayer(i => i.getElement().style.visibility = "")`)
} else {
injectJSIntoPage(`jsonLayer.eachLayer(i => i.getElement().style.visibility = "hidden")`)
}
} else if (jsonLayer) {
if (layersHidden) {
jsonLayer.eachLayer(intoPageWithFun(i => getMap()._layers[i._leaflet_id].getElement().style.visibility = ""))
} else {
jsonLayer.eachLayer(intoPageWithFun(i => getMap()._layers[i._leaflet_id].getElement().style.visibility = "hidden"))
}
}
layersHidden = !layersHidden;
} else if (e.code === "KeyF" && !e.altKey && !e.metaKey && !e.ctrlKey) {
document.querySelector("#changesets-filter-btn")?.click()
document.querySelector("#mass-action-btn")?.click()
} else if (isDebug() && e.code === "KeyP" && e.altKey) {
if (location.pathname.startsWith("/changeset")) {
const params = new URLSearchParams(location.search)
let changesetIDs = params.get("changesets")?.split(",") ?? [parseInt(location.pathname.match(/changeset\/(\d+)/)[1])]
const objects = []
if (changesetIDs) {
setTimeout(async () => {
for (const i of changesetIDs) {
(await getChangeset(i)).data.querySelectorAll("node,way,relation").forEach(obj => {
objects.push(obj)
})
}
objects.sort((a, b) => {
const A = new Date(a.getAttribute("timestamp"))
const B = new Date(b.getAttribute("timestamp"))
if (A < B) return -1;
if (A > B) return 1;
return 0;
})
const nodesList = []
for (let object of objects) {
if (object.nodeName === "node" && object.getAttribute("visible") === "true") {
// debugger
// showNodeMarker(object.getAttribute("lat"), object.getAttribute("lon"), "rgb(0,34,255)", null, 'customObjects')
// await sleep(300)
nodesList.push([object.getAttribute("lat"), object.getAttribute("lon")])
} else if (object.nodeName === "way") {
// TODO
}
}
showActiveWay(nodesList, "#0022ff", false, null, true, 2)
})
}
}
} else if ((e.code === "Slash" || e.code === "Backslash" || e.code === "NumpadDivide" || e.key === "/") && e.shiftKey) {
setTimeout(async () => {
getMap().getBounds()
const query = prompt(`Type overpass selector:\n\tkey\n\tkey=value\n\tkey~val,i\n\tway[footway=crossing](if: length() > 150)\nEnd with ! for global search\n⚠this is a simple prototype of search`, await GM.getValue("lastOverpassQuery", ""))
if (query) {
insertOverlaysStyles()
processOverpassQuery(query)
}
}, 0);
} else if (e.altKey && e.code === "Backquote") {
darkModeForMap = !darkModeForMap
if (darkModeForMap) {
injectDarkMapStyle()
} else {
darkMapStyleElement?.remove()
}
} else if (e.code === "KeyP") {
navigator.clipboard.writeText(shortOsmOrgLinksInText(location.origin + location.pathname));
} else {
// console.log(e.key, e.code)
}
if (location.pathname.startsWith("/changeset") && !location.pathname.includes("/changeset_comments")) {
if (e.code === "Comma") {
const link = getPrevChangesetLink()
if (link) {
getAbortController().abort(ABORT_ERROR_PREV)
needPreloadChangesets = true
link.focus()
link.click()
}
} else if (e.code === "Period") {
const link = getNextChangesetLink()
if (link) {
getAbortController().abort(ABORT_ERROR_NEXT)
needPreloadChangesets = true
link.focus()
link.click()
}
} else if (e.code === "KeyH") {
const userChangesetsLink = document.querySelectorAll("div.secondary-actions")[1]?.querySelector('a[href^="/user/"]')
if (userChangesetsLink) {
getAbortController().abort(ABORT_ERROR_USER_CHANGESETS)
userChangesetsLink.focus()
userChangesetsLink.click()
}
} else if (e.code === "KeyK") {
goToPrevChangesetObject(e);
} else if (e.code === "KeyL" && !e.shiftKey) {
goToNextChangesetObject(e);
}
} else if (location.pathname.match(/^\/(node|way|relation)\/\d+/)) {
if (e.code === "Comma") {
const navigationLinks = document.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
if (navigationLinks && navigationLinks[0].href.includes("/history/")) {
if (location.pathname.includes("history")) {
navigationLinks[0].click()
} else {
Array.from(navigationLinks).at(-1).click()
}
}
} else if (e.code === "Period") {
const navigationLinks = document.querySelectorAll("div.secondary-actions")[1]?.querySelectorAll("a")
if (navigationLinks && Array.from(navigationLinks).at(-1).href.includes("/history/")) {
Array.from(navigationLinks).at(-1).click()
}
}
if (location.pathname.match(/\/history$/)) {
if (e.code === "KeyK") {
goToPrevObjectVersion();
} else if (e.code === "KeyL" && !e.shiftKey) {
gotNextObjectVersion();
}
}
} else if (location.pathname.match(/user\/.+\/(traces|diary_comments|changeset_comments)/)
|| location.pathname.match(/\/user_blocks($|\/)/)
|| location.pathname.match(/\/blocks_by$/)) {
if (e.code === "Comma") {
document.querySelector('.pagination a[href*="after"]')?.click()
} else if (e.code === "Period") {
document.querySelector('.pagination a[href*="before"]')?.click()
}
} else if (location.pathname.match(/user\/.+\/(notes)/)) {
if (e.code === "Comma") {
document.querySelectorAll('.pagination li a')[0]?.click()
} else if (e.code === "Period") {
document.querySelectorAll('.pagination li a')[1]?.click()
}
}
}
document.addEventListener('keydown', keydownHandler, false);
}
//</editor-fold>
function setupClickableAvatar() {
const miniAvatar = document.querySelector(".user_thumbnail_tiny:not([patched-for-click])")
if (!miniAvatar || miniAvatar.setAttribute("patched-for-click", "true")) {
return;
}
miniAvatar.onclick = (e) => {
if (!e.isTrusted) return
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
if (location.pathname.match(/\/user\/.+\/history/)) {
const targetURL = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href")
if (e.ctrlKey || e.metaKey) {
window.open(targetURL, "_blank")
} else {
window.location = targetURL
}
miniAvatar.click() // dirty hack for hide dropdown
} else {
const targetURL = document.querySelector('.dropdown-item[href^="/user/"]').getAttribute("href") + "/history"
if (targetURL !== location.pathname) {
if (e.ctrlKey || e.metaKey) {
window.open(targetURL, "_blank")
} else {
try {
getWindow().OSM.router.route(targetURL)
} catch {
window.location = targetURL
}
}
miniAvatar.click() // dirty hack for hide dropdown
}
}
}
}
function setupOverzoomForDataLayer() {
if (location.hash.includes("D") && location.hash.includes("layers")) {
enableOverzoom()
}
}
/***@type {((function(string): Promise<void>|void))[]}*/
const modules = [
setupDarkModeForMap,
setupHDYCInProfile,
setupBetterProfileStat,
setupCompactChangesetsHistory,
setupMassChangesetsActions,
setupRevertButton,
setupResolveNotesButton,
setupDeletor,
setupHideNoteHighlight,
setupSatelliteLayers,
setupVersionsDiff,
setupChangesetQuickLook,
setupNewEditorsLinks,
setupNavigationViaHotkeys,
setupClickableAvatar,
setupOverzoomForDataLayer,
setupDragAndDropViewers
];
const alwaysEnabledModules = [
setupRelationVersionViewer,
setupMakeVersionPageBetter
]
const fetchJSONWithCache = (() => {
/**@type {Map<string, Object | Promise<Object>>}*/
const cache = new Map();
return async (url, init = {}, responseChecker = (_) => true) => {
if (cache.has(url)) {
return cache.get(url);
}
const promise = fetch(url, init).then((res) => {
if (!res) console.trace()
if (responseChecker(res)) {
return res.json()
}
});
cache.set(url, promise);
try {
const result = await promise;
cache.set(url, result);
return result;
} catch (error) {
cache.delete(url);
throw error;
}
};
})();
function setupTaginfo() {
const instance_text = document.querySelector("#instance")?.textContent;
const instance = instance_text?.replace(/ \(.*\)/, "")
if (instance_text?.includes(" ")) {
const turboLink = document.querySelector("#turbo_button:not(.fixed-link)")
if (turboLink && (turboLink.href.includes("%22+in") || turboLink.href.includes("*+in") || turboLink.href.includes("relation+in"))) {
turboLink.href = turboLink.href.replace(/(%22|\*|relation)\+in\+(.*)&/, `$1+in+"${instance}"&`)
turboLink.classList?.add("fixed-link")
}
}
if (location.pathname.match(/reports\/key_lengths$/)) {
document.querySelectorAll(".dt-body[data-col='1']").forEach(i => {
if (i.querySelector(".overpass-link")) return
const overpassLink = document.createElement("a")
overpassLink.classList.add("overpass-link")
overpassLink.textContent = "🔍"
overpassLink.target = "_blank"
const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ''))
const key = i.querySelector(".empty") ? "" : i.querySelector("a").textContent
overpassLink.href = `${overpass_server.url}?` + (count > 100000
? new URLSearchParams({
w: instance ? `"${key}"=* in "${instance}"` : `"${key}"=*`
}
).toString()
: new URLSearchParams({
w: instance ? `"${key}"=* in "${instance}"` : `"${key}"=* global`,
R: ""
}).toString())
i.prepend(document.createTextNode("\xA0"))
i.prepend(overpassLink)
})
} else if (location.pathname.match(/relations\//)) {
if (location.hash !== "#roles") {
return
}
if (!document.querySelector(".value")) {
console.log("Table not loaded")
return
}
document.querySelectorAll("#roles .dt-body[data-col='0']").forEach(i => {
if (i.querySelector(".overpass-link")) return
const overpassLink = document.createElement("a")
overpassLink.classList.add("overpass-link")
overpassLink.textContent = "🔍"
overpassLink.target = "_blank"
overpassLink.style.cursor = "progress"
const role = i.querySelector(".empty") ? "" : i.textContent.replaceAll("␣", " ")
const type = location.pathname.match(/relations\/(.*$)/)[1]
const count = parseInt(i.nextElementSibling.querySelector(".value").textContent.replace(/\s/g, ''))
if (instance) {
fetchJSONWithCache("https://nominatim.openstreetmap.org/search?" + new URLSearchParams({
format: "json",
q: instance
}).toString()).then((r) => {
if (r[0]['osm_id']) {
const query = `// ${instance}
area(id:${3600000000 + parseInt(r[0]['osm_id'])})->.a;
rel[type=${type}](if:count_by_role("${role}") > 0)(area.a);
out geom;
`;
overpassLink.href = `${overpass_server.url}?` + (count > 1000
? new URLSearchParams({Q: query})
: new URLSearchParams({Q: query, R: ""})).toString()
overpassLink.style.cursor = "pointer"
} else {
overpassLink.remove()
}
})
} else {
const query = `rel[type=${type}](if:count_by_role("${role}") > 0)${count > 1000 ? "({{bbox}})" : ""};\nout geom;`
overpassLink.href = `${overpass_server.url}?` + (count > 1000
? new URLSearchParams({Q: query})
: new URLSearchParams({Q: query, R: ""})).toString()
overpassLink.style.cursor = "pointer"
}
i.prepend(document.createTextNode("\xA0"))
i.prepend(overpassLink)
})
}
}
let trackMetadata = null;
/**
* @param {string} xml
*/
function displayGPXTrack(xml) {
const diffParser = new DOMParser();
const doc = diffParser.parseFromString(xml, "application/xml");
const popup = document.createElement("span")
const name = doc.querySelector("gpx name")?.textContent
const nameSpan = document.createElement("p")
nameSpan.textContent = name
const desc = doc.querySelector("gpx desc")?.textContent
const descSpan = document.createElement("p")
descSpan.textContent = desc
const link = doc.querySelector("gpx link")?.getAttribute("href")
const linkA = document.createElement("a")
linkA.href = link
linkA.textContent = link
popup.appendChild(nameSpan)
popup.appendChild(descSpan)
popup.appendChild(linkA)
console.time("start gpx track render")
const min = Math.min;
const max = Math.max;
trackMetadata = {
min_lat: 10000000,
min_lon: 10000000,
max_lat: -10000000,
max_lon: -100000000,
}
doc.querySelectorAll("gpx trk").forEach(trk => {
const nodesList = []
trk.querySelectorAll("trkseg trkpt").forEach(i => {
const lat = parseFloat(i.getAttribute("lat"));
const lon = parseFloat(i.getAttribute("lon"));
nodesList.push([lat, lon]);
trackMetadata.min_lat = min(trackMetadata.min_lat, lat);
trackMetadata.min_lon = min(trackMetadata.min_lon, lon);
trackMetadata.max_lat = max(trackMetadata.max_lat, lat);
trackMetadata.max_lon = max(trackMetadata.max_lon, lon);
});
displayWay(cloneInto(nodesList, unsafeWindow), false, "rgb(255,0,47)", 5, null, "customObjects", null, popup.outerHTML);
});
doc.querySelectorAll("gpx wpt").forEach(wpt => {
const lat = wpt.getAttribute("lat");
const lon = wpt.getAttribute("lon");
showNodeMarker(lat, lon, "rgb(255,0,47)", null, 'customObjects', 3);
trackMetadata.min_lat = min(trackMetadata.min_lat, lat);
trackMetadata.min_lon = min(trackMetadata.min_lon, lon);
trackMetadata.max_lat = max(trackMetadata.max_lat, lat);
trackMetadata.max_lon = max(trackMetadata.max_lon, lon);
});
console.timeEnd("start gpx track render")
}
function renderGeoJSONwrapper(geojson) {
injectJSIntoPage(`
var jsonLayer = null;
function renderGeoJSON(data) {
function onEachFeature(feature, layer) {
if (feature.properties) {
const table = document.createElement("table")
table.style.overflow = "scroll"
table.classList.add("geojson-props-table")
table.classList.add("zebra_colors")
const tbody = document.createElement("tbody")
table.appendChild(tbody)
Object.entries(feature.properties).forEach(([key, value]) => {
if (Array.isArray(value) && value.length === 0) {
value = "[]"
} else if (value === null) {
value = "null"
} else if (typeof value === 'object' && Object.entries(value).length === 0) {
value = "{}"
}
const th = document.createElement("th")
th.textContent = key
const td = document.createElement("td")
if (key === "id" && typeof value(value.startsWith("node/") || value.startsWith("way/") || value.startsWith("relation/"))) {
const a = document.createElement("a")
a.textContent = value
a.href = "/" + value
td.appendChild(a)
} else {
td.textContent = value
}
const tr = document.createElement("tr")
tr.appendChild(th)
tr.appendChild(td)
tbody.appendChild(tr)
})
layer.on("click", e => {
if (e.originalEvent.altKey) {
layer.remove()
e.originalEvent.stopPropagation()
e.originalEvent.stopImmediatePropagation()
}
})
layer.bindPopup(table.outerHTML)
}
}
jsonLayer = L.geoJSON(data, {
onEachFeature: onEachFeature
});
jsonLayer.addTo(map);
}
`)
getWindow().renderGeoJSON(intoPage(geojson))
}
var jsonLayer = null;
let bannedVersions = null
function currentVersionBanned(module) {
if (!bannedVersions) return false;
if (bannedVersions[GM_info.script.version]) {
if (bannedVersions[GM_info.script.version][module]) {
return bannedVersions[GM_info.script.version][module]
}
}
return false
}
function insertOverlaysStyles() {
const mapWidth = getComputedStyle(document.querySelector("#map")).width
const mapHeight = getComputedStyle(document.querySelector("#map")).height
injectCSSIntoOSMPage(`
.leaflet-popup-content:has(.geojson-props-table) {
overflow: scroll;
}
.leaflet-popup-content:has(.geojson-editor) {
/*max-width: calc(${mapWidth} / 3) !important;
min-width: calc(${mapWidth} / 3) !important;
max-height: calc(${mapHeight} / 2);
min-height: calc(${mapHeight} / 2);*/
overflow-y: scroll;
font-size: larger;
}
.geojson-editor {
margin-left: 5px;
}
table.tags-table {
margin-top: 5px;
margin-left: 5px;
}
table.metainfo-table {
margin-top: 5px;
margin-left: 5px;
}
table.tags-table th:not(.tag-flag) {
border: solid 2px transparent;
min-width: 50px;
}
table.tags-table td:not(.tag-flag) {
border: solid 2px transparent;
min-width: 150px;
}
table.editable.tags-table th:not(.tag-flag) {
border: solid 2px black;
min-width: 50px;
}
table.editable.tags-table td:not(.tag-flag) {
border: solid 2px black;
min-width: 150px;
}
table:not(.editable).tags-table tr.add-tag-row {
display: none;
min-width: 150px;
}
table.editable.tags-table tr.add-tag-row th {
text-align: center;
cursor: pointer;
min-width: 294px;
resize: both !important;
}
table.tags-table textarea {
min-width: 280px;
}
.mode-btn:not(.visible) {
display: none;
}
.map-img-preview-popup {
width: initial;
}
.zebra_colors tr:nth-child(even) td, .zebra_colors tr:nth-child(even) th {
background-color: color-mix(in srgb, var(--bs-body-bg), black 10%);
}
@media ${mediaQueryForWebsiteTheme} {
.mode-btn.visible img {
filter: invert(0.9);
}
.zebra_colors tr:nth-child(even) td, .zebra_colors tr:nth-child(even) th {
background-color: color-mix(in srgb, var(--bs-body-bg), white 7%);
}
}
.leaflet-popup-content:has(.geotagged-img) {
max-width: calc(${mapWidth} / 2) !important;
min-width: calc(${mapWidth} / 2) !important;
max-height: calc(${mapHeight} / 2);
min-height: calc(${mapHeight} / 2);
width: auto;
height: auto;
overflow-y: scroll;
}
`
);
}
const rawEditIcon = "https://raw.githubusercontent.com/openstreetmap/iD/671e9f00699c3b2602b82b291c5cd776445032aa/svg/fontawesome/fas-i-cursor.svg";
const tableEditIcon = "https://raw.githubusercontent.com/openstreetmap/iD/671e9f00699c3b2602b82b291c5cd776445032aa/svg/fontawesome/fas-th-list.svg";
// const lastVersionsCache = {}
function renderOSMGeoJSON(xml) {
const auth = makeAuth();
GM.xmlHttpRequest({
url: "https://raw.githubusercontent.com/deevroman/better-osm-org/refs/heads/master/banned_versions.json",
responseType: "json"
}).then(async res => {
bannedVersions = await res.response
})
// preloading
GM_addElement("img", {
src: rawEditIcon,
height: "14px",
width: "14px"
})
GM_addElement("img", {
src: tableEditIcon,
height: "14px",
width: "14px"
})
/**
* @param {Object.<string, string>} tags
* @return {HTMLTableSectionElement}
*/
function makeTBody(tags) {
const tbody = document.createElement("tbody")
Object.entries(tags).forEach(([key, value]) => {
const th = document.createElement("th")
th.textContent = key
const td = document.createElement("td")
td.textContent = value
const tr = document.createElement("tr")
th.tabIndex = 0
td.tabIndex = 0
tr.appendChild(th)
tr.appendChild(td)
tbody.appendChild(tr)
})
const tr = document.createElement("tr")
tr.classList.add("add-tag-row")
const th = document.createElement("th")
th.textContent = "+"
th.colSpan = 2
th.tabIndex = 0
th.setAttribute("contenteditable", false)
tr.appendChild(th)
th.onclick = () => {
tbody.lastElementChild.before(makeRow("", "", true))
}
tbody.appendChild(tr)
return tbody
}
function makePopupHTML(feature) {
// debugger
// const cachedObjectInfo = lastVersionsCache[`${feature.type}_${feature.id}`]
// if (cachedObjectInfo && feature.properties.meta.version
// && parseInt(cachedObjectInfo.querySelector("node,way,relation").getAttribute("version")) > feature.properties.meta.version) {
// feature.properties.tags = {}
// lastVersionsCache[`${feature.type}_${feature.id}`].querySelectorAll("tag").forEach(i => {
// feature.properties.tags[i.getAttribute("k")] = i.getAttribute("v")
// })
// feature.properties.meta = {}
// Array.from(cachedObjectInfo.querySelector("node,way,relation").attributes).map(i => [i.name, i.value]).forEach(([key, value]) => {
// if (key === "visible" || key === "nodes" || key === "members" || key === "id") return
// feature.properties.meta[key] = value
// })
// }
const popupBody = document.createElement("span")
popupBody.classList.add("geojson-editor")
const objLink = document.createElement("a")
objLink.textContent = feature.properties.type + "/" + feature.properties.id
objLink.href = "/" + feature.properties.type + "/" + feature.properties.id
popupBody.appendChild(objLink)
popupBody.appendChild(document.createTextNode(", "))
const versionLink = document.createElement("a")
versionLink.classList.add("version-link")
versionLink.textContent = feature.properties.meta.version ? ("v" + feature.properties.meta.version) : ""
versionLink.href = "/" + feature.type + "/" + feature.id + "history/" + feature.properties.meta.version
popupBody.appendChild(versionLink)
const editButton = document.createElement("button")
editButton.id = feature.properties.type + "-" + feature.properties.id + "-" + feature.properties.meta.version
editButton.classList.add("edit-tags-btn")
editButton.textContent = "🖊"
popupBody.appendChild(document.createTextNode("\xA0"))
popupBody.appendChild(editButton)
const modeBtn = document.createElement("button")
modeBtn.classList.add("mode-btn")
modeBtn.title = "Switch between table and raw editor"
popupBody.appendChild(document.createTextNode("\xA0"))
popupBody.appendChild(modeBtn)
const table = document.createElement("table")
popupBody.appendChild(table)
table.style.overflow = "scroll"
table.classList.add("geojson-props-table")
table.classList.add("zebra_colors")
table.classList.add("tags-table")
const details = document.createElement("details")
details.style.color = "gray"
const summary = document.createElement("summary")
summary.textContent = "metainfo"
details.appendChild(summary)
popupBody.appendChild(details)
const metaTable = document.createElement("table")
details.appendChild(metaTable)
metaTable.style.overflow = "scroll"
metaTable.classList.add("geojson-props-table")
metaTable.classList.add("zebra_colors")
metaTable.classList.add("metainfo-table")
const metaTBody = document.createElement("tbody")
metaTable.appendChild(metaTBody)
Object.entries(feature.properties?.meta).forEach(([key, value]) => {
const th = document.createElement("th")
th.textContent = key
const td = document.createElement("td")
if (key === "id" && typeof value === "string" && (value.startsWith("node/") || value.startsWith("way/") || value.startsWith("relation/"))) {
const a = document.createElement("a")
a.textContent = value
a.href = "/" + value
td.appendChild(a)
} else {
td.textContent = value
}
const tr = document.createElement("tr")
tr.appendChild(th)
tr.appendChild(td)
metaTBody.appendChild(tr)
})
return popupBody
}
function onEachFeature(feature, layer) {
if (!feature.properties) {
return;
}
getWindow().L.DomEvent.on(layer, "click", intoPageWithFun((e) => {
const layer = getMap()._layers[e.target._leaflet_id]
if (e.originalEvent.altKey) {
layer.remove()
e.originalEvent.stopPropagation()
e.originalEvent.stopImmediatePropagation()
} else {
if (!layer.getPopup()) {
layer.bindPopup(makePopupHTML(feature).outerHTML, intoPage({minWidth: 300})).openPopup()
}
}
}))
const startEdit = intoPageWithFun(async startEditEvent => {
let lastEditMode = await GM.getValue("lastEditMode", "table")
const table = startEditEvent.target.parentElement.querySelector("table.tags-table")
const metaTable = startEditEvent.target.parentElement.querySelector("table.metainfo-table")
/** @type {Object.<string, string>}*/
let oldTags = {}
if (lastEditMode === "table") {
table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
oldTags[i.querySelector("th").textContent] = i.querySelector("td").textContent
})
} else {
oldTags = buildTags(table.querySelector("textarea").value)
}
const modeBtn = startEditEvent.target.parentElement.querySelector(".mode-btn")
modeBtn.classList.add("visible")
const tableModeBtnImg = GM_addElement("img", {
src: tableEditIcon,
height: "14px",
width: "14px"
})
tableModeBtnImg.style.marginTop = "-3px"
const rawModeBtnImg = GM_addElement("img", {
src: rawEditIcon,
height: "14px",
width: "14px"
})
rawModeBtnImg.style.marginTop = "-3px"
if (lastEditMode === "table") {
modeBtn.appendChild(rawModeBtnImg)
} else {
modeBtn.appendChild(tableModeBtnImg)
const textarea = table.querySelector("textarea")
textarea.setAttribute("disabled", "true")
textarea.value = ""
textarea.rows = 5
Object.entries(feature.properties?.tags).forEach(([k, v]) => {
textarea.value += `${k}=${v.replaceAll('\\\\', '\n')}\n`
})
textarea.value = textarea.value.trim()
table.appendChild(textarea)
}
modeBtn.onclick = async (e) => {
e.stopPropagation()
modeBtn.querySelector("img").remove()
if (lastEditMode === "table") {
modeBtn.appendChild(tableModeBtnImg)
lastEditMode = "raw"
await GM.setValue("lastEditMode", lastEditMode)
table.appendChild(makeTextareaFromTagsTable(table))
table.querySelector("tbody")?.remove()
} else {
modeBtn.appendChild(rawModeBtnImg)
lastEditMode = "table"
await GM.setValue("lastEditMode", lastEditMode)
table.appendChild(makeTBody(buildTags(table.querySelector("textarea").value)))
table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
i.querySelector("th").setAttribute("contenteditable", true)
i.querySelector("td").setAttribute("contenteditable", true)
})
table.querySelector("textarea")?.remove()
}
}
const object_type = feature.properties.type
const object_id = parseInt(feature.properties.id)
let object_version = parseInt(feature.properties.meta.version)
async function syncTags() {
const rawObjectInfo = (await (await auth.fetch(osm_server.apiBase + object_type + '/' + object_id, {
method: 'GET',
prefix: false,
})).text());
const objectInfo = (new DOMParser()).parseFromString(rawObjectInfo, "text/xml")
// lastVersionsCache[`${object_type}_${object_id}`] = objectInfo
const lastTags = {}
objectInfo.querySelectorAll("tag").forEach(i => {
lastTags[i.getAttribute("k")] = i.getAttribute("v")
})
const new_object_version = parseInt(objectInfo.querySelector("[version]:not(osm)").getAttribute("version"))
if (JSON.stringify(lastTags) !== JSON.stringify(oldTags) || object_version && object_version + 1 !== new_object_version) {
console.log("applying new tags")
if (lastEditMode === "table") {
table.querySelector("tbody").remove()
table.appendChild(makeTBody(lastTags))
} else {
table.querySelector("textarea")?.remove()
const textarea = document.createElement("textarea")
textarea.value = ""
textarea.rows = 5
Object.entries(lastTags).forEach(([k, v]) => {
textarea.value += `${k}=${v.replaceAll('\\\\', '\n')}\n`
})
textarea.value = textarea.value.trim()
table.appendChild(textarea)
}
}
object_version = new_object_version
startEditEvent.target.parentElement.querySelector(".version-link").textContent = (object_version ? "v" + object_version : "")
startEditEvent.target.parentElement.querySelector(".version-link").href = `/${object_type}/${object_id}/history/${object_version}`
metaTable.querySelector("tbody")?.remove()
const metaTBody = document.createElement("tbody")
metaTable.appendChild(metaTBody)
Array.from(objectInfo.querySelector("node,way,relation").attributes).map(i => [i.name, i.value]).forEach(([key, value]) => {
if (key === "visible" || key === "nodes" || key === "members" || key === "id") return
const th = document.createElement("th")
th.textContent = key
const td = document.createElement("td")
td.textContent = value
const tr = document.createElement("tr")
tr.appendChild(th)
tr.appendChild(td)
metaTBody.appendChild(tr)
})
}
await syncTags()
table.classList.add("editable")
table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
i.querySelector("th").setAttribute("contenteditable", true)
i.querySelector("td").setAttribute("contenteditable", true)
})
table.querySelector("textarea")?.removeAttribute("disabled")
table.addEventListener("input", () => {
startEditEvent.target.removeAttribute("disabled")
})
startEditEvent.target.textContent = "📤"
startEditEvent.target.setAttribute("disabled", true)
startEditEvent.target.addEventListener("click", async function upload() {
startEditEvent.target.style.cursor = "progress"
/** @type {Object.<string, string>}*/
let newTags = {}
const lastEditMode = await GM.getValue("lastEditMode", "table")
if (lastEditMode === "table") {
table.querySelectorAll("tr:not(.add-tag-row)").forEach(i => {
const key = i.querySelector("th").textContent.trim()
const value = i.querySelector("td").textContent.trim()
if (key === "" || value === "") { // todo notify about error
return;
}
newTags[key] = value
})
} else {
newTags = buildTags(table.querySelector("textarea").value)
}
console.log("Opening changeset");
const rawObjectInfo = (await (await auth.fetch(osm_server.apiBase + object_type + '/' + object_id, {
method: 'GET',
prefix: false,
})).text());
const objectInfo = (new DOMParser()).parseFromString(rawObjectInfo, "text/xml")
const lastVersion = parseInt(objectInfo.querySelector("[version]:not(osm)").getAttribute("version"))
if (lastVersion !== object_version) {
startEditEvent.target.textContent = "🔄"
alert("Conflict")
throw ""
}
const objectXML = objectInfo.querySelector("node,way,relation")
const prevTags = {}
objectXML.querySelectorAll("tag").forEach(i => {
prevTags[i.getAttribute("k")] = i.getAttribute("v")
i.remove()
})
Object.entries(newTags).forEach(([k, v]) => {
const tag = objectInfo.createElement("tag")
tag.setAttribute("k", k)
tag.setAttribute("v", v)
objectXML.appendChild(tag)
})
function makeComment(object_type, object_id, prevTags, newTags) {
const removedKeys = Object.entries(prevTags).map(([k,]) => k).filter(k => newTags[k] === undefined)
const addedKeys = Object.entries(newTags).map(([k,]) => k).filter(k => prevTags[k] === undefined)
const modifiedKeys = Object.entries(prevTags).filter(([k, v]) => newTags[k] !== undefined && newTags[k] !== v).map(([k,]) => k)
let tagsHint = ""
if (addedKeys.length) {
tagsHint += "Add " + addedKeys.map(k => `${k}=${newTags[k]}`).join(", ") + "; "
}
if (modifiedKeys.length) {
tagsHint += "Changed " + modifiedKeys.map(k => `${k}=${prevTags[k]}\u200b→\u200b${newTags[k]}`).join(", ") + "; "
}
if (removedKeys.length) {
tagsHint += "Removed " + removedKeys.map(k => `${k}=${prevTags[k]}`).join(", ") + "; "
}
if (tagsHint.length > 200 || modifiedKeys.length > 1) {
tagsHint = ""
if (addedKeys.length) {
tagsHint += "Add " + addedKeys.join(", ") + "; "
}
if (modifiedKeys.length) {
tagsHint += "Changed " + modifiedKeys.join(", ") + "; "
}
if (removedKeys.length) {
tagsHint += "Removed " + removedKeys.join(", ") + "; "
}
}
tagsHint = tagsHint.match(/(.*); /)[1]
let mainTagsHint = ""
for (const i of Object.entries(prevTags)) {
if (mainTags.includes(i[0]) && !removedKeys.includes(i[0]) && !modifiedKeys.includes(i[0])) {
mainTagsHint += ` ${i[0]}=${i[1]}`;
break
}
}
for (const i of Object.entries(prevTags)) {
if (i[0] === "name" && !removedKeys.includes("name") && !modifiedKeys.includes("name")) {
mainTagsHint += ` ${i[0]}=${i[1]}`;
break
}
}
if (mainTagsHint !== "") {
if (removedKeys.length) {
tagsHint += " from" + mainTagsHint
} else if (modifiedKeys.length) {
tagsHint += " of" + mainTagsHint
} else if (addedKeys.length) {
tagsHint += " to" + mainTagsHint
}
} else {
tagsHint += ` for ${object_type} ${object_id}`
}
return tagsHint !== "" ? tagsHint.slice(0, 255) : `Update tags of ${object_type} ${object_id}`
}
const changesetTags = {
'created_by': `better osm.org v${GM_info.script.version}`,
'comment': makeComment(object_type, object_id, prevTags, newTags)
};
let changesetPayload = document.implementation.createDocument(null, 'osm');
let cs = changesetPayload.createElement('changeset');
changesetPayload.documentElement.appendChild(cs);
tagsToXml(changesetPayload, cs, changesetTags);
const chPayloadStr = new XMLSerializer().serializeToString(changesetPayload);
const changesetId = await auth.fetch(osm_server.apiBase + 'changeset/create', {
method: 'PUT',
prefix: false,
body: chPayloadStr
}).then((res) => {
if (res.ok) return res.text();
throw new Error(res);
});
console.log(changesetId);
try {
objectInfo.children[0].children[0].setAttribute('changeset', changesetId);
const objectInfoStr = new XMLSerializer().serializeToString(objectInfo).replace(/xmlns="[^"]+"/, '')
console.log(objectInfoStr);
await auth.fetch(osm_server.apiBase + object_type + '/' + object_id, {
method: 'PUT',
prefix: false,
body: objectInfoStr
}).then(async (res) => {
const text = await res.text()
if (res.ok) return text;
alert(text)
throw new Error(text);
});
} finally {
startEditEvent.target.style.cursor = ""
await auth.fetch(osm_server.apiBase + 'changeset/' + changesetId + '/close', {
method: 'PUT',
prefix: false
});
}
startEditEvent.target.textContent = "#" + changesetId
startEditEvent.target.style.color = "green"
startEditEvent.target.onclick = () => {
window.open("/changeset/" + changesetId, "_blank")
}
table.addEventListener("input", () => {
startEditEvent.target.removeAttribute("disabled")
startEditEvent.target.textContent = "📤"
startEditEvent.target.onclick = null
}, {once: true})
oldTags = {}
objectInfo.querySelectorAll("tag").forEach(i => {
oldTags[i.getAttribute("k")] = i.getAttribute("v")
})
await syncTags()
console.log(objectInfo);
}, {once: true})
table.addEventListener('keydown', (e) => {
if (e.code === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const tr = document.createElement("tr")
const th = document.createElement("th")
const td = document.createElement("td")
tr.appendChild(th)
tr.appendChild(td)
tr.style.height = "1rem"
tr.tabIndex = 0
e.target.after(tr)
tr.focus()
}
}, false);
})
getWindow().L.DomEvent.on(layer, "popupopen", intoPageWithFun(async (openEvent) => {
const layer = getMap()._layers[openEvent.target._leaflet_id]
const editButton = layer.getPopup().getElement().querySelector(".edit-tags-btn")
if (currentVersionBanned("overpass_tags_editor")) {
editButton.classList.add("banned-feature")
editButton.textContent = "Need update better-osm-org"
editButton.title = "Please click for update better-osm-org script.\nThe current version contains a bug that may corrupt OSM data."
editButton.addEventListener("click", intoPageWithFun(() => {
window.open(SCRIPT_UPDATE_URL, "_blank")
}), intoPage({once: true}))
} else {
editButton.addEventListener("click", startEdit, intoPage({once: true}))
if (await GM.getValue("lastEditMode", "table") === "raw") {
const textarea = document.createElement("textarea")
textarea.setAttribute("disabled", "true")
Object.entries(feature.properties?.tags).forEach(([k, v]) => {
if (k === "" && v === "") return
textarea.value += `${k}=${v.replaceAll('\\\\', '\n')}\n`
})
textarea.value = textarea.value.trim()
textarea.rows = 5
layer.getPopup().getElement().querySelector(".tags-table").appendChild(textarea)
} else {
layer.getPopup().getElement().querySelector(".tags-table").appendChild(makeTBody(feature.properties?.tags))
}
}
}, intoPage({once: true})))
}
jsonLayer = getWindow().L.geoJSON(intoPage(osmtogeojson(xml, {flatProperties: false})), intoPageWithFun({
onEachFeature: intoPageWithFun(onEachFeature),
pointToLayer: intoPageWithFun(function (feature, latlng) {
return getWindow().L.circleMarker(latlng);
})
})
);
jsonLayer.addTo(getMap());
}
async function setupDragAndDropViewers() {
// GM_addElement(document.head, "link", {
// href: "https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css",
// rel:'stylesheet'
// })
// GM_addElement(document.head, "script", {
// src: "https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js",
// })
// GM_addElement(document.head, "script", {
// src: "https://unpkg.com/@maplibre/maplibre-gl-leaflet/leaflet-maplibre-gl.js",
// })
document.querySelector("#map")?.addEventListener("drop", e => {
if (location.pathname.includes("/directions") || location.pathname.includes("/note/new")) {
return;
}
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation();
e.target.style.cursor = "progress"
try {
const mapWidth = getComputedStyle(document.querySelector("#map")).width
const mapHeight = getComputedStyle(document.querySelector("#map")).height
insertOverlaysStyles();
[...e.dataTransfer.items].forEach(async (item, _) => {
if (item.kind === "file") {
const file = item.getAsFile();
if (file.type.startsWith("image/jpeg")) {
const metadata = EXIF.readFromBinaryFile(await file.arrayBuffer())
console.log(metadata)
console.log(metadata.GPSLatitude, metadata.GPSLongitude)
let lat = parseFloat(metadata.GPSLatitude[0]) + parseFloat(metadata.GPSLatitude[1]) / 60 + parseFloat(metadata.GPSLatitude[2]) / 3600;
let lon = parseFloat(metadata.GPSLongitude[0]) + parseFloat(metadata.GPSLongitude[1]) / 60 + parseFloat(metadata.GPSLongitude[2]) / 3600;
if (metadata.GPSLatitudeRef === "S") {
lat = parseFloat(lat) * -1;
}
if (metadata.GPSLongitudeRef === "W") {
lon = parseFloat(lon) * -1;
}
const marker = getWindow().L.marker(getWindow().L.latLng(lat, lon), intoPage({
maxWidth: mapWidth,
maxHeight: mapHeight,
className: "map-img-preview-popup"
}));
const img = document.createElement("img")
img.classList.add("geotagged-img")
img.setAttribute("width", "100%")
const fr = new FileReader();
fr.onload = function () {
img.src = fr.result;
marker.bindPopup(img.outerHTML);
}
fr.readAsDataURL(file);
marker.addTo(getMap());
} else if (file.type === "application/json" || file.type === "application/geo+json") {
const geojson = JSON.parse(await file.text())
renderGeoJSONwrapper(geojson)
} else if (file.type === "application/gpx+xml") {
displayGPXTrack(await file.text())
} else if (file.type === "application/vnd.openstreetmap.data+xml") {
const diffParser = new DOMParser();
const doc = diffParser.parseFromString(await file.text(), "application/xml");
renderOSMGeoJSON(doc, true)
}
}
});
} finally {
e.target.style.cursor = "grab"
}
})
document.querySelector("#map")?.addEventListener("dragover", e => {
if (!location.pathname.includes("/directions") && !location.pathname.includes("/note/new")) {
e.preventDefault()
}
})
if (location.pathname.includes("/traces")) {
document.querySelectorAll('a[href*="edit?gpx="]').forEach(i => {
const trackID = i.getAttribute("href").match(/edit\?gpx=(\d+)/)[1]
const editLink = i.parentElement.parentElement.querySelector('a:not([href*="display-gpx"])')
const url = new URL(editLink.href);
url.search += "&display-gpx=" + trackID
editLink.href = url.toString()
})
} else if (location.search.includes("&display-gpx=")) {
const trackID = location.search.match(/&display-gpx=(\d+)/)[1]
const res = await GM.xmlHttpRequest({
url: `${osm_server.url}/traces/${trackID}/data`,
responseType: "blob"
});
const contentType = res.responseHeaders.split("\r\n").find(i => i.toLowerCase().startsWith("content-type:")).split(" ")[1]
if (contentType === "application/gpx+xml") {
displayGPXTrack(await res.response.text())
} else if (contentType === "application/gzip") {
displayGPXTrack(await (await decompressBlob(res.response)).text());
}
if (trackMetadata) {
fitBounds([
[trackMetadata.min_lat, trackMetadata.min_lon],
[trackMetadata.max_lat, trackMetadata.max_lon]
])
}
}
// todo refactor
const createNoteButton = document.querySelector(".control-note.leaflet-control a")
if (createNoteButton && !createNoteButton.getAttribute("data-bs-original-title").includes(" (shift + N)")) {
createNoteButton.setAttribute("data-bs-original-title", createNoteButton.getAttribute("data-bs-original-title") + " (shift + N)")
}
}
function setupIDframe(){
if (GM_config.get("DarkModeForID")) {
injectCSSIntoOSMPage(`
@media ${mediaQueryForWebsiteTheme} {
${GM_getResourceText("DARK_THEME_FOR_ID_CSS")}
}`
)
}
GM_registerMenuCommand("Show iD OAuth token", function () {
let token = document.querySelector("#id-container")?.getAttribute("data-token")
if (!token) {
token = localStorage.getItem(`${osm_server.url}oauth2_access_token`)
if (!token) {
alert("Please switch the focus to the Iframe iD.\nJust click anywhere in the editor.")
return;
}
}
alert(token);
});
}
function setupOSMWebsite() {
if (location.pathname === "/id") {
setupIDframe()
return
}
resetSearchFormFocus();
if (location.origin === prod_server.origin) {
osm_server = prod_server;
} else if (location.origin === dev_server.origin) {
osm_server = dev_server;
} else if (location.origin === ohm_prod_server.origin) {
osm_server = ohm_prod_server
} else {
osm_server = local_server;
}
if (GM_config.get("OverpassInstance") === MAILRU_OVERPASS_INSTANCE.name) {
overpass_server = MAILRU_OVERPASS_INSTANCE
} else if (GM_config.get("OverpassInstance") === PRIVATECOFFEE_OVERPASS_INSTANCE.name) {
overpass_server = PRIVATECOFFEE_OVERPASS_INSTANCE
} else {
overpass_server = MAIN_OVERPASS_INSTANCE
}
let lastPath = "";
new MutationObserver(function fn() {
const path = location.pathname;
if (path === lastPath) return;
if (lastPath.startsWith("/changeset/") && (!path.startsWith("/changeset/") || lastPath !== path) || lastPath.includes("/history") || path === "/" && lastPath !== "/") {
try {
abortPrevControllers(ABORT_ERROR_WHEN_PAGE_CHANGED)
cleanAllObjects()
getMap().attributionControl.setPrefix("")
addSwipes();
document.querySelector("#fixed-rss-feed")?.remove()
buildingViewerIframe?.remove()
buildingViewerIframe = null
if (!path.startsWith("/changeset") && !path.startsWith("/history") &&
!path.startsWith("/node") && !path.startsWith("/way") && path !== "/relation" &&
!path.startsWith("/note")) {
showSearchForm()
}
} catch { /* empty */
}
}
lastPath = path;
for (const module of modules.filter(module => GM_config.get(module.name.slice('setup'.length)))) {
queueMicrotask(() => {
// console.log(module.name)
module(path)
});
}
for (const module of alwaysEnabledModules) {
queueMicrotask(() => {
// console.log(module.name)
module(path)
});
}
return fn
}()).observe(document, {subtree: true, childList: true});
if (location.pathname.startsWith("/dashboard") || location.pathname.startsWith("/user/")) {
setTimeout(loadFriends, 4000);
}
}
function makeCommandsMenu() {
try {
GM_registerMenuCommand("Settings", function () {
if (!inFrame()) {
GM_config.open();
}
});
if (isMobile || isDebug()) {
GM_registerMenuCommand("Check script updates", function () {
window.open(`${SCRIPT_UPDATE_URL}?bypasscache=${Math.random()}`, "_blank")
});
}
if (isDebug()) {
GM_registerMenuCommand("Check dev script updates", function () {
window.open(`${DEV_SCRIPT_UPDATE_URL}?bypasscache=${Math.random()}`, "_blank")
});
}
// New Year Easter egg
const curDate = new Date()
if (curDate.getMonth() === 11 && curDate.getDate() >= 18 || curDate.getMonth() === 0 && curDate.getDate() < 10) {
GM_registerMenuCommand("☃️", runSnowAnimation);
}
// GM_registerMenuCommand("Ask question on forum", function () {
// window.open("https://community.openstreetmap.org/t/better-osm-org-a-script-that-adds-useful-little-things-to-osm-org/121670")
// });
} catch (e) {
console.error(e)
}
}
function main() {
'use strict';
if (location.origin === "https://www.hdyc.neis-one.org" || location.origin === "https://hdyc.neis-one.org") {
simplifyHDCYIframe();
return
}
if (location.origin === "https://osmcha.org") {
setTimeout(async () => {
await GM.setValue("OSMCHA_TOKEN", localStorage.getItem("token"))
}, 1000);
return
}
makeCommandsMenu();
if (location.origin === "https://taginfo.openstreetmap.org" || location.origin === "https://taginfo.geofabrik.de") {
new MutationObserver(function fn() {
setTimeout(setupTaginfo, 0);
return fn
}()).observe(document, {subtree: true, childList: true});
return
}
if ([prod_server.origin, dev_server.origin, local_server.origin].includes(location.origin)) {
setupOSMWebsite();
}
}
setTimeout(async () => {
if (location.pathname.includes("/user/")) return
if (getWindow && !getWindow().mapIntercepted) {
console.log("map not intercepted after 900ms");
await interceptMapManually()
}
}, 900)
// garbage collection for cached infos (user info, changeset history)
setTimeout(async function () {
if (Math.random() > 0.5) return
if (!location.pathname.includes("/history") && !location.pathname.includes("/note")) return
const lastGC = await GM.getValue("last-garbage-collection-time")
if (lastGC && (new Date(lastGC)).getTime() + 1000 * 60 * 60 * 24 * 2 > Date.now()) return
await GM.setValue("last-garbage-collection-time", Date.now());
const keys = GM_listValues();
for (const i of keys) {
if (i.startsWith("userinfo-")) {
try {
const userinfo = JSON.parse(await GM.getValue(i))
if (userinfo.cacheTime && (new Date(userinfo.cacheTime)).getTime() + 1000 * 60 * 60 * 24 * 14 < Date.now()) {
await GM_deleteValue(i);
}
} catch { /* empty */
}
} else if (i.startsWith("useridinfo-")) {
try {
const userIDInfo = JSON.parse(await GM.getValue(i))
if (userIDInfo.cacheTime && (new Date(userIDInfo.cacheTime)).getTime() + 1000 * 60 * 60 * 24 * 14 < Date.now()) {
await GM_deleteValue(i);
}
} catch { /* empty */
}
}
}
console.log("Old cache cleaned")
}, 1000 * 12)