mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-03 10:33:43 +00:00
303 lines
12 KiB
JavaScript
303 lines
12 KiB
JavaScript
// regionmap.js — Leaflet + Geoman map for org config regions.
|
|
//
|
|
// A region's geofence is a GeoJSON Polygon/MultiPolygon geometry; no geometry means
|
|
// the region is a draft that applies nowhere until an area is drawn. Two entry
|
|
// points:
|
|
//
|
|
// initRegionArea(mapId, opts) — the admin area workspace: draw/edit exactly one
|
|
// region's polygon, with the org's other regions outlined as read-only context.
|
|
// regionMapView(mapId, opts) — read-only: a location picker (with an optional
|
|
// previewed location marker).
|
|
(function () {
|
|
// Colors come from the server (web.RegionPalette) so a legend swatch and the
|
|
// polygon it labels can never drift apart. FALLBACK covers a caller that passes
|
|
// none.
|
|
var FALLBACK = "#4dabf7";
|
|
|
|
// Breathing room when framing a map. FIT_PADDING is in screen pixels, so it stays
|
|
// a consistent visual margin no matter how large the area is — a geographic pad
|
|
// would scale with the region and zoom a big one right back out. PRIMARY_CONTEXT
|
|
// is the small geographic nudge that shows a little of what surrounds the primary
|
|
// region; keep it low, because fitBounds then rounds *down* to an integer zoom and
|
|
// any slack here gets magnified by that rounding.
|
|
var FIT_PADDING = [24, 24];
|
|
var PRIMARY_CONTEXT = 0.08;
|
|
|
|
// baseLayers adds the dark (default) and a light basemap with a layers control to
|
|
// toggle between them, remembering the choice in localStorage (shared with
|
|
// meshmap.js's meshBaseLayers, so the preference carries across maps). Left
|
|
// expanded — Leaflet's collapsed toggle needs an icon asset we don't bundle.
|
|
function baseLayers(map) {
|
|
var attribution = "© OpenStreetMap © CARTO";
|
|
var dark = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
|
|
maxZoom: 19,
|
|
subdomains: "abcd",
|
|
attribution: attribution,
|
|
});
|
|
var light = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
|
|
maxZoom: 19,
|
|
subdomains: "abcd",
|
|
attribution: attribution,
|
|
});
|
|
var pref = null;
|
|
try {
|
|
pref = localStorage.getItem("mt_map_base");
|
|
} catch (e) {
|
|
/* storage unavailable */
|
|
}
|
|
(pref === "light" ? light : dark).addTo(map);
|
|
L.control.layers({ Dark: dark, Light: light }, null, { position: "topright", collapsed: false }).addTo(map);
|
|
map.on("baselayerchange", function (e) {
|
|
try {
|
|
localStorage.setItem("mt_map_base", e.name === "Light" ? "light" : "dark");
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
});
|
|
}
|
|
|
|
// editableStyle is the one shape the current page can modify: drawn prominently so
|
|
// it always reads which polygon the tools will act on.
|
|
function editableStyle(color) {
|
|
var c = color || FALLBACK;
|
|
return { color: c, weight: 3, opacity: 1, fillColor: c, fillOpacity: 0.25 };
|
|
}
|
|
|
|
// Region fills stack: nested regions overlap, so N of them at opacity a cover
|
|
// 1-(1-a)^N of the basemap. Orgs run deep hierarchies (3 levels is common, 5-6 is
|
|
// planned), and at a=0.25 six levels reach 82% — the map underneath disappears.
|
|
// These are chosen so six levels stay readable (~43% matched, ~22% context) while
|
|
// three land at ~25%, the weight a single region used to carry. Raise them and
|
|
// deep hierarchies turn to mud; the stroke, not the fill, is what identifies a
|
|
// region.
|
|
var FILL_MATCHED = 0.09;
|
|
var FILL_CONTEXT = 0.04;
|
|
|
|
// outlineStyle is a region drawn for reference — a neighbor on the area editor, or
|
|
// any region on the read-only config map. Dimmed by default; a region that applies
|
|
// at the previewed location is brought forward, which is what explains why its
|
|
// token shows up in the assembled config.
|
|
function outlineStyle(color, matched) {
|
|
var c = color || FALLBACK;
|
|
return {
|
|
color: c,
|
|
weight: matched ? 3 : 1.5,
|
|
opacity: matched ? 1 : 0.55,
|
|
fillColor: c,
|
|
fillOpacity: matched ? FILL_MATCHED : FILL_CONTEXT,
|
|
interactive: false,
|
|
};
|
|
}
|
|
|
|
// layerFromGeoJSON turns a stored geometry string into a single Leaflet layer,
|
|
// or null for an empty/invalid shape. geometryToLayer handles the [lon,lat] ->
|
|
// [lat,lng] axis flip for us, so we never touch coordinate order by hand.
|
|
function layerFromGeoJSON(str) {
|
|
if (!str) return null;
|
|
try {
|
|
return L.GeoJSON.geometryToLayer(JSON.parse(str));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// geomString serializes a layer back to a GeoJSON geometry string. toGeoJSON
|
|
// emits [lon,lat] per spec — the same axis order the Go geo package expects.
|
|
function geomString(layer) {
|
|
return JSON.stringify(layer.toGeoJSON().geometry);
|
|
}
|
|
|
|
function fitToLayers(map, layers, fallback) {
|
|
var group = L.featureGroup(layers);
|
|
if (layers.length) {
|
|
map.fitBounds(group.getBounds().pad(0.3), { animate: false });
|
|
} else {
|
|
map.setView(fallback || [20, 0], fallback ? 13 : 2, { animate: false });
|
|
}
|
|
}
|
|
|
|
// ---- Area editor (one region) -------------------------------------------
|
|
|
|
// initRegionArea drives the per-region area workspace. It edits exactly one
|
|
// shape — the region whose page this is — and renders the org's other regions as
|
|
// non-interactive outlines for spatial reference. opts:
|
|
// input — id of the hidden field carrying the GeoJSON geometry (required);
|
|
// status — id of an element to show "Custom area" / "No area yet" in;
|
|
// clear — id of a button that removes the shape;
|
|
// name — this region's label, used in the shape's tooltip;
|
|
// color — this region's palette color (see web.RegionPalette);
|
|
// siblings — [{name, geojson, color}] drawn read-only.
|
|
//
|
|
// Everything the form submits lives in the hidden input, so the server sees the
|
|
// same shape whether it was drawn, edited, or cleared.
|
|
window.initRegionArea = function (mapId, opts) {
|
|
opts = opts || {};
|
|
var input = document.getElementById(opts.input);
|
|
if (!input) return;
|
|
var statusEl = opts.status ? document.getElementById(opts.status) : null;
|
|
var map = L.map(mapId);
|
|
baseLayers(map);
|
|
|
|
// Geoman toolbar: only the tools that make sense for an area geofence. Drawing
|
|
// is enabled only while there's no shape — one region, one polygon, so a second
|
|
// draw would be ambiguous.
|
|
map.pm.addControls({
|
|
position: "topleft",
|
|
drawMarker: false,
|
|
drawCircleMarker: false,
|
|
drawCircle: false,
|
|
drawPolyline: false,
|
|
drawText: false,
|
|
drawPolygon: true,
|
|
drawRectangle: true,
|
|
editMode: true,
|
|
dragMode: true,
|
|
removalMode: true,
|
|
cutPolygon: false,
|
|
rotateMode: false,
|
|
});
|
|
map.pm.setGlobalOptions({ allowSelfIntersection: false });
|
|
|
|
var layer = null;
|
|
|
|
function serialize() {
|
|
input.value = layer ? geomString(layer) : "";
|
|
if (statusEl) statusEl.textContent = layer ? "Custom area" : "No area yet";
|
|
// With a shape present, drawing is off (edit/drag/remove stay on); once it's
|
|
// cleared, the draw tools come back.
|
|
map.pm.Toolbar.setButtonDisabled("drawPolygon", !!layer);
|
|
map.pm.Toolbar.setButtonDisabled("drawRectangle", !!layer);
|
|
}
|
|
|
|
function bind(l) {
|
|
layer = l;
|
|
l.setStyle(editableStyle(opts.color));
|
|
if (opts.name) l.bindTooltip(String(opts.name), { sticky: true });
|
|
l.addTo(map);
|
|
// Re-serialize after any vertex edit or whole-shape drag.
|
|
l.on("pm:edit pm:update pm:dragend pm:markerdragend", serialize);
|
|
serialize();
|
|
}
|
|
|
|
function clear() {
|
|
if (layer) {
|
|
map.removeLayer(layer);
|
|
layer = null;
|
|
}
|
|
serialize();
|
|
}
|
|
|
|
map.on("pm:create", function (e) {
|
|
// Belt and braces: the draw buttons are disabled while a shape exists, but if
|
|
// one is somehow drawn anyway, replace rather than orphan a second polygon.
|
|
if (layer) map.removeLayer(layer);
|
|
bind(e.layer);
|
|
});
|
|
map.on("pm:remove", function (e) {
|
|
if (e.layer === layer) clear();
|
|
});
|
|
|
|
// Siblings first, so the edited shape sits on top of them.
|
|
var context = [];
|
|
(opts.siblings || []).forEach(function (sib) {
|
|
if (!sib) return;
|
|
var l = layerFromGeoJSON(sib.geojson);
|
|
if (!l) return;
|
|
l.setStyle(outlineStyle(sib.color));
|
|
if (sib.name) l.bindTooltip(String(sib.name));
|
|
l.addTo(map);
|
|
context.push(l);
|
|
});
|
|
|
|
var existing = layerFromGeoJSON(input.value);
|
|
if (existing) bind(existing);
|
|
else serialize();
|
|
|
|
var clearBtn = opts.clear ? document.getElementById(opts.clear) : null;
|
|
if (clearBtn) clearBtn.addEventListener("click", clear);
|
|
|
|
// Frame the edited shape when there is one, otherwise the siblings — a brand-new
|
|
// region should open looking at where its neighbors are.
|
|
fitToLayers(map, layer ? [layer] : context);
|
|
};
|
|
|
|
// ---- Read-only viewer ---------------------------------------------------
|
|
|
|
// regionMapView renders a read-only region map. opts:
|
|
// regions — [{name, geojson, matched, color, primary}] drawn as non-interactive
|
|
// outlines, the ones matching the previewed location brought forward.
|
|
// Omit for a plain location picker (the console and serial-setup
|
|
// callers do);
|
|
// pickURL — clicking the map navigates here with lat/lon appended so the
|
|
// server resolves the region def for that point (must end "?"/"&");
|
|
// preview — {lat, lon} of an already-picked point, shown as a marker;
|
|
// bounds — [[minLat,minLon],[maxLat,maxLon]] to frame the org's regions.
|
|
window.regionMapView = function (mapId, opts) {
|
|
opts = opts || {};
|
|
var map = L.map(mapId, { scrollWheelZoom: false });
|
|
baseLayers(map);
|
|
|
|
// Region outlines go on first so the picked-location marker stays on top. They
|
|
// are non-interactive (see outlineStyle), so a click still reaches the map and
|
|
// drops a pin rather than being swallowed by a polygon.
|
|
var drawn = [];
|
|
var primary = null;
|
|
(opts.regions || []).forEach(function (r) {
|
|
if (!r) return;
|
|
var l = layerFromGeoJSON(r.geojson);
|
|
if (!l) return;
|
|
l.setStyle(outlineStyle(r.color, r.matched));
|
|
if (r.name) l.bindTooltip(String(r.name));
|
|
l.addTo(map);
|
|
drawn.push(l);
|
|
if (r.primary) primary = l;
|
|
});
|
|
if (opts.pickURL) {
|
|
map.on("click", function (e) {
|
|
window.location = opts.pickURL + "lat=" + e.latlng.lat.toFixed(6) + "&lon=" + e.latlng.lng.toFixed(6);
|
|
});
|
|
}
|
|
// onPick drops/moves a marker and reports the point instead of navigating —
|
|
// used by in-page pickers (e.g. the serial setup form) that keep the value
|
|
// client-side rather than round-tripping to the server.
|
|
if (opts.onPick) {
|
|
var picked = null;
|
|
map.on("click", function (e) {
|
|
if (picked) picked.setLatLng(e.latlng);
|
|
else picked = L.circleMarker(e.latlng, {
|
|
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
|
|
}).addTo(map);
|
|
opts.onPick(e.latlng.lat, e.latlng.lng);
|
|
});
|
|
}
|
|
var fit = [];
|
|
if (opts.bounds) fit.push(opts.bounds[0], opts.bounds[1]);
|
|
// With no explicit bounds, open framed on the primary region — where the org
|
|
// actually operates — with room around it for context, rather than zooming out
|
|
// to contain a nationwide parent. Without a primary, frame everything drawn.
|
|
if (!opts.bounds && drawn.length) {
|
|
var rb = L.featureGroup(primary ? [primary] : drawn).getBounds();
|
|
if (primary) rb = rb.pad(PRIMARY_CONTEXT);
|
|
fit.push(rb.getSouthWest(), rb.getNorthEast());
|
|
}
|
|
if (opts.preview) {
|
|
L.circleMarker([opts.preview.lat, opts.preview.lon], {
|
|
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
|
|
}).addTo(map);
|
|
fit.push([opts.preview.lat, opts.preview.lon]);
|
|
}
|
|
if (fit.length) {
|
|
var b = L.latLngBounds(fit);
|
|
// A zero-size box (single point — one repeater, or a preview with no
|
|
// region box) can't be fit; center on it at a neighborhood zoom instead.
|
|
if (b.getNorthEast().equals(b.getSouthWest())) map.setView(b.getCenter(), 11, { animate: false });
|
|
else map.fitBounds(b, { animate: false, padding: FIT_PADDING });
|
|
} else {
|
|
map.setView([20, 0], 2, { animate: false });
|
|
}
|
|
// Return the map so callers that show it inside an initially-hidden container
|
|
// (e.g. a modal) can invalidateSize() once it becomes visible.
|
|
return map;
|
|
};
|
|
})();
|