diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..496ee2ca
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.DS_Store
\ No newline at end of file
diff --git a/README.md b/README.md
index 9b933b68..9529741b 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,43 @@
-# OSM Latest Changes
+![Intro Pic][social-media-pic]
+# Introduction
+*OSM Latest Changes* is a web application that helps to check recent OSM changes within a certain cartographic boundary, for example your home town. It displays all changesets in a list and on a map. The elements can be selected on the map whereby a tag comparison table opens up that highlights all created, modified or deleted tags. Furthermore a "Vandalism Checker" helps to find suspicious edits.
-Shows recent changes on OpenStreetMap.
+![Picture of App][screenshot]
-Objects that have been modified (or created or deleted) during the last week (or month or day) are displayed alongside with their [changeset](https://wiki.openstreetmap.org/wiki/Changeset) meta data.
+# Visualization
+Newer edits are displayed in more saturated colors than older modifications. Deleted objects as well as the "previous state" of modified map features are displayed semi-transparently. Intermediate states of objects that have been modified more than once in the selected time period are not shown.
-Newer edits are displayed in more saturated colors than older modifications. Deleted objects as well as the "previous state" of modified map features are displayed semi-transparently. Intermediate states of objects that have been modified more than once in the selected time period are not shown. The site currently doesn't show OSM modifications of relation objects.
+# How to use
+1. Zoom to the area of interest and click the *Get Changesets* button to see the changes of the last 7 days (or 1 day, 3 days, 1 month).
+2. Analyse changesets by...
+- filtering for suspicous edits (hover over red traffic light to get infos)
+- selecting elements on the map and see tag modifications
+3. Bookmark the URL to regularly come back and monitor your area of interest.
-This is based on an earlier prototype by [@lxbarth](https://github.com/lxbarth), see http://www.openstreetmap.org/user/lxbarth/diary/19185 for background.
+## Vandalism Checker
+A simple tool that verifies the integrity of downloaded changesets. It calculates the total number of elements, tags and iD warnings added and deleted in each changeset. If the net change is below a specified threshold (currently -3), a red traffic light alert is triggered to notify users about the suspicious changeset.
-## Running
+### Examples
+| Type | Case | Sum | Result |
+|---------------|---------------------------------------------------------------------------------|--------|:-------------:|
+| Element | A user added **one** new supermarket and deleted **two** roads. | -1 | 🟢⚫️ |
+| Tag | A user added **three** tags to a restaurant but deleted **seven** of a library. | -4 | ⚫️🔴 |
+| iD Warning | A user fixed **one** iD warning but caused **five** new ones. | -4 | ⚫️🔴 |
-Just `git clone` and [boot up a quick development server](https://gist.github.com/tmcw/4989751).
+# Limitations
+- The site currently doesn't show OSM modifications of relation objects.
+- Too many or too large download requests can cause the Overpass server to deny the request.
+
+# Running
+Just `git clone` and [boot up a quick development server](https://gist.github.com/tmcw/4989751). If you use *Visual Studio Code* you can alternatively install the *Live Server* extension.
+
+# History
+## 2013
+It was first [prototyped](https://osmlab.github.io/latest-changes/) by [@lxbarth](https://github.com/lxbarth) and [@tmcw](https://github.com/tmcw) during the [Chicago Hack weekend](https://wiki.openstreetmap.org/wiki/Chicago_Hack_Weekend_April_2013) on the 26th-28th April 2013 (see [diary entry of lxbarth](http://www.openstreetmap.org/user/lxbarth/diary/19185) for background). This was conceived to be an enhancement to the OSM history tab with other notable users of the community working on coming up with a solution for a history tab that visualizes local changesets in an easy to understand way.
+## 2013-2021
+A hacked version of this prototype has been created with aims into saving bandwith and rendering time. Furthermore it allows lower zoom levels. It will show changes made in the last 24h, 3 days, 7 days or 30 days. Those enhancements were mainly implemented by [@tyrasd](https://github.com/tyrasd)
+## 2022-now
+As of July 2022 another [updated version](https://www.openstreetmap.org/user/rene78/diary/399505) has been created, that offers added functionality, i.e. a tag comparison table, a vandalism checker and a filter functionality.
+
+[social-media-pic]: img/SocialMedia-Latest-Changes.png "Intro Pic"
+[screenshot]: img/multi-devices.png "Picture of the App"
\ No newline at end of file
diff --git a/css/images/layers-2x.png b/css/images/layers-2x.png
new file mode 100644
index 00000000..200c333d
Binary files /dev/null and b/css/images/layers-2x.png differ
diff --git a/css/images/layers.png b/css/images/layers.png
new file mode 100644
index 00000000..1a72e578
Binary files /dev/null and b/css/images/layers.png differ
diff --git a/css/leaflet.css b/css/leaflet.css
new file mode 100644
index 00000000..440b6a95
--- /dev/null
+++ b/css/leaflet.css
@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+.leaflet-container {
+ overflow: hidden;
+ }
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+ background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+ image-rendering: -webkit-optimize-contrast;
+ }
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+ width: 1600px;
+ height: 1600px;
+ -webkit-transform-origin: 0 0;
+ }
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ display: block;
+ }
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+ max-width: none !important;
+ max-height: none !important;
+ }
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+ max-width: none !important;
+ max-height: none !important;
+ width: auto;
+ padding: 0;
+ }
+
+.leaflet-container img.leaflet-tile {
+ /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+ mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+ -ms-touch-action: pan-x pan-y;
+ touch-action: pan-x pan-y;
+ }
+.leaflet-container.leaflet-touch-drag {
+ -ms-touch-action: pinch-zoom;
+ /* Fallback for FF which doesn't support pinch-zoom */
+ touch-action: none;
+ touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+ -ms-touch-action: none;
+ touch-action: none;
+}
+.leaflet-container {
+ -webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+ -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+ filter: inherit;
+ visibility: hidden;
+ }
+.leaflet-tile-loaded {
+ visibility: inherit;
+ }
+.leaflet-zoom-box {
+ width: 0;
+ height: 0;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ z-index: 800;
+ }
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+ -moz-user-select: none;
+ }
+
+.leaflet-pane { z-index: 400; }
+
+.leaflet-tile-pane { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane { z-index: 500; }
+.leaflet-marker-pane { z-index: 600; }
+.leaflet-tooltip-pane { z-index: 650; }
+.leaflet-popup-pane { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg { z-index: 200; }
+
+.leaflet-vml-shape {
+ width: 1px;
+ height: 1px;
+ }
+.lvml {
+ behavior: url(#default#VML);
+ display: inline-block;
+ position: absolute;
+ }
+
+
+/* control positioning */
+
+.leaflet-control {
+ position: relative;
+ z-index: 800;
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+.leaflet-top,
+.leaflet-bottom {
+ position: absolute;
+ z-index: 1000;
+ pointer-events: none;
+ }
+.leaflet-top {
+ top: 0;
+ }
+.leaflet-right {
+ right: 0;
+ }
+.leaflet-bottom {
+ bottom: 0;
+ }
+.leaflet-left {
+ left: 0;
+ }
+.leaflet-control {
+ float: left;
+ clear: both;
+ }
+.leaflet-right .leaflet-control {
+ float: right;
+ }
+.leaflet-top .leaflet-control {
+ margin-top: 10px;
+ }
+.leaflet-bottom .leaflet-control {
+ margin-bottom: 10px;
+ }
+.leaflet-left .leaflet-control {
+ margin-left: 10px;
+ }
+.leaflet-right .leaflet-control {
+ margin-right: 10px;
+ }
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ -moz-transition: opacity 0.2s linear;
+ transition: opacity 0.2s linear;
+ }
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+ opacity: 1;
+ }
+.leaflet-zoom-animated {
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ }
+svg.leaflet-zoom-animated {
+ will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+ -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+ -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+ transition: transform 0.25s cubic-bezier(0,0,0.25,1);
+ }
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none;
+ }
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+ visibility: hidden;
+ }
+
+
+/* cursors */
+
+.leaflet-interactive {
+ cursor: pointer;
+ }
+.leaflet-grab {
+ cursor: -webkit-grab;
+ cursor: -moz-grab;
+ cursor: grab;
+ }
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+ cursor: crosshair;
+ }
+.leaflet-popup-pane,
+.leaflet-control {
+ cursor: auto;
+ }
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+ cursor: move;
+ cursor: -webkit-grabbing;
+ cursor: -moz-grabbing;
+ cursor: grabbing;
+ }
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+ pointer-events: none;
+ }
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+
+/* visual tweaks */
+
+.leaflet-container {
+ background: #ddd;
+ outline-offset: 1px;
+ }
+.leaflet-container a {
+ color: #0078A8;
+ }
+.leaflet-zoom-box {
+ border: 2px dotted #38f;
+ background: rgba(255,255,255,0.5);
+ }
+
+
+/* general typography */
+.leaflet-container {
+ font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+ font-size: 12px;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ }
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+ border-radius: 4px;
+ }
+.leaflet-bar a {
+ background-color: #fff;
+ border-bottom: 1px solid #ccc;
+ width: 26px;
+ height: 26px;
+ line-height: 26px;
+ display: block;
+ text-align: center;
+ text-decoration: none;
+ color: black;
+ }
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ display: block;
+ }
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+ background-color: #f4f4f4;
+ }
+.leaflet-bar a:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+.leaflet-bar a:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom: none;
+ }
+.leaflet-bar a.leaflet-disabled {
+ cursor: default;
+ background-color: #f4f4f4;
+ color: #bbb;
+ }
+
+.leaflet-touch .leaflet-bar a {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ }
+.leaflet-touch .leaflet-bar a:first-child {
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ }
+.leaflet-touch .leaflet-bar a:last-child {
+ border-bottom-left-radius: 2px;
+ border-bottom-right-radius: 2px;
+ }
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+ font: bold 18px 'Lucida Console', Monaco, monospace;
+ text-indent: 1px;
+ }
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
+ font-size: 22px;
+ }
+
+
+/* layers control */
+
+.leaflet-control-layers {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+ background: #fff;
+ border-radius: 5px;
+ }
+.leaflet-control-layers-toggle {
+ background-image: url(images/layers.png);
+ width: 36px;
+ height: 36px;
+ }
+.leaflet-retina .leaflet-control-layers-toggle {
+ background-image: url(images/layers-2x.png);
+ background-size: 26px 26px;
+ }
+.leaflet-touch .leaflet-control-layers-toggle {
+ width: 44px;
+ height: 44px;
+ }
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+ display: none;
+ }
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+ display: block;
+ position: relative;
+ }
+.leaflet-control-layers-expanded {
+ padding: 6px 10px 6px 6px;
+ color: #333;
+ background: #fff;
+ }
+.leaflet-control-layers-scrollbar {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ padding-right: 5px;
+ }
+.leaflet-control-layers-selector {
+ margin-top: 2px;
+ position: relative;
+ top: 1px;
+ }
+.leaflet-control-layers label {
+ display: block;
+ font-size: 13px;
+ font-size: 1.08333em;
+ }
+.leaflet-control-layers-separator {
+ height: 0;
+ border-top: 1px solid #ddd;
+ margin: 5px -10px 5px -6px;
+ }
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+ background-image: url(images/marker-icon.png);
+ }
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.8);
+ margin: 0;
+ }
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+ padding: 0 5px;
+ color: #333;
+ line-height: 1.4;
+ }
+.leaflet-control-attribution a {
+ text-decoration: none;
+ }
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+ text-decoration: underline;
+ }
+.leaflet-attribution-flag {
+ display: inline !important;
+ vertical-align: baseline !important;
+ width: 1em;
+ height: 0.6669em;
+ }
+.leaflet-left .leaflet-control-scale {
+ margin-left: 5px;
+ }
+.leaflet-bottom .leaflet-control-scale {
+ margin-bottom: 5px;
+ }
+.leaflet-control-scale-line {
+ border: 2px solid #777;
+ border-top: none;
+ line-height: 1.1;
+ padding: 2px 5px 1px;
+ white-space: nowrap;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.8);
+ text-shadow: 1px 1px #fff;
+ }
+.leaflet-control-scale-line:not(:first-child) {
+ border-top: 2px solid #777;
+ border-bottom: none;
+ margin-top: -2px;
+ }
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+ border-bottom: 2px solid #777;
+ }
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ box-shadow: none;
+ }
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ border: 2px solid rgba(0,0,0,0.2);
+ background-clip: padding-box;
+ }
+
+
+/* popup */
+
+.leaflet-popup {
+ position: absolute;
+ text-align: center;
+ margin-bottom: 20px;
+ }
+.leaflet-popup-content-wrapper {
+ padding: 1px;
+ text-align: left;
+ border-radius: 12px;
+ }
+.leaflet-popup-content {
+ margin: 13px 24px 13px 20px;
+ line-height: 1.3;
+ font-size: 13px;
+ font-size: 1.08333em;
+ min-height: 1px;
+ }
+.leaflet-popup-content p {
+ margin: 17px 0;
+ margin: 1.3em 0;
+ }
+.leaflet-popup-tip-container {
+ width: 40px;
+ height: 20px;
+ position: absolute;
+ left: 50%;
+ margin-top: -1px;
+ margin-left: -20px;
+ overflow: hidden;
+ pointer-events: none;
+ }
+.leaflet-popup-tip {
+ width: 17px;
+ height: 17px;
+ padding: 1px;
+
+ margin: -10px auto 0;
+ pointer-events: auto;
+
+ -webkit-transform: rotate(45deg);
+ -moz-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+ }
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+ background: white;
+ color: #333;
+ box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+ }
+.leaflet-container a.leaflet-popup-close-button {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: none;
+ text-align: center;
+ width: 24px;
+ height: 24px;
+ font: 16px/24px Tahoma, Verdana, sans-serif;
+ color: #757575;
+ text-decoration: none;
+ background: transparent;
+ }
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+ color: #585858;
+ }
+.leaflet-popup-scrolled {
+ overflow: auto;
+ }
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+ -ms-zoom: 1;
+ }
+.leaflet-oldie .leaflet-popup-tip {
+ width: 24px;
+ margin: 0 auto;
+
+ -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+ filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+ }
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+ border: 1px solid #999;
+ }
+
+
+/* div icon */
+
+.leaflet-div-icon {
+ background: #fff;
+ border: 1px solid #666;
+ }
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+ position: absolute;
+ padding: 6px;
+ background-color: #fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ color: #222;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ pointer-events: none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+ }
+.leaflet-tooltip.leaflet-interactive {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ position: absolute;
+ pointer-events: none;
+ border: 6px solid transparent;
+ background: transparent;
+ content: "";
+ }
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+ margin-top: 6px;
+}
+.leaflet-tooltip-top {
+ margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+ left: 50%;
+ margin-left: -6px;
+ }
+.leaflet-tooltip-top:before {
+ bottom: 0;
+ margin-bottom: -12px;
+ border-top-color: #fff;
+ }
+.leaflet-tooltip-bottom:before {
+ top: 0;
+ margin-top: -12px;
+ margin-left: -6px;
+ border-bottom-color: #fff;
+ }
+.leaflet-tooltip-left {
+ margin-left: -6px;
+}
+.leaflet-tooltip-right {
+ margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ top: 50%;
+ margin-top: -6px;
+ }
+.leaflet-tooltip-left:before {
+ right: 0;
+ margin-right: -12px;
+ border-left-color: #fff;
+ }
+.leaflet-tooltip-right:before {
+ left: 0;
+ margin-left: -12px;
+ border-right-color: #fff;
+ }
+
+/* Printing */
+
+@media print {
+ /* Prevent printers from removing background-images of controls. */
+ .leaflet-control {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+ }
\ No newline at end of file
diff --git a/css/site.css b/css/site.css
index 39c41e49..522be885 100644
--- a/css/site.css
+++ b/css/site.css
@@ -1,10 +1,33 @@
+/* #region CSS RELATED TO GENERAL PAGE LAYOUT */
+body {
+ margin: 0;
+ font: 15px/1.67 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+.container {
+ display: flex;
+}
+
+@media (max-width: 600px) {
+ .container {
+ flex-direction: column;
+ }
+}
+
a,
a:hover,
a:visited {
- text-decoration:underline;
- color:inherit;
+ /* text-decoration: underline; */
+ color: inherit;
}
+.hide {
+ display: none;
+}
+
+/* #endregion */
+
+/* #region CSS RELATED TO INFO/WARNING MODAL */
.infobox {
font-family: Arial, Helvetica, sans-serif;
text-align: center;
@@ -12,64 +35,76 @@ a:visited {
top: -100px;
left: 0;
right: 0;
- margin: 5px 30px 0px;/* top / left,right / bottom */
+ margin: 5px 30px 0px;
+ /* top / left,right / bottom */
padding: 5px 20px;
border-radius: 10px;
- background-color: #FFFFFF;
+ color: white;
cursor: Default;
- box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);
- z-index: 1;
+ box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
+ z-index: 1001;
}
.show {
animation-name: animateShowHide;
- animation-duration: 2s;
+ animation-duration: 4s;
animation-iteration-count: 2;
animation-direction: alternate;
}
@keyframes animateShowHide {
- 0% {top: -100px;}
- 30% {top: 0px;}
- 100% {top: 0px;}
+ 0% {
+ top: -100px;
+ }
+
+ 30% {
+ top: 0px;
+ }
+
+ 100% {
+ top: 0px;
+ }
}
.alarm {
background-color: red;
- color: white;
}
.success {
background-color: green;
- color: black;
+}
+
+/* #endregion */
+
+/* #region CSS RELATED TO CHANGESETS COLUMN */
+
+.background {
+ background-color: #e5e5e5;
}
.heading {
position: relative;
+ width: 293px;
+ height: auto;
+ padding: 5px 0;
+ margin: 0px auto;
}
-.heading h3 {
- text-align: center;
-}
-@media (min-width: 601px){
- .heading h3 {
- position: absolute;
- left: 65px;
- }
+.logo-latest-changes {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+ font-size: 25px;
+ gap: 2px;
+ margin: 0px 10px;
+ font-weight: bold;
}
-.heading img {
- width: 60px;
- height: 60px;
-}
-@media (max-width: 600px){
- .heading img {
- position: relative;
- margin-left: auto;
- margin-right: auto;
- display: block;
- margin-bottom: 10px;
- }
+#clock {
+ width: 30px;
+ height: 30px;
+ border: 4px solid black;
+ border-radius: 20px;
}
.heading select {
@@ -77,20 +112,523 @@ a:visited {
margin-left: auto;
margin-right: auto;
display: block;
+ height: 30px;
+ width: 95%;
+ max-width: 400px;
+ margin-bottom: 10px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 20px;
+ color: #a0a0a0;
+ padding: 4px 6px;
+ border-radius: 1px;
}
#resolution {
cursor: pointer;
}
+.circular-arrow {
+ width: 16px;
+ height: 16px;
+}
+
+#download-changesets-button {
+ display: block;
+ width: 270px;
+ padding: 10px 50px;
+ margin: 5px auto;
+ font-size: 20px;
+ color: #f3f0f0;
+ background-color: #72befb;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+#download-changesets-button:hover {
+ background-color: #008dff;
+}
+
+#download-changesets-button:disabled {
+ cursor: not-allowed;
+ opacity: .3;
+}
+
+.sidebar {
+ flex: 0 1 310px;
+ overflow: auto;
+ height: 100vh;
+}
+
+/* Make sure that sidebar is shown on small screens and hide the "toggle sidebar"-icon */
+@media (max-width: 600px) {
+ .sidebar {
+ display: block;
+ flex: 1 1 auto;
+ height: 50vh;
+ }
+
+ /* Hide toggle sidebar icon on small screens*/
+ .leaflet-control-toggle {
+ display: none;
+ }
+}
+
+#results {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+.result {
+ display: grid;
+ padding: 2px 6px;
+ grid-template-columns: 0fr 0fr 0fr auto 0fr;
+ grid-template-rows: auto auto auto;
+ /* | Loupe | Traffic light | Text bubble | User Name & Time | Arrow | */
+ /* | Loupe | Changeset Comment | Arrow | */
+ /* | Changeset Details | */
+ border-bottom: 1px solid #eee;
+ transition: background-color .2s ease-in-out;
+ -moz-transition: background-color .2s ease-in-out;
+ -webkit-transition: background-color .2s ease-in-out;
+}
+
+.result.active,
+.result:hover {
+ background-color: #f5f5f5;
+}
+
+.result.active {
+ color: #008dff !important;
+}
+
+.result .zoom {
+ grid-column: 1;
+ grid-row: 1 / span 2;
+ justify-content: center;
+
+ display: flex;
+ align-items: center;
+ height: 30px;
+ width: 30px;
+ padding: 5px;
+ border-radius: 50%;
+ margin-right: 5px;
+ margin-top: 5px;
+ background-color: #e5e5e5;
+ stroke: #777;
+ box-shadow: 0 2px 4px darkslategrey;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.result .zoom:active {
+ box-shadow: 0 0 2px darkslategray;
+ transform: translateY(2px);
+}
+
+.result .zoom:hover {
+ background-color: #eee;
+}
+
+.loupe {
+ width: 20px;
+ height: 20px;
+ margin-left: 6px;
+}
+
+.traffic-light-container {
+ grid-column: 2;
+ grid-row: 1;
+ display: flex;
+ align-items: center;
+ justify-content: left;
+
+ /* display: inline-block; */
+ margin-right: 3px;
+ cursor: help;
+}
+
+.traffic-light {
+ background-color: black;
+ width: 25px;
+ height: 16px;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0px 2px;
+ border-radius: 7px;
+}
+
+.traffic-light span {
+ width: 12px;
+ height: 12px;
+ border-radius: 100%;
+}
+
+.gray {
+ background-color: gray;
+}
+
+.green {
+ background-color: green;
+}
+
+.red {
+ background-color: red;
+}
+
+.result .text-bubble {
+ grid-column: 3;
+ grid-row: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ cursor: help;
+}
+
+.result .text-bubble .text-bubble-svg {
+ width: 16px;
+ height: 14px;
+ margin-right: 2px;
+}
+
+.container-name-date {
+ grid-column: 4;
+ grid-row: 1;
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ /* prevents flex item overflow issues */
+ margin-right: 8px;
+}
+
+/*In order to avoid that 'user name' and 'time since creation' jumps to a second line the
+user name is truncated if it takes up too much space*/
+.user-name {
+ flex: 1 1 0;
+ min-width: 0;
+ /* prevents flex item overflow issues */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.result .date {
+ flex: 0 0 auto;
+ white-space: nowrap;
+
+ padding-left: 8px;
+ text-decoration: underline dotted;
+ cursor: help;
+}
+
+.result .arrow {
+ grid-column: 5;
+ grid-row: 1 / span 2;
+ justify-content: center;
+ display: flex;
+ align-items: center;
+ height: 30px;
+ width: 30px;
+ padding: 5px;
+ border-radius: 50%;
+ margin-top: 5px;
+ background-color: #e5e5e5;
+ box-shadow: 0 2px 4px darkslategrey;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.result .arrow:active {
+ box-shadow: 0 0 2px darkslategray;
+ transform: translateY(2px);
+}
+
+.result .arrow:hover {
+ background-color: #eee;
+}
+
+.arrow-up-svg {
+ width: 24px;
+ height: 24px;
+ transition: transform 0.3s ease;
+ transform: rotate(0deg);
+ fill: #777777;
+}
+
+.arrow-up-svg.rotated {
+ transform: rotate(180deg);
+ /* Turn arrow counterclockwise when expanding */
+}
+
+.changeset-comment {
+ grid-column: 2 / span 3;
+ grid-row: 2;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ text-decoration: none;
+ font-weight: bold;
+ font-size: 18px;
+}
+
+.changeset-comment.expanded {
+ text-align: center;
+}
+
+.table-container {
+ grid-column: 1 / span 5;
+ grid-row: 3;
+ height: calc-size(max-content, size);
+ /* calc-size is only in draft status as of 2025-08. Thus limited browser compatibility. */
+ transition: height 0.3s ease;
+ overflow: hidden;
+ color: #333;
+}
+
+.table-container.hidden {
+ height: 0;
+}
+
+.changeset-table {
+ border-collapse: collapse;
+ margin: 0 auto;
+ background-color: white;
+ width: 100%;
+}
+
+/* Color the right border of the first column in black. Increase the font weight of all the text in this column.
+Exclude all table headings and the changeset discussion section from this rule*/
+.changeset-table tr:not(.table-heading):not(:has(td.changeset-discussion)) td:nth-child(1) {
+ border-right: 1px solid black;
+ font-weight: 600;
+}
+
+.changeset-table td {
+ padding: 0 3px;
+}
+
+.changeset-table .border-bottom {
+ border-bottom: 1px solid black;
+}
+
+.table-heading {
+ background-color: #e5e5e5;
+ text-align: center;
+ font-weight: 600;
+ font-size: 16px;
+}
+
+/* Sometimes the content of 'comment' can be quite long and without whitespaces
+(e.g. the url of a website). Without the CSS declaration
+below the long text would expand the table width. */
+.table-changeset-comment {
+ overflow-wrap: anywhere;
+}
+
+.imagery-list {
+ padding-left: 0px;
+ list-style-position: inside;
+ overflow-wrap: anywhere;
+ /* To avoid overflow issues with long text without whitespaces, e.g. URL's */
+}
+
+/* Sometimes the content of 'editor' can be quite long and without whitespaces
+(e.g. 'reverter_plugin/36447;JOSM/1.5 (19431 de)'). Without the CSS declaration
+below the long text would expand the table width. */
+.editor {
+ overflow-wrap: anywhere;
+}
+
+.integrity {
+ white-space: nowrap;
+}
+
+.changeset-discussion {
+ font-weight: initial;
+}
+
+.comment-container {
+ margin-top: 10px;
+}
+
+.comment-info {
+ font-weight: 600;
+}
+
+.comment-text {
+ margin-left: 7px;
+ white-space: pre-line;
+}
+
+/* Custom scroll bar
+ ------------------------------------------------------- */
+/* Designing for scroll-bar */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+/* Track */
+::-webkit-scrollbar-track {
+ background: gainsboro;
+ border-radius: 5px;
+}
+
+/* Handle */
+::-webkit-scrollbar-thumb {
+ background: black;
+ border-radius: 5px;
+}
+
+/* Handle on hover */
+::-webkit-scrollbar-thumb:hover {
+ background: #555;
+}
+
+/* Back to top button */
+.to-top {
+ cursor: pointer;
+ position: absolute;
+ background: #008dff;
+ color: white;
+ opacity: 0.8;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ left: 210px;
+ bottom: 12px;
+ transition: background 0.5s;
+}
+
+.to-top:hover {
+ color: #008dff;
+ background: white;
+}
+
+/* Position "Back to top" button differently on small screens */
+@media (max-width: 600px) {
+ .to-top {
+ left: auto;
+ bottom: calc(50% + 7px);
+ right: 65px;
+ }
+}
+
+/* #endregion */
+
+/* #region CSS RELATED TO FILTER CHANGESETS TOOLBAR */
+.filter-container {
+ padding: 5px 12px;
+}
+
+#filter-red-checkbox {
+ display: none;
+}
+
+.filter-red-checkbox-label {
+ width: 42px;
+ height: 42px;
+ background-color: #72befb;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ border-radius: 6px;
+}
+
+.filter-red-color {
+ filter: grayscale(100%);
+}
+
+.filter-red-checkbox-label:hover {
+ background-color: #008dff;
+}
+
+.search-changesets-field-container {
+ position: absolute;
+ left: 60px;
+ color: #f3f0f0;
+ display: inline-block;
+}
+
+.filter-symbol {
+ position: absolute;
+ pointer-events: none;
+ left: 8px;
+ top: 12px;
+ width: 16px;
+ height: 16px;
+}
+
+.delete-filter-input {
+ position: absolute;
+ cursor: pointer;
+ left: 197px;
+ top: 11px;
+ fill: #f3f0f0;
+}
+
+.delete-filter-input:hover {
+ fill: red;
+}
+
+/* Make sure that the whole input field remains in the darker color when hovering of the x */
+.delete-filter-input:hover~.search-changesets-field {
+ background-color: #008dff;
+}
+
+.delete-filter-input-symbol {
+ width: 16px;
+ height: 16px;
+}
+
+.search-changesets-field {
+ padding: 0 30px 0 33px;
+ background-color: #72befb;
+ color: inherit;
+ border-radius: 6px;
+ border: none;
+ font-size: 18px;
+ width: 159px;
+ height: 42px;
+ vertical-align: top;
+ /*needed to keep the height of the element unchanged (https://stackoverflow.com/a/28157789/5263954)*/
+}
+
+::placeholder {
+ color: #cad9e7;
+ opacity: 1;
+}
+
+.search-changesets-field:hover {
+ background-color: #008dff;
+}
+
+/* #endregion */
+
+/* #region CSS RELATED TO LEAFLET MAP LAYOUT */
+.map-container {
+ position: relative;
+ flex: 1 1 auto;
+ height: 100vh;
+}
+
#map {
+ background: white;
+ position: relative;
+ height: 100%;
+ width: 100%;
opacity: 1;
transition: opacity .5s ease-in-out;
-moz-transition: opacity .5s ease-in-out;
-webkit-transition: opacity .5s ease-in-out;
- flex: 1 1 auto;
- height: 100vh;
}
+
#map.faded {
opacity: .5;
}
@@ -98,145 +636,155 @@ a:visited {
#zoom-in {
position: absolute;
left: 0;
- top: 40px;
+ top: 0;
text-align: center;
font-size: 25px;
font-weight: bold;
pointer-events: none;
- opacity: .5;
+ opacity: .6;
padding-left: 44px;
z-index: 444;
width: 100%;
+ color: white;
+ background-color: red;
+ box-sizing: border-box;
}
-.hide {
- display: none;
+#loading-animation {
+ position: absolute;
+ left: calc(50% - 25px);
+ top: 40%;
+ border: 16px solid white;
+ border-radius: 50%;
+ border-top: 16px solid #2074B6;
+ width: 50px;
+ height: 50px;
+ -webkit-animation: spin 2s linear infinite;
+ /* Safari */
+ animation: spin 2s linear infinite;
+ z-index: 444;
+ pointer-events: none;
}
-.result {
- border-bottom:1px solid #eee;
+@media (max-width: 600px) {
+ #loading-animation {
+ top: 0%;
+ }
}
-.affix {
- position:fixed;
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
}
-span.load {
- cursor:pointer;
+/* Toggle sidebar icon */
+.leaflet-control-toggle-icon {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ margin: 5px;
+ background-color: #000;
+ -webkit-mask-image: url(../img/icon-sidebar-left.svg);
+ mask-image: url(../img/icon-sidebar-left.svg);
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
}
-a.l {
- padding:2px;
- text-decoration:underline;
- font-size:13px;
- margin:0 4px;
+.stylePopup .leaflet-popup-content-wrapper {
+ opacity: 0.9
}
-h2, p {
- margin:0;
- padding:0 20px;
+.tag-table-container {
+ border-collapse: collapse;
+ margin: 5px 0;
+ border-top: 1px solid #c9c8c8;
}
-.result {
- padding: 5px 0 0 0;
- transition: background-color .2s ease-in-out;
- -moz-transition: background-color .2s ease-in-out;
- -webkit-transition: background-color .2s ease-in-out;
- }
- .result.active,
- .result:hover {
- background-color: #f5f5f5;
- }
- .result.active {
- color: #00c500 !important;
- }
- .result .date {
- padding-left: 5px;
- }
+.tag-table-container tr {
+ border-bottom: 1px solid black;
+}
-.changeset {
- padding: 0px 0px 5px 20px;
- height: 35px;
- overflow: hidden;
- }
- .changeset a { text-decoration: none;}
- .changeset a.comment:first-letter { text-transform: capitalize; }
- .result.active .changeset,
- .result:hover .changeset { height: auto; min-height: 35px;}
- .changeset:first-line { font-weight: bold; font-size: 18px; }
-
-.reveal { visibility: hidden; }
- a.reveal { text-decoration: none; opacity: 0.6; }
- a.reveal:hover { text-decoration: underline; opacity: 1; }
- .result.active .reveal,
- .result:hover .reveal { visibility: visible; }
-
-h2 select {
- width: auto;
- height: auto;
- border: 0;
- background: transparent;
- color: black;
- font-weight: bold;
- font-size: 28px;
- padding: 0;
- margin: 0;
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
- vertical-align: baseline;
+.tag-table-container td {
+ padding: 0 3px;
}
-.col {
- flex: 0 1 310px;
- overflow: auto;
- height: 100vh;
+/* Color the right border of the first column in black. Increase the font weight of all the text in this column. */
+.tag-table-container td:nth-child(1) {
+ border-right: 1px solid black;
+ font-weight: 600;
}
-@media (max-width: 600px){
- .col {
- flex: 1 1 auto;
- height: 50vh;
- }
+
+/* Color the right border of the second column in a brighter shade of gray */
+.tag-table-container td:nth-child(2) {
+ border-right: 1px solid rgb(170, 170, 170);
}
-.container {
- /* margin: 0 auto;
- overflow: hidden; */
- display: flex;
- /* flex-wrap: wrap; */
- /* max-width: 100%; */
+/* Wrap long text in the 'Old' and 'New' column if there is not enough space */
+.tag-table-container td:nth-child(2),
+.tag-table-container td:nth-child(3) {
+ overflow-wrap: anywhere;
}
-@media (max-width: 600px){
- .container {
- flex-direction: column;
+.tag-table-container a {
+ text-decoration: none;
+ color: #004764;
+}
+
+.tag-table-container a:hover {
+ text-decoration: underline;
+}
+
+/* Truncate text in table on small screens */
+@media (max-width: 600px) {
+ .tag-table-container td {
+ max-width: 100px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
}
-/* Custom scroll bar
-------------------------------------------------------- */
-/* Designing for scroll-bar */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-/*Increase size of scroll bar on hover*/
-*:hover::-webkit-scrollbar {
- width: auto;
- /* height: 10px; */
-}
-
-/* Track */
-::-webkit-scrollbar-track {
- background: gainsboro;
- border-radius: 5px;
-}
-
-/* Handle */
-::-webkit-scrollbar-thumb {
- background: black;
- border-radius: 5px;
-}
-
-/* Handle on hover */
-::-webkit-scrollbar-thumb:hover {
- background: #555;
-}
\ No newline at end of file
+.create {
+ background-color: #c8ffc8;
+}
+
+.delete {
+ background-color: #ffcccc;
+}
+
+.modify {
+ background-color: #ffffae;
+}
+
+.capitalize {
+ display: inline-block;
+}
+
+.capitalize::first-letter {
+ text-transform: capitalize;
+}
+
+.metatags {
+ background-color: #e5e5e5;
+}
+
+.clock-with-circular-arrow-symbol {
+ vertical-align: top;
+ width: 16px;
+ height: 16px;
+ padding-left: 3px;
+ fill: #333;
+}
+
+.clock-with-circular-arrow-symbol:hover {
+ fill: black;
+}
+
+/* #endregion */
\ No newline at end of file
diff --git a/css/style.css b/css/style.css
deleted file mode 100644
index 3872f5ce..00000000
--- a/css/style.css
+++ /dev/null
@@ -1,440 +0,0 @@
-/* Reset ♥
- http://meyerweb.com/eric/tools/css/reset/
- v2.0 | 20110126
- License: none (public domain)
-------------------------------------------------------- */
-html, body, div, span, applet, object, iframe,
-h1, h2, h3, h4, h5, h6, p, blockquote, pre,
-a, abbr, acronym, address, big, cite, code,
-del, dfn, em, img, ins, kbd, q, s, samp,
-small, strike, strong, sub, sup, tt, var,
-b, u, i, center,
-dl, dt, dd, ol, ul, li,
-fieldset, form, label, legend,
-table, caption, tbody, tfoot, thead, tr, th, td,
-article, aside, canvas, details, embed,
-figure, figcaption, footer, header, hgroup,
-menu, nav, output, ruby, section, summary,
-time, mark, audio, video {
- margin:0;
- padding:0;
- border:0;
- font-size:100%;
- font:inherit;
- vertical-align:baseline;
- }
-/* HTML5 display-role reset for older browsers */
-article, aside, details, figcaption, figure,
-footer, header, hgroup, menu, nav, section {
- display:block;
- }
-body { line-height:1; }
-ol, ul { list-style:none; }
-blockquote, q { quotes:none; }
-blockquote:before, blockquote:after,
-q:before, q:after { content:''; content:none; }
-/* tables still need 'cellspacing="0"' in the markup */
-table { border-collapse: collapse; border-spacing:0; }
-/* remember to define focus styles. Hee Haw */
-:focus { outline:0; }
-
-/* Inline Elements & Typography
-------------------------------------------------------- */
-body,
-input,
-textarea {
- color:#333;
- font:15px/1.67 'Helvetica Neue', Helvetica, Arial, sans-serif;
- -webkit-font-smoothing:antialiased;
- }
-*, *:after, *:before {
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
- }
-
-h1,
-h2,
-h3,
-h4,
-h5,
-h6 {
- margin:0;
- font-weight:bold;
- }
-
-h1 {
- font-size:32px;
- margin-bottom:20px;
- line-height:1em;
- }
-
-h2 {
- font-size:28px;
- margin-bottom:20px;
- line-height:1.25em;
- }
-
-h3 {
- font-size:20px;
- margin-bottom:20px;
- line-height:1.5em;
- }
-
-h4, h5 {
- font-size:15px;
- margin-bottom:0;
- line-height:1.67em;
- }
-
-p {
- margin:0 0 20px;
- }
- p:last-child { margin-bottom:0;}
-
-/* Links */
-a {
- color:#77c453;
- text-decoration:none;
- }
- a:visited {
- color:#9ed485;
- }
- a:hover {
- color:#54af29;
- }
- a:active {
- color:#9ed485;
- }
-
-abbr {
- border-bottom:1px dotted #000;
- cursor:help;
- }
-
-address { font-style:italic;}
-small { font-size:11px;}
-strong { font-weight:bold;}
-em { font-style:italic;}
-
-hr {
- margin:0 0 20px;
- border:0;
- height:1px;
- background:#f8f8f8;
- }
-
-/* Block Quotes */
-blockquote,
-q {
- quotes:none;
- font-style:italic;
- padding-left:20px;
- margin:10px;
- }
-
-blockquote:before,
-blockquote:after,
-q:before,
-q:after {
- content:'';
- }
-
-/* Code Blocks & Pre */
-code,
-pre {
- padding:5px;
- font-family:Menlo, Bitstream Vera Sans Mono, Monaco, Consolas, monospace;
- font-size:12px;
- border-radius:3px;
- }
-code {
- padding:5px;
- background:#f8f8f8;
- border:1px solid #ddd;
- }
-pre {
- display:block;
- padding:10px;
- margin-bottom:10px;
- font-size:12px;
- word-break:break-all;
- word-wrap:break-word;
- white-space:pre;
- white-space:pre-wrap;
- background:#f8f8f8;
- border:1px solid #ddd;
- border-radius:3px;
- }
- pre code {
- padding:0;
- color:inherit;
- background-color:transparent;
- border:0;
- }
-.pre-scrollable {
- max-height:300px;
- overflow-y:scroll;
- }
-
-/* sub/superscripts */
-sup,
-sub {
- height:0;
- line-height:1;
- vertical-align:baseline;
- _vertical-align:bottom;
- position:relative;
- font-size:75%;
- }
-sup {
- top:.5em;
- bottom:1em;
- }
-
-label {
- display:block;
- }
-select,
-textarea,
-input[type=text] {
- display:inline-block;
- height:30px;
- width:95%;
- max-width:400px;
- margin-bottom:10px;
- font-size:13px;
- font-weight:500;
- line-height:20px;
- /* color:#a0a0a0; */
- vertical-align:middle;
- padding:4px 6px;
- -webkit-border-radius:1px;
- border-radius:1px;
- }
-textarea,
-input[type=text] {
- background-color:#fff;
- border:1px solid #ccc;
- -webkit-box-shadow:1px 1px 2px rgba(0,0,0,0.1);
- -moz-box-shadow:1px 1px 2px rgba(0,0,0,0.1);
- box-shadow:1px 1px 2px rgba(0,0,0,0.1);
- -webkit-transition:border linear .2s, box-shadow linear .2s;
- -moz-transition:border linear .2s, box-shadow linear .2s;
- -o-transition:border linear .2s, box-shadow linear .2s;
- transition:border linear .2s, box-shadow linear .2s;
- }
- textarea:focus,
- input[type=text]:focus {
- outline:thin dotted\8; /* ie8 below */
- color:#404040;
- border-color:#00395D;
- border-width:1px;
- }
-
-textarea {
- height:200px;
- max-width:none;
- }
-input[type=submit] {
- background-color:#00395D;
- cursor:pointer;
- color:#fff;
- font-weight:bold;
- text-transform:uppercase;
- border:none;
- padding:9px 20px;
- -webkit-box-shadow:2px 2px 4px rgba(0,0,0,0.1);
- -moz-box-shadow:2px 2px 4px rgba(0,0,0,0.1);
- box-shadow:2px 2px 4px rgba(0,0,0,0.1);
- }
- input[type=submit]:hover {
- background-color:#002f4c;
- }
- input[type=submit]:active {
- position:relative;
- top:1px;
- }
-
-table {
- width:100%;
- background-color:transparent;
- border-collapse:collapse;
- border-spacing:0;
- margin-bottom:20px;
- table-layout:fixed;
- }
- th,
- td {
- padding:4px 0;
- line-height:20px;
- text-align:left;
- vertical-align:top;
- border-bottom:1px solid #d5d5d5;
- }
- th {
- font-weight:bold;
- }
- thead th {
- vertical-align:bottom;
- color:#57594D;
- }
-
-/* Read content styling */
-.prose ul {
- list-style:disc;
- margin-left:40px;
- }
-.prose ol {
- list-style:decimal;
- }
-.prose p {
- margin:0 0 10px;
- }
-
-.icon {
- background:transparent url(img/sprite.png) no-repeat 0 0;
- display:block;
- width:30px;
- height:30px;
- text-indent:-999em;
- }
-
-/* Layout
-------------------------------------------------------- */
-/* .container {
- max-width:1600px;
- margin:0 auto;
- overflow:hidden;
- } */
-
-/* Columns
-------------------------------------------------------- */
-.col0 { float:left; width:04.1666%; }
-.col1 { float:left; width:08.3333%; }
-.col2 { float:left; width:16.6666%; }
-.col3 { float:left; width:25.0000%; }
-.col4 { float:left; width:33.3333%; }
-.col5 { float:left; width:41.6666%; }
-.col6 { float:left; width:50.0000%; }
-.col7 { float:left; width:58.3333%; }
-.col8 { float:left; width:66.6666%; }
-.col9 { float:left; width:75.0000%; }
-.col10 { float:left; width:83.3333%; }
-.col11 { float:left; width:91.6666%; }
-.col12 { width:100%; }
-.margin0 { margin-left:04.1666%; }
-.margin1 { margin-left:08.3333%; }
-.margin2 { margin-left:16.6666%; }
-.margin3 { margin-left:25.0000%; }
-.margin4 { margin-left:33.3333%; }
-.margin5 { margin-left:41.6666%; }
-.margin6 { margin-left:50.0000%; }
-.margin7 { margin-left:58.3333%; }
-.margin8 { margin-left:66.6666%; }
-.margin9 { margin-left:75.0000%; }
-.margin10 { margin-left:83.3333%; }
-.margin11 { margin-left:91.6666%; }
-.margin12 { margin-left:100.0000%; }
-
-/* Padding
-------------------------------------------------------- */
-.pad1 { padding:10px; }
-.pad2 { padding:5px; }
-.pad21h { padding:10px 20px; }
-.pad2h { padding:0 20px; }
-.pad4 { padding:40px; }
-.pad4h { padding-left:40px; padding-right:40px; }
-.pad8 { padding:80px 40px; }
-.pad4c { padding:40px; }
-
-/* Additional Utility Classes
-------------------------------------------------------- */
-.fr { float:right; }
-.fl { float:left; }
-.show { display:block; }
-.hide { display:none; }
-.deemphasize { color:#888; }
-.center { text-align:center; }
-
-.tip-top:after,
-.tip-right:after,
-.tip-bottom:after,
-.tip-left:after {
- content:'';
- border-width:0 5px 5px;
- border-style:solid;
- position:absolute;
- border-color:#333 transparent;
- }
- .tip-bottom:after {
- border-width:5px 5px 0;
- }
- .tip-left:after {
- border-width:5px 5px 5px 0;
- border-color:transparent #333;
- }
- .tip-right:after {
- border-width:5px 0 5px 5px;
- }
-
-/* Markup free clearing
-Details: http://www.positioniseverything.net/easyclearing.html
-------------------------------------------------------- */
-.clearfix:after {
- content:'.';
- display:block;
- height:0;
- clear:both;
- visibility:hidden;
- }
-
-.clearfix { display:inline-block; }
-
-/* Tablet Layout
-------------------------------------------------------- */
-@media only screen and (max-width:770px) {
- .hide-tablet { display:none; }
- .show-tablet { display:block; }
- }
-
-/* Mobile Layout
-------------------------------------------------------- */
-@media only screen and (max-width:640px) {
- .col1,
- .col2,
- .col3,
- .col4,
- .col5,
- .col6,
- .col7,
- .col8,
- .col9,
- .col10,
- .col11,
- .col12 { width:100%; max-width:100%; }
- .margin0,
- .margin1,
- .margin2,
- .margin3,
- .margin4,
- .margin5,
- .margin6,
- .margin7,
- .margin8,
- .margin9,
- .margin10,
- .margin11,
- .margin12 { margin-left:0; }
- .pad4c { padding:0; }
- .pad4,
- .pad4h { padding-left:20px; padding-right:20px; }
- .pad8 { padding:40px 20px; }
- .hide-mobile { display:none; }
- .show-mobile { display:block; }
- .icon {
- background-image:url(img/sprite@2x.png);
- background-size:240px 240px;
- }
- }
-
diff --git a/doc/Equirectangular_Distance_Explanation.md b/doc/Equirectangular_Distance_Explanation.md
new file mode 100644
index 00000000..c6a990df
--- /dev/null
+++ b/doc/Equirectangular_Distance_Explanation.md
@@ -0,0 +1,95 @@
+
+# Understanding the Equirectangular Approximation for Distance Calculation
+
+This JavaScript function calculates the approximate distance between two points on the Earth's surface, given their latitude and longitude. It uses the **equirectangular approximation**, which is simpler and faster than more accurate formulas like the **Haversine formula**, but still sufficiently precise for short distances.
+
+---
+
+## JS Function
+```js
+function distance(λ1, φ1, λ2, φ2) {
+ const R = 6371000;
+ const Δλ = (λ2 - λ1) * Math.PI / 180;
+ φ1 = φ1 * Math.PI / 180;
+ φ2 = φ2 * Math.PI / 180;
+ const x = Δλ * Math.cos((φ1 + φ2) / 2);
+ const y = (φ2 - φ1);
+ const d = Math.sqrt(x * x + y * y);
+ return R * d;
+};
+```
+
+## Key Concepts
+
+### 1. **The Radius of the Earth (`R`)**
+
+```js
+const R = 6371000;
+```
+- The Earth's mean radius is approximately **6371 kilometers** or **6,371,000 meters**.
+- In this function, distance is returned in meters because `R` is defined in meters.
+
+---
+
+### 2. **Converting Degrees to Radians**
+
+```js
+Δλ = (λ2 - λ1) * Math.PI / 180;
+φ1 = φ1 * Math.PI / 180;
+φ2 = φ2 * Math.PI / 180;
+```
+- Latitude (`φ`) and longitude (`λ`) are usually given in **degrees**, but trigonometric functions in JavaScript (and most programming languages) operate in **radians**.
+- To convert degrees to radians, we multiply by `π / 180`.
+
+---
+
+### 3. **Why Use the Cosine Function for Longitude Differences?**
+
+```js
+const x = Δλ * Math.cos((φ1 + φ2) / 2);
+```
+
+- When calculating the distance between two points on a sphere, the distance covered by a change in **longitude** depends on the latitude.
+- **Longitude lines** converge at the poles, so the distance between two points with the same longitude difference is smaller as you move closer to the poles.
+- To account for this, the function multiplies the difference in longitude `Δλ` by the **cosine of the average latitude** `(φ1 + φ2) / 2`.
+ - This ensures the longitudinal difference is scaled correctly according to the latitude.
+
+> **Why cosine?**
+> - At the equator, `cos(0°) = 1`, so longitude differences correspond directly to distances.
+> - Moving towards the poles, `cos(90°) = 0`, meaning longitude differences have virtually no effect on distance, as longitude lines meet at the poles.
+
+---
+
+### 4. **Latitude Differences are Simpler**
+
+```js
+const y = (φ2 - φ1);
+```
+- **Latitude lines** are **parallel**, meaning the distance between degrees of latitude remains roughly constant regardless of where you are on Earth.
+- Thus, the difference in latitude can be taken directly in radians, without any additional scaling.
+
+---
+
+### 5. **Calculating the Final Distance**
+
+```js
+const d = Math.sqrt(x * x + y * y);
+return R * d;
+```
+- The function treats the small section of the Earth's surface between the two points as a flat triangle.
+- It uses the **Pythagorean theorem** to combine the distances in the x (longitude) and y (latitude) directions.
+- Finally, multiplying by `R` gives the actual distance on the Earth's surface.
+
+---
+
+## Summary
+
+- **Latitude difference:** Direct subtraction in radians (latitude lines are parallel).
+- **Longitude difference:** Scaled by `cos(latitude)` because longitude lines converge towards the poles.
+- **Distance:** Calculated using the Pythagorean theorem and scaled by the Earth's radius.
+
+This method offers a good trade-off between simplicity and accuracy for short distances, making it suitable for many applications in geospatial analysis.
+
+## Credit
+
+This formula has been found on the website [Movable Type](http://www.movable-type.co.uk/scripts/latlong.html#equirectangular). Thanks Chris Veness!
\ No newline at end of file
diff --git a/examples/exampleDiscussions.json b/examples/exampleDiscussions.json
new file mode 100644
index 00000000..e33aecfb
--- /dev/null
+++ b/examples/exampleDiscussions.json
@@ -0,0 +1,47 @@
+{
+ "version": "0.6",
+ "generator": "openstreetmap-cgimap 2.1.0 (113219 spike-06.openstreetmap.org)",
+ "copyright": "OpenStreetMap and contributors",
+ "attribution": "http://www.openstreetmap.org/copyright",
+ "license": "http://opendatacommons.org/licenses/odbl/1-0/",
+ "changeset": {
+ "id": 122803935,
+ "created_at": "2022-06-24T13:36:38Z",
+ "open": false,
+ "comments_count": 2,
+ "changes_count": 1,
+ "closed_at": "2022-06-24T13:36:38Z",
+ "min_lat": 47.6521043,
+ "min_lon": 9.4757572,
+ "max_lat": 47.6521043,
+ "max_lon": 9.4757572,
+ "uid": 257555,
+ "user": "rene78",
+ "tags": {
+ "changesets_count": "20932",
+ "comment": "Schinacher immer noch geöffnet. Aber eher Kinderkleidungsladen als Spielwarengeschäft. Abgeändert.",
+ "created_by": "iD 2.21.1",
+ "host": "https://www.openstreetmap.org/edit",
+ "imagery_used": "Bing Maps Aerial",
+ "locale": "de"
+ },
+ "comments": [
+ {
+ "id": 948812,
+ "visible": true,
+ "date": "2022-11-24T08:08:55Z",
+ "uid": 257555,
+ "user": "rene78",
+ "text": "Kommentar zu Testwecken für https://github.com/rene78/latest-changes"
+ },
+ {
+ "id": 1512892,
+ "visible": true,
+ "date": "2025-11-16T08:36:24Z",
+ "uid": 257555,
+ "user": "rene78",
+ "text": "Noch ein Kommentar zu Testzwecken. Jetzt kommen 2 Leerzeilen:\n\nDas sollte richtig gerendert werden."
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/examples/exampleOSMAPI.xml b/examples/exampleOSMAPI.xml
new file mode 100644
index 00000000..d255d0ed
--- /dev/null
+++ b/examples/exampleOSMAPI.xml
@@ -0,0 +1,88 @@
+
+
=a.length)return n;var r=[],u=o[e++];return n.forEach(function(n,u){r.push({key:n,values:t(u,e)})}),u?r.sort(function(n,t){return u(n.key,t.key)}):r}var e,r,i={},a=[],o=[];return i.map=function(t,e){return n(e,t,0)},i.entries=function(e){return t(n(ca.map,e,0),0)},i.key=function(n){return a.push(n),i},i.sortKeys=function(n){return o[a.length-1]=n,i},i.sortValues=function(n){return e=n,i},i.rollup=function(n){return r=n,i},i},ca.set=function(n){var t=new i;if(n)for(var e=0;e =0?n.substring(0,t):n,r=t>=0?n.substring(t+1):"in";return e=Uo.get(e)||Yo,r=Io.get(r)||st,xr(r(e.apply(null,Array.prototype.slice.call(arguments,1))))},ca.interpolateHcl=Dr,ca.interpolateHsl=jr,ca.interpolateLab=Lr,ca.interpolateRound=Fr,ca.layout={},ca.layout.bundle=function(){return function(n){for(var t=[],e=-1,r=n.length;++ethis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;s
+ `;
+
+ //Object with all key-value pairs for new and old feature and relevant meta tags for table
+ const keyvalues = { old: { meta: {}, tags: {} }, new: { meta: {}, tags: {} } };
+
+ //1 CREATE
+ if (action === "create") {
+ //Copy meta tags
+ const keysNew = node[0].querySelectorAll("tag");
+
+ //Create table
+ tableHtml += `
+
+
+ `;
+
+ //Create link to edit geometry in iD editor (only if element has not been deleted - deleted elements cannot be edited)
+ if (action !== "delete") {
+ tableHtml += `Edit in iD`;
+ }
+
+ return tableHtml;
}
- rl.on('click', click);
+ // --- Start of the second async API call ---
+ // Download changeset text and changeset comment count from OSM API.
+ // Once done render changesets list on the left side
- rl.append('span')
- .classed('load', true)
- .attr('title', 'Click to highlight changeset on map')
- .html('→ ');
+ //Write changeset id's in an array. This is used to create URL for API call.
+ const changesetIds = [];
+ for (const key in changesets) {
+ changesetIds.push(key);
+ }
+ // console.log(changesetIds);
- rl.append('a').text(function (d) {
- return d.user;
- })
- .attr('target', '_blank')
- .attr('href', function (d) {
- return '//openstreetmap.org/user/' + d.user;
- });
+ const promises = []; // Array to hold promises for changeset details
+ const fetchChangesetBatch = (ids) => {
+ const url = debugMode
+ ? "./examples/exampleOSMAPI.xml" // Handle debug mode (only makes one request)
+ : 'https://api.openstreetmap.org/api/0.6/changesets?changesets=' + ids.join(',');
- rl.append('span').text(' ');
+ // Use fetch, check response, parse XML
+ return fetch(url, { signal: signal }) // Return the promise chain
+ .then(response => {
+ if (!response.ok) { // Check if the HTTP request was successful
+ throw new Error(`HTTP error fetching changeset details! Status: ${response.status} ${response.statusText || ''} URL: ${url}`);
+ }
+ return response.text(); // Get the response body as text
+ })
+ .then(text => new window.DOMParser().parseFromString(text, "text/xml")); // Parse the text as XML
+ };
+ //Load a local example file if debug mode is on. If not call the OSM API.
+ if (debugMode) {
+ promises.push(fetchChangesetBatch([])); // Pass empty array if URL is fixed
+ } else {
+ //Fetch data from OSM database. If there are more than 100 changesets to query make multiple API requests with a hundred CS each.
+ while (changesetIds.length > 0) {
+ promises.push(fetchChangesetBatch(changesetIds.splice(0, 100)));
+ }
+ }
+ // Use Promise.all to wait for all changeset detail requests
+ return Promise.all(promises); // IMPORTANT: Return this promise to chain correctly
+ // --- End of the second async operation ---
- rl.append('span')
- .attr('title', function (d) {
- return moment(d.time).format('MMM Do YYYY, h:mm:ss a');
- })
- .attr('class', 'date').text(function (d) {
- return moment(d.time).fromNow();
- });
- //Zoom link removed in order to have more space for map on small screens.
- /* rl.append('span').text(' ');
-
- rl.append('a').attr('class', 'reveal').text('«zoom»')
- .attr('target', '_blank')
- .attr('href', '#')
- .on('click', function(d) {
- d3.event.preventDefault();
- map.fitBounds(d.features.reduce(function(a, b) {
- return a.extend(b.getBounds());
- }, new L.LatLngBounds()));
- });
-
- */
- rl.append('span').text(' ');
- rl.append('a').attr('class', 'reveal').text('«achavi»')
- .attr('target', '_blank')
- .attr('title', 'Get details about this changeset on Achavi')
- .attr('href', function(d) {
- return 'https://overpass-api.de/achavi/?changeset=' + d.id;
- });
-
- rl.append('div').attr('class', 'changeset');
- var queue = d3.queue();
- var changesetIds = rl.data()
- .map(function (d) { return d.id })
- .filter(function (changesetId) { return changesetId !== ''; });
- while (changesetIds.length > 0) {
- queue.defer(d3.xml, 'https://www.openstreetmap.org/api/0.6/changesets?changesets=' + changesetIds.splice(0, 100).join(','));
+ }) // This .then() receives the result of Promise.all(promises)
+ .then(function (xmls) { // SUCCESS callback for the *second* async operation (Promise.all)
+ // Check if aborted before processing results
+ if (signal.aborted) {
+ throw new DOMException('Aborted', 'AbortError');
}
- queue.awaitAll(function (error, xmls) {
- if (error) return console.error(error);
-
- var changesets = {};
- xmls.forEach(function (xml) {
- var css = xml.getElementsByTagName('changeset');
- for (var i = 0; i < css.length; i++) {
- var cid = css[i].getAttribute('id');
- changesets[cid] = {
- discussionCount: +css[i].getAttribute("comments_count")
- };
- var tag = css[i].querySelector('tag[k="comment"]');
- if (tag)
- changesets[cid].comment = tag.getAttribute('v');
+ // console.log(xmls);
+ // 'xmls' is the array of parsed XML documents from the changeset details
+ // the forEach below is needed if there have been more than 100 changesets fetched and the length of the xmls array is > 1.
+ xmls.forEach(function (xmlDoc) {
+ // console.log(xmlDoc);
+ const css = xmlDoc.getElementsByTagName('changeset');
+ // console.log(css);
+ for (let i = 0; i < css.length; i++) {
+ const cid = css[i].getAttribute('id');
+ if (changesets[cid]) { // Check if changeset exists in our main object
+ //Write discussion count to changesets object after converting it to a number
+ changesets[cid].discussionCount = +css[i].getAttribute("comments_count");
+ //Write changeset comment to changesets object
+ const commentTag = css[i].querySelector('tag[k="comment"]');
+ if (commentTag) changesets[cid].comment = commentTag.getAttribute('v');
+
+ //Get sum of warnings and resolved iD warnings for each changeset and write the delta into changesets object
+ // console.log(css[i]);
+ const tagsWithResolvedIdWarnings = css[i].querySelectorAll('tag[k^="resolved"]');
+ const nIdResolvedWarnings = getSumOfiDWarningsAndResolvedWarnings(tagsWithResolvedIdWarnings);
+ const tagsWithIdWarnings = css[i].querySelectorAll('tag[k^="warning"]');
+ const nIdWarnings = getSumOfiDWarningsAndResolvedWarnings(tagsWithIdWarnings);
+
+ function getSumOfiDWarningsAndResolvedWarnings(tagsWithWarnings) {
+ let counter = 0;
+ tagsWithWarnings.forEach(tag => { // Use the passed parameter
+ const value = +tag.getAttribute('v');
+ // console.log(value);
+ counter += value;
+ });
+ return counter;
+ }
+ changesets[cid].deltaInIdWarningsAndResolves = nIdResolvedWarnings - nIdWarnings;
+
+ //Get name of used OSM editor
+ const osmEditorTag = css[i].querySelector('tag[k="created_by"]');
+ if (osmEditorTag)
+ changesets[cid].osmEditor = osmEditorTag.getAttribute('v');
+
+ //Write the names of the used satellite imagery providers into changeset object
+ const imageryUsedTag = css[i].querySelector('tag[k="imagery_used"]');
+ const sourceTag = css[i].querySelector('tag[k="source"]');
+
+ const imageryUsedValue = imageryUsedTag?.getAttribute('v') || "";
+ const sourceValue = sourceTag?.getAttribute('v') || "";
+
+ // Combine both strings with a semicolon (only if both are non-empty)
+ const combined = [imageryUsedValue, sourceValue]
+ .filter(s => s.trim() !== "") // remove empty strings
+ .join(';');
+
+ const imageryUsedArray = combined
+ .split(';')
+ .map(s => s.trim())
+ .filter(s => s); // final cleanup
+
+ // console.log(cid + ': ' + imageryUsedArray + ' length: ' + imageryUsedArray.length);
+ // console.log(imageryUsedArray);
+ changesets[cid].imageryUsed = imageryUsedArray;
}
+ }
+ });
+
+ /*Vandalism Checker
+ Simple sanity checker for all the downloaded changesets. 3 things are checked:
+ 1. It summarizes the number of all elements which have been added or deleted
+ 2. It summarizes the number of all tags which have been added or deleted
+ 3. It summarizes the number of all iD warnings and resolved iD warnings
+ for each changeset. If the sum is below a certain treshold (currently -3) then a traffic light changes to red to alert the user
+ about this changeset.*/
+ function vandalismChecker() {
+ // console.log(elements);
+ for (let i = 0; i < allOverpassXMLDataElements.length; i++) {
+ //Get type, i.e. "create", "modify" or "delete"
+ const type = allOverpassXMLDataElements[i].getAttribute("type");
+ // console.log(type);
+
+ //Get changeset number
+ let changesetNumber;
+ if (type === "create") changesetNumber = allOverpassXMLDataElements[i].lastElementChild.getAttribute("changeset");
+ else changesetNumber = allOverpassXMLDataElements[i].lastElementChild.firstElementChild.getAttribute("changeset");
+ // console.log(changesetNumber);
+
+ if (changesets[changesetNumber]) { // Check if changeset exists in our main object
+ //Check which action is performed
+ //"Create"
+ //deltaInNodesWays++
+ //deltaInTags += nTagsAdded
+ if (type === "create") {
+ //Check the amount of tags that have been added
+ const nTagsAdded = allOverpassXMLDataElements[i].lastElementChild.querySelectorAll("tag").length;
+ // console.log(nTagsAdded);
+ changesets[changesetNumber].deltaInTags += nTagsAdded;
+
+ //If a node with 0 tags has been created: Do not add it to deltaInNodesWays
+ //(normally it is just a newly created node of an already existing way)
+
+ //Get element type (i.e. "node" or "way")
+ const elementType = allOverpassXMLDataElements[i].firstElementChild.nodeName;
+ // console.log(elementType);
+
+ if (elementType === "node" && nTagsAdded == 0) continue;
+ else changesets[changesetNumber].deltaInNodesWays++;
+ }
+
+ // "Modify"
+ //deltaInNodesWays = unchanged
+ //deltaInTags += nTagsNew - nTagsOld
+ if (type === "modify") {
+ const nTagsNew = allOverpassXMLDataElements[i].lastElementChild.firstElementChild.querySelectorAll("tag").length;
+ const nTagsOld = allOverpassXMLDataElements[i].firstElementChild.firstElementChild.querySelectorAll("tag").length;
+ // console.log(nTagsNew);
+ // console.log(nTagsOld);
+ changesets[changesetNumber].deltaInTags += (nTagsNew - nTagsOld);
+ }
+
+ // "Delete"
+ //deltaInNodesWays--
+ //deltaInTags -= nTags
+ if (type === "delete") {
+ // The 'old' element in a delete action is under element.firstElementChild.firstElementChild
+ const oldElementNode = allOverpassXMLDataElements[i].firstElementChild.firstElementChild;
+ const nTagsDeleted = oldElementNode.querySelectorAll("tag").length;//Number of deleted tags
+ // console.log(nTagsDeleted);
+ changesets[changesetNumber].deltaInTags -= nTagsDeleted;
+
+ //Get element type (i.e. "node" or "way")
+ const elementType = oldElementNode.nodeName;
+ // console.log(elementType);
+
+ //If a node with 0 tags has been deleted: Do not subtract it from deltaInNodesWays
+ // (normally it is just a newly deleted node of an already existing way)
+ if (elementType === "node" && nTagsDeleted == 0) continue;
+ else changesets[changesetNumber].deltaInNodesWays--;
+ }
+ }
+ }
+
+ //Write boolean value 'possibleVandalims' into 'changesets'
+ for (const changesetId in changesets) {
+ const cs = changesets[changesetId];
+ // console.log(changesetId);
+ //Check how many warnings and resolves iD editor produced.
+ // console.log("Changeset number: " + changesetId + ", deltaInIdWarningsAndResolves: " + cs.deltaInIdWarningsAndResolves);
+
+ if ((cs.deltaInNodesWays < vandalismThreshold) || (cs.deltaInTags < vandalismThreshold) || (cs.deltaInIdWarningsAndResolves < vandalismThreshold)) {
+ cs.possibleVandalism = true;
+ }
+ }
+ }
+
+ // console.log(changesets);
+ vandalismChecker();//Analyse each changeset and create boolean "possibleVandalism" within "changesets" object
+ renderChangesetsList(changesets);//Render changesets list on the left side
+ fetchAndDisplayUserDetails(changesets);//After the whole data is loaded and rendered download some 'nice-to-have' user data (total edits & sign-up date) and add it to the changeset information
+ fetchAndDisplayChangesetDiscussions(changesets);//After the whole data is loaded and rendered download changeset discussions and add it to the changeset information
+ })
+ .catch(function (error) { // ERROR handler for ANY error in the Promise chain above
+ if (error.name === 'AbortError') {
+ console.log("Fetch aborted.");
+ // Don't show a user error for deliberate aborts, just ensure UI is reset
+ // (toggleWaitingScreen might already be handled if the abort happens early)
+ // May need explicit UI reset here depending on state.
+ d3.select('#map').classed('faded', false); // Un-fade map
+ // Ensure loading animation is off if it was turned on
+ const loadingAnimation = document.querySelector("#loading-animation");
+ if (!loadingAnimation.classList.contains("hide")) {
+ toggleWaitingScreen();
+ }
+
+ } else {
+ // Handle actual network or processing errors
+ toggleWaitingScreen(); // Ensure loading screen is off
+ console.error("Error fetching or processing data:", error); // Log the actual error
+ message("alarm", "Server error: " + (error.message || "Could not load data."));
+ }
+ })
+ .finally(() => {
+ // Cleanup: Clear the controller reference ONLY if it's the one from this run
+ if (window.currentAbortController && signal === window.currentAbortController.signal) {
+ window.currentAbortController = null;
+ // console.log("AbortController reference cleared.");
+ }
+ });
+}
+
+// --- Third API call: Fetch extended user details (total edits & sign-up date) from the OSM API and update the UI ---
+function fetchAndDisplayUserDetails(changesetsToDisplay) {
+ // 1. Collect all unique user IDs from the visible changesets
+ const userIds = new Set();
+ for (const id in changesetsToDisplay) {
+ if (changesetsToDisplay[id] && changesetsToDisplay[id].userId) {
+ userIds.add(changesetsToDisplay[id].userId);
+ }
+ }
+
+ if (userIds.size === 0) return; // No users to fetch
+
+ // 2. Form the URL for the OSM User API
+ const userIdsArray = Array.from(userIds);
+ const url = debugMode
+ ? "./examples/exampleUsers.json" // Use a local file for debugging
+ : `https://api.openstreetmap.org/api/0.6/users.json?users=${userIdsArray.join(',')}`;
+
+ // 3. Fetch the data. This is a "nice-to-have" call, so we handle errors gracefully.
+ fetch(url)
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error fetching user data! Status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then(data => {
+ // 4. Write the response into our 'changesets' object
+ // console.log(data);
+ changesets.userData = {};
+ if (data && data.users) {
+ data.users.forEach(user => {
+ // console.log(user.user.id);
+ changesets.userData[user.user.id] = { accountCreated: user.user.account_created, changesetCount: user.user.changesets.count };
});
- rl.select('span.load').each(function (d) {
- if (changesets[d.id].discussionCount > 0)
- d3.select(this).html('⇒ ');
- });
- rl.select('div.changeset').each(function (d) {
- d3.select(this).html(
- '' +
- (changesets[d.id].comment || '—') +
- ''
- );
- });
+ }
+
+ // 5. Update the DOM with the fetched data
+ // Select all result list items that have a 'osm-user-id' attribute
+ d3.selectAll('.result[user-id]').each(function () {
+ const li = d3.select(this);
+ const userId = li.attr('user-id');
+ // console.log(userId);
+ const userData = changesets.userData[userId];
+ // console.log(userData);
+
+ if (userData) {
+ // Unhide the "User Experience" section
+ li.selectAll('.user-experience-heading, .user-experience-row').classed('hide', false);
+
+ // Update the table cells with the new data
+ li.select('.user-changesets-count').text(userData.changesetCount.toLocaleString());
+ li.select('.user-time-since-signup').text(moment(userData.accountCreated).fromNow());
+
+ // Update the title of '.user-name' link to include total changesets and signup date
+ const userNameLink = li.select('.user-name');
+ const originalTitle = userNameLink.attr('title') || '';
+ const newTitle = `\n\nTotal changesets: ${userData.changesetCount.toLocaleString()}\nSigned up: ${moment(userData.accountCreated).fromNow()}`;
+ userNameLink.attr('title', originalTitle ? originalTitle + newTitle : newTitle);
+ }
});
+ })
+ .catch(error => {
+ // If the API call fails, we just log it and do nothing. The UI will simply not show the extra info.
+ console.warn("Could not fetch extended user details:", error.message);
+ });
+}
+
+// --- Fourth API call: Fetch changeset comments from the OSM API and update the UI. ---
+// As of 2025 the OSM API only allows to download changeset comments for one changeset at a time
+async function fetchAndDisplayChangesetDiscussions(changesetsToDisplay) {
+ // 1. Collect all changeset numbers, where discussionCount > 0
+ const changesetsWithDiscussions = [];
+ for (const id in changesetsToDisplay) {
+ if (changesetsToDisplay[id].discussionCount > 0) {
+ changesetsWithDiscussions.push(id);
+ }
+ }
+ // console.log(changesetsWithDiscussions);
+
+ //Return early if there are no changesets with discussions
+ if (changesetsWithDiscussions.length === 0) return;
+
+ // Get the current abort signal
+ const signal = window.currentAbortController ? window.currentAbortController.signal : null;
+
+ // 2. Iterate over each changeset ID and fetch its discussion
+ for (const changesetID of changesetsWithDiscussions) {
+
+ // Check if aborted before starting the next request
+ if (signal && signal.aborted) {
+ console.log("Fetch discussions aborted.");
+ break;
+ }
+
+ const url = debugMode
+ ? "./examples/exampleDiscussions.json"
+ : `https://api.openstreetmap.org/api/0.6/changeset/${changesetID}.json?include_discussion=true`;
+
+ try {
+ // 3. Await the fetch. This ensures requests are done sequentially (1 by 1),
+ // preventing API rate limiting issues or browser network congestion.
+ const response = await fetch(url, { signal: signal });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! Status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // Update the main object (the discussion data in the changesets object is used, when the changesets list is filtered and thus rerendered)
+ const discussion = data.changeset.comments;
+
+ // 4. Update the DOM
+
+ // Select the discussion heading and unhide it
+ const discussionHeading = d3.select(`[changeset-id="${changesetID}"] .changeset-discussion-heading`);
+ discussionHeading.classed('hide', false);
+
+ // Select the discussion container for this changeset
+ const discussionTR = d3.select(`[changeset-id="${changesetID}"] .changeset-discussion`);
+
+ // Loop through each comment and append it to the discussion container
+ for (let j = 0; j < discussion.length; j++) {
+ const comment = discussion[j];
+ const singleCommentDiv = discussionTR.append('article')
+ .classed('comment-container', true);
+
+ singleCommentDiv.append('div')
+ .classed('comment-info', true)
+ .html(`
+ Comment from ${comment.user} ${moment(comment.date).fromNow()}
+ `);
+
+ singleCommentDiv.append('div')
+ .classed('comment-text', true)
+ .html(linkify(escapeHtml(comment.text))); // Escape HTML and linkify URLs
+ }
+ //Write discussion HTML to changeset object for future use (i.e. when filtering the changesets list)
+ changesets[changesetID].discussionHTML = discussionTR.html();
+
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ // Silent return/break on abort
+ return;
+ }
+ console.warn(`Could not fetch discussions for changeset ${changesetID}:`, error.message);
+ }
+ }
+}
+
+/* Sort GeoJSON features by geometry type and size.
+ By doing this we can make sure that an element which is completely covered by a larger polygon can still be selected.
+ Sort order (i.e. order in which they are added to the map):
+ 1. Polygons (largest to smallest)
+ 2. Lines
+ 3. Points
+*/
+function sortGeoJsonFeatures(a, b) { // a and b are GeoJSON features
+ const typeOrder = { 'Polygon': 1, 'LineString': 2, 'Point': 3 };
+ const aType = a.geometry.type;
+ const bType = b.geometry.type;
+ if (typeOrder[aType] !== typeOrder[bType]) {
+ return typeOrder[aType] - typeOrder[bType];
+ }
+ if (aType === 'Polygon') {
+ const aArea = calculateArea(a.geometry);
+ const bArea = calculateArea(b.geometry);
+ return bArea - aArea; // Largest first (Reverse the subtraction for descending order)
+ }
+ return 0;
+};
+
+/*Calculate relative area of polygon.
+This so called 'Shoelace formula' does not return accurate results in m² because it only works for planar
+2D coordinates. Our geo coordinates are on a sphere though (in degrees). Since we only want to compare
+the relative size of each polygon for sorting purposes the formula is sufficient.*/
+function calculateArea(geometry) {
+ let area = 0;
+ const coords = geometry.coordinates[0];
+ for (let i = 0; i < coords.length; i++) {
+ const j = (i + 1) % coords.length; //j is always x+1 except on the last element of the array where it is 0.
+ const [xi, yi] = coords[i];
+ const [xj, yj] = coords[j];
+ area += xi * yj - xj * yi;
+ }
+ // console.log(area);
+ return Math.abs(area) / 2;
+};
+
+//Render changesets list on the left side
+function renderChangesetsList(changesetsToDisplay) {
+ // console.log(changesetsToDisplay);
+ //Object.values writes all values of 'changesetsToDisplay' object into an array
+ //Then the array elements (i.e. changesets) are sorted newest to oldest
+ const bytime = Object.values(changesetsToDisplay)
+ // Filter out non-changeset properties like 'allLeafletLayers'.
+ // Workflow: Is 'cs' NOT 'null' or undefined? And does it have a valid '.id' property? If true: keep this changeset in 'bytime'.
+ // If false: Filter it out. 'allLeafletLayers' does not have an '.id' property.
+ .filter(cs => cs && typeof cs.id !== 'undefined')
+ .sort((a, b) => (+b.time) - (+a.time));
+ // console.log(bytime);
- }).get();
+ //From here onwards the creation of the changesets section starts
+ const results = d3.select('#results').html("");//Select and Clear the Results Container
+ const allresults = results
+ .selectAll('li.result')//Since li.results is nonexistent at this point in time D3 creates an empty selection
+ .data(bytime, d => d.id);//Bind data
+ // console.log(allresults);
+ //Below a single changeset div container 'rl' with all its content (e.g. loupe, traffic light, username, ...) is created.
+ const rl = allresults.enter()
+ .append('li')
+ .attr('class', 'result')
+ .attr('changeset-id', d => d.id)
+ .attr('user-id', d => d.userId)
+ // .attr('title', 'Changeset is highlighted on map')
+ .style('color', d => changesets[d.id].color)
+ .on('click', (event, d) => click(null, d))//Highlight changeset on click (desktop/mobile) - Pass null for feature, d for data
+ .on('mouseover', (event, d) => click(null, d));//Highlight changeset on mouseover (desktop) - Pass null for feature, d for data
+ // console.log(rl);
+ allresults.order();
+
+ //"Zoom to changeset" button
+ rl.append('div')
+ .classed('zoom', true)
+ .attr('title', 'Zoom to changeset')
+ //.html('🔎 ')//Unicode glyph for a loupe
+ .on('click', function (event, d) {
+ //Fit the bounds of the Leaflet layers featureGroup for this changeset
+ if (changesets[d.id] && changesets[d.id].leafletFeatureGroup) {
+ const groupToZoom = changesets[d.id].leafletFeatureGroup;
+ const bounds = groupToZoom.getBounds();
+ map.fitBounds(bounds);
+ }
+
+ //On small screens (screen width < 601px) scroll all the way down, so that map is completely visible on screen
+ if (screen.width < 601) {
+ window.scrollTo({
+ top: document.body.scrollHeight, // Scroll to the bottom of the page ('document.body.scrollHeight' returns the total height of the entire document body)
+ behavior: 'smooth'
+ });
+ }
+ })
+ .append('svg')
+ .classed('loupe', true)
+ .append('use')
+ .attr('href', 'img/icons.svg#loupe');
+
+ //Vandalism Checker traffic light
+ let trafficLightContainer = rl.append("div")
+ .classed("traffic-light-container", true)
+ .attr('title', function (d) {
+ const changesetData = changesets[d.id];
+ if (!changesetData) return "Changeset data not available for title.";
+
+ const possibleVandalism = changesetData.possibleVandalism;
+ const deltaInNodesWays = changesetData.deltaInNodesWays;
+ const deltaInTags = changesetData.deltaInTags;
+ const deltaInIdWarningsAndResolves = changesetData.deltaInIdWarningsAndResolves;
+ const usedEditorWasId = changesetData.osmEditor && changesetData.osmEditor.toLowerCase().startsWith("id");
+ // console.log('OSM Editor: ' + changesetData.osmEditor + ', usedEditorWasId: ' + usedEditorWasId);
+ let titleText;
+ //Only show the line "All resolved iD warnings..." if the used editor was actually iD
+ if (possibleVandalism) {
+ titleText = `This changeset is potentially destructive!\nAll added nodes/ways - All deleted nodes/ways: ${deltaInNodesWays}. ${deltaInNodesWays < vandalismThreshold ? "This is suspicious!" : ""}\nAll added tags - All deleted tags: ${deltaInTags}. ${deltaInTags < vandalismThreshold ? "This is suspicious!" : ""}\n${usedEditorWasId ? `All resolved iD warnings - New iD warnings: ${deltaInIdWarningsAndResolves}. ${deltaInIdWarningsAndResolves < vandalismThreshold ? "This is suspicious!" : ""}` : ""}\n\nReminder: It is often NOT necessary to delete elements in OSM. For example a closed shop should be tagged as 'disused:shop'. One day a new shop might open at the same exact lot and the tags can be updated. The same is true for demolished buildings ('demolished:building')`;
+ } else {
+ titleText = `This changeset looks good!\nAll added nodes/ways - All deleted nodes/ways: ${deltaInNodesWays}. ${deltaInNodesWays < vandalismThreshold ? "This is suspicious!" : ""}\nAll added tags - All deleted tags: ${deltaInTags}. ${deltaInTags < vandalismThreshold ? "This is suspicious!" : ""}\n${usedEditorWasId ? `All resolved iD warnings - New iD warnings: ${deltaInIdWarningsAndResolves}. ${deltaInIdWarningsAndResolves < vandalismThreshold ? "This is suspicious!" : ""}` : ""}`;
+ }
+ return titleText;
+ });
+ let trafficLight = trafficLightContainer.append("div")
+ .classed("traffic-light", true);
+ trafficLight.append("span")
+ .attr('class', function (d) {
+ const possibleVandalism = changesets[d.id].possibleVandalism;
+ return (possibleVandalism ? "gray" : "green");
+ });
+ trafficLight.append("span")
+ .attr('class', function (d) {
+ const possibleVandalism = changesets[d.id].possibleVandalism;
+ return (possibleVandalism ? "red" : "gray");
+ });
+
+ //Adds a span element for displaying a text bubble SVG symbol if this OSM changeset has received comments
+ rl.append('div')
+ .classed('text-bubble', true)
+ .filter(d => d.discussionCount > 0) // Filter this span based on the data ('d.discussionCount' from 'bytime')
+ // If 'discussionCount' is larger 0 the 'span' html tag will pass the filter and the attributes below will be attached to it:
+ .attr('title', d => `Changeset has ${d.discussionCount} comment${d.discussionCount !== 1 ? "s" : ""}`)
+ .append('svg') // Append SVG only to filtered spans
+ .classed('text-bubble-svg', true)
+ .append('use')
+ .attr('href', 'img/icons.svg#speech-bubble');
+
+ //Container for name and age of changeset
+ let containerNameDate = rl.append('div')
+ .classed('container-name-date', true);
+
+ //User name.
+ containerNameDate.append('a')
+ .classed('user-name', true)
+ .html(function (d) {
+ return d.user; // d.user might contain HTML highlights from filtering
+ })
+ .attr('title', function (d) {
+ // Get unaltered user name from changesets. The value in d.user might have html in it if filtered,
+ // e.g. rene78. We don't want that in the title and href.
+ const unalteredUserName = changesets[d.id].user;
+ let titleText = `Go to OSM user page of ${unalteredUserName}`;
+ // If we have extended user data, add total changesets and signup date to the title
+ if (changesets.userData?.[d.userId]) {
+ return `${titleText}\n\nTotal changesets: ${changesets.userData?.[d.userId]?.changesetCount}\nSigned up: ${moment(changesets.userData?.[d.userId]?.accountCreated).fromNow()}`;
+ } else return titleText;
+ })
+ .attr('target', '_blank')
+ .attr('href', function (d) {
+ const unalteredUserName = changesets[d.id].user;//Get unaltered user name from changesets object.
+ return '//openstreetmap.org/user/' + unalteredUserName;
+ });
+
+ //Timespan since changeset creation
+ containerNameDate.append('span')
+ .attr('title', function (d) {
+ return moment(d.time).format('MMM Do YYYY, h:mm:ss a');
+ })
+ .attr('class', 'date').text(function (d) {
+ return moment(d.time).fromNow();
+ });
+
+ //Arrow to expand changeset information
+ rl.append('div')
+ .classed('arrow', true)
+ .attr('title', 'Open details of changeset')
+ .on('click', function (event, d) {
+ //Element related to the arrow animation
+ const arrowDiv = d3.select(this); // 'this' refers to the clicked element
+ const arrowSvg = arrowDiv.select('svg').node(); // grab the SVG node inside the arrow div
+
+ //Elements related to the expansion of the changeset details
+ const thisResult = this.closest('.result'); // find parent element
+ const changesetComment = thisResult.querySelector('.changeset-comment'); //Changeset comment
+ const tableContainer = thisResult.querySelector('.table-container'); //Changeset details
+
+ const isCollapsed = tableContainer.classList.toggle('hidden');
+
+ if (isCollapsed) {
+ //Collapsing
+ arrowSvg.classList.remove('rotated');
+ // Only after collapse transition ends, restore truncation. Else it looks very choppy.
+ setTimeout(() => {
+ changesetComment.classList.remove('expanded');
+ changesetComment.innerHTML = linkify(d.comment);
+ }, 300);
+ // Update tooltip when hovering over arrow
+ arrowDiv.attr('title', 'Open changeset information');
+ // Add back the changeset comment once the details are collapsed
+ changesetComment.title = `Go to OSM changeset page\n\n${changesets[d.id].comment}`;
+ } else {
+ // Expanding
+ arrowSvg.classList.add('rotated');
+ changesetComment.classList.add('expanded');
+ changesetComment.innerText = "Information";
+ //Remove the changeset comment from the tooltip when details are expanded.
+ changesetComment.title = "Go to OSM Changeset page";
+ // Update tooltip when hovering over arrow
+ arrowDiv.attr('title', 'Close changeset information');
+ }
+ })
+ .append('svg')
+ .classed('arrow-up-svg', true)
+ .append('use')
+ .attr('href', 'img/icons.svg#arrow-up');
+
+ //Changeset comment
+ rl.append('a')
+ .classed('changeset-comment', true)
+ .attr('href', d => `https://www.openstreetmap.org/changeset/${d.id}`)
+ .attr('target', '_blank')
+ .attr('title', d => `Go to OSM changeset page\n\n${changesets[d.id].comment}`)
+ //Changeset title (was downloaded separately from OSM API)
+ .html((d) => {// d.comment might contain HTML highlights from filtering
+ // We apply linkify to make URLs clickable.
+ // Note: If a URL matches the search term, the highlighting might interfere with the tag creation, but this is a rare edge case.
+ return linkify(d.comment) || '—';
+ })
+
+ //Changeset details. Appears after clicking on the arrow button
+ let tableContainer = rl.append('div')
+ .classed('table-container hidden', true)
+
+ //The changeset details table which is hidden by default
+ tableContainer.append('table')
+ .classed('changeset-table', true)
+ .html(function (d) {
+ let imageryHtml;
+ const imageryUsed = changesets[d.id].imageryUsed;
+ if (imageryUsed.length === 0) imageryHtml = '-';
+ else if (imageryUsed.length === 1) imageryHtml = imageryUsed[0];
+ else {
+ imageryHtml = '
+
+
+
+ Tag
+ New
+
+
+ version
+ ${node[0].getAttribute("version")}
+
+
+ timestamp
+ ${moment(node[0].getAttribute("timestamp")).fromNow()}
+
+
+ `;
+
+ for (let i = 0; i < keysNew.length; i++) {
+ const k = keysNew[i].getAttribute('k');
+ const v = keysNew[i].getAttribute('v');
+ tableHtml += `
+ user
+
+ ${node[0].getAttribute("user")}
+
+
+
+ `;
+ }
+ }
+
+ //2 MODIFY/DELETE
+ else {
+ //Create Set with all unique key-values from old and new
+ const uniqueKeysSet = new Set();
+ //Start with old
+ //Copy meta tags
+ keyvalues.old.meta["version"] = node[0].getAttribute("version");
+ keyvalues.old.meta["timestamp"] = moment(node[0].getAttribute("timestamp")).fromNow();
+ keyvalues.old.meta["user"] = node[0].getAttribute("user");
+ const keysOld = node[0].querySelectorAll("tag");
+ for (let i = 0; i < keysOld.length; i++) {
+ keyvalues.old.tags[keysOld[i].getAttribute('k')] = keysOld[i].getAttribute('v');
+ uniqueKeysSet.add(keysOld[i].getAttribute('k'));
+ }
+ //Continue with new
+ //Copy meta tags
+ keyvalues.new.meta["version"] = node[1].getAttribute("version");
+ keyvalues.new.meta["timestamp"] = moment(node[1].getAttribute("timestamp")).fromNow();
+ keyvalues.new.meta["user"] = node[1].getAttribute("user");
+ const keysNew = node[1].querySelectorAll("tag");
+ for (let i = 0; i < keysNew.length; i++) {
+ keyvalues.new.tags[keysNew[i].getAttribute('k')] = keysNew[i].getAttribute('v');
+ uniqueKeysSet.add(keysNew[i].getAttribute('k'));
+ }
+ // console.log(keyvalues);
+ //Create array in which all keys are ordered alphabetically
+ const uniqueKeysArr = Array.from(uniqueKeysSet).sort();
+ // console.log(uniqueKeysArr);
+
+ //Create table
+ tableHtml += `
+
+ ${linkKey(k)}
+ ${linkValue(k, v)}
+
+
+
+
+ Tag
+ Old
+ New
+
+
+ version
+ ${keyvalues.old.meta["version"]}
+ ${keyvalues.new.meta["version"]}
+
+
+ timestamp
+ ${keyvalues.old.meta["timestamp"]}
+ ${keyvalues.new.meta["timestamp"]}
+
+
+ `;
+
+ //Traverse uniqueKeysArray and check in object which value this key has in new and old
+ for (let i = 0; i < uniqueKeysArr.length; i++) {
+ const key = uniqueKeysArr[i];
+ let oldTag = keyvalues.old.tags[key];
+ let newTag = keyvalues.new.tags[key];
+ let cssClass;
+ //Case 1: Tag deleted in new --> Background color red, change from "undefined" to ""
+ if (!newTag) {
+ cssClass = "delete";
+ newTag = "";
+ }
+ //Case 2: Tag created in new --> Background color green, change from "undefined" to ""
+ else if (!oldTag) {
+ cssClass = "create";
+ oldTag = "";
+ }
+ //Case 3: Tag different in new --> Background color yellow
+ else if (oldTag !== newTag) cssClass = "modify";
+
+ //Case 4: Tags similar --> Default (i.e. no) background color
+ else cssClass = "unchanged";
+
+ tableHtml += `
+
+
+ ${keyvalues.old.meta["user"]}
+
+
+ ${keyvalues.new.meta["user"]}
+
+
+
+ `;
+ }
+ }
+
+ tableHtml += `
+
+ ${linkKey(key)}
+ ${linkValue(key, oldTag)}
+ ${linkValue(key, newTag)}
+ ';
+ imageryUsed.forEach((provider, index) => {
+ imageryHtml += `
';
+ }
+
+ const usedEditorWasId = changesets[d.id].osmEditor && changesets[d.id].osmEditor.toLowerCase().startsWith("id");//code is written twice. not very clean.
+ htmlForIdWarningsCheck = `
+
+ `;
+
+ // Infos regarding the "User Experience" data: After executing run() the user data (i.e. total edits & sign-up date) has not been
+ // downloaded and written into the 'changesets' object yet. When using the filter function the user data is already in 'changesets' though.
+ // That is why we check if 'changesets.userData?.[d.userId]' is available or not. The same is true for the discussion data further down.
+ let tableHtml = `
+
+ iD Warnings
+
Resolved-New${changesets[d.id].deltaInIdWarningsAndResolves}
+
+
+ Changeset Details
+
+
+ Comment
+ ${d.comment}
+
+
+ Imagery
+ ${imageryHtml}
+
+
+ Editor
+ ${changesets[d.id].osmEditor || '-'}
+
+
+ User Experience
+
+
+ Edits count
+ ${!(changesets.userData?.[d.userId]) ? "..." : changesets.userData[d.userId].changesetCount.toLocaleString()}
+
+
+ Joined
+ ${!(changesets.userData?.[d.userId]) ? "..." : moment(changesets.userData[d.userId].accountCreated).fromNow()}
+
+
+ Changeset Integrity
+
+
+ Elements
+
Added-Deleted${changesets[d.id].deltaInNodesWays}
+
+
+ ${usedEditorWasId ? htmlForIdWarningsCheck : ""}
+ Tags
+
Added-Deleted${changesets[d.id].deltaInTags}
+
+
+ Discussion
+
+
+ `;
+ return tableHtml;
+ })
+}
+
+//Highlight clicked layer on map and in sidebar (happens when selecting element in sidebar or on map)
+// eventOrFeature can be a Leaflet GeoJSON feature (from map click) or null (from sidebar click/hover)
+// d can be changeset data (from sidebar click/hover) or null (from map click)
+function click(eventOrFeature, d) {
+ // console.log("--- click Function Called ---");
+ // console.log("Argument 1 (eventOrFeature):", eventOrFeature);
+ // console.log("Argument 2 (d):", d);
+
+ let changesetNumber;
+ if (d && typeof d.id !== 'undefined') { // Click/hover from sidebar (d is changeset data from bytime array)
+ changesetNumber = d.id;
+ } else if (eventOrFeature?.properties?.meta?.changeset) { // Click from map (eventOrFeature is a GeoJSON feature)
+ changesetNumber = eventOrFeature.properties.meta.changeset;
+ } else {
+ console.warn("Could not determine changeset number in click handler.", eventOrFeature, d);
+ return; // Cannot proceed without a changeset number
+ }
+ // console.log('changesetNumber: ' + changesetNumber);
+
+ //Reset style of previously highlighted changeset
+ //The code 'changesets.allLeafletLayers.resetStyle();' is possible, too. But for this setStyle() is called for every element on the map.
+ //Computationally demanding for no added value since we only need to reset the color of the previously highlighted featureGroup.
+ highlightedChangeset = changesets.highlightedChangeset;
+ // console.log(highlightedChangeset);
+ if (highlightedChangeset) changesets[highlightedChangeset].leafletFeatureGroup.setStyle({ color: changesets[highlightedChangeset].color });//Change color back to shade of red
+ changesets.highlightedChangeset = changesetNumber;//Change highlighted changeset to the currently selected one.
+
+ const results = d3.select('#results');
+ results
+ .selectAll('li.result')
+ .classed('active', function (dataItem) { // dataItem here is an element from the 'bytime' array
+ //assign the class "active" if id of li element equals changeset number
+ return dataItem.id == changesetNumber; //returns true if dataItem.id equals the changeset number from above. Else it returns false.
+ });
+
+ //Highlight featureGroup belonging to the selected changeset number.
+ if (changesets[changesetNumber] && changesets[changesetNumber].leafletFeatureGroup) {
+ changesets[changesetNumber].leafletFeatureGroup.setStyle({ color: '#008dff' }); //Highlighting color: blue
+ }
+
+ //Make sure that sidebar is displayed (if it was hidden)
+ sidebar.classList.remove("hide");
}
-//Error message in case of no results from Overpass
-function message(type, error) {
- // console.log(error.statusText);
+// Helper function to highlight search term in a given text
+function highlightSearchTermInText(text, searchTerm) {
+ const originalText = text || ""; // Ensure we have a string, even if input is null/undefined
+ let highlightedText = originalText;
+ let matchFound = false;
+
+ // Only proceed if searchTerm is provided and text is not empty
+ if (searchTerm && originalText) {
+ // Check if the original text (case-insensitively) contains the search term
+ if (originalText.toLowerCase().includes(searchTerm.toLowerCase())) {
+ matchFound = true;
+ highlightedText = ''; // Rebuild with highlights
+ let lastIndex = 0;
+ // Safely handle any user input, even special regex symbols. Make it case-insensitive.
+ const regex = new RegExp(searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
+ let match;
+ while ((match = regex.exec(originalText)) !== null) {
+ highlightedText += originalText.substring(lastIndex, match.index); // The original text up until the search term encounter...
+ highlightedText += `${match[0]}`; // ...plus the highlighted search term...
+ lastIndex = regex.lastIndex; // index at the end of the search term
+ }
+ highlightedText += originalText.substring(lastIndex); // ...plus the part of the text after the highlighted search term
+ }
+ }
+ return { highlightedText, matchFound };
+}
+
+//Filter changesets and update changesets list and GeoJSON data on map
+function filterChangesets() {
+ let foundChangesets = {}; // This will store data for rendering (potentially with highlighted text)
+ const searchTerm = document.querySelector(".search-changesets-field").value.toLowerCase();
+ const onlyRed = document.querySelector("#filter-red-checkbox").checked;
+
+ // If searchTerm is empty AND "onlyRed" is not checked, show all changesets by writing all the data from 'changesets' object into 'foundChangesets'
+ if (!searchTerm && !onlyRed) {
+ for (const changesetId in changesets) {
+ // Ensure we are only processing actual changeset entries, not 'allLeafletLayers'
+ if (changesets.hasOwnProperty(changesetId) && changesetId !== 'allLeafletLayers') {
+ foundChangesets[changesetId] = changesets[changesetId]; // Use original data
+ }
+ }
+ renderChangesetsList(foundChangesets);
+ displayGeoJson(foundChangesets); // Pass the same object, displayGeoJson will look up groups in changesets
+ return;
+ }
+
+ for (const changesetId in changesets) {
+ if (!changesets.hasOwnProperty(changesetId) || changesetId === 'allLeafletLayers') continue;
+ const currentChangesetData = changesets[changesetId]; // Original data
+
+ // Apply "onlyRed" filter first. If it's active and current changeset is not marked as 'possible vandalism', skip.
+ if (onlyRed && !currentChangesetData.possibleVandalism) continue;
+
+ let matchFoundBySearchTerm = false;
+ let modifiedComment = currentChangesetData.comment || ""; // Start with original or empty string
+ let modifiedUserName = currentChangesetData.user || ""; // Start with original or empty string
+
+ if (searchTerm) { // Only perform search term matching if searchTerm is present
+ // 1. Search within COMMENTS for search term and get highlighted version
+ const commentResult = highlightSearchTermInText(currentChangesetData.comment, searchTerm);
+ modifiedComment = commentResult.highlightedText;
+ if (commentResult.matchFound) {
+ matchFoundBySearchTerm = true;
+ }
+
+ // 2. Search within USER NAME for search term and get highlighted version
+ const userNameResult = highlightSearchTermInText(currentChangesetData.user, searchTerm);
+ modifiedUserName = userNameResult.highlightedText;
+ if (userNameResult.matchFound) {
+ matchFoundBySearchTerm = true;
+ }
+ }
+
+ // Determine if this changeset should be included in results:
+ // - If searchTerm is entered AND matchFoundBySearchTerm must be true.
+ // - If searchTerm is NOT entered BUT onlyRed IS (due to initial check)
+ if ((searchTerm && matchFoundBySearchTerm) || (!searchTerm && onlyRed)) {
+ foundChangesets[changesetId] = {
+ ...currentChangesetData, // Copy all original properties
+ user: modifiedUserName, // Override with (potentially) highlighted user
+ comment: modifiedComment // Override with (potentially) highlighted comment
+ };
+ }
+ }
+ // console.table(foundChangesets);
+ renderChangesetsList(foundChangesets); // Pass the object with potentially highlighted text
+ displayGeoJson(foundChangesets);
+
+ //Filter GeoJSON on map
+ function displayGeoJson(fSets) { // fSets is the foundChangesets object
+ if (!changesets.allLeafletLayers) return;
+
+ // Clear layers. Important: The layers are just detached from the map. The reference to those layers
+ // inside 'changesets[csId].leafletFeatureGroup' is still intact.
+ changesets.allLeafletLayers.clearLayers();
+
+ // 1. Collect all Leaflet layer instances that should be visible from the filtered sets
+ const visibleLayers = [];
+ for (const csId in fSets) { // Iterate over the fSets (which are the filtered changesets)
+ if (fSets.hasOwnProperty(csId)) {
+ // Get the original changeset data from the main object to access its leafletFeatureGroup
+ const originalChangesetData = changesets[csId];
+ if (originalChangesetData && originalChangesetData.leafletFeatureGroup) {
+ originalChangesetData.leafletFeatureGroup.eachLayer(layer => {
+ visibleLayers.push(layer);
+ });
+ }
+ }
+ }
+
+ // 2. Sort these visible layers using the same logic as the initial sort.
+ // The Leaflet layer instance (`layer`) has `layer.feature`.
+ visibleLayers.sort((layerA, layerB) => {
+ // Use your existing sortGeoJsonFeatures by passing the features
+ return sortGeoJsonFeatures(layerA.feature, layerB.feature);
+ });
+
+ // 3. Add the sorted layers back to the main display group
+ visibleLayers.forEach(layer => {
+ if (changesets.allLeafletLayers && typeof changesets.allLeafletLayers.addLayer === 'function') {
+ changesets.allLeafletLayers.addLayer(layer);
+ }
+ });
+ }
+}
+
+// --- Helper Functions for URL Linking ---
+
+// Escapes HTML characters to prevent XSS and broken layout
+function escapeHtml(text) {
+ if (!text) return text;
+ return text
+ .replace(/&/g, "&")
+ .replace(/
+ ${!(changesets[d.id].discussionHTML) ? "" : changesets[d.id].discussionHTML}
+
+