feat: multiple map tile styles switchable in Settings > Interface
New src/mapStyles.ts defines five styles from bincio_planner's sources: - Liberty (OpenFreeMap vector, default) - CyclOSM (cycling infrastructure raster) - Topo (OpenTopoMap elevation raster) - Satellite (Esri World Imagery raster) - OSM (standard raster fallback) Raster sources are wrapped in a StyleSpecification so MapLibre handles them natively. Setting persisted via ThemeContext (AsyncStorage key mapTileStyle). RecordingScreen and ActivityDetailScreen both read from context so the style updates everywhere simultaneously. MAP_STRATEGY.md added (untracked) with full map roadmap.
This commit is contained in:
@@ -39,3 +39,4 @@ yarn-error.*
|
|||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
MAP_STRATEGY.md
|
||||||
|
|||||||
+11
-1
@@ -1,6 +1,7 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
|
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
|
||||||
|
import { MAP_STYLES, type MapTileStyle } from './mapStyles';
|
||||||
|
|
||||||
export type MapOrientation = 'north' | 'compass' | 'course';
|
export type MapOrientation = 'north' | 'compass' | 'course';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ interface ThemeValue {
|
|||||||
scale: number;
|
scale: number;
|
||||||
mapOrientation: MapOrientation;
|
mapOrientation: MapOrientation;
|
||||||
setMapOrientation: (o: MapOrientation) => void;
|
setMapOrientation: (o: MapOrientation) => void;
|
||||||
|
mapTileStyle: MapTileStyle;
|
||||||
|
setMapTileStyle: (s: MapTileStyle) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_ORIENTATIONS: MapOrientation[] = ['north', 'compass', 'course'];
|
const MAP_ORIENTATIONS: MapOrientation[] = ['north', 'compass', 'course'];
|
||||||
@@ -31,6 +34,8 @@ const ThemeContext = createContext<ThemeValue>({
|
|||||||
scale: 1,
|
scale: 1,
|
||||||
mapOrientation: 'north',
|
mapOrientation: 'north',
|
||||||
setMapOrientation: () => {},
|
setMapOrientation: () => {},
|
||||||
|
mapTileStyle: 'liberty',
|
||||||
|
setMapTileStyle: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -38,20 +43,23 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
|
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
|
||||||
const [boldLabels, setBoldLabelsState] = useState(false);
|
const [boldLabels, setBoldLabelsState] = useState(false);
|
||||||
const [mapOrientation, setMapOrientationState] = useState<MapOrientation>('north');
|
const [mapOrientation, setMapOrientationState] = useState<MapOrientation>('north');
|
||||||
|
const [mapTileStyle, setMapTileStyleState] = useState<MapTileStyle>('liberty');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const [p, f, b, m] = await Promise.all([
|
const [p, f, b, m, t] = await Promise.all([
|
||||||
AsyncStorage.getItem('themePalette'),
|
AsyncStorage.getItem('themePalette'),
|
||||||
AsyncStorage.getItem('themeFontSize'),
|
AsyncStorage.getItem('themeFontSize'),
|
||||||
AsyncStorage.getItem('themeBoldLabels'),
|
AsyncStorage.getItem('themeBoldLabels'),
|
||||||
AsyncStorage.getItem('mapOrientation'),
|
AsyncStorage.getItem('mapOrientation'),
|
||||||
|
AsyncStorage.getItem('mapTileStyle'),
|
||||||
]);
|
]);
|
||||||
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
|
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
|
||||||
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
|
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
|
||||||
if (b !== null) setBoldLabelsState(b === 'true');
|
if (b !== null) setBoldLabelsState(b === 'true');
|
||||||
if (m && MAP_ORIENTATIONS.includes(m as MapOrientation))
|
if (m && MAP_ORIENTATIONS.includes(m as MapOrientation))
|
||||||
setMapOrientationState(m as MapOrientation);
|
setMapOrientationState(m as MapOrientation);
|
||||||
|
if (t && t in MAP_STYLES) setMapTileStyleState(t as MapTileStyle);
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -59,6 +67,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
function setFontSize(f: FontSizeKey) { setFontSizeState(f); AsyncStorage.setItem('themeFontSize', f); }
|
function setFontSize(f: FontSizeKey) { setFontSizeState(f); AsyncStorage.setItem('themeFontSize', f); }
|
||||||
function setBoldLabels(b: boolean) { setBoldLabelsState(b); AsyncStorage.setItem('themeBoldLabels', String(b)); }
|
function setBoldLabels(b: boolean) { setBoldLabelsState(b); AsyncStorage.setItem('themeBoldLabels', String(b)); }
|
||||||
function setMapOrientation(o: MapOrientation) { setMapOrientationState(o); AsyncStorage.setItem('mapOrientation', o); }
|
function setMapOrientation(o: MapOrientation) { setMapOrientationState(o); AsyncStorage.setItem('mapOrientation', o); }
|
||||||
|
function setMapTileStyle(s: MapTileStyle) { setMapTileStyleState(s); AsyncStorage.setItem('mapTileStyle', s); }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{
|
<ThemeContext.Provider value={{
|
||||||
@@ -69,6 +78,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
boldLabels, setBoldLabels,
|
boldLabels, setBoldLabels,
|
||||||
scale: FONT_SCALE[fontSize],
|
scale: FONT_SCALE[fontSize],
|
||||||
mapOrientation, setMapOrientation,
|
mapOrientation, setMapOrientation,
|
||||||
|
mapTileStyle, setMapTileStyle,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { StyleSpecification } from '@maplibre/maplibre-gl-style-spec';
|
||||||
|
|
||||||
|
export type MapTileStyle = 'liberty' | 'cyclosm' | 'topo' | 'satellite' | 'osm';
|
||||||
|
|
||||||
|
export interface MapStyleDef {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
style: string | StyleSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rasterStyle(tiles: string[], attribution: string): StyleSpecification {
|
||||||
|
return {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
base: { type: 'raster', tiles, tileSize: 256, attribution },
|
||||||
|
},
|
||||||
|
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
||||||
|
} as StyleSpecification;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAP_STYLES: Record<MapTileStyle, MapStyleDef> = {
|
||||||
|
liberty: {
|
||||||
|
label: 'Liberty',
|
||||||
|
description: 'Vector street map',
|
||||||
|
style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||||
|
},
|
||||||
|
cyclosm: {
|
||||||
|
label: 'CyclOSM',
|
||||||
|
description: 'Cycling infrastructure',
|
||||||
|
style: rasterStyle(
|
||||||
|
[
|
||||||
|
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||||
|
'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||||
|
'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||||
|
],
|
||||||
|
'© OpenStreetMap contributors, CyclOSM',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
topo: {
|
||||||
|
label: 'Topo',
|
||||||
|
description: 'Elevation & terrain',
|
||||||
|
style: rasterStyle(
|
||||||
|
[
|
||||||
|
'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||||
|
'https://b.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||||
|
'https://c.tile.opentopomap.org/{z}/{x}/{y}.png',
|
||||||
|
],
|
||||||
|
'© OpenStreetMap contributors, OpenTopoMap',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
satellite: {
|
||||||
|
label: 'Satellite',
|
||||||
|
description: 'Esri World Imagery',
|
||||||
|
style: rasterStyle(
|
||||||
|
['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
|
||||||
|
'© Esri, Maxar, Earthstar Geographics',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
osm: {
|
||||||
|
label: 'OSM',
|
||||||
|
description: 'Standard street map',
|
||||||
|
style: rasterStyle(
|
||||||
|
['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||||
|
'© OpenStreetMap contributors',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAP_TILE_STYLE_ORDER: MapTileStyle[] = ['liberty', 'cyclosm', 'topo', 'satellite', 'osm'];
|
||||||
@@ -16,8 +16,7 @@ import {
|
|||||||
} from '../sports';
|
} from '../sports';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme } from '../ThemeContext';
|
||||||
|
import { MAP_STYLES } from '../mapStyles';
|
||||||
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
|
||||||
|
|
||||||
type Route = RouteProp<RootStackParamList, 'ActivityDetail'>;
|
type Route = RouteProp<RootStackParamList, 'ActivityDetail'>;
|
||||||
|
|
||||||
@@ -57,7 +56,8 @@ function formatDuration(secs: number) {
|
|||||||
|
|
||||||
export function ActivityDetailScreen() {
|
export function ActivityDetailScreen() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const { accent, accentDim } = useTheme();
|
const { accent, accentDim, mapTileStyle } = useTheme();
|
||||||
|
const mapStyle = MAP_STYLES[mapTileStyle].style;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const route = useRoute<Route>();
|
const route = useRoute<Route>();
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export function ActivityDetailScreen() {
|
|||||||
{points === null
|
{points === null
|
||||||
? <View style={styles.center}><ActivityIndicator color={accent} /></View>
|
? <View style={styles.center}><ActivityIndicator color={accent} /></View>
|
||||||
: (
|
: (
|
||||||
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
<Map mapStyle={mapStyle} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
||||||
{bounds && (
|
{bounds && (
|
||||||
<Camera
|
<Camera
|
||||||
initialViewState={{ bounds, padding: { top: 32, bottom: 32, left: 32, right: 32 } }}
|
initialViewState={{ bounds, padding: { top: 32, bottom: 32, left: 32, right: 32 } }}
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import { startGpsRecording, stopGpsRecording, requestLocationPermissions, reques
|
|||||||
import { RootStackParamList } from '../types';
|
import { RootStackParamList } from '../types';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
import { useTheme } from '../ThemeContext';
|
import { useTheme } from '../ThemeContext';
|
||||||
|
import { MAP_STYLES } from '../mapStyles';
|
||||||
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function RecordingScreen() {
|
export function RecordingScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
const { accent, accentDim, scale, boldLabels, mapOrientation } = useTheme();
|
const { accent, accentDim, scale, boldLabels, mapOrientation, mapTileStyle } = useTheme();
|
||||||
|
const mapStyle = MAP_STYLES[mapTileStyle].style;
|
||||||
|
|
||||||
// Map orientation setting → MapLibre trackUserLocation value
|
// Map orientation setting → MapLibre trackUserLocation value
|
||||||
const trackUserLocation = mapOrientation === 'north' ? 'default'
|
const trackUserLocation = mapOrientation === 'north' ? 'default'
|
||||||
@@ -109,7 +109,7 @@ export function RecordingScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.mapArea}>
|
<View style={styles.mapArea}>
|
||||||
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
<Map mapStyle={mapStyle} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
||||||
<Camera
|
<Camera
|
||||||
trackUserLocation={trackUserLocation}
|
trackUserLocation={trackUserLocation}
|
||||||
initialViewState={{ zoom: 14 }}
|
initialViewState={{ zoom: 14 }}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import { login, logout, loadAuthState } from '../services/auth';
|
import { login, logout, loadAuthState } from '../services/auth';
|
||||||
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
||||||
import { useTheme, type MapOrientation } from '../ThemeContext';
|
import { useTheme, type MapOrientation } from '../ThemeContext';
|
||||||
|
import { MAP_STYLES, MAP_TILE_STYLE_ORDER } from '../mapStyles';
|
||||||
|
|
||||||
type Tab = 'ui' | 'app' | 'sync';
|
type Tab = 'ui' | 'app' | 'sync';
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function SettingsScreen() {
|
|||||||
// ─── UI tab ─────────────────────────────────────────────────────────────────
|
// ─── UI tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function UITab() {
|
function UITab() {
|
||||||
const { accent, palette, setPalette, fontSize, setFontSize, boldLabels, setBoldLabels } = useTheme();
|
const { accent, palette, setPalette, fontSize, setFontSize, boldLabels, setBoldLabels, mapTileStyle, setMapTileStyle } = useTheme();
|
||||||
|
|
||||||
const fontSizes: FontSizeKey[] = ['small', 'medium', 'large'];
|
const fontSizes: FontSizeKey[] = ['small', 'medium', 'large'];
|
||||||
const palettes = Object.entries(PALETTES) as [PaletteKey, typeof PALETTES[PaletteKey]][];
|
const palettes = Object.entries(PALETTES) as [PaletteKey, typeof PALETTES[PaletteKey]][];
|
||||||
@@ -77,6 +78,25 @@ function UITab() {
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Map style</Text>
|
||||||
|
{MAP_TILE_STYLE_ORDER.map((key) => {
|
||||||
|
const def = MAP_STYLES[key];
|
||||||
|
const active = mapTileStyle === key;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={key}
|
||||||
|
style={[styles.row, active && { borderColor: accent, backgroundColor: PALETTES[palette].accentDim }]}
|
||||||
|
onPress={() => setMapTileStyle(key)}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text style={[styles.rowLabel, active && { color: accent }]}>{def.label}</Text>
|
||||||
|
<Text style={styles.rowSub}>{def.description}</Text>
|
||||||
|
</View>
|
||||||
|
{active && <Text style={{ color: accent, fontSize: 16 }}>✓</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Stat labels</Text>
|
<Text style={styles.sectionTitle}>Stat labels</Text>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Text style={styles.rowLabel}>Bold labels</Text>
|
<Text style={styles.rowLabel}>Bold labels</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user