Initial commit
This commit is contained in:
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 + ' · 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>
|
||||
Reference in New Issue
Block a user