Add basic routing
This commit is contained in:
parent
84b14c26cc
commit
f4d5f282f3
6 changed files with 459 additions and 75 deletions
99
db.go
99
db.go
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"math"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
|
@ -97,3 +98,101 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
|
|||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Route corridor query: find repeaters within corridorKm of a polyline.
|
||||
func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string) ([]Repeater, error) {
|
||||
if len(points) == 0 {
|
||||
return []Repeater{}, nil
|
||||
}
|
||||
|
||||
// Compute bounding box of all route points + corridor padding
|
||||
minLat, maxLat := points[0][0], points[0][0]
|
||||
minLng, maxLng := points[0][1], points[0][1]
|
||||
for _, p := range points {
|
||||
if p[0] < minLat {
|
||||
minLat = p[0]
|
||||
}
|
||||
if p[0] > maxLat {
|
||||
maxLat = p[0]
|
||||
}
|
||||
if p[1] < minLng {
|
||||
minLng = p[1]
|
||||
}
|
||||
if p[1] > maxLng {
|
||||
maxLng = p[1]
|
||||
}
|
||||
}
|
||||
latPad := corridorKm / 111.32
|
||||
avgLat := (minLat + maxLat) / 2
|
||||
lngPad := corridorKm / (111.32 * math.Cos(avgLat*math.Pi/180))
|
||||
minLat -= latPad
|
||||
maxLat += latPad
|
||||
minLng -= lngPad
|
||||
maxLng += lngPad
|
||||
|
||||
// Fetch candidates from bounding box
|
||||
candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter by distance to route segments
|
||||
var results []Repeater
|
||||
for _, r := range candidates {
|
||||
if minDistToRoute(r.Lat, r.Lng, points) <= corridorKm {
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func minDistToRoute(lat, lng float64, points [][2]float64) float64 {
|
||||
best := math.Inf(1)
|
||||
for i := 0; i < len(points)-1; i++ {
|
||||
d := distToSegmentKm(lat, lng, points[i][0], points[i][1], points[i+1][0], points[i+1][1])
|
||||
if d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
if len(points) == 1 {
|
||||
best = haversineKm(lat, lng, points[0][0], points[0][1])
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Approximate distance from point P to line segment AB in km.
|
||||
func distToSegmentKm(pLat, pLng, aLat, aLng, bLat, bLng float64) float64 {
|
||||
cosLat := math.Cos(pLat * math.Pi / 180)
|
||||
// Project to approximate planar coordinates (km)
|
||||
px := (pLng - aLng) * cosLat * 111.32
|
||||
py := (pLat - aLat) * 111.32
|
||||
bx := (bLng - aLng) * cosLat * 111.32
|
||||
by := (bLat - aLat) * 111.32
|
||||
|
||||
lenSq := bx*bx + by*by
|
||||
if lenSq == 0 {
|
||||
return math.Sqrt(px*px + py*py)
|
||||
}
|
||||
|
||||
t := (px*bx + py*by) / lenSq
|
||||
if t < 0 {
|
||||
t = 0
|
||||
}
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
|
||||
dx := px - t*bx
|
||||
dy := py - t*by
|
||||
return math.Sqrt(dx*dx + dy*dy)
|
||||
}
|
||||
|
||||
func haversineKm(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
const r = 6371.0
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
dLng := (lng2 - lng1) * math.Pi / 180
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
|
||||
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
|
||||
math.Sin(dLng/2)*math.Sin(dLng/2)
|
||||
return r * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
|
|
|||
53
handlers.go
53
handlers.go
|
|
@ -12,6 +12,12 @@ type apiResponse struct {
|
|||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type routeRequest struct {
|
||||
Points [][2]float64 `json:"points"`
|
||||
Band string `json:"band"`
|
||||
Corridor float64 `json:"corridor"`
|
||||
}
|
||||
|
||||
func handleRepeaters(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
|
@ -56,3 +62,50 @@ func handleRepeaters(db *sql.DB) http.HandlerFunc {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRouteRepeaters(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req routeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Points) < 2 {
|
||||
http.Error(w, `{"error":"at least 2 route points required"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Band == "" {
|
||||
req.Band = "all"
|
||||
}
|
||||
if req.Band != "2m" && req.Band != "70cm" && req.Band != "all" {
|
||||
http.Error(w, `{"error":"band must be 2m, 70cm, or all"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Corridor <= 0 {
|
||||
req.Corridor = 10
|
||||
}
|
||||
|
||||
repeaters, err := queryRepeatersAlongRoute(db, req.Points, req.Corridor, req.Band)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if repeaters == nil {
|
||||
repeaters = []Repeater{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(apiResponse{
|
||||
Repeaters: repeaters,
|
||||
Count: len(repeaters),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
main.go
1
main.go
|
|
@ -27,6 +27,7 @@ func main() {
|
|||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
||||
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
|
||||
mux.Handle("/", http.FileServer(http.Dir("static")))
|
||||
|
||||
log.Printf("Listening on %s", addr)
|
||||
|
|
|
|||
240
static/app.js
240
static/app.js
|
|
@ -1,22 +1,34 @@
|
|||
(function () {
|
||||
"use strict";
|
||||
|
||||
// === Map Setup ===
|
||||
var map = L.map("map").setView([52.37, 9.73], 9);
|
||||
|
||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
// === State ===
|
||||
var markerLayer = L.layerGroup().addTo(map);
|
||||
var statusBar = document.getElementById("status-bar");
|
||||
var band2m = document.getElementById("band-2m");
|
||||
var band70cm = document.getElementById("band-70cm");
|
||||
|
||||
var routeLayer = null;
|
||||
var routePoints = null; // stored [lat, lng] pairs for re-fetching on band change
|
||||
var isRouteMode = false;
|
||||
var debounceTimer = null;
|
||||
var controller = null;
|
||||
|
||||
// === DOM ===
|
||||
var band2m = document.getElementById("band-2m");
|
||||
var band70cm = document.getElementById("band-70cm");
|
||||
var countEl = document.getElementById("count");
|
||||
var fromInput = document.getElementById("route-from");
|
||||
var toInput = document.getElementById("route-to");
|
||||
var routeBtn = document.getElementById("route-btn");
|
||||
var clearBtn = document.getElementById("clear-btn");
|
||||
|
||||
clearBtn.style.display = "none";
|
||||
|
||||
// === Utilities ===
|
||||
function getSelectedBand() {
|
||||
var has2m = band2m.checked;
|
||||
var has70cm = band70cm.checked;
|
||||
|
|
@ -35,6 +47,15 @@
|
|||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function showCount(count) {
|
||||
countEl.textContent = count + " repeater" + (count !== 1 ? "s" : "");
|
||||
}
|
||||
|
||||
function showStatus(msg) {
|
||||
countEl.textContent = msg;
|
||||
}
|
||||
|
||||
// === Popup ===
|
||||
function buildPopup(r) {
|
||||
var bandClass = r.band === "2m" ? "band-2m" : "band-70cm";
|
||||
var html = '<div class="rptr-popup">';
|
||||
|
|
@ -53,8 +74,7 @@
|
|||
"<tr><td>Offset</td><td>" +
|
||||
escapeHtml(r.offset) +
|
||||
" MHz</td></tr>";
|
||||
html +=
|
||||
"<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
|
||||
html += "<tr><td>CC</td><td>" + r.color_code + "</td></tr>";
|
||||
var loc = escapeHtml(r.city);
|
||||
if (r.state) loc += ", " + escapeHtml(r.state);
|
||||
if (r.country) loc += ", " + escapeHtml(r.country);
|
||||
|
|
@ -82,10 +102,28 @@
|
|||
return html;
|
||||
}
|
||||
|
||||
function fetchRepeaters() {
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
// === Display markers ===
|
||||
function displayRepeaters(repeaters) {
|
||||
markerLayer.clearLayers();
|
||||
repeaters.forEach(function (r) {
|
||||
var color = r.band === "2m" ? "#2196F3" : "#FF9800";
|
||||
var marker = L.circleMarker([r.lat, r.lng], {
|
||||
radius: 6,
|
||||
fillColor: color,
|
||||
color: "#fff",
|
||||
weight: 1,
|
||||
fillOpacity: 0.85,
|
||||
});
|
||||
marker.bindPopup(buildPopup(r), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
// === Viewport mode ===
|
||||
function fetchRepeaters() {
|
||||
if (isRouteMode) return;
|
||||
|
||||
if (controller) controller.abort();
|
||||
controller = new AbortController();
|
||||
|
||||
var bounds = map.getBounds();
|
||||
|
|
@ -103,43 +141,171 @@
|
|||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
markerLayer.clearLayers();
|
||||
|
||||
data.repeaters.forEach(function (r) {
|
||||
var color = r.band === "2m" ? "#2196F3" : "#FF9800";
|
||||
var marker = L.circleMarker([r.lat, r.lng], {
|
||||
radius: 6,
|
||||
fillColor: color,
|
||||
color: "#fff",
|
||||
weight: 1,
|
||||
fillOpacity: 0.85,
|
||||
});
|
||||
marker.bindPopup(buildPopup(r), { maxWidth: 280 });
|
||||
markerLayer.addLayer(marker);
|
||||
});
|
||||
|
||||
displayRepeaters(data.repeaters);
|
||||
console.log("Showing " + data.count + " repeaters");
|
||||
statusBar.textContent =
|
||||
"Showing " + data.count + " repeater" + (data.count !== 1 ? "s" : "");
|
||||
statusBar.className = "";
|
||||
showCount(data.count);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (err.name === "AbortError") return;
|
||||
console.error("Fetch error:", err);
|
||||
statusBar.textContent = "Failed to load repeaters";
|
||||
statusBar.className = "";
|
||||
showStatus("Error");
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedFetch() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(fetchRepeaters, 150);
|
||||
// === Route mode ===
|
||||
function geocode(address) {
|
||||
return fetch(
|
||||
"https://nominatim.openstreetmap.org/search?" +
|
||||
new URLSearchParams({
|
||||
q: address,
|
||||
format: "json",
|
||||
limit: "1",
|
||||
})
|
||||
)
|
||||
.then(function (resp) {
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data.length) throw new Error("Not found: " + address);
|
||||
return {
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
map.on("moveend", debouncedFetch);
|
||||
band2m.addEventListener("change", fetchRepeaters);
|
||||
band70cm.addEventListener("change", fetchRepeaters);
|
||||
function getRoute(from, to) {
|
||||
return fetch(
|
||||
"https://router.project-osrm.org/route/v1/driving/" +
|
||||
from.lng + "," + from.lat + ";" +
|
||||
to.lng + "," + to.lat +
|
||||
"?overview=full&geometries=geojson"
|
||||
)
|
||||
.then(function (resp) {
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data.routes || !data.routes.length)
|
||||
throw new Error("No route found");
|
||||
// coordinates are [lng, lat], convert to [lat, lng]
|
||||
return data.routes[0].geometry.coordinates.map(function (c) {
|
||||
return [c[1], c[0]];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
function fetchRouteRepeaters() {
|
||||
if (!routePoints) return;
|
||||
|
||||
showStatus("Loading...");
|
||||
return fetch("/api/repeaters/route", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
points: routePoints,
|
||||
band: getSelectedBand(),
|
||||
corridor: 10,
|
||||
}),
|
||||
})
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
displayRepeaters(data.repeaters);
|
||||
console.log("Showing " + data.count + " repeaters along route");
|
||||
showCount(data.count);
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Route fetch error:", err);
|
||||
showStatus("Error");
|
||||
});
|
||||
}
|
||||
|
||||
function findRoute() {
|
||||
var fromAddr = fromInput.value.trim();
|
||||
var toAddr = toInput.value.trim();
|
||||
if (!fromAddr || !toAddr) return;
|
||||
|
||||
routeBtn.disabled = true;
|
||||
showStatus("Geocoding...");
|
||||
|
||||
geocode(fromAddr)
|
||||
.then(function (from) {
|
||||
return geocode(toAddr).then(function (to) {
|
||||
return { from: from, to: to };
|
||||
});
|
||||
})
|
||||
.then(function (endpoints) {
|
||||
showStatus("Routing...");
|
||||
return getRoute(endpoints.from, endpoints.to);
|
||||
})
|
||||
.then(function (latLngs) {
|
||||
// Draw route on map
|
||||
if (routeLayer) map.removeLayer(routeLayer);
|
||||
routeLayer = L.polyline(latLngs, {
|
||||
color: "#4CAF50",
|
||||
weight: 4,
|
||||
opacity: 0.8,
|
||||
}).addTo(map);
|
||||
map.fitBounds(routeLayer.getBounds(), { padding: [50, 50] });
|
||||
|
||||
// Switch to route mode
|
||||
routePoints = latLngs;
|
||||
isRouteMode = true;
|
||||
clearBtn.style.display = "";
|
||||
|
||||
return fetchRouteRepeaters();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Route error:", err);
|
||||
showStatus(err.message);
|
||||
})
|
||||
.then(function () {
|
||||
routeBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function clearRoute() {
|
||||
if (routeLayer) {
|
||||
map.removeLayer(routeLayer);
|
||||
routeLayer = null;
|
||||
}
|
||||
routePoints = null;
|
||||
isRouteMode = false;
|
||||
clearBtn.style.display = "none";
|
||||
fromInput.value = "";
|
||||
toInput.value = "";
|
||||
fetchRepeaters();
|
||||
}
|
||||
|
||||
// === Events ===
|
||||
map.on("moveend", function () {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(fetchRepeaters, 150);
|
||||
});
|
||||
|
||||
band2m.addEventListener("change", function () {
|
||||
if (isRouteMode) fetchRouteRepeaters();
|
||||
else fetchRepeaters();
|
||||
});
|
||||
|
||||
band70cm.addEventListener("change", function () {
|
||||
if (isRouteMode) fetchRouteRepeaters();
|
||||
else fetchRepeaters();
|
||||
});
|
||||
|
||||
routeBtn.addEventListener("click", findRoute);
|
||||
clearBtn.addEventListener("click", clearRoute);
|
||||
|
||||
toInput.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") findRoute();
|
||||
});
|
||||
|
||||
fromInput.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") toInput.focus();
|
||||
});
|
||||
|
||||
// === Init ===
|
||||
fetchRepeaters();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -11,10 +11,26 @@
|
|||
<body>
|
||||
<div id="map"></div>
|
||||
<div id="controls">
|
||||
<label><input type="checkbox" id="band-2m" checked> 2m</label>
|
||||
<label><input type="checkbox" id="band-70cm" checked> 70cm</label>
|
||||
<div class="controls-row">
|
||||
<label class="band-label band-2m"><input type="checkbox" id="band-2m" checked> 2m</label>
|
||||
<label class="band-label band-70cm"><input type="checkbox" id="band-70cm" checked> 70cm</label>
|
||||
<span id="count" class="count"></span>
|
||||
</div>
|
||||
<div class="controls-row route-row">
|
||||
<input type="text" id="route-from" placeholder="From address" />
|
||||
</div>
|
||||
<div class="controls-row route-row">
|
||||
<input type="text" id="route-to" placeholder="To address" />
|
||||
</div>
|
||||
<div class="controls-row route-row">
|
||||
<button id="route-btn">Route</button>
|
||||
<button id="clear-btn">Clear</button>
|
||||
</div>
|
||||
<div class="attribution">
|
||||
Data from <a href="https://radioid.net/database/dumps" target="_blank" rel="noopener">radioid.net</a>.
|
||||
Not affiliated with or endorsed by RadioID.
|
||||
</div>
|
||||
</div>
|
||||
<div id="status-bar">Loading...</div>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="app.js"></script>
|
||||
|
|
|
|||
119
static/style.css
119
static/style.css
|
|
@ -24,56 +24,113 @@ html, body {
|
|||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
#controls label {
|
||||
.controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.band-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-height: 44px;
|
||||
min-height: 36px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.band-2m {
|
||||
color: #1976D2;
|
||||
}
|
||||
|
||||
.band-70cm {
|
||||
color: #F57C00;
|
||||
}
|
||||
|
||||
#controls input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#band-2m:checked + span,
|
||||
#controls label:first-child {
|
||||
color: #1976D2;
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#controls label:last-child {
|
||||
color: #F57C00;
|
||||
.route-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 8px 16px;
|
||||
.route-row input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(4px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#status-bar.truncated {
|
||||
background: rgba(255, 193, 7, 0.9);
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
.route-row input[type="text"]:focus {
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
|
||||
.route-row button {
|
||||
flex: 1;
|
||||
padding: 7px 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
#route-btn {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#route-btn:hover {
|
||||
background: #43A047;
|
||||
}
|
||||
|
||||
#route-btn:disabled {
|
||||
background: #A5D6A7;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#clear-btn {
|
||||
background: #eee;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#clear-btn:hover {
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.attribution {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.attribution a {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Popups */
|
||||
|
||||
.rptr-popup h3 {
|
||||
margin: 0 0 6px 0;
|
||||
font-size: 15px;
|
||||
|
|
@ -120,16 +177,8 @@ html, body {
|
|||
#controls {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#controls label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue