Redesign UI as an OsmAnd-style sidebar, add live GNSS position via gpsd
Restructure the map controls from MapLibre corner buttons into a left sidebar of icons with flyouts/panels (radar, GPX, GNSS), matching OsmAnd's layout more closely: - Base map and GPX selection are now full-height side panels docked next to the sidebar (not small dropdown flyouts or a right-docked panel), opening/closing exclusively via a small shared event bus (panels.js) so only one is ever open at a time. - The GPX panel's upload/new-folder controls are now icon buttons in the header instead of an always-visible dropzone and form; dragging a GPX file onto the tree, a specific folder, or straight onto the map itself all upload it. - New: Maptool::GpsdClient (lib/maptool/gpsd_client.rb) streams live GNSS fixes from a local gpsd daemon over Server-Sent Events (/api/gnss/stream) to a marker on the map (public/js/gnss.js) — one background gpsd connection shared by every browser tab. - The online map style (public/js/osmand-style.js) is now styled to resemble OsmAnd's own rendering, applied to both the online and offline (PMTiles) styles. Also adds a GeolocateControl (browser geolocation) next to the zoom controls.
This commit is contained in:
1 parent
008e034932
commit
87df90f75c
14 files changed
+939
-200
No files matched your search
@@ -0,0 +1,160 @@
|
||||
// Approximates the look of OsmAnd's own map render (osmand.net/map — muted
|
||||
// earth tones, cream background, orange roads) without depending on their
|
||||
// tile server: OsmAnd's terms restrict tile use to "personal, non-commercial
|
||||
// use within OsmAnd applications and platforms", which this app isn't.
|
||||
//
|
||||
// One shared palette drives two independent recolorings so Online and
|
||||
// Offline look consistent: OpenFreeMap's "liberty" vector style (paint
|
||||
// colors patched in place, see recolorLayer) for Online, and a custom
|
||||
// protomaps-themes-base Theme (see offlineTheme) for Offline.
|
||||
|
||||
const PALETTE = {
|
||||
background: "#f1ecd7",
|
||||
water: "#aad3df",
|
||||
wood: "#c8dfb0",
|
||||
park: "#d3e8b8",
|
||||
landuse: "#ece4c6",
|
||||
building: "#d9cead",
|
||||
buildingOutline: "#c7bb92",
|
||||
roadMajor: "#eb9d43",
|
||||
roadMajorCasing: "#c9822c",
|
||||
roadMinor: "#fdf8ee",
|
||||
roadMinorCasing: "#cfc09a",
|
||||
boundary: "#a98fc2",
|
||||
};
|
||||
|
||||
const LIBERTY_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty";
|
||||
let cachedOnlineStyle = null;
|
||||
|
||||
// Fetches OpenFreeMap's "liberty" style and recolors it in place. Cached —
|
||||
// the recoloring is pure, and re-fetching on every Online/Offline toggle
|
||||
// would be wasteful.
|
||||
export async function onlineStyle() {
|
||||
if (cachedOnlineStyle) return cachedOnlineStyle;
|
||||
|
||||
const response = await fetch(LIBERTY_STYLE_URL);
|
||||
const style = await response.json();
|
||||
style.layers = style.layers.map(recolorLayer);
|
||||
cachedOnlineStyle = style;
|
||||
return style;
|
||||
}
|
||||
|
||||
// Overrides the color roles that define the "look" on top of a
|
||||
// protomaps-themes-base Theme (e.g. namedTheme("light")), leaving roles it
|
||||
// doesn't set (labels, rail, tunnels, POI colors) at their defaults.
|
||||
export function offlineTheme(baseTheme) {
|
||||
return {
|
||||
...baseTheme,
|
||||
background: PALETTE.background,
|
||||
earth: PALETTE.background,
|
||||
water: PALETTE.water,
|
||||
wood_a: PALETTE.wood,
|
||||
wood_b: PALETTE.wood,
|
||||
park_a: PALETTE.park,
|
||||
park_b: PALETTE.park,
|
||||
scrub_a: PALETTE.park,
|
||||
scrub_b: PALETTE.park,
|
||||
pedestrian: PALETTE.landuse,
|
||||
industrial: PALETTE.landuse,
|
||||
school: PALETTE.landuse,
|
||||
hospital: PALETTE.landuse,
|
||||
buildings: PALETTE.building,
|
||||
highway_casing_early: PALETTE.roadMajorCasing,
|
||||
highway_casing_late: PALETTE.roadMajorCasing,
|
||||
highway: PALETTE.roadMajor,
|
||||
major_casing_early: PALETTE.roadMajorCasing,
|
||||
major_casing_late: PALETTE.roadMajorCasing,
|
||||
major: PALETTE.roadMajor,
|
||||
link_casing: PALETTE.roadMajorCasing,
|
||||
link: PALETTE.roadMajor,
|
||||
minor_casing: PALETTE.roadMinorCasing,
|
||||
minor_service_casing: PALETTE.roadMinorCasing,
|
||||
minor_a: PALETTE.roadMinor,
|
||||
minor_b: PALETTE.roadMinor,
|
||||
other: PALETTE.roadMinor,
|
||||
minor_service: PALETTE.roadMinor,
|
||||
boundaries: PALETTE.boundary,
|
||||
};
|
||||
}
|
||||
|
||||
const FILL_PROP = /^(background-color|fill-color|line-color|fill-extrusion-color)$/;
|
||||
const OUTLINE_PROP = /outline-color$/;
|
||||
|
||||
const FILL_COLOR = {
|
||||
background: PALETTE.background,
|
||||
water: PALETTE.water,
|
||||
wood: PALETTE.wood,
|
||||
park: PALETTE.park,
|
||||
landuse: PALETTE.landuse,
|
||||
building: PALETTE.building,
|
||||
roadMajor: PALETTE.roadMajor,
|
||||
roadMinor: PALETTE.roadMinor,
|
||||
boundary: PALETTE.boundary,
|
||||
};
|
||||
|
||||
const OUTLINE_COLOR = {
|
||||
building: PALETTE.buildingOutline,
|
||||
roadMajor: PALETTE.roadMajorCasing,
|
||||
roadMinor: PALETTE.roadMinorCasing,
|
||||
park: "#bcdb9a",
|
||||
};
|
||||
|
||||
// Maps a "liberty" style layer id to one of the categories above, based on
|
||||
// the id naming conventions OpenFreeMap's style uses (verified against its
|
||||
// actual layer list — see docs/architecture.adoc). Returns null for
|
||||
// anything left at its original color: labels/icons (all "symbol" layers,
|
||||
// filtered by type below before this even runs), railways, and minor
|
||||
// terrain fills (ice/wetland/sand) that aren't part of OsmAnd's signature
|
||||
// look.
|
||||
function categoryFor(layerId) {
|
||||
if (layerId === "background") return "background";
|
||||
if (/rail/.test(layerId)) return null;
|
||||
if (/^water/.test(layerId)) return "water";
|
||||
if (/^landcover_wood/.test(layerId)) return "wood";
|
||||
if (/^(park|landcover_grass)/.test(layerId)) return "park";
|
||||
if (/^landuse/.test(layerId)) return "landuse";
|
||||
if (/^building/.test(layerId)) return "building";
|
||||
if (/^(road|bridge|tunnel)_(motorway|trunk_primary)/.test(layerId)) return "roadMajor";
|
||||
if (/^(road|bridge|tunnel)_/.test(layerId)) return "roadMinor";
|
||||
if (/^boundary/.test(layerId)) return "boundary";
|
||||
return null;
|
||||
}
|
||||
|
||||
const RECOLORABLE_TYPES = new Set(["background", "fill", "line", "fill-extrusion"]);
|
||||
|
||||
function recolorLayer(layer) {
|
||||
if (!RECOLORABLE_TYPES.has(layer.type) || !layer.paint) return layer;
|
||||
|
||||
const category = categoryFor(layer.id);
|
||||
if (!category) return layer;
|
||||
|
||||
const paint = { ...layer.paint };
|
||||
for (const [prop, value] of Object.entries(paint)) {
|
||||
if (OUTLINE_PROP.test(prop) && OUTLINE_COLOR[category]) {
|
||||
paint[prop] = replaceColors(value, OUTLINE_COLOR[category]);
|
||||
} else if (FILL_PROP.test(prop)) {
|
||||
paint[prop] = replaceColors(value, FILL_COLOR[category]);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...layer, paint };
|
||||
}
|
||||
|
||||
const COLOR_PATTERN = /^#[0-9a-f]{3,8}$/i;
|
||||
const COLOR_FN_PATTERN = /^(rgb|hsl)a?\(/i;
|
||||
|
||||
// Walks a paint value — a flat color string, or a MapLibre expression like
|
||||
// ["interpolate", ["linear"], ["zoom"], 5, "hsl(26,87%,62%)", 6, "#fc8"] —
|
||||
// and swaps every color-shaped string for `newColor`, leaving operators,
|
||||
// zoom/get references and numbers untouched. Generic across expression
|
||||
// types (interpolate/match/case/step/...) since it never inspects the
|
||||
// expression's operator, only whether each leaf looks like a color.
|
||||
function replaceColors(value, newColor) {
|
||||
if (typeof value === "string") {
|
||||
return COLOR_PATTERN.test(value) || COLOR_FN_PATTERN.test(value) ? newColor : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => replaceColors(item, newColor));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in new issue
Block a user