mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 10:03:43 +00:00
495 lines
20 KiB
JavaScript
495 lines
20 KiB
JavaScript
// regionmap.js — MapLibre + Terra Draw maps 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).
|
|
//
|
|
// Load after maplibre-gl.js, terra-draw.js, terra-draw-maplibre-gl-adapter.js and
|
|
// basemap.js.
|
|
(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;
|
|
var PRIMARY_CONTEXT = 0.08;
|
|
|
|
// 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;
|
|
|
|
// The one shape the area editor can modify is drawn prominently, so it always
|
|
// reads which polygon the tools will act on.
|
|
var EDIT_FILL_OPACITY = 0.25;
|
|
var EDIT_OUTLINE_WIDTH = 3;
|
|
|
|
// The picked-location marker, on both the preview and the click-to-pick paths.
|
|
var PICK_COLOR = "#ffffff";
|
|
|
|
// How near, in screen pixels, a click counts as "the same point" while drawing.
|
|
//
|
|
// This is the floor on how finely an outline can be drawn, because Terra Draw
|
|
// measures it against the previous vertex as well as the first one: at its default
|
|
// of 40 the next click has to land more than 40px away or it is read as closing the
|
|
// ring rather than extending it, which makes tracing anything detailed — a
|
|
// shoreline, a county line — impossible without zooming way in. It is also the grab
|
|
// radius for a vertex in select mode, where 40 means dense vertices fight over the
|
|
// pointer.
|
|
//
|
|
// 12 leaves the closing point a 24px target, which is still a comfortable click and
|
|
// the usual floor for a touch target, so finishing by clicking the first vertex
|
|
// stays workable. Going much below this starts trading away that target.
|
|
var POINTER_DISTANCE = 12;
|
|
|
|
function parseGeometry(str) {
|
|
if (!str) return null;
|
|
try {
|
|
var g = JSON.parse(str);
|
|
return g && g.coordinates && g.coordinates.length ? g : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// polygonsOf splits any stored geometry into a list of Polygon coordinate arrays.
|
|
// Terra Draw edits Polygons only, so a stored MultiPolygon is loaded as one
|
|
// editable shape per part and reassembled on the way out (see serialize below) —
|
|
// that way existing multi-part regions stay editable rather than read-only.
|
|
function polygonsOf(geometry) {
|
|
if (!geometry) return [];
|
|
if (geometry.type === "Polygon") return [geometry.coordinates];
|
|
if (geometry.type === "MultiPolygon") return geometry.coordinates;
|
|
return [];
|
|
}
|
|
|
|
// regionFeatures turns the server's region list into one FeatureCollection. The
|
|
// matched/context distinction rides on each feature's properties and is resolved
|
|
// by a paint expression, so N nested regions still cost two layers, not 2N.
|
|
function regionFeatures(regions) {
|
|
var features = [];
|
|
(regions || []).forEach(function (r) {
|
|
if (!r) return;
|
|
var geom = parseGeometry(r.geojson);
|
|
if (!geom) return;
|
|
features.push({
|
|
type: "Feature",
|
|
// A stable feature id, so a polygon spanning several tiles is still one
|
|
// feature to anything querying the source rather than one hit per tile.
|
|
id: features.length,
|
|
geometry: geom,
|
|
properties: {
|
|
color: r.color || FALLBACK,
|
|
matched: !!r.matched,
|
|
primary: !!r.primary,
|
|
},
|
|
});
|
|
});
|
|
return features;
|
|
}
|
|
|
|
// addRegionLayers draws a set of regions as outlines: dimmed by default, with any
|
|
// region that applies at the previewed location brought forward — which is what
|
|
// explains why its token shows up in the assembled config.
|
|
//
|
|
// The outlines carry no name labels. A symbol layer over a polygon source labels
|
|
// each tile the polygon covers, so a nationwide region picks up one label per tile
|
|
// and reads as several regions rather than one. What names a region is the legend
|
|
// beside the map, whose swatch comes from the same server palette as the stroke.
|
|
function addRegionLayers(map, id, features) {
|
|
map.addSource(id, { type: "geojson", data: { type: "FeatureCollection", features: features } });
|
|
map.addLayer({
|
|
id: id + "-fill",
|
|
type: "fill",
|
|
source: id,
|
|
paint: {
|
|
"fill-color": ["get", "color"],
|
|
"fill-opacity": ["case", ["get", "matched"], FILL_MATCHED, FILL_CONTEXT],
|
|
},
|
|
});
|
|
map.addLayer({
|
|
id: id + "-line",
|
|
type: "line",
|
|
source: id,
|
|
paint: {
|
|
"line-color": ["get", "color"],
|
|
"line-width": ["case", ["get", "matched"], 3, 1.5],
|
|
"line-opacity": ["case", ["get", "matched"], 1, 0.55],
|
|
},
|
|
});
|
|
}
|
|
|
|
// markerFeature is the picked/previewed location, drawn as a small white dot.
|
|
function addMarkerLayer(map, id, lngLat) {
|
|
map.addSource(id, {
|
|
type: "geojson",
|
|
data: {
|
|
type: "FeatureCollection",
|
|
features: lngLat ? [{ type: "Feature", geometry: { type: "Point", coordinates: lngLat }, properties: {} }] : [],
|
|
},
|
|
});
|
|
map.addLayer({
|
|
id: id + "-point",
|
|
type: "circle",
|
|
source: id,
|
|
paint: {
|
|
"circle-radius": 7,
|
|
"circle-color": PICK_COLOR,
|
|
"circle-opacity": 0.9,
|
|
"circle-stroke-width": 2,
|
|
"circle-stroke-color": PICK_COLOR,
|
|
},
|
|
});
|
|
}
|
|
|
|
// notSelfIntersecting builds the geofence validity rule: a polygon with a crossed
|
|
// edge has no well-defined inside, which is the constraint Geoman enforced as
|
|
// allowSelfIntersection: false.
|
|
//
|
|
// Which updates it applies to is the whole subtlety. Terra Draw runs validation on
|
|
// provisional updates too — every pointer move, and every click while the ring is
|
|
// still open — and a polygon under construction is auto-closed back to its first
|
|
// point. So an outline that runs south, then east, and then wants to head farther
|
|
// south crosses that closing edge *in the intermediate state*, even though the
|
|
// finished shape never does. Enforcing there rejects the click with no feedback at
|
|
// all: the cursor simply stops adding points, and concave areas — a state whose
|
|
// coastline doubles back, say — cannot be drawn. Judge the finished shape instead.
|
|
function notSelfIntersecting(updateTypes, onInvalid) {
|
|
return function (feature, context) {
|
|
if (updateTypes.indexOf(context.updateType) === -1) return { valid: true };
|
|
var result = terraDraw.ValidateNotSelfIntersecting(feature);
|
|
// Terra Draw refuses the update and says nothing, so an operator who has just
|
|
// crossed their own outline sees the tool stop responding with no reason
|
|
// given. Report it.
|
|
if (!result.valid && onInvalid) onInvalid();
|
|
return result;
|
|
};
|
|
}
|
|
|
|
// ---- Area editor (one region) -------------------------------------------
|
|
|
|
// DrawControl is the editor's toolbar. Terra Draw is headless — it renders shapes
|
|
// and handles the pointer work but ships no UI at all — so unlike Leaflet-Geoman,
|
|
// which brought its own toolbar, the buttons are ours. They are built here rather
|
|
// than in the template so the modes and the buttons that switch them stay in one
|
|
// file, and they carry data-testid hooks for the browser tests.
|
|
function DrawControl(actions) {
|
|
this._actions = actions;
|
|
}
|
|
|
|
DrawControl.prototype.onAdd = function () {
|
|
var self = this;
|
|
var el = document.createElement("div");
|
|
el.className = "maplibregl-ctrl maplibregl-ctrl-group mesh-draw-ctrl";
|
|
this._buttons = {};
|
|
[
|
|
{ key: "polygon", label: "Draw" },
|
|
{ key: "rectangle", label: "Rectangle" },
|
|
{ key: "select", label: "Edit" },
|
|
].forEach(function (spec) {
|
|
var b = document.createElement("button");
|
|
b.type = "button";
|
|
b.textContent = spec.label;
|
|
b.setAttribute("data-testid", "region-draw-" + spec.key);
|
|
b.addEventListener("click", function () {
|
|
self._actions.setMode(spec.key);
|
|
});
|
|
el.appendChild(b);
|
|
self._buttons[spec.key] = b;
|
|
});
|
|
this._el = el;
|
|
return el;
|
|
};
|
|
|
|
// sync reflects the editor's state on the toolbar: with a shape present the draw
|
|
// tools are off (one region, one area, so a second draw would be ambiguous) and
|
|
// editing is on; with none, the reverse.
|
|
DrawControl.prototype.sync = function (mode, hasShape) {
|
|
if (!this._buttons) return;
|
|
this._buttons.polygon.disabled = hasShape;
|
|
this._buttons.rectangle.disabled = hasShape;
|
|
this._buttons.select.disabled = !hasShape;
|
|
for (var key in this._buttons) {
|
|
if (Object.prototype.hasOwnProperty.call(this._buttons, key)) {
|
|
this._buttons[key].setAttribute("aria-pressed", String(key === mode));
|
|
}
|
|
}
|
|
};
|
|
|
|
DrawControl.prototype.onRemove = function () {
|
|
if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
|
|
};
|
|
|
|
// 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
|
|
// read-only 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 on the drawn shape;
|
|
// 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 — and, because the input is
|
|
// the single source of truth, the editor can be rebuilt from it after a basemap
|
|
// swap without losing work.
|
|
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 color = opts.color || FALLBACK;
|
|
var siblings = regionFeatures(opts.siblings);
|
|
|
|
var draw = null;
|
|
var control = new DrawControl({
|
|
setMode: function (mode) {
|
|
if (!draw) return;
|
|
draw.setMode(mode);
|
|
sync();
|
|
},
|
|
});
|
|
|
|
// shapes are the polygons the operator is editing. Terra Draw's store also holds
|
|
// the select mode's own furniture — selection points and midpoints — so filter
|
|
// to polygons rather than taking the snapshot whole.
|
|
function shapes() {
|
|
if (!draw) return [];
|
|
return draw.getSnapshot().filter(function (f) {
|
|
return f.geometry && f.geometry.type === "Polygon";
|
|
});
|
|
}
|
|
|
|
function serialize() {
|
|
var polys = shapes();
|
|
if (!polys.length) {
|
|
input.value = "";
|
|
} else if (polys.length === 1) {
|
|
input.value = JSON.stringify(polys[0].geometry);
|
|
} else {
|
|
// Reassemble the parts of a multi-part region exactly as they arrived.
|
|
input.value = JSON.stringify({
|
|
type: "MultiPolygon",
|
|
coordinates: polys.map(function (f) {
|
|
return f.geometry.coordinates;
|
|
}),
|
|
});
|
|
}
|
|
if (statusEl) statusEl.textContent = polys.length ? "Custom area" : "No area yet";
|
|
sync();
|
|
}
|
|
|
|
function sync() {
|
|
control.sync(draw ? draw.getMode() : "select", shapes().length > 0);
|
|
}
|
|
|
|
// rejectCrossed explains a refused edit in the status line. serialize() overwrites
|
|
// it as soon as there is a valid shape again.
|
|
function rejectCrossed() {
|
|
if (statusEl) statusEl.textContent = "Edges cross — an area can't overlap itself";
|
|
}
|
|
|
|
function buildDraw(map) {
|
|
var styles = {
|
|
fillColor: color,
|
|
fillOpacity: EDIT_FILL_OPACITY,
|
|
outlineColor: color,
|
|
outlineWidth: EDIT_OUTLINE_WIDTH,
|
|
};
|
|
var flags = {
|
|
feature: {
|
|
// An edit lands as a commit, so that is where a dragged vertex is judged.
|
|
validation: notSelfIntersecting(["commit", "finish"], rejectCrossed),
|
|
draggable: true,
|
|
coordinates: { midpoints: true, draggable: true, deletable: true },
|
|
},
|
|
};
|
|
return new terraDraw.TerraDraw({
|
|
adapter: new terraDrawMaplibreGlAdapter.TerraDrawMapLibreGLAdapter({ map: map }),
|
|
modes: [
|
|
// Drawing is judged only when the operator finishes; see notSelfIntersecting.
|
|
new terraDraw.TerraDrawPolygonMode({
|
|
styles: styles,
|
|
validation: notSelfIntersecting(["finish"], rejectCrossed),
|
|
editable: true,
|
|
pointerDistance: POINTER_DISTANCE,
|
|
}),
|
|
new terraDraw.TerraDrawRectangleMode({ styles: styles }),
|
|
new terraDraw.TerraDrawSelectMode({
|
|
flags: { polygon: flags, rectangle: flags },
|
|
pointerDistance: POINTER_DISTANCE,
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
function loadShapes(geometry) {
|
|
var polys = polygonsOf(geometry);
|
|
if (!polys.length) return;
|
|
draw.addFeatures(
|
|
polys.map(function (coords) {
|
|
return {
|
|
type: "Feature",
|
|
geometry: { type: "Polygon", coordinates: coords },
|
|
properties: { mode: "polygon" },
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
// overlays runs on first load and again after every basemap swap, because
|
|
// setStyle discards the sibling layers *and* the layers Terra Draw's adapter
|
|
// renders into. Rebuilding the editor from the hidden input is lossless — the
|
|
// input is always current — and is simpler than trying to re-register an adapter
|
|
// against a style that no longer has its sources.
|
|
function overlays(map) {
|
|
addRegionLayers(map, "siblings", siblings);
|
|
var geometry = parseGeometry(input.value);
|
|
if (draw) {
|
|
try {
|
|
draw.stop();
|
|
} catch (e) {
|
|
/* already torn down with the old style */
|
|
}
|
|
}
|
|
draw = buildDraw(map);
|
|
draw.start();
|
|
// Only a finished shape reaches the field. Terra Draw keeps the ring currently
|
|
// being drawn in the same store as finished ones, so serializing on every
|
|
// change would put a half-drawn outline into the form — and would leave a
|
|
// self-intersecting one there after its finish was refused, letting Save
|
|
// persist a geofence with no inside. A change in select mode, by contrast, is
|
|
// a real edit of a real shape.
|
|
draw.on("change", function () {
|
|
if (draw.getMode() === "select") serialize();
|
|
else sync();
|
|
});
|
|
draw.on("finish", function () {
|
|
// Drawing is done: hand the operator the tool that edits what they drew.
|
|
draw.setMode("select");
|
|
serialize();
|
|
});
|
|
loadShapes(geometry);
|
|
window.MESHTENDER_MAPS[mapId].draw = draw;
|
|
draw.setMode(geometry ? "select" : "polygon");
|
|
serialize();
|
|
}
|
|
|
|
var map = window.meshCreateMap(mapId, { overlays: overlays });
|
|
map.addControl(control, "top-right");
|
|
|
|
var clearBtn = opts.clear ? document.getElementById(opts.clear) : null;
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener("click", function () {
|
|
if (!draw) return;
|
|
draw.clear();
|
|
draw.setMode("polygon");
|
|
serialize();
|
|
});
|
|
}
|
|
|
|
// Frame the edited shape when there is one, otherwise the siblings — a brand-new
|
|
// region should open looking at where its neighbors are.
|
|
var own = window.meshCoordsOf(parseGeometry(input.value));
|
|
var context = [];
|
|
siblings.forEach(function (f) {
|
|
context = context.concat(window.meshCoordsOf(f.geometry));
|
|
});
|
|
window.meshFrame(map, own.length ? own : context, { padding: FIT_PADDING, pointZoom: 13 });
|
|
return map;
|
|
};
|
|
|
|
// ---- Read-only viewer ---------------------------------------------------
|
|
|
|
// regionMapView renders a read-only region map. opts:
|
|
// regions — [{name, geojson, matched, color, primary}] drawn as 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 features = regionFeatures(opts.regions);
|
|
var preview = opts.preview ? [opts.preview.lon, opts.preview.lat] : null;
|
|
var picked = preview;
|
|
|
|
function overlays(map) {
|
|
addRegionLayers(map, "regions", features);
|
|
// The marker source goes on after the regions so the picked location always
|
|
// sits on top of them.
|
|
addMarkerLayer(map, "picked", picked);
|
|
}
|
|
|
|
var map = window.meshCreateMap(mapId, { scrollZoom: false, overlays: overlays });
|
|
|
|
if (opts.pickURL) {
|
|
map.on("click", function (e) {
|
|
window.location = opts.pickURL + "lat=" + e.lngLat.lat.toFixed(6) + "&lon=" + e.lngLat.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) {
|
|
map.on("click", function (e) {
|
|
picked = [e.lngLat.lng, e.lngLat.lat];
|
|
var src = map.getSource("picked");
|
|
if (src) {
|
|
src.setData({
|
|
type: "FeatureCollection",
|
|
features: [{ type: "Feature", geometry: { type: "Point", coordinates: picked }, properties: {} }],
|
|
});
|
|
}
|
|
opts.onPick(e.lngLat.lat, e.lngLat.lng);
|
|
});
|
|
}
|
|
|
|
// Framing. An explicit bounds wins; otherwise 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. The previewed point is always kept in view.
|
|
var coords = [];
|
|
var pad = 0;
|
|
if (opts.bounds) {
|
|
coords.push([opts.bounds[0][1], opts.bounds[0][0]], [opts.bounds[1][1], opts.bounds[1][0]]);
|
|
} else if (features.length) {
|
|
// Read primary off the features, not the input list: regionFeatures drops
|
|
// regions with no drawn area, so the two are not index-aligned.
|
|
var primary = null;
|
|
features.forEach(function (f) {
|
|
if (f.properties.primary) primary = f;
|
|
});
|
|
(primary ? [primary] : features).forEach(function (f) {
|
|
coords = coords.concat(window.meshCoordsOf(f.geometry));
|
|
});
|
|
if (primary) pad = PRIMARY_CONTEXT;
|
|
}
|
|
if (preview) coords.push(preview);
|
|
window.meshFrame(map, coords, { padding: FIT_PADDING, pad: pad, pointZoom: 11 });
|
|
|
|
// Return the map so callers that show it inside an initially-hidden container
|
|
// (e.g. a modal) can resize() once it becomes visible.
|
|
return map;
|
|
};
|
|
})();
|