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