Initial commit

This commit is contained in:
2026-06-30 18:30:24 +00:00
commit 67e0a90b15
54 changed files with 6184 additions and 0 deletions
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
from fastapi import FastAPI, HTTPException, Form, Body
from fastapi.responses import HTMLResponse
import os, json, uuid
from datetime import datetime
from typing import Optional
DATA = "/data/overlay.json"
app = FastAPI(redirect_slashes=False)
def load():
if os.path.exists(DATA):
with open(DATA) as f: return json.load(f)
return {}
def save(d):
with open(DATA, "w") as f: json.dump(d, f, indent=2)
@app.get("/api/list")
def list_features():
items = load()
return [{"id": k, **v} for k, v in items.items()]
@app.post("/api/add")
def add(payload: dict = Body(...)):
items = load()
fid = uuid.uuid4().hex[:12]
items[fid] = {
"name": payload.get("name", ""),
"category": payload.get("category", "general"),
"color": payload.get("color", "#22d3ee"),
"notes": payload.get("notes", ""),
"geometry": payload.get("geometry", {}), # GeoJSON geometry
"added": datetime.utcnow().isoformat(),
}
save(items)
return {"id": fid, **items[fid]}
@app.patch("/api/update/{fid}")
def update(fid: str, payload: dict = Body(...)):
items = load()
if fid not in items: raise HTTPException(404)
for k in ["name", "category", "color", "notes", "geometry"]:
if k in payload: items[fid][k] = payload[k]
save(items)
return {"ok": True}
@app.delete("/api/delete/{fid}")
def delete(fid: str):
items = load()
if fid not in items: raise HTTPException(404)
del items[fid]
save(items)
return {"ok": True}
@app.get("/", response_class=HTMLResponse)
def root():
with open("/static/index.html") as f:
return f.read()
File diff suppressed because it is too large Load Diff
+478
View File
@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Overlay - The Dark Elite</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/pmtiles@3.2.1/dist/pmtiles.js"></script>
<script src="https://unpkg.com/protomaps-themes-base@4.3.0/dist/protomaps-themes-base.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css">
<script src="https://unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js"></script>
<style>
:root { --bg:#060708; --panel:#0f1115; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;height:100vh;overflow:hidden}
#app{display:grid;grid-template-columns:340px 1fr;height:100vh}
#sidebar{background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
#sidebar header{padding:1rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
#sidebar header h1{margin:0;font-size:1rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent);text-shadow:0 0 12px rgba(34,211,238,0.4)}
#sidebar header a{color:var(--muted);text-decoration:none;font-size:0.75rem}
#sidebar header a:hover{color:var(--accent)}
.tool-row{display:flex;gap:0.4rem;padding:0.75rem;border-bottom:1px solid var(--border);flex-wrap:wrap}
.tool-btn{flex:1;background:rgba(18,20,24,0.9);border:1px solid var(--border);color:var(--text);padding:0.5rem;border-radius:4px;font-size:0.7rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace;min-width:60px}
.tool-btn:hover{border-color:var(--accent);color:var(--accent)}
.tool-btn.active{border-color:var(--accent);color:var(--accent);background:rgba(34,211,238,0.1)}
.filter-row{padding:0.75rem;border-bottom:1px solid var(--border);display:flex;gap:0.4rem;flex-direction:column}
.filter-row select,.filter-row input{background:rgba(10,11,13,0.6);border:1px solid var(--border);color:var(--text);padding:0.4rem 0.6rem;border-radius:4px;font-size:0.8rem;font-family:ui-monospace,monospace}
.filter-row select:focus,.filter-row input:focus{outline:none;border-color:var(--accent)}
.list{flex:1;overflow-y:auto;padding:0.5rem}
.item{background:linear-gradient(135deg,rgba(18,20,24,0.85),rgba(10,11,13,0.95));border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:4px;padding:0.6rem 0.75rem;margin-bottom:0.5rem;cursor:pointer;transition:all 0.15s}
.item:hover{border-color:rgba(34,211,238,0.5);transform:translateX(2px)}
.item .name{font-size:0.85rem;color:var(--text);font-weight:500}
.item .meta{font-size:0.65rem;color:var(--muted);font-family:ui-monospace,monospace;text-transform:uppercase;letter-spacing:0.1em;margin-top:0.2rem;display:flex;justify-content:space-between;align-items:center}
.item .del{color:var(--muted);font-size:0.6rem;cursor:pointer}
.item .del:hover{color:var(--danger)}
.empty{color:var(--muted);text-align:center;padding:1.5rem;font-style:italic;font-size:0.8rem}
#map{height:100vh}
#dialog{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--panel);border:1px solid var(--accent);border-radius:8px;padding:1.25rem;width:340px;z-index:200;display:none;box-shadow:0 10px 40px rgba(0,0,0,0.7)}
#dialog h3{margin:0 0 0.85rem 0;font-size:0.8rem;letter-spacing:0.2em;text-transform:uppercase;color:var(--accent)}
#dialog label{display:block;font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.1em;margin:0.6rem 0 0.2rem 0}
#dialog input,#dialog select,#dialog textarea{width:100%;background:rgba(10,11,13,0.7);border:1px solid var(--border);color:var(--text);padding:0.5rem 0.7rem;border-radius:4px;font-size:0.85rem;font-family:ui-monospace,monospace}
#dialog textarea{resize:vertical;min-height:60px}
#dialog .actions{display:flex;gap:0.5rem;margin-top:1rem}
#dialog .actions button{flex:1;background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.55rem;border-radius:4px;font-size:0.75rem;cursor:pointer;letter-spacing:0.1em;text-transform:uppercase;font-family:ui-monospace,monospace}
#dialog .actions button.cancel{background:transparent;border-color:var(--border);color:var(--muted)}
#dialog .actions button:hover{background:rgba(34,211,238,0.25)}
.color-row{display:flex;gap:0.4rem;margin-top:0.3rem}
.color-row .sw{width:24px;height:24px;border-radius:4px;cursor:pointer;border:2px solid transparent}
.color-row .sw.active{border-color:var(--text)}
.toast{position:fixed;bottom:1.5rem;right:1.5rem;background:rgba(18,20,24,0.95);border:1px solid var(--accent);color:var(--accent);padding:0.7rem 1.1rem;border-radius:4px;font-family:ui-monospace,monospace;font-size:0.8rem;z-index:300;display:none}
.maplibregl-ctrl-group{background:rgba(15,17,21,0.9) !important;border:1px solid var(--border) !important}
.maplibregl-ctrl-group button{background-color:transparent !important;filter:invert(85%)}
.maplibregl-ctrl-group button:hover{background-color:rgba(34,211,238,0.15) !important}
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<header>
<h1>Overlay</h1>
<a href="/">← Hub</a>
</header>
<div class="tool-row">
<button class="tool-btn" data-tool="point">+ Point</button>
<button class="tool-btn" data-tool="line">+ Line</button>
<button class="tool-btn" data-tool="polygon">+ Area</button>
<button class="tool-btn" id="printBtn" style="flex-basis:100%">Print Map</button>
</div>
<div class="filter-row">
<select id="filterCat"><option value="">All Categories</option></select>
<input type="text" id="search" placeholder="Filter...">
</div>
<div class="list" id="list"><div class="empty">Loading...</div></div>
</aside>
<div id="map"></div>
</div>
<div id="dialog">
<h3 id="dlgTitle">New Feature</h3>
<label>Name</label>
<input type="text" id="fName" placeholder="e.g. Water source #2">
<label>Category</label>
<select id="fCat">
<option>Home</option><option>Routes</option><option>Caches</option><option>Hazards</option>
<option>Resources</option><option>Water</option><option>Allies</option><option>Recon</option>
<option>Hunting</option><option>Foraging</option><option>Medical</option><option>Comms</option>
<option>general</option>
</select>
<label>Color</label>
<div class="color-row" id="colorRow">
<div class="sw" data-color="#22d3ee" style="background:#22d3ee"></div>
<div class="sw" data-color="#22c55e" style="background:#22c55e"></div>
<div class="sw" data-color="#f59e0b" style="background:#f59e0b"></div>
<div class="sw" data-color="#dc2626" style="background:#dc2626"></div>
<div class="sw" data-color="#a78bfa" style="background:#a78bfa"></div>
<div class="sw" data-color="#ec4899" style="background:#ec4899"></div>
<div class="sw" data-color="#ffffff" style="background:#ffffff"></div>
</div>
<label>Notes</label>
<textarea id="fNotes" placeholder="Optional notes..."></textarea>
<div class="actions">
<button class="cancel" id="dlgCancel">Cancel</button>
<button id="dlgSave">Save</button>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({
container: "map",
preserveDrawingBuffer: true,
style: {
version: 8,
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/dark",
sources: { protomaps: { type: "vector", url: "pmtiles:///maps/base.pmtiles" } },
layers: protomaps_themes_base.default("protomaps", "dark")
},
center: [-96.9469, 32.1268],
zoom: 10
});
map.addControl(new maplibregl.NavigationControl());
map.addControl(new maplibregl.ScaleControl());
const draw = new MapboxDraw({
displayControlsDefault: false,
styles: defaultDrawStyles()
});
map.addControl(draw);
let allFeatures = [];
let pendingFeature = null; // GeoJSON feature waiting for save dialog
let editingId = null; // id of feature being edited
let selectedColor = "#22d3ee";
function defaultDrawStyles(){
return [
{"id":"gl-draw-polygon-fill","type":"fill","filter":["all",["==","$type","Polygon"],["!=","mode","static"]],"paint":{"fill-color":"#22d3ee","fill-opacity":0.15}},
{"id":"gl-draw-polygon-stroke-active","type":"line","filter":["all",["==","$type","Polygon"]],"paint":{"line-color":"#22d3ee","line-width":2}},
{"id":"gl-draw-line-active","type":"line","filter":["==","$type","LineString"],"paint":{"line-color":"#22d3ee","line-width":3}},
{"id":"gl-draw-point","type":"circle","filter":["all",["==","$type","Point"],["==","meta","feature"]],"paint":{"circle-radius":6,"circle-color":"#22d3ee"}},
{"id":"gl-draw-vertex","type":"circle","filter":["all",["==","meta","vertex"],["==","$type","Point"]],"paint":{"circle-radius":4,"circle-color":"#fff"}}
];
}
function toast(msg){
const t = document.getElementById("toast");
t.textContent = msg; t.style.display = "block";
setTimeout(() => t.style.display = "none", 2000);
}
async function loadAll(){
const r = await fetch("/overlay/api/list");
allFeatures = await r.json();
renderFeatures();
renderList();
populateCats();
}
function renderFeatures(){
// Clear existing rendered layers
const existing = ["overlay-points","overlay-lines","overlay-polygons","overlay-polygon-stroke","overlay-labels"];
existing.forEach(id => {
if (map.getLayer(id)) map.removeLayer(id);
});
if (map.getSource("overlay-data")) map.removeSource("overlay-data");
const fc = {
type: "FeatureCollection",
features: allFeatures.map(f => ({
type: "Feature",
geometry: f.geometry,
properties: { id: f.id, name: f.name, color: f.color || "#22d3ee", category: f.category }
}))
};
map.addSource("overlay-data", { type: "geojson", data: fc });
map.addLayer({ id: "overlay-polygons", type: "fill", source: "overlay-data",
filter: ["==", "$type", "Polygon"],
paint: { "fill-color": ["get", "color"], "fill-opacity": 0.2 }
});
map.addLayer({ id: "overlay-polygon-stroke", type: "line", source: "overlay-data",
filter: ["==", "$type", "Polygon"],
paint: { "line-color": ["get", "color"], "line-width": 2 }
});
map.addLayer({ id: "overlay-lines", type: "line", source: "overlay-data",
filter: ["==", "$type", "LineString"],
paint: { "line-color": ["get", "color"], "line-width": 3 }
});
map.addLayer({ id: "overlay-points", type: "circle", source: "overlay-data",
filter: ["==", "$type", "Point"],
paint: { "circle-radius": 7, "circle-color": ["get", "color"], "circle-stroke-color": "#000", "circle-stroke-width": 1.5 }
});
map.addLayer({ id: "overlay-labels", type: "symbol", source: "overlay-data",
layout: {
"text-field": ["get", "name"],
"text-size": 11,
"text-offset": [0, 1.2],
"text-anchor": "top",
"text-font": ["Noto Sans Regular"]
},
paint: { "text-color": "#e5e7eb", "text-halo-color": "#000", "text-halo-width": 1.5 }
});
}
function renderList(){
const q = document.getElementById("search").value.toLowerCase();
const cat = document.getElementById("filterCat").value;
const filtered = allFeatures.filter(f => {
if (cat && f.category !== cat) return false;
if (q) {
const s = ((f.name||"")+" "+(f.category||"")+" "+(f.notes||"")).toLowerCase();
if (!s.includes(q)) return false;
}
return true;
});
const el = document.getElementById("list");
if (!filtered.length) {
el.innerHTML = '<div class="empty">No features. Add one with the tool buttons above.</div>';
return;
}
el.innerHTML = filtered.map(f => {
const geomType = f.geometry?.type || "?";
const icon = geomType === "Point" ? "📍" : geomType === "LineString" ? "📏" : "▱";
return '<div class="item" data-id="'+f.id+'" style="border-left-color:'+(f.color||"#22d3ee")+'">' +
'<div class="name">'+icon+' '+escapeHtml(f.name||"(unnamed)")+'</div>' +
'<div class="meta"><span>'+escapeHtml(f.category||"general")+'</span><span class="del" data-id="'+f.id+'">DEL</span></div>' +
'</div>';
}).join("");
}
function populateCats(){
const cats = [...new Set(allFeatures.map(f => f.category).filter(Boolean))].sort();
const sel = document.getElementById("filterCat");
const cur = sel.value;
sel.innerHTML = '<option value="">All Categories</option>' + cats.map(c => '<option>'+c+'</option>').join("");
sel.value = cur;
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Tool buttons activate draw modes
document.querySelectorAll(".tool-btn").forEach(b => {
b.addEventListener("click", () => {
document.querySelectorAll(".tool-btn").forEach(x => x.classList.remove("active"));
b.classList.add("active");
const t = b.dataset.tool;
if (t === "point") draw.changeMode("draw_point");
else if (t === "line") draw.changeMode("draw_line_string");
else if (t === "polygon") draw.changeMode("draw_polygon");
});
});
// When a feature finishes drawing, open the save dialog
map.on("draw.create", e => {
pendingFeature = e.features[0];
editingId = null;
showDialog("New Feature", {});
});
function showDialog(title, vals){
document.getElementById("dlgTitle").textContent = title;
document.getElementById("fName").value = vals.name || "";
document.getElementById("fCat").value = vals.category || "general";
document.getElementById("fNotes").value = vals.notes || "";
selectedColor = vals.color || "#22d3ee";
document.querySelectorAll("#colorRow .sw").forEach(s => {
s.classList.toggle("active", s.dataset.color === selectedColor);
});
document.getElementById("dialog").style.display = "block";
document.getElementById("fName").focus();
}
function closeDialog(){
document.getElementById("dialog").style.display = "none";
if (pendingFeature && !editingId) {
draw.delete(pendingFeature.id);
}
pendingFeature = null;
editingId = null;
document.querySelectorAll(".tool-btn").forEach(x => x.classList.remove("active"));
}
document.querySelectorAll("#colorRow .sw").forEach(s => {
s.addEventListener("click", () => {
selectedColor = s.dataset.color;
document.querySelectorAll("#colorRow .sw").forEach(x => x.classList.remove("active"));
s.classList.add("active");
});
});
document.getElementById("dlgCancel").addEventListener("click", closeDialog);
document.getElementById("dlgSave").addEventListener("click", async () => {
const body = {
name: document.getElementById("fName").value.trim(),
category: document.getElementById("fCat").value,
color: selectedColor,
notes: document.getElementById("fNotes").value.trim(),
geometry: pendingFeature ? pendingFeature.geometry : (editingId ? allFeatures.find(f => f.id === editingId).geometry : null)
};
if (editingId) {
await fetch("/overlay/api/update/" + editingId, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body)
});
toast("Updated");
} else {
await fetch("/overlay/api/add", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body)
});
toast("Saved");
if (pendingFeature) draw.delete(pendingFeature.id);
}
document.getElementById("dialog").style.display = "none";
pendingFeature = null;
editingId = null;
document.querySelectorAll(".tool-btn").forEach(x => x.classList.remove("active"));
await loadAll();
});
// List item clicks
document.getElementById("list").addEventListener("click", async e => {
if (e.target.classList.contains("del")) {
e.stopPropagation();
const id = e.target.dataset.id;
if (!confirm("Delete this feature?")) return;
await fetch("/overlay/api/delete/" + id, { method: "DELETE" });
toast("Deleted");
await loadAll();
return;
}
const item = e.target.closest(".item");
if (!item) return;
const id = item.dataset.id;
const f = allFeatures.find(x => x.id === id);
if (!f || !f.geometry) return;
const coords = flatCoords(f.geometry);
if (coords.length === 1) {
map.flyTo({ center: coords[0], zoom: 14 });
} else {
const bounds = coords.reduce((b, c) => b.extend(c), new maplibregl.LngLatBounds(coords[0], coords[0]));
map.fitBounds(bounds, { padding: 60, duration: 800 });
}
});
// Double-click on an item to edit metadata
document.getElementById("list").addEventListener("dblclick", e => {
const item = e.target.closest(".item");
if (!item) return;
const id = item.dataset.id;
const f = allFeatures.find(x => x.id === id);
if (!f) return;
editingId = id;
pendingFeature = null;
showDialog("Edit Feature", f);
});
// Click a feature on the map -> enter draw direct_select mode for vertex editing
map.on("click", "overlay-points", enterEditMode);
map.on("click", "overlay-lines", enterEditMode);
map.on("click", "overlay-polygons", enterEditMode);
function enterEditMode(e){
const props = e.features[0].properties;
const id = props.id;
const f = allFeatures.find(x => x.id === id);
if (!f) return;
// Load the feature into draw as a temporary editable copy
const drawId = draw.add({
type: "Feature",
properties: { _overlayId: id },
geometry: f.geometry
})[0];
draw.changeMode("direct_select", { featureId: drawId });
toast("Drag vertices to edit. Click outside when done.");
}
// When a feature is updated via direct_select, save the new geometry
map.on("draw.update", async e => {
for (const updated of e.features) {
const id = updated.properties._overlayId;
if (!id) continue;
await fetch("/overlay/api/update/" + id, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ geometry: updated.geometry })
});
}
toast("Geometry updated");
// Remove the draw layer copy and reload features
draw.deleteAll();
await loadAll();
});
// If user hits Esc or clicks elsewhere, also clean up draw layer
map.on("draw.modechange", e => {
if (e.mode === "simple_select" && draw.getAll().features.length > 0) {
// Check if any of the in-draw features are edit copies (have _overlayId) and clean them up
const feats = draw.getAll().features;
const editCopies = feats.filter(f => f.properties && f.properties._overlayId);
if (editCopies.length > 0) {
// Give the draw.update handler a tick to fire first
setTimeout(() => {
editCopies.forEach(f => {
if (draw.get(f.id)) draw.delete(f.id);
});
}, 50);
}
}
});
function flatCoords(geom){
if (geom.type === "Point") return [geom.coordinates];
if (geom.type === "LineString") return geom.coordinates;
if (geom.type === "Polygon") return geom.coordinates[0];
return [];
}
document.getElementById("search").addEventListener("input", renderList);
document.getElementById("filterCat").addEventListener("change", renderList);
map.on("load", loadAll);
document.getElementById("printBtn").addEventListener("click", () => {
// Force a render so canvas is up to date
map.triggerRepaint();
setTimeout(() => {
const canvas = map.getCanvas();
const dataUrl = canvas.toDataURL("image/png");
const center = map.getCenter();
const zoom = map.getZoom().toFixed(1);
const bounds = map.getBounds();
const date = new Date().toLocaleString();
const html = '<!DOCTYPE html><html><head><title>Map Print - ' + date + '</title>' +
'<style>' +
'@page { size: landscape; margin: 0.5in; }' +
'body { margin: 0; padding: 1rem; font-family: system-ui, sans-serif; color: #e5e7eb; background: #060708; }' +
'.header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.5rem; border-bottom: 2px solid #22d3ee; margin-bottom: 0.75rem; }' +
'.header h1 { margin: 0; font-size: 16pt; letter-spacing: 0.2em; color: #22d3ee; text-shadow: 0 0 12px rgba(34,211,238,0.4); }' +
'.header .meta { font-family: ui-monospace, monospace; font-size: 9pt; text-align: right; color: #9ca3af; }' +
'img { width: 100%; max-height: 75vh; object-fit: contain; border: 1px solid rgba(80,90,100,0.3); border-radius: 4px; }' +
'.footer { margin-top: 0.5rem; font-family: ui-monospace, monospace; font-size: 8pt; color: #9ca3af; display: flex; justify-content: space-between; }' +
'.btns { padding: 0.75rem 0; text-align: center; }' +
'.btns button { background: rgba(18,20,24,0.9); color: #22d3ee; border: 1px solid rgba(34,211,238,0.4); padding: 0.5rem 1.2rem; margin: 0 0.25rem; cursor: pointer; font-family: ui-monospace, monospace; font-size: 10pt; letter-spacing: 0.1em; text-transform: uppercase; border-radius: 4px; }' +
'@media print { .btns { display: none; } body { background: #fff !important; color: #000 !important; } .header h1 { color: #000 !important; text-shadow: none !important; } .header { border-bottom-color: #000 !important; } .header .meta, .footer { color: #555 !important; } }' +
'</style></head><body>' +
'<div class="btns"><button onclick="window.print()">Print</button><button onclick="window.close()">Close</button><a download="map-' + Date.now() + '.png" href="' + dataUrl + '"><button>Save PNG</button></a></div>' +
'<div class="header">' +
'<h1>THE DARK ELITE // MAP</h1>' +
'<div class="meta">' + date + '<br>Zoom: ' + zoom + ' &middot; Center: ' + center.lng.toFixed(4) + ", " + center.lat.toFixed(4) + '</div>' +
'</div>' +
'<img src="' + dataUrl + '">' +
'<div class="footer">' +
'<span>SW: ' + bounds.getSouthWest().lng.toFixed(4) + ", " + bounds.getSouthWest().lat.toFixed(4) + '</span>' +
'<span>NE: ' + bounds.getNorthEast().lng.toFixed(4) + ", " + bounds.getNorthEast().lat.toFixed(4) + '</span>' +
'</div>' +
'</body></html>';
const w = window.open("", "_blank");
w.document.write(html);
w.document.close();
}, 200);
});
</script>
</body>
</html>