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
+
-
-
+
+
{% if maps %}
-
+
{% for map in maps %}
-
+
+ {{ map.title }}
-
+
-
-
+
{% 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"""