diff --git a/Development/client/angular.json b/Development/client/angular.json index 7bb5c52..525132c 100644 --- a/Development/client/angular.json +++ b/Development/client/angular.json @@ -57,6 +57,11 @@ "glob": "CHANGELOG.md", "input": "docs", "output": "/assets/docs" + }, + { + "glob": "**/*", + "input": "docs/releases", + "output": "/assets/docs/releases" } ], "styles": [ diff --git a/Development/client/docs/CHANGELOG.md b/Development/client/docs/CHANGELOG.md deleted file mode 100644 index 8f7560a..0000000 --- a/Development/client/docs/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [revision 1603] - 2026-04-28 - -- Created the changelog file \ No newline at end of file diff --git a/Development/client/package-lock.json b/Development/client/package-lock.json index 99c8272..8d57fe1 100644 --- a/Development/client/package-lock.json +++ b/Development/client/package-lock.json @@ -40,10 +40,13 @@ "geodesy": "^1.1.3", "intl": "^1.2.5", "leaflet": "^1.9.4", + "leaflet-river": "^1.0.1", "marked": "^1.2.9", + "mermaid": "^8.14.0", "ngrx-store-localstorage": "^9.0.0", "ngx-captcha": "^8.0.1", "ngx-markdown": "^9.1.1", + "polygon-clipping": "^0.15.7", "primeng-lts": "^9.2.8", "quill": "^1.3.7", "rbush": "^3.0.1", @@ -2113,6 +2116,12 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-3.1.0.tgz", + "integrity": "sha512-GcIY79elgB+azP74j8vqkiXz8xLFfIzbQJdlwOPisgbKT00tviJQuEghOXSMVxJ00HoYJbGswr4kcllUc4xCcg==", + "deprecated": "Potential XSS vulnerability patched in v6.0.0." + }, "node_modules/@fullcalendar/core": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-4.4.2.tgz", @@ -5874,6 +5883,690 @@ "integrity": "sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==", "dev": true }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/dagre-d3": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/dagre-d3/-/dagre-d3-0.6.4.tgz", + "integrity": "sha512-e/6jXeCP7/ptlAM48clmX4xTZc5Ek6T6kagS7Oz2HrYSdqcLZFLqpAfh7ldbZRFfxCZVyh61NEPR08UQRVxJzQ==", + "dependencies": { + "d3": "^5.14", + "dagre": "^0.8.5", + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "node_modules/dagre-d3/node_modules/d3": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz", + "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==", + "dependencies": { + "d3-array": "1", + "d3-axis": "1", + "d3-brush": "1", + "d3-chord": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-contour": "1", + "d3-dispatch": "1", + "d3-drag": "1", + "d3-dsv": "1", + "d3-ease": "1", + "d3-fetch": "1", + "d3-force": "1", + "d3-format": "1", + "d3-geo": "1", + "d3-hierarchy": "1", + "d3-interpolate": "1", + "d3-path": "1", + "d3-polygon": "1", + "d3-quadtree": "1", + "d3-random": "1", + "d3-scale": "2", + "d3-scale-chromatic": "1", + "d3-selection": "1", + "d3-shape": "1", + "d3-time": "1", + "d3-time-format": "2", + "d3-timer": "1", + "d3-transition": "1", + "d3-voronoi": "1", + "d3-zoom": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "node_modules/dagre-d3/node_modules/d3-axis": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", + "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==" + }, + "node_modules/dagre-d3/node_modules/d3-brush": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.6.tgz", + "integrity": "sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==", + "dependencies": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-chord": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", + "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", + "dependencies": { + "d3-array": "1", + "d3-path": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "node_modules/dagre-d3/node_modules/d3-contour": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", + "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", + "dependencies": { + "d3-array": "^1.1.1" + } + }, + "node_modules/dagre-d3/node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" + }, + "node_modules/dagre-d3/node_modules/d3-drag": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", + "dependencies": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-dsv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", + "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", + "dependencies": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json", + "csv2tsv": "bin/dsv2dsv", + "dsv2dsv": "bin/dsv2dsv", + "dsv2json": "bin/dsv2json", + "json2csv": "bin/json2dsv", + "json2dsv": "bin/json2dsv", + "json2tsv": "bin/json2dsv", + "tsv2csv": "bin/dsv2dsv", + "tsv2json": "bin/dsv2json" + } + }, + "node_modules/dagre-d3/node_modules/d3-ease": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", + "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==" + }, + "node_modules/dagre-d3/node_modules/d3-fetch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz", + "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==", + "dependencies": { + "d3-dsv": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" + }, + "node_modules/dagre-d3/node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==" + }, + "node_modules/dagre-d3/node_modules/d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "dependencies": { + "d3-color": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/dagre-d3/node_modules/d3-polygon": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", + "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==" + }, + "node_modules/dagre-d3/node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" + }, + "node_modules/dagre-d3/node_modules/d3-random": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", + "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==" + }, + "node_modules/dagre-d3/node_modules/d3-scale": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", + "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", + "dependencies": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "node_modules/dagre-d3/node_modules/d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "dependencies": { + "d3-color": "1", + "d3-interpolate": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-selection": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", + "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" + }, + "node_modules/dagre-d3/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" + }, + "node_modules/dagre-d3/node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "dependencies": { + "d3-time": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" + }, + "node_modules/dagre-d3/node_modules/d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", + "dependencies": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "node_modules/dagre-d3/node_modules/d3-zoom": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", + "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", + "dependencies": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6177,6 +6870,14 @@ "rimraf": "bin.js" } }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6366,6 +7067,11 @@ "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==" }, + "node_modules/dompurify": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.5.tgz", + "integrity": "sha512-kD+f8qEaa42+mjdOpKeztu9Mfx5bv9gVLO6K9jRx4uGvh6Wv06Srn4jr1wPNY2OOUGGSKHNFN+A8MA3v0E0QAQ==" + }, "node_modules/domutils": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", @@ -7886,6 +8592,14 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -8533,7 +9247,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -8866,6 +9579,14 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/intl": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/intl/-/intl-1.2.5.tgz", @@ -10187,6 +10908,11 @@ "katex": "cli.js" } }, + "node_modules/khroma": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-1.4.1.tgz", + "integrity": "sha512-+GmxKvmiRuCcUYDgR7g5Ngo0JEDeOsGdNONdU2zsiBQaK4z19Y2NvXqfEDE0ZiIrg45GTZyAnPLVsLZZACYm3Q==" + }, "node_modules/killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -10206,6 +10932,14 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, + "node_modules/leaflet-river": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/leaflet-river/-/leaflet-river-1.0.1.tgz", + "integrity": "sha512-hKkIkoAtiyjsLGKBbEYhPRE/5cxLkAg6zx+LEXEnW3113AsnG0GqwCrHikaDSo/COMRDTw0edqvwUK1ng76l8Q==", + "dependencies": { + "leaflet": "^1.0.1" + } + }, "node_modules/less": { "version": "3.11.3", "resolved": "https://registry.npmjs.org/less/-/less-3.11.3.tgz", @@ -10782,6 +11516,22 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.14.0.tgz", + "integrity": "sha512-ITSHjwVaby1Li738sxhF48sLTxcNyUAoWfoqyztL1f7J6JOLpHOuQPNLBb6lxGPUA0u7xP9IRULgvod0dKu35A==", + "dependencies": { + "@braintree/sanitize-url": "^3.1.0", + "d3": "^7.0.0", + "dagre": "^0.8.5", + "dagre-d3": "^0.6.4", + "dompurify": "2.3.5", + "graphlib": "^2.1.8", + "khroma": "^1.4.1", + "moment-mini": "^2.24.0", + "stylis": "^4.0.10" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -11082,6 +11832,11 @@ "node": "*" } }, + "node_modules/moment-mini": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment-mini/-/moment-mini-2.29.4.tgz", + "integrity": "sha512-uhXpYwHFeiTbY9KSgPPRoo1nt8OxNVdMVoTBYHfSEKeRkIkwGpO+gERmhuhBtzfaeOyTkykSrm2+noJBgqt3Hg==" + }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -12466,6 +13221,15 @@ "node": ">=8" } }, + "node_modules/polygon-clipping": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", + "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", + "dependencies": { + "robust-predicates": "^3.0.2", + "splaytree": "^3.1.0" + } + }, "node_modules/portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -14304,6 +15068,11 @@ "node": ">= 0.8" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==" + }, "node_modules/rollup": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.1.0.tgz", @@ -14374,6 +15143,11 @@ "aproba": "^1.1.1" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -14662,8 +15436,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.26.3", @@ -15937,6 +16710,14 @@ "node": ">=4" } }, + "node_modules/splaytree": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz", + "integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==", + "engines": { + "node": ">=18.20 || >=20" + } + }, "node_modules/split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -16334,6 +17115,11 @@ "node": ">=8" } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==" + }, "node_modules/stylus": { "version": "0.54.7", "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.7.tgz", @@ -21236,6 +22022,11 @@ "@babel/helper-validator-identifier": "^7.28.5" } }, + "@braintree/sanitize-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-3.1.0.tgz", + "integrity": "sha512-GcIY79elgB+azP74j8vqkiXz8xLFfIzbQJdlwOPisgbKT00tviJQuEghOXSMVxJ00HoYJbGswr4kcllUc4xCcg==" + }, "@fullcalendar/core": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-4.4.2.tgz", @@ -24307,6 +25098,570 @@ "integrity": "sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==", "dev": true }, + "d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "requires": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + } + }, + "d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "requires": { + "internmap": "1 - 2" + } + }, + "d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==" + }, + "d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + } + }, + "d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "requires": { + "d3-path": "1 - 3" + } + }, + "d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, + "d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "requires": { + "d3-array": "^3.2.0" + } + }, + "d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "requires": { + "delaunator": "5" + } + }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "requires": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==" + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "requires": { + "d3-dsv": "1 - 3" + } + }, + "d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" + }, + "d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "requires": { + "d3-array": "2.5.0 - 3" + } + }, + "d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==" + }, + "d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "requires": { + "d3-color": "1 - 3" + } + }, + "d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==" + }, + "d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==" + }, + "d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==" + }, + "d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "requires": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + } + }, + "d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "requires": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + } + }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" + }, + "d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "requires": { + "d3-path": "^3.1.0" + } + }, + "d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "requires": { + "d3-array": "2 - 3" + } + }, + "d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "requires": { + "d3-time": "1 - 3" + } + }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } + }, + "dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "requires": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, + "dagre-d3": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/dagre-d3/-/dagre-d3-0.6.4.tgz", + "integrity": "sha512-e/6jXeCP7/ptlAM48clmX4xTZc5Ek6T6kagS7Oz2HrYSdqcLZFLqpAfh7ldbZRFfxCZVyh61NEPR08UQRVxJzQ==", + "requires": { + "d3": "^5.14", + "dagre": "^0.8.5", + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + }, + "dependencies": { + "d3": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz", + "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==", + "requires": { + "d3-array": "1", + "d3-axis": "1", + "d3-brush": "1", + "d3-chord": "1", + "d3-collection": "1", + "d3-color": "1", + "d3-contour": "1", + "d3-dispatch": "1", + "d3-drag": "1", + "d3-dsv": "1", + "d3-ease": "1", + "d3-fetch": "1", + "d3-force": "1", + "d3-format": "1", + "d3-geo": "1", + "d3-hierarchy": "1", + "d3-interpolate": "1", + "d3-path": "1", + "d3-polygon": "1", + "d3-quadtree": "1", + "d3-random": "1", + "d3-scale": "2", + "d3-scale-chromatic": "1", + "d3-selection": "1", + "d3-shape": "1", + "d3-time": "1", + "d3-time-format": "2", + "d3-timer": "1", + "d3-transition": "1", + "d3-voronoi": "1", + "d3-zoom": "1" + } + }, + "d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "d3-axis": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", + "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==" + }, + "d3-brush": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.6.tgz", + "integrity": "sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "d3-chord": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", + "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", + "requires": { + "d3-array": "1", + "d3-path": "1" + } + }, + "d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "d3-contour": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", + "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", + "requires": { + "d3-array": "^1.1.1" + } + }, + "d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" + }, + "d3-drag": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", + "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", + "requires": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "d3-dsv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", + "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", + "requires": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + } + }, + "d3-ease": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", + "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==" + }, + "d3-fetch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz", + "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==", + "requires": { + "d3-dsv": "1" + } + }, + "d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" + }, + "d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "requires": { + "d3-array": "1" + } + }, + "d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==" + }, + "d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "requires": { + "d3-color": "1" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-polygon": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", + "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==" + }, + "d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" + }, + "d3-random": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", + "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==" + }, + "d3-scale": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", + "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", + "requires": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-scale-chromatic": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", + "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", + "requires": { + "d3-color": "1", + "d3-interpolate": "1" + } + }, + "d3-selection": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", + "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" + }, + "d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "requires": { + "d3-time": "1" + } + }, + "d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" + }, + "d3-transition": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", + "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "d3-zoom": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", + "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + } + } + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -24526,6 +25881,14 @@ } } }, + "delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "requires": { + "robust-predicates": "^3.0.2" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -24687,6 +26050,11 @@ "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==" }, + "dompurify": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.3.5.tgz", + "integrity": "sha512-kD+f8qEaa42+mjdOpKeztu9Mfx5bv9gVLO6K9jRx4uGvh6Wv06Srn4jr1wPNY2OOUGGSKHNFN+A8MA3v0E0QAQ==" + }, "domutils": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", @@ -25913,6 +27281,14 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "requires": { + "lodash": "^4.17.15" + } + }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -26453,7 +27829,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -26703,6 +28078,11 @@ "side-channel": "^1.1.0" } }, + "internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, "intl": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/intl/-/intl-1.2.5.tgz", @@ -27677,6 +29057,11 @@ "commander": "^2.19.0" } }, + "khroma": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-1.4.1.tgz", + "integrity": "sha512-+GmxKvmiRuCcUYDgR7g5Ngo0JEDeOsGdNONdU2zsiBQaK4z19Y2NvXqfEDE0ZiIrg45GTZyAnPLVsLZZACYm3Q==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -27693,6 +29078,14 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, + "leaflet-river": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/leaflet-river/-/leaflet-river-1.0.1.tgz", + "integrity": "sha512-hKkIkoAtiyjsLGKBbEYhPRE/5cxLkAg6zx+LEXEnW3113AsnG0GqwCrHikaDSo/COMRDTw0edqvwUK1ng76l8Q==", + "requires": { + "leaflet": "^1.0.1" + } + }, "less": { "version": "3.11.3", "resolved": "https://registry.npmjs.org/less/-/less-3.11.3.tgz", @@ -28158,6 +29551,22 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, + "mermaid": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-8.14.0.tgz", + "integrity": "sha512-ITSHjwVaby1Li738sxhF48sLTxcNyUAoWfoqyztL1f7J6JOLpHOuQPNLBb6lxGPUA0u7xP9IRULgvod0dKu35A==", + "requires": { + "@braintree/sanitize-url": "^3.1.0", + "d3": "^7.0.0", + "dagre": "^0.8.5", + "dagre-d3": "^0.6.4", + "dompurify": "2.3.5", + "graphlib": "^2.1.8", + "khroma": "^1.4.1", + "moment-mini": "^2.24.0", + "stylis": "^4.0.10" + } + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -28394,6 +29803,11 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" }, + "moment-mini": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment-mini/-/moment-mini-2.29.4.tgz", + "integrity": "sha512-uhXpYwHFeiTbY9KSgPPRoo1nt8OxNVdMVoTBYHfSEKeRkIkwGpO+gERmhuhBtzfaeOyTkykSrm2+noJBgqt3Hg==" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -29533,6 +30947,15 @@ "find-up": "^4.0.0" } }, + "polygon-clipping": { + "version": "0.15.7", + "resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz", + "integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==", + "requires": { + "robust-predicates": "^3.0.2", + "splaytree": "^3.1.0" + } + }, "portfinder": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", @@ -31056,6 +32479,11 @@ } } }, + "robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==" + }, "rollup": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.1.0.tgz", @@ -31098,6 +32526,11 @@ "aproba": "^1.1.1" } }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -31322,8 +32755,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sass": { "version": "1.26.3", @@ -32377,6 +33809,11 @@ } } }, + "splaytree": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.2.3.tgz", + "integrity": "sha512-7OXrNWzy6CK+r7Ch9OLPBDTKfB6XlWHjX4P0RU5B3IgFuWPeYN0XtRtlexGRjgbQxpfaUve6jTAwBGWuGntz/w==" + }, "split-string": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", @@ -32692,6 +34129,11 @@ } } }, + "stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==" + }, "stylus": { "version": "0.54.7", "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.54.7.tgz", diff --git a/Development/client/package.json b/Development/client/package.json index a596a77..d1f90de 100644 --- a/Development/client/package.json +++ b/Development/client/package.json @@ -4,12 +4,19 @@ "license": "COMMERCIAL", "angular-cli": {}, "scripts": { + "generate-release-manifest": "node scripts/generate-release-manifest.js", "ng": "ng", + "prestart-cert": "npm run generate-release-manifest", "start-cert": "ng serve --ssl true --sslKey ~/ssl/server.key --sslCert ~/ssl/server.crt --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", + "prestart": "npm run generate-release-manifest", "start": "CHOKIDAR_USEPOLLING=true ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck", + "prestart-es": "npm run generate-release-manifest", "start-es": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=es", + "prestart-pt": "npm run generate-release-manifest", "start-pt": "ng serve --ssl true --proxy-config proxy.config.json --host 0.0.0.0 --disableHostCheck --configuration=pt", + "prebuild": "npm run generate-release-manifest", "build": "ng build", + "prebuild-prep": "npm run generate-release-manifest", "build-prep": "ng build --aot --localize=false", "test": "ng test", "lint": "ng lint", @@ -22,7 +29,9 @@ "sync-i18n": "npm run build-prep && npm run i18n-extract && npm run i18n-merge", "sync-i18n-w": "npm run build-prep && npm run i18n-extract-w && npm run i18n-merge-w", "pre-translate": "npx translation start && npm run sync-i18n && npx translation translate && npx translation cleanup", + "prebuild-prod": "npm run generate-release-manifest", "build-prod": "ng build --prod --localize && cp -R dist/en/* dist/ && rm -R dist/en", + "prebuild-prod-window": "npm run generate-release-manifest", "build-prod-window": "ng build --prod --localize && xcopy /E /Y dist\\en\\* dist\\ && rmdir /S /Q dist\\en" }, "private": true, @@ -58,10 +67,13 @@ "geodesy": "^1.1.3", "intl": "^1.2.5", "leaflet": "^1.9.4", + "leaflet-river": "^1.0.1", "marked": "^1.2.9", + "mermaid": "^8.14.0", "ngrx-store-localstorage": "^9.0.0", "ngx-captcha": "^8.0.1", "ngx-markdown": "^9.1.1", + "polygon-clipping": "^0.15.7", "primeng-lts": "^9.2.8", "quill": "^1.3.7", "rbush": "^3.0.1", diff --git a/Development/client/scripts/generate-release-manifest.js b/Development/client/scripts/generate-release-manifest.js new file mode 100644 index 0000000..4fd0571 --- /dev/null +++ b/Development/client/scripts/generate-release-manifest.js @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); + +const releasesDir = path.resolve(__dirname, '..', 'docs', 'releases'); +const manifestPath = path.join(releasesDir, 'releases-manifest.json'); + +function toTitle(fileName) { + return path.basename(fileName, path.extname(fileName)); +} + +function getReleaseEntries() { + if (!fs.existsSync(releasesDir)) { + return []; + } + + return fs.readdirSync(releasesDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && path.extname(entry.name).toLowerCase() === '.md') + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left, undefined, { numeric: true, sensitivity: 'base' })) + .map((fileName) => ({ + fileName, + title: toTitle(fileName) + })); +} + +function main() { + fs.mkdirSync(releasesDir, { recursive: true }); + + const manifest = { + revisions: getReleaseEntries() + }; + + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); +} + +main(); diff --git a/Development/client/src/app/app-routing.module.ts b/Development/client/src/app/app-routing.module.ts index 2805e1b..7751916 100644 --- a/Development/client/src/app/app-routing.module.ts +++ b/Development/client/src/app/app-routing.module.ts @@ -106,10 +106,15 @@ const routes: Routes = [ runGuardsAndResolvers: 'always' }, { - path: 'changelog', - loadChildren: () => import('./changelog/changelog.module').then(m => m.ChangelogModule), + path: 'release-notes', + loadChildren: () => import('./release-notes/release-notes.module').then(m => m.ReleaseNotesModule), runGuardsAndResolvers: 'always' }, + { + path: 'changelog', + redirectTo: 'release-notes', + pathMatch: 'full' + }, ], }, { diff --git a/Development/client/src/app/app.menu.component.ts b/Development/client/src/app/app.menu.component.ts index 4b4eba8..31e0512 100644 --- a/Development/client/src/app/app.menu.component.ts +++ b/Development/client/src/app/app.menu.component.ts @@ -59,7 +59,7 @@ export class AppMenuComponent implements OnInit { { id: 'subscription', label: $localize`:@@promoManagement:Promo Management`, icon: 'credit_card', routerLink: ['/settings/subscription'] } ] }, - { id: 'changelog', label: $localize`:@@changelog:Changelog`, icon: 'history', routerLink: ['/changelog'] }, + { id: 'release-notes', label: $localize`:@@releaseNotes:Release Notes`, icon: 'history', routerLink: ['/release-notes'] }, ]; this.model = mItems; } @@ -76,12 +76,7 @@ export class AppMenuComponent implements OnInit { { id: 'Help', label: $localize`:@@help:Help`, icon: 'help_outline', - items: [{ - label: $localize`:@@trainingVideos:Training Videos`, - icon: 'video_library', - url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV', - target: '_blank' - }] + items: this.buildHelpMenuItems() } ]; this.model = mItems; @@ -124,16 +119,28 @@ export class AppMenuComponent implements OnInit { { id: 'Help', label: $localize`:@@help:Help`, icon: 'help_outline', - items: [{ - label: $localize`:@@trainingVideos:Training Videos`, - icon: 'video_library', - url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV', - target: '_blank' - }] + items: this.buildHelpMenuItems() } ) } + private buildHelpMenuItems(): MenuItem[] { + return [ + { + id: 'release-notes', + label: $localize`:@@releaseNotes:Release Notes`, + icon: 'history', + routerLink: ['/release-notes'] + }, + { + label: $localize`:@@trainingVideos:Training Videos`, + icon: 'video_library', + url: 'https://www.youtube.com/watch?v=QjGZan5QdAo&list=PLSMll_kIgHA3eamxiSH0Dgl95v60okMcV', + target: '_blank' + } + ]; + } + private addOnlyTrackingItems(mItems: MenuItem[]) { if (!this.authSvc.hasRole([RoleIds.INSPECTOR])) { mItems.push( diff --git a/Development/client/src/app/changelog/changelog.component.html b/Development/client/src/app/changelog/changelog.component.html deleted file mode 100644 index 7222926..0000000 --- a/Development/client/src/app/changelog/changelog.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-
-

Changelog

-
- -
- - -
-
-
-
-
-
diff --git a/Development/client/src/app/changelog/changelog.component.ts b/Development/client/src/app/changelog/changelog.component.ts deleted file mode 100644 index d9b027b..0000000 --- a/Development/client/src/app/changelog/changelog.component.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import * as marked from 'marked'; -const parseMarkdown: (src: string) => string = (marked as any).marked ?? (marked as any).default ?? (marked as any); - -interface ChangelogSection { - header: string; - content: SafeHtml; -} - -@Component({ - selector: 'app-changelog', - templateUrl: './changelog.component.html' -}) -export class ChangelogComponent implements OnInit { - sections: ChangelogSection[] = []; - loading = true; - - constructor( - private readonly http: HttpClient, - private readonly sanitizer: DomSanitizer - ) { } - - ngOnInit(): void { - this.http.get('/assets/docs/CHANGELOG.md', { responseType: 'text' }).subscribe({ - next: (md) => { - this.sections = this.parseSections(md); - this.loading = false; - }, - error: () => { this.loading = false; } - }); - } - - private parseSections(md: string): ChangelogSection[] { - const sections: ChangelogSection[] = []; - let currentHeader = ''; - let currentBody = ''; - - for (const line of md.split('\n')) { - if (line.startsWith('## ')) { - if (currentHeader) { - sections.push({ - header: currentHeader, - content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim())) - }); - } - currentHeader = line.replace(/^##\s*/, ''); - currentBody = ''; - } else if (currentHeader) { - currentBody += line + '\n'; - } - } - if (currentHeader) { - sections.push({ - header: currentHeader, - content: this.sanitizer.bypassSecurityTrustHtml(parseMarkdown(currentBody.trim())) - }); - } - return sections; - } -} diff --git a/Development/client/src/app/changelog/changelog.module.ts b/Development/client/src/app/changelog/changelog.module.ts deleted file mode 100644 index ab7920a..0000000 --- a/Development/client/src/app/changelog/changelog.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NgModule } from '@angular/core'; -import { HttpClientModule } from '@angular/common/http'; -import { ProgressSpinnerModule } from 'primeng/progressspinner'; -import { PanelModule } from 'primeng/panel'; - -import { AppSharedModule } from '../shared/app-shared.module'; -import { ChangelogRoutingModule } from './changelog-routing.module'; -import { ChangelogComponent } from './changelog.component'; - -@NgModule({ - imports: [ - AppSharedModule, - HttpClientModule, - ProgressSpinnerModule, - PanelModule, - ChangelogRoutingModule - ], - declarations: [ChangelogComponent] -}) -export class ChangelogModule { } diff --git a/Development/client/src/app/customers/customer-list/customer-list.component.html b/Development/client/src/app/customers/customer-list/customer-list.component.html index 16d4a16..4155160 100644 --- a/Development/client/src/app/customers/customer-list/customer-list.component.html +++ b/Development/client/src/app/customers/customer-list/customer-list.component.html @@ -20,8 +20,6 @@ ? Controls how long results stay cached after you return to this page. Value is in seconds. - - diff --git a/Development/client/src/app/customers/customer-list/customer-list.component.ts b/Development/client/src/app/customers/customer-list/customer-list.component.ts index 287ad76..9e92dce 100644 --- a/Development/client/src/app/customers/customer-list/customer-list.component.ts +++ b/Development/client/src/app/customers/customer-list/customer-list.component.ts @@ -35,7 +35,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy partners: SelectItem[]; cols: any[]; totalItems; - isSelfSignup = false; searchAccordionOpen = sessionStorage.getItem('customers-list-accordion') === 'true'; private lastFiltersQuery: Record | undefined; @@ -74,13 +73,14 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy { key: 'email', label: globals.email, dataType: 'text' }, { key: 'contact', label: globals.contact, dataType: 'text' }, { key: 'createdAt', label: globals.from, dataType: 'date-preset' }, + { key: 'selfSignup', label: 'Self Signup', dataType: 'select', options: [ + { label: 'True', value: true }, + { label: 'False', value: false }, + ]}, ]; } ngOnInit() { - const saved = localStorage.getItem('isSelfSignup'); - this.isSelfSignup = saved === 'true'; - this.sub$ = this.store.select(fromCustomers.getAllCustomers).subscribe(customers => { this.setCustomersAndPartners(customers); }); @@ -105,8 +105,7 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy } private setCustomersAndPartners(customers: Customer[]) { - const filtered = this.isSelfSignup ? customers.filter(c => c.selfSignup) : customers; - this.customers = filtered.map(c => ({ + this.customers = customers.map(c => ({ ...c, partnerName: c.partner?.name || null })); @@ -120,14 +119,6 @@ export class CustomerListComponent extends BaseComp implements OnInit, OnDestroy ]; } - onToggle(event: any): void { - this.isSelfSignup = event.checked; - localStorage.setItem('isSelfSignup', String(this.isSelfSignup)); - this.store.select(fromCustomers.getAllCustomers).subscribe(customers => { - this.setCustomersAndPartners(customers); - }); - } - onRowSelect(event) { this.store.dispatch(new customerActions.Select(event.data)); } diff --git a/Development/client/src/app/domain/services/auth-interceptor.service.ts b/Development/client/src/app/domain/services/auth-interceptor.service.ts index 58e72fd..7bea38a 100644 --- a/Development/client/src/app/domain/services/auth-interceptor.service.ts +++ b/Development/client/src/app/domain/services/auth-interceptor.service.ts @@ -41,7 +41,7 @@ export class AuthInterceptor implements HttpInterceptor { }); let url = req.url; - if (!StringUtils.contains(req.url, '/track')) + if (!StringUtils.contains(req.url, '/track') && !req.url.startsWith('/assets')) url = `/api${!req.url.startsWith('/') ? '' + req.url : req.url}`; const authReq = req.clone({ url: url, headers: headers }); diff --git a/Development/client/src/app/entities/reducers/index.ts b/Development/client/src/app/entities/reducers/index.ts index fa8719a..3abb51c 100644 --- a/Development/client/src/app/entities/reducers/index.ts +++ b/Development/client/src/app/entities/reducers/index.ts @@ -30,7 +30,7 @@ export const getEntityState = createFeatureSelector(FEATURE_KEY); export const getCropsState = createSelector( getEntityState, - state => state.crops + state => state ? state.crops : fromCrops.initialState ) export const { selectIds: getCropIds, @@ -44,7 +44,7 @@ export const getCropsLoading = createSelector(getCropsState, fromCrops.getIsLoad export const getPilotsState = createSelector( getEntityState, - state => state.pilots + state => state ? state.pilots : fromPilots.initialState ) export const { selectIds: getPilotIds, @@ -56,7 +56,7 @@ export const { export const getProductsState = createSelector( getEntityState, - state => state.products + state => state ? state.products : fromProducts.initialState ) export const { selectIds: getProductIds, @@ -68,7 +68,7 @@ export const { export const getVehilesState = createSelector( getEntityState, - state => state.vehicles + state => state ? state.vehicles : fromVehicles.initialState ) export const { selectIds: getVehicleIds, diff --git a/Development/client/src/app/job/job-list/job-list.component.ts b/Development/client/src/app/job/job-list/job-list.component.ts index cd70d05..912551b 100644 --- a/Development/client/src/app/job/job-list/job-list.component.ts +++ b/Development/client/src/app/job/job-list/job-list.component.ts @@ -172,7 +172,7 @@ export class JobListComponent extends BaseComp implements OnInit, AfterViewInit, { key: 'name', label: globals.name, dataType: 'text' as const }, { key: 'startDate', label: $localize`:@@startDate:Start Date`, dataType: 'date' as const }, { key: 'endDate', label: $localize`:@@endDate:End Date`, dataType: 'date' as const }, - { key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const }, + { key: 'createdAt', label: $localize`:@@createdDate:Created Date`, dataType: 'date-preset' as const, removable: false }, { key: 'status', label: $localize`:@@status:Status`, dataType: 'select-multi' as const, options: GC.selJobStatuses }, ]; } diff --git a/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.css b/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.css new file mode 100644 index 0000000..6b6bae4 --- /dev/null +++ b/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.css @@ -0,0 +1,120 @@ +.buf-editor-panel { + position: absolute; + top: 10px; + left: 10px; + z-index: 1000; + background: #fff; + max-width: 50em; + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + border-radius: 4px; + font-size: 13px; + pointer-events: auto; +} + +.buf-editor-toolbar { + display: flex; + align-items: center; + padding: 6px 8px; + border-bottom: 1px solid #e0e0e0; + background: #f5f5f5; + border-radius: 4px 4px 0 0; +} + +.buf-editor-title { + font-weight: 500; + font-size: 13px; +} + +.buf-editor-drag-handle { + display: flex; + align-items: center; + cursor: grab; + margin-right: 6px; + color: #757575; + user-select: none; +} + +.buf-editor-drag-handle:active { + cursor: grabbing; +} + +.buf-editor-footer { + display: flex; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid #e0e0e0; + align-items: center; +} + +.buf-editor-body { + padding: 8px 10px; +} + +.buf-editor-name-row { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 0 !important; +} + +.buf-editor-name-label { + font-size: 12px; + white-space: nowrap; +} + +.buf-editor-name-input.ui-inputtext { + flex: 1; + font-size: 12px; + padding: 2px 4px !important; +} + +.buf-editor-controls-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; +} + +.buf-editor-width-group { + display: flex; + align-items: center; + white-space: nowrap; + gap: 6px; +} + +.buf-editor-slider { + width: 100px; + cursor: pointer; + accent-color: #388e3c; +} + +.buf-editor-width-input.ui-inputtext { + width: 54px !important; + text-align: right; + padding: 2px 4px !important; + font-size: 12px; + margin: 0 2px 0 4px; +} + +.buf-editor-unit-label { + font-size: 12px; + color: #555; + min-width: 16px; +} + +.buf-editor-icon-btn.ui-button { + width: 28px !important; + height: 28px !important; + padding: 0 !important; +} + +.buf-editor-create-another-label { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + font-size: 12px; + cursor: pointer; + user-select: none; + white-space: nowrap; +} diff --git a/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.html b/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.html new file mode 100644 index 0000000..9f42faa --- /dev/null +++ b/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.html @@ -0,0 +1,65 @@ +
+ + +
+ + + + {{ title }} +
+ + +
+ {{ instructionText }} +
+ + +
+ + +
+ +
+
+ + + + {{ widthUnit }} +
+ +
+ + + + + + +
diff --git a/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.ts b/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.ts new file mode 100644 index 0000000..bcc6dc8 --- /dev/null +++ b/Development/client/src/app/job/job-map-edit/buf-editor-panel/buf-editor-panel.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { SelectItem } from 'primeng/api'; + +@Component({ + selector: 'app-buf-editor-panel', + templateUrl: './buf-editor-panel.component.html', + styleUrls: ['./buf-editor-panel.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BufEditorPanelComponent { + /** Whether the panel is visible. */ + @Input() active = false; + /** Title shown in the toolbar. */ + @Input() title = 'Buffer Zone'; + /** True = edge-buffer mode (shows edge-side selector + flip button). */ + @Input() isEdge = false; + /** True once the geometry is ready and Confirm should be shown. */ + @Input() confirmReady = false; + /** Hides "Create another" when editing an existing buffer. */ + @Input() isEditing = false; + /** Instruction text shown before confirmReady. */ + @Input() instructionText = ''; + + @Input() name = ''; + @Input() widthSlider = 0; + @Input() widthInUnit = 30; + @Input() maxWidthInUnit = 100; + @Input() widthUnit: 'm' | 'ft' = 'm'; + @Input() edgeSideOptions: SelectItem[] = []; + @Input() edgeSide = 'on'; + @Input() canFlip = false; + @Input() createAnother = false; + @Input() panelTop = 10; + @Input() panelLeft = 10; + + @Output() nameChange = new EventEmitter(); + @Output() widthSliderChange = new EventEmitter(); + @Output() widthInputChange = new EventEmitter(); + @Output() edgeSideChange = new EventEmitter(); + @Output() flipClick = new EventEmitter(); + @Output() confirmClick = new EventEmitter(); + @Output() cancelClick = new EventEmitter(); + @Output() createAnotherChange = new EventEmitter(); + @Output() dragStart = new EventEmitter(); +} diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.css b/Development/client/src/app/job/job-map-edit/job-map-edit.component.css index 71cf1b7..fe172a1 100644 --- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.css +++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.css @@ -3,6 +3,25 @@ margin-top : 1em; } +/* Projected content inside app-buf-editor-panel — must live here due to Angular view encapsulation */ +.buf-editor-feature-row { + padding: 0 10px 10px 10px; + gap: 6px; + display: flex; + align-items: center; + font-size: 12px; +} + +.buf-editor-feature-row label { + white-space: nowrap; +} + +.buf-editor-feature-row select { + flex: 1; + font-size: 12px; + padding: 2px 4px; +} + .data-detail-box { border: 1px solid lightgray; } @@ -42,3 +61,193 @@ .loc-time { padding-top: .45em; } + +/* Advanced Buffer Tools chooser panel */ +.adv-buf-chooser-panel { + min-width: 200px; +} + +.adv-buf-chooser-body { + padding: 10px 12px 6px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.adv-buf-chooser-label { + font-size: 12px; + color: #555; +} + +.adv-buf-chooser-btns { + display: flex; + gap: 8px; + align-items: center; +} + +.adv-buf-type-btn.ui-button { + width: 40px !important; + height: 40px !important; + padding: 0 !important; + font-size: 20px !important; +} + +.adv-buf-type-btn.ui-button .ui-button-icon-left { + font-size: 20px; + margin-top: -10px; + margin-left: -10px; +} + +.adv-buf-type-btn--disabled.ui-button { + opacity: 0.4; + cursor: not-allowed; +} + +/* Edge Buffer overlay panel – positioned inside the Leaflet map at top-left, matching Leaflet control pane */ +.edge-buf-panel { + position: absolute; + top: 10px; + left: 10px; + z-index: 1000; + background: #fff; + max-width: 50em; + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + border-radius: 4px; + font-size: 13px; + pointer-events: auto; +} + +.edge-buf-panel-toolbar { + display: flex; + align-items: center; + padding: 6px 8px; + border-bottom: 1px solid #e0e0e0; + background: #f5f5f5; + border-radius: 4px 4px 0 0; +} + +.edge-buf-panel-title { + font-weight: 500; + font-size: 13px; +} + +.edge-buf-drag-handle { + display: flex; + align-items: center; + cursor: grab; + margin-right: 6px; + color: #757575; + user-select: none; +} + +.edge-buf-drag-handle:active { + cursor: grabbing; +} + +.edge-buf-panel-footer { + display: flex; + gap: 6px; + padding: 8px 10px; + border-top: 1px solid #e0e0e0; + justify-content: flex-start; +} + +.edge-buf-controls-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; +} + +.edge-buf-width-group { + display: flex; + align-items: center; + white-space: nowrap; + gap: 6px; +} + +.edge-buf-slider { + width: 100px; + cursor: pointer; + accent-color: #388e3c; +} + +.edge-buf-width-input.ui-inputtext { + width: 54px !important; + text-align: right; + padding: 2px 4px !important; + font-size: 12px; + margin: 0 2px 0 4px; +} + +.edge-buf-unit-select { + font-size: 12px; + border: 1px solid #bdbdbd; + border-radius: 3px; + padding: 1px 2px; + cursor: pointer; + background: #fff; +} + +.edge-buf-icon-btn.ui-button { + width: 28px !important; + height: 28px !important; + padding: 0 !important; +} + +.edge-buf-panel-body { + padding: 8px 10px; +} + +.edge-buf-name-row { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 0 !important; +} + +.edge-buf-name-label { + font-size: 12px; + white-space: nowrap; +} + +.edge-buf-name-input.ui-inputtext { + flex: 1; + font-size: 12px; + padding: 2px 4px !important; +} + +.edge-buf-create-another-label { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + font-size: 12px; + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +/* Edge Buffer snap-to-edge drawing mode */ +.edge-buf-snap-marker { + width: 12px; + height: 12px; + background: #fff; + border: 2px solid #e65100; + border-radius: 50%; +} + +.edge-buf-cursor-tip { + position: fixed; + pointer-events: none; + z-index: 9999; + background: rgba(0, 0, 0, 0.72); + color: #fff; + border-radius: 4px; + padding: 4px 10px; + font-size: 12px; + white-space: nowrap; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); + left: -9999px; + top: -9999px; +} diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.html b/Development/client/src/app/job/job-map-edit/job-map-edit.component.html index b7dfcba..fa656b3 100644 --- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.html +++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.html @@ -71,7 +71,7 @@ {{ state.totalRecords | i18nPlural: totalItems }} - +
@@ -157,6 +157,171 @@
+ +
+
+ + + + Buffer Creation Mode +
+
+ Please select a buffer type +
+ + + +
+
+ +
+ + + + + +
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+
+ + {{ bufWidthUnit }} +
+
+
+
+
+ +
+
+
+ +
+ + +
+
+
+ + +
@@ -212,8 +377,16 @@
- - {{ job.measureUnit | lengthUnit }} + + {{ job.measureUnit | lengthUnit }} +
+ +
+
+ +
+
+
@@ -583,6 +756,8 @@ + +
@@ -627,244 +802,11 @@
- -
- -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
- -
- -
- - -
-
-
-
- - - - -
- {{ fileItem(file) }} -
-
-
-
- -
-
Counter
-
{{(playIdx + 1)}}
-
TimeLocal
-
-
- {{curPlayRec.timeLocal || "00:00:00.0"}} -
-
- -
-
-
TimeGPS
-
{{curPlayRec.timeGPS?.toFixed(2)}}
-
Latitude
-
{{curPlayRec.lat | coordinate:'lat':'DM' }}
-
Longitude
-
{{curPlayRec.lon | coordinate:'lon':'DM' }}
-
UTM X
-
{{NumUtils.fixedTo(curPlayRec.utmX, 1, '0.0')}}
-
UTM Y
-
{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}
-
Altitude
-
{{curPlayRec.alt | length:isUS:0}}
-
Speed
-
{{curPlayRec.speed | speed:isUS:true}}
-
AvgSXt/ Xt
-
{{playXt.avg | length:isUS:0 }} / {{curPlayRec.xt | xtract:isUS:0}}
-
TrckAngle
-
{{curPlayRec.trckAngle}}
- -
LckedLine
-
{{curPlayRec.lockedLine | lockline:curPlayLoc?.xTrack }}
-
-
HDOP
-
{{curPlayRec.hdop}}
-
Sat/Cor/ID
-
{{curPlayRec.sats || 0}} / {{curPlayRec.corId || 0}}/ {{curPlayRec.waasId}}
- -
SprayStat
-
{{curPlayLoc?.sprayStat}} (DEBUG)
-
-
-
- -
- -
Applic.RateAp
-
{{ curPlayRec.appRateAp | appRate:playMatType:isUS:null:false }}
-
Applic.RateRq
- -
{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}
-
- -
{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}
-
-
FlowRateAp -
-
{{curPlayRec.flowRateAp || 0 | flowRate:isUS }}
-
FlowRateRq
-
{{curPlayRec.flowRateRq || 0 | flowRate:isUS }}
-
- -
AvgRMP
-
{{(curPlayRec.avgRMP || 0) | number:'1.1-1':'en' }}
-
AppRateAp
-
{{(curPlayRec.flowRateAp || 0) | appRate:playMatType:isUS }}
-
AppRateRq
-
{{(curPlayRec.flowRateRq || 0) | appRate:playMatType:isUS }}
-
- -
Flow Control
-
{{curPlayRec.flowControl }}
- - -
Bm Pressure
-
{{curPlayRec.bmPressure | number:'1.1-1':'en'}} psi
-
- -
Area
-
{{ curPlayRec.area | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}
-
AreaSprIn
-
{{ UnitUtils.toArea(areaSprIn.total, isUS) | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}
-
AreaSprTot
-
{{ UnitUtils.toArea(areaSprTot.total, isUS) | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}
-
Swath Width
-
{{curPlayRec.swathWidth | length:isUS }}
-
AvgSprSpd
-
{{sprSpd.avg | speed:isUS:true }}
-
{{playMatType === MatType.LIQUID ? 'RPM' : 'AppRPM' }} 1/2
-
{{curPlayRec.rpm[0] || 0}} / {{curPlayRec.rpm[1] || 0}}
-
{{playMatType === MatType.LIQUID ? 'RPM 3/4' : 'TarRPM 1/2' }}
-
{{curPlayRec.rpm[2] || 0}} / {{curPlayRec.rpm[3] || 0}}
- - -
RPM 5/6
-
{{curPlayRec.rpm[4] || 0}} / {{curPlayRec.rpm[5] || 0}}
-
RPM 7/8
-
{{curPlayRec.rpm[6] || 0}} / {{curPlayRec.rpm[7] || 0}}
-
RPM 9/10
-
{{curPlayRec.rpm[8] || 0}} / {{curPlayRec.rpm[9] || 0}}
-
- -
GFC VIn
-
{{curPlayRec.rpm[4] | number:'1.1-1':'en'}}
-
Revs/Lb
-
{{UnitUtils.revkgTolb(curPlayRec.rpm[6]) | number:'1.2-2':'en'}} / {{UnitUtils.revkgTolb(curPlayRec.rpm[7]) | number:'1.2-2':'en'}}
-
Amp 1/2
-
{{curPlayRec.rpm[8] | number:'1.1-1':'en'}} / {{curPlayRec.rpm[9] | number:'1.1-1':'en'}}
-
- -
AutoSpr On/Off
-
{{curPlayRec.sprOnLag | number:'1.2-2':'en' }} / {{curPlayRec.sprOffLag | number:'1.2-2':'en'}}
- -
Pulses/Liter
-
{{curPlayRec.pulsesPLiter | number:'1.0-0':'en'}}
-
-
-
- -
-
Counter
-
{{(playIdx + 1)}}
-
TimeLocal
-
{{curPlayRec.timeLocal || "00:00:00.0"}}
-
TimeGPS
-
{{curPlayRec.timeGPS}}
-
Latitude
-
{{curPlayRec.lat | coordinate:'lat':'DM' }}
-
Longitude
-
{{curPlayRec.lon | coordinate:'lon':'DM' }}
-
UTM X
-
{{NumUtils.fixedTo(curPlayRec.utmX, 1, '0.0')}}
-
UTM Y
-
{{NumUtils.fixedTo(curPlayRec.utmY, 1, '0.0')}}
-
Speed
-
{{UnitUtils.mpsToKnot(curPlayRec.speed) | number:'1.1-1':'en'}} knots
- -
LckedLine
-
{{curPlayRec.lockedLine | lockline}}
-
-
Wind Spd
-
{{curPlayRec.windSpd | number:'1.1-1':'en' }} knots
-
Wind Dir
-
{{curPlayRec.windDir | number:'1.1-1':'en' }} °
-
Temperature
-
{{curPlayRec.temp | temperature:isUS }}
-
Humidity
-
{{curPlayRec.humid}} %
-
Spr. Height
-
{{curPlayRec.sprHeight | length:isUS:0 }}
-
DriftX
-
{{curPlayRec.driftX | length:isUS:0 }}
-
DriftY
-
{{curPlayRec.driftY | length:isUS:0 }}
-
DepositX
-
{{curPlayRec.depositX | length:isUS:0 }}
-
DepositY
-
{{curPlayRec.depositY | length:isUS:0 }}
-
GPS Alt
-
{{curPlayRec.alt | length:isUS:0 }}
-
Rad/Laser Alt
-
{{curPlayRec.radAlt | length:isUS:0 }} / {{curPlayRec.laserAlt | length:isUS:0}}
-
-
- -
- -
AreaName
-
{{curPlayRec.areaName}}
-
-
Mapped Area
-
{{curPlayRec.mappedArea | number:'1.1-1':'en' }} {{ currentJob.measureUnit | areaUnit:false }}
-
AreaSprTot
-
{{ UnitUtils.toArea(areaSprTot.total, isUS) | number:'1.1-1':'en'}} {{ currentJob.measureUnit | areaUnit:false }}
-
AvgSprSpd
-
{{sprSpd.avg | speed:isUS:true}}
-
Pilot Name
-
{{curPlayRec.pilotName}}
-
Applic.Rate
- - -
{{ curPlayRec.applicRate | number:'1.2-2':'en'}} {{ curPlayRec.applicRateUnit | rateUnit:2:false }}
-
- -
{{ curPlayRec.applicRate | appRate:playMatType:isUS:null:false }}
-
-
Mat Needed
-
{{( totalAmount?.value || 0) | number:'1.1-1':'en'}} {{ totalAmount?.appRateUnit | rateUnit:1:false }}
-
Mat Sprayed
-
{{ matSprayed.total | appVolume:totalAmount?.appRateUnit }}
-
OverSprayed
-
{{curPlayRec.overSprayed | number:'1.1-1':'en'}}%
-
-
-
-
-
\ No newline at end of file + + diff --git a/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts b/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts index e10ee2e..8e31991 100644 --- a/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts +++ b/Development/client/src/app/job/job-map-edit/job-map-edit.component.ts @@ -14,12 +14,17 @@ import { saveAs } from 'file-saver'; import cloneDeep from 'clone-deep'; import * as L from 'leaflet'; +import * as polygonClipping from 'polygon-clipping'; import '../../../assets/js/leaflet-corridor'; import '../../../assets/js/utm'; import '../../../assets/js/L.Control.MapCenterCoord'; import '../../../assets/js/leaflet.canvas-markers'; import '../../../assets/js/Leaflet.SelectAreaFeature'; +import '../../../assets/js/Leaflet.river'; import '../../../assets/js/leaflet.polylineDecorator'; +import '../../../assets/js/leaflet.geometryutil'; +import '../../../assets/js/leaflet.snap'; +import '../../../assets/js/leaflet.polylineoffset'; import { Obstacle } from '@app/domain/models/obstacle.model'; import { IJob, Area, WayPoint, ITEM, BufferZone, RptOption, WeatherInfo, defWeatherInfo, IUIJob } from '../models/job.model'; @@ -82,6 +87,70 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte bufColor = 'orange'; minBuf: number; maxBuf: number; + bufEdgeSide: 'on' | 'inside' | 'outside' = 'on'; + readonly edgeSideOptions = [ + { label: 'Inside', value: 'inside' }, + { label: 'On Edge', value: 'on' }, + { label: 'Outside', value: 'outside' } + ]; + + // ─── Advanced Buffer Tools panel state ───────────────────────────────────── + advBufPanelActive = false; // true when the Buffer Creation Mode chooser is visible + + // ─── Segment Buffer panel state (Advanced Buffer Tools → Segment) ──────────── + segBufPanelActive = false; // true when the segment buf editor panel is shown + segBufConfirmReady = false; // true after the corridor layer has been drawn + segBufName = ''; + segBufCreateAnother = false; + private _segBufLayer: any = null; // the pending L.corridor layer awaiting confirmation + + // ─── Edge Buffer (snap-to-polygon-edge) drawing state ─────────────────────── + edgeBufActive = false; + edgeBufDlgVisible = false; // true when editing an existing buffer via the click-to-edit dialog + edgeBufConfirmReady = false; // true after both snap points are set + edgeBufName = ''; // editable name field in the panel + edgeBufCreateAnother = false; // "Create another" checkbox + private _edgeBufPt1: { latlng: L.LatLng; segIdx: number; ring: L.LatLng[]; layer: any; } | null = null; + private _edgeBufPt2: { latlng: L.LatLng; segIdx: number; ring: L.LatLng[]; layer: any; } | null = null; + private _edgeBufPathFwd: L.LatLng[] = []; + private _edgeBufPathRev: L.LatLng[] = []; + private _edgeBufUseFwd = true; + private _edgeBufPreview: any = null; // L.Corridor preview layer + private _edgeBufMarker1: any = null; + private _edgeBufMarker2: any = null; + private _edgeBufClickHandler: ((e: any) => void) | null = null; + private _edgeBufMoveHandler: ((e: any) => void) | null = null; + private _edgeBufKeyHandler: ((e: KeyboardEvent) => void) | null = null; + private _edgeBufTooltipEl: HTMLElement | null = null; + private _edgeBufLastClickMs = 0; // used to deduplicate editableGrp + map level double-fire + private _edgeBufEditLayer: any = null; // non-null when editing an existing edge buffer + + // ─── Feature Buffer (water-body / school buffer) drawing state ─────────────── + featureBufActive = false; // true during polygon-draw phase + featureBufPanelActive = false; // true when the editor panel is shown + featureBufConfirmReady = false; + featureBufName = ''; + featureBufLoading = false; // true while fetching from Overpass API + featureBufFeatureType: 'water' | 'schools' | 'both' = 'water'; // Feature type dropdown + featureBufCreateAnother = false; // "Create another" checkbox + private _featureBufKeyHandler: ((e: KeyboardEvent) => void) | null = null; + private _featureBufTooltipEl: HTMLElement | null = null; + private _featureBufLoadingMoveHandler: ((e: MouseEvent) => void) | null = null; + private _featureBufPreviewLayers: any[] = []; // dashed buffer polygon previews + private _featureBufRiverLayers: any[] = []; // L.River / waterway visuals + private _featureBufSchoolLayers: any[] = []; // school polygon visuals + private _featureBufSearchAreaLayer: any = null; // drawn search-boundary polygon + private _featureBufWaterFeatures: any[] = []; // GeoJSON features from Overpass + private _featureBufSchoolFeatures: any[] = []; // school GeoJSON features from Overpass + get edgeBufIsEditing(): boolean { return !!this._edgeBufEditLayer && !this._edgeBufEditIsNew; } + private _edgeBufEditIsNew = false; // true when editing a freshly-created copy ("Create another") + edgeBufPanelTop = +( localStorage.getItem('edgeBufPanelTop') ?? '10') || 10; + edgeBufPanelLeft = +( localStorage.getItem('edgeBufPanelLeft') ?? '10') || 10; + private _edgeBufDragOffX = 0; + private _edgeBufDragOffY = 0; + private _edgeBufDragMoveHandler: ((e: MouseEvent) => void) | null = null; + private _edgeBufDragUpHandler: ((e: MouseEvent) => void) | null = null; + private readonly SNAP_TOLERANCE_PX = 20; towerColors = GC.selColors; ranges = {}; @@ -95,7 +164,17 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte totalAmount: any; private routeFlag: number; - private bufWidth: number = 0; + bufWidth: number = 0; + _bufWidthSlider: number = 0; + get bufWidthUnit(): 'm' | 'ft' { return this.isUS ? 'ft' : 'm'; } + get bufWidthInUnit(): number { + // bufWidth is always stored in user units (ft for US, m for metric) + return this.bufWidth; + } + get maxBufInUnit(): number { + // maxBuf is always in user units + return this.maxBuf; + } private locLayer: any; private lastLocFmt: string; @@ -304,6 +383,9 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this.markerDrawer = this.newMarkDrawer(type); break; + case DRAW.EDGE_BUFFER: + this.openAdvBufPanel(); + break; } } @@ -335,7 +417,8 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this.minBuf = this.isUS ? 100 : 30.48; this.maxBuf = this.isUS ? NumUtils.round(MAX_BUF_WIDTH / 0.3048) : MAX_BUF_WIDTH; - if (!this.bufWidth) this.bufWidth = this.minBuf; + if (!this.bufWidth) this.bufWidth = 30; // 30 metres default + this._bufWidthSlider = this._bufWidthToSlider(this.bufWidth); this.updateDlOps(); if (this.job.useCustWI && this.job.weatherInfo) { @@ -399,6 +482,7 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte this.drawItems = this.drawItems.concat([ { label: globals.waypoint, icon: '', command: () => { this.onSelectDrawItem(DRAW.WAYPOINT); } }, { label: globals.bufferZone, icon: '', command: () => { this.onSelectDrawItem(DRAW.BUFFER); } }, + { label: globals.edgeBufferZone, icon: '', command: () => { this.onSelectDrawItem(DRAW.EDGE_BUFFER); } }, { label: globals.placeMark, icon: '', command: () => { this.onSelectDrawItem(DRAW.PLACE); } }, { label: globals.obstacle, icon: '', command: () => { this.onSelectDrawItem(DRAW.OBSTACLE); } } ]); @@ -706,7 +790,31 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte } else if (type === ITEM.WAYPOINT) { this.job.waypoints.push(geoJson); } else if (type === ITEM.BUFFER) { - this.job.bufs.push(geoJson); + const origPath: L.LatLng[] | null = layer['_origPath'] || null; + const isEdgeBuf = !!geoJson.properties['edgeSide']; + if (origPath && isEdgeBuf) { + // Edge buffers are L.polygon — save the original traced line path (not the polygon) + // so that width/side changes can rebuild the polygon on load. + const bufGeo = { + ...geoJson, + geometry: { + type: 'LineString', + coordinates: origPath.map((ll: L.LatLng) => [ll.lng, ll.lat]) + } + }; + // Persist vertex-edited polygon so it survives save/reload. + // Use the layer's actual current geometry as the source of truth — Leaflet's + // edit mode updates latlngs in-place without updating _editedPolyPath. + const _saveLLs: any = (layer).getLatLngs(); + const _saveFlat: L.LatLng[] = Array.isArray(_saveLLs[0]) ? _saveLLs[0] : _saveLLs; + if (_saveFlat.length >= 3) { + bufGeo.properties['editedCoords'] = _saveFlat.map((ll: L.LatLng) => [ll.lng, ll.lat]); + } + this.job.bufs.push(bufGeo); + } else { + // Regular/segment corridor buffers: toGeoJSON() already gives the correct LineString. + this.job.bufs.push(geoJson); + } } else if (type === ITEM.PLACE) { this.job.places.push(geoJson); } @@ -714,6 +822,11 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte } } + cancelDrawing() { + this.cleanupEdgeBuf(); + super.cancelDrawing(); + } + protected setupMapTools() { if (!this.map.hasLayer(this.geoItemLGrp)) this.map.addLayer(this.geoItemLGrp); @@ -808,13 +921,1334 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte setTimeout(() => this.loadObstacles(), 1000); } + // ─── Edge Buffer (snap-to-polygon-edge) helpers ────────────────────────────── + + /** + * Returns the signed winding of a lat/lng ring: +1 = CCW (left of travel = polygon interior), -1 = CW. + * Uses the shoelace formula in (lng, lat) coordinates. + */ + private computeRingWinding(ring: L.LatLng[]): number { + let area = 0; + const n = ring.length; + for (let i = 0; i < n; i++) { + const a = ring[i], b = ring[(i + 1) % n]; + area += (a.lng * b.lat - b.lng * a.lat); + } + return area >= 0 ? 1 : -1; + } + + /** + * Converts an edge-side choice into an offset in metres. + * edgeSign: +1 means CCW ring so left-of-travel = inside. + */ + private computeEdgeOffsetM(widthM: number, side: 'on' | 'inside' | 'outside', edgeSign: number): number { + if (side === 'on') return 0; + // applyOffsetToPath: positive = RIGHT of travel direction. + // CCW ring (edgeSign=+1): interior is LEFT of travel, so inside = negative offset. + const halfW = widthM / 2; + return side === 'inside' ? -edgeSign * halfW : edgeSign * halfW; + } + + /** + * Builds a closed polygon ring representing the corridor strip. + * For inside/outside modes, one edge IS the original polygon boundary — bleed is impossible. + * For on-edge, both edges are half-width from the boundary. + * CCW ring (edgeSign=+1): right-of-travel = exterior, left-of-travel = interior. + * Cap ends that do NOT lie on the spray-area boundary receive rounded corners. + */ + private buildCorridorPolygon( + origPath: L.LatLng[], + side: 'on' | 'inside' | 'outside', + widthM: number, + edgeSign: number + ): L.LatLng[] { + const capR = widthM / 3; // rounding radius — ⅓ of buffer width gives a clearly rounded corner + + if (side === 'inside') { + const inner = this.applyOffsetToPath(origPath, -edgeSign * widthM); + return this._buildRoundedRing(origPath, inner, capR); + } else if (side === 'outside') { + const outer = this.applyOffsetToPath(origPath, edgeSign * widthM); + return this._buildRoundedRing(origPath, outer, capR); + } else { // 'on': straddles the edge equally + const inner = this.applyOffsetToPath(origPath, -edgeSign * widthM / 2); + const outer = this.applyOffsetToPath(origPath, edgeSign * widthM / 2); + // Neither side is on the spray boundary, so round interior corners of both sides. + const N = inner.length; + if (N < 2) return [...inner, ...outer.slice().reverse()]; + // Forward along inner path: round interior vertices [1 .. N-2] + const innerMid: L.LatLng[] = []; + for (let i = 1; i < N - 1; i++) { + innerMid.push(...this._roundCornerFlat(inner[i - 1], inner[i], inner[i + 1], capR)); + } + // Reverse along outer path: round interior vertices [N-2 .. 1] + const outerMid: L.LatLng[] = []; + for (let i = N - 2; i >= 1; i--) { + outerMid.push(...this._roundCornerFlat(outer[i + 1], outer[i], outer[i - 1], capR)); + } + // Caps (at pt1 and pt2) stay sharp — they are the user's snap points. + return [inner[0], ...innerMid, inner[N - 1], outer[N - 1], ...outerMid, outer[0]]; + } + } + + /** + * Assembles a corridor polygon ring from origPath (on the spray boundary) and offsetPath + * (the other long side). + * + * origPath forms the boundary-touching long side — corners stay sharp. + * offsetPath interior vertices (the bends that follow boundary corners) are rounded. + * Cap connections at pt1 (index 0) and pt2 (index N-1) stay sharp. + */ + private _buildRoundedRing(origPath: L.LatLng[], offsetPath: L.LatLng[], capR: number): L.LatLng[] { + const N = offsetPath.length; + if (N < 2) return [...origPath, ...offsetPath.slice().reverse()]; + + // Round interior vertices of offsetPath, traversed in reverse (N-2 down to 1). + // Traversal direction: offsetPath[N-1] → offsetPath[i+1] → offsetPath[i] → offsetPath[i-1] + const middleParts: L.LatLng[] = []; + for (let i = N - 2; i >= 1; i--) { + middleParts.push(...this._roundCornerFlat(offsetPath[i + 1], offsetPath[i], offsetPath[i - 1], capR)); + } + // origPath forward (sharp) → cap at offsetPath[N-1] (sharp) → rounded interior → cap at offsetPath[0] (sharp) + return [...origPath, offsetPath[N - 1], ...middleParts, offsetPath[0]]; + } + + /** + * Generates arc-interpolation points for a single rounded polygon corner. + * Uses a flat-earth (local Cartesian) approximation — valid for radii up to ~1 km. + * Returns the tangent point on the prev-side, arc intermediate points, and the tangent + * point on the next-side (replacing the sharp corner vertex entirely). + */ + private _roundCornerFlat( + prev: L.LatLng, corner: L.LatLng, next: L.LatLng, + radiusM: number, numPts: number = 10 + ): L.LatLng[] { + const toRad = (d: number) => d * Math.PI / 180; + const R = 6378137; + const cosLat = Math.cos(toRad(corner.lat)); + + const toXY = (ll: L.LatLng) => ({ + x: (ll.lng - corner.lng) * toRad(1) * R * cosLat, + y: (ll.lat - corner.lat) * toRad(1) * R, + }); + const toLl = (x: number, y: number): L.LatLng => L.latLng( + corner.lat + y / (toRad(1) * R), + corner.lng + x / (toRad(1) * R * cosLat), + ); + + const pp = toXY(prev), np = toXY(next); + const lenP = Math.sqrt(pp.x * pp.x + pp.y * pp.y); + const lenN = Math.sqrt(np.x * np.x + np.y * np.y); + if (lenP < 0.1 || lenN < 0.1) return [corner]; + + const upx = pp.x / lenP, upy = pp.y / lenP; // unit toward prev + const unx = np.x / lenN, uny = np.y / lenN; // unit toward next + + const a1 = Math.atan2(upy, upx); + const a2 = Math.atan2(uny, unx); + + let da = a2 - a1; + if (da > Math.PI) da -= 2 * Math.PI; + if (da < -Math.PI) da += 2 * Math.PI; + + const halfDa = da / 2; + const sinH = Math.sin(Math.abs(halfDa)); + const tanH = Math.tan(Math.abs(halfDa)); + if (sinH < 0.01) return [corner]; // nearly straight — nothing to round + + // Concave fillet construction: + // tangent length tLen = arcRadius / tan(halfAngle) + // arc center is on the interior bisector at distance arcRadius / sin(halfAngle). + // Clamp tLen so the tangent points stay within their edge segments, then + // derive the (possibly reduced) arc radius from the clamped tLen. + const tLenMax = Math.min(lenP * 0.45, lenN * 0.45, radiusM / tanH); + if (tLenMax < 0.5) return [corner]; + const r = tLenMax * tanH; // actual arc radius after clamping + const tLen = tLenMax; // tangent length from corner to each tangent point + + // Tangent points (on the two edges, at distance tLen from corner) + const t1x = tLen * upx, t1y = tLen * upy; + const t2x = tLen * unx, t2y = tLen * uny; + + // Arc center: interior bisector at distance r / sin(halfAngle) from corner + const bisAngle = a1 + halfDa; + const d = r / sinH; + const cx = d * Math.cos(bisAngle); + const cy = d * Math.sin(bisAngle); + + // Angles from center to each tangent point + const startAngle = Math.atan2(t1y - cy, t1x - cx); + const endAngle = Math.atan2(t2y - cy, t2x - cx); + + // Sweep the shorter arc (which curves concavely — inward through the corner) + let sweep = endAngle - startAngle; + if (sweep > Math.PI) sweep -= 2 * Math.PI; + if (sweep < -Math.PI) sweep += 2 * Math.PI; + + const pts: L.LatLng[] = []; + for (let i = 0; i <= numPts; i++) { + const a = startAngle + sweep * i / numPts; + pts.push(toLl(cx + r * Math.cos(a), cy + r * Math.sin(a))); + } + return pts; + } + + /** + * Shifts a polyline path laterally by offsetMeters using per-segment perpendicular bearing. + * Positive = left of travel direction, negative = right. + */ + private applyOffsetToPath(path: L.LatLng[], offsetMeters: number): L.LatLng[] { + if (!offsetMeters || path.length < 2) return path; + const R = 6378137; // Earth radius metres (WGS84) + const toRad = (d: number) => d * Math.PI / 180; + const toDeg = (r: number) => r * 180 / Math.PI; + + // Forward bearing in radians from a to b + const bearing = (a: L.LatLng, b: L.LatLng): number => { + const lat1 = toRad(a.lat), lat2 = toRad(b.lat); + const dLng = toRad(b.lng - a.lng); + return Math.atan2( + Math.sin(dLng) * Math.cos(lat2), + Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng) + ); + }; + + // Destination point given start, bearing (rad), distance (m) + const destPoint = (p: L.LatLng, brng: number, dist: number): L.LatLng => { + const d = dist / R; + const lat1 = toRad(p.lat), lng1 = toRad(p.lng); + const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(brng)); + const lng2 = lng1 + Math.atan2(Math.sin(brng) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - Math.sin(lat1) * Math.sin(lat2)); + return L.latLng(toDeg(lat2), toDeg(lng2)); + }; + + // Pre-compute the right-perpendicular bearing for each segment + const segPerps = path.slice(0, -1).map((pt, i) => bearing(pt, path[i + 1]) + Math.PI / 2); + + return path.map((pt, i) => { + let brng: number; + let dist = offsetMeters; + + if (i === 0) { + // Start endpoint: perpendicular to first segment + brng = segPerps[0]; + } else if (i === path.length - 1) { + // End endpoint: perpendicular to last segment + brng = segPerps[segPerps.length - 1]; + } else { + // Interior point: miter bisector of adjacent segment perpendiculars. + // In geographic bearing convention: east-component = sin(θ), north-component = cos(θ) + const p1 = segPerps[i - 1], p2 = segPerps[i]; + const nx = Math.sin(p1) + Math.sin(p2); + const ny = Math.cos(p1) + Math.cos(p2); + const mag = Math.sqrt(nx * nx + ny * ny); + if (mag < 0.01) { + // ~180° U-turn: skip (use endpoint perpendicular) + brng = p1; + } else { + brng = Math.atan2(nx, ny); + // Scale so the perpendicular component equals offsetMeters + // mag/2 = cos(half-angle); dist = offsetMeters / cos(half-angle) + const scale = 2 / mag; + dist = offsetMeters * Math.min(scale, 4); // clamp to 4× to avoid extreme spikes + } + } + + return destPoint(pt, brng, dist); + }); + } + + /** + * Collects all spray area polygon rings currently on the map as arrays of LatLngs. + * Returns only ITEM.SPRAY and ITEM.XCL polygon layers. + */ + private getSprayRings(): { layer: any; ring: L.LatLng[] }[] { + const result: { layer: any; ring: L.LatLng[] }[] = []; + this.editableGrp.eachLayer((layer: any) => { + if (layer instanceof L.Polygon && layer.feature && (layer.feature.properties.type === ITEM.SPRAY || layer.feature.properties.type === ITEM.XCL)) { + const lls = (layer as L.Polygon).getLatLngs() as any[]; + // Polygon.getLatLngs() returns LatLng[][] (array of rings); lls[0] is the outer ring. + // For multi-polygons (LatLng[][][]) lls[0][0] is the outer ring. + const outerRing = lls[0]; + const ring: L.LatLng[] = Array.isArray(outerRing[0]) ? outerRing[0] : outerRing; + result.push({ layer, ring }); + } + }); + return result; + } + + /** + * Snaps `latlng` to the nearest polygon edge within tolerance pixels. + * Returns { latlng: snapped point, segIdx: segment start index, ring } or null. + */ + private snapToEdge(latlng: L.LatLng): { latlng: L.LatLng; segIdx: number; ring: L.LatLng[]; layer: any; } | null { + const rings = this.getSprayRings(); + let bestDist = Infinity; + let best: { latlng: L.LatLng; segIdx: number; ring: L.LatLng[]; layer: any; } | null = null; + + for (const { ring, layer } of rings) { + const n = ring.length; + for (let i = 0; i < n; i++) { + const a = ring[i]; + const b = ring[(i + 1) % n]; + const snapped = (L).GeometryUtil.closestOnSegment(this.map, latlng, a, b); + const dist = this.map.latLngToLayerPoint(latlng).distanceTo(this.map.latLngToLayerPoint(snapped)); + if (dist < bestDist) { + bestDist = dist; + best = { latlng: snapped, segIdx: i, ring, layer }; + } + } + } + + return best && bestDist <= this.SNAP_TOLERANCE_PX ? best : null; + } + + /** + * Traces the polygon ring between pt1 and pt2 in the forward direction + * (pt1.segIdx → pt1.segIdx+1 → … → pt2, inserting all intermediate vertices). + */ + private tracePolygonEdgeFwd(pt1: { latlng: L.LatLng; segIdx: number; ring: L.LatLng[] }, + pt2: { latlng: L.LatLng; segIdx: number; ring: L.LatLng[] }): L.LatLng[] { + const ring = pt1.ring; + const n = ring.length; + const path: L.LatLng[] = [pt1.latlng]; + let idx = (pt1.segIdx + 1) % n; + while (idx !== (pt2.segIdx + 1) % n) { + path.push(ring[idx]); + idx = (idx + 1) % n; + if (path.length > n + 2) break; // Safety guard against infinite loops + } + path.push(pt2.latlng); + return path; + } + + private snapMarkerIcon(): L.DivIcon { + return L.divIcon({ className: 'edge-buf-snap-marker', iconSize: [12, 12], iconAnchor: [6, 6] }); + } + + /** + * Enters the Edge Buffer drawing mode: shows instructional tooltip, wires click/move/key handlers. + */ + private startEdgeBufMode() { + this.cleanupEdgeBuf(); + this.edgeBufActive = true; + this.edgeBufConfirmReady = false; + this._edgeBufUseFwd = true; + // Pre-populate name so it shows immediately when the panel opens + const _tempFeature = { feature: { properties: { type: ITEM.BUFFER } } }; + this.edgeBufName = this.getDefaultName(_tempFeature); + + const clickHandler = (e: any) => { + // Deduplicate: editableGrp click and map click both fire for the same polygon click + const now = Date.now(); + if (now - this._edgeBufLastClickMs < 150) return; + this._edgeBufLastClickMs = now; + + const snap = this.snapToEdge(e.latlng); + if (!snap) return; + + if (!this._edgeBufPt1) { + // First point + this._edgeBufPt1 = snap; + this._edgeBufMarker1 = L.marker(snap.latlng, { icon: this.snapMarkerIcon(), interactive: false, pane: PANE.SprayZones }).addTo(this.map); + } else if (!this._edgeBufPt2) { + // Second point — only allow on the same polygon + if (snap.layer !== this._edgeBufPt1.layer) { + this.msgSvc.addWarnMsg($localize`:@@edgeBufSameRing:Both points must be on the same area boundary.`); + return; + } + this._edgeBufPt2 = snap; + this._edgeBufMarker2 = L.marker(snap.latlng, { icon: this.snapMarkerIcon(), interactive: false, pane: PANE.SprayZones }).addTo(this.map); + + // Build the two alternative paths + this._edgeBufPathFwd = this.tracePolygonEdgeFwd(this._edgeBufPt1, this._edgeBufPt2); + this._edgeBufPathRev = this.tracePolygonEdgeFwd(this._edgeBufPt2, this._edgeBufPt1).reverse(); + this._edgeBufPathRev.push(this._edgeBufPt2.latlng); // make it go from pt1→pt2 in reverse order + + this._edgeBufUseFwd = true; + this.renderEdgeBufPreview(); + this.edgeBufConfirmReady = true; + this.cdRef.detectChanges(); + } + }; + + // Create cursor-following tooltip (matches Leaflet Draw tooltip style) + const ttEl = document.createElement('div'); + ttEl.className = 'leaflet-draw-tooltip leaflet-draw-tooltip-single'; + ttEl.style.cssText = 'pointer-events:none;z-index:1001;margin:0;'; + ttEl.innerHTML = `${$localize`:@@edgeBufClickStart:Click on an area boundary to start`}`; + this.map.getContainer().appendChild(ttEl); + this._edgeBufTooltipEl = ttEl; + + const moveHandler = (e: any) => { + if (this._edgeBufPt2) { + // Both points chosen — hide tooltip + if (this._edgeBufTooltipEl) this._edgeBufTooltipEl.style.visibility = 'hidden'; + this.map.getContainer().style.cursor = ''; + return; + } + const containerPt = this.map.latLngToContainerPoint(e.latlng); + if (this._edgeBufTooltipEl) { + this._edgeBufTooltipEl.style.visibility = 'visible'; + this._edgeBufTooltipEl.style.left = (containerPt.x + 20) + 'px'; + this._edgeBufTooltipEl.style.top = (containerPt.y - 10) + 'px'; + const snap = this.snapToEdge(e.latlng); + const msg = this._edgeBufPt1 + ? $localize`:@@edgeBufClickFinish:Click again to finish` + : $localize`:@@edgeBufClickStart:Click on an area boundary to start`; + this._edgeBufTooltipEl.innerHTML = `${msg}`; + this.map.getContainer().style.cursor = snap ? 'crosshair' : ''; + } else { + const snap = this.snapToEdge(e.latlng); + this.map.getContainer().style.cursor = snap ? 'crosshair' : ''; + } + }; + + const keyHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.cancelEdgeBuf(); + }; + + this._edgeBufClickHandler = clickHandler; + this._edgeBufMoveHandler = moveHandler; + this._edgeBufKeyHandler = keyHandler; + this.map.on('click', clickHandler); + this.map.on('mousemove', moveHandler); + document.addEventListener('keydown', keyHandler); + } + + private _bufWidthToSlider(w: number): number { + if (!w || !this.maxBuf) return 0; + return Math.round(100 * Math.log(w) / Math.log(this.maxBuf)); + } + + onBufWidthSliderChange(val: number): void { + this.bufWidth = Math.round(Math.exp(val / 100 * Math.log(this.maxBuf))); + this._bufWidthSlider = val; + this.renderEdgeBufPreview(true); + this._applySegBufWidthPreview(); + this.renderFeatureBufPreview(); + } + + onBufWidthInputChange(val: number): void { + // val is in user units (ft for US, m for metric); store as-is like the slider does + const clamped = Math.max(this.minBuf, Math.min(this.maxBuf, val || this.minBuf)); + this.bufWidth = Math.round(clamped); + this._bufWidthSlider = this._bufWidthToSlider(this.bufWidth); + this.renderEdgeBufPreview(true); + this._applySegBufWidthPreview(); + this.renderFeatureBufPreview(); + } + + /** Live-updates the pending segment buffer corridor width. */ + private _applySegBufWidthPreview() { + if (!this._segBufLayer) return; + const corridorM = NumUtils.round(UnitUtils.toMeter(this.bufWidth || 30, this.isUS), 1); + this._segBufLayer.options['corridor'] = corridorM; + try { (this._segBufLayer as any)._updateWeight(); } catch (_) {} + try { (this._segBufLayer as any).updateArea(); } catch (_) {} + this.updateSprayAreas(); + if (this._segBufLayer.getTooltip()) + this._segBufLayer.getTooltip().setContent(this.ttipText(this._segBufLayer)); + } + + renderEdgeBufPreview(rebuildFromParams = false) { + if (this._edgeBufPreview) { + this.map.removeLayer(this._edgeBufPreview); this._edgeBufPreview = null; + } + // When a param (width/side/flip) changes, discard any saved vertex-edited shape + if (rebuildFromParams && this._edgeBufEditLayer) delete this._edgeBufEditLayer['_editedPolyPath']; + // On reopen use the saved vertex-edited shape instead of rebuilding from origPath + const savedPolyPath: L.LatLng[] | null = + (!rebuildFromParams && this._edgeBufEditLayer) ? (this._edgeBufEditLayer['_editedPolyPath'] || null) : null; + let polyPath: L.LatLng[]; + if (savedPolyPath) { + polyPath = savedPolyPath; + } else { + const origPath = this._edgeBufUseFwd ? this._edgeBufPathFwd : this._edgeBufPathRev; + if (!origPath || origPath.length < 2) return; + const corridorM = NumUtils.round(UnitUtils.toMeter(this.bufWidth || 30, this.isUS), 1); + const edgeSign = this._edgeBufPt1 ? this.computeRingWinding(this._edgeBufPt1.ring) + : (this._edgeBufEditLayer ? (this._edgeBufEditLayer.feature.properties.edgeSign || 1) : 1); + polyPath = this.buildCorridorPolygon(origPath, this.bufEdgeSide, corridorM, edgeSign); + } + this._edgeBufPreview = L.polygon([polyPath], { + color: this.bufColor, + fillColor: this.bufColor, + fillOpacity: 0.4, + opacity: 0.85, + weight: 2, + dashArray: '6 4', + pane: PANE.XCLZones + } as any).addTo(this.map); + // Do NOT enable vertex editing here — shape can only be changed via Leaflet's edit mode toolbar. + } + + /** True when the flip button should be shown — requires both path directions to be available. */ + get canFlipEdgeBuf(): boolean { + return this.edgeBufConfirmReady && (!!this._edgeBufPt1 || this._edgeBufPathRev.length > 0); + } + + /** Flip the traced path direction between forward and reverse. */ + flipEdgeBufDirection() { + // In edit mode _edgeBufPt1/_edgeBufPt2 are not set; use _edgeBufPathRev length instead + if (!this._edgeBufPt1 && !this._edgeBufPathRev.length) return; + this._edgeBufUseFwd = !this._edgeBufUseFwd; + this.renderEdgeBufPreview(true); + } + + /** Open the top-left editor panel to adjust an existing edge buffer layer. */ + openEdgeBufEditor(layer: any, asDlg = false) { + this.cleanupEdgeBuf(); + this._edgeBufEditLayer = layer; + this._edgeBufPathFwd = layer['_origPath'] ? [...layer['_origPath']] : []; + this._edgeBufPathRev = layer['_origPathAlt'] ? [...layer['_origPathAlt']] : []; + this._edgeBufUseFwd = true; + // Edge buffer width is stored in metres; convert to user units for the panel + const _storedWidthM = layer.feature.properties.width || 30; + this.bufWidth = this.isUS ? Math.round(_storedWidthM / 0.3048) : _storedWidthM; + this._bufWidthSlider = this._bufWidthToSlider(this.bufWidth); + this.bufEdgeSide = layer.feature.properties.edgeSide || 'on'; + this.edgeBufName = layer.feature.properties.name || ''; + this.edgeBufActive = !asDlg; + this.edgeBufConfirmReady = true; + this.edgeBufDlgVisible = asDlg; + // Sync _editedPolyPath from the layer's actual current geometry. + // Leaflet's edit mode updates latlngs in-place but does not update _editedPolyPath, + // so without this the dialog preview reverts to the pre-edit shape. + const _curLLs: any = layer.getLatLngs(); + const _flatLLs: L.LatLng[] = Array.isArray(_curLLs[0]) ? _curLLs[0] : _curLLs; + if (_flatLLs.length >= 3) layer['_editedPolyPath'] = _flatLLs; + // Hide the original polygon so only the preview is visible while editing + if (layer && this.map.hasLayer(layer)) layer.setStyle({ opacity: 0, fillOpacity: 0 }); + this.renderEdgeBufPreview(); + this.cdRef.detectChanges(); + } + + /** Save the edge buffer: updates an existing layer or creates a new one. No confirmation dialog. */ + confirmEdgeBuf() { + if (!this.edgeBufConfirmReady) return; + // bufWidth is in user units; convert to metres for buildCorridorPolygon and storage + const corridorM = NumUtils.round(UnitUtils.toMeter(this.bufWidth || 30, this.isUS), 1); + + if (this._edgeBufEditLayer) { + // ── Update existing edge buffer ─────────────────────────────────────── + const layer = this._edgeBufEditLayer; + const edgeSign: number = layer.feature.properties.edgeSign || 1; + const polyPath = this._edgeBufPreview + ? (this._edgeBufPreview as any).getLatLngs()[0] as L.LatLng[] + : this.buildCorridorPolygon(this._edgeBufPathFwd, this.bufEdgeSide, corridorM, edgeSign); + layer['_origPath'] = [...this._edgeBufPathFwd]; + layer['_origPathAlt'] = [...this._edgeBufPathRev]; + layer['_editedPolyPath'] = polyPath; // Persist vertex edits for next reopen + layer.feature.properties.width = corridorM; + layer.feature.properties.edgeSide = this.bufEdgeSide; + layer.feature.properties.editedCoords = (polyPath as L.LatLng[]).map((ll: L.LatLng) => [ll.lng, ll.lat]); + if (this.edgeBufName && this.edgeBufName.trim()) layer.feature.properties.name = this.edgeBufName.trim(); + (layer).setLatLngs([polyPath]); + // L.Edit.Poly captures a reference to _latlngs at construction time. + // After setLatLngs() a new array is created, so we must update the reference + // or edit-mode vertex markers will be positioned on the old geometry. + if ((layer).editing) { + (layer).editing.latlngs = [(layer)._latlngs]; + } + if (layer.getTooltip()) + layer.getTooltip().setContent(this.ttipText(layer)); + this.updateSprayAreas(); + + if (this._edgeBufEditIsNew && this.edgeBufCreateAnother) { + // "Create another" — restart drawing mode so user picks a new edge on the map + this.startEdgeBufMode(); + this.edgeBufCreateAnother = true; // preserve the checkbox across restart + } else { + this.cleanupEdgeBuf(); + } + return; + } + + // ── Create new edge buffer ──────────────────────────────────────────── + if (!this._edgeBufPt1 || !this._edgeBufPt2 || !this._edgeBufPreview) return; + const origPath = this._edgeBufUseFwd ? this._edgeBufPathFwd : this._edgeBufPathRev; + const edgeSign = this.computeRingWinding(this._edgeBufPt1.ring); + const polyPath = (this._edgeBufPreview as any).getLatLngs()[0] as L.LatLng[]; + const ops: any = { + color: this.bufColor, + fillColor: this.bufColor, + fillOpacity: 0.5, + opacity: 0.85, + weight: 2, + pane: PANE.XCLZones + }; + const bufLine: any = L.polygon([polyPath], ops); + bufLine['_origPath'] = origPath; // Store original so width/side changes can rebuild + bufLine['_origPathAlt'] = this._edgeBufUseFwd ? this._edgeBufPathRev : this._edgeBufPathFwd; // The other edge route + bufLine['_editedPolyPath'] = polyPath; // Persist vertex edits for next reopen + const feature = bufLine.feature = bufLine.feature || {}; + feature['properties'] = { + type: ITEM.BUFFER, + name: '', + width: corridorM, + edgeSide: this.bufEdgeSide, + edgeSign: edgeSign + }; + bufLine.feature['fId'] = this.newFId; + bufLine.feature['properties']['name'] = (this.edgeBufName && this.edgeBufName.trim()) + ? this.edgeBufName.trim() + : this.getDefaultName(bufLine); + bufLine.feature['properties']['editedCoords'] = (polyPath as L.LatLng[]).map((ll: L.LatLng) => [ll.lng, ll.lat]); + + if (!this.showMeasure) + bufLine.bindTooltip(this.ttipText(bufLine), this.getTTOps(ITEM.BUFFER)); + + this.editableGrp.addLayer(bufLine); + this.mapItems = this.mapItems.concat(bufLine); + this.updateSprayAreas(); + + if (this.edgeBufCreateAnother) { + // "Create another" — restart drawing mode so user picks a new edge on the map + this.startEdgeBufMode(); + this.edgeBufCreateAnother = true; // preserve the checkbox across restart + } else { + this.cleanupEdgeBuf(); + } + } + + /** Opens the Buffer Creation Mode chooser panel. */ + openAdvBufPanel() { + this.cleanupEdgeBuf(false); + this.advBufPanelActive = true; + this.cdRef.detectChanges(); + } + + /** Called when user picks "Segment" in the chooser — shows the segment panel and starts drawing. */ + onAdvBufSegment() { + this.advBufPanelActive = false; + this.cleanupSegBuf(); + this.segBufPanelActive = true; + const _tempFeature = { feature: { properties: { type: ITEM.BUFFER } } }; + this.segBufName = this.getDefaultName(_tempFeature, 1); + this.onSelectDrawItem(DRAW.BUFFER); + this.cdRef.detectChanges(); + } + + /** Confirm the pending segment buffer. */ + confirmSegBuf() { + if (!this._segBufLayer || !this.segBufConfirmReady) return; + const layer = this._segBufLayer; + // bufWidth is in user units; corridor needs metres; width property stores user units (like regular buf) + const corridorM = NumUtils.round(UnitUtils.toMeter(this.bufWidth || 30, this.isUS), 1); + layer.feature['properties']['name'] = (this.segBufName && this.segBufName.trim()) + ? this.segBufName.trim() + : this.getDefaultName(layer); + layer.feature['properties']['width'] = NumUtils.round(this.bufWidth, 1); + layer.options['corridor'] = corridorM; + try { (layer as any)._updateWeight(); } catch (_) {} + try { (layer as any).updateArea(); } catch (_) {} + if (layer.getTooltip()) layer.getTooltip().setContent(this.ttipText(layer)); + this.updateSprayAreas(); + + if (this.segBufCreateAnother) { + this.cleanupSegBuf(); + this.segBufPanelActive = true; + const _tempFeature = { feature: { properties: { type: ITEM.BUFFER } } }; + this.segBufName = this.getDefaultName(_tempFeature, 1); + this.onSelectDrawItem(DRAW.BUFFER); + this.cdRef.detectChanges(); + } else { + this.cleanupSegBuf(); + } + } + + /** Cancel the pending segment buffer — discard the pending layer and return to chooser. */ + cancelSegBuf() { + if (this._segBufLayer) { + try { this.editableGrp.removeLayer(this._segBufLayer); } catch (_) {} + this.mapItems = this.mapItems.filter((l: any) => l !== this._segBufLayer); + } + // Stop any in-progress Leaflet draw + if (this.bufDrawer) { try { (this.bufDrawer as any).disable(); } catch (_) {} } + this.cleanupSegBuf(); + this.advBufPanelActive = true; + this.cdRef.detectChanges(); + } + + /** Tear down segment buf panel state. */ + private cleanupSegBuf() { + this._segBufLayer = null; + this.segBufPanelActive = false; + this.segBufConfirmReady = false; + this.segBufName = ''; + this.segBufCreateAnother = false; + } + + /** Called when user picks "Edge" in the chooser — starts edge snap mode. */ + onAdvBufEdge() { + this.advBufPanelActive = false; + this.startEdgeBufMode(); + } + + /** Cancel button on the edge buffer panel: tear down edge mode and re-show chooser (unless editing existing). */ + cancelEdgeBuf() { + // Guard against double-call: (onHide) fires after the Cancel button already cleaned up. + if (!this.edgeBufActive && !this.edgeBufDlgVisible && !this._edgeBufEditLayer) return; + const wasEditingExisting = !!this._edgeBufEditLayer; + this.cleanupEdgeBuf(false); + if (!wasEditingExisting) { + this.advBufPanelActive = true; + this.cdRef.detectChanges(); + } + } + + /** Handler for edge-side selector change from the shared panel component. */ + onEdgeSideChange(side: string) { + this.bufEdgeSide = side as 'inside' | 'on' | 'outside'; + this.renderEdgeBufPreview(true); + } + + /** Close everything — both chooser and edge/segment panels. */ + closeAdvBuf() { + this.advBufPanelActive = false; + this.cleanupEdgeBuf(false); + this.cancelSegBuf(); + this.cleanupFeatureBuf(); + this.advBufPanelActive = false; // cancelSegBuf sets it back; force off + } + + /** Tears down all edge buffer drawing state: removes handlers, markers, and preview layer. */ + cleanupEdgeBuf(reopenChooser = false) { + if (this._edgeBufTooltipEl) { this._edgeBufTooltipEl.remove(); this._edgeBufTooltipEl = null; } + if (this._edgeBufClickHandler) { this.map.off('click', this._edgeBufClickHandler); this._edgeBufClickHandler = null; } + if (this._edgeBufMoveHandler) { this.map.off('mousemove', this._edgeBufMoveHandler); this._edgeBufMoveHandler = null; } + if (this._edgeBufKeyHandler) { document.removeEventListener('keydown', this._edgeBufKeyHandler); this._edgeBufKeyHandler = null; } + if (this._edgeBufPreview) { try { (this._edgeBufPreview as any).editing.disable(); } catch (_) {} this.map.removeLayer(this._edgeBufPreview); this._edgeBufPreview = null; } + if (this._edgeBufMarker1) { this.map.removeLayer(this._edgeBufMarker1); this._edgeBufMarker1 = null; } + if (this._edgeBufMarker2) { this.map.removeLayer(this._edgeBufMarker2); this._edgeBufMarker2 = null; } + this._edgeBufPt1 = null; + this._edgeBufPt2 = null; + this._edgeBufPathFwd = []; + this._edgeBufPathRev = []; + this._edgeBufLastClickMs = 0; + // Restore original layer visibility if edit was cancelled + if (this._edgeBufEditLayer && this.map && this.map.hasLayer(this._edgeBufEditLayer)) { + this._edgeBufEditLayer.setStyle({ opacity: 0.85, fillOpacity: 0.5 }); + } + this._edgeBufEditLayer = null; + this.bufEdgeSide = 'on'; + this.edgeBufActive = false; + this.edgeBufConfirmReady = false; + this.edgeBufName = ''; + this.edgeBufCreateAnother = false; + this._edgeBufEditIsNew = false; + this.edgeBufDlgVisible = false; + if (this._edgeBufDragMoveHandler) { document.removeEventListener('mousemove', this._edgeBufDragMoveHandler); this._edgeBufDragMoveHandler = null; } + if (this._edgeBufDragUpHandler) { document.removeEventListener('mouseup', this._edgeBufDragUpHandler); this._edgeBufDragUpHandler = null; } + if (this.map) { + this.map.getContainer().style.cursor = ''; + // Vertex editing can internally call map.dragging.disable() on mousedown; if cleanup + // runs before the matching mouseup fires, dragging stays disabled. Reset it unconditionally. + try { this.map.dragging.disable(); this.map.dragging.enable(); } catch (_) {} + } + if (reopenChooser) { + this.advBufPanelActive = true; + } + this.cdRef.detectChanges(); + } + + onEdgeBufPanelDragStart(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + const mapRect = this.map.getContainer().getBoundingClientRect(); + this._edgeBufDragOffX = event.clientX - mapRect.left - this.edgeBufPanelLeft; + this._edgeBufDragOffY = event.clientY - mapRect.top - this.edgeBufPanelTop; + this._edgeBufDragMoveHandler = (e: MouseEvent) => { + const rect = this.map.getContainer().getBoundingClientRect(); + this.edgeBufPanelLeft = Math.max(0, e.clientX - rect.left - this._edgeBufDragOffX); + this.edgeBufPanelTop = Math.max(0, e.clientY - rect.top - this._edgeBufDragOffY); + this.cdRef.detectChanges(); + }; + this._edgeBufDragUpHandler = () => { + document.removeEventListener('mousemove', this._edgeBufDragMoveHandler!); + document.removeEventListener('mouseup', this._edgeBufDragUpHandler!); + this._edgeBufDragMoveHandler = null; + this._edgeBufDragUpHandler = null; + localStorage.setItem('edgeBufPanelTop', String(this.edgeBufPanelTop)); + localStorage.setItem('edgeBufPanelLeft', String(this.edgeBufPanelLeft)); + }; + document.addEventListener('mousemove', this._edgeBufDragMoveHandler); + document.addEventListener('mouseup', this._edgeBufDragUpHandler); + } + + // ─── End Edge Buffer helpers ───────────────────────────────────────────────── + + // ─── Feature Buffer helpers (water-body buffers via OpenStreetMap) ─────────── + + /** Called when user picks "Feature" in the chooser — starts spray-zone selection mode. */ + onAdvBufFeature() { + this.advBufPanelActive = false; + this.startFeatureBufMode(); + } + + private startFeatureBufMode() { + this.cleanupFeatureBuf(); + this.featureBufActive = true; + const _tempFeature = { feature: { properties: { type: ITEM.BUFFER } } }; + this.featureBufName = this.getDefaultName(_tempFeature); + + // Use the standard polygon drawer with custom tooltips + (L).drawLocal.draw.handlers.polygon = { + tooltip: { + start: 'Click to start drawing the search area.', + cont: 'Click to continue drawing the search area.', + end: 'Click the first point to close the search area.' + } + }; + this.polygonDrawer.setOptions({ + showArea: false, + showLength: false, + allowIntersection: false, + shapeOptions: { + color: '#1565C0', fillColor: '#1E88E5', fillOpacity: 0.15, + weight: 2, dashArray: '6 4', + pane: PANE.SprayZones + }, + metric: !this.isUS + }); + this.polygonDrawer.enable(); + + const keyHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.cancelFeatureBuf(); + }; + this._featureBufKeyHandler = keyHandler; + document.addEventListener('keydown', keyHandler); + this.cdRef.detectChanges(); + } + + private async loadWaterFeatures(drawnLayer: any) { + if (this._featureBufTooltipEl) { this._featureBufTooltipEl.remove(); this._featureBufTooltipEl = null; } + this.featureBufActive = false; + this.featureBufLoading = true; + this.featureBufPanelActive = true; + this.map.getContainer().style.cursor = 'wait'; + + // Show a cursor-following tooltip while the Overpass fetch is in progress. + // Position it over the centre of the drawn area immediately (visibility:visible overrides + // Leaflet Draw's default visibility:hidden), then track the mouse from there. + const loadingTtEl = document.createElement('div'); + loadingTtEl.className = 'leaflet-draw-tooltip leaflet-draw-tooltip-single'; + loadingTtEl.style.cssText = 'pointer-events:none;z-index:1001;margin:0;visibility:visible;'; + loadingTtEl.innerHTML = 'Loading features from OpenStreetMap\u2026'; + const initPt = this.map.latLngToContainerPoint(drawnLayer.getBounds().getCenter()); + loadingTtEl.style.left = (initPt.x + 20) + 'px'; + loadingTtEl.style.top = (initPt.y - 10) + 'px'; + this.map.getContainer().appendChild(loadingTtEl); + this._featureBufTooltipEl = loadingTtEl; + this._featureBufLoadingMoveHandler = (e: MouseEvent) => { + const rect = this.map.getContainer().getBoundingClientRect(); + loadingTtEl.style.left = (e.clientX - rect.left + 20) + 'px'; + loadingTtEl.style.top = (e.clientY - rect.top - 10) + 'px'; + }; + this.map.getContainer().addEventListener('mousemove', this._featureBufLoadingMoveHandler); + + this.cdRef.detectChanges(); + + const bounds = drawnLayer.getBounds(); + const sprayGeoJSON = drawnLayer.toGeoJSON(); + const [waterFeatures, schoolFeatures] = await Promise.all([ + this._fetchOsmWaterFeatures(bounds, sprayGeoJSON), + this._fetchOsmSchoolFeatures(bounds, sprayGeoJSON) + ]); + this._featureBufWaterFeatures = waterFeatures; + this._featureBufSchoolFeatures = schoolFeatures; + + // Remove loading tooltip and restore cursor + if (this._featureBufLoadingMoveHandler) { + this.map.getContainer().removeEventListener('mousemove', this._featureBufLoadingMoveHandler); + this._featureBufLoadingMoveHandler = null; + } + if (this._featureBufTooltipEl) { this._featureBufTooltipEl.remove(); this._featureBufTooltipEl = null; } + + this.featureBufLoading = false; + const enabledCount = this._getEnabledFeatures().length; + this.featureBufConfirmReady = enabledCount > 0; + this.map.getContainer().style.cursor = ''; + this.renderFeatureBufPreview(); + if (enabledCount === 0) { + this.msgSvc.addWarnMsg('No water bodies or schools found in the selected area.'); + } + this.cdRef.detectChanges(); + } + + private async _fetchOsmWaterFeatures(bounds: L.LatLngBounds, sprayGeoJSON: any): Promise { + const s = bounds.getSouth(), w = bounds.getWest(), n = bounds.getNorth(), e = bounds.getEast(); + // Query waterway lines and area water bodies within the bounding box + const query = `[out:json][timeout:25];(way["waterway"~"^(river|stream|canal|drain|ditch)$"](${s},${w},${n},${e});way["natural"="water"](${s},${w},${n},${e}););out geom;`; + try { + const resp = await fetch(`https://overpass-api.de/api/interpreter?data=${encodeURIComponent(query)}`); + if (!resp.ok) throw new Error(`Overpass returned ${resp.status}`); + const data = await resp.json(); + return this._osmElementsToGeoJSON(data.elements || [], sprayGeoJSON); + } catch (err) { + console.error('Overpass query failed:', err); + this.msgSvc.addFailedMsg('Failed to fetch water features from OpenStreetMap. Check your internet connection and try again.'); + return []; + } + } + + private _osmElementsToGeoJSON(elements: any[], sprayGeoJSON: any): any[] { + const features: any[] = []; + for (const el of elements) { + if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) continue; + const coords: number[][] = el.geometry.map((p: any) => [p.lon, p.lat]); + const tags = el.tags || {}; + + const first = coords[0], last = coords[coords.length - 1]; + const isClosed = coords.length > 3 && first[0] === last[0] && first[1] === last[1]; + const isAreaWater = tags['natural'] === 'water' || tags['waterway'] === 'riverbank'; + + const feature: any = isClosed && isAreaWater + ? { type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] }, properties: { ...tags, osmId: el.id } } + : { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { ...tags, osmId: el.id } }; + + // Keep only features whose coordinates intersect the spray zone + const anyInside = coords.some((c: number[]) => { + try { return turf.booleanPointInPolygon(c, sprayGeoJSON); } catch { return true; } + }); + if (anyInside) features.push(feature); + } + return features; + } + + private async _fetchOsmSchoolFeatures(bounds: L.LatLngBounds, sprayGeoJSON: any): Promise { + const s = bounds.getSouth(), w = bounds.getWest(), n = bounds.getNorth(), e = bounds.getEast(); + // Query school grounds (closed ways) within the bounding box + const query = `[out:json][timeout:25];(way["amenity"~"^(school|university|college|kindergarten)$"](${s},${w},${n},${e}););out geom;`; + try { + const resp = await fetch(`https://overpass-api.de/api/interpreter?data=${encodeURIComponent(query)}`); + if (!resp.ok) throw new Error(`Overpass returned ${resp.status}`); + const data = await resp.json(); + return this._osmSchoolElementsToGeoJSON(data.elements || [], sprayGeoJSON); + } catch (err) { + console.error('Overpass school query failed:', err); + return []; + } + } + + private _osmSchoolElementsToGeoJSON(elements: any[], sprayGeoJSON: any): any[] { + const features: any[] = []; + for (const el of elements) { + if (el.type !== 'way' || !el.geometry || el.geometry.length < 3) continue; + const coords: number[][] = el.geometry.map((p: any) => [p.lon, p.lat]); + const tags = el.tags || {}; + const first = coords[0], last = coords[coords.length - 1]; + const isClosed = first[0] === last[0] && first[1] === last[1]; + if (!isClosed) continue; // school grounds must be closed polygons + const feature: any = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [coords] }, + properties: { ...tags, osmId: el.id, _featureType: 'school' } + }; + const anyInside = coords.some((c: number[]) => { + try { return turf.booleanPointInPolygon(c, sprayGeoJSON); } catch { return true; } + }); + if (anyInside) features.push(feature); + } + return features; + } + + /** Returns the active features based on the selected feature type. */ + private _getEnabledFeatures(): any[] { + if (this.featureBufFeatureType === 'water') return [...this._featureBufWaterFeatures]; + if (this.featureBufFeatureType === 'schools') return [...this._featureBufSchoolFeatures]; + return [...this._featureBufWaterFeatures, ...this._featureBufSchoolFeatures]; + } + + /** + * Builds a corridor/expanded ring for each water feature, unions them all with + * polygon-clipping, and returns the result as arrays of L.LatLng rings. + * The first ring of each polygon is the outer boundary; subsequent rings are holes. + */ + private _computeUnifiedFeatureBuf(bufWidthM: number): L.LatLng[][][] { + // Collect raw corridor rings as [lng, lat] coordinate arrays + const rawRings: number[][][] = []; + + for (const feature of this._getEnabledFeatures()) { + const geo = feature.geometry; + if (geo.type === 'LineString') { + const lls = (geo.coordinates as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (lls.length < 2) continue; + const left = this.applyOffsetToPath(lls, bufWidthM / 2); + const right = this.applyOffsetToPath(lls, -bufWidthM / 2); + const ring = [...left, ...right.slice().reverse()]; + if (ring.length < 3) continue; + const coords = ring.map((ll: L.LatLng) => [ll.lng, ll.lat] as [number, number]); + // Close the ring + coords.push([coords[0][0], coords[0][1]]); + rawRings.push(coords); + } else if (geo.type === 'Polygon') { + const lls = (geo.coordinates[0] as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (lls.length < 3) continue; + const open = lls[lls.length - 1].equals(lls[0]) ? lls.slice(0, -1) : lls; + const exp = this.applyOffsetToPath(open, -bufWidthM); + if (exp.length < 3) continue; + const coords = exp.map((ll: L.LatLng) => [ll.lng, ll.lat] as [number, number]); + coords.push([coords[0][0], coords[0][1]]); + rawRings.push(coords); + } + } + + if (!rawRings.length) return []; + + // polygon-clipping.union takes an array of MultiPolygon-style inputs + // Each input is a Polygon: array of rings; each ring is array of [lng, lat] points + let unified: ReturnType; + try { + const inputs = rawRings.map(r => [r]) as Parameters; + unified = polygonClipping.union(...inputs); + } catch (_) { + // Fallback: return each ring as its own single-ring polygon + return rawRings.map(r => [r.map(([lng, lat]) => L.latLng(lat, lng))]); + } + + // Clip to the drawn search area if available + if (this._featureBufSearchAreaLayer) { + try { + const searchCoords = (this._featureBufSearchAreaLayer.getLatLngs()[0] as L.LatLng[]) + .map((ll: L.LatLng) => [ll.lng, ll.lat] as [number, number]); + searchCoords.push([searchCoords[0][0], searchCoords[0][1]]); + const searchRing = [[searchCoords]] as Parameters[1]; + const clipped = polygonClipping.intersection(unified as any, searchRing as any); + if (clipped && clipped.length) unified = clipped; + } catch (_) {} + } + + // Convert result (MultiPolygon coords) back to L.LatLng[][] + return unified.map(polygon => + polygon.map(ring => ring.map(([lng, lat]) => L.latLng(lat, lng))) + ); + } + + /** Splits a LatLng path into sub-arrays of consecutive points inside searchAreaGeoJSON. */ + private _clipLineToSearchArea(lls: L.LatLng[], searchAreaGeoJSON: any): L.LatLng[][] { + const segments: L.LatLng[][] = []; + let current: L.LatLng[] = []; + for (const ll of lls) { + const inside = turf.booleanPointInPolygon([ll.lng, ll.lat], searchAreaGeoJSON); + if (inside) { + current.push(ll); + } else { + if (current.length >= 2) segments.push(current); + current = []; + } + } + if (current.length >= 2) segments.push(current); + return segments; + } + + /** Redraws waterway visuals, school visuals, and the unified dashed buffer polygon preview. */ + renderFeatureBufPreview() { + if (!this.featureBufPanelActive && !this.featureBufLoading) return; + for (const l of this._featureBufPreviewLayers) { try { this.map.removeLayer(l); } catch (_) {} } + for (const l of this._featureBufRiverLayers) { try { this.map.removeLayer(l); } catch (_) {} } + for (const l of this._featureBufSchoolLayers) { try { this.map.removeLayer(l); } catch (_) {} } + this._featureBufPreviewLayers = []; + this._featureBufRiverLayers = []; + this._featureBufSchoolLayers = []; + + if (!this.map) return; + const enabledFeatures = this._getEnabledFeatures(); + if (!enabledFeatures.length) return; + const bufWidthM = NumUtils.round(UnitUtils.toMeter(this.bufWidth || 30, this.isUS), 1); + + // ── Waterway visuals (L.River per OSM feature, clipped to search area) ── + const searchAreaGeoJSON = this._featureBufSearchAreaLayer?.toGeoJSON() || null; + if (this.featureBufFeatureType !== 'schools') { + for (const feature of this._featureBufWaterFeatures) { + const geo = feature.geometry; + if (geo.type === 'LineString') { + const allLls = (geo.coordinates as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (allLls.length < 2) continue; + const segments = searchAreaGeoJSON ? this._clipLineToSearchArea(allLls, searchAreaGeoJSON) : [allLls]; + for (const lls of segments) { + if (lls.length < 2) continue; + try { + const river = (L as any).river(lls, { + minWidth: 2, maxWidth: 8, + color: '#1565C0', fillColor: '#1E88E5', fillOpacity: 0.7, + pane: PANE.SprayZones + }).addTo(this.map); + this._featureBufRiverLayers.push(river); + } catch (_) { + const pl = L.polyline(lls, { color: '#1E88E5', weight: 5, pane: PANE.SprayZones }).addTo(this.map); + this._featureBufRiverLayers.push(pl); + } + } + } else if (geo.type === 'Polygon') { + const lls = (geo.coordinates[0] as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (lls.length < 3) continue; + let displayLls = lls; + if (searchAreaGeoJSON) { + try { + const clipped = turf.intersect(feature, searchAreaGeoJSON); + if (clipped?.geometry?.type === 'Polygon') { + displayLls = (clipped.geometry.coordinates[0] as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + } + } catch (_) {} + } + const waterPoly = L.polygon([displayLls], { + color: '#1565C0', fillColor: '#1E88E5', fillOpacity: 0.6, weight: 2, pane: PANE.SprayZones + }).addTo(this.map); + this._featureBufRiverLayers.push(waterPoly); + } + } + } + + // ── School ground visuals (orange fill, clipped to search area) ── + if (this.featureBufFeatureType !== 'water') { + for (const feature of this._featureBufSchoolFeatures) { + const geo = feature.geometry; + if (geo.type !== 'Polygon') continue; + const lls = (geo.coordinates[0] as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (lls.length < 3) continue; + let displayLls = lls; + if (searchAreaGeoJSON) { + try { + const clipped = turf.intersect(feature, searchAreaGeoJSON); + if (clipped?.geometry?.type === 'Polygon') { + displayLls = (clipped.geometry.coordinates[0] as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + } + } catch (_) {} + } + const schoolPoly = L.polygon([displayLls], { + color: '#E65100', fillColor: '#FF9800', fillOpacity: 0.55, weight: 2, pane: PANE.SprayZones + }).addTo(this.map); + this._featureBufSchoolLayers.push(schoolPoly); + } + } + + // ── Unified buffer preview ── + const polygons = this._computeUnifiedFeatureBuf(bufWidthM); + for (const rings of polygons) { + const preview = L.polygon(rings as any, { + color: this.bufColor, fillColor: this.bufColor, fillOpacity: 0.35, + opacity: 0.85, weight: 2, dashArray: '6 4', pane: PANE.XCLZones + } as any).addTo(this.map); + this._featureBufPreviewLayers.push(preview); + } + } + + confirmFeatureBuf() { + if (!this.featureBufConfirmReady) return; + const bufWidthM = NumUtils.round(UnitUtils.toMeter(this.bufWidth || 30, this.isUS), 1); + const baseName = (this.featureBufName && this.featureBufName.trim()) ? this.featureBufName.trim() : null; + + // Capture sources before cleanupFeatureBuf() clears them so width edits can recompute geometry + const _sources = this._getEnabledFeatures(); + const _searchAreaCoords: [number, number][] | null = this._featureBufSearchAreaLayer + ? (this._featureBufSearchAreaLayer.getLatLngs()[0] as L.LatLng[]).map((ll: L.LatLng) => [ll.lng, ll.lat] as [number, number]) + : null; + + const polygons = this._computeUnifiedFeatureBuf(bufWidthM); + polygons.forEach((rings, idx) => { + if (!rings[0] || rings[0].length < 3) return; + const bufLayer: any = L.polygon(rings as any, { + color: this.bufColor, fillColor: this.bufColor, fillOpacity: 0.5, + opacity: 0.85, weight: 2, pane: PANE.XCLZones + } as any); + const feat = bufLayer.feature = bufLayer.feature || {}; + feat['properties'] = { + type: ITEM.BUFFER, + name: '', + width: this.bufWidth, + featureBuf: true + }; + feat['fId'] = this.newFId; + const label = baseName ? (idx === 0 ? baseName : `${baseName} ${idx + 1}`) : null; + feat['properties']['name'] = label || this.getDefaultName(bufLayer); + feat['properties']['editedCoords'] = rings.map((ring: L.LatLng[]) => + ring.map((ll: L.LatLng) => [ll.lng, ll.lat]) + ); + // Store source features so width can be re-applied after creation + bufLayer['_featureBufSources'] = _sources; + bufLayer['_featureBufSearchArea'] = _searchAreaCoords; + // Store the centroid of this polygon so _recomputeFeatureBufLayer can find it + // even if polygon-clipping reorders the union output at the new width + const outerRing = rings[0]; + const centroid: [number, number] = [ + outerRing.reduce((s: number, ll: L.LatLng) => s + ll.lat, 0) / outerRing.length, + outerRing.reduce((s: number, ll: L.LatLng) => s + ll.lng, 0) / outerRing.length, + ]; + bufLayer['_featureBufCentroid'] = centroid; + // Persist in feature.properties so these survive save/reload via toGeoJSON() + feat['properties']['featureBufSources'] = _sources; + feat['properties']['featureBufSearchArea'] = _searchAreaCoords; + feat['properties']['featureBufCentroid'] = centroid; + if (!this.showMeasure) bufLayer.bindTooltip(this.ttipText(bufLayer), this.getTTOps(ITEM.BUFFER)); + this.editableGrp.addLayer(bufLayer); + this.mapItems = this.mapItems.concat(bufLayer); + }); + + this.updateSprayAreas(); + if (this.featureBufCreateAnother) { + // Restart drawing mode so user can draw a new search area + this.startFeatureBufMode(); + this.featureBufCreateAnother = true; // preserve across cleanupFeatureBuf inside startFeatureBufMode + } else { + this.cleanupFeatureBuf(); + } + } + + cancelFeatureBuf() { + this.cleanupFeatureBuf(); + this.advBufPanelActive = true; + this.cdRef.detectChanges(); + } + + /** + * Recomputes the geometry of an existing feature buffer layer at a new buffer width. + * Uses the OSM source features stored on the layer during `confirmFeatureBuf()`. + */ + private _recomputeFeatureBufLayer(layer: any, bufWidthM: number): void { + // In-memory fields are set at creation time. After save/reload they come from feature.properties. + const props = (layer['feature'] && layer['feature']['properties']) || {}; + const sources: any[] = layer['_featureBufSources'] || props['featureBufSources']; + const searchAreaCoords: [number, number][] | null = + layer['_featureBufSearchArea'] !== undefined ? layer['_featureBufSearchArea'] : (props['featureBufSearchArea'] || null); + if (!sources || !sources.length) return; + + const rawRings: number[][][] = []; + for (const feature of sources) { + const geo = feature.geometry; + if (geo.type === 'LineString') { + const lls = (geo.coordinates as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (lls.length < 2) continue; + const left = this.applyOffsetToPath(lls, bufWidthM / 2); + const right = this.applyOffsetToPath(lls, -bufWidthM / 2); + const ring = [...left, ...right.slice().reverse()]; + if (ring.length < 3) continue; + const coords = ring.map((ll: L.LatLng) => [ll.lng, ll.lat] as [number, number]); + coords.push([coords[0][0], coords[0][1]]); + rawRings.push(coords); + } else if (geo.type === 'Polygon') { + const lls = (geo.coordinates[0] as number[][]).map((c: number[]) => L.latLng(c[1], c[0])); + if (lls.length < 3) continue; + const open = lls[lls.length - 1].equals(lls[0]) ? lls.slice(0, -1) : lls; + const exp = this.applyOffsetToPath(open, -bufWidthM); + if (exp.length < 3) continue; + const coords = exp.map((ll: L.LatLng) => [ll.lng, ll.lat] as [number, number]); + coords.push([coords[0][0], coords[0][1]]); + rawRings.push(coords); + } + } + if (!rawRings.length) return; + + let unified: ReturnType; + try { + const inputs = rawRings.map(r => [r]) as Parameters; + unified = polygonClipping.union(...inputs); + } catch (_) { return; } + + if (searchAreaCoords && searchAreaCoords.length) { + try { + const sc = [...searchAreaCoords, searchAreaCoords[0]]; + const clipped = polygonClipping.intersection(unified as any, [[sc]] as any); + if (clipped && clipped.length) unified = clipped; + } catch (_) {} + } + + const newPolygons = unified.map(polygon => + polygon.map(ring => ring.map(([lng, lat]) => L.latLng(lat, lng))) + ); + if (!newPolygons.length || !newPolygons[0].length) return; + + // Find the new polygon whose centroid is closest to this layer's original centroid. + // This handles cases where polygon-clipping reorders or merges polygons at the new width. + const storedCentroid: [number, number] | undefined = layer['_featureBufCentroid'] || props['featureBufCentroid']; + let bestIdx = 0; + if (storedCentroid && newPolygons.length > 1) { + let minDist = Infinity; + newPolygons.forEach((poly, i) => { + const ring = poly[0]; + const cLat = ring.reduce((s, ll) => s + ll.lat, 0) / ring.length; + const cLng = ring.reduce((s, ll) => s + ll.lng, 0) / ring.length; + const dist = (cLat - storedCentroid[0]) ** 2 + (cLng - storedCentroid[1]) ** 2; + if (dist < minDist) { minDist = dist; bestIdx = i; } + }); + } + const bestPolygon = newPolygons[bestIdx]; + + (layer).setLatLngs(bestPolygon); + // Keep Leaflet.Draw edit handles in sync with the updated geometry + if ((layer).editing) { + (layer).editing.latlngs = [(layer)._latlngs]; + } + layer['feature']['properties']['editedCoords'] = (bestPolygon as L.LatLng[][]).map( + (ring: L.LatLng[]) => ring.map((ll: L.LatLng) => [ll.lng, ll.lat]) + ); + } + + onFeatureBufFeatureTypeChange(value: string) { + this.featureBufFeatureType = value as 'water' | 'schools' | 'both'; + this.featureBufConfirmReady = this._getEnabledFeatures().length > 0; + this.renderFeatureBufPreview(); + this.cdRef.detectChanges(); + } + + private cleanupFeatureBuf() { + if (this.polygonDrawer?.enabled()) this.polygonDrawer.disable(); + if (this._featureBufTooltipEl) { this._featureBufTooltipEl.remove(); this._featureBufTooltipEl = null; } + if (this._featureBufLoadingMoveHandler && this.map) { + this.map.getContainer().removeEventListener('mousemove', this._featureBufLoadingMoveHandler); + this._featureBufLoadingMoveHandler = null; + } + if (this._featureBufKeyHandler) { document.removeEventListener('keydown', this._featureBufKeyHandler); this._featureBufKeyHandler = null; } + if (this._featureBufSearchAreaLayer) { try { this.map.removeLayer(this._featureBufSearchAreaLayer); } catch (_) {} this._featureBufSearchAreaLayer = null; } + for (const l of this._featureBufPreviewLayers) { try { this.map.removeLayer(l); } catch (_) {} } + for (const l of this._featureBufRiverLayers) { try { this.map.removeLayer(l); } catch (_) {} } + for (const l of this._featureBufSchoolLayers) { try { this.map.removeLayer(l); } catch (_) {} } + this._featureBufPreviewLayers = []; + this._featureBufRiverLayers = []; + this._featureBufSchoolLayers = []; + this._featureBufWaterFeatures = []; + this._featureBufSchoolFeatures = []; + this.featureBufFeatureType = 'water'; + this.featureBufActive = false; + this.featureBufPanelActive = false; + this.featureBufConfirmReady = false; + this.featureBufName = ''; + this.featureBufLoading = false; + this.featureBufCreateAnother = false; + if (this.map) this.map.getContainer().style.cursor = ''; + } + + // ─── End Feature Buffer helpers ────────────────────────────────────────────── + protected onEditGrpClick(e) { + // While edge buffer snap mode is active, delegate the click there and do nothing else. + if (this.edgeBufActive) { + if (e.latlng && this._edgeBufClickHandler) this._edgeBufClickHandler(e); + return; + } + // While feature buffer zone-selection mode is active, the polygonDrawer handles + // all clicks — just return so the click doesn't trigger item selection. + if (this.featureBufActive) { + return; + } const layer = e.layer; if (this.obstacles && e.containerPoint) { if (this.obstacles.atMarker(e.containerPoint.x, e.containerPoint.y)) return; // Skip edit item if an Obstacle was clicked } if (!layer) return; + + // Edge buffer layers open the top-left editor panel, but not while Leaflet edit mode is active. + const _ebProps = layer && layer.feature && layer.feature.properties; + if (_ebProps && _ebProps.type === ITEM.BUFFER && layer['_origPath'] && _ebProps.edgeSide) { + if (!this.editing) { + this.openEdgeBufEditor(layer, true); + return; + } + return; + } + this.onPolyEditClick(layer); if (!this.editing && !this.forecastOn) { @@ -879,6 +2313,26 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte protected onDrawCreated(e) { let layer = e.layer; + + // Feature buffer draw mode: the drawn polygon is the search area, not a spray zone + if (this.featureBufActive && e.layerType === 'polygon') { + this.polygonDrawer.disable(); + this.featureBufActive = false; + if (!layer.isEmpty() && layer.getLatLngs()[0].length >= 3) { + // Show the drawn search-area boundary on the map so the user can see it + layer.setStyle({ + color: '#1565C0', fillColor: '#1E88E5', fillOpacity: 0.1, + weight: 2, dashArray: '6 4' + }); + layer.addTo(this.map); + this._featureBufSearchAreaLayer = layer; + this.loadWaterFeatures(layer); + } else { + this.cancelFeatureBuf(); + } + return; + } + let feature = e.layer.feature = e.layer.feature || {}; feature['type'] = 'Feature'; @@ -972,6 +2426,23 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte layer = bufLine; this.updateSprayAreas(); + + // If created via Advanced Buffer Tools → Segment panel, hold for confirmation + if (this.segBufPanelActive) { + this._segBufLayer = bufLine; + bufLine.feature['fId'] = bufLine.feature['fId'] !== undefined ? bufLine.feature['fId'] : this.newFId; + bufLine.feature['properties']['name'] = (this.segBufName && this.segBufName.trim()) + ? this.segBufName.trim() : this.getDefaultName(bufLine); + bufLine.feature['properties']['width'] = NumUtils.round(this.bufWidth, 1); + if (!this.showMeasure) + bufLine.bindTooltip(this.ttipText(bufLine), this.getTTOps(bufLine.feature.properties.type)); + else + bufLine.bindTooltip(this.ttipText(bufLine), this.getTTOps(bufLine.feature.properties.type, true)); + this.mapItems = this.mapItems.concat(bufLine); + this.segBufConfirmReady = true; + this.cdRef.detectChanges(); + return; + } } if (layer.feature['fId'] === undefined) layer.feature['fId'] = this.newFId; @@ -1304,27 +2775,74 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte if (this.job.bufs) { this.job.bufs.forEach(buf => { - var coordinates = (buf).geometry.coordinates.map(coor => { - return [coor[1], coor[0]]; // LonLat => LatLon - }); - var ops = { - corridor: NumUtils.round(UnitUtils.toMeter(buf.properties["width"] || 10, this.isUS), 1), - usUnit: this.isUS, - fill: false, - color: this.bufColor, - opacity: 0.75, - lineCap: 'butt', - pane: PANE.XCLZones - }; - var bufLine = (L).corridor(coordinates, ops); + const geomType: string = (buf).geometry.type; + const geomCoords: any = (buf).geometry.coordinates; + // LineString: [[lon,lat],...] — used by corridor and edge buffers. + // Polygon: [[[lon,lat],...]] — used by feature buffers (toGeoJSON output). + const origPath: L.LatLng[] | null = (geomType === 'LineString') + ? geomCoords.map((coor: number[]) => L.latLng(coor[1], coor[0])) + : null; + const edgeSide: 'on'|'inside'|'outside' = buf.properties["edgeSide"] || null; + const edgeSign: number = buf.properties["edgeSign"] || 1; + const widthM: number = buf.properties["width"] || 30; + let bufLine: any; + if (edgeSide && origPath) { + // Edge buffer — polygon rebuilt from traced line path + const editedCoords: number[][] | null = buf.properties['editedCoords'] || null; + const polyPath = (editedCoords && editedCoords.length >= 3) + ? editedCoords.map((c: number[]) => L.latLng(c[1], c[0])) + : this.buildCorridorPolygon(origPath, edgeSide, widthM, edgeSign); + bufLine = L.polygon([polyPath], { + color: this.bufColor, + fillColor: this.bufColor, + fillOpacity: 0.5, + opacity: 0.85, + weight: 2, + pane: PANE.XCLZones + } as any); + if (editedCoords && editedCoords.length >= 3) bufLine['_editedPolyPath'] = polyPath; + } else if (geomType === 'Polygon') { + // Feature buffer — saved as Polygon GeoJSON, reconstruct directly from the outer ring. + // NOTE: properties.editedCoords for feature bufs is a 3D rings array (not usable here); + // the geometry coordinates are the authoritative source. + const polyPath: L.LatLng[] = geomCoords[0].map((c: number[]) => L.latLng(c[1], c[0])); + bufLine = L.polygon([polyPath], { + color: this.bufColor, + fillColor: this.bufColor, + fillOpacity: 0.5, + opacity: 0.75, + weight: 2, + pane: PANE.XCLZones + } as any); + } else { + // Corridor buffer (regular / segment) + const offsetM: number = buf.properties["offset"] || 0; + const displayPath = this.applyOffsetToPath(origPath, offsetM); + bufLine = (L).corridor(displayPath, { + corridor: NumUtils.round(UnitUtils.toMeter(widthM, this.isUS), 1), + usUnit: this.isUS, + fill: false, + color: this.bufColor, + opacity: 0.75, + lineCap: 'butt', + lineJoin: 'miter', + pane: PANE.XCLZones + }); + } + if (origPath) bufLine['_origPath'] = origPath; // Store original line for edge bufs const feature = bufLine.feature = bufLine.feature || {}; feature["properties"] = buf.properties; geojsonLayers.push(bufLine); }); // Check to set last buffer width for the drawing tool - if (this.job.bufs.length && this.job.bufs.length > 0) { - const bufWidth = this.job.bufs[this.job.bufs.length - 1].properties["width"]; - if (!Number.isNaN(Number(bufWidth))) this.bufWidth = bufWidth; + if (this.job.bufs.length > 0) { + // Non-edge bufs store width in user units; edge bufs store in metres. + // Prefer the last non-edge buf as the default drawing width. + const lastNonEdge = [...this.job.bufs].reverse().find((b: any) => !b.properties['edgeSide']); + if (lastNonEdge) { + const bw = Number(lastNonEdge.properties['width']); + if (!Number.isNaN(bw)) this.bufWidth = bw; + } } } @@ -1583,6 +3101,12 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte } editMapItem(layer, isNew: boolean = false) { + // Edge buffer layers open the top-left editor panel instead of the modal dialog. + const props = layer && layer.feature && layer.feature.properties; + if (props && props.type === ITEM.BUFFER && layer['_origPath'] && props.edgeSide) { + this.openEdgeBufEditor(layer, true); + return; + } this.editOne = true; const mItem = this.layerToMapItem(layer); this.orgItem = { layer: layer, mItem: mItem }; @@ -1725,12 +3249,43 @@ export class JobMapEditComponent extends MapEditBaseComp implements OnInit, Afte (layer).setStyle(options); } else if (type === ITEM.BUFFER) { + const isEdgeBuf = !!(layer['_origPath'] && layer['feature']['properties']['edgeSide']); + let needsRedraw = false; + if (layer['feature']['properties']['width'] !== this.curItem.width) { const w = NumUtils.round(NumUtils.bound(this.curItem.width, this.minBuf, this.maxBuf), 1); this.bufWidth = this.curItem.width = w; layer['feature']['properties']['width'] = w; - options.corridor = UnitUtils.toMeter(w, this.isUS); - (layer).updateCallback.call(this.map); + if (!isEdgeBuf) options.corridor = UnitUtils.toMeter(w, this.isUS); + needsRedraw = true; + } + + const prevSide = layer['feature']['properties']['edgeSide']; + const newSide = this.curItem.edgeSide; + if (newSide !== prevSide) { + layer['feature']['properties']['edgeSide'] = newSide; + needsRedraw = true; + } + + if (needsRedraw) { + if (isEdgeBuf) { + const edgeSign: number = this.curItem.edgeSign || layer['feature']['properties']['edgeSign'] || 1; + const side: 'on' | 'inside' | 'outside' = ((newSide || prevSide || 'on') as any); + const polyPath = this.buildCorridorPolygon(layer['_origPath'], side, this.curItem.width, edgeSign); + (layer).setLatLngs([polyPath]); + if ((layer).editing) { + (layer).editing.latlngs = [(layer)._latlngs]; + } + } else { + if (layer['feature']['properties']['featureBuf'] && + (layer['_featureBufSources'] || layer['feature']['properties']['featureBufSources'])) { + const newWidthM = NumUtils.round(UnitUtils.toMeter(this.curItem.width, this.isUS), 1); + this._recomputeFeatureBufLayer(layer, newWidthM); + } else { + layer['_styleWasSet'] = false; + (layer)._updateWeight(); + } + } this.updateSprayAreas(); } } diff --git a/Development/client/src/app/job/job.module.ts b/Development/client/src/app/job/job.module.ts index b45c2af..e578ea0 100644 --- a/Development/client/src/app/job/job.module.ts +++ b/Development/client/src/app/job/job.module.ts @@ -24,6 +24,7 @@ import { TabViewModule } from 'primeng/tabview'; import { SliderModule } from 'primeng/slider'; import { OrderListModule } from 'primeng/orderlist'; import { AccordionModule } from 'primeng/accordion'; +import { SelectButtonModule } from 'primeng/selectbutton'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; @@ -38,6 +39,7 @@ import { JobListComponent } from './job-list/job-list.component'; import { JobEditComponent } from './job-edit/job-edit.component'; import { JobAssignmentComponent } from './job-assignment/job-assignment.component'; import { JobMapEditComponent } from './job-map-edit/job-map-edit.component'; +import { BufEditorPanelComponent } from './job-map-edit/buf-editor-panel/buf-editor-panel.component'; import { JobsRoutingModule } from './job-routing.module'; import { InvoicesModule } from '@app/invoices/invoices.module'; @@ -48,14 +50,14 @@ import { InvoicesModule } from '@app/invoices/invoices.module'; PaginatorModule, DialogModule, ConfirmDialogModule, ToastModule, MessagesModule, CheckboxModule, AutoCompleteModule, ToolbarModule, InputSwitchModule, SplitButtonModule, CalendarModule, FileUploadModule, PanelModule, ProgressSpinnerModule, AccordionModule, - PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, + PickListModule, TableModule, ToggleButtonModule, TooltipModule, TabViewModule, SliderModule, OrderListModule, SelectButtonModule, JobsRoutingModule, StoreModule.forFeature(fromJobs.FEATURE_KEY, fromJobs.reducer), StoreModule.forFeature(fromClients.FEATURE_KEY, fromClients.reducer), EffectsModule.forFeature([JobEffects, ClientEffects]), InvoicesModule, ], - declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent], + declarations: [JobMgtComponent, JobListComponent, JobEditComponent, JobAssignmentComponent, JobMapEditComponent, BufEditorPanelComponent], providers: [DatePipe], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/Development/client/src/app/job/models/job.model.ts b/Development/client/src/app/job/models/job.model.ts index e4201a3..e3b27e0 100644 --- a/Development/client/src/app/job/models/job.model.ts +++ b/Development/client/src/app/job/models/job.model.ts @@ -29,6 +29,12 @@ export interface BufferZone { type: ITEM; name?: string; width: number; + /** Computed display offset in metres (derived from edgeSide + width). */ + offset?: number; + /** Which side of the traced edge the corridor occupies. */ + edgeSide?: 'on' | 'inside' | 'outside'; + /** Winding sign of the source polygon ring: +1 = CCW (left of travel = inside), -1 = CW. */ + edgeSign?: number; } export interface MapFeature { diff --git a/Development/client/src/app/changelog/changelog-routing.module.ts b/Development/client/src/app/release-notes/release-notes-routing.module.ts similarity index 68% rename from Development/client/src/app/changelog/changelog-routing.module.ts rename to Development/client/src/app/release-notes/release-notes-routing.module.ts index ed61857..5fd9c26 100644 --- a/Development/client/src/app/changelog/changelog-routing.module.ts +++ b/Development/client/src/app/release-notes/release-notes-routing.module.ts @@ -1,12 +1,12 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { AuthGuard } from '../domain/guards/auth.guard'; -import { ChangelogComponent } from './changelog.component'; +import { ReleaseNotesComponent } from './release-notes.component'; const routes: Routes = [ { path: '', - component: ChangelogComponent, + component: ReleaseNotesComponent, canActivate: [AuthGuard] } ]; @@ -15,4 +15,4 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) -export class ChangelogRoutingModule { } +export class ReleaseNotesRoutingModule { } diff --git a/Development/client/src/app/release-notes/release-notes.component.css b/Development/client/src/app/release-notes/release-notes.component.css new file mode 100644 index 0000000..9dca514 --- /dev/null +++ b/Development/client/src/app/release-notes/release-notes.component.css @@ -0,0 +1,212 @@ +.changelog-empty { + padding: 2rem 1rem; + color: #666; + font-style: italic; +} + +.changelog-releases-layout { + display: flex; + gap: 0; + align-items: stretch; + background: none; +} + +.changelog-left-column { + flex: 0 0 270px; + display: flex; + flex-direction: column; + gap: 0.5rem; + align-self: flex-start; + position: sticky; + top: 9.5vh; + max-height: 100vh; + overflow-y: auto; + background: none; + box-sizing: border-box; +} + +.changelog-releases-wrapper { + /* width controlled by parent column */ +} + +.changelog-toc-wrapper { + /* width controlled by parent column */ +} + +/* Ensure PrimeNG p-panel custom elements stretch to full column width */ +:host ::ng-deep .changelog-left-column p-panel { + display: block; +} + +/* Make the release content panel fill the column and scroll only inside */ +:host ::ng-deep .changelog-releases-content p-panel, +:host ::ng-deep .changelog-releases-content .ui-panel { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} + +:host ::ng-deep .changelog-releases-content .ui-panel-content-wrapper { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +:host ::ng-deep .changelog-releases-content .ui-panel-content { + height: 100%; + overflow-y: auto; + box-sizing: border-box; +} + +/* Collapse/expand toggle button */ +.changelog-left-toggle { + position: sticky; + top: calc(50vh - 1rem); + align-self: flex-start; + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + border: 0; + background: #4CAF50; + cursor: pointer; + border-radius: 4px 0 0 4px; + color: #ffffff; + font-size: 0.85rem; + padding: 0.4rem 0; + box-shadow: -2px 0 6px rgba(0, 0, 0, 0.35); + transition: background 0.2s ease; + z-index: 2; + margin-left: -0.75rem; +} + +.changelog-left-toggle--hidden { + margin-left: 0; + border-radius: 0 4px 4px 0; + box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35); +} + +.changelog-left-toggle:hover { + background: #2E7D32; +} + +/* Resize handle between left column and content */ +.changelog-left-resize { + flex: 0 0 3px; + cursor: col-resize; + background: none; + position: relative; + align-self: stretch; + z-index: 1; +} + +.changelog-left-resize::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + right: 2px; + background: transparent; + border-radius: 3px; + transition: background 0.15s; +} + +.changelog-left-resize, +.changelog-left-resize { + background: #8fa3bb; +} + +.changelog-releases-content { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + background: #ffffff; + height: 85vh; + overflow: hidden; +} + +.changelog-releases-list { + list-style: none; + padding: 0; + margin: 0; +} + +.changelog-releases-list li { + padding-top: 0.35rem; + padding-bottom: 0.35rem; +} + +.changelog-releases-item--active > a { + font-weight: 700; + color: #1a4f7a; +} + +.changelog-releases-latest-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + background: #2e7d32; + color: #fff; + border-radius: 3px; + padding: 0 0.35rem; + margin-left: 0.35rem; + vertical-align: middle; + line-height: 1.5; +} + +.changelog-releases-group { + list-style: none; +} + +.changelog-releases-group-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + font-weight: 600; + color: #3a5068; + text-decoration: none; + padding: 0.35rem 0; + cursor: pointer; +} + +.changelog-releases-group-header:hover { + color: #1a4f7a; +} + +.changelog-releases-group-icon { + font-size: 0.75rem; + line-height: 1; +} + +.changelog-releases-group-list { + padding-left: 1rem; +} + +@media (max-width: 640px) { + .changelog-releases-layout { + flex-direction: column; + } + + .changelog-left-column { + flex-basis: auto !important; + max-width: none !important; + width: 100%; + position: static; + max-height: none; + overflow-y: visible; + } + + .changelog-left-toggle { + display: none; + } + + .changelog-left-resize { + display: none; + } +} diff --git a/Development/client/src/app/release-notes/release-notes.component.html b/Development/client/src/app/release-notes/release-notes.component.html new file mode 100644 index 0000000..061f6b0 --- /dev/null +++ b/Development/client/src/app/release-notes/release-notes.component.html @@ -0,0 +1,112 @@ +
+
+
+ +
+ +
+ +
+ No release notes have been uploaded yet. +
+ +
+ + + + + + + + +
+
+ + +
+ + + + +
+ +
+
+
+
diff --git a/Development/client/src/app/release-notes/release-notes.component.ts b/Development/client/src/app/release-notes/release-notes.component.ts new file mode 100644 index 0000000..339c584 --- /dev/null +++ b/Development/client/src/app/release-notes/release-notes.component.ts @@ -0,0 +1,121 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, HostListener, OnInit, ViewChild } from '@angular/core'; +import { forkJoin } from 'rxjs'; +import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component'; + +interface ReleaseManifestEntry { + fileName: string; + title: string; + ver: string; +} + +interface ReleaseSection { + title: string; + markdown: string; +} + +@Component({ + selector: 'app-release-notes', + templateUrl: './release-notes.component.html', + styleUrls: ['./release-notes.component.css'] +}) +export class ReleaseNotesComponent implements OnInit { + + @ViewChild(MarkdownViewerComponent) markdownViewer?: MarkdownViewerComponent; + + private readonly releasesApiBase = '/releases'; + private readonly releasesStaticBase = '/releases'; + + sections: ReleaseSection[] = []; + loading = false; + activeSectionIndex = 0; + latestSectionIndex = -1; + previousReleasesExpanded = false; + tocItems: { label: string; anchorId: string }[] = []; + + leftColumnVisible = true; + leftColumnWidth = 270; + private isResizing = false; + private resizeStartX = 0; + private resizeStartWidth = 0; + + constructor(private readonly http: HttpClient) {} + + ngOnInit(): void { + this.loadManifest(); + } + + get activeMarkdown(): string { + return this.sections[this.activeSectionIndex]?.markdown ?? ''; + } + + private loadManifest(): void { + this.loading = true; + this.http.get(this.releasesApiBase).subscribe({ + next: (revisions) => { + if (!revisions || revisions.length === 0) { + this.loading = false; + return; + } + + const requests = revisions.map((r: ReleaseManifestEntry) => + this.http.get(`${this.releasesStaticBase}/${encodeURIComponent(r.fileName)}`, { responseType: 'text' }) + ); + + forkJoin(requests).subscribe({ + next: (markdownFiles: string[]) => { + this.sections = markdownFiles.map((md: string, i: number) => ({ + title: revisions[i].title || revisions[i].fileName.replace(/\.md$/i, ''), + markdown: md + })); + // Server returns entries sorted descending by ver; index 0 is always the latest. + this.latestSectionIndex = revisions.length > 0 ? 0 : -1; + this.activeSectionIndex = 0; + this.loading = false; + }, + error: () => { this.loading = false; } + }); + }, + error: () => { this.loading = false; } + }); + } + + selectSection(event: MouseEvent, index: number): void { + event.preventDefault(); + this.activeSectionIndex = index; + } + + scrollToContent(event: MouseEvent, anchorId: string): void { + event.preventDefault(); + this.markdownViewer?.scrollToId(anchorId); + } + + togglePreviousReleases(event: MouseEvent): void { + event.preventDefault(); + this.previousReleasesExpanded = !this.previousReleasesExpanded; + } + + toggleLeftColumn(): void { + this.leftColumnVisible = !this.leftColumnVisible; + } + + onResizeStart(event: MouseEvent): void { + this.isResizing = true; + this.resizeStartX = event.clientX; + this.resizeStartWidth = this.leftColumnWidth; + event.preventDefault(); + } + + @HostListener('document:mousemove', ['$event']) + onResizeMove(event: MouseEvent): void { + if (this.isResizing) { + const delta = event.clientX - this.resizeStartX; + this.leftColumnWidth = Math.max(160, Math.min(480, this.resizeStartWidth + delta)); + } + } + + @HostListener('document:mouseup') + onResizeEnd(): void { + this.isResizing = false; + } +} diff --git a/Development/client/src/app/release-notes/release-notes.module.ts b/Development/client/src/app/release-notes/release-notes.module.ts new file mode 100644 index 0000000..2997f6d --- /dev/null +++ b/Development/client/src/app/release-notes/release-notes.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { PanelModule } from 'primeng/panel'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; + +import { AppSharedModule } from '../shared/app-shared.module'; +import { ReleaseNotesRoutingModule } from './release-notes-routing.module'; +import { ReleaseNotesComponent } from './release-notes.component'; + +@NgModule({ + imports: [ + AppSharedModule, + ReleaseNotesRoutingModule, + PanelModule, + ProgressSpinnerModule + ], + declarations: [ReleaseNotesComponent] +}) +export class ReleaseNotesModule { } diff --git a/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.html b/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.html index bf13e6b..db137ef 100644 --- a/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.html +++ b/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.html @@ -181,17 +181,17 @@ (click)="openNewDialog()"> diff --git a/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.ts b/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.ts index 6c18835..697bc62 100644 --- a/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.ts +++ b/Development/client/src/app/settings/api-keys/api-key-manager/api-key-manager.component.ts @@ -45,6 +45,7 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro cols: any[] = []; expandedRows: { [id: string]: boolean } = {}; selectedKey: ApiKey | null = null; + keys: ApiKey[] = []; showNewDialog = false; createdAtFilter: Date | null = null; @@ -81,6 +82,8 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro map(([keys, filters]) => this.applyFilters(keys, filters)) ); + this.filteredKeys$.pipe(takeUntil(this.destroy$)).subscribe(k => { this.keys = k; }); + this.isAdmin = this.authSvc.hasRole([RoleIds.ADMIN]); this.isMasterAccount = this.authSvc.hasRole([RoleIds.APP]); @@ -223,6 +226,7 @@ export class ApiKeyManagerComponent extends BaseComp implements OnInit, OnDestro header: $localize`:@@deleteKey:Delete Key`, icon: 'pi pi-trash', accept: () => { + this.selectedKey = null; this.store.dispatch(ApiKeyActions.deleteApiKey({ keyId: key._id, ownerId: this.ownerId })); } }); diff --git a/Development/client/src/app/settings/settings.module.ts b/Development/client/src/app/settings/settings.module.ts index 8ef7281..b8bae1a 100644 --- a/Development/client/src/app/settings/settings.module.ts +++ b/Development/client/src/app/settings/settings.module.ts @@ -1,7 +1,6 @@ import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { HttpClientModule } from '@angular/common/http'; import { SettingsRoutingModule } from './settings-routing.module'; import { SubscriptionMgtComponent } from './subscription/subscription-mgt.component'; @@ -30,7 +29,6 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; CommonModule, FormsModule, ReactiveFormsModule, - HttpClientModule, SettingsRoutingModule, AppSharedModule, // PrimeNG diff --git a/Development/client/src/app/shared/app-shared.module.ts b/Development/client/src/app/shared/app-shared.module.ts index dedfb26..d3cef9b 100644 --- a/Development/client/src/app/shared/app-shared.module.ts +++ b/Development/client/src/app/shared/app-shared.module.ts @@ -9,6 +9,7 @@ import { DropdownModule } from 'primeng/dropdown'; import { CheckboxModule } from 'primeng/checkbox'; import { KeyFilterModule } from 'primeng/keyfilter'; import { PanelModule } from 'primeng/panel'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { MessagesModule } from 'primeng/messages'; import { MessageModule } from 'primeng/message'; import { RadioButtonModule } from 'primeng/radiobutton'; @@ -78,12 +79,13 @@ import { PromoLabelComponent } from './promo-label/promo-label.component'; import { ActivePromoLabelComponent } from './active-promo-label/active-promo-label.component'; import { LegacyNoticeLabelComponent } from './legacy-notice-label/legacy-notice-label.component'; import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.component'; +import { MarkdownViewerComponent } from './markdown-viewer/markdown-viewer.component'; @NgModule({ imports: [ CommonModule, GlobalModule, SharedModule, InputTextModule, ButtonModule, DropdownModule, KeyFilterModule, ReactiveFormsModule, CheckboxModule, PanelModule, - MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule, + ProgressSpinnerModule, MessagesModule, MessageModule, InputNumberModule, CalendarModule, DialogModule, MultiSelectModule, FormsModule ], declarations: [ @@ -93,7 +95,7 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen JobStatusPipe, VehicleTypePipe, FlowRatePipe, LockLinePipe, XtractPipe, SubscriptionPkgPipe, UsCurrencyPipe, TsDatePipe, CreditCurrencyPipe, DebounceDirective, UnitIdUniqueDirective, AppVolumePipe, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, PaymentInfoComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent, - DynamicFilterComponent + DynamicFilterComponent, MarkdownViewerComponent ], exports: [ CommonModule, GlobalModule, SharedModule, ReactiveFormsModule, FormsModule, @@ -105,7 +107,7 @@ import { DynamicFilterComponent } from './dynamic-filter/dynamic-filter.componen DebounceDirective, UnitIdUniqueDirective, ProfileFormComponent, CreditcardFormComponent, CardInfoComponent, PaymentInfoComponent, PaymentSummaryComponent, PaymentMethodSummaryComponent, SubPlansDirective, PaymentAmountComponent, CreditcardExpCalComponent, CreditcardComponent, ReviewAircraftComponent, GenericMessageComponent, TrialMessageComponent, InputTrimDirective, BillingAddressEltComponent, AppFooterComponent, LanguageSwicherComponent, ConstraintMessageComponent, BadgeComponent, PromoLabelComponent, ActivePromoLabelComponent, LegacyNoticeLabelComponent, - DynamicFilterComponent + DynamicFilterComponent, MarkdownViewerComponent ], providers: [RateUnitPipe, LengthUnitPipe, UnitPipe, ProductTypePipe, CostingItemTypePipe, CostingItemUnitPipe, CurrencyNamePipe, CurrencyCodePositionPipe] }) diff --git a/Development/client/src/app/shared/base/map-base.component.ts b/Development/client/src/app/shared/base/map-base.component.ts index 167bfcc..e2e35c0 100644 --- a/Development/client/src/app/shared/base/map-base.component.ts +++ b/Development/client/src/app/shared/base/map-base.component.ts @@ -316,7 +316,10 @@ export class MapBaseComp extends BaseComp implements OnDestroy { color: props.color, area: parseFloat(props.area), appRate: parseFloat(props.appRate), - width: NumUtils.round(props.width, 1) + width: NumUtils.round(props.width, 1), + offset: props.offset !== undefined ? Number(props.offset) : undefined, + edgeSide: props.edgeSide, + edgeSign: props.edgeSign !== undefined ? Number(props.edgeSign) : undefined }; if (item.type === ITEM.SPRAY || item.type === ITEM.XCL) { if (!item.area) @@ -415,4 +418,7 @@ export interface MapItem { client?: string; lat?: number; lon?: number; + offset?: number; + edgeSide?: 'on' | 'inside' | 'outside'; + edgeSign?: number; } diff --git a/Development/client/src/app/shared/base/mapedit-base.component.ts b/Development/client/src/app/shared/base/mapedit-base.component.ts index ca6f52d..94ca295 100644 --- a/Development/client/src/app/shared/base/mapedit-base.component.ts +++ b/Development/client/src/app/shared/base/mapedit-base.component.ts @@ -363,16 +363,21 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy { xclArea += dA; } } else if (exType === ITEM.BUFFER) { - // Check whether the buffer zone within (at least one point within) the spray poly to count exlusion - xclLayers[j].updateArea(); const llns = xclLayers[j].getLatLngs(); if (llns.length) { - let ll; - for (let k = 0; k < 1; k++) { - ll = llns[k]; - if (turf.booleanPointInPolygon([ll.lng, ll.lat], sprayPoly)) { + const firstEl = llns[0]; + if (Array.isArray(firstEl)) { + // Polygon-type buffer (e.g. edge buffer zone) — use turf.intersect for accuracy + const diff = turf.intersect(sprayPoly, xclPoly); + if (diff) { + const dA = turf.area(diff); + if (dA) xclArea += dA; + } + } else { + // L.Corridor buffer — use corridor's own area calculation + xclLayers[j].updateArea(); + if (turf.booleanPointInPolygon([firstEl.lng, firstEl.lat], sprayPoly)) { xclArea += xclLayers[j].getArea(); - break; } } } @@ -446,7 +451,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy { this.postTypeChanged(e); } - protected getDefaultName(layer) { + protected getDefaultName(layer, extraOffset = 0) { if (!layer.feature || !layer.feature.properties) { return ''; } @@ -479,7 +484,7 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy { } } } - number = layers.filter(l => (l).feature.properties.type === type).length; + number = layers.filter(l => (l).feature.properties.type === type).length + extraOffset; } return `${name.trim()}_${NumUtils.padZero(number, 2)}`; } @@ -555,7 +560,10 @@ export class MapEditBaseComp extends MapBaseComp implements OnInit, OnDestroy { else { if (type === ITEM.BUFFER) { this.map.fitBounds((layer).getBounds(), GC.fbOps); - this.selItem = layer.openTooltip(); + layer.openTooltip(); + // Use a proxy so cleanup calls closeTooltip() but never removeLayer() + // (instanceof L.Polygon would match feature buffers and wrongly remove them) + this.selItem = { closeTooltip: () => layer.closeTooltip() }; } else { setTimeout(() => this.map.setView((layer).getLatLng(), Math.min(GC.MAX_ZOOM_ITEM, this.map.getZoom())), 200); if (!layer.isTooltipOpen()) diff --git a/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.css b/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.css index dc863d5..07c4d95 100644 --- a/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.css +++ b/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.css @@ -105,6 +105,14 @@ margin: 0; } +.filter-field-header-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + min-height: 2rem; +} + .filter-field-header .remove-btn { background: transparent !important; border: none !important; diff --git a/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.html b/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.html index d7883e1..d673296 100644 --- a/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.html +++ b/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.html @@ -26,9 +26,11 @@
- + + +
@@ -112,15 +114,17 @@
-
+
- Select Date... - {{ filter.value | date:'shortDate' }} - + Select Date... + {{ filter.value[0] | date:'shortDate' }} + {{ filter.value[0] | date:'shortDate' }} – {{ filter.value[1] | date:'shortDate' }} +
- + (onClearClick)="onValueChange()" [showButtonBar]="true" + selectionMode="range" [readonlyInput]="true">
diff --git a/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.ts b/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.ts index 5e4b788..332243b 100644 --- a/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.ts +++ b/Development/client/src/app/shared/dynamic-filter/dynamic-filter.component.ts @@ -14,6 +14,7 @@ export interface FilterDefinition { label: string; dataType: FilterDataType; options?: SelectItem[]; + removable?: boolean; } export type FilterOperator = 'and' | 'or'; @@ -80,10 +81,14 @@ export function buildFilterQuery(activeFilters: ActiveFilter[]): Record 0; + const queryValue = (f.definition.dataType === 'date-preset' && Array.isArray(f.value)) + ? f.value.filter((v: any) => v != null) + : f.value; query[f.definition.key] = { - value: f.value, + value: queryValue, operator: f.operator, ...(hasValueOperator ? { valueOperator: f.valueOperator } : {}), dataType: f.definition.dataType, @@ -101,7 +106,7 @@ export function buildFilterQuery(activeFilters: ActiveFilter[]): Record = []; @Input() showSearch = true; @Input() autoSaveOnChange = false; @@ -180,6 +185,9 @@ export class DynamicFilterComponent implements OnInit, OnChanges { } removeFilter(id: number): void { + const filter = this.activeFilters.find(f => f.id === id); + if (!filter || !this.isFilterRemovable(filter)) { return; } + this.activeFilters = this.activeFilters.filter(f => f.id !== id); this.datePresetSelected.delete(id); this.buildAvailableFilters(); @@ -205,11 +213,17 @@ export class DynamicFilterComponent implements OnInit, OnChanges { } clearAll(): void { - this.activeFilters = []; + this.activeFilters = this.activeFilters + .filter((filter: ActiveFilter) => !this.isFilterRemovable(filter)) + .map((filter: ActiveFilter) => this.resetFilter(filter)); this.selectedFilterKey = null; this.datePresetSelected.clear(); + this.activeFilters.forEach((filter: ActiveFilter) => { + if (filter.definition.dataType === 'date-preset' && filter.value != null) { + this.datePresetSelected.set(filter.id, filter.value); + } + }); this.buildAvailableFilters(); - this.clearState(); this.emitChange(); this.submit(); } @@ -235,6 +249,10 @@ export class DynamicFilterComponent implements OnInit, OnChanges { return this.datePresetSelected.get(filterId) === 'custom'; } + isFilterRemovable(filter: ActiveFilter): boolean { + return filter.definition.removable !== false; + } + openCal(filterId: number, isRange: boolean): void { const attr = isRange ? `data-filter-cal-range` : `data-filter-cal`; const calHost = this.el.nativeElement.querySelector(`[${attr}="${filterId}"]`); @@ -276,13 +294,18 @@ export class DynamicFilterComponent implements OnInit, OnChanges { case 'select': return null; case 'select-multi': return []; case 'date': return op === 'range' ? null : null; - case 'date-preset': return '1m'; + case 'date-preset': return null; default: return null; } } private saveState(): void { if (!this.stateKey) { return; } + if (!this.activeFilters.length) { + this.clearState(); + return; + } + const state = this.activeFilters.map(f => ({ key: f.definition.key, value: f.value, @@ -303,19 +326,13 @@ export class DynamicFilterComponent implements OnInit, OnChanges { for (const df of this.defaultFilters) { const def = this.filterDefinitions.find(f => f.key === df.key); if (!def) { continue; } - const defaultOp = DEFAULT_VALUE_OPERATOR[def.dataType]; - const filter: ActiveFilter = { - id: this.nextId++, - definition: def, - value: df.value, - operator: 'and', - valueOperator: defaultOp - }; + const filter = this.createFilter(def, df.value); this.activeFilters.push(filter); if (def.dataType === 'date-preset') { this.datePresetSelected.set(filter.id, df.value); } } + this.ensureRequiredFilters(); if (this.activeFilters.length) { this.buildAvailableFilters(); this.submit(); @@ -346,9 +363,7 @@ export class DynamicFilterComponent implements OnInit, OnChanges { if (!def) { continue; } const filter: ActiveFilter = { - id: this.nextId++, - definition: def, - value: this.deserializeValue(entry.value, def.dataType, entry.valueOperator), + ...this.createFilter(def, this.deserializeValue(entry.value, def.dataType, entry.valueOperator)), operator: entry.operator || 'and', valueOperator: entry.valueOperator || DEFAULT_VALUE_OPERATOR[def.dataType] }; @@ -359,12 +374,55 @@ export class DynamicFilterComponent implements OnInit, OnChanges { } } + this.ensureRequiredFilters(); + if (this.activeFilters.length) { this.buildAvailableFilters(); this.submit(); } } + private ensureRequiredFilters(): void { + const activeKeys = new Set(this.activeFilters.map((filter: ActiveFilter) => filter.definition.key)); + + this.filterDefinitions + .filter((definition: FilterDefinition) => definition.removable === false && !activeKeys.has(definition.key)) + .forEach((definition: FilterDefinition) => { + const defaultFilter = this.defaultFilters.find(df => df.key === definition.key); + const filter = this.createFilter(definition, defaultFilter ? defaultFilter.value : undefined); + + this.activeFilters.push(filter); + + if (definition.dataType === 'date-preset' && filter.value != null) { + this.datePresetSelected.set(filter.id, filter.value); + } + }); + } + + private createFilter(definition: FilterDefinition, value?: any): ActiveFilter { + const defaultOp = DEFAULT_VALUE_OPERATOR[definition.dataType]; + + return { + id: this.nextId++, + definition, + value: value !== undefined ? value : this.getDefaultValue(definition, defaultOp), + operator: 'and', + valueOperator: defaultOp + }; + } + + private resetFilter(filter: ActiveFilter): ActiveFilter { + const defaultFilter = this.defaultFilters.find(df => df.key === filter.definition.key); + const defaultOp = DEFAULT_VALUE_OPERATOR[filter.definition.dataType]; + + return { + ...filter, + value: defaultFilter ? defaultFilter.value : this.getDefaultValue(filter.definition, defaultOp), + operator: 'and', + valueOperator: defaultOp + }; + } + private deserializeValue(value: any, dataType: FilterDataType, valueOperator: string): any { if (value == null) { return value; } if (dataType === 'date' && valueOperator === 'range' && Array.isArray(value)) { @@ -373,6 +431,9 @@ export class DynamicFilterComponent implements OnInit, OnChanges { if (dataType === 'date' && typeof value === 'string') { return new Date(value); } + if (dataType === 'date-preset' && Array.isArray(value)) { + return value.map(v => v ? new Date(v) : null); + } return value; } } diff --git a/Development/client/src/app/shared/global.ts b/Development/client/src/app/shared/global.ts index 0b916e7..83835d5 100644 --- a/Development/client/src/app/shared/global.ts +++ b/Development/client/src/app/shared/global.ts @@ -4,7 +4,7 @@ export enum RoleIds { ADMIN = "0", APP = "1", APP_ADM = "2", CLIENT = "3", OFFIC export enum JobStatus { NEW = 0, READY = 1, DOWNLOADED = 2, SPRAYED = 3, ARCHIVED = 9 }; export enum Units { OZ = 0, GAL, LB, LIT, KG, /*GR, CC, PT*/ }; -export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE }; +export enum DRAW { SPRAY, PIVOT, XCL, WAYPOINT, BUFFER, PLACE, OBSTACLE, ABLINE, EDGE_BUFFER }; export enum PANE { GeoItems = 'GeoItems', SprayZones = 'SprayZones', XCLZones = 'XCLZones', GridLines = 'GridLines', FlightPaths = 'FlightPaths', SprayData = 'SprayData', Tracks = 'Tracks', ABLine = 'ABLine', Obstacles = 'Obstacles' @@ -628,6 +628,7 @@ export const globals = Object.freeze({ xclZone: $localize`:@@xclZone:Exclusion Zone`, waypoint: $localize`:@@waypoint:WayPoint`, bufferZone: $localize`:@@bufferZone:Buffer Zone`, + edgeBufferZone: $localize`:@@edgeBufferZone:Advanced Buffer Tools`, placeMark: $localize`:@@placeMark:PlaceMark`, obstacle: $localize`:@@obstacle:Obstacle`, userObstacle: $localize`:@@userObstacle:User Obstacles`, diff --git a/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.css b/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.css new file mode 100644 index 0000000..ca17b7d --- /dev/null +++ b/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.css @@ -0,0 +1,284 @@ +.markdown-viewer__find-bar { + display: flex; + align-items: center; + gap: 0.5rem; + margin: -0.5em -0.75em 0.75rem; + padding: 0.35rem 0.75em; + background: #f0f3f6; + border: 1px solid #d0d8e4; + border-radius: 0; + flex-shrink: 0; + position: sticky; + top: -0.5em; + z-index: 10; +} + +.markdown-viewer__find-input { + flex: 1 1 auto; + min-width: 0; + padding: 0.3rem 0.5rem; + border: 1px solid #b8c3ce; + border-radius: 3px; + font-size: 0.9rem; + outline: none; +} + +.markdown-viewer__find-input:focus { + border-color: #5b8db8; +} + +.markdown-viewer__find-count { + flex: 0 0 auto; + font-size: 0.82rem; + color: #5a6a7a; + white-space: nowrap; +} + +.markdown-viewer__find-nav { + flex: 0 0 auto; + background: none; + border: 1px solid #b8c3ce; + border-radius: 3px; + cursor: pointer; + color: #5a6a7a; + font-size: 0.8rem; + line-height: 1; + padding: 0.15rem 0.35rem; +} + +.markdown-viewer__find-nav:hover { + background: #e2e8f0; + color: #2c3e50; +} + +.markdown-viewer__find-clear { + flex: 0 0 auto; + background: none; + border: none; + cursor: pointer; + color: #5a6a7a; + font-size: 1rem; + line-height: 1; + padding: 0 0.2rem; +} + +.markdown-viewer__find-clear:hover { + color: #c0392b; +} + +mark.markdown-viewer__hl { + background: #fff59d; + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +mark.markdown-viewer__hl--active { + background: #f9a825; + outline: 2px solid #e65100; + border-radius: 2px; +} + +.markdown-viewer__toc-list { + list-style: none; + padding: 0; + margin: 0; + text-align: left; +} + +.markdown-viewer__toc-list li { + padding-top: 0.35rem; + padding-bottom: 0.35rem; +} + +.markdown-viewer__toc-resize { + flex: 0 0 6px; + cursor: col-resize; + background: transparent; + position: relative; + align-self: stretch; + z-index: 1; +} + +.markdown-viewer__toc-resize::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + right: 2px; + background: transparent; + border-radius: 3px; + transition: background 0.15s; +} + +.markdown-viewer__toc-resize:hover::after, +.markdown-viewer__toc-resize:active::after { + background: #8fa3bb; +} + +.markdown-viewer__toc-toggle { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + border: 0; + background: #4CAF50; + cursor: pointer; + border-radius: 4px 0 0 4px; + color: #ffffff; + font-size: 0.85rem; + padding: 0.4rem 0; + box-shadow: -2px 0 6px rgba(0, 0, 0, 0.35); + transition: background 0.2s ease; + z-index: 1; +} + +.markdown-viewer__toc-toggle--hidden { + position: static; + transform: none; + flex: 0 0 auto; + align-self: center; + border-radius: 4px 0 0 4px; + margin-left: 0; + box-shadow: 2px 0 6px rgba(0, 0, 0, 0.35); +} + +.markdown-viewer__toc-toggle:hover { + background: #2E7D32; +} + +.markdown-viewer__content { + flex: 1 1 auto; + min-width: 0; + max-height: 70vh; + overflow: auto; + padding: 0 0.5rem 0 0.75rem; + box-sizing: border-box; +} + +.markdown-viewer__content--full { + max-height: none; +} + +.markdown-viewer__content:focus { + outline: none; +} + +.markdown-viewer__intro { + margin-bottom: 1rem; +} + +.markdown-viewer__panel-node { + margin-bottom: 0.75rem; + padding-left: 0.25rem; +} + +.markdown-viewer__panel-node:last-child { + margin-bottom: 0; +} + +.markdown-viewer__section-title { + font-weight: 600; + padding: 0.35rem 0; +} + +.markdown-viewer__panel-content { + margin: 0.2rem 0 0 0.5rem; +} + +/* Table borders inside rendered markdown */ +:host ::ng-deep .markdown-viewer__body table { + border-collapse: collapse; + width: 100%; + margin: 0.75rem 0; +} + +:host ::ng-deep .markdown-viewer__body th, +:host ::ng-deep .markdown-viewer__body td { + border: 1px solid #b0b8c4; + padding: 0.4rem 0.65rem; + text-align: left; +} + +:host ::ng-deep .markdown-viewer__body th { + background: #e8ecf0; + font-weight: 600; +} + +:host ::ng-deep .markdown-viewer__body tr:nth-child(even) td { + background: #f5f7f9; +} + +.markdown-viewer__mermaid { + overflow-x: auto; + margin: 1.5rem 0; +} + +.markdown-viewer__mermaid .mermaid { + min-width: fit-content; +} + +.markdown-viewer__mermaid svg { + display: block; + max-width: 100%; + height: auto; +} + +.markdown-viewer__video { + margin: 1.5rem 0; +} + +.markdown-viewer__video-element, +.markdown-viewer__video-frame { + display: block; + width: 100%; + max-width: 100%; +} + +.markdown-viewer__video-element { + height: auto; +} + +.markdown-viewer__video-frame { + position: relative; + padding-bottom: 56.25%; + height: 0; + overflow: hidden; +} + +.markdown-viewer__video-frame iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +.markdown-viewer__video figcaption { + margin-top: 0.5rem; + color: #55606e; + font-size: 0.95rem; +} + +@media (max-width: 960px) { + .markdown-viewer__content { + max-height: none; + width: 100%; + padding: 0; + } + + .markdown-viewer__panel-node { + margin-left: 0 !important; + padding-left: 0; + } + + .markdown-viewer__panel-content { + margin-left: 0; + } +} diff --git a/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.html b/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.html new file mode 100644 index 0000000..0ee67c8 --- /dev/null +++ b/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.html @@ -0,0 +1,45 @@ +
+ +
+ + + +
+ + + {{ matchCount === 0 ? 'No matches' : (currentMatchIndex + 1) + ' of ' + matchCount }} + + + + + + +
+ + +
+
+
+
{{ panel.header }}
+
+
+
+
+
diff --git a/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.ts b/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.ts new file mode 100644 index 0000000..9d9234c --- /dev/null +++ b/Development/client/src/app/shared/markdown-viewer/markdown-viewer.component.ts @@ -0,0 +1,513 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, ElementRef, EventEmitter, Input, OnChanges, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import * as marked from 'marked'; +import mermaid from 'mermaid'; + +const parseMarkdown: (src: string) => string = (marked as any).marked ?? (marked as any).default ?? (marked as any); +const videoFilePattern = /\.(mp4|webm|ogg)(?:$|[?#])/i; + +interface TocItem { + label: string; + anchorId: string; +} + +interface MarkdownSection { + header: string; + tocItems: TocItem[]; + intro?: SafeHtml; + introAnchorId?: string; + panels: MarkdownContentPanel[]; + flatPanels: MarkdownContentPanel[]; +} + +interface MarkdownContentPanel { + header: string; + anchorId: string; + depth: number; + content?: SafeHtml; + children: MarkdownContentPanel[]; +} + +interface MarkdownContentPanelBuilder { + level: number; + header: string; + anchorId: string; + bodyLines: string[]; + children: MarkdownContentPanelBuilder[]; +} + +@Component({ + selector: 'app-markdown-viewer', + templateUrl: './markdown-viewer.component.html', + styleUrls: ['./markdown-viewer.component.css'] +}) +export class MarkdownViewerComponent implements OnChanges { + @Input() src?: string; + @Input() markdown?: string; + @Input() showFindBar = false; + @Output() tocItemsChange = new EventEmitter(); + @ViewChildren('markdownContent') contentPanes!: QueryList>; + + sections: MarkdownSection[] = []; + loading = false; + searchQuery = ''; + matchCount = 0; + currentMatchIndex = 0; + + private readonly hlClass = 'markdown-viewer__hl'; + private readonly hlActiveClass = 'markdown-viewer__hl--active'; + private allMarks: Element[] = []; + + constructor( + private readonly http: HttpClient, + private readonly sanitizer: DomSanitizer + ) { + mermaid.initialize({ startOnLoad: false }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.src || changes.markdown) { + this.loadContent(); + } + } + + private loadContent(): void { + if (this.markdown !== undefined) { + this.sections = [this.buildSection('', this.markdown, 0)]; + this.loading = false; + this.tocItemsChange.emit(this.sections[0].tocItems); + this.scheduleMermaidRender(); + return; + } + + if (!this.src) { + this.sections = []; + this.loading = false; + return; + } + + this.loading = true; + this.http.get(this.src, { responseType: 'text' }).subscribe({ + next: (md) => { + this.sections = [this.buildSection('', md, 0)]; + this.loading = false; + this.tocItemsChange.emit(this.sections[0].tocItems); + this.scheduleMermaidRender(); + }, + error: () => { + this.sections = []; + this.loading = false; + } + }); + } + + onSearchChange(query: string): void { + this.highlightMatches(query.trim()); + } + + onSearchKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter' || this.matchCount === 0) { return; } + event.preventDefault(); + const delta = event.shiftKey ? -1 : 1; + this.navigateMatch(delta); + } + + navigateMatch(delta: number): void { + if (this.allMarks.length === 0) { return; } + this.allMarks[this.currentMatchIndex].classList.remove(this.hlActiveClass); + this.currentMatchIndex = (this.currentMatchIndex + delta + this.allMarks.length) % this.allMarks.length; + const active = this.allMarks[this.currentMatchIndex]; + active.classList.add(this.hlActiveClass); + active.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + + clearSearch(): void { + this.searchQuery = ''; + this.highlightMatches(''); + } + + private highlightMatches(query: string): void { + // Remove existing marks + this.contentPanes.forEach(ref => { + ref.nativeElement.querySelectorAll('mark.' + this.hlClass).forEach((mark: Element) => { + const parent = mark.parentNode; + if (!parent) { return; } + while (mark.firstChild) { parent.insertBefore(mark.firstChild, mark); } + parent.removeChild(mark); + parent.normalize(); + }); + }); + + this.allMarks = []; + this.matchCount = 0; + this.currentMatchIndex = 0; + if (!query) { return; } + + const lq = query.toLowerCase(); + + this.contentPanes.forEach(ref => { + const walker = document.createTreeWalker(ref.nativeElement, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + let n: Node | null; + while ((n = walker.nextNode())) { textNodes.push(n as Text); } + + for (const textNode of textNodes) { + const text = textNode.textContent || ''; + const ltext = text.toLowerCase(); + let idx = ltext.indexOf(lq); + if (idx === -1) { continue; } + + const frag = document.createDocumentFragment(); + let last = 0; + while (idx !== -1) { + if (idx > last) { frag.appendChild(document.createTextNode(text.slice(last, idx))); } + const mark = document.createElement('mark'); + mark.className = this.hlClass; + mark.textContent = text.slice(idx, idx + query.length); + frag.appendChild(mark); + this.allMarks.push(mark); + this.matchCount++; + last = idx + query.length; + idx = ltext.indexOf(lq, last); + } + if (last < text.length) { frag.appendChild(document.createTextNode(text.slice(last))); } + textNode.parentNode?.replaceChild(frag, textNode); + } + }); + + if (this.allMarks.length > 0) { + this.currentMatchIndex = 0; + this.allMarks[0].classList.add(this.hlActiveClass); + this.allMarks[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + scrollToId(anchorId: string): void { + const contentPane = this.contentPanes.first?.nativeElement; + if (!contentPane) { return; } + const target = contentPane.querySelector('#' + anchorId) as HTMLElement | null; + if (!target) { return; } + const offset = target.getBoundingClientRect().top - contentPane.getBoundingClientRect().top; + contentPane.scrollTop += offset - 12; + target.setAttribute('tabindex', '-1'); + target.focus(); + } + + private buildSection(header: string, body: string, sectionIndex: number): MarkdownSection { + const { contentMarkdown } = this.extractTableOfContents(body); + const anchorPrefix = 'markdown-viewer-section-' + sectionIndex + '-'; + const structuredContent = this.buildStructuredContent(contentMarkdown, anchorPrefix); + const tocItems: TocItem[] = structuredContent.panels.map(p => ({ label: p.header, anchorId: p.anchorId })); + + return { + header, + tocItems, + intro: structuredContent.introHtml, + introAnchorId: structuredContent.introAnchorId, + panels: structuredContent.panels, + flatPanels: this.flattenPanels(structuredContent.panels) + }; + } + + private buildStructuredContent(contentMarkdown: string, anchorPrefix: string): { + introHtml?: SafeHtml; + introAnchorId?: string; + panels: MarkdownContentPanel[]; + } { + const lines = contentMarkdown.split('\n'); + const introLines: string[] = []; + const panelBuilders: MarkdownContentPanelBuilder[] = []; + const panelStack: MarkdownContentPanelBuilder[] = []; + const slugCounts: { [slug: string]: number } = {}; + let introAnchorId: string | undefined; + + for (const line of lines) { + const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); + + if (!headingMatch) { + if (panelStack.length > 0) { + panelStack[panelStack.length - 1].bodyLines.push(line); + } else { + introLines.push(line); + } + continue; + } + + const level = headingMatch[1].length; + const headingMarkdown = headingMatch[2].trim(); + const headingText = this.markdownToPlainText(headingMarkdown); + const anchorId = anchorPrefix + this.nextSlug(headingText, slugCounts); + + if (level === 1 && panelBuilders.length === 0 && panelStack.length === 0) { + introAnchorId = introAnchorId || anchorId; + introLines.push(line); + continue; + } + + if (level < 2) { + if (panelStack.length > 0) { + panelStack[panelStack.length - 1].bodyLines.push(line); + } else { + introLines.push(line); + } + continue; + } + + const panelBuilder: MarkdownContentPanelBuilder = { + level, + header: headingText, + anchorId, + bodyLines: [], + children: [] + }; + + while (panelStack.length > 0 && panelStack[panelStack.length - 1].level >= level) { + panelStack.pop(); + } + + if (panelStack.length === 0) { + panelBuilders.push(panelBuilder); + } else { + panelStack[panelStack.length - 1].children.push(panelBuilder); + } + + panelStack.push(panelBuilder); + } + + return { + introHtml: introLines.join('\n').trim() ? this.sanitizer.bypassSecurityTrustHtml(this.renderMarkdownHtml(introLines.join('\n').trim())) : undefined, + introAnchorId, + panels: this.buildPanels(panelBuilders, 0) + }; + } + + private extractTableOfContents(body: string): { tocMarkdown: string | null; contentMarkdown: string } { + const lines = body.split('\n'); + const tocHeadingIndex = lines.findIndex((line) => /^##\s+Table of Contents\s*$/i.test(line)); + + if (tocHeadingIndex === -1) { + return { tocMarkdown: null, contentMarkdown: body }; + } + + let tocEndIndex = tocHeadingIndex + 1; + let sawListItem = false; + + while (tocEndIndex < lines.length) { + const line = lines[tocEndIndex]; + + if (/^\s*$/.test(line)) { + tocEndIndex += 1; + continue; + } + + if (/^\s*---+\s*$/.test(line) && sawListItem) { + tocEndIndex += 1; + break; + } + + if (/^\s*(?:[-*+]\s+|\d+\.\s+)/.test(line)) { + sawListItem = true; + tocEndIndex += 1; + continue; + } + + if (/^\s{2,}(?:[-*+]\s+|\d+\.\s+)/.test(line) || /^\s{2,}\S/.test(line)) { + tocEndIndex += 1; + continue; + } + + if (sawListItem) { + break; + } + + tocEndIndex += 1; + } + + const tocMarkdown = lines.slice(tocHeadingIndex, tocEndIndex).join('\n').trim(); + const contentLines = [ + ...lines.slice(0, tocHeadingIndex), + ...lines.slice(tocEndIndex) + ]; + + return { + tocMarkdown, + contentMarkdown: contentLines.join('\n').trim() + }; + } + + private renderMarkdownHtml(markdown: string): string { + if (!markdown) { + return ''; + } + + return this.decorateContentHtml(parseMarkdown(this.preprocessMarkdown(markdown))); + } + + private decorateContentHtml(html: string): string { + const root = this.parseHtml(html); + + this.replaceMermaidBlocks(root); + + return root.innerHTML; + } + + private buildPanels(panelBuilders: MarkdownContentPanelBuilder[], depth: number): MarkdownContentPanel[] { + return panelBuilders.map((panelBuilder: MarkdownContentPanelBuilder) => ({ + header: panelBuilder.header, + anchorId: panelBuilder.anchorId, + depth, + content: panelBuilder.bodyLines.join('\n').trim() + ? this.sanitizer.bypassSecurityTrustHtml(this.renderMarkdownHtml(panelBuilder.bodyLines.join('\n').trim())) + : undefined, + children: this.buildPanels(panelBuilder.children, depth + 1), + })); + } + + private flattenPanels(panels: MarkdownContentPanel[]): MarkdownContentPanel[] { + return panels.reduce((flattenedPanels: MarkdownContentPanel[], panel: MarkdownContentPanel) => { + flattenedPanels.push(panel); + + if (panel.children.length > 0) { + flattenedPanels.push(...this.flattenPanels(panel.children)); + } + + return flattenedPanels; + }, []); + } + + private replaceMermaidBlocks(root: HTMLElement): void { + Array.from(root.querySelectorAll('pre > code')).forEach((codeElement: Element) => { + const className = codeElement.getAttribute('class') || ''; + + if (!/(?:^|\s)(?:language|lang)-mermaid(?:\s|$)/.test(className)) { + return; + } + + const preElement = codeElement.parentElement; + + if (!preElement || preElement.tagName !== 'PRE') { + return; + } + + const wrapper = root.ownerDocument.createElement('div'); + const diagram = root.ownerDocument.createElement('div'); + + wrapper.setAttribute('class', 'markdown-viewer__mermaid'); + diagram.setAttribute('class', 'mermaid'); + diagram.textContent = codeElement.textContent || ''; + wrapper.appendChild(diagram); + preElement.parentNode?.replaceChild(wrapper, preElement); + }); + } + + private scheduleMermaidRender(): void { + setTimeout(() => { + this.renderMermaidDiagrams(); + if (this.searchQuery) { + this.highlightMatches(this.searchQuery.trim()); + } + }); + } + + private renderMermaidDiagrams(): void { + if (!this.contentPanes || this.contentPanes.length === 0) { + return; + } + + this.contentPanes.forEach((contentPaneRef: ElementRef) => { + const diagrams = Array.from(contentPaneRef.nativeElement.querySelectorAll('.mermaid')) + .filter((diagramElement: Element) => !diagramElement.getAttribute('data-processed')); + + if (diagrams.length > 0) { + mermaid.init(undefined, diagrams); + } + }); + } + + private parseHtml(html: string): HTMLElement { + const parser = new DOMParser(); + const document = parser.parseFromString(html, 'text/html'); + + return document.body; + } + + private markdownToPlainText(markdown: string): string { + const root = this.parseHtml(parseMarkdown(this.preprocessMarkdown(markdown))); + + return (root.textContent || '').trim(); + } + + private nextSlug(value: string, slugCounts: { [slug: string]: number }): string { + const baseSlug = this.slugify(value); + const occurrenceCount = slugCounts[baseSlug] || 0; + + slugCounts[baseSlug] = occurrenceCount + 1; + + return occurrenceCount === 0 ? baseSlug : baseSlug + '-' + occurrenceCount; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + private preprocessMarkdown(markdown: string): string { + return markdown.replace(/^!video(?:\[([^\]]*)\])?\(([^\s)]+(?:\([^\s)]*\)[^\s)]*)*)\)$/gm, (_match, rawTitle, rawUrl) => { + const title = (rawTitle || 'Embedded video').trim(); + const url = (rawUrl || '').trim(); + + if (!url) { + return ''; + } + + return this.buildVideoEmbedHtml(url, title); + }); + } + + private buildVideoEmbedHtml(url: string, title: string): string { + const safeUrl = this.escapeHtml(url); + const safeTitle = this.escapeHtml(title); + + if (videoFilePattern.test(url)) { + return [ + '
', + ' ', + safeTitle ? '
' + safeTitle + '
' : '', + '
' + ].filter(Boolean).join('\n'); + } + + return [ + '
', + '
', + ' ', + ' ', + '
', + safeTitle ? '
' + safeTitle + '
' : '', + '
' + ].filter(Boolean).join('\n'); + } + + private slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } +} diff --git a/Development/client/src/assets/js/Leaflet.river.js b/Development/client/src/assets/js/Leaflet.river.js new file mode 100644 index 0000000..2a50ea6 --- /dev/null +++ b/Development/client/src/assets/js/Leaflet.river.js @@ -0,0 +1 @@ +L.River=L.FeatureGroup.extend({options:{color:"blue",minWidth:1,maxWidth:10,ratio:null},initialize:function(t,i){L.FeatureGroup.prototype.initialize.call(this,[],i);this._latLngs=t;L.setOptions(this,i);this._buildLines(t)},onAdd:function(t){L.FeatureGroup.prototype.onAdd.call(this,t);this._getLength(t);this.setStyle()},_buildLines:function(t){for(var i=0;i 1000) { + distanceStr = (distance / 1000).toFixed(2) + ' km'; + } + else { + distanceStr = distance.toFixed(1) + ' m'; + } + } + else if (isFeet) { + distance *= 3.28084; + if (distance > 5280) { + distanceStr = (distance / 5280).toFixed(2) + ' mi'; + } + else { + distanceStr = distance.toFixed(1) + ' ft'; + } + } + else { + distance *= 1.09361; + if (distance > 1760) { + distanceStr = (distance / 1760).toFixed(2) + ' miles'; + } + else { + distanceStr = distance.toFixed(1) + ' yd'; + } + } + return distanceStr; + }, + + /** + Returns true if the latlng belongs to segment A-B + @param {L.LatLng} latlng - The position to search + @param {L.LatLng} latlngA geographical point A of the segment + @param {L.LatLng} latlngB geographical point B of the segment + @param {?Number} [tolerance=0.2] tolerance to accept if latlng belongs really + @returns {boolean} + */ + belongsSegment: function(latlng, latlngA, latlngB, tolerance) { + tolerance = tolerance === undefined ? 0.2 : tolerance; + var hypotenuse = latlngA.distanceTo(latlngB), + delta = latlngA.distanceTo(latlng) + latlng.distanceTo(latlngB) - hypotenuse; + return delta/hypotenuse < tolerance; + }, + + /** + * Returns total length of line + * @tutorial distance-length + * + * @param {L.Polyline|Array|Array} coords Set of coordinates + * @returns {Number} Total length (pixels for Point, meters for LatLng) + */ + length: function (coords) { + var accumulated = L.GeometryUtil.accumulatedLengths(coords); + return accumulated.length > 0 ? accumulated[accumulated.length-1] : 0; + }, + + /** + * Returns a list of accumulated length along a line. + * @param {L.Polyline|Array|Array} coords Set of coordinates + * @returns {Array} Array of accumulated lengths (pixels for Point, meters for LatLng) + */ + accumulatedLengths: function (coords) { + if (typeof coords.getLatLngs == 'function') { + coords = coords.getLatLngs(); + } + if (coords.length === 0) + return []; + var total = 0, + lengths = [0]; + for (var i = 0, n = coords.length - 1; i< n; i++) { + total += coords[i].distanceTo(coords[i+1]); + lengths.push(total); + } + return lengths; + }, + + /** + Returns the closest point of a {L.LatLng} on the segment (A-B) + + @tutorial closest + + @param {L.Map} map Leaflet map to be used for this method + @param {L.LatLng} latlng - The position to search + @param {L.LatLng} latlngA geographical point A of the segment + @param {L.LatLng} latlngB geographical point B of the segment + @returns {L.LatLng} Closest geographical point + */ + closestOnSegment: function (map, latlng, latlngA, latlngB) { + var maxzoom = map.getMaxZoom(); + if (maxzoom === Infinity) + maxzoom = map.getZoom(); + var p = map.project(latlng, maxzoom), + p1 = map.project(latlngA, maxzoom), + p2 = map.project(latlngB, maxzoom), + closest = L.LineUtil.closestPointOnSegment(p, p1, p2); + return map.unproject(closest, maxzoom); + }, + + /** + Returns the closest point of a {L.LatLng} on a {L.Circle} + + @tutorial closest + + @param {L.LatLng} latlng - The position to search + @param {L.Circle} circle - A Circle defined by a center and a radius + @returns {L.LatLng} Closest geographical point on the circle circumference + */ + closestOnCircle: function (circle, latLng) { + const center = circle.getLatLng(); + const circleRadius = circle.getRadius(); + const radius = typeof circleRadius === 'number' ? circleRadius : circleRadius.radius; + const x = latLng.lng; + const y = latLng.lat; + const cx = center.lng; + const cy = center.lat; + // dx and dy is the vector from the circle's center to latLng + const dx = x - cx; + const dy = y - cy; + + // distance between the point and the circle's center + const distance = Math.sqrt(dx * dx + dy * dy) + + // Calculate the closest point on the circle by adding the normalized vector to the center + const tx = cx + (dx / distance) * radius; + const ty = cy + (dy / distance) * radius; + + return new L.LatLng(ty, tx); + }, + + + /** + Returns the closest latlng on layer. + + Accept nested arrays + + @tutorial closest + + @param {L.Map} map Leaflet map to be used for this method + @param {Array|Array>|L.PolyLine|L.Polygon} layer - Layer that contains the result + @param {L.LatLng} latlng - The position to search + @param {?boolean} [vertices=false] - Whether to restrict to path vertices. + @returns {L.LatLng} Closest geographical point or null if layer param is incorrect + */ + closest: function (map, layer, latlng, vertices) { + + var latlngs, + mindist = Infinity, + result = null, + i, n, distance, subResult; + + if (layer instanceof Array) { + // if layer is Array> + if (layer[0] instanceof Array && typeof layer[0][0] !== 'number') { + // if we have nested arrays, we calc the closest for each array + // recursive + for (i = 0; i < layer.length; i++) { + subResult = L.GeometryUtil.closest(map, layer[i], latlng, vertices); + if (subResult && subResult.distance < mindist) { + mindist = subResult.distance; + result = subResult; + } + } + return result; + } else if (layer[0] instanceof L.LatLng + || typeof layer[0][0] === 'number' + || typeof layer[0].lat === 'number') { // we could have a latlng as [x,y] with x & y numbers or {lat, lng} + layer = L.polyline(layer); + } else { + return result; + } + } + + // if we don't have here a Polyline, that means layer is incorrect + // see https://github.com/makinacorpus/Leaflet.GeometryUtil/issues/23 + if (! ( layer instanceof L.Polyline ) ) + return result; + + // deep copy of latlngs + latlngs = JSON.parse(JSON.stringify(layer.getLatLngs().slice(0))); + + // add the last segment for L.Polygon + if (layer instanceof L.Polygon) { + // add the last segment for each child that is a nested array + var addLastSegment = function(latlngs) { + if (L.Polyline._flat(latlngs)) { + latlngs.push(latlngs[0]); + } else { + for (var i = 0; i < latlngs.length; i++) { + addLastSegment(latlngs[i]); + } + } + }; + addLastSegment(latlngs); + } + + // we have a multi polygon / multi polyline / polygon with holes + // use recursive to explore and return the good result + if ( ! L.Polyline._flat(latlngs) ) { + for (i = 0; i < latlngs.length; i++) { + // if we are at the lower level, and if we have a L.Polygon, we add the last segment + subResult = L.GeometryUtil.closest(map, latlngs[i], latlng, vertices); + if (subResult.distance < mindist) { + mindist = subResult.distance; + result = subResult; + } + } + return result; + + } else { + + // Lookup vertices + if (vertices) { + for(i = 0, n = latlngs.length; i < n; i++) { + var ll = latlngs[i]; + distance = L.GeometryUtil.distance(map, latlng, ll); + if (distance < mindist) { + mindist = distance; + result = ll; + result.distance = distance; + } + } + return result; + } + + // Keep the closest point of all segments + for (i = 0, n = latlngs.length; i < n-1; i++) { + var latlngA = latlngs[i], + latlngB = latlngs[i+1]; + distance = L.GeometryUtil.distanceSegment(map, latlng, latlngA, latlngB); + if (distance <= mindist) { + mindist = distance; + result = L.GeometryUtil.closestOnSegment(map, latlng, latlngA, latlngB); + result.distance = distance; + } + } + return result; + } + + }, + + /** + Returns the closest layer to latlng among a list of layers. + + @tutorial closest + + @param {L.Map} map Leaflet map to be used for this method + @param {Array} layers Set of layers + @param {L.LatLng} latlng - The position to search + @returns {object} ``{layer, latlng, distance}`` or ``null`` if list is empty; + */ + closestLayer: function (map, layers, latlng) { + var mindist = Infinity, + result = null, + ll = null, + distance = Infinity; + + for (var i = 0, n = layers.length; i < n; i++) { + var layer = layers[i]; + if (layer instanceof L.LayerGroup) { + // recursive + var subResult = L.GeometryUtil.closestLayer(map, layer.getLayers(), latlng); + if (subResult.distance < mindist) { + mindist = subResult.distance; + result = subResult; + } + } else { + if (layer instanceof L.Circle){ + ll = this.closestOnCircle(layer, latlng); + distance = L.GeometryUtil.distance(map, latlng, ll); + } else + // Single dimension, snap on points, else snap on closest + if (typeof layer.getLatLng == 'function') { + ll = layer.getLatLng(); + distance = L.GeometryUtil.distance(map, latlng, ll); + } + else { + ll = L.GeometryUtil.closest(map, layer, latlng); + if (ll) distance = ll.distance; // Can return null if layer has no points. + } + if (distance < mindist) { + mindist = distance; + result = {layer: layer, latlng: ll, distance: distance}; + } + } + } + return result; + }, + + /** + Returns the n closest layers to latlng among a list of input layers. + + @param {L.Map} map - Leaflet map to be used for this method + @param {Array} layers - Set of layers + @param {L.LatLng} latlng - The position to search + @param {?Number} [n=layers.length] - the expected number of output layers. + @returns {Array} an array of objects ``{layer, latlng, distance}`` or ``null`` if the input is invalid (empty list or negative n) + */ + nClosestLayers: function (map, layers, latlng, n) { + n = typeof n === 'number' ? n : layers.length; + + if (n < 1 || layers.length < 1) { + return null; + } + + var results = []; + var distance, ll; + + for (var i = 0, m = layers.length; i < m; i++) { + var layer = layers[i]; + if (layer instanceof L.LayerGroup) { + // recursive + var subResult = L.GeometryUtil.closestLayer(map, layer.getLayers(), latlng); + results.push(subResult); + } else { + if (layer instanceof L.Circle){ + ll = this.closestOnCircle(layer, latlng); + distance = L.GeometryUtil.distance(map, latlng, ll); + } else + // Single dimension, snap on points, else snap on closest + if (typeof layer.getLatLng == 'function') { + ll = layer.getLatLng(); + distance = L.GeometryUtil.distance(map, latlng, ll); + } + else { + ll = L.GeometryUtil.closest(map, layer, latlng); + if (ll) distance = ll.distance; // Can return null if layer has no points. + } + results.push({layer: layer, latlng: ll, distance: distance}); + } + } + + results.sort(function(a, b) { + return a.distance - b.distance; + }); + + if (results.length > n) { + return results.slice(0, n); + } else { + return results; + } + }, + + /** + * Returns all layers within a radius of the given position, in an ascending order of distance. + @param {L.Map} map Leaflet map to be used for this method + @param {Array} layers - A list of layers. + @param {L.LatLng} latlng - The position to search + @param {?Number} [radius=Infinity] - Search radius in pixels + @return {object[]} an array of objects including layer within the radius, closest latlng, and distance + */ + layersWithin: function(map, layers, latlng, radius) { + radius = typeof radius == 'number' ? radius : Infinity; + + var results = []; + var ll = null; + var distance = 0; + + for (var i = 0, n = layers.length; i < n; i++) { + var layer = layers[i]; + + if (typeof layer.getLatLng == 'function') { + ll = layer.getLatLng(); + distance = L.GeometryUtil.distance(map, latlng, ll); + } + else { + ll = L.GeometryUtil.closest(map, layer, latlng); + if (ll) distance = ll.distance; // Can return null if layer has no points. + } + + if (ll && distance < radius) { + results.push({layer: layer, latlng: ll, distance: distance}); + } + } + + var sortedResults = results.sort(function(a, b) { + return a.distance - b.distance; + }); + + return sortedResults; + }, + + /** + Returns the closest position from specified {LatLng} among specified layers, + with a maximum tolerance in pixels, providing snapping behaviour. + + @tutorial closest + + @param {L.Map} map Leaflet map to be used for this method + @param {Array} layers - A list of layers to snap on. + @param {L.LatLng} latlng - The position to snap + @param {?Number} [tolerance=Infinity] - Maximum number of pixels. + @param {?boolean} [withVertices=true] - Snap to layers vertices or segment points (not only vertex) + @returns {object} with snapped {LatLng} and snapped {Layer} or null if tolerance exceeded. + */ + closestLayerSnap: function (map, layers, latlng, tolerance, withVertices) { + tolerance = typeof tolerance == 'number' ? tolerance : Infinity; + withVertices = typeof withVertices == 'boolean' ? withVertices : true; + + var result = L.GeometryUtil.closestLayer(map, layers, latlng); + if (!result || result.distance > tolerance) + return null; + + // If snapped layer is linear, try to snap on vertices (extremities and middle points) + if (withVertices && typeof result.layer.getLatLngs == 'function') { + var closest = L.GeometryUtil.closest(map, result.layer, result.latlng, true); + if (closest.distance < tolerance) { + result.latlng = closest; + result.distance = L.GeometryUtil.distance(map, closest, latlng); + } + } + return result; + }, + + /** + Returns the Point located on a segment at the specified ratio of the segment length. + @param {L.Point} pA coordinates of point A + @param {L.Point} pB coordinates of point B + @param {Number} the length ratio, expressed as a decimal between 0 and 1, inclusive. + @returns {L.Point} the interpolated point. + */ + interpolateOnPointSegment: function (pA, pB, ratio) { + return L.point( + (pA.x * (1 - ratio)) + (ratio * pB.x), + (pA.y * (1 - ratio)) + (ratio * pB.y) + ); + }, + + /** + Returns the coordinate of the point located on a line at the specified ratio of the line length. + @param {L.Map} map Leaflet map to be used for this method + @param {Array|L.PolyLine} latlngs Set of geographical points + @param {Number} ratio the length ratio, expressed as a decimal between 0 and 1, inclusive + @returns {Object} an object with latLng ({LatLng}) and predecessor ({Number}), the index of the preceding vertex in the Polyline + (-1 if the interpolated point is the first vertex) + */ + interpolateOnLine: function (map, latLngs, ratio) { + latLngs = (latLngs instanceof L.Polyline) ? latLngs.getLatLngs() : latLngs; + var n = latLngs.length; + if (n < 2) { + return null; + } + + // ensure the ratio is between 0 and 1; + ratio = Math.max(Math.min(ratio, 1), 0); + + if (ratio === 0) { + return { + latLng: latLngs[0] instanceof L.LatLng ? latLngs[0] : L.latLng(latLngs[0]), + predecessor: -1 + }; + } + if (ratio == 1) { + return { + latLng: latLngs[latLngs.length -1] instanceof L.LatLng ? latLngs[latLngs.length -1] : L.latLng(latLngs[latLngs.length -1]), + predecessor: latLngs.length - 2 + }; + } + + // project the LatLngs as Points, + // and compute total planar length of the line at max precision + var maxzoom = map.getMaxZoom(); + if (maxzoom === Infinity) + maxzoom = map.getZoom(); + var pts = []; + var lineLength = 0; + for(var i = 0; i < n; i++) { + pts[i] = map.project(latLngs[i], maxzoom); + if(i > 0) + lineLength += pts[i-1].distanceTo(pts[i]); + } + + var ratioDist = lineLength * ratio; + + // follow the line segments [ab], adding lengths, + // until we find the segment where the points should lie on + var cumulativeDistanceToA = 0, cumulativeDistanceToB = 0; + for (var i = 0; cumulativeDistanceToB < ratioDist; i++) { + var pointA = pts[i], pointB = pts[i+1]; + + cumulativeDistanceToA = cumulativeDistanceToB; + cumulativeDistanceToB += pointA.distanceTo(pointB); + } + + if (pointA == undefined && pointB == undefined) { // Happens when line has no length + var pointA = pts[0], pointB = pts[1], i = 1; + } + + // compute the ratio relative to the segment [ab] + var segmentRatio = ((cumulativeDistanceToB - cumulativeDistanceToA) !== 0) ? ((ratioDist - cumulativeDistanceToA) / (cumulativeDistanceToB - cumulativeDistanceToA)) : 0; + var interpolatedPoint = L.GeometryUtil.interpolateOnPointSegment(pointA, pointB, segmentRatio); + return { + latLng: map.unproject(interpolatedPoint, maxzoom), + predecessor: i-1 + }; + }, + + /** + Returns a float between 0 and 1 representing the location of the + closest point on polyline to the given latlng, as a fraction of total line length. + (opposite of L.GeometryUtil.interpolateOnLine()) + @param {L.Map} map Leaflet map to be used for this method + @param {L.PolyLine} polyline Polyline on which the latlng will be search + @param {L.LatLng} latlng The position to search + @returns {Number} Float between 0 and 1 + */ + locateOnLine: function (map, polyline, latlng) { + var latlngs = polyline.getLatLngs(); + if (latlng.equals(latlngs[0])) + return 0.0; + if (latlng.equals(latlngs[latlngs.length-1])) + return 1.0; + + var point = L.GeometryUtil.closest(map, polyline, latlng, false), + lengths = L.GeometryUtil.accumulatedLengths(latlngs), + total_length = lengths[lengths.length-1], + portion = 0, + found = false; + for (var i=0, n = latlngs.length-1; i < n; i++) { + var l1 = latlngs[i], + l2 = latlngs[i+1]; + portion = lengths[i]; + if (L.GeometryUtil.belongsSegment(point, l1, l2, 0.001)) { + portion += l1.distanceTo(point); + found = true; + break; + } + } + if (!found) { + throw "Could not interpolate " + latlng.toString() + " within " + polyline.toString(); + } + return portion / total_length; + }, + + /** + Returns a clone with reversed coordinates. + @param {L.PolyLine} polyline polyline to reverse + @returns {L.PolyLine} polyline reversed + */ + reverse: function (polyline) { + return L.polyline(polyline.getLatLngs().slice(0).reverse()); + }, + + /** + Returns a sub-part of the polyline, from start to end. + If start is superior to end, returns extraction from inverted line. + @param {L.Map} map Leaflet map to be used for this method + @param {L.PolyLine} polyline Polyline on which will be extracted the sub-part + @param {Number} start ratio, expressed as a decimal between 0 and 1, inclusive + @param {Number} end ratio, expressed as a decimal between 0 and 1, inclusive + @returns {Array} new polyline + */ + extract: function (map, polyline, start, end) { + if (start > end) { + return L.GeometryUtil.extract(map, L.GeometryUtil.reverse(polyline), 1.0-start, 1.0-end); + } + + // Bound start and end to [0-1] + start = Math.max(Math.min(start, 1), 0); + end = Math.max(Math.min(end, 1), 0); + + var latlngs = polyline.getLatLngs(), + startpoint = L.GeometryUtil.interpolateOnLine(map, polyline, start), + endpoint = L.GeometryUtil.interpolateOnLine(map, polyline, end); + // Return single point if start == end + if (start == end) { + var point = L.GeometryUtil.interpolateOnLine(map, polyline, end); + return [point.latLng]; + } + // Array.slice() works indexes at 0 + if (startpoint.predecessor == -1) + startpoint.predecessor = 0; + if (endpoint.predecessor == -1) + endpoint.predecessor = 0; + var result = latlngs.slice(startpoint.predecessor+1, endpoint.predecessor+1); + result.unshift(startpoint.latLng); + result.push(endpoint.latLng); + return result; + }, + + /** + Returns true if first polyline ends where other second starts. + @param {L.PolyLine} polyline First polyline + @param {L.PolyLine} other Second polyline + @returns {bool} + */ + isBefore: function (polyline, other) { + if (!other) return false; + var lla = polyline.getLatLngs(), + llb = other.getLatLngs(); + return (lla[lla.length-1]).equals(llb[0]); + }, + + /** + Returns true if first polyline starts where second ends. + @param {L.PolyLine} polyline First polyline + @param {L.PolyLine} other Second polyline + @returns {bool} + */ + isAfter: function (polyline, other) { + if (!other) return false; + var lla = polyline.getLatLngs(), + llb = other.getLatLngs(); + return (lla[0]).equals(llb[llb.length-1]); + }, + + /** + Returns true if first polyline starts where second ends or start. + @param {L.PolyLine} polyline First polyline + @param {L.PolyLine} other Second polyline + @returns {bool} + */ + startsAtExtremity: function (polyline, other) { + if (!other) return false; + var lla = polyline.getLatLngs(), + llb = other.getLatLngs(), + start = lla[0]; + return start.equals(llb[0]) || start.equals(llb[llb.length-1]); + }, + + /** + Returns horizontal angle in degres between two points. + @param {L.Point} a Coordinates of point A + @param {L.Point} b Coordinates of point B + @returns {Number} horizontal angle + */ + computeAngle: function(a, b) { + return (Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI); + }, + + /** + Returns slope (Ax+B) between two points. + @param {L.Point} a Coordinates of point A + @param {L.Point} b Coordinates of point B + @returns {Object} with ``a`` and ``b`` properties. + */ + computeSlope: function(a, b) { + var s = (b.y - a.y) / (b.x - a.x), + o = a.y - (s * a.x); + return {'a': s, 'b': o}; + }, + + /** + Returns LatLng of rotated point around specified LatLng center. + @param {L.LatLng} latlngPoint: point to rotate + @param {double} angleDeg: angle to rotate in degrees + @param {L.LatLng} latlngCenter: center of rotation + @returns {L.LatLng} rotated point + */ + rotatePoint: function(map, latlngPoint, angleDeg, latlngCenter) { + var maxzoom = map.getMaxZoom(); + if (maxzoom === Infinity) + maxzoom = map.getZoom(); + var angleRad = angleDeg*Math.PI/180, + pPoint = map.project(latlngPoint, maxzoom), + pCenter = map.project(latlngCenter, maxzoom), + x2 = Math.cos(angleRad)*(pPoint.x-pCenter.x) - Math.sin(angleRad)*(pPoint.y-pCenter.y) + pCenter.x, + y2 = Math.sin(angleRad)*(pPoint.x-pCenter.x) + Math.cos(angleRad)*(pPoint.y-pCenter.y) + pCenter.y; + return map.unproject(new L.Point(x2,y2), maxzoom); + }, + + /** + Returns the bearing in degrees clockwise from north (0 degrees) + from the first L.LatLng to the second, at the first LatLng + @param {L.LatLng} latlng1: origin point of the bearing + @param {L.LatLng} latlng2: destination point of the bearing + @returns {float} degrees clockwise from north. + */ + bearing: function(latlng1, latlng2) { + var rad = Math.PI / 180, + lat1 = latlng1.lat * rad, + lat2 = latlng2.lat * rad, + lon1 = latlng1.lng * rad, + lon2 = latlng2.lng * rad, + y = Math.sin(lon2 - lon1) * Math.cos(lat2), + x = Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1); + + var bearing = ((Math.atan2(y, x) * 180 / Math.PI) + 360) % 360; + return bearing >= 180 ? bearing-360 : bearing; + }, + + /** + Returns the point that is a distance and heading away from + the given origin point. + @param {L.LatLng} latlng: origin point + @param {float} heading: heading in degrees, clockwise from 0 degrees north. + @param {float} distance: distance in meters + @returns {L.latLng} the destination point. + Many thanks to Chris Veness at http://www.movable-type.co.uk/scripts/latlong.html + for a great reference and examples. + */ + destination: function(latlng, heading, distance) { + heading = (heading + 360) % 360; + var rad = Math.PI / 180, + radInv = 180 / Math.PI, + R = L.CRS.Earth.R, // approximation of Earth's radius + lon1 = latlng.lng * rad, + lat1 = latlng.lat * rad, + rheading = heading * rad, + sinLat1 = Math.sin(lat1), + cosLat1 = Math.cos(lat1), + cosDistR = Math.cos(distance / R), + sinDistR = Math.sin(distance / R), + lat2 = Math.asin(sinLat1 * cosDistR + cosLat1 * + sinDistR * Math.cos(rheading)), + lon2 = lon1 + Math.atan2(Math.sin(rheading) * sinDistR * + cosLat1, cosDistR - sinLat1 * Math.sin(lat2)); + lon2 = lon2 * radInv; + lon2 = lon2 > 180 ? lon2 - 360 : lon2 < -180 ? lon2 + 360 : lon2; + return L.latLng([lat2 * radInv, lon2]); + }, + + /** + Returns the the angle of the given segment and the Equator in degrees, + clockwise from 0 degrees north. + @param {L.Map} map: Leaflet map to be used for this method + @param {L.LatLng} latlngA: geographical point A of the segment + @param {L.LatLng} latlngB: geographical point B of the segment + @returns {Float} the angle in degrees. + */ + angle: function(map, latlngA, latlngB) { + var pointA = map.latLngToContainerPoint(latlngA), + pointB = map.latLngToContainerPoint(latlngB), + angleDeg = Math.atan2(pointB.y - pointA.y, pointB.x - pointA.x) * 180 / Math.PI + 90; + angleDeg += angleDeg < 0 ? 360 : 0; + return angleDeg; + }, + + /** + Returns a point snaps on the segment and heading away from the given origin point a distance. + @param {L.Map} map: Leaflet map to be used for this method + @param {L.LatLng} latlngA: geographical point A of the segment + @param {L.LatLng} latlngB: geographical point B of the segment + @param {float} distance: distance in meters + @returns {L.latLng} the destination point. + */ + destinationOnSegment: function(map, latlngA, latlngB, distance) { + var angleDeg = L.GeometryUtil.angle(map, latlngA, latlngB), + latlng = L.GeometryUtil.destination(latlngA, angleDeg, distance); + return L.GeometryUtil.closestOnSegment(map, latlng, latlngA, latlngB); + }, +}); + +return L.GeometryUtil; + +})); diff --git a/Development/client/src/assets/js/leaflet.polylineoffset.js b/Development/client/src/assets/js/leaflet.polylineoffset.js new file mode 100644 index 0000000..b8bae65 --- /dev/null +++ b/Development/client/src/assets/js/leaflet.polylineoffset.js @@ -0,0 +1,227 @@ +(function (factory, window) { + if (typeof define === 'function' && define.amd) { + define(['leaflet'], factory); + } else if (typeof exports === 'object') { + module.exports = factory(require('leaflet')); + } + if (typeof window !== 'undefined' && window.L) { + window.L.PolylineOffset = factory(L); + } +}(function (L) { + +function forEachPair(list, callback) { + if (!list || list.length < 1) { return; } + for (var i = 1, l = list.length; i < l; i++) { + callback(list[i-1], list[i]); + } +} + +/** +Find the coefficients (a,b) of a line of equation y = a.x + b, +or the constant x for vertical lines +Return null if there's no equation possible +*/ +function lineEquation(pt1, pt2) { + if (pt1.x === pt2.x) { + return pt1.y === pt2.y ? null : { x: pt1.x }; + } + + var a = (pt2.y - pt1.y) / (pt2.x - pt1.x); + return { + a: a, + b: pt1.y - a * pt1.x, + }; +} + +/** +Return the intersection point of two lines defined by two points each +Return null when there's no unique intersection +*/ +function intersection(l1a, l1b, l2a, l2b) { + var line1 = lineEquation(l1a, l1b); + var line2 = lineEquation(l2a, l2b); + + if (line1 === null || line2 === null) { + return null; + } + + if (line1.hasOwnProperty('x')) { + return line2.hasOwnProperty('x') + ? null + : { + x: line1.x, + y: line2.a * line1.x + line2.b, + }; + } + if (line2.hasOwnProperty('x')) { + return { + x: line2.x, + y: line1.a * line2.x + line1.b, + }; + } + + if (line1.a === line2.a) { + return null; + } + + var x = (line2.b - line1.b) / (line1.a - line2.a); + return { + x: x, + y: line1.a * x + line1.b, + }; +} + +function translatePoint(pt, dist, heading) { + return { + x: pt.x + dist * Math.cos(heading), + y: pt.y + dist * Math.sin(heading), + }; +} + +var PolylineOffset = { + offsetPointLine: function(points, distance) { + var offsetSegments = []; + + forEachPair(points, L.bind(function(a, b) { + if (a.x === b.x && a.y === b.y) { return; } + + // angles in (-PI, PI] + var segmentAngle = Math.atan2(a.y - b.y, a.x - b.x); + var offsetAngle = segmentAngle - Math.PI/2; + + offsetSegments.push({ + offsetAngle: offsetAngle, + original: [a, b], + offset: [ + translatePoint(a, distance, offsetAngle), + translatePoint(b, distance, offsetAngle) + ] + }); + }, this)); + + return offsetSegments; + }, + + offsetPoints: function(pts, options) { + var offsetSegments = this.offsetPointLine(L.LineUtil.simplify(pts, options.smoothFactor), options.offset); + return this.joinLineSegments(offsetSegments, options.offset); + }, + + /** + Join 2 line segments defined by 2 points each with a circular arc + */ + joinSegments: function(s1, s2, offset) { + // TODO: different join styles + return this.circularArc(s1, s2, offset) + .filter(function(x) { return x; }) + }, + + joinLineSegments: function(segments, offset) { + var joinedPoints = []; + var first = segments[0]; + var last = segments[segments.length - 1]; + + if (first && last) { + joinedPoints.push(first.offset[0]); + forEachPair(segments, L.bind(function(s1, s2) { + joinedPoints = joinedPoints.concat(this.joinSegments(s1, s2, offset)); + }, this)); + joinedPoints.push(last.offset[1]); + } + + return joinedPoints; + }, + + segmentAsVector: function(s) { + return { + x: s[1].x - s[0].x, + y: s[1].y - s[0].y, + }; + }, + + getSignedAngle: function(s1, s2) { + const a = this.segmentAsVector(s1); + const b = this.segmentAsVector(s2); + return Math.atan2(a.x * b.y - a.y * b.x, a.x * b.x + a.y * b.y); + }, + + /** + Interpolates points between two offset segments in a circular form + */ + circularArc: function(s1, s2, distance) { + // if the segments are the same angle, + // there should be a single join point + if (s1.offsetAngle === s2.offsetAngle) { + return [s1.offset[1]]; + } + + const signedAngle = this.getSignedAngle(s1.offset, s2.offset); + // for inner angles, just find the offset segments intersection + if ((signedAngle * distance > 0) && + (signedAngle * this.getSignedAngle(s1.offset, [s1.offset[0], s2.offset[1]]) > 0)) { + return [intersection(s1.offset[0], s1.offset[1], s2.offset[0], s2.offset[1])]; + } + + // draws a circular arc with R = offset distance, C = original meeting point + var points = []; + var center = s1.original[1]; + // ensure angles go in the anti-clockwise direction + var rightOffset = distance > 0; + var startAngle = rightOffset ? s2.offsetAngle : s1.offsetAngle; + var endAngle = rightOffset ? s1.offsetAngle : s2.offsetAngle; + // and that the end angle is bigger than the start angle + if (endAngle < startAngle) { + endAngle += Math.PI * 2; + } + var step = Math.PI / 8; + for (var alpha = startAngle; alpha < endAngle; alpha += step) { + points.push(translatePoint(center, distance, alpha)); + } + points.push(translatePoint(center, distance, endAngle)); + + return rightOffset ? points.reverse() : points; + } +} + +// Modify the L.Polyline class by overwriting the projection function +L.Polyline.include({ + _projectLatlngs: function (latlngs, result, projectedBounds) { + var isFlat = latlngs.length > 0 && latlngs[0] instanceof L.LatLng; + + if (isFlat) { + var ring = latlngs.map(L.bind(function(ll) { + var point = this._map.latLngToLayerPoint(ll); + if (projectedBounds) { + projectedBounds.extend(point); + } + return point; + }, this)); + + // Offset management hack --- + if (this.options.offset) { + ring = L.PolylineOffset.offsetPoints(ring, this.options); + } + // Offset management hack END --- + + result.push(ring.map(function (xy) { + return L.point(xy.x, xy.y); + })); + } else { + latlngs.forEach(L.bind(function(ll) { + this._projectLatlngs(ll, result, projectedBounds); + }, this)); + } + } +}); + +L.Polyline.include({ + setOffset: function(offset) { + this.options.offset = offset; + this.redraw(); + return this; + } +}); + +return PolylineOffset; + +}, window)); diff --git a/Development/client/src/assets/js/leaflet.snap.js b/Development/client/src/assets/js/leaflet.snap.js new file mode 100644 index 0000000..c38f36b --- /dev/null +++ b/Development/client/src/assets/js/leaflet.snap.js @@ -0,0 +1,669 @@ +/* globals L:true */ + +L.Snap = {}; + +L.Snap.isDifferentLayer = function (marker, layer) { + var i; + var n; + var markerId = L.stamp(marker); + + if (layer.hasOwnProperty('_snapIgnore')) { + return false; + } + + if (layer.hasOwnProperty('_topOwner') && marker.hasOwnProperty('_topOwner')) { + return layer._topOwner !== marker._topOwner; + } + + if (layer instanceof L.Marker) { + return markerId !== L.stamp(layer); + } + + if (layer.editing && layer.editing._enabled) { + if (layer.editing._verticesHandlers) { + var points = layer.editing._verticesHandlers[0]._markerGroup.getLayers(); + for(i = 0, n = points.length; i < n; i++) { + if (L.stamp(points[i]) == markerId) { + return false; + } + } + } + + else if (layer.editing._resizeMarkers) { + for(i = 0; i < layer.editing._resizeMarkers.length; i++) { + var resizeMarker = layer.editing._resizeMarkers[i]; + if (L.stamp(resizeMarker) == markerId) { + return false; + } + } + + if (layer.editing._moveMarker) { + return markerId !== L.stamp(layer.editing._moveMarker); + } + + return true; + } + } + + return true; +}; + +L.Snap.processGuide = function (latlng, marker, guide, snaplist, buffer) { + // Guide is a layer group and has no L.LayerIndexMixin (from Leaflet.LayerIndex) + if ((guide._layers !== undefined) && (typeof guide.searchBuffer !== 'function')) { + for (var id in guide._layers) { + if (guide._layers.hasOwnProperty(id)) { + L.Snap.processGuide(latlng, marker, guide._layers[id], snaplist, buffer); + } + } + } + + // Search snaplist around mouse + else if (typeof guide.searchBuffer === 'function') { + var nearlayers = guide.searchBuffer(latlng, buffer); + snaplist = snaplist.concat(nearlayers.filter(function(layer) { + return L.Snap.isDifferentLayer(layer); + })); + } + + // Make sure the marker doesn't snap to itself or an associated polyline layer + else if (L.Snap.isDifferentLayer(marker, guide)) { + snaplist.push(guide); + } +}; + +L.Snap.findClosestLayerSnap = function (map, layers, latlng, tolerance, withVertices) { + var closest = L.GeometryUtil.nClosestLayers(map, layers, latlng, 6); + + // code to correct prefer snap to shapes (and their vertices, if withVertices is true) to gridlines and guidelines, and then guidelines to gridlines + var withinTolerance = []; + var pointsWithinTolerance = []; + var shapesWithinTolerance = []; + var guidesWithinTolerance = []; + for (var c=0; c 0) { + var pointInfo = pointsWithinTolerance[0]; + returnLayer = pointInfo.layer; + returnLatLng = pointInfo.latlng; + } + + else if (shapesWithinTolerance.length > 0) { + var shapeInfo = shapesWithinTolerance[0]; + returnLayer = shapeInfo.layer; + returnLatLng = shapeInfo.latlng; + + // this is code from L.GeometryUtil.closestSnap that will find + // the closest vertex of this layer to the point + if (withVertices && (typeof shapeInfo.layer.getLatLngs == 'function')) { + var vertexLatLng = L.GeometryUtil.closest(map, shapeInfo.layer, shapeInfo.latlng, true); + + if (vertexLatLng) { + var d = L.GeometryUtil.distance(map, latlng, vertexLatLng); + if (d < tolerance) { + returnLatLng = new L.LatLng(vertexLatLng.lat, vertexLatLng.lng); + } + } + } + } + + else if (guidesWithinTolerance.length > 0) { + var guideInfo = guidesWithinTolerance[0]; + var guideType = guideInfo.layer._guidelineGroup; + + for (var i=0; i void` | Scrolls the content area to the element with the given id. Use in conjunction with `tocItemsChange` to implement external TOC navigation. | + +--- + +## Usage Examples + +### Render a remote file + +```html + +``` + +### Render an inline string + +```html + +``` + +### With find-in-page bar + +```html + +``` + +### With an external Table of Contents + +The component emits TOC items but does **not** render a TOC sidebar itself. The host component is responsible for displaying the list and wiring up scroll navigation. + +**Template:** +```html + + + + + + +``` + +**Component:** +```typescript +import { ViewChild } from '@angular/core'; +import { MarkdownViewerComponent } from '../shared/markdown-viewer/markdown-viewer.component'; + +export class MyComponent { + @ViewChild(MarkdownViewerComponent) viewer?: MarkdownViewerComponent; + + tocItems: { label: string; anchorId: string }[] = []; + content = '# Hello\n\nSome text.\n\n## Section Two\n\nMore text.'; + + scrollToHeading(event: MouseEvent, anchorId: string): void { + event.preventDefault(); + this.viewer?.scrollToId(anchorId); + } +} +``` + +--- + +## Content Features + +### Mermaid diagrams + +Fenced code blocks with language `mermaid` are automatically rendered as SVG diagrams: + +````markdown +```mermaid +graph TD + A --> B +``` +```` + +### Inline video embeds + +Use the custom `!video[title](url)` syntax to embed video files (`.mp4`, `.webm`, `.ogg`) or iframes (YouTube, Vimeo, etc.): + +```markdown +!video[Demo walkthrough](https://example.com/demo.mp4) + +!video[YouTube video](https://www.youtube.com/embed/abc123) +``` + +### Tables + +Standard markdown tables are styled with borders and alternating row colours automatically. + +--- + +## Notes + +- Content is split into sections at every heading. The text before the first heading becomes an "intro" block. +- `src` and `markdown` are mutually exclusive. If both are provided, `markdown` wins. +- `scrollToId` is a no-op if the component has not yet rendered or the anchor does not exist in the current content. +- The component is part of `AppSharedModule` and does not need to be imported separately in feature modules that already import `AppSharedModule`. diff --git a/Development/server/model/job.js b/Development/server/model/job.js index f94214f..1c5025e 100644 --- a/Development/server/model/job.js +++ b/Development/server/model/job.js @@ -104,8 +104,8 @@ const schema = new Schema({ bufs: [{ properties: { type: Schema.Types.Mixed }, geometry: { - type: { type: String, required: true }, - coordinates: { type: [[Number]], required: true } + type: { type: String, required: true }, + coordinates: { type: Schema.Types.Mixed, required: true } } }], waypoints: [{ @@ -180,7 +180,6 @@ schema.plugin(AutoIncrement, { inc_field: '_id' }); schema.index({ 'sprayAreas.geometry': '2dsphere' }); schema.index({ 'excludedAreas.geometry': '2dsphere' }); */ -schema.index({ 'bufs.geometry': '2dsphere' }); schema.index({ 'waypoints.geometry': '2dsphere' }); schema.index({ 'places.geometry': '2dsphere' });