Add NASA GIBS satellite basemap world map tab (Leaflet)
Some checks failed
build-and-deploy / build (push) Failing after 5s
Some checks failed
build-and-deploy / build (push) Failing after 5s
Interactive world map panel for the dashboard: - Vendored Leaflet 1.9.4 under app/static/vendor/leaflet/ (served via new /static mount on the FastAPI app) - New Map tab: GIBS WMTS raster basemap (BlueMarble_ShadedRelief_Bathymetry, VIIRS/MODIS/Aqua CorrectedReflectance_TrueColor, VIIRS_DayNightBand), layer picker + UTC date selector (Latest/-7d/-30d quick picks) - /api/map/layers: curated GIBS catalog (tms, format, has_time, max_zoom) - /api/map/times: per-layer date windows from GIBS Domains XML (6h cache, graceful 502 on GIBS hiccup; static layers 422) - Fire hotspots (existing /api/fires) + open cameras (/api/cameras) as toggleable bbox-scoped overlays; dark UI matching dashboard theme
This commit is contained in:
parent
91f436390b
commit
3aa6f265bb
10 changed files with 1142 additions and 2 deletions
157
app/gibs_map.py
Normal file
157
app/gibs_map.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""NASA GIBS basemap catalog + time-domain helper for the OSINT map tab.
|
||||||
|
|
||||||
|
GIBS (Global Imagery Browse Services) is what worldview.earthdata.nasa.gov
|
||||||
|
renders from. Tiles are plain XYZ/WebMercator WMTS rasters, CORS-open
|
||||||
|
(access-control-allow-origin: *), so the browser can pull them directly —
|
||||||
|
no tile proxy needed.
|
||||||
|
|
||||||
|
Layer metadata is curated here (identifier, display title, tile matrix set,
|
||||||
|
format, whether it has a Time dimension). Available time windows per layer
|
||||||
|
come from GIBS' per-layer Domains XML endpoint (tiny), fetched on demand and
|
||||||
|
cached for a few hours.
|
||||||
|
|
||||||
|
Tile URL template (RESTful WMTS):
|
||||||
|
https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/{layer}/default/{time}/{tms}/{z}/{y}/{x}.{ext}
|
||||||
|
The {time} path segment is omitted entirely for static layers (no Time dim).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
GIBS_BASE = "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best"
|
||||||
|
GIBS_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
# ── Curated basemap catalog ────────────────────────────────────────────────
|
||||||
|
# has_time: True → tile URL includes /default/{time}/; the /api/map/times
|
||||||
|
# endpoint returns the valid date windows. False → static basemap.
|
||||||
|
MAP_LAYERS = [
|
||||||
|
{
|
||||||
|
"id": "BlueMarble_ShadedRelief_Bathymetry",
|
||||||
|
"title": "Blue Marble (shaded relief + bathymetry)",
|
||||||
|
"subtitle": "Static MODIS composite basemap",
|
||||||
|
"tms": "GoogleMapsCompatible_Level8",
|
||||||
|
"format": "jpeg",
|
||||||
|
"has_time": False,
|
||||||
|
"max_zoom": 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "VIIRS_SNPP_CorrectedReflectance_TrueColor",
|
||||||
|
"title": "VIIRS S-NPP True Color (daily)",
|
||||||
|
"subtitle": "Suomi NPP corrected reflectance",
|
||||||
|
"tms": "GoogleMapsCompatible_Level9",
|
||||||
|
"format": "jpeg",
|
||||||
|
"has_time": True,
|
||||||
|
"max_zoom": 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MODIS_Terra_CorrectedReflectance_TrueColor",
|
||||||
|
"title": "MODIS Terra True Color (daily)",
|
||||||
|
"subtitle": "Terra corrected reflectance",
|
||||||
|
"tms": "GoogleMapsCompatible_Level9",
|
||||||
|
"format": "jpeg",
|
||||||
|
"has_time": True,
|
||||||
|
"max_zoom": 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "MODIS_Aqua_CorrectedReflectance_TrueColor",
|
||||||
|
"title": "MODIS Aqua True Color (daily)",
|
||||||
|
"subtitle": "Aqua corrected reflectance",
|
||||||
|
"tms": "GoogleMapsCompatible_Level9",
|
||||||
|
"format": "jpeg",
|
||||||
|
"has_time": True,
|
||||||
|
"max_zoom": 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "VIIRS_SNPP_DayNightBand_ENCC",
|
||||||
|
"title": "VIIRS Night Lights (DNB)",
|
||||||
|
"subtitle": "Earth at night, enhanced near-constant contrast",
|
||||||
|
"tms": "GoogleMapsCompatible_Level8",
|
||||||
|
"format": "png",
|
||||||
|
"has_time": True,
|
||||||
|
"max_zoom": 8,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_LAYER_BY_ID = {l["id"]: l for l in MAP_LAYERS}
|
||||||
|
|
||||||
|
# ── Time-domain cache (per layer) ──────────────────────────────────────────
|
||||||
|
# GIBS Domains XML gives a comma-joined list of ISO ranges: START/END/PERIOD
|
||||||
|
_DOMAIN_CACHE: dict[str, tuple[float, dict]] = {}
|
||||||
|
_DOMAIN_TTL = 6 * 3600 # 6h
|
||||||
|
_HTTPS_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
# e.g. 2026-07-16/2026-08-24/P1D
|
||||||
|
_TIME_RANGE_RE = re.compile(
|
||||||
|
r"^(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})/(P\d+D|P\d+M|PT\d+H)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time_domain(domain: str) -> dict:
|
||||||
|
"""Expand a Domains XML <Domain> string into usable date metadata.
|
||||||
|
|
||||||
|
Returns {min, max, latest, ranges:[{start,end}]} where ranges keep the
|
||||||
|
raw GIBS windows so the frontend can warn when a picked date is outside
|
||||||
|
every window (GIBS still serves nearest-time tiles, but this is honest).
|
||||||
|
"""
|
||||||
|
windows = []
|
||||||
|
min_d, max_d = None, None
|
||||||
|
for part in domain.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
m = _TIME_RANGE_RE.match(part)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
start = date.fromisoformat(m.group(1))
|
||||||
|
end = date.fromisoformat(m.group(2))
|
||||||
|
windows.append({"start": start.isoformat(), "end": end.isoformat()})
|
||||||
|
if min_d is None or start < min_d:
|
||||||
|
min_d = start
|
||||||
|
if max_d is None or end > max_d:
|
||||||
|
max_d = end
|
||||||
|
return {
|
||||||
|
"min": min_d.isoformat() if min_d else None,
|
||||||
|
"max": max_d.isoformat() if max_d else None,
|
||||||
|
"latest": max_d.isoformat() if max_d else None,
|
||||||
|
"ranges": windows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_layer_domain(layer_id: str, tms: str) -> dict:
|
||||||
|
"""Return parsed time domain for a layer, cached for _DOMAIN_TTL."""
|
||||||
|
now = time.time()
|
||||||
|
hit = _DOMAIN_CACHE.get(layer_id)
|
||||||
|
if hit and now - hit[0] < _DOMAIN_TTL:
|
||||||
|
return hit[1]
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{GIBS_BASE}/1.0.0/{layer_id}/default/{tms}/all/all.xml"
|
||||||
|
)
|
||||||
|
# One in-flight request per layer; serialize so a burst of layer switches
|
||||||
|
# doesn't fan out parallel GIBS hits.
|
||||||
|
async with _HTTPS_LOCK:
|
||||||
|
hit = _DOMAIN_CACHE.get(layer_id)
|
||||||
|
if hit and time.time() - hit[0] < _DOMAIN_TTL:
|
||||||
|
return hit[1]
|
||||||
|
async with httpx.AsyncClient(timeout=GIBS_TIMEOUT) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
m = re.search(r"<Domain>([^<]+)</Domain>", resp.text)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"No time <Domain> in GIBS response for {layer_id}")
|
||||||
|
parsed = _parse_time_domain(m.group(1))
|
||||||
|
_DOMAIN_CACHE[layer_id] = (time.time(), parsed)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def default_time_for(layer_id: str) -> str | None:
|
||||||
|
"""Most recent data date for a time-aware layer (best effort, from cache)."""
|
||||||
|
hit = _DOMAIN_CACHE.get(layer_id)
|
||||||
|
if not hit:
|
||||||
|
return None
|
||||||
|
latest = hit[1].get("latest")
|
||||||
|
return latest or None
|
||||||
47
app/main.py
47
app/main.py
|
|
@ -21,6 +21,7 @@ from uuid import UUID
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy import and_, func, select, text
|
from sqlalchemy import and_, func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -841,6 +842,52 @@ async def index():
|
||||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── NASA GIBS basemap map tab ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/map/layers")
|
||||||
|
async def map_layers():
|
||||||
|
"""Curated NASA GIBS raster basemap layers for the map tab.
|
||||||
|
|
||||||
|
Each entry has everything the browser needs to render the WMTS tiles:
|
||||||
|
id — GIBS layer identifier (used in the tile URL path)
|
||||||
|
title — human-readable display name
|
||||||
|
tms — GIBS tile matrix set (GoogleMapsCompatible_LevelN)
|
||||||
|
format — tile image extension (jpeg|png)
|
||||||
|
has_time — whether the layer has a Time dimension (=> date selector)
|
||||||
|
max_zoom — highest native zoom served by that tile matrix set
|
||||||
|
"""
|
||||||
|
from gibs_map import MAP_LAYERS
|
||||||
|
return {"layers": MAP_LAYERS}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/map/times")
|
||||||
|
async def map_layer_times(
|
||||||
|
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"),
|
||||||
|
):
|
||||||
|
"""Available date windows for a time-aware GIBS layer.
|
||||||
|
|
||||||
|
Parsed from the layer's Domains XML (GIBS serves nearest-time tiles even
|
||||||
|
for dates slightly outside a window, but we surface the real ranges so
|
||||||
|
the UI can clamp the picker and flag out-of-range picks).
|
||||||
|
"""
|
||||||
|
from gibs_map import _LAYER_BY_ID, fetch_layer_domain
|
||||||
|
|
||||||
|
meta = _LAYER_BY_ID.get(layer)
|
||||||
|
if not meta:
|
||||||
|
raise HTTPException(404, f"Unknown GIBS layer: {layer}")
|
||||||
|
if not meta["has_time"]:
|
||||||
|
raise HTTPException(422, f"Layer '{layer}' is static (no Time dimension)")
|
||||||
|
try:
|
||||||
|
domain = await fetch_layer_domain(meta["id"], meta["tms"])
|
||||||
|
except Exception as exc: # network / GIBS hiccup → degrade gracefully
|
||||||
|
logger.warning("gibs_domain_fetch_failed", layer=layer, error=str(exc))
|
||||||
|
raise HTTPException(502, f"GIBS time domain unavailable: {exc}")
|
||||||
|
return {"layer": layer, **domain}
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>OSINT Dashboard</title>
|
<title>OSINT Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
|
||||||
<style>
|
<style>
|
||||||
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
|
:root { --bg: #0f172a; --surface: #1e293b; --border: #334155; --text: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --green: #4ade80; --red: #f87171; --yellow: #fbbf24; }
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
@ -60,6 +61,30 @@
|
||||||
.key-msg { margin-top: 0.5rem; font-size: 0.8rem; min-height: 1rem; }
|
.key-msg { margin-top: 0.5rem; font-size: 0.8rem; min-height: 1rem; }
|
||||||
.key-msg.ok { color: var(--green); }
|
.key-msg.ok { color: var(--green); }
|
||||||
.key-msg.err { color: var(--red); }
|
.key-msg.err { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Map tab ─────────────────────────────────────────────── */
|
||||||
|
.map-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 0.9rem; }
|
||||||
|
.map-toolbar label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||||
|
.map-toolbar select, .map-toolbar input[type="date"], .map-toolbar input[type="datetime-local"] {
|
||||||
|
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 0.5rem 0.6rem; font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color-scheme: dark;
|
||||||
|
}
|
||||||
|
.map-toolbar select:focus, .map-toolbar input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 8px rgba(56,189,248,0.35); }
|
||||||
|
.map-toolbar .btn { white-space: nowrap; }
|
||||||
|
.map-toolbar .btn.active { background: var(--accent); color: #0f172a; }
|
||||||
|
.map-hint { font-size: 0.78rem; color: var(--muted); align-self: center; flex: 1 1 100%; margin-top: -0.2rem; }
|
||||||
|
#map { width: 100%; height: 70vh; min-height: 420px; border-radius: 10px; border: 1px solid var(--border); z-index: 0; background: #0b1220; }
|
||||||
|
.leaflet-container { background: #0b1220; font-family: inherit; }
|
||||||
|
.leaflet-control-zoom a { background: var(--surface) !important; color: var(--text) !important; border-color: var(--border) !important; }
|
||||||
|
.leaflet-control-zoom a:hover { background: #273449 !important; }
|
||||||
|
.leaflet-control-attribution { background: rgba(15,23,42,0.85) !important; color: var(--muted) !important; font-size: 10px !important; }
|
||||||
|
.leaflet-control-attribution a { color: var(--accent) !important; }
|
||||||
|
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--surface) !important; color: var(--text) !important; border: 1px solid var(--border); }
|
||||||
|
.leaflet-popup-content-wrapper a { color: var(--accent); }
|
||||||
|
.leaflet-popup-content { font-size: 0.82rem; }
|
||||||
|
.leaflet-popup-close-button { color: var(--muted) !important; }
|
||||||
|
.leaflet-overlay-pane svg { filter: drop-shadow(0 0 3px rgba(0,0,0,0.6)); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -106,6 +131,7 @@
|
||||||
<button class="btn" onclick="showTab('entities')">Entities</button>
|
<button class="btn" onclick="showTab('entities')">Entities</button>
|
||||||
<button class="btn" onclick="showTab('ingest')">Ingest</button>
|
<button class="btn" onclick="showTab('ingest')">Ingest</button>
|
||||||
<button class="btn" onclick="showTab('keys')">Keys</button>
|
<button class="btn" onclick="showTab('keys')">Keys</button>
|
||||||
|
<button class="btn" onclick="showTab('map')">Map</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Events -->
|
<!-- Recent Events -->
|
||||||
|
|
@ -172,6 +198,28 @@
|
||||||
<div class="key-grid" id="keys-grid"></div>
|
<div class="key-grid" id="keys-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Map (NASA GIBS satellite basemap) -->
|
||||||
|
<div class="section" id="tab-map" style="display:none">
|
||||||
|
<h2>Global Satellite Map</h2>
|
||||||
|
<p class="sub" style="margin-bottom:0.75rem">NASA GIBS satellite basemap (the same tiles worldview.earthdata.nasa.gov renders). Pick a layer and date, then zoom/pan — fires and open cameras can be overlaid.</p>
|
||||||
|
<div class="map-toolbar">
|
||||||
|
<label>Layer
|
||||||
|
<select id="map-layer" onchange="mapLayerChanged()"></select>
|
||||||
|
</label>
|
||||||
|
<label id="map-time-label" style="display:none">Date / Time (UTC)
|
||||||
|
<input type="date" id="map-date" onchange="mapDateChanged()">
|
||||||
|
</label>
|
||||||
|
<button class="btn" id="map-latest-btn" onclick="mapGoLatest()" style="display:none">Latest</button>
|
||||||
|
<button class="btn" id="map-7d-btn" onclick="mapGoDays(7)" style="display:none">-7d</button>
|
||||||
|
<button class="btn" id="map-30d-btn" onclick="mapGoDays(30)" style="display:none">-30d</button>
|
||||||
|
<button class="btn" id="map-fires-toggle" onclick="mapToggleFires()">● Fires</button>
|
||||||
|
<button class="btn" id="map-cams-toggle" onclick="mapToggleCams()">◉ Cameras</button>
|
||||||
|
<button class="btn" id="map-reset" onclick="mapResetView()">Reset view</button>
|
||||||
|
<div class="map-hint" id="map-hint"></div>
|
||||||
|
</div>
|
||||||
|
<div id="map"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
<div class="section" id="search-results" style="display:none">
|
<div class="section" id="search-results" style="display:none">
|
||||||
<h2>Search Results (<span id="search-total">0</span>)</h2>
|
<h2>Search Results (<span id="search-total">0</span>)</h2>
|
||||||
|
|
@ -182,6 +230,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/vendor/leaflet/leaflet.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const API = '';
|
const API = '';
|
||||||
|
|
||||||
|
|
@ -343,15 +392,16 @@ async function clearKey(name) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(name) {
|
function showTab(name) {
|
||||||
['recent','alerts','entities','ingest','keys'].forEach(t => {
|
['recent','alerts','entities','ingest','keys','map'].forEach(t => {
|
||||||
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
|
document.getElementById('tab-'+t).style.display = t===name?'block':'none';
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
|
document.querySelectorAll('.tab-bar .btn').forEach((b,i) => {
|
||||||
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys'][i]===name);
|
b.classList.toggle('active', ['recent','alerts','entities','ingest','keys','map'][i]===name);
|
||||||
});
|
});
|
||||||
if (name==='alerts') loadAlerts();
|
if (name==='alerts') loadAlerts();
|
||||||
if (name==='entities') loadEntities();
|
if (name==='entities') loadEntities();
|
||||||
if (name==='keys') loadKeys();
|
if (name==='keys') loadKeys();
|
||||||
|
if (name==='map') initMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ingestRSS() {
|
async function ingestRSS() {
|
||||||
|
|
@ -395,6 +445,225 @@ async function checkHealth() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Map (NASA GIBS satellite basemap) ────────────────────────────────────
|
||||||
|
let map = null, mapLayer = null, mapInitStarted = false;
|
||||||
|
let firesGroup = null, camsGroup = null, firesOn = false, camsOn = false;
|
||||||
|
let mapLayers = []; // catalog from /api/map/layers
|
||||||
|
let mapDomain = null; // time windows for current layer
|
||||||
|
let mapLatest = null; // ISO date of most recent imagery
|
||||||
|
let mapAttributionAdded = null; // last attribution string added to control
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
if (mapInitStarted) { if (map) setTimeout(() => map.invalidateSize(), 60); return; }
|
||||||
|
mapInitStarted = true;
|
||||||
|
const hint = document.getElementById('map-hint');
|
||||||
|
hint.textContent = 'Loading layer catalog…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/map/layers`);
|
||||||
|
const d = await r.json();
|
||||||
|
mapLayers = d.layers || [];
|
||||||
|
const sel = document.getElementById('map-layer');
|
||||||
|
sel.innerHTML = mapLayers.map(l =>
|
||||||
|
`<option value="${l.id}">${l.title}</option>`).join('');
|
||||||
|
// default to the first time-aware layer (more interesting than static basemap)
|
||||||
|
const preferred = mapLayers.find(l => l.has_time) || mapLayers[0];
|
||||||
|
if (preferred) sel.value = preferred.id;
|
||||||
|
map = L.map('map', {
|
||||||
|
center: [25, 10], zoom: 2,
|
||||||
|
zoomControl: true,
|
||||||
|
worldCopyJump: true,
|
||||||
|
minZoom: 1,
|
||||||
|
maxZoom: 12,
|
||||||
|
attributionControl: true,
|
||||||
|
});
|
||||||
|
map.attributionControl.setPrefix('');
|
||||||
|
map.on('moveend', () => { if (firesOn) loadFires(); if (camsOn) loadCams(); });
|
||||||
|
// default selection = first layer
|
||||||
|
await mapLayerChanged();
|
||||||
|
} catch(e) {
|
||||||
|
hint.textContent = `Failed to load map layers: ${e.message || e}`;
|
||||||
|
console.error('Map init failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMapAttribution(text) {
|
||||||
|
if (!map) return;
|
||||||
|
if (mapAttributionAdded) map.attributionControl.removeAttribution(mapAttributionAdded);
|
||||||
|
mapAttributionAdded = text;
|
||||||
|
if (text) map.attributionControl.addAttribution(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileUrlFor(layerMeta, time) {
|
||||||
|
// RESTful WMTS template: .../best/{layer}/default[/{time}]/{tms}/{z}/{y}/{x}.{fmt}
|
||||||
|
const base = `https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/${layerMeta.id}/default`;
|
||||||
|
const timePart = layerMeta.has_time ? `/${time || 'default'}` : '';
|
||||||
|
return `${base}${timePart}/${layerMeta.tms}/{z}/{y}/{x}.${layerMeta.format}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapLayerChanged() {
|
||||||
|
const hint = document.getElementById('map-hint');
|
||||||
|
const sel = document.getElementById('map-layer');
|
||||||
|
const meta = mapLayers.find(l => l.id === sel.value);
|
||||||
|
if (!meta) return;
|
||||||
|
|
||||||
|
// Show/hide time controls depending on whether layer has a Time dimension
|
||||||
|
const hasTime = !!meta.has_time;
|
||||||
|
document.getElementById('map-time-label').style.display = hasTime ? 'flex' : 'none';
|
||||||
|
document.getElementById('map-latest-btn').style.display = hasTime ? '' : 'none';
|
||||||
|
document.getElementById('map-7d-btn').style.display = hasTime ? '' : 'none';
|
||||||
|
document.getElementById('map-30d-btn').style.display = hasTime ? '' : 'none';
|
||||||
|
|
||||||
|
let time = null;
|
||||||
|
if (hasTime) {
|
||||||
|
hint.textContent = 'Loading available dates…';
|
||||||
|
const input = document.getElementById('map-date');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/map/times?layer=${encodeURIComponent(meta.id)}`);
|
||||||
|
mapDomain = await r.json();
|
||||||
|
mapLatest = mapDomain.latest;
|
||||||
|
if (mapDomain.min && mapDomain.max) {
|
||||||
|
input.min = mapDomain.min;
|
||||||
|
input.max = mapDomain.max;
|
||||||
|
}
|
||||||
|
input.value = mapLatest || mapDomain.max || '';
|
||||||
|
time = input.value;
|
||||||
|
} catch(e) {
|
||||||
|
// fall back to today if the times endpoint is unreachable
|
||||||
|
mapDomain = null;
|
||||||
|
const today = new Date().toISOString().slice(0,10);
|
||||||
|
input.value = today;
|
||||||
|
time = today;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapDomain = null;
|
||||||
|
mapLatest = null;
|
||||||
|
time = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = tileUrlFor(meta, time);
|
||||||
|
const attribution = `NASA GIBS / EOSDIS · <a href="https://earthdata.nasa.gov/gibs" target="_blank" rel="noopener">${meta.id}</a>`;
|
||||||
|
if (mapLayer) {
|
||||||
|
mapLayer.setUrl(url);
|
||||||
|
mapLayer.options.maxNativeZoom = meta.max_zoom;
|
||||||
|
} else {
|
||||||
|
mapLayer = L.tileLayer(url, {
|
||||||
|
maxZoom: 12,
|
||||||
|
maxNativeZoom: meta.max_zoom,
|
||||||
|
attribution: '',
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
setMapAttribution(attribution);
|
||||||
|
hint.textContent = hasTime
|
||||||
|
? `${meta.subtitle || meta.title} · tile date ${time || 'default'} UTC`
|
||||||
|
: `${meta.subtitle || meta.title} · static basemap`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDateChanged() {
|
||||||
|
const meta = mapLayers.find(l => l.id === document.getElementById('map-layer').value);
|
||||||
|
if (!meta || !meta.has_time) return;
|
||||||
|
const time = document.getElementById('map-date').value;
|
||||||
|
if (!time) return;
|
||||||
|
mapLayer.setUrl(tileUrlFor(meta, time));
|
||||||
|
document.getElementById('map-hint').textContent =
|
||||||
|
`${meta.subtitle || meta.title} · tile date ${time} UTC`;
|
||||||
|
if (mapDomain && mapDomain.ranges && mapDomain.ranges.length) {
|
||||||
|
const inRange = mapDomain.ranges.some(r => time >= r.start && time <= r.end);
|
||||||
|
if (!inRange) {
|
||||||
|
document.getElementById('map-hint').textContent += ' ⚠ outside published windows (GIBS serves nearest-time)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGoLatest() { document.getElementById('map-date').value = mapLatest || ''; mapDateChanged(); }
|
||||||
|
function mapGoDays(n) {
|
||||||
|
const base = mapLatest || document.getElementById('map-date').value;
|
||||||
|
if (!base) return;
|
||||||
|
const dt = new Date(base + 'T00:00:00Z');
|
||||||
|
dt.setUTCDate(dt.getUTCDate() - n);
|
||||||
|
const iso = dt.toISOString().slice(0,10);
|
||||||
|
document.getElementById('map-date').value = iso;
|
||||||
|
mapDateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapResetView() { if (map) map.setView([25, 10], 2); }
|
||||||
|
|
||||||
|
function currentBBox() {
|
||||||
|
const b = map.getBounds();
|
||||||
|
return `${b.getWest().toFixed(4)},${b.getSouth().toFixed(4)},${b.getEast().toFixed(4)},${b.getNorth().toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapToggleFires() {
|
||||||
|
firesOn = !firesOn;
|
||||||
|
document.getElementById('map-fires-toggle').classList.toggle('active', firesOn);
|
||||||
|
if (firesOn) await loadFires();
|
||||||
|
else if (firesGroup) { map.removeLayer(firesGroup); firesGroup = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFires() {
|
||||||
|
if (!map) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/fires?bbox=${currentBBox()}&limit=2000`);
|
||||||
|
const fires = await r.json();
|
||||||
|
if (firesGroup) map.removeLayer(firesGroup);
|
||||||
|
firesGroup = L.layerGroup(fires.map(f => {
|
||||||
|
const color = f.confidence === 'h' ? '#f87171'
|
||||||
|
: f.confidence === 'l' ? '#fbbf24' : '#fb923c';
|
||||||
|
return L.circleMarker([f.latitude, f.longitude], {
|
||||||
|
radius: Math.min(6, 2 + (f.brightness || 300) / 100),
|
||||||
|
color: color, weight: 1, fillColor: color, fillOpacity: 0.6,
|
||||||
|
}).bindPopup(
|
||||||
|
`<b>Fire hotspot</b><br>brightness: ${(f.brightness||0).toFixed(1)}K` +
|
||||||
|
`<br>FRP: ${(f.frp||0).toFixed(1)} MW<br>confidence: ${f.confidence}` +
|
||||||
|
`<br>sat: ${f.satellite} ${f.instrument || ''}<br>acquired: ${f.acq_time}`
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
firesGroup.addTo(map);
|
||||||
|
document.getElementById('map-hint').textContent =
|
||||||
|
`${fires.length} fire hotspots in view`;
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`;
|
||||||
|
console.error('Fires load failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapToggleCams() {
|
||||||
|
camsOn = !camsOn;
|
||||||
|
document.getElementById('map-cams-toggle').classList.toggle('active', camsOn);
|
||||||
|
if (camsOn) await loadCams();
|
||||||
|
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCams() {
|
||||||
|
if (!map) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=500`);
|
||||||
|
const cams = await r.json();
|
||||||
|
if (camsGroup) map.removeLayer(camsGroup);
|
||||||
|
camsGroup = L.layerGroup(cams.map(c => {
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: '<span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:#38bdf8;box-shadow:0 0 8px #38bdf8;border:1px solid #0f172a;"></span>',
|
||||||
|
iconSize: [11, 11], iconAnchor: [5, 5],
|
||||||
|
});
|
||||||
|
const camLink = c.snapshot_url
|
||||||
|
? `<br><a href="/api/cameras/${c.id}/snapshot" target="_blank">snapshot</a>`
|
||||||
|
: '';
|
||||||
|
return L.marker([c.lat, c.lon], { icon })
|
||||||
|
.bindPopup(`<b>${c.location_name || 'Open camera'}</b>` +
|
||||||
|
`<br>vendor: ${c.vendor || '?'} ${c.device_type || ''}` +
|
||||||
|
`<br>src: ${c.discovery_source || '?'}` +
|
||||||
|
(c.source_url ? `<br><a href="${c.source_url}" target="_blank" rel="noopener">source</a>` : '') +
|
||||||
|
camLink);
|
||||||
|
}));
|
||||||
|
camsGroup.addTo(map);
|
||||||
|
document.getElementById('map-hint').textContent =
|
||||||
|
`${cams.length} open cameras in view`;
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
|
||||||
|
console.error('Cameras load failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadSummary(); loadEvents(); checkHealth();
|
loadSummary(); loadEvents(); checkHealth();
|
||||||
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
|
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
|
||||||
|
|
|
||||||
BIN
app/static/vendor/leaflet/images/layers-2x.png
vendored
Normal file
BIN
app/static/vendor/leaflet/images/layers-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/static/vendor/leaflet/images/layers.png
vendored
Normal file
BIN
app/static/vendor/leaflet/images/layers.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
app/static/vendor/leaflet/images/marker-icon-2x.png
vendored
Normal file
BIN
app/static/vendor/leaflet/images/marker-icon-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
app/static/vendor/leaflet/images/marker-icon.png
vendored
Normal file
BIN
app/static/vendor/leaflet/images/marker-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/static/vendor/leaflet/images/marker-shadow.png
vendored
Normal file
BIN
app/static/vendor/leaflet/images/marker-shadow.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
661
app/static/vendor/leaflet/leaflet.css
vendored
Normal file
661
app/static/vendor/leaflet/leaflet.css
vendored
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/static/vendor/leaflet/leaflet.js
vendored
Normal file
6
app/static/vendor/leaflet/leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue