Files
maptool/public/js/osmand-style.js
T
randy 87df90f75c 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.
2026-08-25 19:39:13 +02:00

161 lines
5.7 KiB
JavaScript

// 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;
}