diff --git a/.github/workflows/CustomerWebpage-Deploy-WF.yml b/.github/workflows/CustomerWebpage-Deploy-WF.yml index 231850a..ee1081e 100644 --- a/.github/workflows/CustomerWebpage-Deploy-WF.yml +++ b/.github/workflows/CustomerWebpage-Deploy-WF.yml @@ -1,12 +1,27 @@ name: CustomerWebpage-Deploy-WF on: + push: + branches: + - main + - customer-map + paths: + - "frontend/customer-webapp/**" + - ".github/workflows/CustomerWebpage-Deploy-WF.yml" workflow_dispatch: +permissions: + contents: read + id-token: write + jobs: build-and-deploy: runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend/customer-webapp + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -17,16 +32,28 @@ jobs: node-version: '22.x' - name: Install dependencies - working-directory: ./frontend - run: npm install + run: npm ci + + - name: Lint application + run: npm run lint - - name: Build React app - working-directory: ./frontend + - name: Build application run: npm run build + env: + VITE_MAP_TILE_URL: ${{ vars.VITE_MAP_TILE_URL }} + VITE_SIMULATOR_API_BASE: ${{ vars.VITE_SIMULATOR_API_BASE }} + + - name: Azure Login + if: github.ref == 'refs/heads/main' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: Deploy to Azure Web App + if: github.ref == 'refs/heads/main' uses: azure/webapps-deploy@v3 with: - app-name: 'WA-DeliveryBot-dev' - publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_1BA6A9C76C684517B5106BD8ACF4CE5B }} - package: ./frontend/dist + app-name: WA-DeliveryBot-dev + package: frontend/customer-webapp/dist diff --git a/frontend/customer-webapp/package-lock.json b/frontend/customer-webapp/package-lock.json index 5e8c6f4..91ba7f2 100644 --- a/frontend/customer-webapp/package-lock.json +++ b/frontend/customer-webapp/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "leaflet": "^1.9.4", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" @@ -1605,6 +1606,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/frontend/customer-webapp/package.json b/frontend/customer-webapp/package.json index b4ec813..940e9be 100644 --- a/frontend/customer-webapp/package.json +++ b/frontend/customer-webapp/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "leaflet": "^1.9.4", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.15.1" diff --git a/frontend/customer-webapp/src/pages/Home.jsx b/frontend/customer-webapp/src/pages/Home.jsx index 0b2437b..62852a0 100644 --- a/frontend/customer-webapp/src/pages/Home.jsx +++ b/frontend/customer-webapp/src/pages/Home.jsx @@ -1,8 +1,14 @@ import { Link } from "react-router-dom" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" +import L from "leaflet" +import "leaflet/dist/leaflet.css" const SIMULATOR_API_BASE = import.meta.env.VITE_SIMULATOR_API_BASE || "/api/simulator" +const MAP_TILE_URL = + import.meta.env.VITE_MAP_TILE_URL || + "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" +const SPOKANE_CENTER = [47.6588, -117.426] const demoBots = [ { @@ -10,8 +16,8 @@ const demoBots = [ model: "DeliveryBot-V1", status: "Available", currentLocation: { - latitude: 33.4255, - longitude: -111.94 + latitude: 47.6588, + longitude: -117.426 }, powerLevel: 99.9, externalTemperature: 72, @@ -31,8 +37,8 @@ const demoBots = [ model: "DeliveryBot-V1", status: "OnDelivery", currentLocation: { - latitude: 33.4261, - longitude: -111.9394 + latitude: 47.6572, + longitude: -117.4236 }, powerLevel: 86.4, externalTemperature: 71, @@ -46,8 +52,8 @@ const demoBots = [ model: "DeliveryBot-V1", status: "Charging", currentLocation: { - latitude: 33.4248, - longitude: -111.9408 + latitude: 47.6605, + longitude: -117.4145 }, powerLevel: 24.6, externalTemperature: 72, @@ -171,6 +177,8 @@ export default function Home() { + +
{displayedBots.map((bot) => ( @@ -199,6 +207,164 @@ function Metric({ label, value }) { ) } +function FleetMap({ bots, isDemoData }) { + const mapElementRef = useRef(null) + const mapRef = useRef(null) + const markerLayerRef = useRef(null) + const hasFitMapRef = useRef(false) + const lastBotIdsRef = useRef("") + const locatedBots = useMemo( + () => + bots.filter( + (bot) => + Number.isFinite(bot.currentLocation?.latitude) && + Number.isFinite(bot.currentLocation?.longitude) + ), + [bots] + ) + + useEffect(() => { + if (!mapElementRef.current || mapRef.current) { + return undefined + } + + const map = L.map(mapElementRef.current, { + center: SPOKANE_CENTER, + zoom: 15, + minZoom: 12, + maxZoom: 19, + scrollWheelZoom: true + }) + + L.tileLayer(MAP_TILE_URL, { + attribution: + '© OpenStreetMap contributors', + detectRetina: true, + keepBuffer: 3, + updateWhenIdle: true + }).addTo(map) + + mapRef.current = map + markerLayerRef.current = L.layerGroup().addTo(map) + + window.setTimeout(() => map.invalidateSize(), 0) + + return () => { + map.remove() + mapRef.current = null + markerLayerRef.current = null + hasFitMapRef.current = false + lastBotIdsRef.current = "" + } + }, []) + + useEffect(() => { + const map = mapRef.current + const markerLayer = markerLayerRef.current + + if (!map || !markerLayer) { + return + } + + markerLayer.clearLayers() + + if (locatedBots.length === 0) { + map.setView(SPOKANE_CENTER, 15) + return + } + + locatedBots.forEach((bot) => { + const statusColor = getStatusColor(bot.status) + const marker = L.circleMarker( + [bot.currentLocation.latitude, bot.currentLocation.longitude], + { + radius: 10, + color: statusColor.background, + fillColor: statusColor.text, + fillOpacity: 1, + opacity: 1, + weight: 4 + } + ) + + marker + .bindTooltip(`${bot.botId} - ${formatStatus(bot.status || "Unknown")}`, { + direction: "top", + offset: [0, -10], + opacity: 0.95 + }) + .addTo(markerLayer) + }) + + const botIds = locatedBots + .map((bot) => bot.botId) + .sort() + .join("|") + + if (!hasFitMapRef.current || lastBotIdsRef.current !== botIds) { + const bounds = L.latLngBounds( + locatedBots.map((bot) => [ + bot.currentLocation.latitude, + bot.currentLocation.longitude + ]) + ) + + map.fitBounds(bounds.pad(0.35), { + maxZoom: 16 + }) + hasFitMapRef.current = true + lastBotIdsRef.current = botIds + } + }, [locatedBots]) + + return ( +
+
+
+

Fleet map

+

Robot Locations

+
+ + + {locatedBots.length} mapped robot{locatedBots.length === 1 ? "" : "s"} + +
+ +
+
+ +
+ {["Available", "OnDelivery", "Charging"].map((status) => { + const statusColor = getStatusColor(status) + + return ( +
+ + {formatStatus(status)} +
+ ) + })} +
+
+ + {isDemoData && ( +

+ Demo coordinates are shown until simulator data is available. +

+ )} +
+ ) +} + function BotCard({ bot, isDemoData }) { const statusColor = getStatusColor(bot.status) const location = bot.currentLocation @@ -299,6 +465,14 @@ function getStockSummary(stock = []) { .join(", ") } +function formatStatus(status) { + if (status === "OnDelivery") { + return "On delivery" + } + + return status +} + function getStatusColor(status) { if (status === "Available") { return { @@ -468,6 +642,91 @@ const styles = { marginTop: "0.3rem" }, + mapPanel: { + backgroundColor: "#f8fafc", + border: "1px solid #cbd5e1", + borderRadius: "8px", + color: "#0f172a", + marginBottom: "1rem", + padding: "1rem", + textAlign: "left" + }, + + mapHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + gap: "1rem", + marginBottom: "1rem", + flexWrap: "wrap" + }, + + mapTitle: { + color: "#0f172a", + fontSize: "1.35rem", + lineHeight: 1.2, + margin: 0 + }, + + mapCount: { + color: "#475569", + backgroundColor: "#e2e8f0", + border: "1px solid #cbd5e1", + borderRadius: "999px", + padding: "0.4rem 0.7rem", + fontSize: "0.82rem", + fontWeight: "bold" + }, + + mapShell: { + display: "flex", + flexDirection: "column", + gap: "1rem", + alignItems: "stretch" + }, + + mapCanvas: { + height: "430px", + minHeight: "360px", + overflow: "hidden", + borderRadius: "8px", + border: "1px solid #bfdbfe", + backgroundColor: "#e2e8f0" + }, + + mapLegend: { + display: "flex", + flexWrap: "wrap", + gap: "0.7rem", + backgroundColor: "#f1f5f9", + border: "1px solid #cbd5e1", + borderRadius: "8px", + padding: "1rem", + minHeight: "auto" + }, + + legendItem: { + display: "flex", + alignItems: "center", + gap: "0.55rem", + color: "#334155", + fontSize: "0.9rem", + fontWeight: "bold" + }, + + legendDot: { + width: "0.8rem", + height: "0.8rem", + borderRadius: "999px", + flex: "0 0 auto" + }, + + mapFootnote: { + color: "#64748b", + fontSize: "0.85rem", + marginTop: "0.85rem" + }, + botGrid: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",