From a9fa03fc770fd150fadd6fbc9d47aa42f6640c8e Mon Sep 17 00:00:00 2001 From: Lucas Messenger <1335960+layertwo@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:45:25 -0500 Subject: [PATCH] feat: add pico styling and thumbnails --- src/connections/batch.py | 4 +- src/connections/index.py | 16 ++- src/connections/map.py | 64 +++++++-- src/connections/templates/index.html.j2 | 182 +++--------------------- tests/test_index.py | 84 +++++++++++ tests/test_map.py | 54 +++++++ tests/test_model.py | 8 ++ 7 files changed, 240 insertions(+), 172 deletions(-) diff --git a/src/connections/batch.py b/src/connections/batch.py index 226251a..405d97c 100644 --- a/src/connections/batch.py +++ b/src/connections/batch.py @@ -87,8 +87,8 @@ def _process_single(self, json_path: Path) -> str: output_filename = f"{title}.png" output_path = self.output_dir / output_filename - # Create and save map + # Create and save map with thumbnail fm = FlightMap(flights=flights, title=title) - fm.save(filename=str(output_path)) + fm.save_with_thumbnail(filename=str(output_path)) return str(output_path) diff --git a/src/connections/index.py b/src/connections/index.py index b77caa0..075fcd0 100644 --- a/src/connections/index.py +++ b/src/connections/index.py @@ -18,6 +18,7 @@ class MapMetadata: title: str filename: str relative_path: str + thumbnail_path: Optional[str] = None class IndexGenerator: @@ -81,13 +82,26 @@ def scan_output_directory(self, directory: str) -> List[MapMetadata]: metadata_list = [] for png_file in png_files: + # Skip thumbnail files (they end with _thumb) + if png_file.stem.endswith("_thumb"): + continue + # Use filename (without extension) as title title = png_file.stem filename = png_file.name # Relative path for HTML links relative_path = f"./{filename}" - metadata = MapMetadata(title=title, filename=filename, relative_path=relative_path) + # Check if thumbnail exists + thumbnail_file = png_file.parent / f"{png_file.stem}_thumb{png_file.suffix}" + thumbnail_path = f"./{thumbnail_file.name}" if thumbnail_file.exists() else None + + metadata = MapMetadata( + title=title, + filename=filename, + relative_path=relative_path, + thumbnail_path=thumbnail_path, + ) metadata_list.append(metadata) return metadata_list diff --git a/src/connections/map.py b/src/connections/map.py index 835317e..e63b615 100644 --- a/src/connections/map.py +++ b/src/connections/map.py @@ -18,19 +18,32 @@ def __init__( self._title = title self._image_format = image_format - def draw(self) -> go.Figure: - """Generate map from flights and airports""" + def draw(self, thumbnail: bool = False) -> go.Figure: + """Generate map from flights and airports + + Args: + thumbnail: If True, optimize layout for thumbnail display + """ + # Adjust layout for thumbnail vs full-size + if thumbnail: + title_config = None + margin_config = dict(l=0, r=0, t=0, b=0) + else: + title_config = go.layout.Title( + text=self._title, + font=dict(family="Arial", size=50), + xanchor="center", + yanchor="top", + x=0.5, + ) + margin_config = dict(l=0, r=0, t=80, b=0) + fig = go.Figure( layout=dict( - title=go.layout.Title( - text=self._title, - font=dict(family="Arial", size=50), - xanchor="center", - yanchor="top", - x=0.5, - ), + title=title_config, showlegend=False, autosize=True, + margin=margin_config, geo=dict( fitbounds="locations", showframe=False, @@ -81,7 +94,40 @@ def to_image(self, width: int = 1920, height: int = 1080): format=self._image_format.value, width=width, height=height, scale=10 ) + def to_thumbnail(self, width: int = 640, height: int = 360) -> bytes: + """Generate thumbnail image at lower resolution with optimized layout""" + thumbnail_fig = self.draw(thumbnail=True) + return thumbnail_fig.to_image( + format=self._image_format.value, width=width, height=height, scale=5 + ) + def save(self, filename: str) -> None: image = self.to_image() with open(filename, "wb") as fp: fp.write(image) + + def save_with_thumbnail(self, filename: str) -> str: + """ + Save both full-size image and thumbnail + + Args: + filename: Path for the full-size image + + Returns: + Path to the generated thumbnail file + """ + # Save full-size image + self.save(filename) + + # Generate thumbnail filename + from pathlib import Path + + path = Path(filename) + thumbnail_path = path.parent / f"{path.stem}_thumb{path.suffix}" + + # Save thumbnail + thumbnail_image = self.to_thumbnail() + with open(thumbnail_path, "wb") as fp: + fp.write(thumbnail_image) + + return str(thumbnail_path) diff --git a/src/connections/templates/index.html.j2 b/src/connections/templates/index.html.j2 index c56a045..bd2c908 100644 --- a/src/connections/templates/index.html.j2 +++ b/src/connections/templates/index.html.j2 @@ -1,202 +1,64 @@ - - - Flight Connection Maps + + + + Connections + -
-
-

✈️ Flight Connection Maps

-

Explore your flight routes around the world

-
+
+

Connections

+
+
{% if maps %} -
+
{% for map in maps %} -
+
+ {% endfor %}
{% else %}

No flight maps found

-

Add some flight data to generate your first map!

{% endif %} - - -
+ +
diff --git a/tests/test_index.py b/tests/test_index.py index 71b482c..fa35e62 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -13,6 +13,21 @@ def test_map_metadata_creation(self): assert metadata.title == "Test Map" assert metadata.filename == "test.png" assert metadata.relative_path == "./test.png" + assert metadata.thumbnail_path is None + + def test_map_metadata_with_thumbnail(self): + """Test MapMetadata with thumbnail path""" + metadata = MapMetadata( + title="Test Map", + filename="test.png", + relative_path="./test.png", + thumbnail_path="./test_thumb.png", + ) + + assert metadata.title == "Test Map" + assert metadata.filename == "test.png" + assert metadata.relative_path == "./test.png" + assert metadata.thumbnail_path == "./test_thumb.png" class TestIndexGenerator: @@ -105,3 +120,72 @@ def test_generate_creates_parent_directories(self, tmp_path): assert output_path.exists() assert output_path.parent.exists() + + def test_scan_output_directory_with_thumbnails(self, tmp_path): + """Test scanning directory with PNG files and their thumbnails""" + # Create test PNG files and thumbnails + (tmp_path / "map1.png").touch() + (tmp_path / "map1_thumb.png").touch() + (tmp_path / "map2.png").touch() + (tmp_path / "map2_thumb.png").touch() + (tmp_path / "map3.png").touch() + + generator = IndexGenerator() + metadata_list = generator.scan_output_directory(str(tmp_path)) + + # Should only return 3 maps (not 6 - thumbnails are excluded) + assert len(metadata_list) == 3 + + # Check that thumbnails are properly linked + assert metadata_list[0].title == "map1" + assert metadata_list[0].thumbnail_path == "./map1_thumb.png" + + assert metadata_list[1].title == "map2" + assert metadata_list[1].thumbnail_path == "./map2_thumb.png" + + assert metadata_list[2].title == "map3" + assert metadata_list[2].thumbnail_path is None # No thumbnail for map3 + + def test_scan_output_directory_ignores_thumbnail_files(self, tmp_path): + """Test that thumbnail files are not treated as separate maps""" + (tmp_path / "map1.png").touch() + (tmp_path / "map1_thumb.png").touch() + (tmp_path / "standalone_thumb.png").touch() + + generator = IndexGenerator() + metadata_list = generator.scan_output_directory(str(tmp_path)) + + # Should only return map1, not the thumbnail files + assert len(metadata_list) == 1 + assert metadata_list[0].title == "map1" + + def test_generate_index_html_with_thumbnails(self, tmp_path): + """Test generating index.html with thumbnail paths""" + maps = [ + MapMetadata( + title="Trip 1", + filename="trip1.png", + relative_path="./trip1.png", + thumbnail_path="./trip1_thumb.png", + ), + MapMetadata( + title="Trip 2", + filename="trip2.png", + relative_path="./trip2.png", + thumbnail_path="./trip2_thumb.png", + ), + ] + + output_path = tmp_path / "index.html" + generator = IndexGenerator() + generator.generate(maps, str(output_path)) + + assert output_path.exists() + + # Verify content includes thumbnails + content = output_path.read_text() + assert "trip1_thumb.png" in content + assert "trip2_thumb.png" in content + # Full images should still be in href links + assert 'href="./trip1.png"' in content + assert 'href="./trip2.png"' in content diff --git a/tests/test_map.py b/tests/test_map.py index cd4bd58..1156e8f 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -206,3 +206,57 @@ def test_multiple_flights_trace_generation(self, mock_flights): # Should add traces for each flight (2 flights) assert mock_fig_instance.add_traces.call_count == len(mock_flights) + + def test_to_thumbnail_default_parameters(self, mock_flights): + """Test to_thumbnail method with default parameters""" + flight_map = FlightMap(flights=mock_flights, title="Test Map") + + with patch.object(flight_map, "draw") as mock_draw: + mock_fig = MagicMock() + mock_fig.to_image.return_value = b"mock_thumbnail_data" + mock_draw.return_value = mock_fig + + result = flight_map.to_thumbnail() + + mock_fig.to_image.assert_called_once_with(format="png", width=640, height=360, scale=5) + assert result == b"mock_thumbnail_data" + + def test_to_thumbnail_custom_parameters(self, mock_flights): + """Test to_thumbnail method with custom parameters""" + flight_map = FlightMap(flights=mock_flights, title="Test Map") + + with patch.object(flight_map, "draw") as mock_draw: + mock_fig = MagicMock() + mock_fig.to_image.return_value = b"mock_thumbnail_data" + mock_draw.return_value = mock_fig + + result = flight_map.to_thumbnail(width=320, height=180) + + mock_fig.to_image.assert_called_once_with(format="png", width=320, height=180, scale=5) + assert result == b"mock_thumbnail_data" + + def test_save_with_thumbnail(self, mock_flights, tmp_path): + """Test save_with_thumbnail method creates both full and thumbnail images""" + flight_map = FlightMap(flights=mock_flights, title="Test Map") + + with patch.object(flight_map, "to_image") as mock_to_image, patch.object( + flight_map, "to_thumbnail" + ) as mock_to_thumbnail: + mock_to_image.return_value = b"mock_full_image" + mock_to_thumbnail.return_value = b"mock_thumbnail_image" + + output_file = tmp_path / "test_map.png" + thumbnail_path = flight_map.save_with_thumbnail(str(output_file)) + + # Check full image + assert output_file.exists() + assert output_file.read_bytes() == b"mock_full_image" + + # Check thumbnail + expected_thumb_path = tmp_path / "test_map_thumb.png" + assert expected_thumb_path.exists() + assert expected_thumb_path.read_bytes() == b"mock_thumbnail_image" + assert thumbnail_path == str(expected_thumb_path) + + mock_to_image.assert_called_once() + mock_to_thumbnail.assert_called_once() diff --git a/tests/test_model.py b/tests/test_model.py index 48b4f28..097771a 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -6,6 +6,14 @@ from connections.model import Coordinates, Flight, convert_airport_to_coords +@pytest.fixture(autouse=True) +def clear_cache(): + """Clear lru_cache before each test to prevent cross-test contamination""" + convert_airport_to_coords.cache_clear() + yield + convert_airport_to_coords.cache_clear() + + class TestCoordinates: def test_coordinates_creation(self): """Test Coordinates dataclass creation"""