diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..c4016de --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,21 @@ +name: Create Release +on: + push: + tags: + - 'v*' + +jobs: + create: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ github.ref_name }} + generate_release_notes: true + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index 2f2c966..2a8a633 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -94,3 +94,67 @@ jobs: - name: Publish to GitHub registry run: npm publish + + fleetbase_publish: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - uses: actions/checkout@v2 + + - name: Setup Node.js 18.x + uses: actions/setup-node@v2 + with: + node-version: 18.x + + - name: Setup pnpm + uses: pnpm/action-setup@v2.0.1 + with: + version: latest + + - name: Install Dependencies + run: pnpm install + + - name: Build + run: pnpm run build + + - name: Install Fleetbase CLI + run: npm i -g @fleetbase/cli npm-cli-login + + - name: Login to Fleetbase Registry + run: npm-cli-login -u ${{SECRETS.FLEETBASE_REGISTRY_USERNAME}} -p ${{SECRETS.FLEETBASE_REGISTRY_PASSWORD}} -e ${{SECRETS.FLEETBASE_REGISTRY_EMAIL}} -r https://registry.fleetbase.io + + - name: Publish to Fleetbase registry + run: flb publish + + fleetbase_publish_qa: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v2 + + - name: Setup Node.js 18.x + uses: actions/setup-node@v2 + with: + node-version: 18.x + + - name: Setup pnpm + uses: pnpm/action-setup@v2.0.1 + with: + version: latest + + - name: Install Dependencies + run: pnpm install + + - name: Build + run: pnpm run build + + - name: Install Fleetbase CLI + run: npm i -g @fleetbase/cli npm-cli-login + + - name: Login to Fleetbase Registry + run: npm-cli-login -u ${{SECRETS.FLEETBASE_QA_REGISTRY_USERNAME}} -p ${{SECRETS.FLEETBASE_QA_REGISTRY_PASSWORD}} -e ${{SECRETS.FLEETBASE_QA_REGISTRY_EMAIL}} -r https://registry.qa.fleetbase.io + + - name: Publish to Fleetbase registry + run: flb publish --registry https://registry.qa.fleetbase.io diff --git a/addon/engine.js b/addon/engine.js index 4b2bbfe..221b336 100644 --- a/addon/engine.js +++ b/addon/engine.js @@ -3,9 +3,12 @@ import loadInitializers from 'ember-load-initializers'; import Resolver from 'ember-resolver'; import config from './config/environment'; import services from '@fleetbase/ember-core/exports/services'; +import { RoutingControl } from '@fleetbase/fleetops-engine/services/leaflet-routing-control'; const { modulePrefix } = config; const externalRoutes = ['console', 'extensions']; +const FLEETOPS_ENGINE_NAME = '@fleetbase/fleetops-engine'; +const L = window.L ?? {}; export default class ValhallaEngine extends Engine { modulePrefix = modulePrefix; @@ -14,9 +17,27 @@ export default class ValhallaEngine extends Engine { services, externalRoutes, }; + engineDependencies = [FLEETOPS_ENGINE_NAME]; + /* eslint no-unused-vars: "off" */ setupExtension = function (app, engine, universe) { - // register menu item in header - universe.registerHeaderMenuItem('valhalla', 'console.valhalla', { icon: 'layer-group', priority: 5 }); + const routeOptimization = app.lookup('service:route-optimization'); + const valhalla = app.lookup('service:valhalla'); + if (routeOptimization && valhalla) { + routeOptimization.register('valhalla', valhalla); + } + + // Register Valhalla Routing Control + const leafletRoutingControl = app.lookup('service:leaflet-routing-control'); + if (leafletRoutingControl) { + leafletRoutingControl.register( + 'valhalla', + new RoutingControl({ + name: 'Valhalla', + router: new L.Routing.Valhalla(), + formatter: new L.Routing.Valhalla.Formatter(), + }) + ); + } }; } diff --git a/addon/instance-initializers/load-valhalla-routing.js b/addon/instance-initializers/load-valhalla-routing.js new file mode 100644 index 0000000..a742fa3 --- /dev/null +++ b/addon/instance-initializers/load-valhalla-routing.js @@ -0,0 +1,11 @@ +export function initialize(owner) { + const valhallaRoutingService = owner.lookup('service:valhalla-routing'); + if (valhallaRoutingService) { + valhallaRoutingService.initialize(); + } +} + +export default { + name: 'load-valhalla-routing', + initialize, +}; diff --git a/addon/services/valhalla-routing.js b/addon/services/valhalla-routing.js new file mode 100644 index 0000000..9e63686 --- /dev/null +++ b/addon/services/valhalla-routing.js @@ -0,0 +1,427 @@ +import Service from '@ember/service'; +import Routing from '@fleetbase/leaflet-routing-machine'; +import corslite from '@fleetbase/ember-core/utils/corslite'; +import polyline from '@fleetbase/ember-core/utils/polyline'; +import { later } from '@ember/runloop'; + +const L = window.L ?? {}; +export default class ValhallaRoutingService extends Service { + initialize() { + this.#createRoutingControl(); + this.#createValhallaRoutingFormatter(); + } + + #createRoutingControl() { + L.Routing = Routing; + L.Routing.Valhalla = L.Class.extend({ + initialize: function (accessToken, transitmode = 'auto', costingOptions, options) { + L.Util.setOptions( + this, + options || { + serviceUrl: '//valhalla1.openstreetmap.de/', + timeout: 30 * 1000, + transitmode: 'auto', + } + ); + this._accessToken = accessToken; + this._transitmode = transitmode; + this._costingOptions = costingOptions; + this._hints = { + locations: {}, + }; + }, + + route: function (waypoints, callback, context, options) { + var timedOut = false, + wps = [], + url, + timer, + wp, + i; + + options = options || {}; + //waypoints = options.waypoints || waypoints; + url = this.buildRouteUrl(waypoints, options); + + timer = later( + this, + function () { + timedOut = true; + callback.call(context || callback, { + status: -1, + message: 'OSRM request timed out.', + }); + }, + this.options.timeout + ); + + // Create a copy of the waypoints, since they + // might otherwise be asynchronously modified while + // the request is being processed. + for (i = 0; i < waypoints.length; i++) { + wp = waypoints[i]; + wps.push({ + latLng: wp.latLng, + name: wp.name || '', + options: wp.options || {}, + }); + } + + corslite( + url, + L.bind(function (err, resp) { + var data; + + clearTimeout(timer); + if (!timedOut) { + if (!err) { + data = JSON.parse(resp.responseText); + this._routeDone(data, wps, callback, context); + } else { + console.log('Error : ' + err.response); + callback.call(context || callback, { + status: err.status, + message: err.response, + }); + } + } + }, this), + true + ); + + return this; + }, + + _routeDone: function (response, inputWaypoints, callback, context) { + var alts, actualWaypoints; + context = context || callback; + if (response.trip.status !== 0) { + callback.call(context, { + status: response.status, + message: response.status_message, + }); + return; + } + + var insts = []; + var coordinates = []; + var shapeIndex = 0; + + for (var i = 0; i < response.trip.legs.length; i++) { + var coord = polyline.decode(response.trip.legs[i].shape, 6); + + for (var k = 0; k < coord.length; k++) { + coordinates.push(coord[k]); + } + + for (var j = 0; j < response.trip.legs[i].maneuvers.length; j++) { + var res = response.trip.legs[i].maneuvers[j]; + res.distance = response.trip.legs[i].maneuvers[j]['length']; + res.index = shapeIndex + response.trip.legs[i].maneuvers[j]['begin_shape_index']; + insts.push(res); + } + + shapeIndex += response.trip.legs[i].maneuvers[response.trip.legs[i].maneuvers.length - 1]['begin_shape_index']; + } + + actualWaypoints = this._toWaypoints(inputWaypoints, response.trip.locations); + + alts = [ + { + name: this._trimLocationKey(inputWaypoints[0].latLng) + ' , ' + this._trimLocationKey(inputWaypoints[1].latLng), + unit: response.trip.units, + transitmode: this._transitmode, + coordinates: coordinates, + instructions: insts, //response.route_instructions ? this._convertInstructions(response.route_instructions) : [], + summary: response.trip.summary ? this._convertSummary(response.trip.summary) : [], + inputWaypoints: inputWaypoints, + waypoints: actualWaypoints, + waypointIndices: this._clampIndices([0, response.trip.legs[0].maneuvers.length], coordinates), + }, + ]; + + // only versions <4.5.0 will support this flag + if (response.hint_data) { + this._saveHintData(response.hint_data, inputWaypoints); + } + callback.call(context, null, alts); + }, + + _saveHintData: function (hintData, waypoints) { + var loc; + this._hints = { + checksum: hintData.checksum, + locations: {}, + }; + for (var i = hintData.locations.length - 1; i >= 0; i--) { + loc = waypoints[i].latLng; + this._hints.locations[this._locationKey(loc)] = hintData.locations[i]; + } + }, + + _toWaypoints: function (inputWaypoints, vias) { + var wps = [], + i; + for (i = 0; i < vias.length; i++) { + wps.push(L.Routing.waypoint(L.latLng([vias[i]['lat'], vias[i]['lon']]), 'name', {})); + } + + return wps; + }, + /* eslint-disable no-unused-vars */ + buildRouteUrl: function (waypoints, options) { + var locs = [], + locationKey, + hint; + var transitM = options.transitmode || this._transitmode; + var streetName = options.street; + var costingOptions = this._costingOptions; + this._transitmode = transitM; + + for (var i = 0; i < waypoints.length; i++) { + var loc; + locationKey = this._locationKey(waypoints[i].latLng).split(','); + if (i === 0 || i === waypoints.length - 1) { + loc = { + lat: parseFloat(locationKey[0]), + lon: parseFloat(locationKey[1]), + type: 'break', + }; + } else { + loc = { + lat: parseFloat(locationKey[0]), + lon: parseFloat(locationKey[1]), + type: 'through', + }; + } + locs.push(loc); + } + + var params = JSON.stringify({ + locations: locs, + costing: transitM, + street: streetName, + costing_options: costingOptions, + }); + + return this.options.serviceUrl + 'route?json=' + params + (this._accessToken ? '&api_key=' + this._accessToken : ''); + }, + + _locationKey: function (location) { + return location.lat + ',' + location.lng; + }, + + _trimLocationKey: function (location) { + var lat = location.lat; + var lng = location.lng; + + var nameLat = Math.floor(location.lat * 1000) / 1000; + var nameLng = Math.floor(location.lng * 1000) / 1000; + + return nameLat + ' , ' + nameLng; + }, + + _convertSummary: function (route) { + return { + totalDistance: route.length, + totalTime: route.time, + }; + }, + + _convertInstructions: function (osrmInstructions) { + var result = [], + i, + instr, + type, + driveDir; + + for (i = 0; i < osrmInstructions.length; i++) { + instr = osrmInstructions[i]; + type = this._drivingDirectionType(instr[0]); + driveDir = instr[0].split('-'); + if (type) { + result.push({ + type: type, + distance: instr[2], + time: instr[4], + road: instr[1], + direction: instr[6], + exit: driveDir.length > 1 ? driveDir[1] : undefined, + index: instr[3], + }); + } + } + return result; + }, + + _clampIndices: function (indices, coords) { + var maxCoordIndex = coords.length - 1, + i; + for (i = 0; i < indices.length; i++) { + indices[i] = Math.min(maxCoordIndex, Math.max(indices[i], 0)); + } + }, + }); + + L.Routing.valhalla = function (accessToken, transitmode, options) { + return new L.Routing.Valhalla(accessToken, transitmode, options); + }; + + return L.Routing.Valhalla; + } + + #createValhallaRoutingFormatter() { + L.Routing = Routing; + + //L.extend(L.Routing, require('./L.Routing.Localization')); + /* eslint-disable ember/avoid-leaking-state-in-ember-objects */ + L.Routing.Valhalla.Formatter = L.Class.extend({ + options: { + units: 'metric', + unitNames: { + meters: 'm', + kilometers: 'km', + yards: 'yd', + miles: 'mi', + hours: 'h', + minutes: 'mín', + seconds: 's', + }, + language: 'en', + roundingSensitivity: 1, + distanceTemplate: '{value} {unit}', + }, + + initialize: function (options) { + L.setOptions(this, options); + }, + + formatDistance: function (d /* Number (meters) */) { + var un = this.options.unitNames, + v, + data; + if (this.options.units === 'imperial') { + //valhalla returns distance in km + d = d * 1000; + d = d / 1.609344; + if (d >= 1000) { + data = { + value: this._round(d) / 1000, + unit: un.miles, + }; + } else { + data = { + value: this._round(d / 1.76), + unit: un.yards, + }; + } + } else { + v = d; + data = { + value: v >= 1 ? v : v * 1000, + unit: v >= 1 ? un.kilometers : un.meters, + }; + } + + return L.Util.template(this.options.distanceTemplate, data); + }, + + _round: function (d) { + var pow10 = Math.pow(10, (Math.floor(d / this.options.roundingSensitivity) + '').length - 1), + r = Math.floor(d / pow10), + p = r > 5 ? pow10 : pow10 / 2; + + return Math.round(d / p) * p; + }, + + formatTime: function (t /* Number (seconds) */) { + if (t > 86400) { + return Math.round(t / 3600) + ' h'; + } else if (t > 3600) { + return Math.floor(t / 3600) + ' h ' + Math.round((t % 3600) / 60) + ' min'; + } else if (t > 300) { + return Math.round(t / 60) + ' min'; + } else if (t > 60) { + return Math.floor(t / 60) + ' min' + (t % 60 !== 0 ? ' ' + (t % 60) + ' s' : ''); + } else { + return t + ' s'; + } + }, + + formatInstruction: function (instr, i) { + // Valhalla returns instructions itself. + return instr.instruction; + }, + + getIconName: function (instr, i) { + // you can find all Valhalla's direction types at https://github.com/valhalla/odin/blob/master/proto/tripdirections.proto + switch (instr.type) { + case 1: + return 'kStart'; + case 2: + return 'kStartRight'; + case 3: + return 'kStartLeft'; + case 4: + return 'kDestination'; + case 5: + return 'kDestinationRight'; + case 6: + return 'kDestinationLeft'; + case 7: + return 'kBecomes'; + case 8: + return 'kContinue'; + case 9: + return 'kSlightRight'; + case 10: + return 'kRight'; + case 11: + return 'kSharpRight'; + case 12: + return 'kUturnRight'; + case 13: + return 'kUturnLeft'; + case 14: + return 'kSharpLeft'; + case 15: + return 'kLeft'; + case 16: + return 'kSlightLeft'; + case 17: + return 'kRampStraight'; + case 18: + return 'kRampRight'; + case 19: + return 'kRampLeft'; + case 20: + return 'kExitRight'; + case 21: + return 'kExitLeft'; + case 22: + return 'kStayStraight'; + case 23: + return 'kStayRight'; + case 24: + return 'kStayLeft'; + case 25: + return 'kMerge'; + case 26: + return 'kRoundaboutEnter'; + case 27: + return 'kRoundaboutExit'; + case 28: + return 'kFerryEnter'; + case 29: + return 'kFerryExit'; + } + }, + + _getInstructionTemplate: function (instr, i) { + return instr.instruction + ' ' + instr.length; + }, + }); + + return L.Routing.Valhalla.Formatter; + } +} diff --git a/addon/services/valhalla.js b/addon/services/valhalla.js new file mode 100644 index 0000000..0aa161f --- /dev/null +++ b/addon/services/valhalla.js @@ -0,0 +1,42 @@ +import RouteOptimizationInterfaceService from '@fleetbase/fleetops-engine/services/route-optimization-interface'; +import { debug } from '@ember/debug'; + +export default class ValhallaService extends RouteOptimizationInterfaceService { + name = 'Valhalla'; + + async optimize({ order, waypoints, coordinates }, options = {}) { + const driverAssigned = order.driver_assigned; + const driverPosition = driverAssigned?.location?.coordinates; // [lon,lat] | undefined + const locations = (driverPosition ? [driverPosition, ...coordinates] : [...coordinates]).map(([lon, lat]) => { + return { lat, lon }; + }); + const hasDriverStart = Boolean(driverPosition); + + try { + const result = await this.#request('optimized-route', { locations, costing: 'auto' }, options); + + // Pair each Valhalla waypoint with its Waypoint model + // Valhalla returns locations in the array sorted by optimization and a property `original_index` + const modelsByInputIndex = hasDriverStart ? [null, ...waypoints] : waypoints; + const pairs = result.trip.locations.map((wp) => ({ + model: modelsByInputIndex[wp.original_index], + wp, + })); + + // Drop the driver start if present + const payloadPairs = hasDriverStart ? pairs.slice(1) : pairs; + + // Extract the Ember models (null-safe) + const sortedWaypoints = payloadPairs.map((p) => p.model).filter(Boolean); + + return { sortedWaypoints, result, engine: 'valhalla' }; + } catch (err) { + debug(`[Valhalla] Error optimizing route : ${err.message}`); + throw err; + } + } + + #request(path, data = {}, options = {}) { + return this.fetch.post(path, data, { namespace: 'valhalla/int/v1', ...options }); + } +} diff --git a/app/routes/home.js b/app/routes/home.js index fc3c427..01d9318 100644 --- a/app/routes/home.js +++ b/app/routes/home.js @@ -1 +1 @@ -export { default } from '@fleetbase/starter-engine/routes/home'; +export { default } from '@fleetbase/valhalla-engine/routes/home'; diff --git a/app/services/valhalla-routing.js b/app/services/valhalla-routing.js new file mode 100644 index 0000000..a601969 --- /dev/null +++ b/app/services/valhalla-routing.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/valhalla-engine/services/valhalla-routing'; diff --git a/app/services/valhalla.js b/app/services/valhalla.js new file mode 100644 index 0000000..0d07c52 --- /dev/null +++ b/app/services/valhalla.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/valhalla-engine/services/valhalla'; diff --git a/app/templates/virtual.js b/app/templates/virtual.js deleted file mode 100644 index fb9602a..0000000 --- a/app/templates/virtual.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/starter-engine/templates/virtual'; diff --git a/package.json b/package.json index 5646cad..3464547 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,14 @@ "publish:github": "npm config set '@fleetbase:registry' https://npm.pkg.github.com/ && npm publish" }, "dependencies": { + "@babel/core": "^7.23.2", "@fleetbase/ember-core": "latest", "@fleetbase/ember-ui": "latest", + "@fleetbase/leaflet-routing-machine": "^3.2.17", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", - "@fortawesome/free-solid-svg-icons": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", - "@babel/core": "^7.23.2", + "@fortawesome/free-solid-svg-icons": "6.4.0", "broccoli-funnel": "^3.0.8", "ember-auto-import": "^2.7.4", "ember-cli-babel": "^8.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b75361b..79d4ba2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@fleetbase/ember-ui': specifier: latest version: 0.3.2(@ember/test-helpers@3.3.1(@babel/core@7.27.1)(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.9))(webpack@5.99.9))(@glimmer/component@1.1.2(@babel/core@7.27.1))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.9)))(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.9))(postcss@8.5.3)(rollup@2.79.2)(tracked-built-ins@3.4.0(@babel/core@7.27.1))(webpack@5.99.9) + '@fleetbase/leaflet-routing-machine': + specifier: ^3.2.17 + version: 3.2.17 '@fortawesome/ember-fontawesome': specifier: ^2.0.0 version: 2.0.0(ember-source@5.4.1(@babel/core@7.27.1)(@glimmer/component@1.1.2(@babel/core@7.27.1))(rsvp@4.8.5)(webpack@5.99.9))(rollup@2.79.2)(webpack@5.99.9) @@ -1240,6 +1243,9 @@ packages: resolution: {integrity: sha512-/efmMSA5k2dLI1Iq0xmjo1dMlAnuGEQVJAaaCCMhhhc4T7e5r2HlWXoHgZopTOM3y2R48O7cUxmkMQcxtaAvnA==} engines: {node: '>= 18'} + '@fleetbase/leaflet-routing-machine@3.2.17': + resolution: {integrity: sha512-2S/XLPzf25ZKV7cFJwfeu4voYQboF9JiDfpRUTrif4XCfgdrQ2Zim7O5iTpoNv2l8Ne8D+Ed7BGJsKWjJFLcsw==} + '@floating-ui/core@1.7.0': resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==} @@ -1472,6 +1478,13 @@ packages: resolution: {integrity: sha512-CaNTtpaypA69fCqFlz69SMSuuLY1TLcDxjXaPmBNj+SsZpqQXcFgkPVRQEna7IICdgDbJIoLQnB/AB7/J6AD9g==} engines: {node: '>= 10.*'} + '@mapbox/corslite@0.0.7': + resolution: {integrity: sha512-w/uS474VFjmqQ7fFWIMZINQM1BAQxDLuoJaZZIPES1BmeYpCtlh9MtbFxKGGDAsfvut8/HircIsVvEYRjQ+iMg==} + + '@mapbox/polyline@0.2.0': + resolution: {integrity: sha512-GCddO0iw6AzOQqZgBmjEQI9Pgo40/yRgkTkikGctE01kNBN0ThWYuAnTD+hRWrAWMV6QJ0rNm4m8DAsaAXE7Pg==} + hasBin: true + '@mrmlnc/readdir-enhanced@2.2.1': resolution: {integrity: sha512-bPHp6Ji8b41szTOcaP63VlnbbO5Ny6dwAATtY6JTjh5N2OLrb5Qk/Th5cRkRQhkWCt+EJsYrNB0MiL+Gpn6e3g==} engines: {node: '>=4'} @@ -6129,6 +6142,9 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} + osrm-text-instructions@0.13.4: + resolution: {integrity: sha512-ge4ZTIetMQKAHKq2MwWf83ntzdJN20ndRKRaVNoZ3SkDkBNO99Qddz7r6+hrVx38I+ih6Rk5T1yslczAB6Q9Pg==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -9756,6 +9772,12 @@ snapshots: - webpack-cli - webpack-command + '@fleetbase/leaflet-routing-machine@3.2.17': + dependencies: + '@mapbox/corslite': 0.0.7 + '@mapbox/polyline': 0.2.0 + osrm-text-instructions: 0.13.4 + '@floating-ui/core@1.7.0': dependencies: '@floating-ui/utils': 0.2.9 @@ -10147,6 +10169,10 @@ snapshots: - webpack-cli - webpack-command + '@mapbox/corslite@0.0.7': {} + + '@mapbox/polyline@0.2.0': {} + '@mrmlnc/readdir-enhanced@2.2.1': dependencies: call-me-maybe: 1.0.2 @@ -16483,6 +16509,8 @@ snapshots: os-tmpdir@1.0.2: {} + osrm-text-instructions@0.13.4: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 diff --git a/server/src/Exceptions/ValhallaException.php b/server/src/Exceptions/ValhallaException.php new file mode 100644 index 0000000..5ac5a4d --- /dev/null +++ b/server/src/Exceptions/ValhallaException.php @@ -0,0 +1,45 @@ +statusCode = $response->status(); + + // Attempt to parse JSON error data + try { + $this->errorData = $response->json(); + } catch (\Throwable $e) { + $this->errorData = null; + } + + $message = sprintf( + 'Valhalla API [%s] request failed [%d]: %s', + $endpoint, + $this->statusCode, + $response->body() + ); + + parent::__construct($message, $this->statusCode); + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getErrorData(): ?array + { + return $this->errorData; + } +} diff --git a/server/src/Http/Controllers/ValhallaController.php b/server/src/Http/Controllers/ValhallaController.php new file mode 100644 index 0000000..1e48be0 --- /dev/null +++ b/server/src/Http/Controllers/ValhallaController.php @@ -0,0 +1,140 @@ +valhalla = $valhalla; + } + + /** + * Compute a turn-by-turn route. + * + * POST /valhalla/route + * + * @return \Illuminate\Http\JsonResponse + */ + public function route(Request $request) + { + $payload = $request->all(); + + try { + $data = $this->valhalla->route($payload); + + return response()->json($data); + } catch (ValhallaException $e) { + $error = $e->getErrorData(); + + return response()->error( + $error['error'] ?? $e->getMessage(), + $e->getStatusCode() ?? 400 + ); + } catch (\Exception $e) { + return response()->error( + config('app.debug') ? $e->getMessage() : 'Valhalla API request failed.' + ); + } + } + + /** + * Solve an optimized route (CVRP). + * + * POST /valhalla/optimized_route + * + * @return \Illuminate\Http\JsonResponse + */ + public function optimizedRoute(Request $request) + { + $payload = $request->all(); + + try { + $data = $this->valhalla->optimizedRoute($payload); + + return response()->json($data); + } catch (ValhallaException $e) { + $error = $e->getErrorData(); + + return response()->error( + $error['error'] ?? $e->getMessage(), + $e->getStatusCode() ?? 400 + ); + } catch (\Exception $e) { + return response()->error( + config('app.debug') ? $e->getMessage() : 'Valhalla API request failed.' + ); + } + } + + /** + * Compute an isochrone (reachable area). + * + * POST /valhalla/isochrone + * + * @return \Illuminate\Http\JsonResponse + */ + public function isochrone(Request $request) + { + $payload = $request->all(); + + try { + $data = $this->valhalla->isochrone($payload); + + return response()->json($data); + } catch (ValhallaException $e) { + $error = $e->getErrorData(); + + return response()->error( + $error['error'] ?? $e->getMessage(), + $e->getStatusCode() ?? 400 + ); + } catch (\Exception $e) { + return response()->error( + config('app.debug') ? $e->getMessage() : 'Valhalla API request failed.' + ); + } + } + + /** + * Compute travel matrix from sources to targets. + * + * POST /valhalla/sources_to_targets + * + * @return \Illuminate\Http\JsonResponse + */ + public function matrix(Request $request) + { + $payload = $request->all(); + + try { + $data = $this->valhalla->matrix($payload); + + return response()->json($data); + } catch (ValhallaException $e) { + $error = $e->getErrorData(); + + return response()->error( + $error['error'] ?? $e->getMessage(), + $e->getStatusCode() ?? 400 + ); + } catch (\Exception $e) { + return response()->error( + config('app.debug') ? $e->getMessage() : 'Valhalla API request failed.' + ); + } + } +} diff --git a/server/src/Providers/ValhallaServiceProvider.php b/server/src/Providers/ValhallaServiceProvider.php index edc637e..6575a13 100644 --- a/server/src/Providers/ValhallaServiceProvider.php +++ b/server/src/Providers/ValhallaServiceProvider.php @@ -51,5 +51,6 @@ public function boot() $this->registerExpansionsFrom(__DIR__ . '/../Expansions'); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); $this->loadMigrationsFrom(__DIR__ . '/../../migrations'); + $this->mergeConfigFrom(__DIR__ . '/../../config/valhalla.php', 'valhalla'); } } diff --git a/server/src/Support/Valhalla.php b/server/src/Support/Valhalla.php new file mode 100644 index 0000000..60fbcf1 --- /dev/null +++ b/server/src/Support/Valhalla.php @@ -0,0 +1,84 @@ +baseUri = config('valhalla.base_uri', 'https://valhalla1.openstreetmap.de'); + $this->apiKey = config('valhalla.api_key'); + } + + /** + * Computes a route with directions between points. + * + * @param array $payload JSON body with `locations`, `costing`, etc + * + * @throws ValhallaException + */ + public function route(array $payload): array + { + return $this->post('route', $payload); + } + + /** + * Solves a simple routing optimization (CVRP) in one call. + * + * @param array $payload JSON body with `locations`, `costing`, etc + * + * @throws ValhallaException + */ + public function optimizedRoute(array $payload): array + { + return $this->post('optimized_route', $payload); + } + + /** + * Isochrone endpoint: computes reachable area. + * + * @param array $payload JSON body as per Valhalla docs + * + * @throws ValhallaException + */ + public function isochrone(array $payload): array + { + return $this->post('isochrone', $payload); + } + + /** + * Matrix endpoint: computes source-to-target travel times/distances. + * + * @param array $payload JSON body as per Valhalla docs + * + * @throws ValhallaException + */ + public function matrix(array $payload): array + { + return $this->post('sources_to_targets', $payload); + } + + protected function post(string $endpoint, array $payload): array + { + $url = rtrim($this->baseUri, '/') . '/' . $endpoint; + + $response = Http::timeout(30) + ->withHeaders(['Content-Type' => 'application/json']) + ->post($url, $payload); + + if (!$response->successful()) { + throw new ValhallaException($endpoint, $response); + } + + return $response->json(); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 042b22b..31bcde3 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -13,7 +13,7 @@ | */ -Route::prefix(config('valhalla.api.routing.prefix', 'starter'))->namespace('Fleetbase\Valhalla\Http\Controllers')->group( +Route::prefix(config('valhalla.api.routing.prefix', 'valhalla'))->namespace('Fleetbase\Valhalla\Http\Controllers')->group( function ($router) { /* |-------------------------------------------------------------------------- @@ -27,7 +27,9 @@ function ($router) { $router->group( ['prefix' => 'v1', 'middleware' => ['fleetbase.protected']], function ($router) { - // $router->fleetbaseRoutes('resource'); + $router->post('route', 'ValhallaController@route'); + $router->post('optimized-route', 'ValhallaController@optimizedRoute'); + $router->post('matrix', 'ValhallaController@matrix'); } ); } diff --git a/tests/unit/instance-initializers/load-valhalla-routing-test.js b/tests/unit/instance-initializers/load-valhalla-routing-test.js new file mode 100644 index 0000000..601fab1 --- /dev/null +++ b/tests/unit/instance-initializers/load-valhalla-routing-test.js @@ -0,0 +1,39 @@ +import Application from '@ember/application'; + +import config from 'dummy/config/environment'; +import { initialize } from 'dummy/instance-initializers/load-valhalla-routing'; +import { module, test } from 'qunit'; +import Resolver from 'ember-resolver'; +import { run } from '@ember/runloop'; + +module('Unit | Instance Initializer | load-valhalla-routing', function (hooks) { + hooks.beforeEach(function () { + this.TestApplication = class TestApplication extends Application { + modulePrefix = config.modulePrefix; + podModulePrefix = config.podModulePrefix; + Resolver = Resolver; + }; + + this.TestApplication.instanceInitializer({ + name: 'initializer under test', + initialize, + }); + + this.application = this.TestApplication.create({ + autoboot: false, + }); + + this.instance = this.application.buildInstance(); + }); + hooks.afterEach(function () { + run(this.instance, 'destroy'); + run(this.application, 'destroy'); + }); + + // TODO: Replace this with your real tests. + test('it works', async function (assert) { + await this.instance.boot(); + + assert.ok(true); + }); +}); diff --git a/tests/unit/services/valhalla-routing-test.js b/tests/unit/services/valhalla-routing-test.js new file mode 100644 index 0000000..1af118a --- /dev/null +++ b/tests/unit/services/valhalla-routing-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | valhalla-routing', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:valhalla-routing'); + assert.ok(service); + }); +}); diff --git a/tests/unit/services/valhalla-test.js b/tests/unit/services/valhalla-test.js new file mode 100644 index 0000000..c1f6565 --- /dev/null +++ b/tests/unit/services/valhalla-test.js @@ -0,0 +1,12 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +module('Unit | Service | valhalla', function (hooks) { + setupTest(hooks); + + // TODO: Replace this with your real tests. + test('it exists', function (assert) { + let service = this.owner.lookup('service:valhalla'); + assert.ok(service); + }); +});