Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const config: ExpoConfig = {
"Tracky uses your location to show nearby stations and your position on the map.",
NSLocationAlwaysAndWhenInUseUsageDescription:
"Tracky uses your location to show nearby stations and your position on the map.",
UIAppFonts: [
"Ionicons.ttf",
"MaterialCommunityIcons.ttf",
"MaterialIcons.ttf",
"FontAwesome6_Solid.ttf",
],
},
privacyManifests: {
NSPrivacyAccessedAPITypes: [
Expand Down Expand Up @@ -52,12 +58,6 @@ const config: ExpoConfig = {
],
},
plugins: [
[
"react-native-maps",
{
androidGoogleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
},
],
[
"expo-splash-screen",
{
Expand Down Expand Up @@ -88,6 +88,7 @@ const config: ExpoConfig = {
["expo-background-fetch"],
"expo-font",
"expo-web-browser",
"@maplibre/maplibre-react-native",
[
"expo-widgets",
{
Expand Down
32 changes: 22 additions & 10 deletions apps/mobile/components/map/AnimatedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Polyline } from 'react-native-maps';
import { GeoJSONSource, Layer } from '@maplibre/maplibre-react-native';

interface Coordinate {
latitude: number;
Expand All @@ -15,15 +15,27 @@ interface AnimatedRouteProps {
}

export const AnimatedRoute = React.memo(function AnimatedRoute({ id, coordinates, strokeColor, strokeWidth }: AnimatedRouteProps) {
const geoJSON: GeoJSON.Feature<GeoJSON.LineString> = {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: coordinates.map(c => [c.longitude, c.latitude]),
},
properties: {},
};

return (
<Polyline
key={id}
coordinates={coordinates}
strokeColor={strokeColor}
strokeWidth={strokeWidth}
lineCap="round"
lineJoin="round"
geodesic={true}
/>
<GeoJSONSource id={`route-src-${id}`} data={geoJSON}>
<Layer
id={`route-line-${id}`}
type="line"
style={{
lineColor: strokeColor,
lineWidth: strokeWidth,
lineCap: 'round',
lineJoin: 'round',
}}
/>
</GeoJSONSource>
);
});
64 changes: 29 additions & 35 deletions apps/mobile/components/map/AnimatedStationMarker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { Animated, StyleSheet, Text } from 'react-native';
import { Marker } from 'react-native-maps';
import { Marker } from '@maplibre/maplibre-react-native';
import { TouchableOpacity } from 'react-native';
import Ionicons from 'react-native-vector-icons/Ionicons';

interface StationCluster {
Expand Down Expand Up @@ -42,8 +43,6 @@ export const AnimatedStationMarker = React.memo(function AnimatedStationMarker({
const scaleAnim = useRef(new Animated.Value(0.8)).current;
const [currentDisplay, setCurrentDisplay] = useState(displayName);
const [currentIsCluster, setCurrentIsCluster] = useState(cluster.isCluster);
const [tracksChanges, setTracksChanges] = useState(true);

useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
Expand All @@ -57,14 +56,11 @@ export const AnimatedStationMarker = React.memo(function AnimatedStationMarker({
tension: 100,
useNativeDriver: true,
}),
]).start(() => {
setTracksChanges(false);
});
]).start();
}, []);

useEffect(() => {
if (displayName !== currentDisplay || cluster.isCluster !== currentIsCluster) {
setTracksChanges(true);
Animated.sequence([
Animated.parallel([
Animated.timing(fadeAnim, {
Expand Down Expand Up @@ -94,9 +90,7 @@ export const AnimatedStationMarker = React.memo(function AnimatedStationMarker({
tension: 100,
useNativeDriver: true,
}),
]).start(() => {
setTracksChanges(false);
});
]).start();
});
}
}, [displayName, cluster.isCluster, currentDisplay, currentIsCluster]);
Expand All @@ -108,35 +102,35 @@ export const AnimatedStationMarker = React.memo(function AnimatedStationMarker({
return (
<Marker
key={cluster.id}
coordinate={{ latitude: cluster.lat, longitude: cluster.lon }}
anchor={{ x: 0.5, y: 0.5 }}
onPress={handlePress}
tracksViewChanges={tracksChanges}
id={cluster.id}
lngLat={[cluster.lon, cluster.lat]}
>
<Animated.View
style={[
markerStyles.container,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
<Ionicons
name="location"
size={24}
color={color}
/>
<Text
<TouchableOpacity onPress={handlePress} activeOpacity={0.7}>
<Animated.View
style={[
currentIsCluster ? markerStyles.clusterLabel : markerStyles.stationLabel,
{ color },
markerStyles.container,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
numberOfLines={1}
>
{currentDisplay}
</Text>
</Animated.View>
<Ionicons
name="location"
size={24}
color={color}
/>
<Text
style={[
currentIsCluster ? markerStyles.clusterLabel : markerStyles.stationLabel,
{ color },
]}
numberOfLines={1}
>
{currentDisplay}
</Text>
</Animated.View>
</TouchableOpacity>
</Marker>
);
});
45 changes: 9 additions & 36 deletions apps/mobile/components/map/LiveTrainMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
*/

import React, { useEffect, useRef, useState } from 'react';
import { Animated, StyleSheet, Text } from 'react-native';
import { AnimatedRegion, Marker } from 'react-native-maps';
import { Animated, StyleSheet, Text, TouchableOpacity } from 'react-native';
import { Marker } from '@maplibre/maplibre-react-native';
import { TrainIcon } from '../TrainIcon';

interface LiveTrainMarkerProps {
Expand Down Expand Up @@ -68,17 +68,8 @@ export const LiveTrainMarker = React.memo(function LiveTrainMarker({
const fadeAnim = useRef(new Animated.Value(0)).current;
const scaleAnim = useRef(new Animated.Value(0.8)).current;

const animatedCoordinate = useRef(new AnimatedRegion({
latitude: coordinate.latitude,
longitude: coordinate.longitude,
latitudeDelta: 0,
longitudeDelta: 0,
})).current;
const isFirstRender = useRef(true);

const [currentLabel, setCurrentLabel] = useState(isCluster ? `${clusterCount}+` : trainNumber);
const [currentIsCluster, setCurrentIsCluster] = useState(isCluster);
const [tracksChanges, setTracksChanges] = useState(true);

const iconColor = color;

Expand All @@ -95,30 +86,12 @@ export const LiveTrainMarker = React.memo(function LiveTrainMarker({
tension: 100,
useNativeDriver: true,
}),
]).start(() => {
setTracksChanges(false);
});
]).start();
}, []);

useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
(animatedCoordinate.timing as any)({
latitude: coordinate.latitude,
longitude: coordinate.longitude,
latitudeDelta: 0,
longitudeDelta: 0,
duration: 1000,
useNativeDriver: false,
}).start();
}, [coordinate.latitude, coordinate.longitude]);

const newLabel = isCluster ? `${clusterCount}+` : trainNumber;
useEffect(() => {
if (newLabel !== currentLabel || isCluster !== currentIsCluster) {
setTracksChanges(true);
Animated.sequence([
Animated.parallel([
Animated.timing(fadeAnim, {
Expand Down Expand Up @@ -148,15 +121,14 @@ export const LiveTrainMarker = React.memo(function LiveTrainMarker({
tension: 100,
useNativeDriver: true,
}),
]).start(() => {
setTracksChanges(false);
});
]).start();
});
}
}, [newLabel, isCluster, currentLabel, currentIsCluster]);

return (
<Marker.Animated coordinate={animatedCoordinate as any} onPress={onPress} anchor={{ x: 0.5, y: 0.5 }} tracksViewChanges={tracksChanges}>
<Marker lngLat={[coordinate.longitude, coordinate.latitude]}>
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
<Animated.View
style={[
markerStyles.container,
Expand All @@ -167,7 +139,7 @@ export const LiveTrainMarker = React.memo(function LiveTrainMarker({
]}
>
<TrainIcon
name={routeName}
name={routeName ?? undefined}
size={24}
color={iconColor}
/>
Expand All @@ -181,6 +153,7 @@ export const LiveTrainMarker = React.memo(function LiveTrainMarker({
{currentLabel}
</Text>
</Animated.View>
</Marker.Animated>
</TouchableOpacity>
</Marker>
);
}, arePropsEqual);
2 changes: 0 additions & 2 deletions apps/mobile/components/map/RouteOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

import React, { useMemo } from 'react';
import Svg, { Path } from 'react-native-svg';
import type { LatLng, MapView as MapViewType } from 'react-native-maps';

export interface RouteShape {
id: string;
Expand All @@ -15,7 +14,6 @@ export interface RouteShape {

export interface RouteOverlayProps {
routes: RouteShape[];
mapRef: React.RefObject<MapViewType>;
viewport: {
latitude: number;
longitude: number;
Expand Down
27 changes: 6 additions & 21 deletions apps/mobile/constants/map-styles.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
/** Google Maps "Night" dark style */
export const darkMapStyle = [
{ elementType: 'geometry', stylers: [{ color: '#242f3e' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#746855' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#242f3e' }] },
{ featureType: 'administrative.locality', elementType: 'labels.text.fill', stylers: [{ color: '#d59563' }] },
{ featureType: 'poi', elementType: 'labels.text.fill', stylers: [{ color: '#d59563' }] },
{ featureType: 'poi.park', elementType: 'geometry', stylers: [{ color: '#263c3f' }] },
{ featureType: 'poi.park', elementType: 'labels.text.fill', stylers: [{ color: '#6b9a76' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#38414e' }] },
{ featureType: 'road', elementType: 'geometry.stroke', stylers: [{ color: '#212a37' }] },
{ featureType: 'road', elementType: 'labels.text.fill', stylers: [{ color: '#9ca5b3' }] },
{ featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#746855' }] },
{ featureType: 'road.highway', elementType: 'geometry.stroke', stylers: [{ color: '#1f2835' }] },
{ featureType: 'road.highway', elementType: 'labels.text.fill', stylers: [{ color: '#f3d19c' }] },
{ featureType: 'transit', elementType: 'geometry', stylers: [{ color: '#2f3948' }] },
{ featureType: 'transit.station', elementType: 'labels.text.fill', stylers: [{ color: '#d59563' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#17263c' }] },
{ featureType: 'water', elementType: 'labels.text.fill', stylers: [{ color: '#515c6d' }] },
{ featureType: 'water', elementType: 'labels.text.stroke', stylers: [{ color: '#17263c' }] },
];
/** MapLibre vector tile style URLs (CARTO - free, no API key required) */
export const MAP_STYLE = {
standard: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
satellite: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
};
17 changes: 17 additions & 0 deletions apps/mobile/constants/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,22 @@ export const ANDROID_STAGGER_DELAY = 100;
/** Loading overlay fade duration (ms) */
export const LOADING_FADE_DURATION = 400;

/** Default zoom level when focusing on a train or station (MapLibre) */
export const FOCUS_ZOOM_LEVEL = 12;

/** Initial zoom level (MapLibre) */
export const INITIAL_ZOOM_LEVEL = 11;

/** Convert latitudeDelta to MapLibre zoom level */
export function latDeltaToZoom(latitudeDelta: number): number {
if (latitudeDelta <= 0) return 20; // Max zoom as fallback
return Math.log2(360 / latitudeDelta) - 1;
}
Comment thread
Mr-Technician marked this conversation as resolved.

/** Convert MapLibre zoom level to approximate latitudeDelta */
export function zoomToLatDelta(zoom: number): number {
return 360 / Math.pow(2, zoom + 1);
}

/** Quick swipe velocity threshold for snap detection */
export const QUICK_SWIPE_VELOCITY = 1000;
13 changes: 7 additions & 6 deletions apps/mobile/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,19 @@ jest.mock('expo-haptics', () => ({
},
}));

// Mock react-native-maps
jest.mock('react-native-maps', () => {
// Mock MapLibre
jest.mock('@maplibre/maplibre-react-native', () => {
const React = require('react');
const { View } = require('react-native');

return {
__esModule: true,
default: props => React.createElement(View, props),
Map: props => React.createElement(View, props),
Camera: props => React.createElement(View, props),
UserLocation: props => React.createElement(View, props),
GeoJSONSource: props => React.createElement(View, props),
Layer: props => React.createElement(View, props),
Marker: props => React.createElement(View, props),
Polyline: props => React.createElement(View, props),
PROVIDER_DEFAULT: 'default',
PROVIDER_GOOGLE: 'google',
};
});

Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@expo/ngrok": "^4.1.3",
"@expo/ui": "~55.0.2",
"@maplibre/maplibre-react-native": "^11.0.2",
"@photostructure/tz-lookup": "^11.4.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/datetimepicker": "^8.6.0",
Expand All @@ -40,15 +41,14 @@
"expo-system-ui": "~55.0.9",
"expo-task-manager": "~55.0.9",
"expo-web-browser": "~55.0.9",
"expo-widgets": "^55.0.2",
"expo-widgets": "55.0.4",
"fflate": "^0.8.2",
"gtfs-realtime-bindings": "^1.1.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-calendars": "^1.1314.0",
"react-native-gesture-handler": "~2.30.0",
"react-native-maps": "1.26.20",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
Expand Down
Loading