diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f297f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Go build artifacts +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Go modules cache +vendor/ + +# Dependency directories +# /go/ + +# IDE/editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +Thumbs.db + +# Environment files +.env + +# Docker +*.tar + +# Node/npm +node_modules/ +npm-debug.log +yarn-error.log + +# Custom tiles or generated map data +backend/custom_tiles + +# Gitea +.gitea/workflows/*.log + +# Static build output +dist/ +build/ diff --git a/app/web/.env.example b/app/web/.env.example new file mode 100644 index 0000000..fdb11d8 --- /dev/null +++ b/app/web/.env.example @@ -0,0 +1,2 @@ +VALHALLA_URL=http://10.200.0.15:8002/route +PORT=8080 \ No newline at end of file diff --git a/app/web/.gitea/workflows/docker.yml b/app/web/.gitea/workflows/docker.yml new file mode 100644 index 0000000..0077801 --- /dev/null +++ b/app/web/.gitea/workflows/docker.yml @@ -0,0 +1,31 @@ +name: Build and Publish Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: gitea.ztsw.de + username: ${{ secrets.CR_USERNAME }} + password: ${{ secrets.CR_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: git.ztsw.de/pedan/freemoto/freemoto-web:latest \ No newline at end of file diff --git a/app/web/Dockerfile b/app/web/Dockerfile new file mode 100644 index 0000000..81f9736 --- /dev/null +++ b/app/web/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.22-alpine + +WORKDIR /app + +COPY . . + +RUN go mod tidy + +EXPOSE 8080 + +CMD ["go", "run", "main.go"] \ No newline at end of file diff --git a/app/web/docker-compose.yml b/app/web/docker-compose.yml new file mode 100644 index 0000000..251850d --- /dev/null +++ b/app/web/docker-compose.yml @@ -0,0 +1,7 @@ +services: + freemoto-web: + build: . + ports: + - "8080:8080" + env_file: + - .env \ No newline at end of file diff --git a/app/web/go.mod b/app/web/go.mod new file mode 100644 index 0000000..d8348a3 --- /dev/null +++ b/app/web/go.mod @@ -0,0 +1,5 @@ +module pedan/freemoto + +go 1.24.5 + +require github.com/joho/godotenv v1.5.1 diff --git a/app/web/go.sum b/app/web/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/app/web/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/app/web/main.go b/app/web/main.go new file mode 100644 index 0000000..9781394 --- /dev/null +++ b/app/web/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "io" + "log" + "net/http" + "os" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load(".env") // Load .env file + + valhallaURL := os.Getenv("VALHALLA_URL") + if valhallaURL == "" { + valhallaURL = "http://10.200.0.15:8002/route" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + http.HandleFunc("/route", func(w http.ResponseWriter, r *http.Request) { + proxyToValhalla(w, r, valhallaURL) + }) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + http.ServeFile(w, r, "./static/index.html") + return + } + http.ServeFile(w, r, "./static/"+r.URL.Path[1:]) + }) + log.Printf("Listening on :%s", port) + http.ListenAndServe(":"+port, nil) +} + +func proxyToValhalla(w http.ResponseWriter, r *http.Request, valhallaURL string) { + req, _ := http.NewRequest("POST", valhallaURL, r.Body) + req.Header = r.Header + + client := http.Client{} + resp, err := client.Do(req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer resp.Body.Close() + w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) + io.Copy(w, resp.Body) +} \ No newline at end of file diff --git a/app/web/static/geolocate.js b/app/web/static/geolocate.js new file mode 100644 index 0000000..ebaad06 --- /dev/null +++ b/app/web/static/geolocate.js @@ -0,0 +1,139 @@ +document.addEventListener('DOMContentLoaded', function() { + function geocode(query, callback) { + fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(query)) + .then(response => response.json()) + .then(data => { + if (data && data.length > 0) { + callback(data[0]); + } else { + callback(null); + } + }); + } + + function setInputToCurrentLocation(inputId) { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(function(position) { + var lat = position.coords.latitude; + var lon = position.coords.longitude; + fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`) + .then(response => response.json()) + .then(data => { + document.getElementById(inputId).value = data.display_name || `${lat},${lon}`; + document.getElementById(inputId).dataset.lat = lat; + document.getElementById(inputId).dataset.lon = lon; + }); + }); + } + } + + function showSuggestions(inputId, suggestionsId, value) { + var suggestionsBox = document.getElementById(suggestionsId); + if (!value) { + suggestionsBox.innerHTML = ''; + suggestionsBox.style.display = 'none'; + return; + } + fetch('https://nominatim.openstreetmap.org/search?format=json&q=' + encodeURIComponent(value)) + .then(response => response.json()) + .then(data => { + suggestionsBox.innerHTML = ''; + if (data && data.length > 0) { + data.slice(0, 5).forEach(function(item) { + var option = document.createElement('button'); + option.type = 'button'; + option.className = 'list-group-item list-group-item-action'; + option.textContent = item.display_name; + option.onclick = function() { + var input = document.getElementById(inputId); + input.value = item.display_name; + input.dataset.lat = item.lat; + input.dataset.lon = item.lon; + suggestionsBox.innerHTML = ''; + suggestionsBox.style.display = 'none'; + }; + suggestionsBox.appendChild(option); + }); + suggestionsBox.style.display = 'block'; + } else { + suggestionsBox.style.display = 'none'; + } + }); + } + + // Debounce helper + function debounce(fn, delay) { + let timer = null; + return function(...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; + } + + function handleInput(e) { + var val = e.target.value; + if (val) { + geocode(val, function(result) { + if (result) { + e.target.dataset.lat = result.lat; + e.target.dataset.lon = result.lon; + } else { + delete e.target.dataset.lat; + delete e.target.dataset.lon; + } + }); + } else { + delete e.target.dataset.lat; + delete e.target.dataset.lon; + } + } + + document.getElementById('sourceInput').addEventListener('input', debounce(function(e) { + showSuggestions('sourceInput', 'sourceSuggestions', e.target.value); + }, 400)); + document.getElementById('destInput').addEventListener('input', debounce(function(e) { + showSuggestions('destInput', 'destSuggestions', e.target.value); + }, 400)); + + // Hide suggestions when input loses focus (with a slight delay for click) + document.getElementById('sourceInput').addEventListener('blur', function() { + setTimeout(() => { + document.getElementById('sourceSuggestions').style.display = 'none'; + }, 200); + }); + document.getElementById('destInput').addEventListener('blur', function() { + setTimeout(() => { + document.getElementById('destSuggestions').style.display = 'none'; + }, 200); + }); + + document.getElementById('useCurrentSource').onclick = function() { + setInputToCurrentLocation('sourceInput'); + }; + document.getElementById('useCurrentDest').onclick = function() { + setInputToCurrentLocation('destInput'); + }; + + document.getElementById('sourceInput').addEventListener('blur', function(e) { + var val = e.target.value; + if (val && !e.target.dataset.lat) { + geocode(val, function(result) { + if (result) { + e.target.dataset.lat = result.lat; + e.target.dataset.lon = result.lon; + } + }); + } + }); + document.getElementById('destInput').addEventListener('blur', function(e) { + var val = e.target.value; + if (val && !e.target.dataset.lat) { + geocode(val, function(result) { + if (result) { + e.target.dataset.lat = result.lat; + e.target.dataset.lon = result.lon; + } + }); + } + }); +}); \ No newline at end of file diff --git a/app/web/static/index.html b/app/web/static/index.html new file mode 100644 index 0000000..a0210a9 --- /dev/null +++ b/app/web/static/index.html @@ -0,0 +1,126 @@ + + + + FreeMoto Web + + + + + + + + +
+
+

FreeMoto Route Planner

+
+
Route
+
+ + + +
+
+
+ + + +
+
+
+ +
+
+
+
Route Options
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + + + + + diff --git a/app/web/static/main.js b/app/web/static/main.js new file mode 100644 index 0000000..e47126b --- /dev/null +++ b/app/web/static/main.js @@ -0,0 +1,37 @@ +// Center on a default point +var map = L.map('map', { zoomControl: false }).setView([53.866237, 10.676289], 18); + +// Add OSM tiles +L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap' +}).addTo(map); + +var userIcon = L.icon({ + iconUrl: '/maps-arrow.svg', // Add a motorcycle icon to your static folder + iconSize: [40, 40] +}); + +// Get users location +if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(function(position) { + var lat = position.coords.latitude; + var lon = position.coords.longitude; + map.setView([lat, lon], 14); + L.marker([lat, lon], {icon: userIcon}).addTo(map).bindPopup('You are here!'); + }); +} + +// Custom Bootstrap zoom controls +document.addEventListener('DOMContentLoaded', function() { + var zoomInBtn = document.getElementById('zoomInBtn'); + var zoomOutBtn = document.getElementById('zoomOutBtn'); + if (zoomInBtn && zoomOutBtn) { + zoomInBtn.addEventListener('click', function() { + map.zoomIn(); + }); + zoomOutBtn.addEventListener('click', function() { + map.zoomOut(); + }); + } +}); \ No newline at end of file diff --git a/app/web/static/maps-arrow.svg b/app/web/static/maps-arrow.svg new file mode 100644 index 0000000..13f7636 --- /dev/null +++ b/app/web/static/maps-arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/web/static/route.js b/app/web/static/route.js new file mode 100644 index 0000000..442765d --- /dev/null +++ b/app/web/static/route.js @@ -0,0 +1,171 @@ +document.addEventListener('DOMContentLoaded', function() { + var avoidHighwaysCheckbox = document.getElementById('avoidHighways'); + var useShortestCheckbox = document.getElementById('useShortest'); + var avoidTollRoadsCheckbox = document.getElementById('avoidTollRoads'); + var avoidFerriesCheckbox = document.getElementById('avoidFerries'); + var avoidUnpavedCheckbox = document.getElementById('avoidUnpaved'); + var points = []; + var markers = []; + var routePolyline = null; + + function calculateRoute() { + if (points.length === 2) { + var options = { + "exclude_restrictions": true + }; + if (avoidHighwaysCheckbox && avoidHighwaysCheckbox.checked) { + options.use_highways = 0; + } + if (useShortestCheckbox && useShortestCheckbox.checked) { + options.use_shortest = true; + } + if (avoidTollRoadsCheckbox && avoidTollRoadsCheckbox.checked) { + options.avoid_toll = true; + } + if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) { + options.avoid_ferry = true; + } + if (avoidUnpavedCheckbox && avoidUnpavedCheckbox.checked) { + options.avoid_unpaved = true; + } + + var costing_options = { motorcycle: options }; + + var requestBody = { + locations: [ + { lat: points[0].lat, lon: points[0].lng }, + { lat: points[1].lat, lon: points[1].lng } + ], + costing: "motorcycle", + costing_options: costing_options + }; + + fetch('/route', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(requestBody) + }) + .then(response => response.json()) + .then(data => { + var latlngs = decodePolyline6(data.trip.legs[0].shape); + if (routePolyline) { + map.removeLayer(routePolyline); + } + routePolyline = L.polyline(latlngs, { color: 'red', weight: 5}).addTo(map); + map.fitBounds(routePolyline.getBounds()); + }); + } + } + + map.on('click', function(e) { + if (points.length < 2) { + var marker = L.marker(e.latlng).addTo(map); + markers.push(marker); + points.push(e.latlng); + marker.bindPopup(points.length === 1 ? "Start" : "End").openPopup(); + } + calculateRoute(); + }); + + // Listen for changes on all checkboxes + [ + avoidHighwaysCheckbox, + useShortestCheckbox, + avoidTollRoadsCheckbox, + avoidFerriesCheckbox, + avoidUnpavedCheckbox + ].forEach(function(checkbox) { + if (checkbox) { + checkbox.addEventListener('change', calculateRoute); + } + }); + + // Disable other checkboxes when "Use shortest route" is checked + useShortestCheckbox.addEventListener('change', function() { + var disable = useShortestCheckbox.checked; + [ + avoidHighwaysCheckbox, + avoidTollRoadsCheckbox, + avoidFerriesCheckbox, + avoidUnpavedCheckbox + ].forEach(function(cb) { + cb.disabled = disable; + }); + }); + + // Adapted from Valhalla docs — polyline6 decoder for JS + function decodePolyline6(encoded) { + var coords = []; + var index = 0, lat = 0, lng = 0; + var shift = 0, result = 0, byte = null, latitude_change, longitude_change; + var factor = Math.pow(10, 6); + while (index < encoded.length) { + byte = shift = result = 0; + do { + byte = encoded.charCodeAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + latitude_change = (result & 1) ? ~(result >> 1) : (result >> 1); + + shift = result = 0; + do { + byte = encoded.charCodeAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + longitude_change = (result & 1) ? ~(result >> 1) : (result >> 1); + + lat += latitude_change; + lng += longitude_change; + + coords.push([lat / factor, lng / factor]); + } + return coords; + } + + // Remove Markers + function resetMarkers() { + markers.forEach(function(marker) { + map.removeLayer(marker); + }); + markers = []; + points = []; + if (routePolyline) { + map.removeLayer(routePolyline); + routePolyline = null; + } + } + + // Make resetMarkers available globally + window.resetMarkers = resetMarkers; + + // Plot Route button logic + document.getElementById('plotRouteBtn').addEventListener('click', function() { + var sourceInput = document.getElementById('sourceInput'); + var destInput = document.getElementById('destInput'); + var sourceLat = parseFloat(sourceInput.dataset.lat); + var sourceLon = parseFloat(sourceInput.dataset.lon); + var destLat = parseFloat(destInput.dataset.lat); + var destLon = parseFloat(destInput.dataset.lon); + + if (!isNaN(sourceLat) && !isNaN(sourceLon) && !isNaN(destLat) && !isNaN(destLon)) { + // Remove old markers + markers.forEach(function(marker) { + map.removeLayer(marker); + }); + markers = []; + points = []; + + // Add new markers + var startMarker = L.marker([sourceLat, sourceLon]).addTo(map).bindPopup("Start").openPopup(); + var endMarker = L.marker([destLat, destLon]).addTo(map).bindPopup("End").openPopup(); + markers.push(startMarker, endMarker); + points.push({lat: sourceLat, lng: sourceLon}, {lat: destLat, lng: destLon}); + + calculateRoute(); + } else { + alert("Please enter valid addresses for both start and destination."); + } + }); +}); \ No newline at end of file diff --git a/backend/compose.yml b/backend/compose.yml new file mode 100644 index 0000000..25d16ea --- /dev/null +++ b/backend/compose.yml @@ -0,0 +1,9 @@ +services: + valhalla-scripted: + tty: true + container_name: valhalla + ports: + - 8002:8002 + volumes: + - $PWD/custom_files:/custom_files + image: ghcr.io/valhalla/valhalla-scripted:latest \ No newline at end of file