diff --git a/assets/css/_schedule-line.scss b/assets/css/_schedule-line.scss index 2048ce77c3..c6da0c58a8 100644 --- a/assets/css/_schedule-line.scss +++ b/assets/css/_schedule-line.scss @@ -40,6 +40,10 @@ } .m-schedule-line__stop-popup { + // Must stay above the z-index set on marker groups (see `z_index` on `icon_markers`). + // The map wrapper is `isolate`d so this can't overlap dropdowns outside the map. + z-index: $z-index-map-popup; + .maplibregl-popup-content { background-color: $black; color: $white; diff --git a/assets/css/_variables.scss b/assets/css/_variables.scss index 4155e5b3dc..1147a558c3 100644 --- a/assets/css/_variables.scss +++ b/assets/css/_variables.scss @@ -123,6 +123,7 @@ $btn-padding-y: $base-spacing * .6; $z-index-modal: 101; $z-index-desktop-menu: 100; +$z-index-map-popup: 50; // Above marker groups; only applies within the map's isolated stacking context $z-index-above-leaflet: 23; $z-index-leaflet-level-8: 21; $z-index-leaflet-level-7: 21; diff --git a/assets/css/app.scss b/assets/css/app.scss index 25fa38beec..962117e940 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -1,5 +1,6 @@ @use "../node_modules/maplibre-gl/dist/maplibre-gl.css"; @import "../../deps/mbta_metro/priv/dist/metro.css"; +@import "../../deps/maplibrex/assets/css/maplibrex.css"; @import "./map.css"; @tailwind base; diff --git a/assets/js/app.js b/assets/js/app.js index e7bd44a3ef..abde69cb7e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -17,6 +17,7 @@ import fullstory from "./fullstory"; import googleAnalytics from "./google-analytics"; import googleTranslate from "./google-translate"; import juxtapose from "./juxtapose"; +import { MapHooks } from "maplibrex" import modal from "./modal"; import photoGallery from "./photo-gallery"; import pslPageSetup from "./psl-page-setup.js"; @@ -48,7 +49,7 @@ import storageOptions from "./storage.js"; let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, - hooks: { ...DotcomHooks, Map }, + hooks: { ...MapHooks, ...DotcomHooks, Map }, dom: { onBeforeElUpdated(from, to) { /* diff --git a/assets/package-lock.json b/assets/package-lock.json index 08730963c5..9ff546ae30 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -31,7 +31,8 @@ "leaflet": "^1.4.0", "leaflet-rotatedmarker": "^0.2.0", "lodash": "^4.17.21", - "maplibre-gl": "4.7.0", + "maplibre-gl": "5.24.0", + "maplibrex": "file:../deps/maplibrex", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", @@ -51,6 +52,8 @@ "@babel/core": "^7.17.4", "@babel/preset-env": "^7.22.5", "@babel/register": "^7.17.0", + "@deck.gl/core": "^9.4.1", + "@deck.gl/mapbox": "^9.4.1", "@faker-js/faker": "^8.0.2", "@tailwindcss/container-queries": "^0.1.1", "@testing-library/dom": "^8.16.0", @@ -140,6 +143,31 @@ "webpack-merge": "5.10.0" } }, + "../deps/maplibrex": { + "version": "0.1.0", + "license": "MIT", + "peerDependencies": { + "@deck.gl/aggregation-layers": "^9.0.0", + "@deck.gl/core": "^9.0.0", + "@deck.gl/layers": "^9.0.0", + "@deck.gl/mapbox": "^9.0.0", + "maplibre-gl": ">=5.0.0 <7.0.0" + }, + "peerDependenciesMeta": { + "@deck.gl/aggregation-layers": { + "optional": true + }, + "@deck.gl/core": { + "optional": true + }, + "@deck.gl/layers": { + "optional": true + }, + "@deck.gl/mapbox": { + "optional": true + } + } + }, "../deps/phoenix": { "version": "1.8.15", "license": "MIT", @@ -4040,6 +4068,47 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/@deck.gl/core": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.4.1.tgz", + "integrity": "sha512-DyZTcn4DIWbCKqi4W4PMcG57DD+B/CbEkNex9EKLLUvxl27i45UEEwbYu5Br+c3P3bkcH6tNvB8v/rbsxkCWWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.4.3", + "@loaders.gl/images": "^4.4.3", + "@luma.gl/core": "^9.4.2", + "@luma.gl/engine": "^9.4.2", + "@luma.gl/gpgpu": "^9.4.2", + "@luma.gl/shadertools": "^9.4.2", + "@luma.gl/webgl": "^9.4.2", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.1", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.1.1" + } + }, + "node_modules/@deck.gl/mapbox": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.4.1.tgz", + "integrity": "sha512-gg18JhYAHZoW8v7QVzcIU5mTRkfuPWuZRXHF+vBq6QY3E5OKInEmRzHsw/iRa/BKu3lSkN2WM3tKn8CpMie64Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.4.0", + "@luma.gl/core": "~9.4.0", + "@math.gl/web-mercator": "^4.1.0" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.5.7", "dev": true, @@ -5010,29 +5079,175 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", - "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", - "license": "ISC", + "node_modules/@loaders.gl/core": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.5.4.tgz", + "integrity": "sha512-mhO2TT5KWU3GbFYSQ78nPAhoeCnoUfTYjzwxzhHAhd656U4MW/Rq5CxZxM6liKk0Jt11dCQ8VT4nXu8334CmqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.5.4", + "@loaders.gl/schema": "4.5.4", + "@loaders.gl/schema-utils": "4.5.4", + "@loaders.gl/worker-utils": "4.5.4", + "@probe.gl/log": "^4.1.1" + } + }, + "node_modules/@loaders.gl/images": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.5.4.tgz", + "integrity": "sha512-Z/7SSC9v+iQdcpwkr7nSloCpgAREuO2/3yD4niAhk+ar7nrgRByaiBH16L51M5Juse8MG3oC67gaO85VuUb3jA==", + "dev": true, + "license": "MIT", "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" + "@loaders.gl/loader-utils": "4.5.4" }, - "bin": { - "geojson-rewind": "geojson-rewind" + "peerDependencies": { + "@loaders.gl/core": "~4.5.0" } }, - "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/@loaders.gl/loader-utils": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.5.4.tgz", + "integrity": "sha512-ZUNn/4+DEn8yTKGUKdjlbA7ok2JyNrica/Lvf1d8dwx7vCUSZ84PBB9cNtrx8VDXmVjkIClQzmnriz16QHjTMQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@loaders.gl/schema": "4.5.4", + "@loaders.gl/worker-utils": "4.5.4", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1" + } + }, + "node_modules/@loaders.gl/schema": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.5.4.tgz", + "integrity": "sha512-5CO/TY2OkmM53+qfSvzMt17LwVJhBatqi/QaO45VOn8poTK1jqyZvnI0qvKFqOsMWzHh4yoQVUnxaWiV4CZ41w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7", + "apache-arrow": ">= 17.0.0" + } + }, + "node_modules/@loaders.gl/schema-utils": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema-utils/-/schema-utils-4.5.4.tgz", + "integrity": "sha512-vV52aT4RJiHzzbHlwdkX254d/ubdNzcxJuFfrfDeyUZkKDa//YPhVFjCxfNlxXgS+7pS0TGakfNB18vyYkk3FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.5.4", + "@math.gl/types": "^4.1.0", + "@types/geojson": "^7946.0.7", + "apache-arrow": ">= 17.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@loaders.gl/core": "~4.5.0" + } + }, + "node_modules/@loaders.gl/worker-utils": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.5.4.tgz", + "integrity": "sha512-OvVMMLh56QdBQgyMDxuho9doWq6oiz7paqP3rwidb+LqgJB5GvdqcoL+GbUcku2X8k0ZxCldvo+l46Yhpv0bfQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@loaders.gl/core": "~4.5.0" + } + }, + "node_modules/@luma.gl/core": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.4.2.tgz", + "integrity": "sha512-VFol6FUSHQeYR40NrkCM/mkP5DX7aI8pcDewxI8DBjtrcP4+ZJiJwnxWZnsMBzM4RuuvmDoHkPWCxj6Ob9yo+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.1.1", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1", + "@types/offscreencanvas": "^2019.7.3" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.4.2.tgz", + "integrity": "sha512-gBAcn+b1jOwE4pDF5zbuzMxCa/KGY3Q2Gq0gNUPU1AZHNk4aDgOEQaY2sqMsaoGHkDXB+pxneJQPw5c6Q5mnHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1" + }, + "peerDependencies": { + "@luma.gl/core": "~9.4.0", + "@luma.gl/shadertools": "~9.4.0" + } + }, + "node_modules/@luma.gl/gpgpu": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@luma.gl/gpgpu/-/gpgpu-9.4.2.tgz", + "integrity": "sha512-LoEqoViPZqMHqQzvq/txFuOLsxI6sn8xmv+IZyovRFBqcWaV184PE/8Ieufww0pjVyJMfubhUCQmGuGrOME8oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.4.0", + "@luma.gl/engine": "~9.4.0", + "@luma.gl/shadertools": "~9.4.0", + "@luma.gl/webgpu": "~9.4.0" + } + }, + "node_modules/@luma.gl/shadertools": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.4.2.tgz", + "integrity": "sha512-0/6pLZSfnQnvcWdYePKKSwVet+gFtAAnKeGXNfxpgp3I8E3WsF/SbD6oEzgU7jdnRqONwGsoACWNpQHO0zZvIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.4.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.4.2.tgz", + "integrity": "sha512-D3GqnxTgQI+vsyH56hX5+jwqdVUhvhbpnPurTwbQOSlSQ8mshhNKcRdrZytTy10qiodDwrcjTnndPEAP6UgCzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.1.1" + }, + "peerDependencies": { + "@luma.gl/core": "~9.4.0" + } + }, + "node_modules/@luma.gl/webgpu": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@luma.gl/webgpu/-/webgpu-9.4.2.tgz", + "integrity": "sha512-24gDOmLyL81fVHxn+UXajnYN5jVln7XgB9fvobc1Yh3PAtXjlcAkmVTqYRvULVGeEamuqvqPDscAkaK/T+R1iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@probe.gl/env": "^4.1.1", + "@webgpu/types": "^0.1.69" + }, + "peerDependencies": { + "@luma.gl/core": "~9.4.0", + "@luma.gl/shadertools": "~9.4.0" } }, "node_modules/@mapbox/jsonlint-lines-primitives": { @@ -5045,9 +5260,9 @@ } }, "node_modules/@mapbox/point-geometry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", - "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", "license": "ISC" }, "node_modules/@mapbox/tiny-sdf": { @@ -5063,12 +5278,14 @@ "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", - "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/point-geometry": "~0.1.0" + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" } }, "node_modules/@mapbox/whoots-js": { @@ -5080,18 +5297,26 @@ "node": ">=6.0.0" } }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.3.1.tgz", + "integrity": "sha512-743sKqGQPU35Vgdjw0GFk58mUdKlxI3c36vvUvrU9qBx7EdWGSXagLU5yNlOTP12WADo+foDyP5vtlDJjzlvHw==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", - "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", "license": "ISC", "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", - "@mapbox/unitbezier": "^0.0.1", + "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", - "quickselect": "^2.0.0", - "rw": "^1.3.3", + "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { @@ -5100,11 +5325,77 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", - "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", - "license": "ISC" + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz", + "integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.3.tgz", + "integrity": "sha512-dEe5XUd64RQbxNxy2OzIkJnwNDWjVmSXjCd0A7zLirm7QJ/WWbmtDFIkfsiM4yt1cplqzG6oSivRYeWx1SM1yg==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.2" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -5154,6 +5445,30 @@ "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", "dev": true }, + "node_modules/@probe.gl/env": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.2.tgz", + "integrity": "sha512-KIcdjrEiEZVIGX9nh7pZSmTMIKrd+wCb09gyXqpYYI4hs9WMqrQHvKZ7KD8ASutf6kwARwgd4WNQn8aucLSz3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.2.tgz", + "integrity": "sha512-2LqSq4CHhc/HJQasHMPOzA6c7rprlGz8CCREGebo19jxcuKdvXQo0DEmlfId076U6UldMp6jGhWvDHEFLX111Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.2" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.2.tgz", + "integrity": "sha512-Hqc0UIHd0IZ2+JbovzTz7lYG7CY9D8yCB81zyP92Zl9UDSPcw2cLbTySW14keAQeAo1sjGjUw0+KHxr0Y2h/hA==", + "dev": true, + "license": "MIT" + }, "node_modules/@remix-run/router": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", @@ -5810,18 +6125,10 @@ } }, "node_modules/@types/geojson": { - "version": "7946.0.14", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", - "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" - }, - "node_modules/@types/geojson-vt": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", - "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" }, "node_modules/@types/graceful-fs": { "version": "4.1.5", @@ -5906,23 +6213,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mapbox__point-geometry": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", - "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", - "license": "MIT" - }, - "node_modules/@types/mapbox__vector-tile": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", - "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*", - "@types/mapbox__point-geometry": "*", - "@types/pbf": "*" - } - }, "node_modules/@types/mdast": { "version": "3.0.12", "dev": true, @@ -5942,9 +6232,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.11.9", + "version": "25.9.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.9.tgz", + "integrity": "sha512-b4e2xxj/yMeT2hNlD6x7zNFiQK2Dlmkb9CQ6v3/1G2RezKN4YS2lcEv8lzGm1mhs4f2lM4yNAMI/zUDBZBBt7w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } }, "node_modules/@types/node-forge": { "version": "1.3.11", @@ -5960,15 +6255,16 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/parse-json": { - "version": "4.0.0", + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "dev": true, "license": "MIT" }, - "node_modules/@types/pbf": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", - "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "node_modules/@types/parse-json": { + "version": "4.0.0", + "dev": true, "license": "MIT" }, "node_modules/@types/phoenix": { @@ -6123,15 +6419,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/supercluster": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", - "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.5", "dev": true, @@ -6635,6 +6922,14 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webgpu/types": { + "version": "0.1.74", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz", + "integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/@webpack-cli/configtest": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", @@ -6998,6 +7293,29 @@ "node": ">= 8" } }, + "node_modules/apache-arrow": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.2.0.tgz", + "integrity": "sha512-Hxe6Agq26gQOM954qpzYSllJBPJl+e16U5CkfuMUhLrNba+5nKkttIVlflaovN6oaTratqMGAO8H5u/aNhmHWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^25.2.0", + "flatbuffers": "^25.1.24", + "json-with-bigint": "^3.5.3", + "tslib": "^2.6.2" + }, + "bin": { + "arrow2csv": "bin/arrow2csv.js" + } + }, + "node_modules/apache-arrow/node_modules/tslib": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "dev": true, + "license": "0BSD" + }, "node_modules/arch": { "version": "2.2.0", "dev": true, @@ -11540,6 +11858,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/flatpickr": { "version": "4.6.13", "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", @@ -11776,12 +12101,6 @@ "node": ">=6.9.0" } }, - "node_modules/geojson-vt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", - "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", - "license": "ISC" - }, "node_modules/get-caller-file": { "version": "2.0.5", "dev": true, @@ -12544,6 +12863,7 @@ }, "node_modules/ieee754": { "version": "1.2.1", + "dev": true, "funding": [ { "type": "github", @@ -15413,6 +15733,13 @@ "dev": true, "license": "ISC" }, + "node_modules/json-with-bigint": { + "version": "3.5.12", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", + "integrity": "sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "dev": true, @@ -15475,6 +15802,7 @@ }, "node_modules/kind-of": { "version": "6.0.3", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15934,37 +16262,30 @@ "dev": true }, "node_modules/maplibre-gl": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.0.tgz", - "integrity": "sha512-hkt7je7NxiMQE8EpCxLWP8t6tkK6SkrMe0hIBjYd4Ar/Q7BOCILxthGmGnU993Mwmkvs2mGiXnVUSOK12DeCzg==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/point-geometry": "^0.1.0", - "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^1.3.1", + "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", - "@maplibre/maplibre-gl-style-spec": "^20.3.1", - "@types/geojson": "^7946.0.14", - "@types/geojson-vt": "3.2.5", - "@types/mapbox__point-geometry": "^0.1.4", - "@types/mapbox__vector-tile": "^1.3.4", - "@types/pbf": "^3.0.5", - "@types/supercluster": "^7.1.3", - "earcut": "^3.0.0", - "geojson-vt": "^4.0.2", - "gl-matrix": "^3.4.3", - "global-prefix": "^4.0.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", "murmurhash-js": "^1.0.0", - "pbf": "^3.3.0", - "potpack": "^2.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", "quickselect": "^3.0.0", - "supercluster": "^8.0.1", - "tinyqueue": "^3.0.0", - "vt-pbf": "^3.1.3" + "tinyqueue": "^3.0.0" }, "engines": { "node": ">=16.14.0", @@ -15974,52 +16295,9 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, - "node_modules/maplibre-gl/node_modules/global-prefix": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", - "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", - "license": "MIT", - "dependencies": { - "ini": "^4.1.3", - "kind-of": "^6.0.3", - "which": "^4.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/maplibre-gl/node_modules/ini": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", - "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/maplibre-gl/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/maplibre-gl/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } + "node_modules/maplibrex": { + "resolved": "../deps/maplibrex", + "link": true }, "node_modules/math-intrinsics": { "version": "1.1.0", @@ -16367,6 +16645,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mjolnir.js": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.1.1.tgz", + "integrity": "sha512-Em3+0p6n2RamaiiDenHByvaWNolr+K0fWsFpgUgUXRssFI59ramCMdp0crU6V75clbA4fDJBVTBXfBF5rlPB9A==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.3.0", "license": "MIT/X11", @@ -17591,12 +17876,11 @@ } }, "node_modules/pbf": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", - "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", "license": "BSD-3-Clause", "dependencies": { - "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" }, "bin": { @@ -20488,12 +20772,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -22019,15 +22297,6 @@ "url": "https://opencollective.com/postcss/" } }, - "node_modules/supercluster": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", - "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", - "license": "ISC", - "dependencies": { - "kdbush": "^4.0.2" - } - }, "node_modules/supports-color": { "version": "5.5.0", "dev": true, @@ -22967,6 +23236,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", "dev": true, @@ -23272,17 +23548,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/vt-pbf": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", - "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", - "license": "MIT", - "dependencies": { - "@mapbox/point-geometry": "0.1.0", - "@mapbox/vector-tile": "^1.3.1", - "pbf": "^3.2.1" - } - }, "node_modules/w3c-hr-time": { "version": "1.0.2", "dev": true, diff --git a/assets/package.json b/assets/package.json index f0e5cbba5e..350d5dab7a 100644 --- a/assets/package.json +++ b/assets/package.json @@ -26,7 +26,8 @@ "leaflet": "^1.4.0", "leaflet-rotatedmarker": "^0.2.0", "lodash": "^4.17.21", - "maplibre-gl": "4.7.0", + "maplibre-gl": "5.24.0", + "maplibrex": "file:../deps/maplibrex", "phoenix": "file:../deps/phoenix", "phoenix_html": "file:../deps/phoenix_html", "phoenix_live_view": "file:../deps/phoenix_live_view", @@ -46,6 +47,8 @@ "@babel/core": "^7.17.4", "@babel/preset-env": "^7.22.5", "@babel/register": "^7.17.0", + "@deck.gl/core": "^9.4.1", + "@deck.gl/mapbox": "^9.4.1", "@faker-js/faker": "^8.0.2", "@tailwindcss/container-queries": "^0.1.1", "@testing-library/dom": "^8.16.0", diff --git a/assets/ts/phoenix-hooks/__tests__/fit-bounds-test.ts b/assets/ts/phoenix-hooks/__tests__/fit-bounds-test.ts new file mode 100644 index 0000000000..8d880f41e0 --- /dev/null +++ b/assets/ts/phoenix-hooks/__tests__/fit-bounds-test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +type Handler = (payload: object) => void; + +const FitBounds = require("../fit-bounds").default; + +describe("FitBounds hook", () => { + let handlers: Record; + let hook: { handleEvent: (name: string, fn: Handler) => void }; + + beforeEach(() => { + document.body.innerHTML = '
'; + handlers = {}; + hook = { + handleEvent: (name: string, fn: Handler) => { + handlers[name] = fn; + } + }; + FitBounds.mounted.call(hook); + }); + + it("dispatches maplibrex:fit_bounds on the map element", () => { + const received: CustomEvent[] = []; + document + .getElementById("the-map")! + .addEventListener("maplibrex:fit_bounds", e => + received.push(e as CustomEvent) + ); + + handlers["fit-bounds"]({ + map_id: "the-map", + bounds: [[-71.1, 42.3], [-71, 42.4]], + padding: 50, + max_zoom: 16 + }); + + expect(received).toHaveLength(1); + expect(received[0].detail).toEqual({ + bounds: [[-71.1, 42.3], [-71, 42.4]], + padding: 50, + maxZoom: 16 + }); + }); + + it("ignores events for a map that is not on the page", () => { + expect(() => + handlers["fit-bounds"]({ map_id: "missing", bounds: [[0, 0], [1, 1]] }) + ).not.toThrow(); + }); +}); diff --git a/assets/ts/phoenix-hooks/__tests__/icon-markers-test.ts b/assets/ts/phoenix-hooks/__tests__/icon-markers-test.ts new file mode 100644 index 0000000000..286a5f238b --- /dev/null +++ b/assets/ts/phoenix-hooks/__tests__/icon-markers-test.ts @@ -0,0 +1,271 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { MapManager } from "maplibrex"; + +const mockCreated: MockMarker[] = []; + +class MockPopup { + options: object; + html: string | null = null; + + constructor(options: object) { + this.options = options; + } + + setHTML(html: string): this { + this.html = html; + return this; + } +} + +class MockMarker { + options: { rotation: number; anchor: string; element: HTMLElement }; + element: HTMLElement; + lngLat: number[] | null = null; + rotation: number; + popup: MockPopup | null = null; + added = false; + removed = false; + + constructor(options: { + rotation: number; + anchor: string; + element: HTMLElement; + }) { + this.options = options; + this.element = options.element; + this.rotation = options.rotation; + mockCreated.push(this); + } + + setLngLat(lngLat: number[]): this { + this.lngLat = lngLat; + return this; + } + + setRotation(rotation: number): this { + this.rotation = rotation; + return this; + } + + setPopup(popup: MockPopup | null): this { + this.popup = popup; + return this; + } + + addTo(): this { + this.added = true; + return this; + } + + remove(): this { + this.removed = true; + return this; + } +} + +// The factories run when the hook is first required, after the classes above exist. +jest.mock("maplibre-gl", () => ({ + Marker: MockMarker, + Popup: MockPopup +})); + +jest.mock("maplibrex", () => ({ MapManager: { get: jest.fn() } }), { + virtual: true +}); + +interface IconOptions { + id?: string; + coordinates: number[]; + rotation?: string; + popup?: string; +} + +const icon = ({ + id, + coordinates, + rotation, + popup +}: IconOptions): HTMLElement => { + const el = document.createElement("span"); + el.setAttribute("data-coordinates", JSON.stringify(coordinates)); + if (id !== undefined) el.setAttribute("data-marker-id", id); + if (rotation !== undefined) el.setAttribute("data-rotation", rotation); + if (popup !== undefined) el.setAttribute("data-popup", popup); + return el; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Hook = any; + +describe("IconMarkers hook", () => { + const IconMarkers = require("../icon-markers").default; + let hook: Hook; + + const setIcons = (icons: IconOptions[]): void => { + hook.el.innerHTML = ""; + icons.forEach(i => hook.el.appendChild(icon(i))); + }; + + beforeEach(() => { + mockCreated.length = 0; + (MapManager.get as jest.Mock).mockImplementation((id: string) => + id === "map" ? {} : undefined + ); + const el = document.createElement("div"); + el.dataset.mapId = "map"; + hook = Object.assign(Object.create(IconMarkers), { el }); + }); + + it("adds a marker for each icon, with rotation and popup", () => { + setIcons([ + { id: "a", coordinates: [-71, 42], rotation: "90", popup: "A" }, + { id: "b", coordinates: [-72, 43] } + ]); + + hook.mounted(); + + expect(mockCreated).toHaveLength(2); + expect(mockCreated[0].lngLat).toEqual([-71, 42]); + expect(mockCreated[0].options.rotation).toBe(90); + expect(mockCreated[0].options.anchor).toBe("center"); + expect(mockCreated[0].popup!.html).toBe("A"); + expect(mockCreated[0].added).toBe(true); + expect(mockCreated[1].popup).toBeNull(); + expect(mockCreated[1].options.rotation).toBe(0); + }); + + it("applies data-z-index to marker elements, including ones added later", () => { + hook.el.dataset.zIndex = "2"; + setIcons([{ id: "a", coordinates: [-71, 42] }]); + hook.mounted(); + + setIcons([ + { id: "a", coordinates: [-71, 42] }, + { id: "b", coordinates: [-72, 43] } + ]); + hook.updated(); + + expect(mockCreated.map(m => m.element.style.zIndex)).toEqual(["2", "2"]); + }); + + it("leaves z-index unset without data-z-index", () => { + setIcons([{ id: "a", coordinates: [-71, 42] }]); + + hook.mounted(); + + expect(mockCreated[0].element.style.zIndex).toBe(""); + }); + + it("clones the source element instead of moving it", () => { + setIcons([{ id: "a", coordinates: [-71, 42] }]); + + hook.mounted(); + + expect(mockCreated[0].element).not.toBe(hook.el.firstChild); + expect(hook.el.children).toHaveLength(1); + }); + + it("does nothing if the map is not registered", () => { + hook.el.dataset.mapId = "missing"; + setIcons([{ id: "a", coordinates: [-71, 42] }]); + + hook.mounted(); + + expect(mockCreated).toHaveLength(0); + }); + + it("skips icons without a [lng, lat] pair", () => { + setIcons([{ id: "a", coordinates: [-71] }]); + + hook.mounted(); + + expect(mockCreated).toHaveLength(0); + }); + + describe("updated", () => { + it("moves and rotates existing markers in place", () => { + setIcons([{ id: "a", coordinates: [-71, 42], rotation: "10" }]); + hook.mounted(); + + setIcons([{ id: "a", coordinates: [-70, 41], rotation: "20" }]); + hook.updated(); + + expect(mockCreated).toHaveLength(1); + expect(mockCreated[0].removed).toBe(false); + expect(mockCreated[0].lngLat).toEqual([-70, 41]); + expect(mockCreated[0].rotation).toBe(20); + }); + + it("adds new ids and removes missing ids", () => { + setIcons([ + { id: "a", coordinates: [-71, 42] }, + { id: "b", coordinates: [-72, 43] } + ]); + hook.mounted(); + + setIcons([ + { id: "b", coordinates: [-72, 43] }, + { id: "c", coordinates: [-73, 44] } + ]); + hook.updated(); + + expect(mockCreated).toHaveLength(3); + expect(mockCreated[0].removed).toBe(true); + expect(mockCreated[1].removed).toBe(false); + expect(mockCreated[2].lngLat).toEqual([-73, 44]); + }); + + it("keeps the same popup and only replaces changed HTML", () => { + setIcons([{ id: "a", coordinates: [-71, 42], popup: "one" }]); + hook.mounted(); + const popup = mockCreated[0].popup!; + const setHTML = jest.spyOn(popup, "setHTML"); + + setIcons([{ id: "a", coordinates: [-71, 42], popup: "one" }]); + hook.updated(); + expect(setHTML).not.toHaveBeenCalled(); + + setIcons([{ id: "a", coordinates: [-71, 42], popup: "two" }]); + hook.updated(); + expect(mockCreated[0].popup).toBe(popup); + expect(popup.html).toBe("two"); + }); + + it("adds and removes popups as data-popup changes", () => { + setIcons([{ id: "a", coordinates: [-71, 42] }]); + hook.mounted(); + expect(mockCreated[0].popup).toBeNull(); + + setIcons([{ id: "a", coordinates: [-71, 42], popup: "hi" }]); + hook.updated(); + expect(mockCreated[0].popup!.html).toBe("hi"); + + setIcons([{ id: "a", coordinates: [-71, 42] }]); + hook.updated(); + expect(mockCreated[0].popup).toBeNull(); + }); + + it("falls back to position when there are no ids", () => { + setIcons([{ coordinates: [-71, 42] }]); + hook.mounted(); + + setIcons([{ coordinates: [-70, 41] }]); + hook.updated(); + + expect(mockCreated).toHaveLength(1); + expect(mockCreated[0].lngLat).toEqual([-70, 41]); + }); + }); + + it("removes every marker when destroyed", () => { + setIcons([ + { id: "a", coordinates: [-71, 42] }, + { id: "b", coordinates: [-72, 43] } + ]); + hook.mounted(); + + hook.destroyed(); + + expect(mockCreated.every(marker => marker.removed)).toBe(true); + }); +}); diff --git a/assets/ts/phoenix-hooks/__tests__/map-test.ts b/assets/ts/phoenix-hooks/__tests__/map-test.ts new file mode 100644 index 0000000000..2b7eb26ca8 --- /dev/null +++ b/assets/ts/phoenix-hooks/__tests__/map-test.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const mockSetStyle = jest.fn(); +const mockOriginalUpdated = jest.fn(function updated(this: { + _maplibrex: { + map: { setStyle: (style: unknown) => void }; + config: { style: unknown }; + }; + el: HTMLElement; +}) { + const newConfig = JSON.parse(this.el.dataset.config as string); + // Mirrors MaplibreX, which compares styles by reference + if (newConfig.style !== this._maplibrex.config.style) { + this._maplibrex.map.setStyle(newConfig.style); + } +}); + +jest.mock( + "maplibrex", + () => ({ MapHooks: { MapHook: { updated: mockOriginalUpdated } } }), + { virtual: true } +); + +const MapHook = require("../map").default; + +const style = { version: 8, sources: {}, layers: [] }; + +const buildHook = (configStyle: unknown, elementStyle: unknown) => { + const el = document.createElement("div"); + el.dataset.config = JSON.stringify({ style: elementStyle }); + + // maplibre-gl defines setStyle on the prototype + class MockMap {} + Object.assign(MockMap.prototype, { setStyle: mockSetStyle }); + const map = new MockMap() as { setStyle: typeof mockSetStyle }; + return { el, _maplibrex: { map, config: { style: configStyle } } }; +}; + +describe("Map hook", () => { + beforeEach(() => { + mockSetStyle.mockClear(); + mockOriginalUpdated.mockClear(); + }); + + it("doesn't reset the style when an equal style object is re-sent", () => { + const hook = buildHook(style, { ...style }); + + MapHook.updated.call(hook); + + expect(mockOriginalUpdated).toHaveBeenCalledTimes(1); + expect(mockSetStyle).not.toHaveBeenCalled(); + expect(Object.keys(hook._maplibrex.map)).toEqual([]); + expect(hook._maplibrex.map.setStyle).toBe(mockSetStyle); + }); + + it("still sets the style when it has changed", () => { + const newStyle = { ...style, version: 9 }; + const hook = buildHook(style, newStyle); + + MapHook.updated.call(hook); + + expect(mockSetStyle).toHaveBeenCalledWith(newStyle); + }); +}); diff --git a/assets/ts/phoenix-hooks/fit-bounds.ts b/assets/ts/phoenix-hooks/fit-bounds.ts new file mode 100644 index 0000000000..25f848db46 --- /dev/null +++ b/assets/ts/phoenix-hooks/fit-bounds.ts @@ -0,0 +1,37 @@ +import { ViewHook } from "phoenix_live_view"; + +interface FitBoundsPayload { + map_id: string; // eslint-disable-line camelcase + bounds: [[number, number], [number, number]]; + padding?: number; + max_zoom?: number; // eslint-disable-line camelcase +} + +/** + * Hook that lets the server fit a MaplibreX map to a set of bounds with + * `push_event(socket, "fit-bounds", %{map_id: ..., bounds: ..., padding: ..., max_zoom: ...})`. + * + * It forwards the request to the map as MaplibreX's own `maplibrex:fit_bounds` + * event, so it behaves the same as `MaplibreX.Components.Map.fit_bounds/3`. + */ +const FitBounds: Partial = { + mounted(this: ViewHook) { + this.handleEvent( + "fit-bounds", + ({ + map_id: mapId, + bounds, + padding, + max_zoom: maxZoom + }: FitBoundsPayload) => { + document.getElementById(mapId)?.dispatchEvent( + new CustomEvent("maplibrex:fit_bounds", { + detail: { bounds, padding, maxZoom } + }) + ); + } + ); + } +}; + +export default FitBounds; diff --git a/assets/ts/phoenix-hooks/icon-markers.ts b/assets/ts/phoenix-hooks/icon-markers.ts new file mode 100644 index 0000000000..b78ec34557 --- /dev/null +++ b/assets/ts/phoenix-hooks/icon-markers.ts @@ -0,0 +1,124 @@ +import maplibregl from "maplibre-gl"; +import { MapManager } from "maplibrex"; +import { ViewHook } from "phoenix_live_view"; + +interface MarkerEntry { + marker: maplibregl.Marker; + popup: maplibregl.Popup | null; + popupHtml: string | null; +} + +interface IconMarkersHook extends ViewHook { + markers: Map; + renderMarkers(): void; +} + +// Returns the entry with its popup brought in line with `popupHtml` +function syncPopup( + { marker, popup, popupHtml }: MarkerEntry, + newPopupHtml: string | null +): MarkerEntry { + if (!newPopupHtml) { + if (popup) marker.setPopup(null); + return { marker, popup: null, popupHtml: null }; + } + + const activePopup = + popup || + new maplibregl.Popup({ + className: "m-schedule-line__stop-popup", + focusAfterOpen: false + }); + if (!popup) marker.setPopup(activePopup); + if (popupHtml !== newPopupHtml) activePopup.setHTML(newPopupHtml); + + return { marker, popup: activePopup, popupHtml: newPopupHtml }; +} + +/** + * Hook that places custom HTML elements on a MaplibreX map. + * + * Set `data-map-id` to the id of the map. Set `data-z-index` to control how + * this group's markers stack against other marker groups on the same map + * (otherwise later-added markers draw on top). Each child with a + * `data-coordinates` attribute (JSON `[lng, lat]`) becomes a marker, using a + * clone of that child as the marker element. Optional attributes on each child: + * + * - `data-marker-id`: stable id for the marker. When the hook updates, markers + * with the same id are moved in place (keeping any open popup), new ids are + * added, and missing ids are removed. Defaults to the child's position. + * - `data-anchor`: marker anchor, e.g. "center" (default) or "bottom" + * - `data-rotation`: marker rotation in degrees + * - `data-popup`: HTML for a popup that opens when the marker is clicked + */ +const IconMarkers: Partial = { + mounted(this: IconMarkersHook) { + this.markers = new Map(); + this.renderMarkers(); + }, + + updated(this: IconMarkersHook) { + this.renderMarkers(); + }, + + destroyed(this: IconMarkersHook) { + this.markers.forEach(({ marker }) => marker.remove()); + this.markers.clear(); + }, + + renderMarkers(this: IconMarkersHook) { + const map = MapManager.get(this.el.dataset.mapId || ""); + if (!map) return; + + const seen = new Set(); + + this.el + .querySelectorAll("[data-coordinates]") + .forEach((source: Element, index: number) => { + const coordinates: number[] = JSON.parse( + source.getAttribute("data-coordinates") || "[]" + ); + if (coordinates.length !== 2) return; + + const id = source.getAttribute("data-marker-id") || `index-${index}`; + seen.add(id); + + const lngLat = coordinates as [number, number]; + const rotation = Number(source.getAttribute("data-rotation")) || 0; + const popupHtml = source.getAttribute("data-popup"); + const existing = this.markers.get(id); + + if (existing) { + existing.marker.setLngLat(lngLat).setRotation(rotation); + this.markers.set(id, syncPopup(existing, popupHtml)); + return; + } + + const element = source.cloneNode(true) as HTMLElement; + if (this.el.dataset.zIndex) + element.style.zIndex = this.el.dataset.zIndex; + + const marker = new maplibregl.Marker({ + anchor: (source.getAttribute("data-anchor") || + "center") as maplibregl.PositionAnchor, + rotation, + element + }).setLngLat(lngLat); + + marker.addTo(map); + this.markers.set( + id, + syncPopup({ marker, popup: null, popupHtml: null }, popupHtml) + ); + }); + + this.markers.forEach((entry, id) => { + if (!seen.has(id)) { + entry.marker.remove(); + this.markers.delete(id); + } + }); + } +}; + +export default IconMarkers; diff --git a/assets/ts/phoenix-hooks/index.ts b/assets/ts/phoenix-hooks/index.ts index df01aeec84..f905f41488 100644 --- a/assets/ts/phoenix-hooks/index.ts +++ b/assets/ts/phoenix-hooks/index.ts @@ -1,4 +1,7 @@ import AlgoliaAutocomplete from "./algolia-autocomplete"; +import FitBounds from "./fit-bounds"; +import IconMarkers from "./icon-markers"; +import MapHook from "./map"; import MBTAGoCTABanner from "./mbta-go-cta-banner"; import MenuButton from "./menu-button"; import PageVisibility from "./page-visibility"; @@ -19,6 +22,9 @@ import TripPlannerDatePicker from "../../js/trip-planner/datepicker"; */ const Hooks = { AlgoliaAutocomplete, + FitBounds, + IconMarkers, + MapHook, MBTAGoCTABanner, MenuButton, PageVisibility, diff --git a/assets/ts/phoenix-hooks/map.ts b/assets/ts/phoenix-hooks/map.ts new file mode 100644 index 0000000000..b9954133b0 --- /dev/null +++ b/assets/ts/phoenix-hooks/map.ts @@ -0,0 +1,53 @@ +import { MapHooks } from "maplibrex"; +import { ViewHook } from "phoenix_live_view"; + +const MAPLIBREX_STATE = "_maplibrex"; + +interface MapHookState { + map: { setStyle: (style: unknown) => unknown }; + config: { style?: unknown }; +} + +type MapHookInstance = ViewHook & { [MAPLIBREX_STATE]?: MapHookState }; + +/** + * Wraps MaplibreX's map hook so that a patch of the map element doesn't reset + * the style when the style hasn't changed. + * + * MaplibreX compares the previous and new style with `!==`. For a style object + * (as opposed to a URL) the freshly parsed config never equals the old one, so + * every patch, including the one that follows a LiveView reconnect, calls + * `map.setStyle` and wipes out all of the sources and layers added since. + */ +const callOriginalUpdated = (hook: MapHookInstance): void => + (MapHooks.MapHook.updated as (this: MapHookInstance) => void).call(hook); + +const MapHookWithStableStyle = { + ...MapHooks.MapHook, + + updated(this: MapHookInstance) { + const state = this[MAPLIBREX_STATE]; + const rawConfig = this.el.dataset.config; + const styleUnchanged = + !!state && + !!rawConfig && + JSON.stringify(JSON.parse(rawConfig).style) === + JSON.stringify(state.config.style); + + if (!state || !styleUnchanged) { + callOriginalUpdated(this); + return; + } + + const { map } = state; + // Shadow the prototype method for the duration of the call only + map.setStyle = () => map; + try { + callOriginalUpdated(this); + } finally { + delete (map as Partial).setStyle; + } + } +}; + +export default MapHookWithStableStyle; diff --git a/assets/ts/types/deckgl.d.ts b/assets/ts/types/deckgl.d.ts new file mode 100644 index 0000000000..9865220eb4 --- /dev/null +++ b/assets/ts/types/deckgl.d.ts @@ -0,0 +1,3 @@ +// Optional MaplibreX peer dependencies that are only used as values (we don't use <.deckgl_layer>) +declare module "@deck.gl/layers"; +declare module "@deck.gl/aggregation-layers"; diff --git a/assets/tsconfig.json b/assets/tsconfig.json index bba7dbb0dd..b8bf120d85 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -39,7 +39,8 @@ /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // MaplibreX ships TypeScript sources under deps/, so its imports must resolve against our node_modules + "paths": { "*": ["./node_modules/*"] }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ diff --git a/assets/webpack.config.base.js b/assets/webpack.config.base.js index 7b5673ab3a..aa678e4769 100644 --- a/assets/webpack.config.base.js +++ b/assets/webpack.config.base.js @@ -189,7 +189,12 @@ module.exports = { resolve: { alias: { // Metro's Flatpickr import briefly, optionally references jQuery without importing it, which breaks Webpack unless we add this line - jquery: path.resolve(__dirname, "./node_modules/jquery") + jquery: path.resolve(__dirname, "./node_modules/jquery"), + // Optional MaplibreX deck.gl deps; we don't use <.deckgl_layer> + "@deck.gl/core": false, + "@deck.gl/layers": false, + "@deck.gl/aggregation-layers": false, + "@deck.gl/mapbox": false }, symlinks: false, extensions: [".tsx", ".ts", ".jsx", ".js"] diff --git a/lib/dotcom_web/live/line_diagram_live.ex b/lib/dotcom_web/live/line_diagram_live.ex index c4347f871a..c588794213 100644 --- a/lib/dotcom_web/live/line_diagram_live.ex +++ b/lib/dotcom_web/live/line_diagram_live.ex @@ -177,6 +177,7 @@ defmodule DotcomWeb.LineDiagramLive do |> apply_if_param_changed(:direction_id, &apply_direction_change/1) |> apply_if_param_changed(:selected_variant, &apply_variant_change/1) |> assign_route_data() + |> push_fit_bounds() |> assign_other_stops()} end @@ -191,7 +192,7 @@ defmodule DotcomWeb.LineDiagramLive do %Phoenix.Socket.Broadcast{topic: "vehicles-v2:" <> _, event: event, payload: payload}, socket ) do - {:noreply, socket |> assign_vehicle_icons(event, payload.data) |> assign(:move_map, false)} + {:noreply, assign_vehicle_icons(socket, event, payload.data)} end @impl true @@ -294,6 +295,7 @@ defmodule DotcomWeb.LineDiagramLive do popup = vehicle_popup(%{route: route, vehicle_info: vehicle_info, vehicle: vehicle}) %{ + id: vehicle.id, coordinates: [vehicle.longitude, vehicle.latitude], type: "icon-svg", name: "icon-vehicle-bordered-expanded", @@ -441,14 +443,16 @@ defmodule DotcomWeb.LineDiagramLive do {~t(Change Direction)} - <.map - map_config={@map_config} - route_patterns={@route_patterns} - map_lines={@map_lines} - map_icons={@map_icons} - vehicle_icons={Map.values(@vehicle_icons)} - move_map={@move_map} - /> + +
+ <.map + map_style={@map_config.style} + map_route_shapes={@map_route_shapes} + route_color={"##{@route.color}"} + map_icons={@map_icons} + vehicle_icons={Map.values(@vehicle_icons)} + /> +
assign(search_phrase: search |> String.trim()) |> assign_other_stops()} end - # re-fit the map to the newly selected variant or direction + def handle_event(_event, _params, socket) do + {:noreply, socket} + end + defp assign_route_data(socket) do socket |> assign_stops() |> assign_map_attributes() - |> assign(:move_map, true) end + # re-fit the map to the newly selected variant or direction + defp push_fit_bounds(%{assigns: %{map_icons: [_ | _] = map_icons}} = socket) do + push_event(socket, "fit-bounds", %{ + map_id: "new-map", + bounds: icons_to_bounds(map_icons), + padding: 50, + max_zoom: 16 + }) + end + + defp push_fit_bounds(socket), do: socket + defp assign_route_patterns(%{assigns: %{route: route, direction_id: direction_id}} = socket) do route_patterns = route.id @@ -687,10 +705,8 @@ defmodule DotcomWeb.LineDiagramLive do |> assign_map_icons() end - defp assign_map_lines( - %{assigns: %{route: route, selected_route_patterns: selected_route_patterns}} = socket - ) do - map_line_shapes = + defp assign_map_lines(%{assigns: %{selected_route_patterns: selected_route_patterns}} = socket) do + map_route_shapes = selected_route_patterns |> Enum.map(fn route_pattern -> %{ @@ -701,18 +717,7 @@ defmodule DotcomWeb.LineDiagramLive do } end) - map_lines_outlines = - map_line_shapes - |> Enum.map(&(&1 |> Map.merge(%{width: 6, color: "#000000"}))) - - map_lines_with_route_colors = - map_line_shapes - |> Enum.map(&(&1 |> Map.merge(%{width: 4, color: "##{route.color}"}))) - - map_lines = map_lines_outlines ++ map_lines_with_route_colors - - socket - |> assign(:map_lines, map_lines) + assign(socket, :map_route_shapes, map_route_shapes) end defp assign_map_icons(%{assigns: %{stops: stops}} = socket) do @@ -720,6 +725,7 @@ defmodule DotcomWeb.LineDiagramLive do stops |> Enum.map( &%{ + id: &1.id, coordinates: [&1.longitude, &1.latitude], type: "icon-svg", name: "icon-stop-circle-bordered-expanded", @@ -902,18 +908,112 @@ defmodule DotcomWeb.LineDiagramLive do defp map(assigns) do ~H""" - <.live_component - module={DotcomWeb.Components.Map} - id="line-diagram-map" + + + + + <%!-- Receives the "fit-bounds" event pushed when the direction or variant changes --%> +
+ + "#000000", "line-width" => 6}} + layout={%{"line-cap" => "round", "line-join" => "round"}} + /> + + <%!-- Shares the source created by the outline layer above; added after it so it draws on top --%> + @route_color, "line-width" => 4}} + layout={%{"line-cap" => "round", "line-join" => "round"}} + /> + + <.icon_markers id="new-map-stops" map_id="new-map" icons={@map_icons} z_index={1} /> + <.icon_markers id="new-map-vehicles" map_id="new-map" icons={@vehicle_icons} z_index={2} /> """ end + defp icons_to_bounds(icons) do + {lngs, lats} = icons |> Enum.map(&List.to_tuple(&1.coordinates)) |> Enum.unzip() + + [[Enum.min(lngs), Enum.min(lats)], [Enum.max(lngs), Enum.max(lats)]] + end + + attr :id, :string, required: true + attr :map_id, :string, required: true, doc: "The id of the MaplibreX map to add the icons to" + + attr :icons, :list, + required: true, + doc: + "Maps with `:coordinates`, `:type`, `:name`, and optionally `:id`, `:class`, `:anchor`, `:rotation`, `:popup`" + + attr :z_index, :integer, + default: nil, + doc: "Stacking order against other icon groups on the map; higher draws on top" + + defp icon_markers(assigns) do + ~H""" + + """ + end + + defp render_popup(nil), do: nil + + defp render_popup(%Phoenix.LiveView.Rendered{} = heex) do + heex + |> Phoenix.HTML.Safe.to_iodata() + |> IO.iodata_to_binary() + end + + defp render_popup(html), do: html + + defp map_shapes_to_feature_collection(shapes) do + %{ + "type" => "FeatureCollection", + "features" => + Enum.map(shapes, fn %{coordinates: coordinates} -> + %{ + "type" => "Feature", + "properties" => %{}, + "geometry" => %{"type" => "LineString", "coordinates" => coordinates} + } + end) + } + end + defp route_pdf_sidebar_content(assigns) do ~H"""
diff --git a/mix.exs b/mix.exs index efcb5f6aad..58f247f4df 100644 --- a/mix.exs +++ b/mix.exs @@ -145,6 +145,7 @@ defmodule Dotcom.Mixfile do {:logster, "~> 2.0.0-rc.5"}, # reverted from 0.4 {:mail, "0.3.1"}, + {:maplibrex, "0.1.0"}, {:mbta_metro, "1.4.0", runtime: false}, {:memoize, "1.4.5"}, {:mox, "1.3.2", [only: [:dev, :test]]}, diff --git a/mix.lock b/mix.lock index bafe5e9e63..82dd30221f 100644 --- a/mix.lock +++ b/mix.lock @@ -77,6 +77,7 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "makeup_html": {:hex, :makeup_html, "0.2.0", "9f810da8d43d625ccd3f7ea25997e588fa541d80e0a8c6b895157ad5c7e9ca13", [:mix], [{:makeup, "~> 1.2", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "0856f7beb9a6a642ab1307e06d990fe39f0ba58690d0b8e662aa2e027ba331b2"}, + "maplibrex": {:hex, :maplibrex, "0.1.0", "057926a1861accbc51fff540d401a0c273fcd8990ee6331dbc39ae6c6bcc2c46", [:mix], [{:esbuild, "~> 0.8", [hex: :esbuild, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "3929446619d3a7fece469925e9ff63657a0cb6adf2612f71ff23015e27615a22"}, "mbta_metro": {:hex, :mbta_metro, "1.4.0", "ef4e55419c1c077c9dd858af08aa2f492b05c714c306af236ffd978496780759", [:mix], [{:bandit, "~> 1.12", [hex: :bandit, repo: "hexpm", optional: false]}, {:cva, "~> 0.2", [hex: :cva, repo: "hexpm", optional: false]}, {:esbuild, "~> 0.10", [hex: :esbuild, repo: "hexpm", optional: false]}, {:faker, "~> 0.19", [hex: :faker, repo: "hexpm", optional: false]}, {:floki, "~> 0.38", [hex: :floki, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:heroicons, "~> 0.5", [hex: :heroicons, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_storybook, "~> 1.5", [hex: :phoenix_storybook, repo: "hexpm", optional: false]}, {:tailwind, "~> 0.3", [hex: :tailwind, repo: "hexpm", optional: false]}], "hexpm", "213390b1d002de757dc907413f7370155b86c4e33681f2ed0754b51f96914031"}, "mdex": {:hex, :mdex, "0.14.2", "8d2b376f3de8f9f03a2ba0992eeb4f76d58fa790b0ed78dc90bcb5422ce7ff5d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.10", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.11", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "7003e60b8621ab1c9b3671221a8dcc27dee9f9086026c25ea7aed4057d07978f"}, "mdex_native": {:hex, :mdex_native, "0.2.11", "439f8448bd349611dccce197c8822fc1e2d63907e346f3a68527905bae91c6d7", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "97e5c201fa016ad4dbd948a8014e314fc2d2e15a7fe88e73994ecfdd51a5dd08"}, diff --git a/test/dotcom_web/live/line_diagram_live_test.exs b/test/dotcom_web/live/line_diagram_live_test.exs index ec5fb1c2ab..7e79c1abd1 100644 --- a/test/dotcom_web/live/line_diagram_live_test.exs +++ b/test/dotcom_web/live/line_diagram_live_test.exs @@ -227,7 +227,174 @@ defmodule DotcomWeb.LineDiagramLiveTest do assert label(view) =~ "All variants" end + describe "new map" do + defp new_map_html(view) do + view |> render() |> Floki.parse_document!() + end + + test "renders a marker for each stop with a popup, and fits to them", %{ + conn: conn, + route: route, + pattern_a: a + } do + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new?variant=#{a.id}") + + stops = view |> new_map_html() |> Floki.find("#new-map-stops [data-coordinates]") + + assert length(stops) == length(Enum.uniq(a.stop_ids)) + assert has_element?(view, ~s|#new-map-stops[phx-hook="IconMarkers"][data-map-id="new-map"]|) + + for stop <- stops do + assert [_] = Floki.attribute(stop, "data-marker-id") + assert [popup] = Floki.attribute(stop, "data-popup") + assert popup =~ "popup-title" + end + + assert has_element?(view, ~s|#new-map-fit[phx-hook="FitBounds"]|) + + assert_push_event(view, "fit-bounds", %{ + map_id: "new-map", + bounds: [[_, _], [_, _]], + padding: 50 + }) + end + + test "re-fits on variant change but not on vehicle updates", %{ + conn: conn, + route: route, + pattern_a: a, + pattern_b: b + } do + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new?variant=#{a.id}") + assert_push_event(view, "fit-bounds", %{}) + + send(view.pid, %Phoenix.Socket.Broadcast{ + topic: "vehicles-v2:#{route.id}:1", + event: "reset", + payload: %{data: []} + }) + + render(view) + refute_push_event(view, "fit-bounds", %{}, 50) + + view + |> element(~s|#variant-menu-menu li[phx-value-option="#{b.id}"]|) + |> render_click() + + assert_push_event(view, "fit-bounds", %{map_id: "new-map"}) + end + + test "stacks vehicle markers above stop markers", %{conn: conn, route: route} do + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new") + + z_index = fn id -> + [value] = view |> new_map_html() |> Floki.attribute("##{id}", "data-z-index") + String.to_integer(value) + end + + assert z_index.("new-map-vehicles") > z_index.("new-map-stops") + end + + test "renders an outline layer and a route layer sharing one source", %{ + conn: conn, + route: route + } do + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new") + html = new_map_html(view) + + assert [outline] = Floki.attribute(html, "#new-map-lines-outline", "data-config") + + assert %{"source" => %{"data" => %{"type" => "FeatureCollection", "features" => features}}} = + Jason.decode!(outline) + + assert Enum.all?(features, &(&1["geometry"]["type"] == "LineString")) + + assert [line] = Floki.attribute(html, "#new-map-lines", "data-config") + + assert %{ + "sourceId" => "new-map-lines-outline-source", + "paint" => %{"line-color" => line_color} + } = Jason.decode!(line) + + assert line_color == "##{route.color}" + end + + test "renders vehicle markers with their bearing", %{conn: conn, route: route} do + vehicle = + Factories.Vehicles.Vehicle.build(:vehicle, %{ + route_id: route.id, + latitude: 42.35, + longitude: -71.06, + bearing: 90 + }) + + stub(Vehicles.Repo.Mock, :route, fn _, _ -> [vehicle] end) + + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new") + + assert [marker] = + view |> new_map_html() |> Floki.find("#new-map-vehicles [data-coordinates]") + + assert Floki.attribute(marker, "data-marker-id") == [vehicle.id] + assert Floki.attribute(marker, "data-rotation") == ["90"] + assert Floki.attribute(marker, "data-coordinates") == ["[-71.06,42.35]"] + end + + test "updates vehicle markers when a vehicle moves", %{conn: conn, route: route} do + vehicle = + Factories.Vehicles.Vehicle.build(:vehicle, %{ + route_id: route.id, + latitude: 42.35, + longitude: -71.06, + bearing: 90 + }) + + stub(Vehicles.Repo.Mock, :route, fn _, _ -> [vehicle] end) + + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new") + + send(view.pid, %Phoenix.Socket.Broadcast{ + topic: "vehicles-v2:#{route.id}:1", + event: "update", + payload: %{data: [%{vehicle | latitude: 42.4, longitude: -71.1, bearing: 180}]} + }) + + assert [marker] = + view |> new_map_html() |> Floki.find("#new-map-vehicles [data-coordinates]") + + assert Floki.attribute(marker, "data-marker-id") == [vehicle.id] + assert Floki.attribute(marker, "data-rotation") == ["180"] + assert Floki.attribute(marker, "data-coordinates") == ["[-71.1,42.4]"] + end + + test "defaults the rotation to 0 when a vehicle has no bearing", %{ + conn: conn, + route: route + } do + vehicle = + Factories.Vehicles.Vehicle.build(:vehicle, %{ + route_id: route.id, + latitude: 42.35, + longitude: -71.06, + bearing: nil + }) + + stub(Vehicles.Repo.Mock, :route, fn _, _ -> [vehicle] end) + + {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new") + + assert view + |> new_map_html() + |> Floki.attribute("#new-map-vehicles [data-coordinates]", "data-rotation") == ["0"] + end + end + describe "variant map updates" do + defp outline_features(html) do + [config] = Floki.attribute(html, "#new-map-lines-outline", "data-config") + Jason.decode!(config)["source"]["data"]["features"] + end + test "reuses loaded stops when switching variants", %{ conn: conn, route: route, @@ -246,16 +413,13 @@ defmodule DotcomWeb.LineDiagramLiveTest do assert has_element?(view, "#variant-menu-button", pattern.headsign) expected_stop_names = Enum.uniq(pattern.stop_ids) |> Enum.map(&"Stop #{&1}") assert stop_names(view) == expected_stop_names - assert has_element?(view, "#line-diagram-map [data-line]:nth-child(2)") - refute has_element?(view, "#line-diagram-map [data-line]:nth-child(3)") - icons = - view - |> render() - |> Floki.parse_document!() - |> Floki.find("#line-diagram-map .mbta-map-icon") + html = view |> render() |> Floki.parse_document!() - assert length(icons) == length(Enum.uniq(pattern.stop_ids)) + assert length(outline_features(html)) == 1 + + assert length(Floki.find(html, "#new-map-stops [data-coordinates]")) == + length(Enum.uniq(pattern.stop_ids)) end view @@ -263,9 +427,9 @@ defmodule DotcomWeb.LineDiagramLiveTest do |> render_click() assert has_element?(view, "#variant-menu-button", "All variants") + assert view |> render() |> Floki.parse_document!() |> outline_features() |> length() == 2 expected_stop_names = Enum.uniq(a.stop_ids ++ b.stop_ids) |> Enum.map(&"Stop #{&1}") assert stop_names(view) == expected_stop_names - assert has_element?(view, "#line-diagram-map [data-line]:nth-child(4)") end test "refits on selection immediately after a vehicle update", %{ @@ -275,9 +439,6 @@ defmodule DotcomWeb.LineDiagramLiveTest do } do {:ok, view, _html} = live(conn, "/schedules/#{route.id}/line_new") - view |> with_target("#line-diagram-map") |> render_hook("map-loaded", %{}) - assert_push_event(view, "update-markers", %{}) - send(view.pid, %Phoenix.Socket.Broadcast{ topic: "vehicles-v2:#{route.id}:1", event: "reset", @@ -285,13 +446,12 @@ defmodule DotcomWeb.LineDiagramLiveTest do }) render(view) - assert_push_event(view, "update-markers", %{move_map: false}) view |> element(~s|#variant-menu-menu li[phx-value-option="#{a.id}"]|) |> render_click() - assert_push_event(view, "update-markers", %{move_map: true}) + assert_push_event(view, "fit-bounds", %{map_id: "new-map"}) end end end