add annotation

This commit is contained in:
deng
2025-12-10 19:33:52 +08:00
parent 74e1d96600
commit 145b52490f
9 changed files with 56 additions and 38 deletions

View File

@ -47,5 +47,5 @@ EXPOSE 8800
CMD ["python", "-m", "streamlit", "run", "app.py"] CMD ["python", "-m", "streamlit", "run", "app.py"]
# Build # Build
# docker build --platform=linux/amd64 -t hiking_assistant:1.0.0 . # docker build --platform=linux/amd64 -t hiking_assistant:1.0.1 .
# docker save -o hiking_assistant.tar hiking_assistant:1.0.0 # docker save -o hiking_assistant.tar hiking_assistant:1.0.1

View File

@ -16,14 +16,15 @@ from weather import WeatherFetcher
class HikingAssistant: class HikingAssistant:
def __init__(self): def __init__(self, config_path: str = 'assets/config.yaml') -> None:
self._config = self._load_config() self._config = self._load_config(config_path)
def _load_config(self): @staticmethod
with open('assets/config.yaml', 'r') as f: def _load_config(config_path: str) -> dict:
with open(config_path, 'r') as f:
return yaml.safe_load(f) return yaml.safe_load(f)
def _set_page_background(self, image_path, opacity=0.8): def _set_page_background(self, image_path: str, opacity: float = 0.8) -> None:
"""Set background image for the application. """Set background image for the application.
Args: Args:
@ -46,7 +47,7 @@ class HikingAssistant:
unsafe_allow_html=True, unsafe_allow_html=True,
) )
def _add_custom_title(self, title, image_path): def _add_custom_title(self, title: str, image_path: str) -> None:
favicon_data = convert_image_to_base64(image_path) favicon_data = convert_image_to_base64(image_path)
ext = image_path.split('.')[-1] ext = image_path.split('.')[-1]
st.markdown( st.markdown(
@ -59,7 +60,7 @@ class HikingAssistant:
unsafe_allow_html=True, unsafe_allow_html=True,
) )
def run(self): def run(self) -> None:
"""Hiking Assistant application""" """Hiking Assistant application"""
# Page configuration # Page configuration
st.set_page_config( st.set_page_config(

View File

@ -3,13 +3,15 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import plotly.graph_objects as go import plotly.graph_objects as go
class ElevationRenderer: class ElevationRenderer:
"""Render elevation profiles with gradient-based coloring.""" """Render elevation profiles with gradient-based coloring."""
def __init__(self): def __init__(self) -> None:
"""Initialize elevation profile renderer.""" """Initialize elevation profile renderer."""
self.color = { self.color = {
'steep_ascent': 'rgb(139, 69, 19)', 'steep_ascent': 'rgb(139, 69, 19)',
@ -32,7 +34,7 @@ class ElevationRenderer:
'gentle_descent': '緩下坡 (> -20%)', 'gentle_descent': '緩下坡 (> -20%)',
} }
def _get_gradient_category(self, gradient): def _get_gradient_category(self, gradient: float) -> str:
"""Determine gradient category based on slope thresholds. """Determine gradient category based on slope thresholds.
Args: Args:
@ -50,7 +52,9 @@ class ElevationRenderer:
else: else:
return 'gentle_descent' return 'gentle_descent'
def _downsample_data(self, distances, elevations, gradients, max_points=800): def _downsample_data(
self, distances: list[float], elevations: list[float], gradients: list[float], max_points: int = 800
) -> tuple[list[float], list[float], list[float]]:
"""Downsample data if there are too many points. """Downsample data if there are too many points.
Args: Args:
@ -96,7 +100,7 @@ class ElevationRenderer:
return downsampled_distances, downsampled_elevations, downsampled_gradients return downsampled_distances, downsampled_elevations, downsampled_gradients
def create_elevation_profile(self, distances, elevations, gradients): def create_elevation_profile(self, distances: list[float], elevations: list[float], gradients: list[float]) -> Optional[go.Figure]:
"""Create an interactive elevation profile chart. """Create an interactive elevation profile chart.
Args: Args:
@ -184,7 +188,7 @@ class ElevationRenderer:
return fig return fig
def calculate_gradient_distribution(self, distances, gradients): def calculate_gradient_distribution(self, distances: list[float], gradients: list[float]) -> Optional[dict[str, float]]:
"""Calculate distance distribution across different gradient categories. """Calculate distance distribution across different gradient categories.
Args: Args:
@ -224,7 +228,7 @@ class ElevationRenderer:
'gentle_descent': gentle_descent_km, 'gentle_descent': gentle_descent_km,
} }
def create_gradient_pie_chart(self, distances, gradients): def create_gradient_pie_chart(self, distances: list[float], gradients: list[float]) -> Optional[go.Figure]:
"""Create a pie chart showing gradient distribution. """Create a pie chart showing gradient distribution.
Args: Args:
@ -262,7 +266,7 @@ class ElevationRenderer:
return fig return fig
def add_gradient_legend(self, fig): def add_gradient_legend(self, fig: go.Figure) -> go.Figure:
"""Add a custom legend explaining the color scheme. """Add a custom legend explaining the color scheme.
Args: Args:

View File

@ -3,6 +3,8 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import IO, Optional
import gpxpy import gpxpy
import numpy as np import numpy as np
from geopy.distance import geodesic from geopy.distance import geodesic
@ -11,7 +13,7 @@ from geopy.distance import geodesic
class GPXProcessor: class GPXProcessor:
"""Handle GPX file parsing and route analysis.""" """Handle GPX file parsing and route analysis."""
def __init__(self, gpx_file): def __init__(self, gpx_file: IO) -> None:
"""Initialize GPX processor with uploaded file. """Initialize GPX processor with uploaded file.
Args: Args:
@ -25,7 +27,7 @@ class GPXProcessor:
self.distances = [] # unit: km self.distances = [] # unit: km
self.waypoints = [] # list of (lat, lon, name, elevation) self.waypoints = [] # list of (lat, lon, name, elevation)
def _get_sample_rate(self): def _get_sample_rate(self) -> float:
"""Get the median time interval between points. """Get the median time interval between points.
Returns: Returns:
@ -37,7 +39,7 @@ class GPXProcessor:
median_sample_time = np.median(diff).total_seconds() median_sample_time = np.median(diff).total_seconds()
return median_sample_time return median_sample_time
def validate_and_parse(self): def validate_and_parse(self) -> bool:
"""Validate and parse the GPX file. """Validate and parse the GPX file.
Returns: Returns:
@ -77,7 +79,7 @@ class GPXProcessor:
except Exception as e: except Exception as e:
raise Exception(f'GPX 檔案解析失敗: {str(e)}') raise Exception(f'GPX 檔案解析失敗: {str(e)}')
def calculate_distance(self): def calculate_distance(self) -> float:
"""Calculate total distance of the route. """Calculate total distance of the route.
Returns: Returns:
@ -96,7 +98,7 @@ class GPXProcessor:
return total_distance return total_distance
def calculate_elevation_gain_loss(self, threshold=1.0): def calculate_elevation_gain_loss(self, threshold: float = 1.0) -> tuple[float, float]:
"""Calculate total elevation gain and loss with noise filtering. """Calculate total elevation gain and loss with noise filtering.
GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can
@ -127,7 +129,7 @@ class GPXProcessor:
return gain, loss return gain, loss
def get_min_max_elevation(self): def get_min_max_elevation(self) -> tuple[float, float]:
"""Get minimum and maximum elevation. """Get minimum and maximum elevation.
Returns: Returns:
@ -138,7 +140,7 @@ class GPXProcessor:
return min(self.elevations), max(self.elevations) return min(self.elevations), max(self.elevations)
def get_start_end_points(self): def get_start_end_points(self) -> tuple[Optional[tuple[float, float, float]], Optional[tuple[float, float, float]]]:
"""Get start and end point coordinates with elevation. """Get start and end point coordinates with elevation.
Returns: Returns:
@ -149,7 +151,7 @@ class GPXProcessor:
return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1]) return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1])
def get_all_points(self): def get_all_points(self) -> list[tuple[float, float]]:
"""Get all route points. """Get all route points.
Returns: Returns:
@ -157,7 +159,7 @@ class GPXProcessor:
""" """
return self.points return self.points
def get_elevation_profile_data(self): def get_elevation_profile_data(self) -> tuple[list[float], list[float]]:
"""Get data for elevation profile visualization. """Get data for elevation profile visualization.
Returns: Returns:
@ -165,7 +167,7 @@ class GPXProcessor:
""" """
return self.distances, self.elevations return self.distances, self.elevations
def get_gradients(self): def get_gradients(self) -> list[float]:
"""Calculate gradient (slope) for each segment. """Calculate gradient (slope) for each segment.
Returns: Returns:
@ -188,7 +190,7 @@ class GPXProcessor:
return gradients return gradients
def calculate_naismith_time(self, horizontal_speed=5, vertical_speed=600): def calculate_naismith_time(self, horizontal_speed: float = 5, vertical_speed: float = 600) -> int:
"""Calculate estimated hiking time using Naismith's Rule. """Calculate estimated hiking time using Naismith's Rule.
Naismith's Rule: Naismith's Rule:

View File

@ -3,17 +3,26 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import folium import folium
class MapRenderer: class MapRenderer:
"""Render hiking routes on interactive maps.""" """Render hiking routes on interactive maps."""
def __init__(self): def __init__(self) -> None:
"""Initialize map renderer.""" """Initialize map renderer."""
pass pass
def create_route_map(self, points, start_point, end_point, waypoints=None, tile_layer='OpenStreetMap'): def create_route_map(
self,
points: list[tuple[float, float]],
start_point: Optional[tuple[float, float, float]],
end_point: Optional[tuple[float, float, float]],
waypoints: Optional[list[dict]] = None,
tile_layer: str = 'OpenStreetMap',
) -> Optional[folium.Map]:
"""Create an interactive map with the hiking route. """Create an interactive map with the hiking route.
Args: Args:
@ -117,7 +126,7 @@ class MapRenderer:
return route_map return route_map
def add_hover_marker(self, route_map, position, label='當前位置'): def add_hover_marker(self, route_map: folium.Map, position: tuple[float, float], label: str = '當前位置') -> folium.Map:
"""Add a hover position marker to the map. """Add a hover position marker to the map.
Args: Args:

View File

@ -9,6 +9,6 @@ import streamlit as st
@st.cache_data @st.cache_data
def convert_image_to_base64(image_path): def convert_image_to_base64(image_path: str) -> str:
with open(image_path, 'rb') as f: with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode() return base64.b64encode(f.read()).decode()

View File

@ -3,19 +3,21 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import requests import requests
class WeatherFetcher: class WeatherFetcher:
"""Fetch weather data for hiking locations.""" """Fetch weather data for hiking locations."""
def __init__(self, api_url='https://api.open-meteo.com/v1/forecast', request_timeout=8, forecast_days=7): def __init__(self, api_url: str = 'https://api.open-meteo.com/v1/forecast', request_timeout: int = 8, forecast_days: int = 7) -> None:
"""Initialize weather fetcher.""" """Initialize weather fetcher."""
self.api_url = api_url self.api_url = api_url
self.request_timeout = request_timeout self.request_timeout = request_timeout
self.forecast_days = max(forecast_days, 1) self.forecast_days = max(forecast_days, 1)
def convert_wind_degrees_to_flow_direction(self, degrees): def convert_wind_degrees_to_flow_direction(self, degrees: Optional[float]) -> str:
"""Convert wind direction from degrees to flow direction emoji. """Convert wind direction from degrees to flow direction emoji.
Args: Args:
@ -30,7 +32,7 @@ class WeatherFetcher:
index = round(degrees / 45) % 8 index = round(degrees / 45) % 8
return directions[index] return directions[index]
def get_wind_speed_indicator(self, wind_speed): def get_wind_speed_indicator(self, wind_speed: Optional[float]) -> str:
"""Get wind speed level indicator based on speed. """Get wind speed level indicator based on speed.
Args: Args:
@ -51,7 +53,7 @@ class WeatherFetcher:
else: else:
return '🔴' # Dangerous return '🔴' # Dangerous
def get_uv_index_indicator(self, uv_index): def get_uv_index_indicator(self, uv_index: Optional[float]) -> str:
"""Get UV index level indicator based on index. """Get UV index level indicator based on index.
Args: Args:
@ -74,7 +76,7 @@ class WeatherFetcher:
else: else:
return '🟣' # Extreme return '🟣' # Extreme
def get_weather(self, latitude, longitude): def get_weather(self, latitude: float, longitude: float) -> Optional[dict]:
"""Fetch weather data for given coordinates. """Fetch weather data for given coordinates.
Args: Args:

View File

@ -1,6 +1,6 @@
[project] [project]
name = "hiking-assistant" name = "hiking-assistant"
version = "1.0.0" version = "1.0.1"
description = "This is a web app to analyze gpx for hiking planning" description = "This is a web app to analyze gpx for hiking planning"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"

2
uv.lock generated
View File

@ -227,7 +227,7 @@ wheels = [
[[package]] [[package]]
name = "hiking-assistant" name = "hiking-assistant"
version = "1.0.0" version = "1.0.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "folium" }, { name = "folium" },