feat(ui,roundtrip): modern neutral “Apple Glass” UI, scenic round trips, and isochrone guidance
Some checks failed
build-and-push / docker (push) Failing after 10m2s
Some checks failed
build-and-push / docker (push) Failing after 10m2s
- UI (neutral minimal, glass-like)
- Rewrote app shell and control panel in [app/web/static/index.html](cci:7://file:///home/pedan/freemoto/app/web/static/index.html:0:0-0:0)
- Modernized responsive styles in [app/web/static/styles.css](cci:7://file:///home/pedan/freemoto/app/web/static/styles.css:0:0-0:0)
- Proper internal scrolling for control panel, suggestions, waypoints, and directions sheet
- Improved dark mode, spacing, and visual polish
- Round Trip generation
- Direction preference (N/E/S/W) with biased waypoint bearings ([route.js](cci:7://file:///home/pedan/freemoto/app/web/static/route.js:0:0-0:0))
- Ferry avoidance by default on round trips; added strong ferry cost
- Coastal safety: reverse-geocode water check and automatic inland adjustment for generated waypoints
- New “Scenic optimizer” option: generate candidate loops, route them, score twistiness, pick the best
- Heading-change/turn-density based scoring using decoded polyline
- Isochrone-guided loops
- Backend: added `/isochrone` proxy in [app/web/main.go](cci:7://file:///home/pedan/freemoto/app/web/main.go:0:0-0:0) (derives Valhalla base from `VALHALLA_URL`)
- Frontend: optional “Use isochrone guidance” + time (minutes); sample preferred sector of polygon
- Settings persistence and live updates
- Direction, scenic, isochrone, and distance persist via localStorage
- Changing options regenerates round trips when enabled
- Docker/Build
- Confirmed multi-arch build (amd64/arm64) and push to git.ztsw.de/pedan/freemoto/freemoto-web:latest
Refs: [index.html](cci:7://file:///home/pedan/freemoto/app/web/static/index.html:0:0-0:0), [styles.css](cci:7://file:///home/pedan/freemoto/app/web/static/styles.css:0:0-0:0), [route.js](cci:7://file:///home/pedan/freemoto/app/web/static/route.js:0:0-0:0), [main.js](cci:7://file:///home/pedan/freemoto/app/web/static/main.js:0:0-0:0), [app/web/main.go](cci:7://file:///home/pedan/freemoto/app/web/main.go:0:0-0:0), [Dockerfile](cci:7://file:///home/pedan/freemoto/Dockerfile:0:0-0:0)
This commit is contained in:
@@ -48,6 +48,54 @@ func parseLogLevel(s string) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// proxyToValhallaPOST forwards a POST with JSON body to a specific Valhalla endpoint (e.g., /isochrone)
|
||||||
|
func proxyToValhallaPOST(w http.ResponseWriter, r *http.Request, target string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, target, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header = r.Header.Clone()
|
||||||
|
if req.Header.Get("Content-Type") == "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := http.Client{Timeout: 20 * time.Second}
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
|
||||||
|
logf(levelError, "rid=%s upstream=valhalla target=%s error=%v", rid, target, err)
|
||||||
|
} else {
|
||||||
|
logf(levelError, "upstream=valhalla target=%s error=%v", target, err)
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Forward common headers and status
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
if key == "Content-Type" || key == "Content-Encoding" || key == "Cache-Control" || key == "Vary" {
|
||||||
|
for _, v := range values {
|
||||||
|
w.Header().Add(key, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
|
||||||
|
if rid, ok := r.Context().Value(ctxReqID).(string); ok {
|
||||||
|
logf(levelDebug, "rid=%s upstream=valhalla target=%s status=%d dur=%s", rid, target, resp.StatusCode, time.Since(start))
|
||||||
|
} else {
|
||||||
|
logf(levelDebug, "upstream=valhalla target=%s status=%d dur=%s", target, resp.StatusCode, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func logf(level int, format string, args ...any) {
|
func logf(level int, format string, args ...any) {
|
||||||
if level < currentLogLevel {
|
if level < currentLogLevel {
|
||||||
return
|
return
|
||||||
@@ -74,6 +122,8 @@ func main() {
|
|||||||
if valhallaURL == "" {
|
if valhallaURL == "" {
|
||||||
valhallaURL = "http://valhalla:8002/route"
|
valhallaURL = "http://valhalla:8002/route"
|
||||||
}
|
}
|
||||||
|
// Derive Valhalla base for other endpoints like /isochrone
|
||||||
|
valhallaBase := strings.TrimSuffix(valhallaURL, "/route")
|
||||||
|
|
||||||
nominatimBase := os.Getenv("NOMINATIM_URL")
|
nominatimBase := os.Getenv("NOMINATIM_URL")
|
||||||
if nominatimBase == "" {
|
if nominatimBase == "" {
|
||||||
@@ -100,6 +150,15 @@ func main() {
|
|||||||
http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
http.Handle("/route", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
proxyToValhalla(w, r, valhallaURL)
|
proxyToValhalla(w, r, valhallaURL)
|
||||||
})))
|
})))
|
||||||
|
http.Handle("/isochrone", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Forward to Valhalla isochrone endpoint (POST JSON)
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target := valhallaBase + "/isochrone"
|
||||||
|
proxyToValhallaPOST(w, r, target)
|
||||||
|
})))
|
||||||
// Nominatim proxy endpoints
|
// Nominatim proxy endpoints
|
||||||
http.Handle("/geocode", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
http.Handle("/geocode", withLogging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
proxyToNominatimGET(w, r, nominatimBase+"/search", nominatimUA)
|
proxyToNominatimGET(w, r, nominatimBase+"/search", nominatimUA)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<link href="/styles.css?v=20250918" rel="stylesheet" />
|
<link href="/styles.css?v=20250918" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- App Bar -->
|
<!-- App Bar (Neutral minimal, glassy) -->
|
||||||
<header class="appbar navbar px-3">
|
<header class="appbar navbar px-3">
|
||||||
<div class="container-fluid d-flex align-items-center justify-content-between">
|
<div class="container-fluid d-flex align-items-center justify-content-between">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
@@ -20,11 +20,11 @@
|
|||||||
<span id="summaryPill" class="badge rounded-pill text-bg-light d-none"></span>
|
<span id="summaryPill" class="badge rounded-pill text-bg-light d-none"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="clearRouteBtn" title="Clear route">Clear</button>
|
|
||||||
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom controls">
|
<div class="btn-group btn-group-sm" role="group" aria-label="Zoom controls">
|
||||||
<button class="btn btn-outline-secondary" id="zoomOutBtn" title="Zoom out">−</button>
|
<button class="btn btn-outline-secondary" id="zoomOutBtn" title="Zoom out">−</button>
|
||||||
<button class="btn btn-outline-secondary" id="zoomInBtn" title="Zoom in">+</button>
|
<button class="btn btn-outline-secondary" id="zoomInBtn" title="Zoom in">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="clearRouteBtn" title="Clear route">Clear</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Toggle dark mode">🌙</button>
|
<button class="btn btn-outline-secondary btn-sm" id="themeToggle" title="Toggle dark mode">🌙</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,12 +33,11 @@
|
|||||||
<!-- Map canvas -->
|
<!-- Map canvas -->
|
||||||
<div id="map" aria-label="Map"></div>
|
<div id="map" aria-label="Map"></div>
|
||||||
|
|
||||||
<!-- Control Drawer: overlay on mobile, sidebar on desktop -->
|
<!-- Control Panel (neutral, glassy, scrollable) -->
|
||||||
<aside class="nav-panel shadow">
|
<aside class="nav-panel shadow">
|
||||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge text-bg-primary">Beta</span>
|
<span class="fw-semibold">Planner</span>
|
||||||
<span class="fw-semibold">Motorcycle Route Planner</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline" onclick="resetMarkers()">Reset</button>
|
<button class="btn btn-sm btn-outline-secondary d-none d-md-inline" onclick="resetMarkers()">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,14 +45,12 @@
|
|||||||
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
|
<div id="routeInfoCard" class="alert alert-info d-none" role="alert"></div>
|
||||||
<div id="nextManeuverBanner" class="alert alert-secondary py-2 px-3 d-none" role="status"></div>
|
<div id="nextManeuverBanner" class="alert alert-secondary py-2 px-3 d-none" role="status"></div>
|
||||||
|
|
||||||
<!-- Route inputs -->
|
<!-- Route inputs (compact) -->
|
||||||
<div class="section-title mb-1">Route</div>
|
<div class="section-title mb-1">Route</div>
|
||||||
<div class="row g-2 align-items-stretch">
|
<div class="row g-2 align-items-stretch">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="input-group position-relative">
|
<div class="input-group position-relative">
|
||||||
<span class="input-group-text" title="Start" aria-label="Start">
|
<span class="input-group-text" title="Start" aria-label="Start">A</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="#0d6efd" viewBox="0 0 16 16"><path d="M8 16s6-5.686 6-10A6 6 0 1 0 2 6c0 4.314 6 10 6 10zm0-7a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></svg>
|
|
||||||
</span>
|
|
||||||
<input type="text" class="form-control" id="sourceInput" placeholder="Start address" autocomplete="off" />
|
<input type="text" class="form-control" id="sourceInput" placeholder="Start address" autocomplete="off" />
|
||||||
<button class="btn btn-outline-secondary" type="button" id="useCurrentSource" title="Use current location">📍</button>
|
<button class="btn btn-outline-secondary" type="button" id="useCurrentSource" title="Use current location">📍</button>
|
||||||
<div id="sourceSuggestions" class="list-group position-absolute w-100"></div>
|
<div id="sourceSuggestions" class="list-group position-absolute w-100"></div>
|
||||||
@@ -64,9 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="input-group position-relative">
|
<div class="input-group position-relative">
|
||||||
<span class="input-group-text" title="Destination" aria-label="Destination">
|
<span class="input-group-text" title="Destination" aria-label="Destination">B</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="#dc3545" viewBox="0 0 16 16"><path d="M14.778 2.222a.5.5 0 0 1 0 .707l-2.5 2.5a.5.5 0 0 1-.707 0l-2.5-2.5a.5.5 0 0 1 .707-.707L12 3.793l2.071-2.071a.5.5 0 0 1 .707 0z"/><path d="M2.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5z"/></svg>
|
|
||||||
</span>
|
|
||||||
<input type="text" class="form-control" id="destInput" placeholder="Destination address" autocomplete="off" />
|
<input type="text" class="form-control" id="destInput" placeholder="Destination address" autocomplete="off" />
|
||||||
<button class="btn btn-outline-secondary" type="button" id="useCurrentDest" title="Use current location">📍</button>
|
<button class="btn btn-outline-secondary" type="button" id="useCurrentDest" title="Use current location">📍</button>
|
||||||
<div id="destSuggestions" class="list-group position-absolute w-100"></div>
|
<div id="destSuggestions" class="list-group position-absolute w-100"></div>
|
||||||
@@ -96,12 +91,12 @@
|
|||||||
<button type="button" id="plotRouteBtn" class="btn btn-success">Plot Route</button>
|
<button type="button" id="plotRouteBtn" class="btn btn-success">Plot Route</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Options accordion -->
|
<!-- Options (collapsible minimal) -->
|
||||||
<div class="accordion mb-2" id="optionsAccordion">
|
<div class="accordion mb-2" id="optionsAccordion">
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#optionsCollapse" aria-expanded="false" aria-controls="optionsCollapse">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#optionsCollapse" aria-expanded="false" aria-controls="optionsCollapse">
|
||||||
Route Options
|
Options
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="optionsCollapse" class="accordion-collapse collapse" data-bs-parent="#optionsAccordion">
|
<div id="optionsCollapse" class="accordion-collapse collapse" data-bs-parent="#optionsAccordion">
|
||||||
@@ -144,6 +139,43 @@
|
|||||||
<button type="button" id="roundTripBtn" class="btn btn-sm btn-outline-primary">Create</button>
|
<button type="button" id="roundTripBtn" class="btn btn-sm btn-outline-primary">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row g-2 align-items-center mb-2">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="roundTripDir" class="form-label mb-0 text-muted">Direction preference</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<select id="roundTripDir" class="form-select form-select-sm">
|
||||||
|
<option value="any" selected>Any</option>
|
||||||
|
<option value="N">North</option>
|
||||||
|
<option value="E">East</option>
|
||||||
|
<option value="S">South</option>
|
||||||
|
<option value="W">West</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 align-items-center mb-2">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roundTripScenic">
|
||||||
|
<label class="form-check-label" for="roundTripScenic">Scenic optimizer (twistier)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2 align-items-center mb-2">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="roundTripIsochrone">
|
||||||
|
<label class="form-check-label" for="roundTripIsochrone">Use isochrone guidance</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text">Time</span>
|
||||||
|
<input type="number" min="10" max="240" step="5" id="isochroneMinutes" class="form-control" placeholder="Minutes" value="60">
|
||||||
|
<span class="input-group-text">min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row g-2 align-items-center mb-2">
|
<div class="row g-2 align-items-center mb-2">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var roundTripKm = document.getElementById('roundTripKm');
|
var roundTripKm = document.getElementById('roundTripKm');
|
||||||
var roundTripBtn = document.getElementById('roundTripBtn');
|
var roundTripBtn = document.getElementById('roundTripBtn');
|
||||||
var roundTripNoRepeat = document.getElementById('roundTripNoRepeat');
|
var roundTripNoRepeat = document.getElementById('roundTripNoRepeat');
|
||||||
|
var roundTripDir = document.getElementById('roundTripDir');
|
||||||
|
var roundTripScenic = document.getElementById('roundTripScenic');
|
||||||
|
var roundTripIsochrone = document.getElementById('roundTripIsochrone');
|
||||||
|
var isochroneMinutes = document.getElementById('isochroneMinutes');
|
||||||
if (zoomInBtn && zoomOutBtn) {
|
if (zoomInBtn && zoomOutBtn) {
|
||||||
zoomInBtn.addEventListener('click', function() {
|
zoomInBtn.addEventListener('click', function() {
|
||||||
map.zoomIn();
|
map.zoomIn();
|
||||||
@@ -156,6 +160,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
roundTripKm.addEventListener('change', function(){
|
roundTripKm.addEventListener('change', function(){
|
||||||
var v = parseInt(roundTripKm.value, 10);
|
var v = parseInt(roundTripKm.value, 10);
|
||||||
if (!isNaN(v)) localStorage.setItem('freemoto-roundtrip-km', String(v));
|
if (!isNaN(v)) localStorage.setItem('freemoto-roundtrip-km', String(v));
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (roundTripBtn) {
|
if (roundTripBtn) {
|
||||||
@@ -174,6 +181,54 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direction preference persistence and live update
|
||||||
|
if (roundTripDir) {
|
||||||
|
var savedDir = localStorage.getItem('freemoto-roundtrip-dir') || 'any';
|
||||||
|
roundTripDir.value = savedDir;
|
||||||
|
roundTripDir.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-dir', roundTripDir.value);
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
// Regenerate the round trip to reflect new direction
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenic optimizer toggle
|
||||||
|
if (roundTripScenic) {
|
||||||
|
var savedScenic = localStorage.getItem('freemoto-roundtrip-scenic') === '1';
|
||||||
|
roundTripScenic.checked = savedScenic;
|
||||||
|
roundTripScenic.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-scenic', roundTripScenic.checked ? '1' : '0');
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isochrone toggle and minutes
|
||||||
|
if (roundTripIsochrone) {
|
||||||
|
var savedIso = localStorage.getItem('freemoto-roundtrip-isochrone') === '1';
|
||||||
|
roundTripIsochrone.checked = savedIso;
|
||||||
|
roundTripIsochrone.addEventListener('change', function(){
|
||||||
|
localStorage.setItem('freemoto-roundtrip-isochrone', roundTripIsochrone.checked ? '1' : '0');
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (isochroneMinutes) {
|
||||||
|
var savedMin = parseInt(localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
|
||||||
|
if (!isNaN(savedMin)) isochroneMinutes.value = savedMin;
|
||||||
|
isochroneMinutes.addEventListener('change', function(){
|
||||||
|
var v = parseInt(isochroneMinutes.value, 10);
|
||||||
|
if (!isNaN(v)) localStorage.setItem('freemoto-isochrone-minutes', String(v));
|
||||||
|
if (roundTripToggle && roundTripToggle.checked && typeof window.createRoundTrip === 'function') {
|
||||||
|
window.createRoundTrip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Swap start/end like Google Maps
|
// Swap start/end like Google Maps
|
||||||
(function(){
|
(function(){
|
||||||
var swapBtn = document.getElementById('swapBtn');
|
var swapBtn = document.getElementById('swapBtn');
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
var roundTripToggle = document.getElementById('roundTripToggle');
|
var roundTripToggle = document.getElementById('roundTripToggle');
|
||||||
var roundTripKmInput = document.getElementById('roundTripKm');
|
var roundTripKmInput = document.getElementById('roundTripKm');
|
||||||
var roundTripBtn = document.getElementById('roundTripBtn');
|
var roundTripBtn = document.getElementById('roundTripBtn');
|
||||||
|
var roundTripDirSel = document.getElementById('roundTripDir');
|
||||||
|
var roundTripScenic = document.getElementById('roundTripScenic');
|
||||||
|
var roundTripIsochrone = document.getElementById('roundTripIsochrone');
|
||||||
|
var isochroneMinutesInput = document.getElementById('isochroneMinutes');
|
||||||
|
|
||||||
function renderWaypointList() {
|
function renderWaypointList() {
|
||||||
if (!waypointListEl) return;
|
if (!waypointListEl) return;
|
||||||
@@ -77,6 +81,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return { lat: toDeg(φ2), lng: ((toDeg(λ2) + 540) % 360) - 180 };
|
return { lat: toDeg(φ2), lng: ((toDeg(λ2) + 540) % 360) - 180 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDeg(d){ return ((d % 360) + 360) % 360; }
|
||||||
|
|
||||||
|
function preferredBearing() {
|
||||||
|
var val = roundTripDirSel ? (roundTripDirSel.value || 'any') : 'any';
|
||||||
|
switch (val) {
|
||||||
|
case 'N': return 0;
|
||||||
|
case 'E': return 90;
|
||||||
|
case 'S': return 180;
|
||||||
|
case 'W': return 270;
|
||||||
|
default: return null; // any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function generateRoundTripWaypoints(start) {
|
function generateRoundTripWaypoints(start) {
|
||||||
var km = parseFloat(roundTripKmInput && roundTripKmInput.value ? roundTripKmInput.value : '100');
|
var km = parseFloat(roundTripKmInput && roundTripKmInput.value ? roundTripKmInput.value : '100');
|
||||||
if (isNaN(km) || km < 5) km = 50;
|
if (isNaN(km) || km < 5) km = 50;
|
||||||
@@ -84,19 +101,237 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Use radius r ≈ distance / (2π)
|
// Use radius r ≈ distance / (2π)
|
||||||
var meters = km * 1000;
|
var meters = km * 1000;
|
||||||
var r = Math.max(500, meters / (2 * Math.PI));
|
var r = Math.max(500, meters / (2 * Math.PI));
|
||||||
// Bearings spaced around circle with a random offset to vary loops
|
// If user provided a preferred direction, bias the bearings around that center.
|
||||||
var base = (Math.random() * 360) | 0;
|
var center = preferredBearing();
|
||||||
|
var bearings;
|
||||||
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
var bearings = noRepeat
|
if (center === null) {
|
||||||
? [0, 72, 144, 216, 288].map(function(d){ return base + d; }) // 5 WPs ~ evenly spaced to reduce overlap
|
// Any: keep previous behavior (random base, evenly spaced)
|
||||||
: [45, 165, 285].map(function(d){ return base + d; });
|
var base = (Math.random() * 360) | 0;
|
||||||
var wps = bearings.map(function(b){ return destPoint(start.lat, start.lng, r, b); });
|
bearings = noRepeat
|
||||||
|
? [0, 72, 144, 216, 288].map(function(d){ return base + d; })
|
||||||
|
: [45, 165, 285].map(function(d){ return base + d; });
|
||||||
|
} else {
|
||||||
|
// Directional preference: create an arc centered on preferred bearing
|
||||||
|
// Use 3 or 5 points depending on noRepeat, within +/- 60° spread
|
||||||
|
var offsets = noRepeat ? [-60, -30, 0, 30, 60] : [-30, 0, 30];
|
||||||
|
bearings = offsets.map(function(o){ return normalizeDeg(center + o); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var wps = bearings.map(function(b, i){
|
||||||
|
// Slightly push the middle point further out to emphasize the preferred direction
|
||||||
|
var scale = 1.0;
|
||||||
|
if (center !== null) {
|
||||||
|
if ((noRepeat && (i === Math.floor(bearings.length/2))) || (!noRepeat && i === 1)) {
|
||||||
|
scale = 1.15; // middle point
|
||||||
|
} else {
|
||||||
|
scale = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return destPoint(start.lat, start.lng, r * scale, b);
|
||||||
|
});
|
||||||
// Ensure endpoints list: start -> wps -> start
|
// Ensure endpoints list: start -> wps -> start
|
||||||
var routePts = [start].concat(wps).concat([start]);
|
var routePts = [start].concat(wps).concat([start]);
|
||||||
return routePts;
|
return routePts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRoundTrip() {
|
// ---- Scenic optimizer helpers ----
|
||||||
|
function heading(a, b) {
|
||||||
|
var φ1 = toRad(a.lat), φ2 = toRad(b.lat);
|
||||||
|
var Δλ = toRad(b.lng - a.lng);
|
||||||
|
var y = Math.sin(Δλ) * Math.cos(φ2);
|
||||||
|
var x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||||
|
return normalizeDeg(toDeg(Math.atan2(y, x)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function angleDiff(a, b) {
|
||||||
|
var d = Math.abs(a - b) % 360;
|
||||||
|
return d > 180 ? 360 - d : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function polylineHeadingDeltas(latlngs) {
|
||||||
|
var deltas = [];
|
||||||
|
for (var i = 1; i < latlngs.length - 1; i++) {
|
||||||
|
var h1 = heading({lat: latlngs[i-1][0], lng: latlngs[i-1][1]}, {lat: latlngs[i][0], lng: latlngs[i][1]});
|
||||||
|
var h2 = heading({lat: latlngs[i][0], lng: latlngs[i][1]}, {lat: latlngs[i+1][0], lng: latlngs[i+1][1]});
|
||||||
|
deltas.push(angleDiff(h1, h2));
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceKm(a, b) {
|
||||||
|
var R = 6371.0;
|
||||||
|
var dLat = toRad(b[0] - a[0]);
|
||||||
|
var dLng = toRad(b[1] - a[1]);
|
||||||
|
var s1 = Math.sin(dLat/2), s2 = Math.sin(dLng/2);
|
||||||
|
var aa = s1*s1 + Math.cos(toRad(a[0]))*Math.cos(toRad(b[0]))*s2*s2;
|
||||||
|
var c = 2 * Math.atan2(Math.sqrt(aa), Math.sqrt(1-aa));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathLengthKm(latlngs) {
|
||||||
|
var km = 0;
|
||||||
|
for (var i = 1; i < latlngs.length; i++) km += distanceKm(latlngs[i-1], latlngs[i]);
|
||||||
|
return km;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scorePolyline(latlngs) {
|
||||||
|
if (!latlngs || latlngs.length < 3) return -1e9;
|
||||||
|
var km = pathLengthKm(latlngs);
|
||||||
|
if (km <= 0) return -1e9;
|
||||||
|
var deltas = polylineHeadingDeltas(latlngs);
|
||||||
|
var sumDelta = deltas.reduce((a,b)=>a+b, 0);
|
||||||
|
var turns = deltas.filter(d => d > 12).length; // count meaningful turns
|
||||||
|
var turnDensity = turns / km; // turns per km
|
||||||
|
var curvature = sumDelta / km; // degrees per km
|
||||||
|
// Penalize very straight long segments implicitly by lower curvature
|
||||||
|
// Compose score: weight curvature more than turn count
|
||||||
|
return curvature * 0.7 + turnDensity * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeAndScore(pointsList, requestOptions) {
|
||||||
|
var body = {
|
||||||
|
locations: pointsList.map(function(p){ return { lat: p.lat, lon: p.lng }; }),
|
||||||
|
costing: "motorcycle",
|
||||||
|
costing_options: requestOptions.costing_options || {},
|
||||||
|
units: "kilometers"
|
||||||
|
};
|
||||||
|
if (requestOptions.shortest) body.shortest = true;
|
||||||
|
var resp = await fetch('/route', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) });
|
||||||
|
var data = await resp.json();
|
||||||
|
var legs = (data.trip && Array.isArray(data.trip.legs)) ? data.trip.legs : [];
|
||||||
|
var latlngs = [];
|
||||||
|
legs.forEach(function(leg){
|
||||||
|
var lls = decodePolyline6(leg.shape || '');
|
||||||
|
if (latlngs.length && lls.length) latlngs = latlngs.concat(lls.slice(1)); else latlngs = latlngs.concat(lls);
|
||||||
|
});
|
||||||
|
var score = scorePolyline(latlngs);
|
||||||
|
return {score: score, data: data, latlngs: latlngs};
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomNormal() {
|
||||||
|
// Box-Muller
|
||||||
|
var u = 1 - Math.random();
|
||||||
|
var v = 1 - Math.random();
|
||||||
|
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateScenicCandidates(start) {
|
||||||
|
var basePts = generateRoundTripWaypoints(start);
|
||||||
|
var center = preferredBearing();
|
||||||
|
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
|
var count = 6;
|
||||||
|
var candidates = [];
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
var scale = 1 + (Math.random()*0.25 - 0.1); // -10%..+15%
|
||||||
|
var jitter = center === null ? 30 : 15; // tighter if centered
|
||||||
|
var pts = basePts.map(function(p, idx){
|
||||||
|
if (idx === 0 || idx === basePts.length - 1) return p;
|
||||||
|
// compute bearing from start to p and re-project with jitter and scale
|
||||||
|
var br = heading(start, p);
|
||||||
|
var j = randomNormal() * jitter;
|
||||||
|
var d = distanceKm([start.lat, start.lng], [p.lat, p.lng]) * 1000 * scale; // meters
|
||||||
|
return destPoint(start.lat, start.lng, d, br + j);
|
||||||
|
});
|
||||||
|
candidates.push(pts);
|
||||||
|
}
|
||||||
|
// Ensure base version is included as well
|
||||||
|
candidates.unshift(basePts);
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Isochrone helpers ----
|
||||||
|
async function fetchIsochronePolygon(start, minutes, motoOptions) {
|
||||||
|
var body = {
|
||||||
|
locations: [{ lat: start.lat, lon: start.lng }],
|
||||||
|
costing: "motorcycle",
|
||||||
|
costing_options: { motorcycle: motoOptions || {} },
|
||||||
|
contours: [{ time: Math.max(5, Math.min(240, minutes)) }],
|
||||||
|
polygons: true,
|
||||||
|
denoise: 0.5,
|
||||||
|
generalize: 100
|
||||||
|
};
|
||||||
|
var resp = await fetch('/isochrone', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||||
|
var data = await resp.json();
|
||||||
|
// Expect GeoJSON FeatureCollection
|
||||||
|
try {
|
||||||
|
var coords = data.features[0].geometry.coordinates;
|
||||||
|
// Valhalla returns [ [ [lon,lat], ... ] ]
|
||||||
|
var ring = coords[0].map(function(c){ return { lat: c[1], lng: c[0] }; });
|
||||||
|
return ring;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleIsochroneSector(ring, start, centerBearing) {
|
||||||
|
if (!ring || !ring.length) return [];
|
||||||
|
var sector = [];
|
||||||
|
var spread = 60; // +/- degrees
|
||||||
|
ring.forEach(function(p){
|
||||||
|
var br = heading(start, {lat: p.lat, lng: p.lng});
|
||||||
|
if (centerBearing === null) {
|
||||||
|
sector.push(p);
|
||||||
|
} else {
|
||||||
|
if (angleDiff(br, centerBearing) <= spread) sector.push(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (sector.length < 3) {
|
||||||
|
// Fallback to full ring
|
||||||
|
sector = ring.slice();
|
||||||
|
}
|
||||||
|
// Pick 3 or 5 evenly spaced points from sector
|
||||||
|
var noRepeat = localStorage.getItem('freemoto-roundtrip-norepeat') === '1';
|
||||||
|
var n = noRepeat ? 5 : 3;
|
||||||
|
var step = Math.max(1, Math.floor(sector.length / (n + 1)));
|
||||||
|
var wps = [];
|
||||||
|
for (var i = 1; i <= n; i++) {
|
||||||
|
var idx = Math.min(sector.length - 1, i * step);
|
||||||
|
wps.push(sector[idx]);
|
||||||
|
}
|
||||||
|
return wps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rudimentary land/water detection using backend reverse geocoding
|
||||||
|
async function isLikelyWater(lat, lon) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/reverse?format=json&lat=${lat}&lon=${lon}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
// Heuristics: if class/type indicates water or coastline, treat as water
|
||||||
|
const cls = (data && (data.class || (data.address && data.address.natural))) || '';
|
||||||
|
const typ = (data && (data.type || (data.address && (data.address.water || data.address.waterway)))) || '';
|
||||||
|
const name = (data && data.display_name) || '';
|
||||||
|
const hay = `${cls} ${typ} ${name}`.toLowerCase();
|
||||||
|
return /\b(water|sea|bay|ocean|coast|coastline|harbor|harbour|beach|lake|fjord|inlet|river|estuary)\b/.test(hay);
|
||||||
|
} catch (_) {
|
||||||
|
// On failure, be conservative and assume not water
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adjustPointInland(p, start) {
|
||||||
|
// Try up to ~10 steps of 1km towards start to avoid placing on water
|
||||||
|
const steps = 10;
|
||||||
|
const stepMeters = 1000;
|
||||||
|
let cand = { lat: p.lat, lng: p.lng };
|
||||||
|
// Bearing from candidate to start (move inland towards start)
|
||||||
|
function bearing(from, to) {
|
||||||
|
const φ1 = toRad(from.lat), φ2 = toRad(to.lat);
|
||||||
|
const Δλ = toRad(to.lng - from.lng);
|
||||||
|
const y = Math.sin(Δλ) * Math.cos(φ2);
|
||||||
|
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
|
||||||
|
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const water = await isLikelyWater(cand.lat, cand.lng);
|
||||||
|
if (!water) return cand;
|
||||||
|
const brg = bearing(cand, start);
|
||||||
|
cand = destPoint(cand.lat, cand.lng, stepMeters, brg);
|
||||||
|
}
|
||||||
|
return cand;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoundTrip() {
|
||||||
if (!roundTripToggle || !roundTripToggle.checked) return;
|
if (!roundTripToggle || !roundTripToggle.checked) return;
|
||||||
// Determine start point: prefer first existing point, else sourceInput coords
|
// Determine start point: prefer first existing point, else sourceInput coords
|
||||||
var start = null;
|
var start = null;
|
||||||
@@ -123,8 +358,64 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (routePolyline) { map.removeLayer(routePolyline); routePolyline = null; }
|
if (routePolyline) { map.removeLayer(routePolyline); routePolyline = null; }
|
||||||
if (snapLines && snapLines.length) { snapLines.forEach(function(sl){ map.removeLayer(sl); }); snapLines = []; }
|
if (snapLines && snapLines.length) { snapLines.forEach(function(sl){ map.removeLayer(sl); }); snapLines = []; }
|
||||||
|
|
||||||
var pts = generateRoundTripWaypoints(start);
|
var useIso = !!(roundTripIsochrone && roundTripIsochrone.checked);
|
||||||
pts.forEach(function(p, idx){
|
var useScenic = !!(roundTripScenic && roundTripScenic.checked);
|
||||||
|
|
||||||
|
var pts;
|
||||||
|
if (useIso) {
|
||||||
|
// Build moto options to avoid ferries etc.
|
||||||
|
var moto = {};
|
||||||
|
if (avoidFerriesCheckbox && avoidFerriesCheckbox.checked) moto.use_ferry = 0.0;
|
||||||
|
moto.use_ferry = 0.0; // default for round trips
|
||||||
|
moto.ferry_cost = 1000;
|
||||||
|
var minutes = parseInt((isochroneMinutesInput && isochroneMinutesInput.value) || localStorage.getItem('freemoto-isochrone-minutes') || '60', 10);
|
||||||
|
var ring = await fetchIsochronePolygon(start, minutes, moto);
|
||||||
|
if (ring && ring.length) {
|
||||||
|
var center = preferredBearing();
|
||||||
|
var wps = sampleIsochroneSector(ring, start, center);
|
||||||
|
pts = [start].concat(wps).concat([start]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pts) {
|
||||||
|
pts = generateRoundTripWaypoints(start);
|
||||||
|
}
|
||||||
|
// If scenic optimizer is enabled, evaluate a few candidates and pick the twistiest
|
||||||
|
if (useScenic) {
|
||||||
|
try {
|
||||||
|
var requestOptions = { costing_options: { motorcycle: {} } };
|
||||||
|
// Avoid ferries strongly
|
||||||
|
requestOptions.costing_options.motorcycle.use_ferry = 0.0;
|
||||||
|
requestOptions.costing_options.motorcycle.ferry_cost = 1000;
|
||||||
|
|
||||||
|
var candidates = generateScenicCandidates(start);
|
||||||
|
// Replace the base candidate with our current pts at index 0
|
||||||
|
candidates[0] = pts;
|
||||||
|
var best = null;
|
||||||
|
// Evaluate sequentially to avoid hammering the backend
|
||||||
|
for (var ci = 0; ci < candidates.length; ci++) {
|
||||||
|
var cand = candidates[ci];
|
||||||
|
var result = await routeAndScore(cand, requestOptions);
|
||||||
|
if (!best || result.score > best.score) {
|
||||||
|
best = { idx: ci, score: result.score, pts: cand };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
pts = best.pts;
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('Scenic optimizer failed', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust intermediate points away from water before placing markers
|
||||||
|
var adjusted = await Promise.all(pts.map(async function(p, idx) {
|
||||||
|
if (idx === 0 || idx === pts.length - 1) return p; // keep start/end
|
||||||
|
try {
|
||||||
|
return await adjustPointInland(p, start);
|
||||||
|
} catch (_) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
adjusted.forEach(function(p, idx){
|
||||||
var marker = L.marker(p, { draggable: true }).addTo(map);
|
var marker = L.marker(p, { draggable: true }).addTo(map);
|
||||||
marker.bindPopup(idx === 0 ? 'Start/End' : `WP ${idx}`).openPopup();
|
marker.bindPopup(idx === 0 ? 'Start/End' : `WP ${idx}`).openPopup();
|
||||||
marker.on('dragend', function(ev){
|
marker.on('dragend', function(ev){
|
||||||
@@ -180,6 +471,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// For round trips, avoid ferries by default to prevent water crossings
|
||||||
|
try {
|
||||||
|
if (roundTripToggle && roundTripToggle.checked) {
|
||||||
|
moto.use_ferry = 0.0; // strongest avoidance
|
||||||
|
// Slightly increase ferry_cost as an extra safety
|
||||||
|
moto.ferry_cost = 1000;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
var costing_options = { motorcycle: moto };
|
var costing_options = { motorcycle: moto };
|
||||||
|
|
||||||
var requestBody = {
|
var requestBody = {
|
||||||
|
|||||||
@@ -14,13 +14,10 @@
|
|||||||
--shadow-2: 0 10px 30px rgba(2, 8, 20, 0.12);
|
--shadow-2: 0 10px 30px rgba(2, 8, 20, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html, body { height: 100%; }
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden; /* map-focused app; internal sections handle their own scroll */
|
||||||
background: linear-gradient(180deg, #f1f5f9, #e2e8f0);
|
background: linear-gradient(180deg, #f1f5f9, #e2e8f0);
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
@@ -66,6 +63,8 @@ body {
|
|||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
transition: max-height 200ms ease, transform 200ms ease, padding 200ms ease;
|
transition: max-height 200ms ease, transform 200ms ease, padding 200ms ease;
|
||||||
|
overflow: auto; /* allow scroll when content grows */
|
||||||
|
max-height: 60vh; /* default for mobile */
|
||||||
}
|
}
|
||||||
.nav-panel .form-control,
|
.nav-panel .form-control,
|
||||||
.nav-panel .btn {
|
.nav-panel .btn {
|
||||||
@@ -106,7 +105,7 @@ body {
|
|||||||
.sheet-card {
|
.sheet-card {
|
||||||
border-radius: var(--panel-radius) var(--panel-radius) 0 0;
|
border-radius: var(--panel-radius) var(--panel-radius) 0 0;
|
||||||
max-height: 60vh;
|
max-height: 60vh;
|
||||||
overflow: hidden;
|
overflow: hidden; /* header fixed; body scrolls */
|
||||||
box-shadow: var(--shadow-2);
|
box-shadow: var(--shadow-2);
|
||||||
}
|
}
|
||||||
.sheet .handle {
|
.sheet .handle {
|
||||||
@@ -124,10 +123,7 @@ body {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-bottom: 1px solid #eef2f6;
|
border-bottom: 1px solid #eef2f6;
|
||||||
}
|
}
|
||||||
.sheet-body {
|
.sheet-body { overflow: auto; max-height: calc(60vh - 42px); }
|
||||||
overflow: auto;
|
|
||||||
max-height: calc(60vh - 42px);
|
|
||||||
}
|
|
||||||
#directionsSheet { display: none; }
|
#directionsSheet { display: none; }
|
||||||
.sheet.collapsed .sheet-card { max-height: 28vh; }
|
.sheet.collapsed .sheet-card { max-height: 28vh; }
|
||||||
.sheet.collapsed .sheet-body { max-height: calc(28vh - 42px); }
|
.sheet.collapsed .sheet-body { max-height: calc(28vh - 42px); }
|
||||||
@@ -172,8 +168,8 @@ body {
|
|||||||
left: 8px;
|
left: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
max-height: 48vh;
|
max-height: 56vh; /* slightly taller for more controls */
|
||||||
overflow: visible; /* allow suggestion dropdowns to overflow */
|
overflow: auto; /* scroll internally */
|
||||||
}
|
}
|
||||||
.nav-panel.collapsed {
|
.nav-panel.collapsed {
|
||||||
max-height: 56px; /* show a small header */
|
max-height: 56px; /* show a small header */
|
||||||
@@ -185,16 +181,18 @@ body {
|
|||||||
.section-title { font-size: 0.95rem; }
|
.section-title { font-size: 0.95rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop layout refinements */
|
/* Desktop/tablet layout refinements */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.nav-panel {
|
.nav-panel {
|
||||||
top: 88px;
|
top: 88px;
|
||||||
bottom: auto;
|
bottom: 24px; /* allow taller panel while preserving map footer space */
|
||||||
left: 24px;
|
left: 24px;
|
||||||
right: auto;
|
right: auto;
|
||||||
width: 560px;
|
width: min(560px, 42vw);
|
||||||
border-radius: var(--panel-radius);
|
border-radius: var(--panel-radius);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
max-height: calc(100vh - 88px - 24px);
|
||||||
|
overflow: auto; /* scroll the panel content when needed */
|
||||||
}
|
}
|
||||||
.nav-panel .form-control,
|
.nav-panel .form-control,
|
||||||
.nav-panel .btn {
|
.nav-panel .btn {
|
||||||
@@ -205,6 +203,18 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make long lists scrollable inside the panel */
|
||||||
|
.nav-panel .list-group {
|
||||||
|
max-height: 34vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for WebKit browsers */
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-thumb { background-color: rgba(100, 116, 139, 0.5); border-radius: 8px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background-color: rgba(71, 85, 105, 0.7); }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
/* Dark theme */
|
/* Dark theme */
|
||||||
/* Improved dark mode for readability */
|
/* Improved dark mode for readability */
|
||||||
[data-theme="dark"] :root {
|
[data-theme="dark"] :root {
|
||||||
|
|||||||
Reference in New Issue
Block a user