#!/usr/bin/env python3 """Anki flashcard generator for Warsaw districts. Generates Anki-compatible flashcard decks with maps showing individual Warsaw districts (dzielnice) with their borders using real boundary data from OpenStreetMap. Usage: # Generate Anki cards for all Warsaw districts python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki # Specify custom output file python -m python_pkg.anki_decks.warsaw_districts.warsaw_districts_anki \ --output warsaw.apkg Output: Creates a self-contained .apkg file that can be directly imported into Anki. The file includes all images embedded, so no manual file copying is needed. """ from __future__ import annotations import argparse import hashlib from io import BytesIO from pathlib import Path import secrets import sys import tempfile from typing import TYPE_CHECKING import genanki import geopandas as gpd import matplotlib.pyplot as plt if TYPE_CHECKING: from collections.abc import Sequence from matplotlib.figure import Figure # Path to GeoJSON file with Warsaw district boundaries GEOJSON_PATH = Path(__file__).parent / "warszawa-dzielnice.geojson" def load_district_data() -> gpd.GeoDataFrame: """Load Warsaw district boundaries from GeoJSON. Returns: GeoDataFrame with district boundaries. """ if not GEOJSON_PATH.exists(): msg = f"GeoJSON file not found at {GEOJSON_PATH}" raise FileNotFoundError(msg) gdf = gpd.read_file(GEOJSON_PATH) # Filter out the "Warszawa" entry (whole city) and keep only districts return gdf[gdf["name"] != "Warszawa"].copy() def get_district_names() -> list[str]: """Get list of all district names from GeoJSON data. Returns: Sorted list of district names. """ gdf = load_district_data() return sorted(gdf["name"].tolist()) # Load district names from actual data WARSAW_DISTRICTS = get_district_names() # 18 unique distinct colors for all Warsaw districts # Chosen to be visually distinct from each other in both light and dark modes DISTRICT_COLORS = [ "#E74C3C", # Red "#3498DB", # Blue "#2ECC71", # Emerald green "#9B59B6", # Purple "#F39C12", # Orange "#1ABC9C", # Turquoise "#E91E63", # Pink "#00BCD4", # Cyan "#8BC34A", # Light green "#FF5722", # Deep orange "#673AB7", # Deep purple "#FFEB3B", # Yellow "#795548", # Brown "#607D8B", # Blue grey "#CDDC39", # Lime "#FF9800", # Amber "#4CAF50", # Green "#03A9F4", # Light blue ] def create_district_map(district_name: str) -> Figure: """Create a map showing Warsaw with one district highlighted. The map shows Warsaw as a plain shape (no internal district borders) with only the target district highlighted in color with a bold border. This makes it harder to guess the district using contextual cues. Args: district_name: Name of the district to highlight. Returns: A matplotlib Figure object. """ # Load all district data gdf = load_district_data() # Create figure with transparent background fig, ax = plt.subplots(figsize=(10, 10)) ax.set_aspect("equal") ax.axis("off") fig.patch.set_alpha(0) ax.patch.set_alpha(0) # Find the target district target = gdf[gdf["name"] == district_name] if len(target) == 0: msg = f"District {district_name} not found in data" raise ValueError(msg) # Create unified Warsaw shape by dissolving all districts warsaw_unified = gdf.union_all() # Plot Warsaw as a plain gray shape (no internal borders) warsaw_gdf = gpd.GeoDataFrame(geometry=[warsaw_unified], crs=gdf.crs) warsaw_gdf.plot(ax=ax, color="#D5D8DC", alpha=0.6) warsaw_gdf.boundary.plot(ax=ax, color="#2C3E50", linewidth=2) # Assign colors to districts based on sorted names for consistency sorted_names = sorted(gdf["name"].tolist()) color_map = { name: DISTRICT_COLORS[i % len(DISTRICT_COLORS)] for i, name in enumerate(sorted_names) } # Highlight only the target district with bright color and bold border fill_color = color_map[district_name] target.plot(ax=ax, color=fill_color, alpha=0.9) target.boundary.plot(ax=ax, color="#1A1A1A", linewidth=4) # Set tight layout ax.set_xlim(gdf.total_bounds[0], gdf.total_bounds[2]) ax.set_ylim(gdf.total_bounds[1], gdf.total_bounds[3]) return fig def generate_district_image_bytes(district_name: str) -> bytes: """Generate a district map image as bytes. Args: district_name: Name of the district to visualize. Returns: PNG image data as bytes. """ fig = create_district_map(district_name) # Save to bytes buffer buf = BytesIO() fig.savefig(buf, format="png", bbox_inches="tight", dpi=150) plt.close(fig) buf.seek(0) return buf.read() def generate_anki_package( deck_name: str = "Warsaw Districts", ) -> genanki.Package: """Generate Anki package (.apkg) for Warsaw districts. Args: deck_name: Name for the Anki deck. Returns: genanki.Package object ready to be written to file. """ # Create a unique model ID based on deck name model_id_hash = hashlib.sha256( f"warsaw_districts_{deck_name}".encode() ) model_id = int(model_id_hash.hexdigest()[:8], 16) # Define the note model (card template) with centered styling card_css = """ .card { font-family: Arial, sans-serif; font-size: 24px; text-align: center; color: #333; background-color: #fff; } .card.night_mode { color: #eee; background-color: #2f2f2f; } .map-container { display: flex; justify-content: center; align-items: center; min-height: 80vh; } .map-container img { max-width: 100%; max-height: 80vh; object-fit: contain; } .answer-text { font-size: 32px; font-weight: bold; margin-top: 20px; color: #2C3E50; } .card.night_mode .answer-text { color: #ECF0F1; } """ my_model = genanki.Model( model_id, "Warsaw District Model", fields=[ {"name": "DistrictMap"}, {"name": "DistrictName"}, ], templates=[ { "name": "Card 1", "qfmt": '