brouter-web: directional slope coloring (warm uphill / cool downhill to navy), signed grade
This commit is contained in:
parent
2cc16e0ed6
commit
ab1259eb20
1 changed files with 528 additions and 0 deletions
528
tools/brouter-web-staging/brouter-web/baldav-custom.js
Normal file
528
tools/brouter-web-staging/brouter-web/baldav-custom.js
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
(function () {
|
||||||
|
var LS_DARK = "baldav_bw_dark";
|
||||||
|
function isDark() { try { return localStorage.getItem(LS_DARK) === "1"; } catch (e) { return false; } }
|
||||||
|
|
||||||
|
// Inject the dark rules as a <style> in <head> (no dependency on an external CSS file,
|
||||||
|
// and it covers elements created later — e.g. the elevation chart, drawn only once a
|
||||||
|
// route exists). Toggling body.bw-dark activates/deactivates everything.
|
||||||
|
function injectStyle() {
|
||||||
|
if (document.getElementById("baldav-dark-style")) return;
|
||||||
|
var st = document.createElement("style");
|
||||||
|
st.id = "baldav-dark-style";
|
||||||
|
st.textContent = [
|
||||||
|
// POI emoji markers (no default divIcon box); white halo so they read on any background
|
||||||
|
".baldav-poi-icon{background:none;border:0}",
|
||||||
|
".baldav-poi-icon span{display:block;width:22px;height:22px;line-height:22px;text-align:center;font-size:15px;filter:drop-shadow(0 0 1px #fff) drop-shadow(0 0 1px #fff)}",
|
||||||
|
".baldav-ov{display:inline-block;width:1.2em;text-align:center}",
|
||||||
|
"body.bw-dark .leaflet-tile-pane{filter:invert(1) hue-rotate(180deg) brightness(1.5)}",
|
||||||
|
"body.bw-dark .leaflet-container{background:#1b1f24}",
|
||||||
|
// right sidebar / layers panel
|
||||||
|
"body.bw-dark #sidebar,body.bw-dark #sidebar .leaflet-sidebar-content,body.bw-dark .leaflet-sidebar-pane,body.bw-dark .leaflet-control-layers,body.bw-dark .leaflet-control-layers-expanded{background:#20242a!important;color:#dfe3e8!important}",
|
||||||
|
"body.bw-dark #sidebar label,body.bw-dark #sidebar span,body.bw-dark #sidebar a,body.bw-dark #sidebar h1,body.bw-dark #sidebar h2{color:#dfe3e8!important}",
|
||||||
|
"body.bw-dark #sidebar input[type=text],body.bw-dark #sidebar select{background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}",
|
||||||
|
// left toolbar + all leaflet control buttons (zoom, draw, etc.)
|
||||||
|
"body.bw-dark .leaflet-bar a,body.bw-dark .leaflet-control-zoom a,body.bw-dark .leaflet-control a,body.bw-dark .leaflet-control button{background:#2b3038!important;color:#fff!important;border-color:#454b54!important}",
|
||||||
|
"body.bw-dark .leaflet-bar a:hover,body.bw-dark .leaflet-control a:hover{background:#3a4048!important}",
|
||||||
|
// bottom elevation profile panel + footer/stats
|
||||||
|
"body.bw-dark #elevation-chart,body.bw-dark #elevation-chart svg,body.bw-dark .elevation,body.bw-dark .elevation svg{background:#20242a!important;color:#dfe3e8!important}",
|
||||||
|
"body.bw-dark .elevation .background{background-color:#20242a!important}",
|
||||||
|
"body.bw-dark .elevation rect.background,body.bw-dark .elevation .background rect{fill:#20242a!important}",
|
||||||
|
"body.bw-dark footer,body.bw-dark #stats-info,body.bw-dark #stats-container,body.bw-dark #stats,body.bw-dark #track_statistics{background:#20242a!important;color:#dfe3e8!important}",
|
||||||
|
"body.bw-dark .elevation text,body.bw-dark .elevation .axis text{fill:#dfe3e8!important}",
|
||||||
|
"body.bw-dark .elevation .domain,body.bw-dark .elevation .tick line,body.bw-dark .elevation .grid line{stroke:#55606a!important}",
|
||||||
|
"body.bw-dark .elevation path.area,body.bw-dark .elevation .area{fill:#2b3038!important;opacity:.6}",
|
||||||
|
// elevation SVG axis text (was black on dark)
|
||||||
|
"body.bw-dark #elevation-chart text,body.bw-dark #elevation-chart .tick text,body.bw-dark #elevation-chart .axis text{fill:#dfe3e8!important}",
|
||||||
|
"body.bw-dark #elevation-chart .tick line,body.bw-dark #elevation-chart .domain,body.bw-dark #elevation-chart path.grid,body.bw-dark #elevation-chart line{stroke:#3a4048!important}",
|
||||||
|
// hover tooltip on the elevation chart (.focusbox): its rect is white and our
|
||||||
|
// .elevation text rule turned the text light — light-on-light, unreadable. Give the
|
||||||
|
// box a dark ground and keep the text light. Same for the on-line height label.
|
||||||
|
"body.bw-dark .focusbox rect{fill:rgba(32,36,42,.92)!important;stroke:#667!important}",
|
||||||
|
"body.bw-dark .focusbox,body.bw-dark .focusbox text{fill:#dfe3e8!important}",
|
||||||
|
"body.bw-dark .height-focus.label rect{fill:rgba(32,36,42,.9)!important;stroke:#667!important}",
|
||||||
|
"body.bw-dark .height-focus.label text{fill:#dfe3e8!important}",
|
||||||
|
// guide lines/markers were near-black (#141414) — invisible on the dark chart
|
||||||
|
"body.bw-dark .focusLine line,body.bw-dark .height-focus.line{stroke:#aab4be!important}",
|
||||||
|
"body.bw-dark .height-focus.circle{stroke:#20242a!important}",
|
||||||
|
// lift the on-map hover box above the route so it doesn't cover the line (both themes)
|
||||||
|
".height-focus.label{transform:translateY(-42px)}",
|
||||||
|
// right vertical sidebar tab strip (layers/settings/data/stats icons)
|
||||||
|
"body.bw-dark .leaflet-sidebar-tabs,body.bw-dark .leaflet-sidebar-tabs>ul,body.bw-dark .leaflet-sidebar-tabs li,body.bw-dark .leaflet-sidebar-tabs li a{background:#20242a!important;color:#dfe3e8!important}",
|
||||||
|
"body.bw-dark .leaflet-sidebar-tabs li.active a,body.bw-dark .leaflet-sidebar-tabs li a:hover{background:#2b3038!important;color:#fff!important}",
|
||||||
|
"body.bw-dark .leaflet-sidebar.collapsed,body.bw-dark .leaflet-sidebar-header{background:#20242a!important;color:#dfe3e8!important}",
|
||||||
|
// Bootstrap form controls: search fields, the profile dropdown/select, etc.
|
||||||
|
"body.bw-dark input,body.bw-dark textarea,body.bw-dark select,body.bw-dark .form-control,body.bw-dark .custom-select{background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}",
|
||||||
|
"body.bw-dark ::placeholder{color:#9aa4ae!important}",
|
||||||
|
// dropdown menus (Load, profile chooser)
|
||||||
|
"body.bw-dark .dropdown-menu{background:#20242a!important;color:#dfe3e8!important;border-color:#454b54!important}",
|
||||||
|
"body.bw-dark .dropdown-item{color:#dfe3e8!important}",
|
||||||
|
"body.bw-dark .dropdown-item:hover,body.bw-dark .dropdown-item:focus{background:#2b3038!important;color:#fff!important}",
|
||||||
|
// light-styled buttons (incl. those above the elevation chart) → dark; keep coloured btns
|
||||||
|
"body.bw-dark .btn-light,body.bw-dark .btn-outline-secondary,body.bw-dark .btn-secondary,body.bw-dark .btn-default,body.bw-dark button.btn:not([class*=btn-primary]):not([class*=btn-success]):not([class*=btn-danger]){background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}",
|
||||||
|
"body.bw-dark .card,body.bw-dark .modal-content,body.bw-dark .list-group-item,body.bw-dark .popover,body.bw-dark .popover-body{background:#20242a!important;color:#dfe3e8!important}",
|
||||||
|
// brouter-web bottom footer buttons / export-image control
|
||||||
|
"body.bw-dark footer .btn,body.bw-dark footer button,body.bw-dark #export-to-image,body.bw-dark .leaflet-control button{background:#2b3038!important;color:#dfe3e8!important;border-color:#454b54!important}"
|
||||||
|
].join("\n");
|
||||||
|
(document.head || document.documentElement).appendChild(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
document.body.classList.toggle("bw-dark", isDark());
|
||||||
|
var b = document.getElementById("baldav-daynight");
|
||||||
|
if (b) b.textContent = isDark() ? "☀ День" : "☾ Ночь";
|
||||||
|
}
|
||||||
|
function toggle() {
|
||||||
|
try { localStorage.setItem(LS_DARK, isDark() ? "0" : "1"); } catch (e) {}
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
// Standalone floating button — independent of the navbar so it can't be invisible or
|
||||||
|
// zero-width. Fixed bottom-right, explicit colours/size.
|
||||||
|
function addButton() {
|
||||||
|
if (document.getElementById("baldav-daynight")) return true;
|
||||||
|
var a = document.createElement("button");
|
||||||
|
a.id = "baldav-daynight";
|
||||||
|
a.type = "button";
|
||||||
|
a.textContent = isDark() ? "☀ День" : "☾ Ночь";
|
||||||
|
a.style.cssText = "position:fixed;bottom:24px;right:24px;z-index:100000;" +
|
||||||
|
"padding:8px 12px;font:600 14px sans-serif;background:#2b3038;color:#fff;" +
|
||||||
|
"border:1px solid #667;border-radius:8px;cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,.5)";
|
||||||
|
a.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); toggle(); });
|
||||||
|
document.body.appendChild(a);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var WANT = ["Cycling","Hiking","Bench","Toilets","Bank","ATM","Bar","BBQ","Cafe","Drinking water","Fast food","Food court","Pub","Restaurant","Bicycle parking","Bus station","Bakery","Beverages","Coffee","Supermarket","Picnic site","Viewpoint","Shelter","Route quality"];
|
||||||
|
function setDefaultOverlays() {
|
||||||
|
try { if (localStorage.getItem("baldav_bw_overlays_v1") === "1") return true; } catch (e) {}
|
||||||
|
var labels = document.querySelectorAll("#layers-control-wrapper label, .leaflet-control-layers-overlays label");
|
||||||
|
if (!labels.length) return false;
|
||||||
|
labels.forEach(function (lab) {
|
||||||
|
var t = (lab.textContent || "").trim();
|
||||||
|
if (!WANT.some(function (w) { return t.indexOf(w) !== -1; })) return;
|
||||||
|
var cb = lab.querySelector("input[type=checkbox]") || lab.control || (lab.htmlFor && document.getElementById(lab.htmlFor));
|
||||||
|
if (cb && cb.type === "checkbox" && !cb.checked) cb.click();
|
||||||
|
});
|
||||||
|
try { localStorage.setItem("baldav_bw_overlays_v1", "1"); } catch (e) {}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// --- Route colored by slope, BalDav palette (task: paint the route by gradient, not
|
||||||
|
// elevation). brouter-web's "Route quality" overlay uses a HotLineQualityProvider; its
|
||||||
|
// `incline` provider colors by SIGNED incline in degrees (blue→green→red). We repoint it
|
||||||
|
// at |grade %| with BalDav's discrete bands (green→yellow→red→brown→black, ≥10% black).
|
||||||
|
// The provider reads hotlineOptions.{min,max,palette} and valueFunction directly at draw
|
||||||
|
// time (computeLayers → L.hotline), so mutating them is enough. Registered via addInitHook
|
||||||
|
// BEFORE the control is constructed, so the very first draw already uses our palette.
|
||||||
|
// Directional: uphill through a WARM fan, downhill through a COOL fan (green->cyan->navy),
|
||||||
|
// like BalDav. Hotline value is SIGNED grade over normalized [0,1] with 0.5 = flat.
|
||||||
|
var UP = ["#2e7d32","#7cb342","#aeea00","#ffeb3b","#ffc107","#ff9800","#ff5722","#e53935","#b71c1c","#6d4c41","#000000"];
|
||||||
|
var DOWN = ["#2e7d32","#2e9e5b","#26a69a","#00bcd4","#29b6f6","#1e88e5","#1976d2","#1565c0","#0d47a1","#0a357a","#001f5c"];
|
||||||
|
function slopePalette() {
|
||||||
|
// normalized n in [0,1] maps to grade g=(n-0.5)*20, so n=0 is -10% (steep descent),
|
||||||
|
// n=0.5 flat, n=1 +10% (steep climb). Step bands of 1% each side.
|
||||||
|
var p = {}, eps = 0.0004;
|
||||||
|
for (var k = 0; k < 10; k++) { p[0.5 + k / 20] = UP[k]; p[0.5 + (k + 1) / 20 - eps] = UP[k]; } // climb
|
||||||
|
p[1] = UP[10];
|
||||||
|
for (var k = 0; k < 10; k++) { p[0.5 - (k + 1) / 20 + eps] = DOWN[k]; p[0.5 - k / 20] = DOWN[k]; } // descent
|
||||||
|
p[0] = DOWN[10];
|
||||||
|
p[0.5] = "#2e7d32"; // flat = green
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function slopeValue(cur, prev) {
|
||||||
|
if (!cur || !prev) return 0;
|
||||||
|
var d = prev.distanceTo(cur); if (!d) return 0;
|
||||||
|
return 100 * (((cur.alt) || 0) - ((prev.alt) || 0)) / d; // SIGNED grade %
|
||||||
|
}
|
||||||
|
// White+black casing under the colored line so it reads on any background. leaflet-hotline
|
||||||
|
// has only ONE outlineColor, so we render the colored core thin (no built-in outline) and
|
||||||
|
// add two plain polylines beneath it in a dedicated pane below the hotline pane: a wide
|
||||||
|
// black one and a narrower white one — black rim, white rim, colored core.
|
||||||
|
var CASE_PANE = "baldavCasingPane";
|
||||||
|
function ensureCasingPane() {
|
||||||
|
var m = window.BR && BR.__map;
|
||||||
|
if (!m) return null;
|
||||||
|
if (!m.getPane(CASE_PANE)) {
|
||||||
|
m.createPane(CASE_PANE);
|
||||||
|
var p = m.getPane(CASE_PANE);
|
||||||
|
if (p) p.style.zIndex = 449; // routingQualityPane is 450 → casings sit just under it
|
||||||
|
}
|
||||||
|
return m.getPane(CASE_PANE) ? CASE_PANE : null;
|
||||||
|
}
|
||||||
|
function makeCasings(segments) {
|
||||||
|
var pane = ensureCasingPane(), out = [];
|
||||||
|
for (var i = 0; segments && i < segments.length; i++) {
|
||||||
|
var seg = segments[i], r;
|
||||||
|
if (!seg || !seg.getLatLngs) continue;
|
||||||
|
if (null !== (r = seg._routing) && void 0 !== r && r.beeline) continue; // skip beelines, as brouter does
|
||||||
|
var lls = seg.getLatLngs();
|
||||||
|
var base = { opacity: 1, interactive: false, lineCap: "round", lineJoin: "round" };
|
||||||
|
if (pane) base.pane = pane;
|
||||||
|
out.push(L.polyline(lls, L.extend({}, base, { color: "#000000", weight: 12 })));
|
||||||
|
out.push(L.polyline(lls, L.extend({}, base, { color: "#ffffff", weight: 8 })));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function applySlopeProvider(ctrl) {
|
||||||
|
try {
|
||||||
|
var pr = ctrl && ctrl.providers && ctrl.providers.incline && ctrl.providers.incline.provider;
|
||||||
|
if (!pr || !pr.hotlineOptions) return false;
|
||||||
|
pr.hotlineOptions.min = -10;
|
||||||
|
pr.hotlineOptions.max = 10;
|
||||||
|
pr.hotlineOptions.palette = slopePalette();
|
||||||
|
pr.hotlineOptions.weight = 4; // thin colored core over the casings
|
||||||
|
pr.hotlineOptions.outlineWidth = 0; // no built-in outline; casings provide it
|
||||||
|
pr.valueFunction = slopeValue;
|
||||||
|
ctrl._initialProvider = "incline";
|
||||||
|
ctrl.selectedProvider = "incline";
|
||||||
|
// Prepend black+white casings to whatever hotlines the provider computes (added first
|
||||||
|
// = under the colored line). Wrap once.
|
||||||
|
if (!pr.__baldavCased && typeof pr.computeLayers === "function") {
|
||||||
|
var orig = pr.computeLayers.bind(pr);
|
||||||
|
pr.computeLayers = function (segments) {
|
||||||
|
var hotlines = orig(segments);
|
||||||
|
try { return makeCasings(segments).concat(hotlines); } catch (e) { return hotlines; }
|
||||||
|
};
|
||||||
|
pr.__baldavCased = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
var rqHooked = false, mapHooked = false;
|
||||||
|
function registerRouteQuality() {
|
||||||
|
// Capture the map instance as it's created (needed to make the casing pane).
|
||||||
|
if (!mapHooked && window.L && L.Map && L.Map.addInitHook) {
|
||||||
|
mapHooked = true;
|
||||||
|
L.Map.addInitHook(function () {
|
||||||
|
window.BR = window.BR || {}; BR.__map = this;
|
||||||
|
// Capture the native layers control as it is added to the map (addTo → addControl),
|
||||||
|
// so we can addOverlay() into brouter-web's own checkbox list instead of adding
|
||||||
|
// separate corner buttons. Any control exposing addOverlay()+_layers is the one.
|
||||||
|
var origAdd = this.addControl;
|
||||||
|
this.addControl = function (control) {
|
||||||
|
try {
|
||||||
|
if (control && typeof control.addOverlay === "function" && control._layers && !BR.__layersControl) {
|
||||||
|
BR.__layersControl = control;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return origAdd.call(this, control);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rqHooked) return;
|
||||||
|
if (!(window.BR && BR.RoutingPathQuality && BR.RoutingPathQuality.addInitHook)) return;
|
||||||
|
rqHooked = true;
|
||||||
|
BR.RoutingPathQuality.addInitHook(function () { applySlopeProvider(this); BR.__quality = this; });
|
||||||
|
}
|
||||||
|
// Best-effort: auto-enable the "Route quality" overlay so the route shows slope out of the
|
||||||
|
// box. Its checkbox label is localized (locale files), so match a few languages; if it
|
||||||
|
// isn't found the palette override still applies once the user turns it on (button / key C).
|
||||||
|
function enableRouteQuality() {
|
||||||
|
try { if (localStorage.getItem("baldav_bw_rq_v1") === "1") return true; } catch (e) {}
|
||||||
|
var labels = document.querySelectorAll("#layers-control-wrapper label, .leaflet-control-layers-overlays label");
|
||||||
|
if (!labels.length) return false;
|
||||||
|
var re = /route.?quality|streckenqualit|качеств/i, done = false;
|
||||||
|
labels.forEach(function (lab) {
|
||||||
|
if (!re.test((lab.textContent || ""))) return;
|
||||||
|
var cb = lab.querySelector("input[type=checkbox]") || lab.control || (lab.htmlFor && document.getElementById(lab.htmlFor));
|
||||||
|
if (cb && cb.type === "checkbox" && !cb.checked) { cb.click(); }
|
||||||
|
done = true;
|
||||||
|
});
|
||||||
|
if (done) { try { localStorage.setItem("baldav_bw_rq_v1", "1"); } catch (e) {} }
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- copy coordinates of the map centre --------------------------------------------
|
||||||
|
// The map's own left-click adds a routing waypoint and right-click opens its menu, so we
|
||||||
|
// can't hijack either. Instead: a centre crosshair + a box showing the centre's lat,lon;
|
||||||
|
// click the box to copy. Pan the feature under the crosshair, click, paste into osm.org.
|
||||||
|
var coordHooked = false;
|
||||||
|
function bwToast(msg) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.textContent = msg;
|
||||||
|
d.style.cssText = "position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:100000;" +
|
||||||
|
"background:#222;color:#fff;padding:8px 14px;border-radius:6px;font:14px sans-serif;" +
|
||||||
|
"box-shadow:0 2px 8px rgba(0,0,0,.4);pointer-events:none;";
|
||||||
|
document.body.appendChild(d);
|
||||||
|
setTimeout(function () { d.remove(); }, 1800);
|
||||||
|
}
|
||||||
|
function copyCoord(latlng) {
|
||||||
|
var s = latlng.lat.toFixed(6) + ", " + latlng.lng.toFixed(6);
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(s).then(
|
||||||
|
function () { bwToast("Скопировано: " + s); },
|
||||||
|
function () { window.prompt("Координаты (Ctrl+C):", s); });
|
||||||
|
} else { window.prompt("Координаты (Ctrl+C):", s); }
|
||||||
|
}
|
||||||
|
function hookCoordControl() {
|
||||||
|
if (coordHooked) return true;
|
||||||
|
var m = window.BR && BR.__map;
|
||||||
|
if (!m || !window.L) return false;
|
||||||
|
var cross = document.createElement("div");
|
||||||
|
cross.style.cssText = "position:absolute;left:50%;top:50%;width:16px;height:16px;margin:-8px 0 0 -8px;" +
|
||||||
|
"z-index:600;pointer-events:none;background:" +
|
||||||
|
"linear-gradient(#e33,#e33) no-repeat center/2px 16px, linear-gradient(#e33,#e33) no-repeat center/16px 2px;";
|
||||||
|
m.getContainer().appendChild(cross);
|
||||||
|
var Ctl = L.Control.extend({
|
||||||
|
options: { position: "bottomleft" },
|
||||||
|
onAdd: function () {
|
||||||
|
var d = L.DomUtil.create("div", "leaflet-bar");
|
||||||
|
d.style.cssText = "background:#fff;color:#111;padding:4px 8px;font:12px/1.3 monospace;cursor:pointer;";
|
||||||
|
d.title = "Клик — скопировать координаты центра (перекрестья)";
|
||||||
|
function upd() { var c = m.getCenter(); d.textContent = "📍 " + c.lat.toFixed(6) + ", " + c.lng.toFixed(6); }
|
||||||
|
upd(); m.on("move", upd);
|
||||||
|
L.DomEvent.on(d, "click", function (ev) { L.DomEvent.stop(ev); copyCoord(m.getCenter()); });
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
m.addControl(new Ctl());
|
||||||
|
// Right-click / long-press copies the coordinate at that spot. Capture phase + stop so the
|
||||||
|
// map's own context menu and the browser's don't fire. NOTE: if the browser forbids JS from
|
||||||
|
// overriding the context menu (some Firefox configs), this can't work - use the box above.
|
||||||
|
m.getContainer().addEventListener("contextmenu", function (ev) {
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
try { copyCoord(m.mouseEventToLatLng(ev)); } catch (e) {}
|
||||||
|
}, true);
|
||||||
|
coordHooked = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ridden-track + offline-POI overlays, registered in brouter-web's OWN layers control
|
||||||
|
// (the native checkbox list in the sidebar) rather than as separate corner buttons. All
|
||||||
|
// lazy: the several-MB GeoJSON / mesh files load only when a layer is first switched on.
|
||||||
|
var layersHooked = false;
|
||||||
|
// "Покрытие" (accel-coloured ridden track): an empty layerGroup acts as the native-overlay
|
||||||
|
// handle; its add/remove (fired by the checkbox) lazy-load/attach the real canvas GeoJSON.
|
||||||
|
function surfaceOverlay(m) {
|
||||||
|
var st = { layer: null, loading: false };
|
||||||
|
var proxy = L.layerGroup();
|
||||||
|
proxy.on("add", function () {
|
||||||
|
if (st.layer) { st.layer.addTo(m); return; }
|
||||||
|
if (st.loading) return;
|
||||||
|
st.loading = true; bwToast("Загружаю «Покрытие»…");
|
||||||
|
fetch("track-surface.geojson").then(function (r) { return r.json(); }).then(function (gj) {
|
||||||
|
st.layer = L.geoJSON(gj, { renderer: L.canvas(),
|
||||||
|
style: function (f) { return { color: f.properties.color, weight: 3, opacity: 0.85 }; } });
|
||||||
|
st.loading = false; if (m.hasLayer(proxy)) st.layer.addTo(m);
|
||||||
|
}).catch(function () { st.loading = false; bwToast("Не удалось загрузить «Покрытие»"); });
|
||||||
|
});
|
||||||
|
proxy.on("remove", function () { if (st.layer) m.removeLayer(st.layer); });
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
// --- elevation surface (hmview's WebGL TIN from your own height map) ------------------
|
||||||
|
// Lazy: loads cells.js (~5 MB), mesh.js (~3 MB), mesh_layer.js, then draws makeMeshLayer.
|
||||||
|
// Regenerate the data with tools/track pipeline (pull.sh, heightmap.py, hm_mesh.py,
|
||||||
|
// hm_export.py) and re-copy the 3 files into this folder.
|
||||||
|
var mesh = { ml: null, loading: false };
|
||||||
|
// Slider state persists across toggles. maxEdge is capped at 1000 m: hm_mesh.py exports
|
||||||
|
// only triangles with the longest side <= MAX_EDGE (1000 m), so a larger value shows
|
||||||
|
// nothing new — the beige gaps are stretches wider than 1 km that were never ridden.
|
||||||
|
var hmState = { alpha: 1, edge: 1000, pass: 0 };
|
||||||
|
// Turbo colour ramp for the legend (same 7 stops as the shader in mesh_layer.js).
|
||||||
|
function hmRamp(t) {
|
||||||
|
var T = [[48,18,59],[70,107,227],[54,183,170],[172,220,66],[254,194,59],[232,97,27],[122,4,3]];
|
||||||
|
t = Math.max(0, Math.min(1, t)) * (T.length - 1);
|
||||||
|
var i = Math.floor(t), f = t - i, a = T[i], b = T[Math.min(i + 1, T.length - 1)];
|
||||||
|
return [a[0]+(b[0]-a[0])*f, a[1]+(b[1]-a[1])*f, a[2]+(b[2]-a[2])*f];
|
||||||
|
}
|
||||||
|
// Surface needs only mesh.js (~3 MB) + mesh_layer.js; cells.js (~5 MB, points) is skipped.
|
||||||
|
// Range from MESH.e percentiles (1..99) so outlier vertices don't flatten the colour scale.
|
||||||
|
function loadMesh(m, onReady) {
|
||||||
|
if (mesh.ml) { onReady(); return; }
|
||||||
|
if (mesh.loading) return;
|
||||||
|
mesh.loading = true; bwToast("Загружаю карту высот…");
|
||||||
|
["mesh.js", "mesh_layer.js"].forEach(function (src) {
|
||||||
|
if (document.querySelector('script[data-hm="' + src + '"]')) return;
|
||||||
|
var s = document.createElement("script"); s.src = src; s.setAttribute("data-hm", src); document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
var t0 = Date.now();
|
||||||
|
var iv = setInterval(function () {
|
||||||
|
if (window.MESH && window.makeMeshLayer) {
|
||||||
|
clearInterval(iv); mesh.loading = false;
|
||||||
|
var es = [].slice.call(window.MESH.e).sort(function (a, b) { return a - b; });
|
||||||
|
var LO = es[Math.floor(es.length * 0.01)], HI = es[Math.floor(es.length * 0.99)];
|
||||||
|
var ml = window.makeMeshLayer(m, window.MESH, {});
|
||||||
|
if (ml.error) { bwToast("Высоты: " + ml.error); return; }
|
||||||
|
ml.state.lo = LO; ml.state.hi = HI; ml.setMaxEdge(hmState.edge); ml.draw();
|
||||||
|
mesh.ml = ml; onReady();
|
||||||
|
} else if (Date.now() - t0 > 25000) {
|
||||||
|
clearInterval(iv); mesh.loading = false; bwToast("Карта высот: данные не загрузились");
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
// "Высоты (заливка)": a native-overlay handle (empty layerGroup). Turning it on lazy-loads
|
||||||
|
// the WebGL mesh and shows a small slider panel; turning it off hides both. The panel is a
|
||||||
|
// transient settings surface tied to the active layer, not a permanent button.
|
||||||
|
var hmPanel = null;
|
||||||
|
function heightsOverlay(m) {
|
||||||
|
var proxy = L.layerGroup();
|
||||||
|
proxy.on("add", function () {
|
||||||
|
loadMesh(m, function () {
|
||||||
|
mesh.ml.state.visible = true; mesh.ml.state.alpha = hmState.alpha;
|
||||||
|
mesh.ml.setMinPass(hmState.pass); mesh.ml.setMaxEdge(hmState.edge); mesh.ml.draw();
|
||||||
|
showHmPanel(m);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
proxy.on("remove", function () {
|
||||||
|
if (mesh.ml) { mesh.ml.state.visible = false; mesh.ml.draw(); }
|
||||||
|
if (hmPanel) hmPanel.style.display = "none";
|
||||||
|
});
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
function showHmPanel(m) {
|
||||||
|
if (!hmPanel) {
|
||||||
|
hmPanel = L.DomUtil.create("div", "leaflet-bar");
|
||||||
|
hmPanel.style.cssText = "position:absolute;top:10px;right:54px;z-index:1000;background:#fff;color:#111;" +
|
||||||
|
"padding:6px 8px;font:12px sans-serif;width:190px;line-height:1.5;";
|
||||||
|
hmPanel.innerHTML =
|
||||||
|
'<b>Высоты (заливка)</b>'
|
||||||
|
+ '<div style="margin-top:4px">непрозрачность: <b id="hm-la">100</b>%<input type="range" id="hm-alpha" min="0" max="100" value="100" style="width:100%"></div>'
|
||||||
|
+ 'макс. сторона: <b id="hm-le">1000</b> м<input type="range" id="hm-edge" min="20" max="1000" step="20" value="1000" style="width:100%">'
|
||||||
|
+ 'мин. проездов: <b id="hm-lp">0</b><input type="range" id="hm-pass" min="0" max="10" value="0" style="width:100%">'
|
||||||
|
+ '<div style="margin-top:4px">высота: <span id="hm-lo"></span>..<span id="hm-hi"></span> м</div>'
|
||||||
|
+ '<div id="hm-leg" style="height:10px;border:1px solid #bbb"></div>'
|
||||||
|
+ '<div id="hm-stat" style="color:#888;font-size:10px;margin-top:2px"></div>';
|
||||||
|
m.getContainer().appendChild(hmPanel);
|
||||||
|
L.DomEvent.disableClickPropagation(hmPanel); L.DomEvent.disableScrollPropagation(hmPanel);
|
||||||
|
var $ = function (id) { return hmPanel.querySelector("#" + id); };
|
||||||
|
var stat = function () { if (mesh.ml) $("hm-stat").textContent = mesh.ml.count() + " треугольников"; };
|
||||||
|
$("hm-alpha").oninput = function () { $("hm-la").textContent = this.value; hmState.alpha = +this.value / 100; if (mesh.ml) { mesh.ml.state.alpha = hmState.alpha; mesh.ml.draw(); } };
|
||||||
|
$("hm-edge").oninput = function () { $("hm-le").textContent = this.value; hmState.edge = +this.value; if (mesh.ml) { mesh.ml.setMaxEdge(hmState.edge); stat(); } };
|
||||||
|
$("hm-pass").oninput = function () { $("hm-lp").textContent = this.value; hmState.pass = +this.value; if (mesh.ml) { mesh.ml.setMinPass(hmState.pass); stat(); } };
|
||||||
|
}
|
||||||
|
hmPanel.style.display = "block";
|
||||||
|
var ml = mesh.ml; if (!ml) return;
|
||||||
|
var $ = function (id) { return hmPanel.querySelector("#" + id); };
|
||||||
|
$("hm-lo").textContent = ml.state.lo.toFixed(0);
|
||||||
|
$("hm-hi").textContent = ml.state.hi.toFixed(0);
|
||||||
|
var g = []; for (var i = 0; i <= 10; i++) { var c = hmRamp(i / 10); g.push("rgb(" + (c[0]|0) + "," + (c[1]|0) + "," + (c[2]|0) + ")"); }
|
||||||
|
$("hm-leg").style.background = "linear-gradient(90deg," + g.join(",") + ")";
|
||||||
|
$("hm-stat").textContent = ml.count() + " треугольников";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- offline POI (radius 150 km around НЭВЗ, from the Geofabrik Южный ФО extract) -----
|
||||||
|
// brouter-web's own POI overlays query live Overpass, which is unreachable from this
|
||||||
|
// network. These are static points (tools/poi/make_poi.sh), each category a NATIVE overlay
|
||||||
|
// checkbox with an emoji icon marker. The 4 MB GeoJSON loads once, on first category on.
|
||||||
|
// All off by default (native convention). Regenerate: rerun make_poi.sh, re-copy the file.
|
||||||
|
// z = минимальный зум, с которого категорию показываем (плотные - только вблизи, чтобы не
|
||||||
|
// захламлять карту и не плодить тысячи маркеров при отдалении).
|
||||||
|
var POI_CATS = [
|
||||||
|
{ k: "water", i: "💧", t: "вода", z: 12 },
|
||||||
|
{ k: "food", i: "🍴", t: "еда", z: 13 },
|
||||||
|
{ k: "fuel", i: "⛽", t: "АЗС", z: 12 },
|
||||||
|
{ k: "toilets", i: "🚻", t: "туалеты", z: 13 },
|
||||||
|
{ k: "pharmacy", i: "💊", t: "аптеки", z: 13 },
|
||||||
|
{ k: "shelter", i: "⛺", t: "навесы", z: 12 },
|
||||||
|
{ k: "bike", i: "🚲", t: "веломаг", z: 11 },
|
||||||
|
{ k: "shop", i: "🛒", t: "магазины", z: 14 },
|
||||||
|
{ k: "sight", i: "⭐", t: "интересное", z: 13 }
|
||||||
|
];
|
||||||
|
var poi = { data: null, loading: false, cbs: [], enabled: {}, groups: {}, moveHooked: false };
|
||||||
|
function debounce(fn, ms) { var t; return function () { var a = arguments, c = this; clearTimeout(t); t = setTimeout(function () { fn.apply(c, a); }, ms); }; }
|
||||||
|
function ensurePoiData(cb) {
|
||||||
|
if (poi.data) { cb(); return; }
|
||||||
|
poi.cbs.push(cb);
|
||||||
|
if (poi.loading) return;
|
||||||
|
poi.loading = true; bwToast("Загружаю точки…");
|
||||||
|
fetch("poi-nevz.geojson").then(function (r) { return r.json(); }).then(function (gj) {
|
||||||
|
var g = {}; POI_CATS.forEach(function (x) { g[x.k] = []; });
|
||||||
|
(gj.features || []).forEach(function (f) {
|
||||||
|
var c = f.properties && f.properties.cat; if (g[c]) g[c].push(f);
|
||||||
|
});
|
||||||
|
poi.data = g; poi.loading = false;
|
||||||
|
var cbs = poi.cbs; poi.cbs = []; cbs.forEach(function (fn) { fn(); });
|
||||||
|
}).catch(function () { poi.loading = false; poi.cbs = []; bwToast("Не удалось загрузить точки"); });
|
||||||
|
}
|
||||||
|
function poiIcon(k) {
|
||||||
|
return L.divIcon({ className: "baldav-poi-icon", html: "<span>" + POI_LABEL[k].i + "</span>",
|
||||||
|
iconSize: [22, 22], iconAnchor: [11, 11], popupAnchor: [0, -10] });
|
||||||
|
}
|
||||||
|
// Viewport culling: a category's layerGroup holds ONLY the markers inside the current view
|
||||||
|
// (+margin). On every pan/zoom we rebuild the visible markers, so even 17k shops cost only a
|
||||||
|
// screenful of DOM nodes instead of all at once.
|
||||||
|
function cullCat(m, cat, bounds) {
|
||||||
|
var group = poi.groups[cat.k];
|
||||||
|
if (!group) return;
|
||||||
|
group.clearLayers();
|
||||||
|
if (!poi.enabled[cat.k] || !poi.data) return;
|
||||||
|
if (m.getZoom() < cat.z) return; // too far out for this category — show nothing
|
||||||
|
var ic = poiIcon(cat.k), feats = poi.data[cat.k], added = 0;
|
||||||
|
for (var i = 0; i < feats.length; i++) {
|
||||||
|
var c = feats[i].geometry.coordinates; // [lon, lat]
|
||||||
|
if (!bounds.contains([c[1], c[0]])) continue;
|
||||||
|
var nm = (feats[i].properties && feats[i].properties.name) || "(без названия)";
|
||||||
|
L.marker([c[1], c[0]], { icon: ic }).bindPopup("<b>" + nm + "</b><br>" + cat.t).addTo(group);
|
||||||
|
if (++added > 800) break; // backstop for dense in-view areas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cullPoi(m) {
|
||||||
|
if (!poi.data) return;
|
||||||
|
var b = m.getBounds().pad(0.25);
|
||||||
|
POI_CATS.forEach(function (cat) { if (poi.enabled[cat.k]) cullCat(m, cat, b); });
|
||||||
|
}
|
||||||
|
// Each category is an empty layerGroup; enabling it marks the category on and culls to view.
|
||||||
|
function poiOverlay(m, cat) {
|
||||||
|
var group = L.layerGroup();
|
||||||
|
poi.groups[cat.k] = group;
|
||||||
|
if (!poi.moveHooked) { poi.moveHooked = true; m.on("moveend zoomend", debounce(function () { cullPoi(m); }, 150)); }
|
||||||
|
group.on("add", function () {
|
||||||
|
poi.enabled[cat.k] = true;
|
||||||
|
ensurePoiData(function () { cullCat(m, cat, m.getBounds().pad(0.25)); });
|
||||||
|
});
|
||||||
|
group.on("remove", function () { poi.enabled[cat.k] = false; group.clearLayers(); });
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
var POI_LABEL = {}; POI_CATS.forEach(function (x) { POI_LABEL[x.k] = x; });
|
||||||
|
|
||||||
|
// brouter-web attaches its layers control into the sidebar without map.addControl, so the
|
||||||
|
// addControl hook can miss it. Fallback: the control listens to map events, so recover it
|
||||||
|
// from any handler whose context exposes addOverlay()+_layers (the standard L.Control.Layers).
|
||||||
|
function findLayersControl(m) {
|
||||||
|
if (window.BR && BR.__layersControl) return BR.__layersControl;
|
||||||
|
var ev = m._events || {};
|
||||||
|
for (var name in ev) {
|
||||||
|
var arr = ev[name]; if (!arr || !arr.length) continue;
|
||||||
|
for (var i = 0; i < arr.length; i++) {
|
||||||
|
var ctx = arr[i] && arr[i].ctx;
|
||||||
|
if (ctx && typeof ctx.addOverlay === "function" && ctx._layers) { BR.__layersControl = ctx; return ctx; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function hookLayers() {
|
||||||
|
if (layersHooked) return true;
|
||||||
|
var m = window.BR && BR.__map;
|
||||||
|
if (!m || !window.L) return false;
|
||||||
|
var lc = findLayersControl(m);
|
||||||
|
if (!lc || typeof lc.addOverlay !== "function") return false;
|
||||||
|
lc.addOverlay(surfaceOverlay(m), '<span class="baldav-ov">🛣️</span> Покрытие (трек)');
|
||||||
|
lc.addOverlay(heightsOverlay(m), '<span class="baldav-ov">⛰️</span> Высоты (заливка)');
|
||||||
|
POI_CATS.forEach(function (cat) {
|
||||||
|
lc.addOverlay(poiOverlay(m, cat), '<span class="baldav-ov">' + cat.i + '</span> ' + cat.t);
|
||||||
|
});
|
||||||
|
layersHooked = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
registerRouteQuality();
|
||||||
|
injectStyle();
|
||||||
|
apply();
|
||||||
|
var tries = 0;
|
||||||
|
var iv = setInterval(function () {
|
||||||
|
tries++;
|
||||||
|
var b = addButton(); var o = setDefaultOverlays();
|
||||||
|
// If the control got built before our hook (shouldn't, but be safe), patch it live.
|
||||||
|
if (window.BR && BR.__quality && !BR.__quality.__baldavSlope) {
|
||||||
|
if (applySlopeProvider(BR.__quality)) BR.__quality.__baldavSlope = true;
|
||||||
|
}
|
||||||
|
var q = enableRouteQuality();
|
||||||
|
var cc = hookCoordControl();
|
||||||
|
var sl = hookLayers();
|
||||||
|
if ((b && o && q && cc && sl) || tries > 60) clearInterval(iv);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
// Register the route-quality hook AT SCRIPT-EXECUTION TIME (bw.js has already defined
|
||||||
|
// BR.RoutingPathQuality by now, and the control is only constructed later on DOM ready) —
|
||||||
|
// so the slope palette is in place before its first draw.
|
||||||
|
registerRouteQuality();
|
||||||
|
if (document.readyState !== "loading") init(); else document.addEventListener("DOMContentLoaded", init);
|
||||||
|
})();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue