diff --git a/assets/scss/main.scss b/assets/scss/main.scss
index 6aeedde..2e00af6 100644
--- a/assets/scss/main.scss
+++ b/assets/scss/main.scss
@@ -1,5 +1,12 @@
@import "theme.scss";
@import "bootstrap/scss/bootstrap.scss";
+@import "maplibre-gl/dist/maplibre-gl.css";
+
+:root {
+ --ws-create-color: $review-create-color;
+ --ws-modify-color: $review-modify-color;
+ --ws-delete-color: $review-delete-color;
+}
html, body, #__nuxt {
width: 100%;
@@ -34,3 +41,28 @@ label > .form-select:first-child {
width: 100%;
height: 100%;
}
+
+.dropdown-menu {
+ box-shadow: $box-shadow;
+}
+
+/* Review */
+
+.bg-create {
+ background-color: $review-create-color;
+}
+.bg-modify {
+ background-color: $review-modify-color;
+}
+.bg-delete {
+ background-color: $review-delete-color;
+}
+.text-create {
+ color: #{darken($review-create-color, 27%)};
+}
+.text-modify {
+ color: $review-modify-color;
+}
+.text-delete {
+ color: #{darken($review-delete-color, 3%)};
+}
diff --git a/assets/scss/theme.scss b/assets/scss/theme.scss
index 0ff4519..5df4fdf 100644
--- a/assets/scss/theme.scss
+++ b/assets/scss/theme.scss
@@ -10,3 +10,7 @@ $primary: #9b0092;
@import "bootstrap/scss/mixins";
$navbar-height: 58px;
+
+$review-create-color: #39dbc0;
+$review-modify-color: #db950a;
+$review-delete-color: #cc2c47;
diff --git a/components/AppIcon.vue b/components/AppIcon.vue
index 19c3b2f..0273716 100644
--- a/components/AppIcon.vue
+++ b/components/AppIcon.vue
@@ -30,6 +30,10 @@ const classes = computed(() => ([
.material-icons {
vertical-align: middle;
margin-top: -3px;
+
+ &.me-2 {
+ margin-right: 0.33em !important;
+ }
}
.badge .material-icons {
font-size: 1em;
@@ -37,7 +41,7 @@ const classes = computed(() => ([
margin-top: 0;
&.me-2 {
- margin-right: 0.25rem !important;
+ margin-right: 0.25em !important;
}
}
diff --git a/components/AppNavbar.vue b/components/AppNavbar.vue
index dfe5802..8266eeb 100644
--- a/components/AppNavbar.vue
+++ b/components/AppNavbar.vue
@@ -54,6 +54,8 @@ const auth = tdeiClient.auth
diff --git a/components/ChatBoxMessage.vue b/components/ChatBoxMessage.vue
new file mode 100644
index 0000000..1c3d370
--- /dev/null
+++ b/components/ChatBoxMessage.vue
@@ -0,0 +1,64 @@
+
+
+
+ Comment by
+
+ {{ props.user ?? 'Anonymous' }}
+
+ {{ elapsed }}
+
+ {{ props.text }}
+
+
+
+
+
+
diff --git a/components/dashboard/Toolbar.vue b/components/dashboard/Toolbar.vue
index 7f95fc9..76f788b 100644
--- a/components/dashboard/Toolbar.vue
+++ b/components/dashboard/Toolbar.vue
@@ -4,7 +4,18 @@
Edit
-
+
+
+
+ Review
+
-
- Export
+
+ Export
-
- Settings
+
+ Settings
@@ -49,6 +60,7 @@ const editRoute = computed(() => ({
hash: editHash.value
}));
+const reviewRoute = computed(() => workspacePath('review'));
const exportRoute = computed(() => workspacePath('export'));
const settingsRoute = computed(() => workspacePath('settings'));
diff --git a/components/review/AttributeDiff.vue b/components/review/AttributeDiff.vue
new file mode 100644
index 0000000..41d7d1c
--- /dev/null
+++ b/components/review/AttributeDiff.vue
@@ -0,0 +1,284 @@
+
+
+
+ {{ elementType }}
+ #{{ elementId }}
+ {{ elementAction }} by
+ {{ props.diff.new.user }}
+
+
+
+
+
+
+
+
+ |
+
+ Attribute
+ |
+ Value |
+
+
+
+
+ |
+ -
+ |
+
+ +
+ |
+
+ ~
+ |
+ |
+ {{ key }} |
+ = |
+
+ {{ vals.old }}
+ |
+
+ {{ vals.new }}
+ |
+
+ {{ vals.old }}
+ →
+ {{ vals.new }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/review/ChangesBadge.vue b/components/review/ChangesBadge.vue
new file mode 100644
index 0000000..88b5d5c
--- /dev/null
+++ b/components/review/ChangesBadge.vue
@@ -0,0 +1,92 @@
+
+
+
+ {{ props.create }}
+ elements created
+
+
+ {{ props.modify }}
+ elements modified
+
+
+ {{ props.delete }}
+ elements deleted
+
+
+ Loading…
+
+
+
+
+
+
+
diff --git a/components/review/ChangesetDetails.vue b/components/review/ChangesetDetails.vue
new file mode 100644
index 0000000..72caf03
--- /dev/null
+++ b/components/review/ChangesetDetails.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+ | {{ key }} |
+ {{ value }} |
+
+
+
+
+
+
+
+
+
diff --git a/components/review/Details.vue b/components/review/Details.vue
new file mode 100644
index 0000000..adde49a
--- /dev/null
+++ b/components/review/Details.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
+ | {{ key }} |
+ {{ value }} |
+
+
+
+
+
+
+
+
+
diff --git a/components/review/Discussion.vue b/components/review/Discussion.vue
new file mode 100644
index 0000000..ca8789d
--- /dev/null
+++ b/components/review/Discussion.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
diff --git a/components/review/FilterDropdown.vue b/components/review/FilterDropdown.vue
new file mode 100644
index 0000000..1c89542
--- /dev/null
+++ b/components/review/FilterDropdown.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
diff --git a/components/review/Item.vue b/components/review/Item.vue
new file mode 100644
index 0000000..c470c73
--- /dev/null
+++ b/components/review/Item.vue
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
diff --git a/components/review/Map.vue b/components/review/Map.vue
new file mode 100644
index 0000000..770e0fa
--- /dev/null
+++ b/components/review/Map.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
diff --git a/components/review/Overlay.vue b/components/review/Overlay.vue
new file mode 100644
index 0000000..cb2c57a
--- /dev/null
+++ b/components/review/Overlay.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
diff --git a/components/review/Sidebar.vue b/components/review/Sidebar.vue
new file mode 100644
index 0000000..958d619
--- /dev/null
+++ b/components/review/Sidebar.vue
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
diff --git a/components/review/Toolbar.vue b/components/review/Toolbar.vue
new file mode 100644
index 0000000..98edf75
--- /dev/null
+++ b/components/review/Toolbar.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index c1aa9dd..a67869a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,12 +7,16 @@
"name": "workspaces",
"hasInstallScript": true,
"dependencies": {
+ "@osmcha/maplibre-adiff-viewer": "^1.3.1",
+ "@osmcha/osmchange-parser": "^1.0.0",
"@sentry/integrations": "^7.114.0",
"@sentry/nuxt": "^10.31.0",
"@zip.js/zip.js": "^2.7.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bootstrap": "^5.3.3",
+ "dayjs": "^1.11.19",
+ "maplibre-gl": "^5.10.0",
"nuxt": "^4.0.0",
"papaparse": "^5.5.1",
"vue": "^3.4.19",
@@ -1956,6 +1960,39 @@
"version": "1.1.1",
"license": "MIT"
},
+ "node_modules/@mapbox/geojson-rewind": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
+ "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
+ "license": "ISC",
+ "dependencies": {
+ "get-stream": "^6.0.1",
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "geojson-rewind": "geojson-rewind"
+ }
+ },
+ "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@mapbox/jsonlint-lines-primitives": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
+ "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/@mapbox/node-pre-gyp": {
"version": "2.0.0",
"license": "BSD-3-Clause",
@@ -1982,6 +2019,94 @@
"node": ">=8"
}
},
+ "node_modules/@mapbox/point-geometry": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
+ "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
+ "license": "ISC"
+ },
+ "node_modules/@mapbox/tiny-sdf": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
+ "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mapbox/unitbezier": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
+ "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
+ "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "~1.1.0",
+ "@types/geojson": "^7946.0.16",
+ "pbf": "^4.0.1"
+ }
+ },
+ "node_modules/@mapbox/whoots-js": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
+ "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@maplibre/geojson-vt": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
+ "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
+ "license": "ISC"
+ },
+ "node_modules/@maplibre/maplibre-gl-style-spec": {
+ "version": "24.4.1",
+ "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz",
+ "integrity": "sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg==",
+ "license": "ISC",
+ "dependencies": {
+ "@mapbox/jsonlint-lines-primitives": "~2.0.2",
+ "@mapbox/unitbezier": "^0.0.1",
+ "json-stringify-pretty-compact": "^4.0.0",
+ "minimist": "^1.2.8",
+ "quickselect": "^3.0.0",
+ "rw": "^1.3.3",
+ "tinyqueue": "^3.0.0"
+ },
+ "bin": {
+ "gl-style-format": "dist/gl-style-format.mjs",
+ "gl-style-migrate": "dist/gl-style-migrate.mjs",
+ "gl-style-validate": "dist/gl-style-validate.mjs"
+ }
+ },
+ "node_modules/@maplibre/mlt": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.2.tgz",
+ "integrity": "sha512-SQKdJ909VGROkA6ovJgtHNs9YXV4YXUPS+VaZ50I2Mt951SLlUm2Cv34x5Xwc1HiFlsd3h2Yrs5cn7xzqBmENw==",
+ "license": "(MIT OR Apache-2.0)",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0"
+ }
+ },
+ "node_modules/@maplibre/vt-pbf": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz",
+ "integrity": "sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==",
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/point-geometry": "^1.1.0",
+ "@mapbox/vector-tile": "^2.0.4",
+ "@maplibre/geojson-vt": "^5.0.4",
+ "@types/geojson": "^7946.0.16",
+ "@types/supercluster": "^7.1.3",
+ "pbf": "^4.0.1",
+ "supercluster": "^8.0.1"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.3",
"license": "MIT",
@@ -3722,6 +3847,31 @@
"@opentelemetry/api": "^1.1.0"
}
},
+ "node_modules/@osmcha/maplibre-adiff-viewer": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@osmcha/maplibre-adiff-viewer/-/maplibre-adiff-viewer-1.3.1.tgz",
+ "integrity": "sha512-VE67VWJh1DIH6pYlA+8aYxxKkrW1HkaRpV1DyE0Uiot9BSduwBKspnjm0ugKXV9UfbiKRa8/dI//cv/YyEKefw==",
+ "license": "ISC",
+ "dependencies": {
+ "@turf/area": "^7.2.0",
+ "@turf/bbox": "^7.2.0",
+ "@turf/bbox-polygon": "^7.2.0",
+ "deep-equal": "^2.2.3",
+ "id-area-keys": "^6.5.0"
+ },
+ "peerDependencies": {
+ "maplibre-gl": ">4.0.0"
+ }
+ },
+ "node_modules/@osmcha/osmchange-parser": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@osmcha/osmchange-parser/-/osmchange-parser-1.0.0.tgz",
+ "integrity": "sha512-cGGKwlm3BX0iVN3olmp0rGlNxW1XdFC82VmyUS0xXstEi2RlJXn/b/ENrromwBjNNKDFMDWb3V1dwpjAFnwR5g==",
+ "license": "ISC",
+ "dependencies": {
+ "sax": "^1.4.1"
+ }
+ },
"node_modules/@oxc-minify/binding-android-arm64": {
"version": "0.80.0",
"resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.80.0.tgz",
@@ -6277,6 +6427,77 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/@turf/area": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.3.tgz",
+ "integrity": "sha512-FT66TCxUec+3RsCCyO1kWP57/tiEWEqYfpIs5n44dup401Cne/E+xunahEWxMfP/HSUxfcRQqrjH5vEulLrNoA==",
+ "license": "MIT",
+ "dependencies": {
+ "@turf/helpers": "7.3.3",
+ "@turf/meta": "7.3.3",
+ "@types/geojson": "^7946.0.10",
+ "tslib": "^2.8.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/bbox": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.3.tgz",
+ "integrity": "sha512-1zNO/JUgDp0N+3EG5fG7+8EolE95OW1LD8ur0hRP0JK+lRyN0gAvJT7n1I9pu/NIqTa8x/zXxGRc1dcOdohYkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@turf/helpers": "7.3.3",
+ "@turf/meta": "7.3.3",
+ "@types/geojson": "^7946.0.10",
+ "tslib": "^2.8.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/bbox-polygon": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/@turf/bbox-polygon/-/bbox-polygon-7.3.3.tgz",
+ "integrity": "sha512-m2WfHVoJLZJf+nJizRfnm0GHyJN3eYY/oWL6xsp1bDodgBgrNqNPRD3OfA00x4HIt5VYXOyQ0GMDfvILLjlXWw==",
+ "license": "MIT",
+ "dependencies": {
+ "@turf/helpers": "7.3.3",
+ "@types/geojson": "^7946.0.10",
+ "tslib": "^2.8.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/helpers": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.3.tgz",
+ "integrity": "sha512-9Ias0L1GuZPIzO6sk8jraTEuLJye6n9LYNEdw69ZGOQ6C1IigjxkPW49zmn21aTv1z27vxdVLSS3r+78DB2QnQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "^7946.0.10",
+ "tslib": "^2.8.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
+ "node_modules/@turf/meta": {
+ "version": "7.3.3",
+ "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.3.tgz",
+ "integrity": "sha512-Tz1j4h70iFB5SebWWoVv/uL59x4aOngXU+d1xQDXzOCn/O6txnreGVGMcYU362c5F06yqZx38H9UFTQ553lK0w==",
+ "license": "MIT",
+ "dependencies": {
+ "@turf/helpers": "7.3.3",
+ "@types/geojson": "^7946.0.10",
+ "tslib": "^2.8.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/turf"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"license": "MIT",
@@ -6326,7 +6547,6 @@
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -6387,6 +6607,15 @@
"version": "1.20.2",
"license": "MIT"
},
+ "node_modules/@types/supercluster": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
+ "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/geojson": "*"
+ }
+ },
"node_modules/@types/tedious": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
@@ -7755,6 +7984,22 @@
"devOptional": true,
"license": "Python-2.0"
},
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/ast-kit": {
"version": "2.1.2",
"license": "MIT",
@@ -7833,6 +8078,21 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/b4a": {
"version": "1.6.7",
"license": "Apache-2.0"
@@ -8122,6 +8382,24 @@
"node": ">=8"
}
},
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"license": "MIT",
@@ -8865,6 +9143,12 @@
"node": ">= 12"
}
},
+ "node_modules/dayjs": {
+ "version": "1.11.19",
+ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
+ "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
+ "license": "MIT"
+ },
"node_modules/db0": {
"version": "0.3.2",
"license": "MIT",
@@ -8921,6 +9205,44 @@
"callsite": "^1.0.0"
}
},
+ "node_modules/deep-equal": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
+ "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.0",
+ "call-bind": "^1.0.5",
+ "es-get-iterator": "^1.1.3",
+ "get-intrinsic": "^1.2.2",
+ "is-arguments": "^1.1.1",
+ "is-array-buffer": "^3.0.2",
+ "is-date-object": "^1.0.5",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "isarray": "^2.0.5",
+ "object-is": "^1.1.5",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.4",
+ "regexp.prototype.flags": "^1.5.1",
+ "side-channel": "^1.0.4",
+ "which-boxed-primitive": "^1.0.2",
+ "which-collection": "^1.0.1",
+ "which-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/deep-equal/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -8959,6 +9281,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"license": "MIT",
@@ -8966,6 +9305,23 @@
"node": ">=8"
}
},
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/defu": {
"version": "6.1.4",
"license": "MIT"
@@ -9209,6 +9565,12 @@
"version": "0.1.2",
"license": "MIT"
},
+ "node_modules/earcut": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
+ "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
+ "license": "ISC"
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"license": "MIT"
@@ -9290,6 +9652,32 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-get-iterator": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
+ "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.3",
+ "has-symbols": "^1.0.3",
+ "is-arguments": "^1.1.1",
+ "is-map": "^2.0.2",
+ "is-set": "^2.0.2",
+ "is-string": "^1.0.7",
+ "isarray": "^2.0.5",
+ "stop-iteration-iterator": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-get-iterator/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "license": "MIT"
+ },
"node_modules/es-module-lexer": {
"version": "1.7.0",
"license": "MIT"
@@ -10381,6 +10769,21 @@
"version": "1.1.0",
"license": "MIT"
},
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/foreground-child": {
"version": "3.3.1",
"license": "ISC",
@@ -10450,6 +10853,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/fuse.js": {
"version": "7.1.0",
"license": "Apache-2.0",
@@ -10572,6 +10984,12 @@
"git-up": "^8.1.0"
}
},
+ "node_modules/gl-matrix": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
+ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
+ "license": "MIT"
+ },
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -10723,6 +11141,18 @@
"version": "1.2.2",
"license": "MIT"
},
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -10733,6 +11163,18 @@
"node": ">=8"
}
},
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"license": "MIT",
@@ -10743,6 +11185,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"license": "MIT",
@@ -10839,6 +11296,15 @@
"node": ">=16.17.0"
}
},
+ "node_modules/id-area-keys": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/id-area-keys/-/id-area-keys-6.5.0.tgz",
+ "integrity": "sha512-VY6yV+KtWwQFoOx+/GBKP9cWr+BcCdB0iEnp44Wt19+D32FMS9Sfmbl+JgpNABPbInJto+3Aj4XbzxQ9yo+Cfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"funding": [
@@ -10970,6 +11436,20 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/ioredis": {
"version": "5.7.0",
"license": "MIT",
@@ -10999,10 +11479,58 @@
"url": "https://github.com/sponsors/brc-dd"
}
},
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-arrayish": {
"version": "0.3.2",
"license": "MIT"
},
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"license": "MIT",
@@ -11013,6 +11541,22 @@
"node": ">=8"
}
},
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-builtin-module": {
"version": "3.2.1",
"license": "MIT",
@@ -11026,6 +11570,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-core-module": {
"version": "2.16.1",
"license": "MIT",
@@ -11039,6 +11595,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-docker": {
"version": "3.0.0",
"license": "MIT",
@@ -11106,6 +11678,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-module": {
"version": "1.0.0",
"license": "MIT"
@@ -11117,6 +11701,22 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-path-inside": {
"version": "4.0.0",
"license": "MIT",
@@ -11141,6 +11741,51 @@
"@types/estree": "*"
}
},
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-ssh": {
"version": "1.4.1",
"license": "MIT",
@@ -11158,6 +11803,39 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-url": {
"version": "1.2.4",
"license": "MIT"
@@ -11172,6 +11850,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/is-what": {
"version": "4.1.16",
"license": "MIT",
@@ -11348,6 +12054,12 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/json-stringify-pretty-compact": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
+ "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"license": "MIT",
@@ -11375,6 +12087,12 @@
"node": ">=18"
}
},
+ "node_modules/kdbush": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+ "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
+ "license": "ISC"
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -11701,6 +12419,44 @@
"source-map-js": "^1.2.0"
}
},
+ "node_modules/maplibre-gl": {
+ "version": "5.17.0",
+ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.17.0.tgz",
+ "integrity": "sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==",
+ "license": "BSD-3-Clause",
+ "peer": true,
+ "dependencies": {
+ "@mapbox/geojson-rewind": "^0.5.2",
+ "@mapbox/jsonlint-lines-primitives": "^2.0.2",
+ "@mapbox/point-geometry": "^1.1.0",
+ "@mapbox/tiny-sdf": "^2.0.7",
+ "@mapbox/unitbezier": "^0.0.1",
+ "@mapbox/vector-tile": "^2.0.4",
+ "@mapbox/whoots-js": "^3.1.0",
+ "@maplibre/geojson-vt": "^5.0.4",
+ "@maplibre/maplibre-gl-style-spec": "^24.4.1",
+ "@maplibre/mlt": "^1.1.2",
+ "@maplibre/vt-pbf": "^4.2.1",
+ "@types/geojson": "^7946.0.16",
+ "@types/supercluster": "^7.1.3",
+ "earcut": "^3.0.2",
+ "gl-matrix": "^3.4.4",
+ "kdbush": "^4.0.2",
+ "murmurhash-js": "^1.0.0",
+ "pbf": "^4.0.1",
+ "potpack": "^2.1.0",
+ "quickselect": "^3.0.0",
+ "supercluster": "^8.0.1",
+ "tinyqueue": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.14.0",
+ "npm": ">=8.1.0"
+ },
+ "funding": {
+ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"license": "MIT",
@@ -11904,6 +12660,12 @@
"version": "0.4.1",
"license": "MIT"
},
+ "node_modules/murmurhash-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
+ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
+ "license": "MIT"
+ },
"node_modules/nanoid": {
"version": "5.1.5",
"funding": [
@@ -12412,6 +13174,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/object-is": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/ofetch": {
"version": "1.4.1",
"license": "MIT",
@@ -12840,6 +13647,18 @@
"version": "2.0.3",
"license": "MIT"
},
+ "node_modules/pbf": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
+ "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/pend": {
"version": "1.2.0",
"license": "MIT"
@@ -12910,6 +13729,15 @@
"node": ">=4"
}
},
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"funding": [
@@ -13379,6 +14207,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/potpack": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
+ "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
+ "license": "ISC"
+ },
"node_modules/precinct": {
"version": "12.2.0",
"license": "MIT",
@@ -13464,6 +14298,12 @@
"node": ">= 6"
}
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
+ "license": "MIT"
+ },
"node_modules/protocols": {
"version": "2.0.2",
"license": "MIT"
@@ -13537,6 +14377,12 @@
],
"license": "MIT"
},
+ "node_modules/quickselect": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
+ "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
+ "license": "ISC"
+ },
"node_modules/quote-unquote": {
"version": "1.0.0",
"license": "MIT"
@@ -13691,6 +14537,26 @@
"regexp-tree": "bin/regexp-tree"
}
},
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/regjsparser": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz",
@@ -13784,6 +14650,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/reusify": {
"version": "1.1.0",
"license": "MIT",
@@ -13903,6 +14778,12 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"funding": [
@@ -13921,6 +14802,23 @@
],
"license": "MIT"
},
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"license": "MIT",
@@ -14066,6 +14964,38 @@
"node": ">= 18"
}
},
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"license": "ISC"
@@ -14313,6 +15243,19 @@
"version": "3.9.0",
"license": "MIT"
},
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/streamx": {
"version": "2.22.1",
"license": "MIT",
@@ -14481,6 +15424,15 @@
"postcss": "^8.4.32"
}
},
+ "node_modules/supercluster": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+ "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+ "license": "ISC",
+ "dependencies": {
+ "kdbush": "^4.0.2"
+ }
+ },
"node_modules/superjson": {
"version": "2.2.2",
"license": "MIT",
@@ -14737,6 +15689,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyqueue": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
+ "license": "ISC"
+ },
"node_modules/tmp": {
"version": "0.2.5",
"license": "MIT",
@@ -16111,6 +17069,64 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
+ "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/winston": {
"version": "3.17.0",
"license": "MIT",
diff --git a/package.json b/package.json
index 5e693a0..5578ed6 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,16 @@
"lint:fix": "eslint . --fix"
},
"dependencies": {
+ "@osmcha/maplibre-adiff-viewer": "^1.3.1",
+ "@osmcha/osmchange-parser": "^1.0.0",
"@sentry/integrations": "^7.114.0",
"@sentry/nuxt": "^10.31.0",
"@zip.js/zip.js": "^2.7.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"bootstrap": "^5.3.3",
+ "dayjs": "^1.11.19",
+ "maplibre-gl": "^5.10.0",
"nuxt": "^4.0.0",
"papaparse": "^5.5.1",
"vue": "^3.4.19",
diff --git a/pages/dashboard.vue b/pages/dashboard.vue
index 738db87..f4cc716 100644
--- a/pages/dashboard.vue
+++ b/pages/dashboard.vue
@@ -49,7 +49,7 @@ let lastWorkspaceId: number;
+
+
diff --git a/services/cache.ts b/services/cache.ts
new file mode 100644
index 0000000..218e002
--- /dev/null
+++ b/services/cache.ts
@@ -0,0 +1,168 @@
+export type ValidCacheKey = IDBValidKey | { [key: string]: IDBValidKey };
+
+export interface LocalCache {
+ get(key: TKey): Promise;
+ set(key: TKey, value: TValue): Promise;
+ evict(key: TKey): Promise;
+ prune(): Promise;
+}
+
+const CACHE_DB_VERSION = 1;
+const CACHE_STORE_NAME = 'cache';
+
+export class LruIndexedDbCache
+implements LocalCache {
+ readonly dbName: string;
+ readonly ttlMilliseconds: number;
+ readonly keyPath: IDBObjectStoreParameters['keyPath'];
+
+ #db?: IDBDatabase;
+ #openPromise?: Promise;
+
+ constructor(
+ dbName: string,
+ ttlMilliseconds: number,
+ keyPath: IDBObjectStoreParameters['keyPath'],
+ ) {
+ this.dbName = dbName;
+ this.ttlMilliseconds = ttlMilliseconds;
+ this.keyPath = keyPath;
+ }
+
+ get(key: TKey): Promise {
+ return new Promise((resolve, reject) => {
+ this.#tx((store) => {
+ const request = typeof key === 'object'
+ ? store.get(Object.values(key)) // composite key
+ : store.get(key); // simple key
+
+ request.onsuccess = () => {
+ const entry = request.result;
+
+ if (!entry) {
+ return resolve(undefined);
+ }
+
+ // This implementation doesn't enforce strict eviction on access.
+ // As in, we don't check for expiration here, and there is no max
+ // age setting (yet). This cache will return entries that expired
+ // between prune operations and even refresh the stale entry:
+ //
+ entry.lastAccessed = Date.now();
+ store.put(entry);
+ resolve(entry.value);
+ };
+
+ request.onerror = () => reject(request.error);
+ });
+ });
+ }
+
+ set(key: TKey, value: TValue): Promise {
+ return this.#tx((store) => {
+ if (typeof key === 'object') {
+ store.put({ ...key, value, lastAccessed: Date.now() }); // composite key
+ }
+ else {
+ store.put({ key, value, lastAccessed: Date.now() }); // simple key
+ }
+ });
+ }
+
+ evict(key: TKey): Promise {
+ return this.#tx((store) => {
+ if (typeof key === 'object') {
+ store.delete(Object.values(key)); // composite key
+ }
+ else {
+ store.delete(key); // simple key
+ }
+ });
+ }
+
+ prune(): Promise {
+ const expirationTime = Date.now() - this.ttlMilliseconds;
+ console.info(`Evicting entries in "${this.dbName}" < ${expirationTime}`);
+
+ return this.#tx((store) => {
+ const index = store.index('lastAccessed');
+ const request = index.openCursor(IDBKeyRange.upperBound(expirationTime));
+
+ request.onsuccess = () => {
+ const cursor = request.result;
+
+ if (cursor) {
+ cursor.delete();
+ cursor.continue();
+ }
+ };
+ });
+ }
+
+ async #open(): Promise {
+ if (this.#db) {
+ return this.#db;
+ }
+ if (this.#openPromise) {
+ return this.#openPromise;
+ }
+
+ this.#openPromise = new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, CACHE_DB_VERSION);
+
+ request.onupgradeneeded = () => {
+ this.#upgradeDb(request.result);
+ };
+ request.onerror = () => {
+ reject(request.error);
+ };
+
+ request.onsuccess = () => {
+ this.#db = request.result;
+ this.#db.onversionchange = () => {
+ // Close the DB if it opens in another tab with a different version:
+ this.#db?.close();
+ };
+
+ resolve(this.#db);
+ };
+ });
+
+ try {
+ return await this.#openPromise;
+ }
+ finally {
+ this.#openPromise = undefined;
+ }
+ }
+
+ async #tx(callback: (store: IDBObjectStore) => void): Promise {
+ const db = await this.#open();
+
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(CACHE_STORE_NAME, 'readwrite');
+ tx.oncomplete = () => {
+ resolve();
+ };
+ tx.onabort = () => {
+ reject(tx.error);
+ };
+ tx.onerror = () => {
+ reject(tx.error);
+ };
+
+ callback(tx.objectStore(CACHE_STORE_NAME));
+ });
+ }
+
+ #upgradeDb(db: IDBDatabase) {
+ console.info(`Upgrading ${this.dbName} to version ${CACHE_DB_VERSION}`);
+
+ if (!db.objectStoreNames.contains(CACHE_STORE_NAME)) {
+ const store = db.createObjectStore(CACHE_STORE_NAME, {
+ keyPath: this.keyPath,
+ });
+ store.createIndex('lastAccessed', 'lastAccessed');
+ }
+ }
+}
diff --git a/services/changesets.ts b/services/changesets.ts
new file mode 100644
index 0000000..49d6c1e
--- /dev/null
+++ b/services/changesets.ts
@@ -0,0 +1,449 @@
+import type { OsmApiClient } from '~/services/osm';
+import { OSMCHANGE_ACTION_TYPES } from '~/types/osm';
+import { LruIndexedDbCache } from '~/services/cache';
+
+import type { LocalCache } from '~/services/cache';
+import type {
+ OsmChange,
+ OsmChangeset,
+ OsmElement,
+ OsmNode,
+ OsmElementType,
+} from '~/types/osm';
+import type {
+ AdiffAction,
+ AdiffElement,
+ AdiffNode,
+ AdiffWay,
+ AdiffWayNodeRef,
+ AugmentedDiff,
+} from '~/types/adiff';
+import type { WorkspaceId } from '~/types/workspaces';
+
+const ADIFF_ACTION_TYPES = OSMCHANGE_ACTION_TYPES;
+
+export type OsmChangeCacheKey = { workspaceId: WorkspaceId; changesetId: number };
+export const OsmChangeDb = LruIndexedDbCache;
+export type AugmentedDiffCacheKey = { workspaceId: WorkspaceId; changesetId: number };
+export const AugmentedDiffDb = LruIndexedDbCache;
+
+export class OsmChangeCache {
+ readonly #cache: LocalCache;
+
+ constructor(cache: LocalCache) {
+ this.#cache = cache;
+ }
+
+ async get(workspaceId: WorkspaceId, changesetId: number): Promise {
+ const osmChange = await this.#cache.get({ workspaceId, changesetId });
+
+ if (osmChange) {
+ for (const type of OSMCHANGE_ACTION_TYPES) {
+ for (const element of osmChange[type] ?? []) {
+ element.timestamp = new Date(element.timestamp);
+ }
+ }
+
+ return osmChange;
+ }
+ }
+
+ set(workspaceId: WorkspaceId, changesetId: number, osmChange: OsmChange): Promise {
+ return this.#cache.set({ workspaceId, changesetId }, osmChange);
+ }
+
+ prune(): Promise {
+ return this.#cache.prune();
+ }
+}
+
+export class AugmentedDiffCache {
+ readonly #cache: LocalCache;
+
+ constructor(cache: LocalCache) {
+ this.#cache = cache;
+ }
+
+ async get(workspaceId: WorkspaceId, changesetId: number): Promise {
+ const adiff = await this.#cache.get({ workspaceId, changesetId });
+
+ if (adiff) {
+ return adiff;
+ }
+ }
+
+ set(workspaceId: WorkspaceId, changesetId: number, adiff: AugmentedDiff) {
+ return this.#cache.set({ workspaceId, changesetId }, adiff);
+ }
+
+ prune(): Promise {
+ return this.#cache.prune();
+ }
+}
+
+export class ChangesetManager {
+ readonly #osmClient: OsmApiClient;
+ readonly #oscCache: OsmChangeCache;
+ readonly #adiffCache: AugmentedDiffCache; ;
+
+ constructor(
+ osmClient: OsmApiClient,
+ oscCache: OsmChangeCache,
+ adiffCache: AugmentedDiffCache,
+ ) {
+ this.#osmClient = osmClient;
+ this.#oscCache = oscCache;
+ this.#adiffCache = adiffCache;
+
+ this.pruneCache();
+ }
+
+ async getFullChangesets(workspaceId: WorkspaceId): Promise {
+ const changesets = await this.#osmClient.listChangesets(workspaceId);
+ const promises = [];
+
+ for (const changeset of changesets) {
+ promises.push(
+ this.getOsc(workspaceId, changeset).then(c => changeset.osmChange = c),
+ );
+ }
+
+ await Promise.all(promises);
+
+ return changesets;
+ }
+
+ async pruneCache() {
+ const executeCallback: (f: () => void) => void = window.requestIdleCallback
+ ?? ((f: () => void) => f());
+ executeCallback(() => {
+ this.#oscCache.prune();
+ });
+ executeCallback(() => {
+ this.#adiffCache.prune();
+ });
+ }
+
+ async getOsc(workspaceId: WorkspaceId, changeset: OsmChangeset): Promise {
+ if (changeset.open) {
+ // Skip the cache--someone may add more to this changeset:
+ return await this.#osmClient.getOsmChange(workspaceId, changeset.id);
+ }
+
+ let osc = await this.#oscCache.get(workspaceId, changeset.id);
+
+ if (!osc) {
+ osc = await this.#osmClient.getOsmChange(workspaceId, changeset.id);
+ await this.#oscCache.set(workspaceId, changeset.id, osc);
+ }
+
+ return osc;
+ }
+
+ async getAdiff(workspaceId: WorkspaceId, changeset: OsmChangeset): Promise {
+ if (changeset.open) {
+ // Skip the cache--someone may add more to this changeset:
+ return await this.#buildAdiff(workspaceId, changeset.osmChange);
+ }
+
+ let adiff = await this.#adiffCache.get(workspaceId, changeset.id);
+
+ if (!adiff) {
+ adiff = await this.#buildAdiff(workspaceId, changeset.osmChange);
+ await this.#adiffCache.set(workspaceId, changeset.id, adiff);
+ }
+
+ return adiff;
+ }
+
+ async #buildAdiff(workspaceId: WorkspaceId, osmChange: OsmChange): Promise {
+ const builder = new ChangesetAdiffBuilder(this.#osmClient, workspaceId);
+
+ return await builder.build(osmChange);
+ }
+}
+
+function isNode(element: AdiffElement): element is AdiffNode {
+ return element.type === 'node';
+}
+
+function isWay(element: AdiffElement): element is AdiffWay {
+ return element.type === 'way';
+}
+
+function osmToAdiff(element: OsmElement): AdiffElement {
+ const adiff: AdiffElement = {
+ ...(element as AdiffElement),
+ visible: element.visible ?? true,
+ tags: { ...element.tags },
+ };
+
+ if ('nodes' in adiff && 'nodes' in element) {
+ adiff.nodes = element.nodes?.map(ref => ({ ref, lat: 0, lon: 0 }));
+ }
+
+ return adiff;
+}
+
+function nodeToRef(node: OsmNode): AdiffWayNodeRef {
+ return { ref: node.id, lat: node.lat, lon: node.lon };
+}
+
+function splitIdVersion(idVersion: string): [number, number] {
+ const idVersionParts = idVersion.split('v');
+ const id = Number(idVersionParts[0]);
+ const version = Number(idVersionParts[1]);
+
+ return [id, version];
+}
+
+type OsmElementVersionMap = Map;
+type OsmElementMap = Map;
+type OsmTypeMap = {
+ [K in OsmElementType]: {
+ fromChangesetIds: Set;
+ versions: OsmElementMap;
+ }
+};
+
+class AdiffElementCache {
+ readonly #typeMap: OsmTypeMap;
+
+ constructor() {
+ this.#typeMap = {
+ node: { fromChangesetIds: new Set(), versions: new Map() },
+ way: { fromChangesetIds: new Set(), versions: new Map() },
+ relation: { fromChangesetIds: new Set(), versions: new Map() },
+ };
+ }
+
+ remember(element: OsmElement, fromChangeset: boolean = false) {
+ this.getVersions(element.type, element.id).set(element.version, element);
+
+ if (fromChangeset) {
+ this.#typeMap[element.type].fromChangesetIds.add(element.id);
+ }
+ }
+
+ getVersions(type: OsmElementType, id: number): OsmElementVersionMap {
+ let versionMap = this.#typeMap[type].versions.get(id);
+
+ if (!versionMap) {
+ versionMap = new Map();
+ this.#typeMap[type].versions.set(id, versionMap);
+ }
+
+ return versionMap;
+ }
+
+ getVersion(
+ type: T['type'],
+ id: number,
+ version: number,
+ ): T | undefined {
+ return this.#typeMap[type].versions.get(id)?.get(version) as T;
+ }
+
+ getLatestVersion(type: T['type'], id: number): T | undefined {
+ const versionMap = this.#typeMap[type].versions.get(id);
+
+ if (!versionMap) {
+ return undefined;
+ }
+
+ return versionMap.get(Math.max(...versionMap.keys())) as T;
+ }
+
+ isInChangeset(type: OsmElementType, id: number): boolean {
+ return this.#typeMap[type].fromChangesetIds.has(id);
+ }
+}
+
+class ChangesetAdiffBuilder {
+ readonly #osmClient: OsmApiClient;
+ readonly #workspaceId: WorkspaceId;
+ readonly #cache: AdiffElementCache;
+ readonly #actions: AdiffAction[];
+
+ constructor(osmClient: OsmApiClient, workspaceId: WorkspaceId) {
+ this.#osmClient = osmClient;
+ this.#workspaceId = workspaceId;
+ this.#cache = new AdiffElementCache();
+ this.#actions = [];
+ }
+
+ async build(osmChange: OsmChange): Promise {
+ const actions: AdiffAction[] = [];
+ const promises = [];
+
+ for (const type of ADIFF_ACTION_TYPES) {
+ for (const element of osmChange[type] ?? []) {
+ actions.push({ type, old: undefined, new: osmToAdiff(element) });
+ this.#cache.remember(element, true);
+ }
+ }
+
+ for (const action of actions) {
+ this.#actions.push(action);
+
+ if (action.type !== 'create') {
+ promises.push(
+ this.#getPreviousElement(action.new).then(e => action.old = e),
+ );
+ }
+
+ promises.push(this.#expandReferences(action));
+ }
+
+ await Promise.all(promises);
+
+ return { actions: this.#actions };
+ }
+
+ #expandReferences(action: AdiffAction): Promise {
+ if (isNode(action.new)) {
+ return this.#expandNodeReferences(action.new);
+ }
+
+ return this.#expandWayReferences(action.new);
+ }
+
+ async #expandNodeReferences(node: AdiffNode): Promise {
+ const ways = await this.#fetchWays(node);
+
+ for (const way of ways) {
+ if (this.#cache.isInChangeset('way', way.id)) {
+ continue;
+ }
+
+ const old = { ...way, nodes: [...way.nodes ?? []], tags: { ...way.tags } };
+ const action: AdiffAction = { type: 'modify', old, new: way };
+
+ while (action.old && action.old.changeset > node.changeset) {
+ action.old = await this.#getPreviousElement(action.old);
+ }
+
+ if (action.old === old) {
+ await this.#resolveWayNodes(old, old.changeset);
+ }
+
+ this.#actions.push(action);
+ }
+ }
+
+ async #expandWayReferences(way: AdiffWay): Promise {
+ await this.#resolveWayNodes(way, way.changeset);
+ }
+
+ async #resolveWayNodes(element: AdiffWay, changesetId: number) {
+ const resolvedNodes: AdiffWayNodeRef[] = [];
+ let nodeIds: (string | number)[] = element.nodes?.map(r => r.ref) ?? [];
+
+ while (nodeIds.length > 0) {
+ const refs = await this.#fetchNodes(nodeIds);
+ nodeIds = [];
+
+ for (const node of refs) {
+ if (node.changeset <= changesetId) {
+ resolvedNodes.push(nodeToRef(node));
+ }
+ else {
+ nodeIds.push(`${node.id}v${node.version - 1}`);
+ }
+ }
+ }
+
+ element.nodes = resolvedNodes;
+ }
+
+ async #fetchNodes(nodeIds: (string | number)[]): Promise {
+ const refs: OsmNode[] = [];
+ const pendingIds = [];
+
+ for (const nodeId of nodeIds) {
+ if (Number.isFinite(nodeId)) {
+ const node = this.#cache.getLatestVersion('node', nodeId as number);
+
+ if (node) {
+ refs.push(node);
+ }
+ else {
+ pendingIds.push(nodeId);
+ }
+ }
+ else {
+ const [id, version] = splitIdVersion(nodeId as string);
+ const node = this.#cache.getVersion('node', id, version);
+
+ if (node) {
+ refs.push(node);
+ }
+ else {
+ pendingIds.push(nodeId);
+ }
+ }
+ }
+
+ if (pendingIds.length > 0) {
+ const nodes = await this.#osmClient.getNodes(this.#workspaceId, pendingIds);
+
+ for (const node of nodes) {
+ refs.push(node);
+ this.#cache.remember(node);
+ }
+ }
+
+ return refs;
+ }
+
+ async #fetchWays(node: AdiffElement): Promise {
+ let ways = await this.#osmClient.getWaysForNode(this.#workspaceId, node.id);
+ const resolvedWays = [];
+
+ while (ways.length > 0) {
+ const pendingWayIds = [];
+
+ for (const way of ways) {
+ if (way.changeset <= node.changeset) {
+ const adiffWay = osmToAdiff(way) as AdiffWay;
+ await this.#resolveWayNodes(adiffWay, node.changeset);
+ resolvedWays.push(adiffWay);
+ }
+ else {
+ pendingWayIds.push(`${way.id}v${way.version - 1}`);
+ }
+ }
+
+ if (pendingWayIds.length > 0) {
+ ways = await this.#osmClient.getWays(this.#workspaceId, pendingWayIds);
+ }
+ else {
+ ways.length = 0;
+ }
+ }
+
+ return resolvedWays;
+ }
+
+ async #getPreviousElement(element: AdiffElement): Promise {
+ const version = element.version - 1;
+ let prevElement = this.#cache.getVersion(element.type, element.id, version);
+
+ if (!prevElement) {
+ prevElement = await this.#osmClient.getElement(
+ this.#workspaceId,
+ element.type,
+ element.id,
+ version);
+ this.#cache.remember(prevElement);
+ }
+
+ const adiffElement = osmToAdiff(prevElement);
+
+ if (isWay(adiffElement)) {
+ await this.#resolveWayNodes(adiffElement, element.changeset - 1);
+ }
+
+ return adiffElement;
+ }
+}
diff --git a/services/index.ts b/services/index.ts
index 29fd560..c298ef7 100644
--- a/services/index.ts
+++ b/services/index.ts
@@ -1,7 +1,15 @@
import { reactive } from 'vue';
import { TdeiAuthStore, TdeiClient, TdeiUserClient } from '~/services/tdei';
+import {
+ AugmentedDiffCache,
+ AugmentedDiffDb,
+ OsmChangeCache,
+ OsmChangeDb,
+ ChangesetManager,
+} from '~/services/changesets';
import { OsmApiClient } from '~/services/osm';
import { PathwaysEditorManager } from '~/services/pathways';
+import { ReviewManager } from '~/services/review';
import { RapidManager } from '~/services/rapid';
import { WorkspacesClient } from '~/services/workspaces';
@@ -21,5 +29,24 @@ tdeiClient.restartAutoAuthRefresh();
export const osmClient = new OsmApiClient(osmWebUrl, osmApiUrl, tdeiClient);
export const workspacesClient = new WorkspacesClient(apiUrl, tdeiClient, osmClient);
+const oscCacheTtl = 1000 * 60 * 60 * 24 * 45; // 45 days
+const adiffCacheTtl = oscCacheTtl;
+const oscKeypath = ['workspaceId', 'changesetId'];
+const adiffKeypath = oscKeypath;
+
+const oscCacheDb = new OsmChangeDb('osc-cache', oscCacheTtl, oscKeypath);
+const adiffCacheDb = new AugmentedDiffDb('adiff-cache', adiffCacheTtl, adiffKeypath);
+
+export const changesetManager = new ChangesetManager(
+ osmClient,
+ new OsmChangeCache(oscCacheDb),
+ new AugmentedDiffCache(adiffCacheDb));
+export const reviewManager = new ReviewManager(
+ changesetManager,
+ osmClient,
+ tdeiClient,
+ workspacesClient,
+);
+
export const rapidManager = new RapidManager(rapidUrl, osmWebUrl, tdeiAuth);
export const pathwaysManager = new PathwaysEditorManager(pathwaysUrl, osmWebUrl, tdeiAuth);
diff --git a/services/osm.ts b/services/osm.ts
index 7b3bf02..91ef12d 100644
--- a/services/osm.ts
+++ b/services/osm.ts
@@ -1,7 +1,23 @@
+import parseOsmChangeXml from '@osmcha/osmchange-parser';
+import type { FeatureCollection, Point } from 'geojson';
+
import { BaseHttpClient, BaseHttpClientError } from "~/services/http";
+import * as xml from '~/util/xml';
+
import type { ICancelableClient } from '~/services/loading';
import type { TdeiClient } from '~/services/tdei';
-import * as xml from '~/util/xml';
+import { OSMCHANGE_ACTION_TYPES } from '~/types/osm';
+import type {
+ OsmChange,
+ OsmChangeset,
+ OsmChangesetComment,
+ OsmElement,
+ OsmNode,
+ OsmNote,
+ OsmTags,
+ OsmWay,
+} from '~/types/osm';
+import type { WorkspaceId } from '~/types/workspaces';
function formatFeatureIdPlaceholder(attributeName: string, feature: Element) {
const id = feature.getAttribute(attributeName);
@@ -36,6 +52,30 @@ function formatFeatureIdPlaceholders(feature: Element) {
}
}
+function notesGeoJsonToEntities(geoJson: FeatureCollection): OsmNote[] {
+ const notes = [];
+
+ for (const feature of geoJson.features) {
+ const geometry = feature.geometry as Point;
+ const properties = feature.properties ?? { };
+
+ for (const comment of properties.comments) {
+ comment.date = new Date(comment.date);
+ }
+
+ notes.push({
+ id: properties.id,
+ status: properties.status,
+ lat: geometry.coordinates[1] ?? 0,
+ lon: geometry.coordinates[0] ?? 0,
+ created_at: new Date(properties.date_created),
+ comments: properties.comments,
+ });
+ }
+
+ return notes;
+}
+
function cleanOscForDemo(features: Element[]) {
const nodeIds = new Set();
const ways = [];
@@ -207,8 +247,8 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient {
await this._delete(`workspaces/${workspaceId}`);
}
- async getWorkspaceBbox(id: number) {
- const response = await this._get(`workspaces/${id}/bbox.json`);
+ async getWorkspaceBbox(workspaceId: number) {
+ const response = await this._get(`workspaces/${workspaceId}/bbox.json`);
if (response.status === 204) {
return undefined
@@ -238,7 +278,147 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient {
return `${bbox.min_lon},${bbox.min_lat},${bbox.max_lon + pad},${bbox.max_lat + pad}`;
}
- async createChangeset(workspaceId: number): number {
+ async getElement(
+ workspaceId: WorkspaceId,
+ type: string,
+ id: number,
+ version: number,
+ ): Promise {
+ const response = await this._get(`${type}/${id}/${version}`, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/json',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ const element = (await response.json()).elements[0];
+ element.tags = element.tags ?? { };
+
+ return element;
+ }
+
+ async getNodes(workspaceId: WorkspaceId, nodeIds: (number | string)[]): Promise {
+ const response = await this._get(`nodes?nodes=${nodeIds.join(',')}`, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/json',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ const nodes = (await response.json()).elements;
+
+ for (const node of nodes) {
+ node.timestamp = new Date(node.timestamp);
+ }
+
+ return nodes;
+ }
+
+ async getWays(workspaceId: WorkspaceId, wayIds: (number | string)[]): Promise {
+ const response = await this._get(`ways?ways=${wayIds.join(',')}`, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/json',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ const ways = (await response.json()).elements;
+
+ for (const way of ways) {
+ way.timestamp = new Date(way.timestamp);
+ }
+
+ return ways;
+ }
+
+ async getWaysForNode(workspaceId: WorkspaceId, nodeId: number): Promise {
+ const response = await this._get(`node/${nodeId}/ways`, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/json',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ return (await response.json()).elements;
+ }
+
+ async listChangesets(workspaceId: WorkspaceId): Promise {
+ const response = await this._get(`changesets.json`, {
+ headers: { ...this._requestHeaders, 'X-Workspace': workspaceId },
+ });
+
+ const changesets = (await response.json())?.changesets ?? [];
+
+ for (const changeset of changesets) {
+ changeset.created_at = new Date(changeset.created_at);
+ changeset.closed_at = new Date(changeset.closed_at);
+ }
+
+ return changesets;
+ }
+
+ async getChangeset(
+ workspaceId: WorkspaceId,
+ changesetId: number,
+ includeDiscussion: boolean = false,
+ ): Promise {
+ let url = `changeset/${changesetId}.json`;
+
+ if (includeDiscussion) {
+ url += '?include_discussion=true';
+ }
+
+ const response = await this._get(url, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/json',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ const changeset = (await response.json())?.changeset;
+
+ if (!changeset) {
+ return;
+ }
+
+ changeset.created_at = new Date(changeset.created_at);
+ changeset.closed_at = new Date(changeset.closed_at);
+
+ for (const comment of changeset.comments ?? []) {
+ comment.date = new Date(comment.date);
+ }
+
+ return changeset;
+ }
+
+ async getOsmChange(workspaceId: WorkspaceId, changesetId: number)
+ : Promise
+ {
+ const response = await this._get(`changeset/${changesetId}/download`, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/xml',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ const osmChange = parseOsmChangeXml(await response.text());
+
+ for (const type of OSMCHANGE_ACTION_TYPES) {
+ for (const element of osmChange[type] ?? []) {
+ element.timestamp = new Date(element.timestamp);
+ }
+ }
+
+ return osmChange;
+ }
+
+ async createChangeset(workspaceId: WorkspaceId): Promise {
const doc = xml.parse('');
const changesetNode = doc.firstChild.firstChild;
changesetNode.appendChild(xml.makeNode(doc, "tag", { k: 'workspace', v: workspaceId }));
@@ -247,7 +427,7 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient {
const body = xml.serialize(doc);
const response = await this._put('changeset/create', body, {
- headers: { ...this._requestHeaders, 'X-Workspace': workspaceId }
+ headers: { ...this._requestHeaders, 'X-Workspace': workspaceId },
});
return Number(await response.text());
@@ -258,11 +438,57 @@ export class OsmApiClient extends BaseHttpClient implements ICancelableClient {
headers: {
'Content-Type': 'application/xml',
'Authorization': this._requestHeaders['Authorization'],
- 'X-Workspace': workspaceId
- }
+ 'X-Workspace': workspaceId,
+ },
});
}
+ async getChangesetComments(
+ workspaceId: WorkspaceId,
+ changesetId: number,
+ ): Promise {
+ // There is no OSM API that returns comments directly. We must request the
+ // comments with the changeset:
+ //
+ const changeset = await this.getChangeset(workspaceId, changesetId, true);
+
+ return changeset?.comments ?? [];
+ }
+
+ async postChangesetComment(
+ workspaceId: WorkspaceId,
+ changesetId: number,
+ message: string,
+ ): Promise {
+ const body = new FormData();
+ body.append('text', message);
+
+ await this._post(`changeset/${changesetId}/comment`, body, {
+ headers: {
+ 'Authorization': this._requestHeaders['Authorization'],
+ 'X-Workspace': workspaceId,
+ },
+ });
+ }
+
+ async getNotes(workspaceId: WorkspaceId, includeClosed: boolean): Promise {
+ const params = new URLSearchParams();
+ // Fetch the maximum number of notes:
+ params.append('limit', '10000');
+ // -1: all, 0: open only, > 0: days closed:
+ params.append('closed', includeClosed ? '-1' : '0');
+
+ const response = await this._get(`notes/search.json?${params}`, {
+ headers: {
+ ...this._requestHeaders,
+ 'Accept': 'application/json',
+ 'X-Workspace': workspaceId,
+ },
+ });
+
+ return notesGeoJsonToEntities(await response.json());
+ }
+
async getWorkspaceData(workspaceId: number): Promise {
const bboxParam = await this.getExportBbox(workspaceId);
const response = await this._get(`map.json?bbox=${bboxParam}`, {
diff --git a/services/review.ts b/services/review.ts
new file mode 100644
index 0000000..d5634bb
--- /dev/null
+++ b/services/review.ts
@@ -0,0 +1,325 @@
+import type { ChangesetManager } from '~/services/changesets';
+import type { OsmApiClient } from '~/services/osm';
+import type { TdeiClient } from '~/services/tdei';
+import type { WorkspacesClient } from '~/services/workspaces';
+import type { OsmChange, OsmChangeset, OsmNote } from '~/types/osm';
+import type { TdeiFeedback } from '~/types/tdei';
+import type { Workspace } from '~/types/workspaces';
+
+export const $CHANGESET = Symbol('changeset');
+export const $FEEDBACK = Symbol('feedback');
+export const $NOTE = Symbol('note');
+
+export type ReviewItemData = OsmChangeset | OsmNote | TdeiFeedback;
+
+export class ReviewListItem {
+ readonly type: symbol;
+ readonly key: symbol;
+ readonly data: ReviewItemData;
+
+ loadingChangeset: boolean = false;
+ oscPromise?: Promise;
+
+ constructor(type: symbol, data: ReviewItemData) {
+ this.type = type;
+ this.key = Symbol();
+ this.data = data;
+ }
+
+ get id(): number {
+ return this.data.id;
+ }
+
+ get isChangeset(): boolean {
+ return this.type === $CHANGESET;
+ }
+
+ get isFeedback(): boolean {
+ return this.type === $FEEDBACK;
+ }
+
+ get isNote(): boolean {
+ return this.type === $NOTE;
+ }
+
+ get isResolved(): boolean {
+ switch (this.type) {
+ case $FEEDBACK:
+ return (this.data as TdeiFeedback).status !== 'open';
+ case $NOTE:
+ return (this.data as OsmNote).status !== 'open';
+ }
+
+ return false;
+ }
+
+ get displayType(): string {
+ switch (this.type) {
+ case $CHANGESET:
+ return 'Changeset';
+ case $FEEDBACK:
+ return 'Feedback';
+ case $NOTE:
+ return 'Note';
+ }
+
+ return 'Unknown';
+ }
+
+ get title(): string | undefined {
+ switch (this.type) {
+ case $CHANGESET:
+ return (this.data as OsmChangeset).tags?.comment || '(no comment)';
+ case $FEEDBACK:
+ return (this.data as TdeiFeedback).feedback_text;
+ case $NOTE:
+ return (this.data as OsmNote).comments[0]?.text;
+ }
+
+ return undefined;
+ }
+
+ get displayName(): string {
+ switch (this.type) {
+ case $CHANGESET:
+ return (this.data as OsmChangeset).user;
+ case $FEEDBACK:
+ return (this.data as TdeiFeedback).customer_email;
+ case $NOTE:
+ return (this.data as OsmNote).comments[0]?.user ?? 'Anonymous';
+ }
+
+ return 'Unknown';
+ }
+
+ get feedbackOverdue(): boolean {
+ return this.isFeedback
+ && (this.data as TdeiFeedback).status === 'open'
+ && (this.data as TdeiFeedback).due_date < new Date();
+ }
+
+ get hasComments(): boolean {
+ return this.commentCount > 0;
+ }
+
+ get commentCount(): number {
+ switch (this.type) {
+ case $CHANGESET:
+ return (this.data as OsmChangeset).comments_count;
+ case $NOTE:
+ return (this.data as OsmNote).comments.length - 1;
+ }
+
+ return 0;
+ }
+
+ get badgeClass(): string {
+ switch (this.type) {
+ case $CHANGESET:
+ return 'bg-dark';
+ case $FEEDBACK:
+ return 'bg-danger';
+ case $NOTE:
+ return 'bg-info';
+ }
+
+ return 'bg-secondary';
+ }
+
+ get badgeIcon(): string {
+ return this.type === $CHANGESET ? 'commit' : 'chat_bubble';
+ }
+
+ get changesetCounts() {
+ return {
+ create: (this.data as OsmChangeset).osmChange?.create?.length ?? 0,
+ modify: (this.data as OsmChangeset).osmChange?.modify?.length ?? 0,
+ delete: (this.data as OsmChangeset).osmChange?.delete?.length ?? 0,
+ };
+ }
+
+ async awaitOsmChange(promise: Promise): Promise {
+ this.loadingChangeset = true;
+ this.oscPromise = promise;
+ (this.data as OsmChangeset).osmChange = await promise;
+ this.loadingChangeset = false;
+ }
+}
+
+export class ReviewListFilter {
+ includeChangesets: boolean = true;
+ includeFeedback: boolean = true;
+ includeNotes: boolean = true;
+ includeResolved: boolean = false;
+ startDate: Date | undefined;
+ endDate: Date | undefined;
+ text: string | undefined;
+
+ testDates(item: ReviewItemData): boolean {
+ if (this.startDate && item.created_at < this.startDate) {
+ return false;
+ }
+ if (this.endDate && item.created_at > this.endDate) {
+ return false;
+ }
+
+ return true;
+ }
+
+ testChangeset(changeset: OsmChangeset) {
+ if (!this.testDates(changeset)) {
+ return false;
+ }
+ if (this.text && !changeset.tags?.comment?.includes(this.text)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ testFeedback(feedback: TdeiFeedback) {
+ if (!this.testDates(feedback)) {
+ return false;
+ }
+ if (this.text && !feedback.feedback_text.includes(this.text)) {
+ return false;
+ }
+ if (!this.includeResolved && feedback.status !== 'open') {
+ return false;
+ }
+
+ return true;
+ }
+
+ testNote(note: OsmNote) {
+ if (!this.testDates(note)) {
+ return false;
+ }
+ if (this.text && !note.comments[0]?.text.includes(this.text)) {
+ return false;
+ }
+
+ return true;
+ }
+}
+
+function compareCreatedAtDesc(a: ReviewListItem, b: ReviewListItem) {
+ return b.data.created_at.getTime() - a.data.created_at.getTime();
+}
+
+export class ReviewList {
+ readonly #changesets: ChangesetManager;
+ readonly #osmClient: OsmApiClient;
+ readonly #tdeiClient: TdeiClient;
+ readonly #workspacesClient: WorkspacesClient;
+
+ readonly workspaceId: number;
+ readonly items: ReviewListItem[] = [];
+
+ workspace?: Workspace;
+
+ constructor(
+ changesets: ChangesetManager,
+ osmClient: OsmApiClient,
+ tdeiClient: TdeiClient,
+ workspacesClient: WorkspacesClient,
+ workspaceId: number,
+ ) {
+ this.#changesets = changesets;
+ this.#osmClient = osmClient;
+ this.#tdeiClient = tdeiClient;
+ this.#workspacesClient = workspacesClient;
+ this.workspaceId = workspaceId;
+ }
+
+ async refresh(filter: ReviewListFilter) {
+ this.items.length = 0;
+ const [changesets, feedback, notes] = await this.#fetchLists(filter);
+
+ for (const changeset of changesets) {
+ if (filter.testChangeset(changeset)) {
+ this.items.push(new ReviewListItem($CHANGESET, changeset));
+ }
+ }
+
+ for (const submission of feedback) {
+ if (filter.testFeedback(submission)) {
+ this.items.push(new ReviewListItem($FEEDBACK, submission));
+ }
+ }
+
+ for (const note of notes) {
+ if (filter.testNote(note)) {
+ this.items.push(new ReviewListItem($NOTE, note));
+ }
+ }
+
+ this.items.sort(compareCreatedAtDesc);
+ }
+
+ async loadOsmChange(item: ReviewListItem) {
+ if (!item.isChangeset) {
+ return;
+ }
+
+ const changeset = item.data as OsmChangeset;
+ item.awaitOsmChange(this.#changesets.getOsc(this.workspaceId, changeset));
+ }
+
+ async #fetchLists(filter: ReviewListFilter) {
+ const changesetPromise = filter.includeChangesets
+ ? this.#osmClient.listChangesets(this.workspaceId)
+ : Promise.resolve([]);
+ const notePromise = filter.includeNotes
+ ? this.#osmClient.getNotes(this.workspaceId, filter.includeResolved)
+ : Promise.resolve([]);
+
+ if (!this.workspace) {
+ this.workspace = await this.#workspacesClient.getWorkspace(this.workspaceId);
+ }
+
+ const feedbackPromise = filter.includeFeedback && this.workspace?.tdeiRecordId
+ ? this.#tdeiClient.getDatasetFeedback(this.workspace.tdeiRecordId, filter.includeResolved)
+ : Promise.resolve([]);
+
+ return await Promise.all([
+ changesetPromise,
+ feedbackPromise,
+ notePromise,
+ ]);
+ }
+}
+
+export class ReviewManager {
+ readonly #changesets: ChangesetManager;
+ readonly #osmClient: OsmApiClient;
+ readonly #tdeiClient: TdeiClient;
+ readonly #workspacesClient: WorkspacesClient;
+
+ constructor(
+ changesets: ChangesetManager,
+ osmClient: OsmApiClient,
+ tdeiClient: TdeiClient,
+ workspacesClient: WorkspacesClient,
+ ) {
+ this.#changesets = changesets;
+ this.#osmClient = osmClient;
+ this.#tdeiClient = tdeiClient;
+ this.#workspacesClient = workspacesClient;
+ }
+
+ getList(workspaceId: number): ReviewList {
+ return new ReviewList(
+ this.#changesets,
+ this.#osmClient,
+ this.#tdeiClient,
+ this.#workspacesClient,
+ workspaceId,
+ );
+ }
+
+ getFilter(): ReviewListFilter {
+ // TODO: enable saving/loading filters
+ return new ReviewListFilter();
+ }
+}
diff --git a/services/tdei.ts b/services/tdei.ts
index 09895d3..6f0535d 100644
--- a/services/tdei.ts
+++ b/services/tdei.ts
@@ -2,6 +2,7 @@ import { BlobReader, BlobWriter, ZipReader } from '@zip.js/zip.js';
import { BaseHttpClient, BaseHttpClientError } from '~/services/http';
import type { ICancelableClient } from '~/services/loading';
+import type { TdeiFeedback } from '~/types/tdei.ts';
const MIN_TOKEN_REFRESH_MS = 10 * 1000;
@@ -343,6 +344,39 @@ export class TdeiClient extends BaseHttpClient implements ICancelableClient {
return await fileResponse.blob();
}
+ async getDatasetFeedback(
+ tdeiDatasetId: string,
+ showResolved: boolean = false,
+ ): Promise {
+ const feedback = [];
+ const pageSize = 50;
+ const pageNum = 1;
+ let items;
+
+ const params = new URLSearchParams();
+ params.append('tdei_dataset_id', tdeiDatasetId);
+ params.append('page_size', pageSize.toString());
+
+ if (!showResolved) {
+ params.append('status', 'open');
+ }
+
+ do {
+ params.set('page_no', pageNum.toString());
+ const response = await this._get(`osw/dataset-viewer/feedbacks?${params}`);
+ items = (await response.json()) ?? [];
+
+ for (const submission of items) {
+ submission.created_at = new Date(submission.created_at);
+ submission.updated_at = new Date(submission.updated_at);
+ submission.due_date = new Date(submission.due_date);
+ feedback.push(submission);
+ }
+ } while (items.length === pageSize);
+
+ return feedback;
+ }
+
#setAuth(username: string, body: any) {
const jwt = getJwtBody(body.access_token);
diff --git a/types/adiff.ts b/types/adiff.ts
new file mode 100644
index 0000000..2639599
--- /dev/null
+++ b/types/adiff.ts
@@ -0,0 +1,30 @@
+import type {
+ OsmChangeActionType,
+ OsmNode,
+ OsmWay,
+} from '~/types/osm';
+
+export type AdiffNode = OsmNode;
+
+export interface AdiffWayNodeRef {
+ ref: number;
+ lat: number;
+ lon: number;
+};
+
+export interface AdiffWay extends Omit {
+ nodes: AdiffWayNodeRef[];
+}
+
+export type AdiffElement = AdiffNode | AdiffWay;
+export type AdiffActionType = OsmChangeActionType;
+
+export interface AdiffAction {
+ type: AdiffActionType;
+ new: AdiffElement;
+ old?: AdiffElement;
+}
+
+export interface AugmentedDiff {
+ actions: AdiffAction[];
+}
diff --git a/types/maplibre-adiff-viewer.d.ts b/types/maplibre-adiff-viewer.d.ts
new file mode 100644
index 0000000..507417d
--- /dev/null
+++ b/types/maplibre-adiff-viewer.d.ts
@@ -0,0 +1 @@
+declare module '@osmcha/maplibre-adiff-viewer';
diff --git a/types/osm.ts b/types/osm.ts
new file mode 100644
index 0000000..7282618
--- /dev/null
+++ b/types/osm.ts
@@ -0,0 +1,91 @@
+export const OSM_ELEMENT_TYPES = ['node', 'way', 'relation'] as const;
+export type OsmElementType = typeof OSM_ELEMENT_TYPES[number];
+
+export type OsmTags = Record;
+export type OsmRelationRef = { type: OsmElementType; ref: number; role: string };
+
+interface BaseElement {
+ id: number;
+ visible?: boolean;
+ version: number;
+ changeset: number;
+ timestamp: Date;
+ user: string;
+ uid: number;
+ type: OsmElementType;
+ tags: OsmTags;
+};
+
+export interface OsmNode extends BaseElement {
+ type: 'node';
+ lat: number;
+ lon: number;
+};
+
+export interface OsmWay extends BaseElement {
+ type: 'way';
+ nodes: number[];
+};
+
+export interface OsmRelation extends BaseElement {
+ type: 'relation';
+ members: OsmRelationRef[];
+};
+
+export type OsmElement = OsmNode | OsmWay | OsmRelation;
+
+export const OSMCHANGE_ACTION_TYPES = ['create', 'modify', 'delete'] as const;
+type OsmChangeActionTypeTuple = typeof OSMCHANGE_ACTION_TYPES;
+export type OsmChangeActionType = OsmChangeActionTypeTuple[number];
+
+export type OsmChange = {
+ [K in OsmChangeActionType]: OsmElement[] | undefined;
+};
+
+export interface OsmChangesetComment {
+ id: number;
+ text: string;
+ visible: boolean;
+ user: string;
+ uid: string;
+ date: Date;
+};
+
+export interface OsmChangeset {
+ id: number;
+ created_at: Date;
+ closed_at: Date;
+ open: boolean;
+ user: string;
+ uid: number;
+ min_lat: number;
+ min_lon: number;
+ max_lat: number;
+ max_lon: number;
+ comments_count: number;
+ changes_count: number;
+ tags: OsmTags;
+ comments?: OsmChangesetComment[];
+ osmChange: OsmChange; // Workspaces only
+};
+
+export type OsmNoteStatus = 'open' | 'closed';
+export type OsmNoteAction = 'opened' | 'closed' | 'commented' | 'reopened';
+
+export interface OsmNoteComment {
+ action: OsmNoteAction;
+ date: Date;
+ user?: string;
+ uid?: number;
+ text: string;
+ html: string;
+}
+
+export interface OsmNote {
+ id: number;
+ status: OsmNoteStatus;
+ lat: number;
+ lon: number;
+ created_at: Date;
+ comments: OsmNoteComment[];
+}
diff --git a/types/osmchange-parser.d.ts b/types/osmchange-parser.d.ts
new file mode 100644
index 0000000..c216004
--- /dev/null
+++ b/types/osmchange-parser.d.ts
@@ -0,0 +1 @@
+declare module '@osmcha/osmchange-parser';
diff --git a/types/tdei.ts b/types/tdei.ts
new file mode 100644
index 0000000..3a01edd
--- /dev/null
+++ b/types/tdei.ts
@@ -0,0 +1,26 @@
+export interface TdeiProjectGroupItem {
+ tdei_project_group_id: string;
+ name: string;
+}
+
+export interface TdeiDatasetItem {
+ tdei_dataset_id: string;
+ name: string;
+}
+
+export interface TdeiFeedback {
+ id: number;
+ status: string;
+ location_latitude: number;
+ location_longitude: number;
+ customer_email: string;
+ feedback_text: string;
+ created_at: Date;
+ updated_at: Date;
+ due_date: Date;
+ resolution_status: string | null;
+ resolution_description: string | null;
+ resolved_by: string | null;
+ project_group: TdeiProjectGroupItem;
+ dataset: TdeiDatasetItem;
+}
diff --git a/types/workspaces.ts b/types/workspaces.ts
new file mode 100644
index 0000000..ff9a61f
--- /dev/null
+++ b/types/workspaces.ts
@@ -0,0 +1,20 @@
+
+export type WorkspaceId = number;
+export type WorkspaceType = 'osw' | 'pathways';
+export type WorkspaceAppAccess = 0 | 1 | 2;
+
+export interface Workspace {
+ id: WorkspaceId;
+ type: WorkspaceType;
+ title: string;
+ description?: string;
+ tdeiRecordId?: string;
+ tdeiProjectGroupId: string;
+ tdeiServiceId?: string;
+ tdeiMetadata?: string;
+ createdAt: Date;
+ createdBy: string;
+ createdByName: string;
+ externalAppAccess: WorkspaceAppAccess;
+ kartaViewToken?: string;
+}
diff --git a/util/time.ts b/util/time.ts
new file mode 100644
index 0000000..a347d2b
--- /dev/null
+++ b/util/time.ts
@@ -0,0 +1,17 @@
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+
+dayjs.extend(relativeTime);
+
+const shortFormatter = new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'short',
+ timeStyle: 'short',
+});
+
+export function formatShort(date: Date): string {
+ return shortFormatter.format(date);
+}
+
+export function formatElapsed(date: Date): string {
+ return dayjs(date).fromNow();
+}