Several improvements for advanced users of openstreetmap.org
// ==UserScript== // @name Better osm.org // @name:ru Better osm.org // @version 0.9.5 // @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/57 // @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/edit // @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/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.google.com/s2/favicons?sz=64&domain=openstreetmap.org // @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://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_registerMenuCommand // @grant GM_getValue // @grant GM_setValue // @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 r###ltmaps.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 // @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="config" defaultstate="collapsed"> /*global osmAuth*/ /*global GM*/ /*global GM_info*/ /*global GM_config*/ /*global GM_addElement*/ /*global GM_getValue*/ /*global GM_setValue*/ /*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*/ const accountForceLightTheme = document.querySelector("html")?.getAttribute("data-bs-theme") === "light"; const accountForceDarkTheme = document.querySelector("html")?.getAttribute("data-bs-theme") === "dark"; function isDarkMode() { return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches && !accountForceLightTheme || accountForceDarkTheme; } 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.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 } 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 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', }, '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' }, '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' }, '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://r###ltmaps.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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { #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': { 'save': function () { GM_config.close() } } }); let onInit = config => new Promise(resolve => { let isInit = () => setTimeout(() => config.isInit ? resolve() : isInit(), 0); isInit(); }); let init = onInit(GM_config); 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" //</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"] function addRevertButton() { if (!location.pathname.includes("/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(/(\d+)/)[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">↩️</a> <a href="https://osmcha.org/changesets/${changeset_id}" target="_blank" rel="noreferrer"><img src="${GM_getResourceURL("OSMCHA_ICON", false)}" id="osmcha_link"></a>`; document.querySelector("#revert_button_class").onclick = (e) => { if (!e.shiftKey) return e.preventDefault() window.location = "http://127.0.0.1:8111/revert_changeset?id=" + changeset_id // todo open in new tab } 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) } // 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>`) } 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 === "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 " + 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 = 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 " + 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 = 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 " + 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 " + GM_getValue("OSMCHA_TOKEN"), }, responseType: "json" }) if (res.status === 404) { 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 li[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]) ~ ul li 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/") }) } function setupRevertButton() { if (!location.pathname.includes("/changeset")) return; let timerId = setInterval(() => { if (addRevertButton()) clearInterval(timerId) }, 100); setTimeout(() => { clearInterval(timerId); console.debug('stop try add revert button'); }, 3000); addRevertButton(); } function hideSearchForm() { if (location.pathname.includes("/search") || location.pathname.includes("/directions")) return; if (!document.querySelector("#sidebar .search_forms")?.hasAttribute("hidden")) { document.querySelector("#sidebar .search_forms")?.setAttribute("hidden", "true") } function showSearchForm() { document.querySelector("#sidebar .search_forms")?.removeAttribute("hidden"); cleanAllObjects() } document.querySelector("#sidebar_content .btn-close:not(.hotkeyed)")?.addEventListener("click", showSearchForm) document.querySelector("#sidebar_content .btn-close:not(.hotkeyed)")?.classList?.add("hotkeyed") document.querySelector("h1 .icon-link:not(.hotkeyed)")?.addEventListener("click", showSearchForm) 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); }) } const compactSidebarStyleText = ` .changesets p { margin-bottom: 0; font-weight: 788; font-style: italic; font-size: 14px !important; } @media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { .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; } .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: red; 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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { .fixme-tag { color: #ff5454 !important; font-weight: unset; } .note-tag { background: black !important; font-weight: unset; } } @media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { 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; } `; let styleForSidebarApplied = false async function getChangesetComments(changeset_id) { const res = await fetchJSONWithCache(osm_server.apiBase + "changeset" + "/" + changeset_id + ".json?include_discussion=true") if (res.status === 509) { await error509Handler(res) } else { return res['changeset']['comments']; } } function setupCompactChangesetsHistory() { if (!location.pathname.includes("/history") && !location.pathname.includes("/changeset")) { if (!styleForSidebarApplied && (location.pathname.includes("/node") || location.pathname.includes("/way") || location.pathname.includes("/relation"))) { styleForSidebarApplied = true GM_addElement(document.head, "style", { textContent: compactSidebarStyleText, }); } return; } if (location.pathname.includes("/changeset/")) { if (document.querySelector("#sidebar_content ul")) { document.querySelector("#sidebar_content ul").querySelectorAll("a:not(.page-link)").forEach(i => i.setAttribute("target", "_blank")); } } styleForSidebarApplied = true GM_addElement(document.head, "style", { textContent: compactSidebarStyleText, }); // увы, инвалидация в этом месте ломает зум при загрузке объекте самим сайтом // try { // getMap()?.invalidateSize() // } catch (e) { // } function handleNewChangesets() { // remove useless document.querySelectorAll("#sidebar .changesets .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(".changesets li a.changeset_id span:not(.compacted)").forEach(description => { description.classList.add("compacted") description.textContent = shortOsmOrgLinksInText(description.textContent) }) setTimeout(async () => { for (const elem of document.querySelectorAll(".changesets 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.includes("/changeset")) { sidebarObserver.observe(document.querySelector('#sidebar_content'), {childList: true, subtree: true}); } } /** * * @param {string} text * @return {Object.<string, string>} */ function buildTags(text) { const lines = text.split('\n'); let json = {}; for (let line of lines) { let eqPos = line.indexOf('='); if (eqPos <= 0 || eqPos === line.length - 1) { eqPos = line.indexOf("\t"); if (eqPos <= 0 || eqPos === line.length - 1) { continue; } } const k = line.substring(0, eqPos).trim(); const v = line.substring(eqPos + 1).trim(); if (v === '' || k === '') { 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 = "" const tags = buildTags(document.querySelector("#sidebar_content form textarea").value) 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, r###lt) { const changesetId = r###lt; 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; blurSearchField(); 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.includes("/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.includes("/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, r###lt) { const changesetId = r###lt; 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.includes("/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("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(path) { if (!path.includes("/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; function invertTilesMode(mode) { return mode === "🛰" ? "🗺️" : "🛰"; } 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 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], } } 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 r###lt = await promise; cache.set(url, r###lt); return r###lt; } catch (error) { cache.delete(url); throw error; } }; })(); async function bypassChromeCSPForImagesSrc(imgElem, url) { const res = await fetchBlobWithCache(url); if (res.status !== 200) { if (!GM_config.get("OverzoomForDataLayer")) { 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.r###lt); reader.readAsDataURL(blob); }); if (currentTilesMode === SAT_MODE) { 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(isFirefox ? i.src : i.getAttribute("real-url") ?? "") if (!xyz) return if (isFirefox) { i.src = NewSatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix; } else { bypassChromeCSPForImagesSrc(i, NewSatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix); } }) SatellitePrefix = NewSatellitePrefix if (SatellitePrefix === ESRIBetaPrefix) { getMap()?.attributionControl?.setPrefix("ESRI Beta") } else { getMap()?.attributionControl?.setPrefix("ESRI") } } const isFirefox = navigator.userAgent.includes("Firefox"); 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 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 */ } if (isFirefox) { i.src = SatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix; } else { i.setAttribute("real-url", SatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix); } if (!isFirefox) { bypassChromeCSPForImagesSrc(i, SatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix); } if (i.complete && isFirefox) { i.classList.add("no-invert"); } else { i.addEventListener("load", e => { e.target.classList.add("no-invert"); }, {once: true}) } } else { let xyz = parseESRITileURL(isFirefox ? i.src : i.getAttribute("real-url") ?? "") if (!xyz) return i.src = OSMPrefix + xyz.z + "/" + xyz.x + "/" + xyz.y + ".png"; 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 */ } if (isFirefox) { node.src = SatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix; } else { node.src = "/dev/null"; } node.setAttribute("real-url", SatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix); if (!isFirefox) { bypassChromeCSPForImagesSrc(node, SatellitePrefix + xyz.z + "/" + xyz.y + "/" + xyz.x + blankSuffix) } 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(isFirefox ? node.src : node.getAttribute("real-url")) if (!xyz) return node.src = OSMPrefix + xyz.z + "/" + xyz.x + "/" + xyz.y + ".png"; 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}); } 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 makeHistoryCompact() { // todo -> toogleAttribute if (document.querySelector(".compact-toggle-btn").textContent === "><") { document.querySelectorAll(".non-modified-tag").forEach((el) => { el.classList.replace("non-modified-tag", "hidden-non-modified-tag") }) document.querySelectorAll(".empty-version").forEach((el) => { el.classList.replace("empty-version", "hidden-empty-version") }) document.querySelectorAll(".preview-img-link img").forEach(i => { i.style.display = "none" }) document.querySelector(".compact-toggle-btn").textContent = "<>" } else { document.querySelectorAll(".hidden-non-modified-tag").forEach((el) => { el.classList.replace("hidden-non-modified-tag", "non-modified-tag") }) document.querySelectorAll(".hidden-empty-version").forEach((el) => { el.classList.replace("hidden-empty-version", "empty-version") }) document.querySelectorAll(".preview-img-link img").forEach(i => { i.style.display = "" }) document.querySelector(".compact-toggle-btn").textContent = "><" } } 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 searchR###ltBBOX = null; async function processOverpassQuery(query) { if (!query.length) return 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 = searchR###ltBBOX = 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") } })) Array.from(xml.querySelectorAll("node")).forEach(n => { const lat = parseFloat(n.getAttribute("lat")) const lon = parseFloat(n.getAttribute("lon")) 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 r###lt" } 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) } } finally { if (document.title === newTitle) { document.title = prevTitle } } } function blurSearchField() { if (document.querySelector("#query") && !document.querySelector("#query").getAttribute("blured")) { document.querySelector("#query").setAttribute("blured", "true") document.activeElement?.blur() } } // 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 = arguments[0].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=" + arguments[0].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_####_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_####_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 res = (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: res['query']['pages']["-1"]["imageinfo"][0]['url'], // crossorigin: "anonymous" }) img.style.width = "100%" a.appendChild(img) }) }) } // 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" runSnow() }, { 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") { try { new opening_hours(valueCell.textContent, null, {tag_key: key}); } catch (e) { valueCell.title = e valueCell.classList.add("fixme-tag") } } } }) const tagsTable = document.querySelector(".browse-tag-list") if (tagsTable) { tagsTable.parentElement.previousElementSibling.title = tagsTable.querySelectorAll("tr th").length + " tags" } } function addHistoryLink() { if (!location.pathname.includes("/node") && !location.pathname.includes("/way") && !location.pathname.includes("/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")) } blurSearchField(); makeTimesSwitchable(); if (GM_config.get("ResizableSidebar")) { document.querySelector("#sidebar").style.resize = "horizontal" } makeLinksInTagsClickable() makeHashtagsClickable() shortOsmOrgLinks(document.querySelector(".browse-section p")) setTimeout(() => { GM_addElement(document.head, "style", { textContent: ` 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 }) } 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 }; layers[layerName].push(getWindow().L.circleMarker(getWindow().L.latLng(a, b), intoPage(haloStyle)).addTo(getMap())); 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") }, getWindow(), {cloneFunctions: true})) } } /** * @name showActiveNodeMarker * @memberof unsafeWindow * @param {string} lat * @param {string} lon * @param {string} color * @param {boolean=true} removeActiveObjects */ function showActiveNodeMarker(lat, lon, color, removeActiveObjects = true) { const haloStyle = { weight: 2.5, radius: 5, 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> let abortDownloadingController = new AbortController(); /** * @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 } /** * * @type {Object.<number, Promise<{ * data: XMLDocument, * nodesWithParentWays: Set<number>, * nodesWithOldParentWays: Object * }>>} */ const changesetsCache = {} const fetchWithCache = ((init) => { const cache = new Map(); return async url => { if (cache.has(url)) { return cache.get(url); } const promise = fetch(url, init).then((res) => { if (res.status === 509) { return error509Handler(res) } return res.text() }); cache.set(url, promise); try { const r###lt = await promise; cache.set(url, r###lt); return r###lt; } catch (error) { cache.delete(url); throw error; } }; })(); /** * @param {string|number} id */ async function getChangeset(id) { if (changesetsCache[id]) { return changesetsCache[id]; } const text = await fetchWithCache(osm_server.apiBase + "changeset" + "/" + id + "/download", {signal: abortDownloadingController.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: abortDownloadingController.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: abortDownloadingController.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: abortDownloadingController.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: abortDownloadingController.signal}); 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: abortDownloadingController.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 { 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]) ~ ul li 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.classList?.contains("hidden-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):not(.hidden-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]) ~ ul li 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) { await 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, loadMore = true, 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", "hidden-non-modified-tag", "hidden-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 () => { 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']}` }) } 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() }); }) } }) }) } // 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() { 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.textContent = "><" compactToggle.classList.add("compact-toggle-btn") compactToggle.onclick = makeHistoryCompact let sidebar = document.querySelector("#sidebar_content h2") if (!sidebar) { return } sidebar.appendChild(compactToggle) } const styleText = ` .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); } @media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { .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); } } .non-modified-tag .empty-version { } .hidden-non-modified-tag, .hidden-empty-version { display: none; } .hidden-version, .hidden-h4 { display: none; } #sidebar_content h2:not(.changeset-header){ font-size: 1rem; } h4 { font-size: 1rem; } .copied { background-color: red !important; transition:all 0.3s; } .was-copied { background-color: unset !important; transition:all 0.3s; } @media (max-device-width: 640px) and ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { 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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { [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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { [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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { 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); } } ` : ``); GM_addElement(document.head, "style", { textContent: 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.includes("/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.includes("/way")) { if (!ver.querySelector("details")) { time.before(document.createTextNode("🗑 ")) } } else if (location.pathname.includes("/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) }) 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") 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 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]) { { const colored = document.createElement("span") if (isDarkMode()) { colored.style.background = "rgba(25, 223, 25, 0.9)" } else { colored.style.background = "rgba(25, 223, 25, 0.6)" } colored.textContent = c[1] newText.appendChild(colored) } { const colored = document.createElement("span") if (isDarkMode()) { colored.style.background = "rgba(253, 83, 83, 0.8)" } else { colored.style.background = "rgba(255, 144, 144, 0.6)" } colored.textContent = c[0] prevText.appendChild(colored) } } else { prevText.appendChild(document.createTextNode(c[0])) newText.appendChild(document.createTextNode(c[1])) } }) prevValueSpan.appendChild(prevText) prevValueSpan.appendChild(document.createTextNode(" → ")) newText.classList.add("current-value-span") newText.style.display = "inline-block" currentValueSpan.replaceWith(newText) } 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(" → ")) } else { prevValueSpan.textContent = `${el[1]} → ` } } currentValueSpan.setAttribute("value", v) currentValueSpan.classList.add("current-value-span") currentValueSpan.style.display = "inline-block" prevValueSpan.style.display = "inline-block" valCell.prepend(prevValueSpan) i.title = `Click for hide previous value`; // i.title = `was: "${el[1]}"`; wasModifiedObject = tagWasModified = true } }) } if (!tagWasModified) { 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.includes("/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.includes("/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) } } makeHistoryCompact(); makeHashtagsClickable(); shortOsmOrgLinks(document.querySelector(".browse-section p")); addCommentsCount(); setupNodeVersionView(); setupWayVersionView(); setupRelationVersionView(); } function setupVersionsDiff(path) { if (!path.includes("/history") && !path.includes("/node") && !path.includes("/way") && !path.includes("/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(); } function makeVersionPageBetter() { const match = location.pathname.match(/(node|way|relation)\/(\d+)(\/history\/(\d+)\/?$|\/?$)/) if (!match) { return } if (!styleForSidebarApplied) { styleForSidebarApplied = true GM_addElement(document.head, "style", { textContent: compactSidebarStyleText, }); } 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 */ } } addHistoryLink() makeLinksInTagsClickable() makeHashtagsClickable(); makeTimesSwitchable() shortOsmOrgLinks(document.querySelector(".browse-section p")); addCommentsCount(); } 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 injectingStarted = 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; // #### Tampermonkey // for some ####ing 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.includes("/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/")) { abortDownloadingController.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/")) { abortDownloadingController.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, "'"); } async function error509Handler(res) { rateLimitBan = true console.error("oops, DOS block") 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: abortDownloadingController.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; if (GM_info.scriptHandler !== "FireMonkey") { yml = (await GM.xmlHttpRequest({ url: `https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/refs/heads/master/config/browse_icons.yml`, })).responseText } else { yml = await (await GM.fetch(`https://raw.githubusercontent.com/openstreetmap/openstreetmap-website/refs/heads/master/config/browse_icons.yml`)).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, json] = line.match(/(:\*|\w+): (\{.*})/) iconsList[lines[0].slice(0, -1) + "=" + value] = JSON.parse(json.replaceAll(/(\w+):/g, '"$1":')) }) }) GM_setValue("poi-icons", JSON.stringify({icons: iconsList, cacheTime: new Date()})) return iconsList } async function initPOIIcons() { const cache = 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 r###lt = 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 + "=:*"] && !r###lt) { r###lt = [makeIconURL(iconsList[key + "=:*"]["filename"]), iconsList[key + "=:*"]["invert"]] } } else { r###lt = [makeIconURL(iconsList[key + "=" + value]["filename"]), iconsList[key + "=" + value]["invert"]] } }) return r###lt ?? [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") { 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; // 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 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 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") 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") diff.forEach(c => { if (c[0] !== c[1]) { { const colored = document.createElement("span") if (isDarkMode()) { colored.style.background = "rgba(25, 223, 25, 0.9)" } else { colored.style.background = "rgba(25, 223, 25, 0.6)" } colored.textContent = c[1] newText.appendChild(colored) } { const colored = document.createElement("span") if (isDarkMode()) { colored.style.background = "rgba(253, 83, 83, 0.8)" } else { colored.style.background = "rgba(255, 144, 144, 0.6)" } colored.textContent = c[0] prevText.appendChild(colored) } } else { prevText.appendChild(document.createTextNode(c[0])) newText.appendChild(document.createTextNode(c[1])) } }) valCell.textContent = "" valCell.appendChild(prevText) valCell.appendChild(document.createTextNode(" → ")) valCell.appendChild(newText) } else { valCell.textContent = prevVersion.tags[key] + " → " + valCell.textContent } 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) } 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 * @param {NodeVersion|WayVersion|RelationVersion} lastVersion */ async function processObjectInteractions(changesetID, objType, objectsInComments, i, prevVersion, targetVersion, lastVersion) { 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: abortDownloadingController.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, abortDownloadingController) // 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, abortDownloadingController) // 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 = { nodes: Array.from(document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ ul li div a[href*="node/"]`)), ways: Array.from(document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ ul li div a[href*="way/"]`)), relations: Array.from(document.querySelectorAll(`.browse-section > div:has([name=subscribe],[name=unsubscribe]) ~ ul li 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: abortDownloadingController.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") }) } if (!changesetsCache[changesetID]) { 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: abortDownloadingController.signal}); if (res.status === 509) { await 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] } function addQuickLookStyles() { 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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { .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); } @media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { 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; } } .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) ' unintersting 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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { #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); } } ` : ""); GM_addElement(document.head, "style", { textContent: styleText }); } catch { /* empty */ } } function removeEditTagsButton() { if (location.pathname.includes("/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`) const ways = (await getChangeset(changesetID)).data.querySelectorAll("way") Array.from(ways).slice(0, 5).forEach(way => { getWayHistory(way.id) }) 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: abortDownloadingController.signal}); if (rawRes.status === 509) { await 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) { 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 needHideNodes = location.search.includes("changesets=") 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: abortDownloadingController.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 Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-non-modified`)).forEach(i => { document.querySelector(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li`).parentElement.appendChild(i) }) Array.from(document.querySelectorAll(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li.tags-non-modified:not(.location-modified)`)).forEach(i => { document.querySelector(`[changeset-id="${changesetID}"]#changeset_${objType}s .list-unstyled li`).parentElement.appendChild(i) }) if (needHideNodes) { 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 document.querySelectorAll(".non-modified-tag-in-quick-view").forEach(i => { if (e.target.textContent === "><") { i.removeAttribute("hidden") } else { i.setAttribute("hidden", "true") } }); 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 (needHideNodes && compactToggle.style.display !== "none") { document.querySelectorAll("[changeset-id]").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); 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 > 2500 || other > 10 || /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) { return; } } pagination.remove(); const summaryHeader = document.querySelector(`[changeset-id="${changesetID}"]#changeset_nodes h4`).firstChild; summaryHeader.textContent = summaryHeader.textContent.replace(/\(.*\)/, `(1-${nodes.length})`) 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 && /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)) { return; } if (ways.length > 1520) { return } } pagination.remove(); const summaryHeader = document.querySelector(`[changeset-id="${changesetID}"]#changeset_ways h4`).firstChild; summaryHeader.textContent = summaryHeader.textContent.replace(/\(.*\)/, `(1-${ways.length})`) 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() { 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: abortDownloadingController.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, abortDownloadingController) // todo нужно поретраить } const res2 = await getWayNodesByTimestamp(changesetMetadatas[changesetID].closed_at, objID) if (!res2) { // если линия создана после правки return } const [targetVersion, currentNodesList] = res2 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)) } if (GM_config.get("ShowChangesetGeometry")) { console.log("%cTry find parents ways", 'background: #222; color: #bada55') await findParents() } } catch (e) { // TODO notify user if (e.name === "AbortError") { console.debug("Some requests was aborted") } else { console.error(e) 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].includes(e) && getMap().getZoom) { // eslint-disable-next-line no-debugger debugger try { const reportText = ` **Page:** ${location.origin}${location.pathname} **Error:** \`${e.toString().replace("`", "\\`")}\` **StackTrace:** \`\`\` ${e.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 e } } } finally { injectingStarted = false console.timeEnd(`QuickLook ${changesetID}`) console.log("%cSetup QuickLook finished", 'background: #222; color: #bada55') // todo mark changeset as reviewed } } const currentChangesets = []; function drawBBox(bbox) { 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({color: "#ff7800", weight: 1, fillOpacity: 0}) ); 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) } catch { /* empty */ } } 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; } } async function interceptMapManually() { 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 document.querySelector("#export-image #image_filter").click() document.querySelector("#export-image #image_filter").click() console.warn("wait for map intercepting") await sleep(200) } catch (e) { console.error(e) } } async function addChangesetQuickLook() { if (!location.pathname.includes("/changeset")) { tagsOfObjectsVisible = true return } if (document.querySelector('.quick-look')) return true; if (!document.querySelector("turbo-frame")) { console.log("changeset is empty") return } let sidebar = document.querySelector("#sidebar_content h2"); if (!sidebar) { return; } if (injectingStarted) return injectingStarted = true abortDownloadingController = new AbortController() addQuickLookStyles(); addRegionForFirstChangeset(); blurSearchField(); 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) ?? [] } await interceptMapManually() await processQuickLookInSidebar(changesetID); if (changesetIDs.length) { await processQuickLookForCombinedChangesets(changesetID, changesetIDs); } if (needPreloadChangesets) { preloadPrevNextChangesets(changesetID); } } function setupChangesetQuickLook(path) { if (!path.includes("/changeset")) return; let timerId = setInterval(addChangesetQuickLook, 100); setTimeout(() => { clearInterval(timerId); console.debug('stop try add revert button'); }, 3000); addChangesetQuickLook(); } const rapidLink = "https://mapwith.ai/rapid#background=EsriWorldImagery&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 unDimmed = false; // legacy function setupOffMapDim() { if (!GM_config.get("OffMapDim") || GM_config.get("DarkModeForMap") || unDimmed) { return; } GM_addElement(document.head, "style", { textContent: ` @media (prefers-color-scheme: dark) { .leaflet-tile-container, .mapkey-table-entry td:first-child > * { filter: none !important; } .leaflet-tile-container * { filter: none !important; } } `, }); unDimmed = true } let darkModeForMap = false; function setupDarkModeForMap() { if (!GM_config.get("DarkModeForMap") || darkModeForMap) { return; } GM_addElement(document.head, "style", { textContent: ` @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; } } `, }); darkModeForMap = true } async function setupHDYCInProfile(path) { let match = path.match(/^\/user\/([^/]+)(\/|\/notes)?$/); if (!match || path.includes("/history")) { 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)) } }) } const iframe = document.getElementById('hdyc-iframe'); window.addEventListener('message', function (event) { iframe.height = event.data.height + 'px'; }); } function simplifyHDCYIframe() { if (window.location === window.parent.location) { return } const forceLightTheme = location.hash.includes("forcelighttheme") const forceDarkTheme = location.hash.includes("forcedarktheme") GM_addElement(document.head, "style", { textContent: ` 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; } #r###lt th { background-color: rgba(24, 26, 27, 0.8); } #r###lt 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" GM_addElement(document.head, "style", { textContent: ` #hdyc-warn, #hdycLink { text-align: left !important; width: 50%; position: relative; left: 35%; right: 33%; } `, }); if (navigator.userAgent.includes("Firefox")) { 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; 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'] } 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) } GM_setValue("userinfo-" + username, JSON.stringify(userInfo)) return userInfo } async function getCachedUserInfo(username) { // TODO async better? const localUserInfo = 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(",") navigator.clipboard.writeText(ids).then(() => { console.log("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("btn", "btn-primary") copyIds.onclick = () => { const ids = Array.from(document.querySelectorAll(".mass-action-checkbox:checked")).map(i => i.value).join(",") navigator.clipboard.writeText(ids).then(() => { console.log("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("btn", "btn-primary") const viewChangesetsButton = document.createElement("button") viewChangesetsButton.textContent = "🔍" viewChangesetsButton.title = "Display on one map" viewChangesetsButton.onclick = openCombinedChangesetsMap viewChangesetsButton.classList.add("btn", "btn-primary") const changesetMore = document.querySelector("#sidebar_content div.changeset_more") if (changesetMore) { 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("div") actionBarWrapper.classList.add("mt-3", "text-center") actionBarWrapper.appendChild(copyIds) actionBarWrapper.appendChild(document.createTextNode("\xA0")) actionBarWrapper.appendChild(revertButton) actionBarWrapper.appendChild(document.createTextNode("\xA0")) actionBarWrapper.appendChild(viewChangesetsButton) changesetsList.appendChild(actionBarWrapper) } } 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 .changesets").before(makeTopActionBar()) document.querySelector("#sidebar div.changeset_more").after(document.createTextNode(" ")) makeBottomActionBar() document.querySelectorAll(".changesets 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") }) } } // example: https://osmcha.org?filters={"users":[{"label":"TrickyFoxy","value":"TrickyFoxy"}]} const username = decodeURI(location.pathname.match(/\/user\/(.*)\/history$/)[1]) const osmchaFilter = { "users": [{"label": username, "value": username}], "date__gte": [{"label": "", "value": ""}] } const osmchaLink = document.createElement("a"); osmchaLink.id = "osmcha_link" osmchaLink.title = "Open profile in OSMCha.org" osmchaLink.href = "https://osmcha.org?" + new URLSearchParams({filters: JSON.stringify(osmchaFilter)}).toString() 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 span").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 > a").href document.querySelector(".changeset_more > a").click() } function makeUsernamesFilterable(i) { if (i.classList.contains("listen-for-filters")) { return } i.classList.add("listen-for-filters") i.onclick = (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 } filterChangesets() updateMap() 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" } 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 = () => { document.querySelector("#sidebar .search_forms")?.setAttribute("hidden", "true") function makeTopFilterBar() { const filterBar = document.createElement("div") filterBar.classList.add("filter-bar") const hideBigChangesetsCheckbox = document.createElement("input") hideBigChangesetsCheckbox.checked = needHideBigChangesets = 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 = () => { needHideBigChangesets = hideBigChangesetsCheckbox.checked; filterChangesets() updateMap() 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", function (event) { if (event.key === "Enter") { event.preventDefault(); filterChangesets(); updateMap() GM_setValue("last-user-filter", filterByUsersInput.value) GM_setValue("last-comment-filter", filterByCommentInput.value) } }); filterByUsersInput.value = 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.style.width = "250px" filterByCommentInput.addEventListener("keypress", function (event) { if (event.key === "Enter") { event.preventDefault(); filterChangesets(); updateMap() GM_setValue("last-user-filter", filterByUsersInput.value) GM_setValue("last-comment-filter", filterByCommentInput.value) } }); filterByCommentInput.value = GM_getValue("last-comment-filter", "") filterBar.appendChild(filterByCommentInput) return filterBar } needPatchLoadMoreRequest = true if (massModeActive === null) { massModeActive = true document.querySelector("#sidebar .changesets").before(makeTopFilterBar()) document.querySelectorAll("ol li div > a").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 a").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(".changesets li").forEach(addChangesetCheckbox) makeBottomActionBar() } if (massModeActive && (location.pathname === "/history" || location.pathname === "/history/friends")) { document.querySelectorAll("ol li div > a").forEach(makeUsernamesFilterable) // sidebarObserverForMassActions?.disconnect() filterChangesets() // todo // sidebarObserverForMassActions.observe(document.querySelector('#sidebar'), {childList: true, subtree: true}); } document.querySelectorAll('#sidebar .col .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 > .loader") if (loader === null) { makeBottomActionBar() } else if (loader.style.display === "") { document.querySelector(".changeset_more > a").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 > a") if (!moreButton) return const infinityList = document.createElement("button") infinityList.classList.add("btn", "btn-primary") 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"); } function isDebug() { return document.querySelector("head").getAttribute("data-user") === "11528195"; } function debug_alert() { if (!isDebug()) return alert(arguments) // eslint-disable-next-line debugger } /** * @param {number|null=} changeset_id * @return {Promise<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; } // prevChangesetMetadata = changesetMetadatas[changeset_id] const res = await getChangesetMetadata(changeset_id); if (res.status === 509) { await error509Handler(res) } else if (res.status !== 200) { console.error(res) debug_alert("metadatas failed") } else { const jsonRes = await res.json(); if (jsonRes.changeset) { changesetMetadatas[changeset_id] = jsonRes.changeset return } 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 } } /** * @param {number[]} changeset_ids */ async function loadChangesetMetadatas(changeset_ids) { if (!changeset_ids.length) { return } const res = await fetch(osm_server.apiBase + "changesets.json?changesets=" + changeset_ids.join(",")); // todo split long queries if (res.status === 509) { await 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: abortDownloadingController.signal}); if (res.status === 509) { await 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 res = await fetch(osm_server.apiBase + "node" + "/" + node_id + ".json", {signal: abortDownloadingController.signal}); if (res.status === 509) { await error509Handler(res) } else if (res.status === 410) { console.warn("node was deleted"); } else { const jsonRes = await res.json(); nodeMetadata = jsonRes.elements[0] } } let wayMetadata = null async function loadWayMetadata() { const match = location.pathname.match(/way\/(\d+)/) if (!match) { return; } const way_id = parseInt(match[1]); if (wayMetadata !== null && wayMetadata.id === way_id) { return; } const res = await fetch(osm_server.apiBase + "way" + "/" + way_id + "/full.json", {signal: abortDownloadingController.signal}); if (res.status === 509) { await error509Handler(res) } else if (res.status === 410) { console.warn("way was deleted"); } else { const jsonRes = await res.json(); 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)) } } } 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 res = await fetch(osm_server.apiBase + "relation" + "/" + relation_id + "/full.json", {signal: abortDownloadingController.signal}); if (res.status === 509) { await error509Handler(res) } else if (res.status === 410) { console.warn("relation was deleted"); } else { const jsonRes = await res.json(); 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)) } } } 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)) }) GM_setValue("friends", JSON.stringify(friends)) console.debug("Friends list updated") return friends } let friendsLoadingLock = false; async function getFriends() { const friendsStr = 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") }) } 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"; 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; } 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() 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() 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() 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("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() 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() 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() 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() 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") } 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 setupNavigationViaHotkeys() { if (["/edit", "/id"].includes(location.pathname)) return updateCurrentObjectMetadata() // if (!location.pathname.includes("/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"].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 { // notes if (e.shiftKey) { if (location.pathname.includes("/node") || location.pathname.includes("/way") || location.pathname.includes("/relation")) { newNotePlaceholder = "\n \n" + location.href } document.querySelector("a:has(span.note)").click() } else { Array.from(document.querySelectorAll(".overlay-layers label"))[0].click() } } } else if (e.code === "KeyD") { if (e.altKey && isDebug()) { 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 Array.from(document.querySelectorAll(".overlay-layers label"))[2].click() } else if (e.code === "KeyS") { // satellite enableOverzoom() if (e.shiftKey) { switchESRIbeta() return } 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 (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 if (e.code === "KeyJ") { setTimeout(async () => { if (!location.pathname.includes("changeset")) 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) { 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 [, z, x, y] = new URL(document.querySelector("#editanchor").href).hash.match(/map=(\d+)\/([0-9.-]+)\/([0-9.-]+)/) 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 (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") { if (new URLSearchParams(location.search).has("changesets")) { zoomToChangesets() } else if (location.pathname.includes("/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 changesetsCache[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 changesetsCache[changesetID]).data.querySelectorAll("relation").length) { for (const way of (await changesetsCache[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 (searchR###ltBBOX) { fitBounds([ [searchR###ltBBOX.min_lat, searchR###ltBBOX.min_lon], [searchR###ltBBOX.max_lat, searchR###ltBBOX.max_lon] ]) } } 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 { document.querySelector("#osmcha_link")?.click() } } else if (e.code === "Escape") { cleanObjectsByKey("activeObjects") } else if (e.code === "KeyL") { if (e.shiftKey) { document.getElementsByClassName("geolocate")[0]?.click() } } 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) { document.querySelector("#sidebar .btn-close")?.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 && !e.metaKey && !e.ctrlKey) { if (location.pathname.includes("/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") { } } showActiveWay(nodesList, "#0022ff", false, null, true, 2) }) } } } else if ((e.code === "Slash" || e.code === "Backslash" || e.code === "NumpadDivide" || e.key === "/") && e.shiftKey) { 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`, GM_getValue("lastOverpassQuery", "")) if (query) { insertOverlaysStyles() processOverpassQuery(query) } } else { // console.log(e.key, e.code) } if (location.pathname.includes("/changeset") && !location.pathname.includes("/changeset_comments")) { if (e.code === "Comma") { const link = getPrevChangesetLink() if (link) { abortDownloadingController.abort(ABORT_ERROR_PREV) needPreloadChangesets = true link.focus() link.click() } } else if (e.code === "Period") { const link = getNextChangesetLink() if (link) { abortDownloadingController.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) { abortDownloadingController.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") { 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") } } } else if (e.code === "KeyL" && !e.shiftKey) { 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") } } } } } 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 resetSearchFormFocus() { blurSearchField() // document.querySelector("#sidebar .search_form .input-group > button").setAttribute('tabIndex', "-1") } 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() } } const modules = [ setupDarkModeForMap, setupHDYCInProfile, setupCompactChangesetsHistory, setupMassChangesetsActions, setupRevertButton, setupResolveNotesButton, setupDeletor, setupHideNoteHighlight, setupSatelliteLayers, setupVersionsDiff, setupChangesetQuickLook, setupNewEditorsLinks, setupNavigationViaHotkeys, setupClickableAvatar, setupOverzoomForDataLayer, setupDragAndDropViewers ]; const alwaysEnabledModules = [ setupRelationVersionViewer, setupMakeVersionPageBetter ] const fetchJSONWithCache = (() => { const cache = new Map(); return async url => { if (cache.has(url)) { return cache.get(url); } const promise = fetch(url).then((res) => res.json()); cache.set(url, promise); try { const r###lt = await promise; cache.set(url, r###lt); return r###lt; } 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 = turboLink.href.replace(/(%22|\*)\+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 (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" && (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 GM_addElement(document.head, "style", { textContent: ` .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 ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { .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" && (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 = GM_getValue("lastEditMode", "table") const table = startEditEvent.target.parentElement.querySelector("table.tags-table") const metaTable = startEditEvent.target.parentElement.querySelector("table.metainfo-table") 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 = (e) => { e.stopPropagation() modeBtn.querySelector("img").remove() if (lastEditMode === "table") { modeBtn.appendChild(tableModeBtnImg) lastEditMode = "raw" GM_setValue("lastEditMode", lastEditMode) table.appendChild(makeTextareaFromTagsTable(table)) table.querySelector("tbody")?.remove() } else { modeBtn.appendChild(rawModeBtnImg) lastEditMode = "table" 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" let newTags = {} const lastEditMode = 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") objectXML.querySelectorAll("tag").forEach(i => i.remove()) Object.entries(newTags).forEach(([k, v]) => { const tag = objectInfo.createElement("tag") tag.setAttribute("k", k) tag.setAttribute("v", v) objectXML.appendChild(tag) }) let tagsHint = "" for (const i of Object.entries(oldTags)) { if (mainTags.includes(i[0])) { tagsHint += ` ${i[0]}=${i[1]}`; break } } for (const i of Object.entries(oldTags)) { if (i[0] === "name") { tagsHint += ` ${i[0]}=${i[1]}`; break } } const changesetTags = { 'created_by': `better osm.org v${GM_info.script.version}`, 'comment': tagsHint !== "" ? `Update tags of ${tagsHint}`.slice(0, 255) : `Update tags of ${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); 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((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 (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.r###lt; 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.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 setup() { if (location.href.startsWith("https://osmcha.org")) { setTimeout(() => { GM_setValue("OSMCHA_TOKEN", localStorage.getItem("token")) }, 1000); return } if (location.href.startsWith("https://taginfo.openstreetmap.org") || location.href.startsWith("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) && ["/id"].includes(location.pathname) && GM_config.get("DarkModeForID")) { GM_addElement(document.head, "style", { textContent: `@media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { ${GM_getResourceText("DARK_THEME_FOR_ID_CSS")} }` }) return } if (GM_config.get("ResetSearchFormFocus")) { resetSearchFormFocus(); } if (location.href.startsWith(prod_server.origin)) { osm_server = prod_server; } else if (location.href.startsWith(dev_server.origin)) { osm_server = dev_server; } else if (location.href.startsWith(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 + location.search === lastPath) return; if (lastPath.includes("/changeset/") && (!path.includes("/changeset/") || lastPath !== path) || lastPath.includes("/history")) { try { abortDownloadingController.abort() // todo вообще-то опасненько, нет гарантии что ещё не начились новые запросы cleanAllObjects() getMap().attributionControl.setPrefix("") addSwipes(); document.querySelector("#fixed-rss-feed")?.remove() } catch { } } lastPath = path + location.search; for (const module of modules.filter(module => GM_config.get(module.name.slice('setup'.length)))) { setTimeout(module, 0, path); } for (const module of alwaysEnabledModules) { setTimeout(module, 0, path); } return fn }()).observe(document, {subtree: true, childList: true}); if (location.pathname.includes("/dashboard") || location.pathname.includes("/user/")) { setTimeout(loadFriends, 4000); } } //<editor-fold desc="config" defaultstate="collapsed"> function runSnow() { injectJSIntoPage(` // This code distributed under MIT license // Author: https://github.com/DevBubba/Bookmarklets // Code was deminified function snow(t) { function i() { this.D = function () { const t = h.atan(this.i / this.d); l.save(), l.translate(this.b, this.a), l.rotate(-t), l.scale(this.e, this.e * h.max(1, h.pow(this.j, .7) / 15)), l.drawImage(m, -v / 2, -v / 2), l.restore() } } window; const h = Math, r = h.random, a = document, o = Date.now; const e = (t => { l.clearRect(0, 0, _, f), l.fill(), requestAnimationFrame(e); const i = .001 * y.et; y.r(); const s = L.et * g; for (var n = 0; n < C.length; ++n) { const t = C[n]; t.i = h.sin(s + t.g) * t.h, t.j = h.sqrt(t.i * t.i + t.f), t.a += t.d * i, t.b += t.i * i, t.a > w && (t.a = -u), t.b > b && (t.b = -u), t.b < -u && (t.b = b), t.D() } }), s = (t => { for (var e = 0; e < p; ++e) C[e].a = r() * (f + u), C[e].b = r() * _ }), n = (t => { c.width = _ = innerWidth, c.height = f = innerHeight, w = f + u, b = _ + u, s() }); class d { constructor(t, e = !0) { this._ts = o(), this._p = !0, this._pa = o(), this.d = t, e && this.s() } get et() { return this.ip ? this._pa - this._ts : o() - this._ts } get rt() { return h.max(0, this.d - this.et) } get ip() { return this._p } get ic() { return this.et >= this.d } s() { return this._ts = o() - this.et, this._p = !1, this } r() { return this._pa = this._ts = o(), this } p() { return this._p = !0, this._pa = o(), this } st() { return this._p = !0, this } } const c = a.createElement("canvas"); H = c.style, H.position = "fixed", H.left = 0, H.top = 0, H.width = "100vw", H.height = "100vh", H.zIndex = "100000", H.pointerEvents = "none", a.body.insertBefore(c, a.body.children[0]); const l = c.getContext("2d"), p = 300, g = 5e-4, u = 20; let _ = c.width = innerWidth, f = c.height = innerHeight, w = f + u, b = _ + u; const v = 15.2, m = a.createElement("canvas"), E = m.getContext("2d"), x = E.createRadialGradient(7.6, 7.6, 0, 7.6, 7.6, 7.6); x.addColorStop(0, "hsla(255,255%,255%,1)"), x.addColorStop(1, "hsla(255,255%,255%,0)"), E.fillStyle = x, E.fillRect(0, 0, v, v); let y = new d(0, !0), C = [], L = new d(0, !0); for (var j = 0; j < p; ++j) { const t = new i; t.a = r() * (f + u), t.b = r() * _, t.c = 1 * (3 * r() + .8), t.d = .1 * h.pow(t.c, 2.5) * 50 * (2 * r() + 1), t.d = t.d < 65 ? 65 : t.d, t.e = t.c / 7.6, t.f = t.d * t.d, t.g = r() * h.PI / 1.3, t.h = 15 * t.c, t.i = 0, t.j = 0, C.push(t) } s(), EL = a.addEventListener, EL("visibilitychange", () => setTimeout(n, 100), !1), EL("resize", n, !1), e() };snow();`) } //</editor-fold> 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" function main() { // GM_config.open(); 'use strict'; if (location.origin === "https://www.hdyc.neis-one.org" || location.origin === "https://hdyc.neis-one.org") { simplifyHDCYIframe(); } else { try { GM_registerMenuCommand("Settings", function () { if (window.location !== window.parent.location) { return } GM_config.open(); }); if (/iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || 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("☃️", runSnow); } // 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) } setup(); } } var map = null var getMap = null var getWindow = null if ([prod_server.origin, dev_server.origin, local_server.origin].includes(location.origin) && !["/edit", "/id"].includes(location.pathname)) { // This must be done as early as possible in order to pull the map object into the global scope // https://github.com/deevroman/better-osm-org/issues/34 // и только в ViolentMonkey нельзя наинжектить скрипт на страницу // injectJSIntoPage(` // L.Map.addInitHook(function () { // if (this._container?.id === "map") { // window.map = this; // console.log("%cMap intercepted", 'background: #000; color: #0f0') // window.mapIntercepted = true // } // } // ) // `) if (navigator.userAgent.includes("Firefox") && GM_info.scriptHandler === "Violentmonkey") { function mapHook() { console.log("start map intercepting") window.wrappedJSObject.L.Map.addInitHook(exportFunction((function () { if (this._container?.id === "map") { window.wrappedJSObject.globalThis.map = this; window.wrappedJSObject.globalThis.mapIntercepted = true console.log("%cMap intercepted", 'background: #000; color: #0f0') } }), window.wrappedJSObject) ) } window.wrappedJSObject.mapHook = exportFunction(mapHook, window.wrappedJSObject) window.wrappedJSObject.mapHook() if (window.wrappedJSObject.map instanceof HTMLElement) { console.error("Please, reload page, if something doesn't work") } getMap = () => window.wrappedJSObject.map getWindow = () => window.wrappedJSObject } else { function mapHook() { console.log("start map intercepting") unsafeWindow.L.Map.addInitHook(exportFunction((function () { if (this._container?.id === "map") { unsafeWindow.map = this; unsafeWindow.mapIntercepted = true console.log("%cMap intercepted", 'background: #000; color: #0f0') } }), unsafeWindow) ) } unsafeWindow.mapHook = exportFunction(mapHook, unsafeWindow) unsafeWindow.mapHook() if (unsafeWindow.map instanceof HTMLElement) { console.error("Please, reload page, if something doesn't work") } getMap = () => unsafeWindow.map getWindow = () => unsafeWindow } map = getMap() } else if ([prod_server.origin, dev_server.origin, local_server.origin].includes(location.origin) && ["/edit", "/id"].includes(location.pathname) && 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" // } // }); GM_addElement(document.head, "style", { textContent: `@media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { #id-embed { background: #212529 !important; } }` }) } else { GM_addElement(document.head, "style", { textContent: `@media ${accountForceDarkTheme ? "all" : "(prefers-color-scheme: dark)"} ${accountForceLightTheme ? "and (not all)" : ""} { 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); // } } } init.then(main); setTimeout(async () => { if (!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 = GM_getValue("last-garbage-collection-time") if (lastGC && (new Date(lastGC)).getTime() + 1000 * 60 * 60 * 24 * 2 > Date.now()) return GM_setValue("last-garbage-collection-time", Date.now()); const keys = GM_listValues(); for (const i of keys) { try { const userinfo = JSON.parse(GM_getValue(i)) if (userinfo.cacheTime && (new Date(userinfo.cacheTime)).getTime() + 1000 * 60 * 60 * 24 * 14 < Date.now()) { await GM_deleteValue(i); } } catch { /* empty */ } } console.log("Old cache cleaned") }, 1000 * 12)