deploy commited on
Commit
e4815fd
·
0 Parent(s):

Deploy GeoVision Pro

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +8 -0
  2. Dockerfile +46 -0
  3. README.md +36 -0
  4. backend/Dockerfile +25 -0
  5. backend/app/__init__.py +1 -0
  6. backend/app/config.py +68 -0
  7. backend/app/core/__init__.py +0 -0
  8. backend/app/core/cache.py +39 -0
  9. backend/app/core/logging.py +19 -0
  10. backend/app/database.py +31 -0
  11. backend/app/main.py +54 -0
  12. backend/app/models.py +49 -0
  13. backend/app/routers/__init__.py +0 -0
  14. backend/app/routers/analyze.py +90 -0
  15. backend/app/routers/health.py +34 -0
  16. backend/app/routers/jobs.py +41 -0
  17. backend/app/routers/reference.py +81 -0
  18. backend/app/routers/reports.py +48 -0
  19. backend/app/schemas.py +83 -0
  20. backend/app/services/__init__.py +0 -0
  21. backend/app/services/exif.py +77 -0
  22. backend/app/services/fusion.py +461 -0
  23. backend/app/services/geocode.py +79 -0
  24. backend/app/services/geoengine.py +130 -0
  25. backend/app/services/labels.py +95 -0
  26. backend/app/services/ocr.py +76 -0
  27. backend/app/services/picarta.py +117 -0
  28. backend/app/services/reference.py +275 -0
  29. backend/app/services/report.py +117 -0
  30. backend/app/services/video.py +77 -0
  31. backend/app/services/vision.py +126 -0
  32. backend/pytest.ini +4 -0
  33. backend/requirements.txt +38 -0
  34. backend/sql/schema.sql +31 -0
  35. backend/tests/test_fusion.py +17 -0
  36. backend/tests/test_health.py +15 -0
  37. backend/tests/test_picarta_reference.py +49 -0
  38. frontend/Dockerfile +13 -0
  39. frontend/index.html +14 -0
  40. frontend/nginx.conf +20 -0
  41. frontend/package-lock.json +2805 -0
  42. frontend/package.json +27 -0
  43. frontend/postcss.config.js +6 -0
  44. frontend/src/App.tsx +114 -0
  45. frontend/src/api.ts +69 -0
  46. frontend/src/components/CandidateList.tsx +60 -0
  47. frontend/src/components/Explain.tsx +55 -0
  48. frontend/src/components/HistoryPanel.tsx +30 -0
  49. frontend/src/components/MapView.tsx +61 -0
  50. frontend/src/components/ReferencePanel.tsx +100 -0
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ **/node_modules
2
+ **/dist
3
+ **/__pycache__
4
+ **/*.pyc
5
+ **/.git
6
+ **/.env
7
+ **/models
8
+ **/*.db
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GeoVision Pro — single container (frontend + backend in one image).
2
+ # Used for Hugging Face Spaces (Docker SDK) and any one-URL deployment.
3
+ # Build context must be the `geovision-pro/` directory (sees frontend/ + backend/).
4
+
5
+ # --- Stage 1: build the React frontend ---
6
+ FROM node:20-slim AS web
7
+ WORKDIR /web
8
+ COPY frontend/package.json ./
9
+ RUN npm install
10
+ COPY frontend/ ./
11
+ RUN npm run build # -> /web/dist
12
+
13
+ # --- Stage 2: Python backend that also serves the built frontend ---
14
+ FROM python:3.12-slim
15
+ RUN apt-get update && apt-get install -y --no-install-recommends \
16
+ libgl1 libglib2.0-0 \
17
+ tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ WORKDIR /app
21
+
22
+ COPY backend/requirements.txt .
23
+ # CPU torch + torchvision (GeoCLIP needs torchvision); installed from the CPU
24
+ # index first so the requirements step won't pull the heavy CUDA build.
25
+ RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu \
26
+ torch==2.5.1 torchvision==0.20.1 \
27
+ && pip install --no-cache-dir -r requirements.txt
28
+
29
+ COPY backend/app ./app
30
+ COPY backend/sql ./sql
31
+ # Bundle the compiled frontend so FastAPI serves it at "/"
32
+ COPY --from=web /web/dist ./app/static
33
+
34
+ # Hugging Face Spaces defaults: writable caches under /tmp, SQLite (no Postgres),
35
+ # models downloaded lazily on first request, app on port 7860.
36
+ ENV HF_HOME=/tmp/hf \
37
+ GEOVISION_DATABASE_URL=sqlite+aiosqlite:////tmp/geovision.db \
38
+ GEOVISION_MODEL_LAZY_LOAD=true \
39
+ GEOVISION_NOMINATIM_EMAIL="" \
40
+ PORT=7860
41
+ # Picarta (GeoSpy-class API) turns on by adding a Space *secret* named
42
+ # GEOVISION_PICARTA_API_TOKEN (Settings → Variables and secrets) — no code
43
+ # change; pydantic-settings reads it from the environment at startup.
44
+
45
+ EXPOSE 7860
46
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
README.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GeoVision Pro
3
+ emoji: 🌍
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # GeoVision Pro
13
+
14
+ KI-Geolocation aus Bildern/Videos. Reihenfolge (verlässlichste Quelle zuerst):
15
+ EXIF-GPS → Schildtext (OCR) → **Referenzgalerie** (Bild-Retrieval gegen deine
16
+ eigenen Geo-Fotos) → **Picarta-API** (GeoSpy-Klasse, optional) → GeoCLIP →
17
+ StreetCLIP-Kontext. Läuft als ein Container (FastAPI serviert die React-App + API).
18
+
19
+ > Erster Analyse-Request lädt einmalig die Modelle (GeoCLIP + StreetCLIP) — das
20
+ > dauert auf der kostenlosen CPU ein paar Minuten, danach sind sie gecached.
21
+
22
+ ## Genauer machen (optional)
23
+
24
+ **Picarta einschalten (am nächsten an GeoSpy):**
25
+ 1. Kostenlosen Token holen: https://picarta.ai → Account → API.
26
+ 2. Im Space: *Settings → Variables and secrets → New secret* →
27
+ Name `GEOVISION_PICARTA_API_TOKEN`, Wert = dein Token → Save.
28
+ 3. Space neu starten (*Restart*). Treffer erscheinen dann als „Picarta-API".
29
+
30
+ **Eigene Galerie („Training mit mehr Bildern"):**
31
+ Im Panel **„Eigene Galerie"** in der App ein geotaggtes Foto hochladen und Ort
32
+ angeben (Ortsname, Koordinaten oder per Foto-GPS) — die App erkennt diese Orte
33
+ danach genauer. Je mehr Fotos, desto besser.
34
+ > ⚠️ Auf einem kostenlosen Space ohne *persistent storage* gehen die Galerie-
35
+ > Fotos beim Neustart/Rebuild verloren. Für dauerhaftes Speichern in den Space-
36
+ > Settings **Persistent storage** aktivieren (legt `/data` dauerhaft an).
backend/Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ # System deps: OpenCV runtime libs + Tesseract OCR (German + English)
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ libgl1 libglib2.0-0 \
6
+ tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ COPY requirements.txt .
12
+ # Install CPU torch + torchvision wheels by default (torchvision is required by
13
+ # GeoCLIP). Override with a CUDA base image for GPU. Installing them from the CPU
14
+ # index first means the later `-r requirements.txt` won't pull the heavy CUDA build.
15
+ RUN pip install --no-cache-dir --index-url https://download.pytorch.org/whl/cpu \
16
+ torch==2.5.1 torchvision==0.20.1 \
17
+ && pip install --no-cache-dir -r requirements.txt
18
+
19
+ COPY app ./app
20
+ COPY sql ./sql
21
+
22
+ ENV GEOVISION_DEBUG=false
23
+ EXPOSE 8000
24
+
25
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "1.0.0"
backend/app/config.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application configuration loaded from environment variables."""
2
+ from functools import lru_cache
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_file=".env", env_prefix="GEOVISION_", extra="ignore")
8
+
9
+ # General
10
+ app_name: str = "GeoVision Pro API"
11
+ debug: bool = False
12
+ cors_origins: str = "*" # comma separated
13
+
14
+ # Database
15
+ database_url: str = "postgresql+asyncpg://geovision:geovision@localhost:5432/geovision"
16
+
17
+ # Vision model
18
+ # StreetCLIP is purpose-built for geolocation but large (~600MB).
19
+ # Fallback to a small CLIP keeps the service runnable on modest hardware.
20
+ vision_model: str = "geolocal/StreetCLIP"
21
+ vision_fallback_model: str = "openai/clip-vit-base-patch32"
22
+ device: str = "auto" # "auto" | "cpu" | "cuda"
23
+ model_lazy_load: bool = True
24
+
25
+ # GeoCLIP — predicts real GPS coordinates (GeoSpy-style). Optional: if the
26
+ # `geoclip` package/weights are missing, we fall back to StreetCLIP country
27
+ # inference. This is what lifts results from "country guess" to coordinates.
28
+ enable_geoclip: bool = True
29
+ geoclip_top_k: int = 5 # number of coordinate candidates to return
30
+ # Accuracy boosters (no training needed, cost a bit more CPU time):
31
+ geoclip_tta: bool = True # evaluate original + mirrored view, fuse the gallery probabilities
32
+ geoclip_candidate_pool: int = 64 # gallery entries fused before picking the top_k
33
+ geoclip_country_rerank: bool = True # down-weight GeoCLIP coords whose country contradicts StreetCLIP
34
+
35
+ # Picarta — commercial GeoSpy-class API. Closest thing to GeoSpy accuracy
36
+ # (often city/street level). Optional: needs a free API token. If no token
37
+ # is set, the pipeline silently skips it and uses the open models instead.
38
+ # Get a token at https://picarta.ai (free tier available).
39
+ enable_picarta: bool = True
40
+ picarta_api_token: str = "" # set GEOVISION_PICARTA_API_TOKEN to enable
41
+ picarta_url: str = "https://picarta.ai/classify"
42
+ picarta_top_k: int = 3 # number of coordinate candidates to request
43
+
44
+ # External services
45
+ nominatim_url: str = "https://nominatim.openstreetmap.org"
46
+ nominatim_email: str = "" # set to identify yourself per OSM usage policy
47
+ http_timeout: float = 20.0
48
+
49
+ # Optional features
50
+ # Reference gallery: a folder of YOUR OWN geotagged photos. We embed them
51
+ # once (cached on disk) and match new photos against them — real image
52
+ # retrieval, the way commercial tools pinpoint places. The MORE geotagged
53
+ # images you add, the more places it can recognise. This is the honest,
54
+ # free version of "training with more images". Empty path disables it.
55
+ # Default points at HF persistent storage (/data); if that is not writable
56
+ # (free tier), the service automatically falls back to a /tmp folder. Set to
57
+ # "" to disable the gallery entirely.
58
+ reference_dir: str = "/data/reference"
59
+ reference_min_similarity: float = 0.86 # cosine threshold to trust a match as a location
60
+ reference_use_top_k: int = 5 # nearest neighbours fused into the estimate
61
+ enable_ocr: bool = True # requires the `tesseract` binary on the host
62
+ max_video_frames: int = 12 # frames sampled per video
63
+ upload_max_mb: int = 40
64
+
65
+
66
+ @lru_cache
67
+ def get_settings() -> Settings:
68
+ return Settings()
backend/app/core/__init__.py ADDED
File without changes
backend/app/core/cache.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A tiny thread-safe in-memory LRU+TTL cache for geocoding and embeddings.
2
+
3
+ Deliberately dependency-free. For multi-process deployments swap this for Redis;
4
+ the call sites only use get()/set().
5
+ """
6
+ import threading
7
+ import time
8
+ from collections import OrderedDict
9
+ from typing import Any, Optional
10
+
11
+
12
+ class TTLCache:
13
+ def __init__(self, maxsize: int = 1024, ttl: float = 3600.0) -> None:
14
+ self._data: "OrderedDict[str, tuple[float, Any]]" = OrderedDict()
15
+ self._maxsize = maxsize
16
+ self._ttl = ttl
17
+ self._lock = threading.Lock()
18
+
19
+ def get(self, key: str) -> Optional[Any]:
20
+ with self._lock:
21
+ item = self._data.get(key)
22
+ if item is None:
23
+ return None
24
+ ts, value = item
25
+ if time.time() - ts > self._ttl:
26
+ self._data.pop(key, None)
27
+ return None
28
+ self._data.move_to_end(key)
29
+ return value
30
+
31
+ def set(self, key: str, value: Any) -> None:
32
+ with self._lock:
33
+ self._data[key] = (time.time(), value)
34
+ self._data.move_to_end(key)
35
+ while len(self._data) > self._maxsize:
36
+ self._data.popitem(last=False)
37
+
38
+
39
+ geocode_cache = TTLCache(maxsize=2048, ttl=24 * 3600)
backend/app/core/logging.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central logging setup."""
2
+ import logging
3
+ import sys
4
+
5
+
6
+ def configure_logging(debug: bool = False) -> None:
7
+ level = logging.DEBUG if debug else logging.INFO
8
+ handler = logging.StreamHandler(sys.stdout)
9
+ handler.setFormatter(logging.Formatter(
10
+ "%(asctime)s %(levelname)-7s %(name)s | %(message)s",
11
+ datefmt="%Y-%m-%d %H:%M:%S",
12
+ ))
13
+ root = logging.getLogger()
14
+ root.handlers.clear()
15
+ root.addHandler(handler)
16
+ root.setLevel(level)
17
+ # Quiet noisy libraries
18
+ for noisy in ("httpx", "PIL", "urllib3"):
19
+ logging.getLogger(noisy).setLevel(logging.WARNING)
backend/app/database.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async SQLAlchemy engine/session setup."""
2
+ from collections.abc import AsyncGenerator
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
5
+ from sqlalchemy.orm import DeclarativeBase
6
+
7
+ from .config import get_settings
8
+
9
+ settings = get_settings()
10
+
11
+ engine = create_async_engine(settings.database_url, echo=settings.debug, pool_pre_ping=True)
12
+ SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
13
+
14
+
15
+ class Base(DeclarativeBase):
16
+ pass
17
+
18
+
19
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
20
+ async with SessionLocal() as session:
21
+ yield session
22
+
23
+
24
+ async def init_models() -> None:
25
+ """Create tables on startup if they do not exist (dev convenience).
26
+
27
+ For production use the SQL migration in sql/schema.sql or Alembic.
28
+ """
29
+ from . import models # noqa: F401 (register models)
30
+ async with engine.begin() as conn:
31
+ await conn.run_sync(Base.metadata.create_all)
backend/app/main.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GeoVision Pro — FastAPI application entrypoint."""
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.staticfiles import StaticFiles
8
+
9
+ from .config import get_settings
10
+ from .core.logging import configure_logging
11
+ from .database import init_models
12
+ from .routers import analyze, health, jobs, reference, reports
13
+
14
+ settings = get_settings()
15
+ configure_logging(settings.debug)
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ # Create tables on startup (dev). In prod, prefer sql/schema.sql or Alembic.
21
+ await init_models()
22
+ yield
23
+
24
+
25
+ app = FastAPI(title=settings.app_name, version="1.0.0", lifespan=lifespan)
26
+
27
+ origins = ["*"] if settings.cors_origins.strip() == "*" else \
28
+ [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
29
+ app.add_middleware(
30
+ CORSMiddleware,
31
+ allow_origins=origins,
32
+ allow_credentials=False,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+ app.include_router(health.router, prefix="/api")
38
+ app.include_router(analyze.router, prefix="/api")
39
+ app.include_router(jobs.router, prefix="/api")
40
+ app.include_router(reports.router, prefix="/api")
41
+ app.include_router(reference.router, prefix="/api")
42
+
43
+
44
+ # Serve the built React frontend if it was bundled into the image (single-container
45
+ # deployment, e.g. Hugging Face Space). The /api/* routes and /docs are registered
46
+ # above, so they take precedence over this catch-all mount. When no build is present
47
+ # (pure API mode), expose a small JSON index instead.
48
+ _STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
49
+ if os.path.isdir(_STATIC_DIR):
50
+ app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="frontend")
51
+ else:
52
+ @app.get("/")
53
+ async def root() -> dict:
54
+ return {"name": settings.app_name, "docs": "/docs", "health": "/api/health"}
backend/app/models.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ORM models — persisted analyses and their candidates."""
2
+ from datetime import datetime, timezone
3
+
4
+ from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text
5
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
6
+
7
+ from .database import Base
8
+
9
+
10
+ def _utcnow() -> datetime:
11
+ return datetime.now(timezone.utc)
12
+
13
+
14
+ class Analysis(Base):
15
+ __tablename__ = "analyses"
16
+
17
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
18
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
19
+ kind: Mapped[str] = mapped_column(String(16), default="image") # image | batch | video
20
+ source_name: Mapped[str] = mapped_column(String(255), default="")
21
+
22
+ # Best location summary (nullable when not determinable from the image)
23
+ best_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
24
+ best_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
25
+ best_lon: Mapped[float | None] = mapped_column(Float, nullable=True)
26
+ best_confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
27
+ location_source: Mapped[str] = mapped_column(String(32), default="inference") # exif | ocr | inference
28
+
29
+ # Full structured result (signals, weights, hierarchy, reference matches)
30
+ result: Mapped[dict] = mapped_column(JSON, default=dict)
31
+
32
+ candidates: Mapped[list["Candidate"]] = relationship(
33
+ back_populates="analysis", cascade="all, delete-orphan", order_by="Candidate.rank"
34
+ )
35
+
36
+
37
+ class Candidate(Base):
38
+ __tablename__ = "candidates"
39
+
40
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
41
+ analysis_id: Mapped[int] = mapped_column(ForeignKey("analyses.id", ondelete="CASCADE"), index=True)
42
+ rank: Mapped[int] = mapped_column(Integer)
43
+ label: Mapped[str] = mapped_column(String(255))
44
+ confidence: Mapped[float] = mapped_column(Float)
45
+ lat: Mapped[float | None] = mapped_column(Float, nullable=True)
46
+ lon: Mapped[float | None] = mapped_column(Float, nullable=True)
47
+ reasoning: Mapped[str] = mapped_column(Text, default="")
48
+
49
+ analysis: Mapped[Analysis] = relationship(back_populates="candidates")
backend/app/routers/__init__.py ADDED
File without changes
backend/app/routers/analyze.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Analysis endpoints: single image, batch, and video."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+
7
+ from ..config import get_settings
8
+ from ..database import get_session
9
+ from ..models import Analysis, Candidate
10
+ from ..schemas import AnalysisResult
11
+ from ..services import fusion
12
+
13
+ router = APIRouter(prefix="/analyze", tags=["analyze"])
14
+ settings = get_settings()
15
+
16
+ _IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}
17
+ _VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/x-msvideo"}
18
+
19
+
20
+ async def _read_limited(file: UploadFile) -> bytes:
21
+ data = await file.read()
22
+ if len(data) > settings.upload_max_mb * 1024 * 1024:
23
+ raise HTTPException(413, f"Datei zu groß (> {settings.upload_max_mb} MB).")
24
+ return data
25
+
26
+
27
+ async def _persist(session: AsyncSession, result: AnalysisResult) -> AnalysisResult:
28
+ best = result.candidates[0] if result.candidates else None
29
+ row = Analysis(
30
+ kind=result.kind, source_name=result.source_name,
31
+ best_label=best.label if best else None,
32
+ best_lat=best.lat if best else None,
33
+ best_lon=best.lon if best else None,
34
+ best_confidence=best.confidence if best else None,
35
+ location_source=result.location_source,
36
+ result=result.model_dump(mode="json"),
37
+ )
38
+ for c in result.candidates:
39
+ row.candidates.append(Candidate(
40
+ rank=c.rank, label=str(c.label), confidence=c.confidence,
41
+ lat=c.lat, lon=c.lon, reasoning=c.reasoning))
42
+ session.add(row)
43
+ await session.commit()
44
+ await session.refresh(row)
45
+ result.id = row.id
46
+ result.created_at = row.created_at
47
+ return result
48
+
49
+
50
+ @router.post("/image", response_model=AnalysisResult)
51
+ async def analyze_image(file: UploadFile = File(...), session: AsyncSession = Depends(get_session)):
52
+ if file.content_type not in _IMAGE_TYPES:
53
+ raise HTTPException(415, f"Bildformat nicht unterstützt: {file.content_type}")
54
+ data = await _read_limited(file)
55
+ try:
56
+ result = await fusion.analyze_image(data, source_name=file.filename or "")
57
+ except Exception as exc:
58
+ raise HTTPException(500, f"Analyse fehlgeschlagen: {exc}") from exc
59
+ return await _persist(session, result)
60
+
61
+
62
+ @router.post("/batch", response_model=list[AnalysisResult])
63
+ async def analyze_batch(files: list[UploadFile] = File(...),
64
+ session: AsyncSession = Depends(get_session)):
65
+ if not files:
66
+ raise HTTPException(400, "Keine Dateien.")
67
+ results: list[AnalysisResult] = []
68
+ for file in files:
69
+ if file.content_type not in _IMAGE_TYPES:
70
+ continue
71
+ data = await _read_limited(file)
72
+ try:
73
+ res = await fusion.analyze_image(data, source_name=file.filename or "")
74
+ results.append(await _persist(session, res))
75
+ except Exception as exc: # keep batch going
76
+ results.append(AnalysisResult(source_name=file.filename or "",
77
+ uncertainty=f"Fehler: {exc}"))
78
+ return results
79
+
80
+
81
+ @router.post("/video", response_model=AnalysisResult)
82
+ async def analyze_video(file: UploadFile = File(...), session: AsyncSession = Depends(get_session)):
83
+ if file.content_type not in _VIDEO_TYPES:
84
+ raise HTTPException(415, f"Videoformat nicht unterstützt: {file.content_type}")
85
+ data = await _read_limited(file)
86
+ try:
87
+ result = await fusion.analyze_video(data, source_name=file.filename or "")
88
+ except Exception as exc:
89
+ raise HTTPException(500, f"Videoanalyse fehlgeschlagen: {exc}") from exc
90
+ return await _persist(session, result)
backend/app/routers/health.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Health & model status endpoints."""
2
+ from fastapi import APIRouter
3
+
4
+ from ..config import get_settings
5
+ from ..services import ocr, picarta, reference
6
+ from ..services.geoengine import get_geo_engine
7
+ from ..services.vision import get_engine
8
+
9
+ router = APIRouter(tags=["system"])
10
+ settings = get_settings()
11
+
12
+
13
+ @router.get("/health")
14
+ async def health() -> dict:
15
+ return {"status": "ok", "app": settings.app_name}
16
+
17
+
18
+ @router.get("/status")
19
+ async def status() -> dict:
20
+ engine = get_engine()
21
+ idx = reference.get_index()
22
+ geolocated = sum(1 for e in idx if e.get("lat") is not None)
23
+ return {
24
+ "model_configured": settings.vision_model,
25
+ "model_loaded": engine.loaded,
26
+ "model_in_use": engine.model_name or None,
27
+ "ocr_available": ocr.available(),
28
+ "picarta_enabled": picarta.available(),
29
+ "geoclip_enabled": get_geo_engine().available,
30
+ "reference_images": len(idx),
31
+ "reference_geolocated": geolocated,
32
+ "reference_dir": reference.active_dir() or None,
33
+ "device": settings.device,
34
+ }
backend/app/routers/jobs.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """History / stored analyses."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from sqlalchemy import desc, select
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from ..database import get_session
9
+ from ..models import Analysis
10
+ from ..schemas import AnalysisListItem, AnalysisResult
11
+
12
+ router = APIRouter(prefix="/jobs", tags=["jobs"])
13
+
14
+
15
+ @router.get("", response_model=list[AnalysisListItem])
16
+ async def list_jobs(limit: int = 50, session: AsyncSession = Depends(get_session)):
17
+ rows = (await session.execute(
18
+ select(Analysis).order_by(desc(Analysis.created_at)).limit(min(limit, 200))
19
+ )).scalars().all()
20
+ return rows
21
+
22
+
23
+ @router.get("/{job_id}", response_model=AnalysisResult)
24
+ async def get_job(job_id: int, session: AsyncSession = Depends(get_session)):
25
+ row = await session.get(Analysis, job_id)
26
+ if not row:
27
+ raise HTTPException(404, "Analyse nicht gefunden.")
28
+ data = dict(row.result)
29
+ data["id"] = row.id
30
+ data["created_at"] = row.created_at
31
+ return AnalysisResult.model_validate(data)
32
+
33
+
34
+ @router.delete("/{job_id}")
35
+ async def delete_job(job_id: int, session: AsyncSession = Depends(get_session)):
36
+ row = await session.get(Analysis, job_id)
37
+ if not row:
38
+ raise HTTPException(404, "Analyse nicht gefunden.")
39
+ await session.delete(row)
40
+ await session.commit()
41
+ return {"deleted": job_id}
backend/app/routers/reference.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference gallery management: grow the app's knowledge with your own photos.
2
+
3
+ This is the practical, free "train it with more images" path. Upload a geotagged
4
+ photo (or give a place/coordinates) and it is embedded and matched against future
5
+ uploads. Accuracy for places you cover improves immediately.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile
10
+
11
+ from ..config import get_settings
12
+ from ..services import geocode, reference
13
+ from ..services.exif import extract_gps
14
+
15
+ router = APIRouter(prefix="/reference", tags=["reference"])
16
+ settings = get_settings()
17
+
18
+ _IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"}
19
+
20
+
21
+ @router.get("/list")
22
+ async def list_reference() -> dict:
23
+ entries = reference.list_entries()
24
+ return {
25
+ "reference_images": len(entries),
26
+ "reference_geolocated": sum(1 for e in entries if e["lat"] is not None),
27
+ "entries": entries[-50:], # most recent (avoid huge payloads)
28
+ }
29
+
30
+
31
+ @router.post("/reload")
32
+ async def reload_reference() -> dict:
33
+ count = reference.reload()
34
+ entries = reference.list_entries()
35
+ return {"reference_images": count,
36
+ "reference_geolocated": sum(1 for e in entries if e["lat"] is not None)}
37
+
38
+
39
+ @router.post("/add")
40
+ async def add_reference(
41
+ file: UploadFile = File(...),
42
+ lat: float | None = Form(None),
43
+ lon: float | None = Form(None),
44
+ place: str | None = Form(None),
45
+ ) -> dict:
46
+ """Add one photo to the gallery. Location is taken from (in order):
47
+ explicit lat/lon → a place name (geocoded) → the photo's own EXIF GPS.
48
+ """
49
+ if file.content_type not in _IMAGE_TYPES:
50
+ raise HTTPException(415, f"Bildformat nicht unterstützt: {file.content_type}")
51
+ data = await file.read()
52
+ if len(data) > settings.upload_max_mb * 1024 * 1024:
53
+ raise HTTPException(413, f"Datei zu groß (> {settings.upload_max_mb} MB).")
54
+
55
+ resolved_lat, resolved_lon, how = lat, lon, "Koordinaten"
56
+ if resolved_lat is None or resolved_lon is None:
57
+ if place and place.strip():
58
+ hits = await geocode.forward(place.strip(), limit=1)
59
+ if not hits:
60
+ raise HTTPException(422, f"Ort „{place}“ konnte nicht gefunden werden.")
61
+ resolved_lat, resolved_lon, how = hits[0]["lat"], hits[0]["lon"], f"Ort „{place}“"
62
+ else:
63
+ gps = extract_gps(data)
64
+ if gps.get("lat") is None or gps.get("lon") is None:
65
+ raise HTTPException(
66
+ 422,
67
+ "Kein Standort angegeben. Gib Koordinaten oder einen Ort an, "
68
+ "oder lade ein Foto mit GPS-Metadaten hoch.",
69
+ )
70
+ resolved_lat, resolved_lon, how = gps["lat"], gps["lon"], "EXIF-GPS des Fotos"
71
+
72
+ if not (-90 <= resolved_lat <= 90 and -180 <= resolved_lon <= 180):
73
+ raise HTTPException(422, "Ungültige Koordinaten.")
74
+
75
+ try:
76
+ result = reference.add_image(data, resolved_lat, resolved_lon,
77
+ name_hint=file.filename or "")
78
+ except Exception as exc:
79
+ raise HTTPException(500, f"Konnte nicht hinzufügen: {exc}") from exc
80
+ result.update({"lat": resolved_lat, "lon": resolved_lon, "source": how})
81
+ return result
backend/app/routers/reports.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Report export endpoints (PDF / CSV / JSON) for a stored analysis."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from fastapi.responses import Response
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from ..database import get_session
9
+ from ..models import Analysis
10
+ from ..schemas import AnalysisResult
11
+ from ..services import report
12
+
13
+ router = APIRouter(prefix="/report", tags=["report"])
14
+
15
+
16
+ async def _load(job_id: int, session: AsyncSession) -> AnalysisResult:
17
+ row = await session.get(Analysis, job_id)
18
+ if not row:
19
+ raise HTTPException(404, "Analyse nicht gefunden.")
20
+ data = dict(row.result)
21
+ data["id"] = row.id
22
+ data["created_at"] = row.created_at
23
+ return AnalysisResult.model_validate(data)
24
+
25
+
26
+ @router.get("/{job_id}.json")
27
+ async def report_json(job_id: int, session: AsyncSession = Depends(get_session)):
28
+ result = await _load(job_id, session)
29
+ return Response(report.to_json_bytes(result), media_type="application/json",
30
+ headers={"Content-Disposition": f'attachment; filename="geovision_{job_id}.json"'})
31
+
32
+
33
+ @router.get("/{job_id}.csv")
34
+ async def report_csv(job_id: int, session: AsyncSession = Depends(get_session)):
35
+ result = await _load(job_id, session)
36
+ return Response(report.to_csv_bytes(result), media_type="text/csv",
37
+ headers={"Content-Disposition": f'attachment; filename="geovision_{job_id}.csv"'})
38
+
39
+
40
+ @router.get("/{job_id}.pdf")
41
+ async def report_pdf(job_id: int, session: AsyncSession = Depends(get_session)):
42
+ result = await _load(job_id, session)
43
+ try:
44
+ pdf = report.to_pdf_bytes(result)
45
+ except Exception as exc:
46
+ raise HTTPException(500, f"PDF-Erzeugung fehlgeschlagen: {exc}") from exc
47
+ return Response(pdf, media_type="application/pdf",
48
+ headers={"Content-Disposition": f'attachment; filename="geovision_{job_id}.pdf"'})
backend/app/schemas.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic response/request schemas."""
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class GpsInfo(BaseModel):
9
+ has_gps: bool = False
10
+ lat: Optional[float] = None
11
+ lon: Optional[float] = None
12
+ altitude: Optional[float] = None
13
+ timestamp: Optional[str] = None
14
+ camera: Optional[str] = None
15
+ address: Optional[str] = None
16
+
17
+
18
+ class SignalScore(BaseModel):
19
+ label: str
20
+ score: float
21
+
22
+
23
+ class SignalGroup(BaseModel):
24
+ """One visual-analysis category with its top zero-shot matches."""
25
+ name: str # e.g. "Landschaft", "Architektur"
26
+ top: list[SignalScore]
27
+ weight: float # normalized contribution 0..1
28
+
29
+
30
+ class LocationCandidate(BaseModel):
31
+ rank: int
32
+ label: str
33
+ confidence: float
34
+ lat: Optional[float] = None
35
+ lon: Optional[float] = None
36
+ reasoning: str = ""
37
+
38
+
39
+ class Hierarchy(BaseModel):
40
+ continent: Optional[str] = None
41
+ country: Optional[str] = None
42
+ region: Optional[str] = None
43
+ city: Optional[str] = None
44
+ district: Optional[str] = None
45
+ # Honest note about which levels could not be derived
46
+ note: str = ""
47
+
48
+
49
+ class ReferenceMatch(BaseModel):
50
+ name: str
51
+ similarity: float
52
+ lat: Optional[float] = None
53
+ lon: Optional[float] = None
54
+
55
+
56
+ class AnalysisResult(BaseModel):
57
+ id: Optional[int] = None
58
+ created_at: Optional[datetime] = None
59
+ kind: str = "image"
60
+ source_name: str = ""
61
+
62
+ gps: GpsInfo = GpsInfo()
63
+ location_source: str = "inference" # exif | ocr | reference | picarta | geoclip | inference
64
+ hierarchy: Hierarchy = Hierarchy()
65
+ candidates: list[LocationCandidate] = []
66
+ signals: list[SignalGroup] = []
67
+ ocr_text: str = ""
68
+ reference_matches: list[ReferenceMatch] = []
69
+ uncertainty: str = ""
70
+ model_used: str = ""
71
+
72
+
73
+ class AnalysisListItem(BaseModel):
74
+ id: int
75
+ created_at: datetime
76
+ kind: str
77
+ source_name: str
78
+ best_label: Optional[str]
79
+ best_confidence: Optional[float]
80
+ location_source: str
81
+
82
+ class Config:
83
+ from_attributes = True
backend/app/services/__init__.py ADDED
File without changes
backend/app/services/exif.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EXIF / GPS extraction from image bytes — the only exact location source."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ from typing import Optional
6
+
7
+ from PIL import Image, ExifTags
8
+
9
+ # Register HEIC support if pillow-heif is available.
10
+ try: # pragma: no cover - optional dependency
11
+ import pillow_heif
12
+
13
+ pillow_heif.register_heif_opener()
14
+ HEIC_SUPPORTED = True
15
+ except Exception: # pragma: no cover
16
+ HEIC_SUPPORTED = False
17
+
18
+ _GPS_TAG = next((k for k, v in ExifTags.TAGS.items() if v == "GPSInfo"), 34853)
19
+ _GPS_KEYS = {v: k for k, v in ExifTags.GPSTAGS.items()}
20
+
21
+
22
+ def _to_degrees(value) -> Optional[float]:
23
+ try:
24
+ d, m, s = value
25
+ return float(d) + float(m) / 60.0 + float(s) / 3600.0
26
+ except Exception:
27
+ return None
28
+
29
+
30
+ def open_image(data: bytes) -> Image.Image:
31
+ """Open arbitrary supported image bytes as RGB."""
32
+ img = Image.open(io.BytesIO(data))
33
+ return img.convert("RGB")
34
+
35
+
36
+ def extract_gps(data: bytes) -> dict:
37
+ """Return a dict with GPS + basic camera metadata. Never raises."""
38
+ out: dict = {"has_gps": False, "lat": None, "lon": None,
39
+ "altitude": None, "timestamp": None, "camera": None}
40
+ try:
41
+ img = Image.open(io.BytesIO(data))
42
+ exif = img.getexif()
43
+ if not exif:
44
+ return out
45
+
46
+ make = exif.get(next((k for k, v in ExifTags.TAGS.items() if v == "Make"), None))
47
+ model = exif.get(next((k for k, v in ExifTags.TAGS.items() if v == "Model"), None))
48
+ if make or model:
49
+ out["camera"] = " ".join(str(x).strip() for x in (make, model) if x)
50
+ dto = exif.get(next((k for k, v in ExifTags.TAGS.items() if v == "DateTimeOriginal"), None))
51
+ if dto:
52
+ out["timestamp"] = str(dto)
53
+
54
+ gps = exif.get_ifd(_GPS_TAG) if hasattr(exif, "get_ifd") else None
55
+ if not gps:
56
+ return out
57
+
58
+ lat = _to_degrees(gps.get(_GPS_KEYS.get("GPSLatitude")))
59
+ lon = _to_degrees(gps.get(_GPS_KEYS.get("GPSLongitude")))
60
+ lat_ref = gps.get(_GPS_KEYS.get("GPSLatitudeRef"))
61
+ lon_ref = gps.get(_GPS_KEYS.get("GPSLongitudeRef"))
62
+ if lat is not None and lon is not None:
63
+ if lat_ref in ("S", b"S"):
64
+ lat = -lat
65
+ if lon_ref in ("W", b"W"):
66
+ lon = -lon
67
+ out.update(has_gps=True, lat=round(lat, 6), lon=round(lon, 6))
68
+
69
+ alt = gps.get(_GPS_KEYS.get("GPSAltitude"))
70
+ if alt is not None:
71
+ try:
72
+ out["altitude"] = round(float(alt), 1)
73
+ except Exception:
74
+ pass
75
+ except Exception:
76
+ return out
77
+ return out
backend/app/services/fusion.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fusion: combine EXIF, vision signals, OCR and geocoding into one ranked result.
2
+
3
+ Decision order for the *location* (most reliable first):
4
+ 1. EXIF GPS -> exact, location_source="exif"
5
+ 2. OCR -> geocoded -> real place from a readable sign, location_source="ocr"
6
+ 3. Reference retrieval -> strong cosine match to YOUR geotagged gallery,
7
+ location_source="reference" (grows with added images)
8
+ 4. Picarta API -> commercial GeoSpy-class coordinates (if a token is
9
+ set), location_source="picarta"
10
+ 5. GeoCLIP -> predicted GPS coordinates (GeoSpy-style, open model),
11
+ reverse-geocoded to place names, location_source="geoclip"
12
+ 6. CLIP/StreetCLIP -> country/region inference, location_source="inference"
13
+
14
+ City / district from (5) and (6) are model estimates and are labelled as such in
15
+ the hierarchy note. (1)/(2) provide exact/real places; (3) is as good as your
16
+ gallery; (4) is external. Every model-based source is optional and the pipeline
17
+ degrades cleanly to the next one if it is unavailable.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ from math import asin, cos, radians, sin, sqrt
23
+
24
+ from PIL import Image
25
+
26
+ from ..schemas import (AnalysisResult, GpsInfo, Hierarchy, LocationCandidate,
27
+ ReferenceMatch, SignalGroup, SignalScore)
28
+ from . import geocode, ocr, picarta, reference
29
+ from .exif import extract_gps, open_image
30
+ from .geoengine import get_geo_engine
31
+ from .labels import (COUNTRY_PROMPT, COUNTRY_NAMES, COUNTRY_TO_CONTINENT,
32
+ REGION_PROMPT, REGIONS, SIGNAL_GROUPS)
33
+ from .vision import get_engine
34
+
35
+
36
+ def _haversine_km(a: tuple[float, float], b: tuple[float, float]) -> float:
37
+ """Great-circle distance in km between two (lat, lon) points."""
38
+ lat1, lon1, lat2, lon2 = map(radians, (a[0], a[1], b[0], b[1]))
39
+ dlat, dlon = lat2 - lat1, lon2 - lon1
40
+ h = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
41
+ return 2 * 6371.0 * asin(sqrt(h))
42
+
43
+
44
+ def _short_label(addr: dict, display: str) -> str:
45
+ """Compact human label from a reverse-geocoded address."""
46
+ city = (addr.get("city") or addr.get("town") or addr.get("village")
47
+ or addr.get("municipality") or addr.get("county"))
48
+ region = addr.get("state") or addr.get("region")
49
+ country = addr.get("country")
50
+ parts = [p for p in (city, region, country) if p]
51
+ return ", ".join(parts) if parts else (display or "")
52
+
53
+
54
+ def _analyze_signals(image: Image.Image) -> tuple[list[SignalGroup], dict]:
55
+ """Run each visual-analysis group; weight = top score, normalized across groups."""
56
+ engine = get_engine()
57
+ groups: list[SignalGroup] = []
58
+ tops: dict[str, tuple[str, float]] = {}
59
+ raw_weights: dict[str, float] = {}
60
+ for group_name, mapping in SIGNAL_GROUPS.items():
61
+ labels = list(mapping.keys())
62
+ prompts = list(mapping.values())
63
+ # zero_shot uses a template; here prompts are full sentences already
64
+ ranked = engine.zero_shot(image, prompts, template="{}", top_k=3)
65
+ prompt_to_label = {v: k for k, v in mapping.items()}
66
+ top = [SignalScore(label=prompt_to_label.get(p, p), score=round(s, 4)) for p, s in ranked]
67
+ groups.append(SignalGroup(name=group_name, top=top, weight=0.0))
68
+ if top:
69
+ tops[group_name] = (top[0].label, top[0].score)
70
+ raw_weights[group_name] = top[0].score
71
+ total = sum(raw_weights.values()) or 1.0
72
+ for g in groups:
73
+ g.weight = round(raw_weights.get(g.name, 0.0) / total, 3)
74
+ return groups, tops
75
+
76
+
77
+ def _reasoning_from_signals(tops: dict, country: str) -> str:
78
+ bits = []
79
+ if "Landschaft" in tops:
80
+ bits.append(f"Landschaft ähnelt „{tops['Landschaft'][0]}“")
81
+ if "Architektur" in tops:
82
+ bits.append(f"Architektur weist auf „{tops['Architektur'][0]}“")
83
+ if "Infrastruktur" in tops:
84
+ bits.append(f"Infrastruktur passt zu „{tops['Infrastruktur'][0]}“")
85
+ if "Klima" in tops:
86
+ bits.append(f"Klima-Hinweise: „{tops['Klima'][0]}“")
87
+ joined = "; ".join(bits)
88
+ return f"{joined} → konsistent mit {country}." if joined else f"Modellähnlichkeit zu {country}."
89
+
90
+
91
+ async def analyze_image(data: bytes, source_name: str = "") -> AnalysisResult:
92
+ image = open_image(data)
93
+ engine = get_engine()
94
+ geo = get_geo_engine()
95
+
96
+ # --- visual signals + coordinate prediction (CPU/GPU bound -> thread) ---
97
+ def _vision():
98
+ groups, tops = _analyze_signals(image)
99
+ countries = engine.zero_shot(image, COUNTRY_NAMES, template=COUNTRY_PROMPT, top_k=10)
100
+ region = None
101
+ if countries:
102
+ regs = REGIONS.get(countries[0][0])
103
+ if regs:
104
+ ranked = engine.zero_shot(image, regs, template=REGION_PROMPT, top_k=1)
105
+ if ranked:
106
+ # strip the appended ", Country" for display
107
+ region = ranked[0][0].split(",")[0]
108
+ img_vec = engine.embed_image(image)
109
+ geo_preds = geo.predict(image) # [(lat, lon, prob), ...] or [] if unavailable
110
+ return groups, tops, countries, region, img_vec, geo_preds
111
+
112
+ groups, tops, countries, inferred_region, img_vec, geo_preds = await asyncio.to_thread(_vision)
113
+
114
+ # --- EXIF GPS ---
115
+ gps_raw = extract_gps(data)
116
+ gps = GpsInfo(**{k: gps_raw.get(k) for k in
117
+ ("has_gps", "lat", "lon", "altitude", "timestamp", "camera")})
118
+
119
+ # --- OCR (optional) ---
120
+ ocr_text = ""
121
+ ocr_places: list[dict] = []
122
+ if ocr.available():
123
+ ocr_text = await asyncio.to_thread(ocr.read_text, image)
124
+ for q in ocr.candidate_queries(ocr_text):
125
+ hits = await geocode.forward(q, limit=2)
126
+ ocr_places.extend(hits)
127
+ # de-dup by rounded coords, keep most important
128
+ seen = set()
129
+ uniq = []
130
+ for p in sorted(ocr_places, key=lambda x: x["importance"], reverse=True):
131
+ key = (round(p["lat"], 3), round(p["lon"], 3))
132
+ if key in seen:
133
+ continue
134
+ seen.add(key)
135
+ uniq.append(p)
136
+ ocr_places = uniq[:5]
137
+
138
+ # --- Picarta (optional external GeoSpy-class API; only if a token is set) ---
139
+ picarta_preds = await picarta.predict(data)
140
+
141
+ # --- reference gallery: look-alikes (display) + retrieval geolocation ---
142
+ ref_matches = [ReferenceMatch(**m) for m in reference.match(img_vec, top_k=5)]
143
+ ref_geo = reference.geolocate(img_vec) # None unless a strong geotagged match
144
+
145
+ # --- decide location source + hierarchy + candidates ---
146
+ hierarchy = Hierarchy()
147
+ candidates: list[LocationCandidate] = []
148
+ location_source = "inference"
149
+ uncertainty = ""
150
+
151
+ if gps.has_gps:
152
+ location_source = "exif"
153
+ rev = await geocode.reverse(gps.lat, gps.lon)
154
+ addr = (rev or {}).get("address", {})
155
+ gps.address = (rev or {}).get("display", "")
156
+ hierarchy = Hierarchy(
157
+ continent=None,
158
+ country=addr.get("country"),
159
+ region=addr.get("state") or addr.get("region"),
160
+ city=addr.get("city") or addr.get("town") or addr.get("village"),
161
+ district=addr.get("suburb") or addr.get("city_district"),
162
+ note="Exakt aus GPS-Metadaten.",
163
+ )
164
+ candidates.append(LocationCandidate(
165
+ rank=1, label=gps.address or f"{gps.lat:.5f}, {gps.lon:.5f}",
166
+ confidence=0.99, lat=gps.lat, lon=gps.lon,
167
+ reasoning="Exakte GPS-Koordinaten aus den EXIF-Metadaten des Fotos.",
168
+ ))
169
+ uncertainty = "Sehr gering — Standort stammt direkt aus GPS-Metadaten."
170
+
171
+ elif ocr_places:
172
+ location_source = "ocr"
173
+ top = ocr_places[0]
174
+ rev = await geocode.reverse(top["lat"], top["lon"])
175
+ addr = (rev or {}).get("address", {})
176
+ hierarchy = Hierarchy(
177
+ country=addr.get("country"),
178
+ region=addr.get("state") or addr.get("region"),
179
+ city=addr.get("city") or addr.get("town") or addr.get("village"),
180
+ district=addr.get("suburb") or addr.get("city_district"),
181
+ note="Aus lesbarem Text im Bild (Schild) abgeleitet und geocodiert.",
182
+ )
183
+ for i, p in enumerate(ocr_places, start=1):
184
+ short = ", ".join(p["display"].split(",")[:2])
185
+ candidates.append(LocationCandidate(
186
+ rank=i, label=short or p["display"],
187
+ confidence=round(min(0.9, 0.4 + p["importance"]), 3),
188
+ lat=p["lat"], lon=p["lon"],
189
+ reasoning="Aus erkanntem Schild-/Ortstext per Geocoding gefunden.",
190
+ ))
191
+ uncertainty = "Mittel — abhängig davon, ob der erkannte Text wirklich der Aufnahmeort ist."
192
+
193
+ elif ref_geo:
194
+ # --- Reference retrieval: strong match to YOUR geotagged gallery ----
195
+ location_source = "reference"
196
+ rev = await geocode.reverse(ref_geo["lat"], ref_geo["lon"])
197
+ addr = (rev or {}).get("address", {})
198
+ sim = ref_geo["similarity"]
199
+ hierarchy = Hierarchy(
200
+ continent=COUNTRY_TO_CONTINENT.get(addr.get("country")) if addr.get("country") else None,
201
+ country=addr.get("country"),
202
+ region=addr.get("state") or addr.get("region"),
203
+ city=addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality"),
204
+ district=addr.get("suburb") or addr.get("city_district"),
205
+ note=f"Aus Bild-Retrieval gegen deine eigene Referenzgalerie "
206
+ f"({ref_geo['n']} ähnliche Geo-Fotos, beste Ähnlichkeit {sim:.2f}). "
207
+ "Genauigkeit hängt davon ab, wie nah deine Referenzbilder am Aufnahmeort liegen.",
208
+ )
209
+ for i, m in enumerate(ref_geo["matches"], start=1):
210
+ candidates.append(LocationCandidate(
211
+ rank=i,
212
+ label=_short_label(addr, (rev or {}).get("display", "")) if i == 1
213
+ else f"{m['lat']:.4f}, {m['lon']:.4f} ({m['name']})",
214
+ confidence=round(min(0.99, m["similarity"]), 3),
215
+ lat=m["lat"], lon=m["lon"],
216
+ reasoning=f"Ähnlich zu Referenzbild „{m['name']}“ "
217
+ f"(Kosinus-Ähnlichkeit {m['similarity']:.2f}).",
218
+ ))
219
+ uncertainty = (
220
+ f"Niedrig–mittel — Treffer in deiner Referenzgalerie (Ähnlichkeit {sim:.2f}). "
221
+ "Je näher ein Referenzfoto am echten Ort liegt, desto genauer."
222
+ if sim >= 0.92 else
223
+ f"Mittel — moderater Galerie-Treffer (Ähnlichkeit {sim:.2f}). "
224
+ "Mehr/nähere Referenzfotos verbessern das Ergebnis."
225
+ )
226
+
227
+ elif picarta_preds:
228
+ # --- Picarta: commercial GeoSpy-class API (token set) ---------------
229
+ location_source = "picarta"
230
+ top = picarta_preds[0]
231
+ rev = await geocode.reverse(top["lat"], top["lon"])
232
+ addr = (rev or {}).get("address", {})
233
+ country = top.get("country") or addr.get("country")
234
+ city = top.get("city") or addr.get("city") or addr.get("town") or addr.get("village")
235
+ hierarchy = Hierarchy(
236
+ continent=COUNTRY_TO_CONTINENT.get(country) if country else None,
237
+ country=country,
238
+ region=top.get("province") or addr.get("state") or addr.get("region"),
239
+ city=city,
240
+ district=addr.get("suburb") or addr.get("city_district"),
241
+ note="Von der Picarta-API (GeoSpy-Klasse) vorhergesagt — externe "
242
+ "Bild-Geolokalisierung, oft stadt-/straßengenau. Koordinaten sind "
243
+ "eine Schätzung des Anbieters, kein GPS.",
244
+ )
245
+ for i, p in enumerate(picarta_preds, start=1):
246
+ parts = [x for x in (p.get("city"), p.get("province"), p.get("country")) if x]
247
+ label = ", ".join(parts) or f"{p['lat']:.4f}, {p['lon']:.4f}"
248
+ candidates.append(LocationCandidate(
249
+ rank=i, label=label,
250
+ confidence=round(min(0.99, p.get("confidence", 0.0)), 3),
251
+ lat=p["lat"], lon=p["lon"],
252
+ reasoning="Picarta-API-Vorhersage (GeoSpy-Klasse).",
253
+ ))
254
+ uncertainty = ("Niedrig–mittel — Picarta-API (GeoSpy-Klasse), häufig stadt-/"
255
+ "straßengenau. Externe Schätzung, kein GPS.")
256
+
257
+ elif geo_preds:
258
+ # --- GeoCLIP: real coordinate prediction (GeoSpy-style) -------------
259
+ location_source = "geoclip"
260
+ revs = []
261
+ for lat, lon, _ in geo_preds:
262
+ rev = await geocode.reverse(lat, lon)
263
+ revs.append(rev or {})
264
+ sc = countries[0][0] if countries else None
265
+
266
+ # StreetCLIP cross-check: down-weight GeoCLIP coordinates whose country
267
+ # contradicts StreetCLIP's country guess (catches gross "wrong country/
268
+ # continent" misses). Uses ISO codes so it is language-agnostic. One
269
+ # extra (cached) forward-geocode resolves StreetCLIP's top country code.
270
+ reranked = False
271
+ if settings.geoclip_country_rerank and sc:
272
+ sc_hits = await geocode.forward(sc, limit=1)
273
+ sc_code = (sc_hits[0].get("country_code") if sc_hits else None)
274
+ if sc_code:
275
+ scored = []
276
+ for (lat, lon, p), rev in zip(geo_preds, revs):
277
+ code = (rev.get("address", {}) or {}).get("country_code")
278
+ boost = 1.0 if code == sc_code else 0.4 # penalise disagreement
279
+ scored.append((p * boost, lat, lon, p, rev))
280
+ scored.sort(key=lambda t: t[0], reverse=True)
281
+ geo_preds = [(lat, lon, p) for _, lat, lon, p, _ in scored]
282
+ revs = [rev for *_, rev in scored]
283
+ reranked = True
284
+
285
+ top_addr = revs[0].get("address", {})
286
+ hierarchy = Hierarchy(
287
+ continent=COUNTRY_TO_CONTINENT.get(top_addr.get("country")) if top_addr.get("country") else None,
288
+ country=top_addr.get("country"),
289
+ region=top_addr.get("state") or top_addr.get("region"),
290
+ city=top_addr.get("city") or top_addr.get("town") or top_addr.get("village")
291
+ or top_addr.get("municipality"),
292
+ district=top_addr.get("suburb") or top_addr.get("city_district"),
293
+ note="Aus GeoCLIP-Koordinatenvorhersage (mit TTA-Mehrfachauswertung) "
294
+ "rückwärts-geocodiert. Dies ist eine Modell-Schätzung der Koordinaten "
295
+ "(kein GPS); die Stadt-/Stadtteil-Ebene kann ungenau sein."
296
+ + (f" Per StreetCLIP-Ländercheck bestätigt/neu sortiert ({sc})." if reranked
297
+ else (f" StreetCLIP-Kontext nennt {sc}." if sc else "")),
298
+ )
299
+ total = sum(p for _, _, p in geo_preds) or 1.0
300
+ for i, ((lat, lon, p), rev) in enumerate(zip(geo_preds, revs), start=1):
301
+ addr = rev.get("address", {})
302
+ label = _short_label(addr, rev.get("display", "")) or f"{lat:.4f}, {lon:.4f}"
303
+ candidates.append(LocationCandidate(
304
+ rank=i, label=label, confidence=round(p / total, 3),
305
+ lat=lat, lon=lon,
306
+ reasoning="GeoCLIP-Koordinatenvorhersage (approx.). "
307
+ + _reasoning_from_signals(tops, hierarchy.country or "dem Land"),
308
+ ))
309
+ spread = (_haversine_km((geo_preds[0][0], geo_preds[0][1]),
310
+ (geo_preds[1][0], geo_preds[1][1]))
311
+ if len(geo_preds) > 1 else 0.0)
312
+ uncertainty = (
313
+ f"GeoCLIP-Koordinaten. Streuung Top-1↔Top-2: ~{spread:.0f} km. "
314
+ + ("Vorhersagen liegen nah beieinander → höhere Zuversicht. " if spread < 25
315
+ else "Vorhersagen streuen → geringere Zuversicht. ")
316
+ + (f"Mit StreetCLIP-Ländercheck abgeglichen ({sc}). " if reranked
317
+ else (f"StreetCLIP-Kontext nennt {sc}. " if sc else ""))
318
+ + "Koordinaten sind eine Modell-Schätzung, kein GPS."
319
+ )
320
+
321
+ else:
322
+ location_source = "inference"
323
+ top_country = countries[0][0] if countries else None
324
+ region_note = " Region ist eine grobe Inferenz." if inferred_region else ""
325
+ hierarchy = Hierarchy(
326
+ continent=COUNTRY_TO_CONTINENT.get(top_country) if top_country else None,
327
+ country=top_country,
328
+ region=inferred_region, city=None, district=None,
329
+ note="Stadt/Stadtteil sind aus dem Bildinhalt nicht zuverlässig bestimmbar "
330
+ "(kein GPS, kein lesbares Ortsschild). Es wird ehrlich nur Land/Region geschätzt."
331
+ + region_note,
332
+ )
333
+ # Geocode centroids of the top countries for map markers (limit network calls)
334
+ coords: dict[str, tuple[float, float]] = {}
335
+ for name, _ in countries[:5]:
336
+ hits = await geocode.forward(name, limit=1)
337
+ if hits:
338
+ coords[name] = (hits[0]["lat"], hits[0]["lon"])
339
+ for i, (name, score) in enumerate(countries, start=1):
340
+ latlon = coords.get(name)
341
+ candidates.append(LocationCandidate(
342
+ rank=i, label=name, confidence=round(score, 3),
343
+ lat=latlon[0] if latlon else None,
344
+ lon=latlon[1] if latlon else None,
345
+ reasoning=_reasoning_from_signals(tops, name),
346
+ ))
347
+ spread = countries[0][1] - countries[1][1] if len(countries) > 1 else 0.0
348
+ uncertainty = ("Hoch — reine Bildinferenz auf Land-/Regionsebene. "
349
+ f"Abstand Top-1 zu Top-2: {spread:.2f}. Kein Stadt-/Adress-Treffer.")
350
+
351
+ return AnalysisResult(
352
+ kind="image", source_name=source_name,
353
+ gps=gps, location_source=location_source, hierarchy=hierarchy,
354
+ candidates=candidates[:10], signals=groups, ocr_text=ocr_text,
355
+ reference_matches=ref_matches, uncertainty=uncertainty,
356
+ model_used=engine.model_name or "(lazy)",
357
+ )
358
+
359
+
360
+ async def analyze_video(data: bytes, source_name: str = "") -> AnalysisResult:
361
+ """Sample keyframes, analyse each, aggregate country votes."""
362
+ from .video import extract_keyframes
363
+
364
+ frames = await asyncio.to_thread(extract_keyframes, data)
365
+ if not frames:
366
+ res = AnalysisResult(kind="video", source_name=source_name,
367
+ uncertainty="Keine Frames extrahierbar.")
368
+ return res
369
+
370
+ engine = get_engine()
371
+ geo = get_geo_engine()
372
+
373
+ def _analyze():
374
+ agg: dict[str, float] = {}
375
+ pts: list[tuple[float, float, float]] = []
376
+ for fr in frames:
377
+ for name, score in engine.zero_shot(fr, COUNTRY_NAMES, template=COUNTRY_PROMPT, top_k=5):
378
+ agg[name] = agg.get(name, 0.0) + score
379
+ preds = geo.predict(fr, top_k=1)
380
+ if preds:
381
+ pts.append(preds[0])
382
+ return agg, pts
383
+
384
+ agg, pts = await asyncio.to_thread(_analyze)
385
+
386
+ # --- GeoCLIP path: aggregate frame coordinates by their medoid ----------
387
+ if pts:
388
+ def _cost(i: int) -> float:
389
+ return sum(_haversine_km((pts[i][0], pts[i][1]), (q[0], q[1])) for q in pts)
390
+
391
+ medoid = min(range(len(pts)), key=_cost)
392
+ mlat, mlon, _ = pts[medoid]
393
+ spread = sum(_haversine_km((mlat, mlon), (q[0], q[1])) for q in pts) / len(pts)
394
+ rev = await geocode.reverse(mlat, mlon)
395
+ addr = (rev or {}).get("address", {})
396
+
397
+ uniq: dict[tuple[float, float], tuple[float, float, float]] = {}
398
+ for lat, lon, p in pts:
399
+ key = (round(lat, 2), round(lon, 2))
400
+ if key not in uniq or p > uniq[key][2]:
401
+ uniq[key] = (lat, lon, p)
402
+ ordered = sorted(uniq.values(),
403
+ key=lambda q: _haversine_km((mlat, mlon), (q[0], q[1])))[:10]
404
+ candidates = []
405
+ for i, (lat, lon, p) in enumerate(ordered, start=1):
406
+ label = f"{lat:.4f}, {lon:.4f}"
407
+ if i <= 5: # limit reverse-geocoding network calls
408
+ r = await geocode.reverse(lat, lon)
409
+ label = _short_label((r or {}).get("address", {}),
410
+ (r or {}).get("display", "")) or label
411
+ candidates.append(LocationCandidate(
412
+ rank=i, label=label, confidence=round(p, 3), lat=lat, lon=lon,
413
+ reasoning=f"GeoCLIP-Vorhersage aus Videoframe "
414
+ f"(~{_haversine_km((mlat, mlon), (lat, lon)):.0f} km vom Zentrum).",
415
+ ))
416
+ return AnalysisResult(
417
+ kind="video", source_name=source_name, location_source="geoclip",
418
+ hierarchy=Hierarchy(
419
+ continent=COUNTRY_TO_CONTINENT.get(addr.get("country")) if addr.get("country") else None,
420
+ country=addr.get("country"),
421
+ region=addr.get("state") or addr.get("region"),
422
+ city=addr.get("city") or addr.get("town") or addr.get("village") or addr.get("municipality"),
423
+ district=addr.get("suburb") or addr.get("city_district"),
424
+ note=f"GeoCLIP-Koordinaten über {len(frames)} Frames; zentralster Punkt (Medoid) "
425
+ f"rückwärts-geocodiert. Mittlere Streuung ~{spread:.0f} km. Modell-Schätzung, kein GPS.",
426
+ ),
427
+ candidates=candidates,
428
+ uncertainty=(f"Mittel — GeoCLIP-Koordinaten über {len(frames)} Frames, "
429
+ f"mittlere Streuung zum Zentrum ~{spread:.0f} km. Kein GPS."),
430
+ model_used=engine.model_name or "(lazy)",
431
+ )
432
+
433
+ # --- fallback: StreetCLIP country vote ----------------------------------
434
+ total = sum(agg.values()) or 1.0
435
+ ranked = sorted(((k, v / total) for k, v in agg.items()), key=lambda x: x[1], reverse=True)[:10]
436
+
437
+ coords: dict[str, tuple[float, float]] = {}
438
+ for name, _ in ranked[:5]:
439
+ hits = await geocode.forward(name, limit=1)
440
+ if hits:
441
+ coords[name] = (hits[0]["lat"], hits[0]["lon"])
442
+
443
+ candidates = [
444
+ LocationCandidate(
445
+ rank=i, label=name, confidence=round(score, 3),
446
+ lat=coords.get(name, (None, None))[0], lon=coords.get(name, (None, None))[1],
447
+ reasoning=f"Konsens aus {len(frames)} analysierten Videoframes.",
448
+ )
449
+ for i, (name, score) in enumerate(ranked, start=1)
450
+ ]
451
+ top_country = ranked[0][0] if ranked else None
452
+ return AnalysisResult(
453
+ kind="video", source_name=source_name,
454
+ hierarchy=Hierarchy(continent=COUNTRY_TO_CONTINENT.get(top_country) if top_country else None,
455
+ country=top_country,
456
+ note=f"Aggregiert aus {len(frames)} Frames. Route wird bewusst nicht "
457
+ "rekonstruiert (aus Bildinhalt nicht zuverlässig möglich)."),
458
+ candidates=candidates, location_source="inference",
459
+ uncertainty="Hoch — Videoinferenz auf Land-/Regionsebene, Konsens über Frames.",
460
+ model_used=engine.model_name or "(lazy)",
461
+ )
backend/app/services/geocode.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Geocoding helpers backed by OpenStreetMap Nominatim (cached, rate-limited)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import time
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+ from ..config import get_settings
11
+ from ..core.cache import geocode_cache
12
+
13
+ settings = get_settings()
14
+ _last_call = 0.0
15
+ _lock = asyncio.Lock()
16
+
17
+
18
+ def _headers() -> dict:
19
+ ua = "GeoVisionPro/1.0"
20
+ if settings.nominatim_email:
21
+ ua += f" ({settings.nominatim_email})"
22
+ return {"User-Agent": ua, "Accept": "application/json"}
23
+
24
+
25
+ async def _rate_limited_get(client: httpx.AsyncClient, url: str, params: dict):
26
+ global _last_call
27
+ async with _lock: # Nominatim asks for <= 1 request/second
28
+ wait = 1.0 - (time.time() - _last_call)
29
+ if wait > 0:
30
+ await asyncio.sleep(wait)
31
+ _last_call = time.time()
32
+ return await client.get(url, params=params, headers=_headers())
33
+
34
+
35
+ async def forward(query: str, limit: int = 3) -> list[dict]:
36
+ """Search a free-text place query -> list of {display, lat, lon, importance}."""
37
+ query = (query or "").strip()
38
+ if not query:
39
+ return []
40
+ cache_key = f"fwd:{limit}:{query.lower()}"
41
+ cached = geocode_cache.get(cache_key)
42
+ if cached is not None:
43
+ return cached
44
+ params = {"format": "jsonv2", "q": query, "limit": limit,
45
+ "accept-language": "de", "addressdetails": 1}
46
+ async with httpx.AsyncClient(timeout=settings.http_timeout) as client:
47
+ try:
48
+ r = await _rate_limited_get(client, f"{settings.nominatim_url}/search", params)
49
+ r.raise_for_status()
50
+ data = r.json()
51
+ except Exception:
52
+ return []
53
+ out = [
54
+ {"display": d.get("display_name", ""), "lat": float(d["lat"]), "lon": float(d["lon"]),
55
+ "importance": float(d.get("importance", 0.0)),
56
+ "country_code": (d.get("address", {}) or {}).get("country_code")}
57
+ for d in data if d.get("lat") and d.get("lon")
58
+ ]
59
+ geocode_cache.set(cache_key, out)
60
+ return out
61
+
62
+
63
+ async def reverse(lat: float, lon: float) -> Optional[dict]:
64
+ """Reverse geocode coordinates -> {display, address}."""
65
+ cache_key = f"rev:{round(lat,5)}:{round(lon,5)}"
66
+ cached = geocode_cache.get(cache_key)
67
+ if cached is not None:
68
+ return cached
69
+ params = {"format": "jsonv2", "lat": lat, "lon": lon, "zoom": 14, "accept-language": "de"}
70
+ async with httpx.AsyncClient(timeout=settings.http_timeout) as client:
71
+ try:
72
+ r = await _rate_limited_get(client, f"{settings.nominatim_url}/reverse", params)
73
+ r.raise_for_status()
74
+ d = r.json()
75
+ except Exception:
76
+ return None
77
+ result = {"display": d.get("display_name", ""), "address": d.get("address", {})}
78
+ geocode_cache.set(cache_key, result)
79
+ return result
backend/app/services/geoengine.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GeoCLIP coordinate predictor — the GeoSpy-style core.
2
+
3
+ GeoCLIP (Vivanco et al., NeurIPS 2023) predicts *real GPS coordinates* for an
4
+ image, returning a ranked gallery of (lat, lon) with probabilities. It is the
5
+ closest openly-available model to commercial tools such as GeoSpy: instead of
6
+ only naming a country, it places the photo on the map.
7
+
8
+ It is OPTIONAL. If the `geoclip` package or its weights cannot be loaded, the
9
+ engine marks itself failed and callers transparently fall back to StreetCLIP
10
+ country inference. Heavy imports (torch, geoclip) happen only inside load(),
11
+ so importing this module never pulls in those dependencies.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import tempfile
18
+ import threading
19
+ from typing import Optional
20
+
21
+ from PIL import Image
22
+
23
+ from ..config import get_settings
24
+
25
+ logger = logging.getLogger(__name__)
26
+ settings = get_settings()
27
+
28
+
29
+ class GeoEngine:
30
+ def __init__(self) -> None:
31
+ self._model = None
32
+ self._device = "cpu"
33
+ self._lock = threading.Lock()
34
+ self._failed = False
35
+ self._name = "GeoCLIP"
36
+
37
+ @property
38
+ def name(self) -> str:
39
+ return self._name
40
+
41
+ @property
42
+ def available(self) -> bool:
43
+ """True until a load attempt has definitively failed."""
44
+ return settings.enable_geoclip and not self._failed
45
+
46
+ def load(self) -> None:
47
+ if self._model is not None or self._failed or not settings.enable_geoclip:
48
+ return
49
+ with self._lock:
50
+ if self._model is not None or self._failed:
51
+ return
52
+ try:
53
+ import torch
54
+ from geoclip import GeoCLIP
55
+
56
+ if settings.device == "auto":
57
+ self._device = "cuda" if torch.cuda.is_available() else "cpu"
58
+ else:
59
+ self._device = settings.device
60
+ logger.info("Loading GeoCLIP on %s ...", self._device)
61
+ self._model = GeoCLIP().to(self._device)
62
+ logger.info("GeoCLIP ready.")
63
+ except Exception as exc: # package missing, no weights, OOM, ...
64
+ logger.warning(
65
+ "GeoCLIP unavailable (%s) — falling back to StreetCLIP country inference.",
66
+ exc,
67
+ )
68
+ self._failed = True
69
+
70
+ def _predict_one(self, image: Image.Image, top_k: int) -> list[tuple[float, float, float]]:
71
+ """Single forward pass. geoclip reads from a path, so we use a temp JPEG
72
+ (this also normalises HEIC/PNG inputs uniformly)."""
73
+ tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
74
+ try:
75
+ image.convert("RGB").save(tmp.name, "JPEG", quality=95)
76
+ tmp.close()
77
+ gps, probs = self._model.predict(tmp.name, top_k=top_k)
78
+ return [(float(lat), float(lon), float(p))
79
+ for (lat, lon), p in zip(gps.tolist(), probs.tolist())]
80
+ finally:
81
+ try:
82
+ os.unlink(tmp.name)
83
+ except OSError:
84
+ pass
85
+
86
+ def predict(self, image: Image.Image, top_k: Optional[int] = None) -> list[tuple[float, float, float]]:
87
+ """Return [(lat, lon, prob), ...] best-first. Empty list if unavailable.
88
+
89
+ With TTA on (default), the image and its mirror are each scored against
90
+ the GeoCLIP GPS gallery and the per-coordinate probabilities are summed.
91
+ Because both views rank the *same* fixed gallery, this is a clean
92
+ ensemble that stabilises the prediction at no accuracy cost — only a bit
93
+ more CPU time.
94
+ """
95
+ self.load()
96
+ if self._model is None:
97
+ return []
98
+ top_k = top_k or settings.geoclip_top_k
99
+ pool = max(settings.geoclip_candidate_pool, top_k)
100
+
101
+ views = [image.convert("RGB")]
102
+ if settings.geoclip_tta:
103
+ from PIL import ImageOps
104
+ views.append(ImageOps.mirror(image.convert("RGB")))
105
+
106
+ try:
107
+ agg: dict[tuple[float, float], float] = {}
108
+ for view in views:
109
+ for lat, lon, p in self._predict_one(view, pool):
110
+ key = (round(lat, 4), round(lon, 4))
111
+ agg[key] = agg.get(key, 0.0) + p
112
+ if not agg:
113
+ return []
114
+ total = sum(agg.values()) or 1.0
115
+ ranked = sorted(((lat, lon, p / total) for (lat, lon), p in agg.items()),
116
+ key=lambda t: t[2], reverse=True)
117
+ return ranked[:top_k]
118
+ except Exception as exc:
119
+ logger.warning("GeoCLIP prediction failed: %s", exc)
120
+ return []
121
+
122
+
123
+ _geo: Optional[GeoEngine] = None
124
+
125
+
126
+ def get_geo_engine() -> GeoEngine:
127
+ global _geo
128
+ if _geo is None:
129
+ _geo = GeoEngine()
130
+ return _geo
backend/app/services/labels.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Label data for zero-shot geolocation and visual signal analysis.
2
+
3
+ Country list is curated (not exhaustive) to keep inference fast. StreetCLIP was
4
+ trained on country-level prompts, so these prompts match its training distribution.
5
+ """
6
+
7
+ # (country, continent)
8
+ COUNTRIES: list[tuple[str, str]] = [
9
+ ("Germany", "Europe"), ("France", "Europe"), ("Italy", "Europe"), ("Spain", "Europe"),
10
+ ("Portugal", "Europe"), ("United Kingdom", "Europe"), ("Ireland", "Europe"),
11
+ ("Netherlands", "Europe"), ("Belgium", "Europe"), ("Switzerland", "Europe"),
12
+ ("Austria", "Europe"), ("Poland", "Europe"), ("Czechia", "Europe"), ("Slovakia", "Europe"),
13
+ ("Hungary", "Europe"), ("Romania", "Europe"), ("Bulgaria", "Europe"), ("Greece", "Europe"),
14
+ ("Croatia", "Europe"), ("Slovenia", "Europe"), ("Serbia", "Europe"), ("Norway", "Europe"),
15
+ ("Sweden", "Europe"), ("Finland", "Europe"), ("Denmark", "Europe"), ("Iceland", "Europe"),
16
+ ("Estonia", "Europe"), ("Latvia", "Europe"), ("Lithuania", "Europe"), ("Ukraine", "Europe"),
17
+ ("Russia", "Europe"), ("Turkey", "Asia"),
18
+ ("United States", "North America"), ("Canada", "North America"), ("Mexico", "North America"),
19
+ ("Guatemala", "North America"), ("Cuba", "North America"), ("Costa Rica", "North America"),
20
+ ("Brazil", "South America"), ("Argentina", "South America"), ("Chile", "South America"),
21
+ ("Peru", "South America"), ("Colombia", "South America"), ("Bolivia", "South America"),
22
+ ("Ecuador", "South America"), ("Uruguay", "South America"),
23
+ ("China", "Asia"), ("Japan", "Asia"), ("South Korea", "Asia"), ("India", "Asia"),
24
+ ("Thailand", "Asia"), ("Vietnam", "Asia"), ("Indonesia", "Asia"), ("Malaysia", "Asia"),
25
+ ("Philippines", "Asia"), ("Singapore", "Asia"), ("Taiwan", "Asia"), ("Cambodia", "Asia"),
26
+ ("Nepal", "Asia"), ("Sri Lanka", "Asia"), ("Pakistan", "Asia"), ("Bangladesh", "Asia"),
27
+ ("Israel", "Asia"), ("Jordan", "Asia"), ("United Arab Emirates", "Asia"),
28
+ ("Saudi Arabia", "Asia"), ("Iran", "Asia"), ("Kazakhstan", "Asia"),
29
+ ("Egypt", "Africa"), ("Morocco", "Africa"), ("Tunisia", "Africa"), ("Algeria", "Africa"),
30
+ ("South Africa", "Africa"), ("Kenya", "Africa"), ("Tanzania", "Africa"), ("Nigeria", "Africa"),
31
+ ("Ghana", "Africa"), ("Ethiopia", "Africa"), ("Namibia", "Africa"), ("Botswana", "Africa"),
32
+ ("Australia", "Oceania"), ("New Zealand", "Oceania"), ("Fiji", "Oceania"),
33
+ ]
34
+
35
+ COUNTRY_TO_CONTINENT: dict[str, str] = {name: cont for name, cont in COUNTRIES}
36
+ COUNTRY_NAMES: list[str] = [name for name, _ in COUNTRIES]
37
+
38
+ COUNTRY_PROMPT = "a street level photo taken in {}"
39
+ REGION_PROMPT = "a street level photo taken in {}"
40
+
41
+ # Optional region prompts for a few large countries. Used only to refine the
42
+ # "Region" level when the location is pure inference. Still inference (not exact).
43
+ REGIONS: dict[str, list[str]] = {
44
+ "Germany": ["Bavaria, Germany", "Berlin, Germany", "Hamburg, Germany",
45
+ "North Rhine-Westphalia, Germany", "Saxony, Germany",
46
+ "Baden-Württemberg, Germany", "Hesse, Germany", "Lower Saxony, Germany"],
47
+ "United States": ["California, USA", "Texas, USA", "Florida, USA", "New York, USA",
48
+ "Arizona, USA", "Colorado, USA", "Washington State, USA", "Louisiana, USA"],
49
+ "France": ["Île-de-France", "Provence, France", "Brittany, France", "Normandy, France",
50
+ "Occitanie, France", "Auvergne-Rhône-Alpes, France"],
51
+ "Italy": ["Tuscany, Italy", "Sicily, Italy", "Lombardy, Italy", "Veneto, Italy",
52
+ "Lazio, Italy", "Campania, Italy", "Piedmont, Italy"],
53
+ "Spain": ["Andalusia, Spain", "Catalonia, Spain", "Madrid, Spain", "Valencia, Spain",
54
+ "Galicia, Spain", "Basque Country, Spain"],
55
+ "United Kingdom": ["England", "Scotland", "Wales", "Northern Ireland"],
56
+ }
57
+
58
+ # Visual-analysis signal groups. Each maps a human label -> CLIP prompt.
59
+ # The fusion step turns the top score of each group into an explainability weight.
60
+ SIGNAL_GROUPS: dict[str, dict[str, str]] = {
61
+ "Landschaft": {
62
+ "Küste / Meer": "a photo of a coastline with the sea",
63
+ "Berge": "a photo of a mountain landscape",
64
+ "Wald": "a photo of a dense forest",
65
+ "Wüste": "a photo of a desert",
66
+ "Felder / Ebene": "a photo of open farmland and fields",
67
+ "Stadtlandschaft": "a photo of an urban cityscape",
68
+ "Tropisch": "a photo of a tropical landscape with palm trees",
69
+ "See / Fluss": "a photo of a lake or river",
70
+ },
71
+ "Architektur": {
72
+ "Nordeuropäisch / Backstein": "a photo of north european brick architecture",
73
+ "Mediterran": "a photo of mediterranean architecture with terracotta roofs",
74
+ "Nordamerikanisch (Vorstadt)": "a photo of north american suburban houses",
75
+ "Ostasiatisch": "a photo of east asian architecture",
76
+ "Hochhäuser / modern": "a photo of modern skyscrapers and glass facades",
77
+ "Altstadt / historisch": "a photo of a historic old town",
78
+ "Tropisch / informell": "a photo of informal tropical buildings",
79
+ },
80
+ "Infrastruktur": {
81
+ "Europäische Straßenmarkierung": "a photo of european road markings and signs",
82
+ "US-Straßen / Ampeln": "a photo of north american roads with hanging traffic lights",
83
+ "Linksverkehr": "a photo of a left-hand traffic road",
84
+ "Asiatische Stadtinfrastruktur": "a photo of asian urban street infrastructure",
85
+ "Ländliche Straße": "a photo of a rural road",
86
+ "Bahn / Gleise": "a photo of railway tracks and trains",
87
+ },
88
+ "Klima": {
89
+ "Schnee / Winter": "a snowy winter scene",
90
+ "Trocken / arid": "a dry arid climate scene",
91
+ "Feucht / grün": "a humid lush green climate scene",
92
+ "Gemäßigt": "a temperate climate scene",
93
+ "Tropisch heiß": "a hot tropical climate scene",
94
+ },
95
+ }
backend/app/services/ocr.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optional OCR via Tesseract (host binary). Reads signs/place names from images.
2
+
3
+ Per project boundary: this is general signage/text reading. It is NOT used for
4
+ license-plate numbers or person identification.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+
11
+ from PIL import Image, ImageOps
12
+
13
+ from ..config import get_settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+ settings = get_settings()
17
+
18
+ try: # pragma: no cover - optional dependency
19
+ import pytesseract
20
+
21
+ _TESS = True
22
+ except Exception: # pragma: no cover
23
+ _TESS = False
24
+
25
+
26
+ def available() -> bool:
27
+ if not (settings.enable_ocr and _TESS):
28
+ return False
29
+ try:
30
+ pytesseract.get_tesseract_version()
31
+ return True
32
+ except Exception:
33
+ return False
34
+
35
+
36
+ def _preprocess(image: Image.Image) -> Image.Image:
37
+ gray = ImageOps.grayscale(image)
38
+ gray = ImageOps.autocontrast(gray, cutoff=2)
39
+ w, h = gray.size
40
+ longest = max(w, h)
41
+ if longest < 1600:
42
+ scale = min(2.5, 1600 / longest)
43
+ gray = gray.resize((int(w * scale), int(h * scale)))
44
+ return gray
45
+
46
+
47
+ def read_text(image: Image.Image, lang: str = "deu+eng") -> str:
48
+ if not available():
49
+ return ""
50
+ try:
51
+ txt = pytesseract.image_to_string(_preprocess(image), lang=lang)
52
+ except Exception as exc: # missing language pack etc.
53
+ logger.warning("OCR failed: %s", exc)
54
+ return ""
55
+ return txt.strip()
56
+
57
+
58
+ def candidate_queries(text: str, max_queries: int = 4) -> list[str]:
59
+ """Turn OCR text into geocodable place-name candidates."""
60
+ lines = []
61
+ for raw in text.splitlines():
62
+ cleaned = re.sub(r"[^\w\s\-.,&äöüÄÖÜß]", " ", raw, flags=re.UNICODE)
63
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
64
+ if len(cleaned) >= 3 and re.search(r"[A-Za-zÄÖÜäöü]{3,}", cleaned):
65
+ lines.append(cleaned)
66
+ queries: list[str] = []
67
+ if lines:
68
+ queries.append(", ".join(lines[:4]))
69
+ queries.extend(l for l in lines[:5] if len(l) >= 4)
70
+ # de-duplicate preserving order
71
+ seen, out = set(), []
72
+ for q in queries:
73
+ if q.lower() not in seen:
74
+ seen.add(q.lower())
75
+ out.append(q)
76
+ return out[:max_queries]
backend/app/services/picarta.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Picarta predictor — the commercial, GeoSpy-class location API.
2
+
3
+ Picarta (https://picarta.ai) is the closest publicly-available service to
4
+ GeoSpy in accuracy: it routinely returns city- and street-level guesses, not
5
+ just a country. It is OPTIONAL and OFF unless a token is configured:
6
+
7
+ * Set GEOVISION_PICARTA_API_TOKEN to your (free-tier) token to enable it.
8
+ * Without a token, ``available`` is False and the pipeline transparently
9
+ falls back to the open models (reference retrieval / GeoCLIP / StreetCLIP).
10
+
11
+ Honesty note: this sends the image to an external service. We only call it when
12
+ a token is explicitly set, and we say so in the result's source label.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import logging
18
+ from typing import Optional
19
+
20
+ import httpx
21
+
22
+ from ..config import get_settings
23
+
24
+ logger = logging.getLogger(__name__)
25
+ settings = get_settings()
26
+
27
+
28
+ def available() -> bool:
29
+ return bool(settings.enable_picarta and settings.picarta_api_token.strip())
30
+
31
+
32
+ def _coerce_float(value) -> Optional[float]:
33
+ try:
34
+ if value is None:
35
+ return None
36
+ return float(value)
37
+ except (TypeError, ValueError):
38
+ return None
39
+
40
+
41
+ def _gps_from_entry(entry: dict) -> tuple[Optional[float], Optional[float]]:
42
+ """Pull (lat, lon) out of one prediction entry across Picarta's field shapes."""
43
+ gps = entry.get("gps")
44
+ if isinstance(gps, (list, tuple)) and len(gps) >= 2:
45
+ return _coerce_float(gps[0]), _coerce_float(gps[1])
46
+ lat = entry.get("ai_lat", entry.get("lat", entry.get("latitude")))
47
+ lon = entry.get("ai_lon", entry.get("lon", entry.get("longitude")))
48
+ return _coerce_float(lat), _coerce_float(lon)
49
+
50
+
51
+ def parse_response(data: dict) -> list[dict]:
52
+ """Normalise a Picarta response into [{lat, lon, confidence, country, city,
53
+ province}, ...] best-first. Pure function (no I/O) so it is unit-testable.
54
+ """
55
+ if not isinstance(data, dict):
56
+ return []
57
+ out: list[dict] = []
58
+
59
+ topk = data.get("topk_predictions_dict")
60
+ if isinstance(topk, dict):
61
+ # keys are usually "1", "2", ... -> sort numerically when possible
62
+ def _key(k):
63
+ try:
64
+ return int(k)
65
+ except (TypeError, ValueError):
66
+ return 1_000_000
67
+ for k in sorted(topk.keys(), key=_key):
68
+ entry = topk[k] or {}
69
+ addr = entry.get("address") if isinstance(entry.get("address"), dict) else entry
70
+ lat, lon = _gps_from_entry(entry)
71
+ if lat is None or lon is None:
72
+ continue
73
+ out.append({
74
+ "lat": lat, "lon": lon,
75
+ "confidence": _coerce_float(entry.get("confidence")) or 0.0,
76
+ "country": addr.get("country"),
77
+ "city": addr.get("city") or addr.get("town"),
78
+ "province": addr.get("province") or addr.get("state"),
79
+ })
80
+
81
+ if not out:
82
+ # fall back to the single top-level prediction
83
+ lat = _coerce_float(data.get("ai_lat"))
84
+ lon = _coerce_float(data.get("ai_lon"))
85
+ if lat is not None and lon is not None:
86
+ out.append({
87
+ "lat": lat, "lon": lon,
88
+ "confidence": _coerce_float(data.get("ai_confidence")) or 0.0,
89
+ "country": data.get("ai_country"),
90
+ "city": data.get("ai_city"),
91
+ "province": data.get("ai_province"),
92
+ })
93
+ return out
94
+
95
+
96
+ async def predict(image_bytes: bytes) -> list[dict]:
97
+ """Call Picarta and return normalised predictions. [] on any failure."""
98
+ if not available():
99
+ return []
100
+ payload = {
101
+ "TOKEN": settings.picarta_api_token.strip(),
102
+ "IMAGE": base64.b64encode(image_bytes).decode("ascii"),
103
+ "TOP_K": settings.picarta_top_k,
104
+ }
105
+ try:
106
+ async with httpx.AsyncClient(timeout=settings.http_timeout) as client:
107
+ r = await client.post(settings.picarta_url, json=payload,
108
+ headers={"Content-Type": "application/json"})
109
+ r.raise_for_status()
110
+ data = r.json()
111
+ except Exception as exc:
112
+ logger.warning("Picarta request failed (%s) — falling back to open models.", exc)
113
+ return []
114
+ preds = parse_response(data)
115
+ if not preds:
116
+ logger.info("Picarta returned no usable prediction; falling back.")
117
+ return preds
backend/app/services/reference.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reference image gallery — real image-retrieval geolocation.
2
+
3
+ HONEST DESIGN: there is no bundled "global image database" (that would be a fake).
4
+ Instead, point GEOVISION_REFERENCE_DIR at a folder of YOUR OWN geotagged images.
5
+ We embed them once (cached to disk so restarts and additions are cheap) and match
6
+ new photos against them with cosine similarity. This is exactly how commercial
7
+ tools pinpoint a place: retrieval against known, located images.
8
+
9
+ "Training with more images" lives here: the more geotagged photos you drop into
10
+ the folder, the more places the app can recognise — no GPU, no retraining.
11
+
12
+ Each reference image gets coordinates from, in order:
13
+ 1. its EXIF GPS, or
14
+ 2. a "lat,lon" pattern in its filename, e.g. cafe_48.8584_2.2945.jpg
15
+ Images without coordinates are still indexed (they show as look-alikes) but do
16
+ not contribute to the location estimate.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import re
23
+ import threading
24
+ from uuid import uuid4
25
+
26
+ import numpy as np
27
+
28
+ from ..config import get_settings
29
+ from .exif import extract_gps, open_image
30
+ from .vision import get_engine
31
+
32
+ logger = logging.getLogger(__name__)
33
+ settings = get_settings()
34
+
35
+ _IMG_EXT = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
36
+ _CACHE_NAME = ".geovision_ref_index.npz"
37
+ _COORD_RE = re.compile(r"(-?\d{1,2}\.\d{3,})[,_ ]+(-?\d{1,3}\.\d{3,})")
38
+ _FALLBACK_DIR = "/tmp/geovision_reference"
39
+
40
+ _index: list[dict] | None = None
41
+ _dir_override: str | None = None
42
+ _lock = threading.Lock()
43
+
44
+
45
+ def active_dir() -> str:
46
+ """The reference folder currently in use (may be a writable fallback)."""
47
+ return _dir_override or settings.reference_dir
48
+
49
+
50
+ def ensure_writable_dir() -> str:
51
+ """Return a writable reference folder, creating it; fall back to /tmp if the
52
+ configured dir (e.g. /data without persistent storage) is not writable."""
53
+ global _dir_override
54
+ target = active_dir()
55
+ for candidate in (target, _FALLBACK_DIR):
56
+ if not candidate:
57
+ continue
58
+ try:
59
+ os.makedirs(candidate, exist_ok=True)
60
+ probe = os.path.join(candidate, ".write_test")
61
+ with open(probe, "w") as fh:
62
+ fh.write("ok")
63
+ os.remove(probe)
64
+ if candidate != target:
65
+ _dir_override = candidate
66
+ logger.warning("Reference dir %s not writable — using %s instead.", target, candidate)
67
+ return candidate
68
+ except OSError:
69
+ continue
70
+ raise RuntimeError("No writable reference directory available.")
71
+
72
+
73
+ def coords_from_name(name: str) -> tuple[float, float] | tuple[None, None]:
74
+ """Parse a 'lat,lon' (or 'lat_lon') pattern from a filename. (None, None) if absent."""
75
+ m = _COORD_RE.search(os.path.splitext(os.path.basename(name))[0])
76
+ if not m:
77
+ return None, None
78
+ lat, lon = float(m.group(1)), float(m.group(2))
79
+ if -90 <= lat <= 90 and -180 <= lon <= 180:
80
+ return lat, lon
81
+ return None, None
82
+
83
+
84
+ def _scan(root: str) -> list[tuple[str, float]]:
85
+ """Recursively list (relative_path, mtime) for supported images under root."""
86
+ found: list[tuple[str, float]] = []
87
+ for dirpath, _dirs, files in os.walk(root):
88
+ for fname in files:
89
+ if os.path.splitext(fname)[1].lower() not in _IMG_EXT:
90
+ continue
91
+ full = os.path.join(dirpath, fname)
92
+ rel = os.path.relpath(full, root)
93
+ try:
94
+ found.append((rel, os.path.getmtime(full)))
95
+ except OSError:
96
+ continue
97
+ return sorted(found)
98
+
99
+
100
+ def _load_cache(root: str) -> dict[str, dict]:
101
+ """Load the on-disk embedding cache keyed by relative path -> {vec, lat, lon, mtime}."""
102
+ path = os.path.join(root, _CACHE_NAME)
103
+ if not os.path.isfile(path):
104
+ return {}
105
+ try:
106
+ npz = np.load(path, allow_pickle=True)
107
+ names = npz["names"]
108
+ vecs = npz["vecs"]
109
+ lats = npz["lats"]
110
+ lons = npz["lons"]
111
+ mtimes = npz["mtimes"]
112
+ except Exception as exc:
113
+ logger.warning("Reference cache unreadable (%s) — rebuilding.", exc)
114
+ return {}
115
+ out: dict[str, dict] = {}
116
+ for i, name in enumerate(names):
117
+ lat = float(lats[i]); lon = float(lons[i])
118
+ out[str(name)] = {
119
+ "vec": vecs[i].astype("float32"),
120
+ "lat": None if np.isnan(lat) else lat,
121
+ "lon": None if np.isnan(lon) else lon,
122
+ "mtime": float(mtimes[i]),
123
+ }
124
+ return out
125
+
126
+
127
+ def _save_cache(root: str, entries: list[dict]) -> None:
128
+ path = os.path.join(root, _CACHE_NAME)
129
+ try:
130
+ np.savez(
131
+ path,
132
+ names=np.array([e["name"] for e in entries], dtype=object),
133
+ vecs=np.array([e["vec"] for e in entries], dtype="float32"),
134
+ lats=np.array([np.nan if e["lat"] is None else e["lat"] for e in entries], dtype="float64"),
135
+ lons=np.array([np.nan if e["lon"] is None else e["lon"] for e in entries], dtype="float64"),
136
+ mtimes=np.array([e["mtime"] for e in entries], dtype="float64"),
137
+ )
138
+ except Exception as exc:
139
+ logger.warning("Could not write reference cache: %s", exc)
140
+
141
+
142
+ def _build_index() -> list[dict]:
143
+ root = active_dir()
144
+ if not root or not os.path.isdir(root):
145
+ return []
146
+ scanned = _scan(root)
147
+ if not scanned:
148
+ return []
149
+ cache = _load_cache(root)
150
+ engine = get_engine()
151
+ entries: list[dict] = []
152
+ embedded = reused = 0
153
+ for rel, mtime in scanned:
154
+ cached = cache.get(rel)
155
+ if cached is not None and abs(cached["mtime"] - mtime) < 1e-6:
156
+ entries.append({"name": rel, "vec": cached["vec"],
157
+ "lat": cached["lat"], "lon": cached["lon"], "mtime": mtime})
158
+ reused += 1
159
+ continue
160
+ full = os.path.join(root, rel)
161
+ try:
162
+ with open(full, "rb") as fh:
163
+ data = fh.read()
164
+ vec = engine.embed_image(open_image(data))
165
+ gps = extract_gps(data)
166
+ lat, lon = gps.get("lat"), gps.get("lon")
167
+ if lat is None or lon is None:
168
+ lat, lon = coords_from_name(rel)
169
+ entries.append({"name": rel, "vec": vec, "lat": lat, "lon": lon, "mtime": mtime})
170
+ embedded += 1
171
+ except Exception as exc:
172
+ logger.warning("Reference image %s skipped: %s", rel, exc)
173
+ if embedded:
174
+ _save_cache(root, entries)
175
+ located = sum(1 for e in entries if e["lat"] is not None)
176
+ logger.info("Reference index: %d images (%d new, %d cached, %d geolocated).",
177
+ len(entries), embedded, reused, located)
178
+ return entries
179
+
180
+
181
+ def get_index() -> list[dict]:
182
+ global _index
183
+ if _index is None:
184
+ with _lock:
185
+ if _index is None:
186
+ _index = _build_index()
187
+ return _index
188
+
189
+
190
+ def reload() -> int:
191
+ """Force a rebuild (e.g. after adding images). Returns the image count."""
192
+ global _index
193
+ with _lock:
194
+ _index = _build_index()
195
+ return len(_index)
196
+
197
+
198
+ def list_entries() -> list[dict]:
199
+ """Current gallery contents (for the UI): name + coordinates, newest last."""
200
+ return [{"name": e["name"], "lat": e["lat"], "lon": e["lon"]} for e in get_index()]
201
+
202
+
203
+ def add_image(data: bytes, lat: float, lon: float, name_hint: str = "") -> dict:
204
+ """Add one geotagged photo to the gallery: embed it, persist it (with the
205
+ coordinates encoded in the filename so it survives a rebuild that re-scans
206
+ the folder), and append it to the live in-memory index — effective at once.
207
+ """
208
+ root = ensure_writable_dir()
209
+ engine = get_engine()
210
+ image = open_image(data)
211
+ vec = engine.embed_image(image)
212
+ # letters-only stem keeps the coordinate parser unambiguous
213
+ safe = re.sub(r"[^A-Za-z]+", "", name_hint)[:30] or "ref"
214
+ fname = f"{safe}-{uuid4().hex[:6]}_{float(lat):.5f}_{float(lon):.5f}.jpg"
215
+ path = os.path.join(root, fname)
216
+ image.convert("RGB").save(path, "JPEG", quality=92)
217
+
218
+ idx = get_index() # build (without the new file) before appending
219
+ idx.append({"name": fname, "vec": vec,
220
+ "lat": float(lat), "lon": float(lon),
221
+ "mtime": os.path.getmtime(path)})
222
+ _save_cache(root, idx)
223
+ return {
224
+ "name": fname,
225
+ "reference_images": len(idx),
226
+ "reference_geolocated": sum(1 for e in idx if e["lat"] is not None),
227
+ }
228
+
229
+
230
+ def match(image_vec: "np.ndarray", top_k: int = 5) -> list[dict]:
231
+ """Top-k look-alikes by cosine similarity (for display)."""
232
+ idx = get_index()
233
+ if not idx:
234
+ return []
235
+ sims = [
236
+ {"name": e["name"], "similarity": round(float(np.dot(image_vec, e["vec"])), 4),
237
+ "lat": e["lat"], "lon": e["lon"]}
238
+ for e in idx
239
+ ]
240
+ sims.sort(key=lambda x: x["similarity"], reverse=True)
241
+ return sims[:top_k]
242
+
243
+
244
+ def geolocate(image_vec: "np.ndarray") -> dict | None:
245
+ """Retrieval-based location estimate from the geotagged references.
246
+
247
+ Fuses the nearest geotagged neighbours (cosine >= threshold) into a
248
+ similarity-weighted centroid. Returns None if nothing clears the bar, so the
249
+ pipeline cleanly falls through to the next source.
250
+ """
251
+ idx = get_index()
252
+ if not idx:
253
+ return None
254
+ located = [e for e in idx if e["lat"] is not None and e["lon"] is not None]
255
+ if not located:
256
+ return None
257
+ scored = sorted(
258
+ ((float(np.dot(image_vec, e["vec"])), e) for e in located),
259
+ key=lambda x: x[0], reverse=True,
260
+ )
261
+ top = [(s, e) for s, e in scored[: settings.reference_use_top_k]
262
+ if s >= settings.reference_min_similarity]
263
+ if not top:
264
+ return None
265
+ wsum = sum(s for s, _ in top) or 1.0
266
+ lat = sum(s * e["lat"] for s, e in top) / wsum
267
+ lon = sum(s * e["lon"] for s, e in top) / wsum
268
+ matches = [{"name": e["name"], "similarity": round(s, 4),
269
+ "lat": e["lat"], "lon": e["lon"]} for s, e in top]
270
+ return {
271
+ "lat": lat, "lon": lon,
272
+ "similarity": top[0][0], # best single match (confidence proxy)
273
+ "n": len(top),
274
+ "matches": matches,
275
+ }
backend/app/services/report.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Report generation: JSON, CSV and PDF (Executive Summary) from an AnalysisResult."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import io
6
+
7
+ from ..schemas import AnalysisResult
8
+
9
+
10
+ def to_json_bytes(result: AnalysisResult) -> bytes:
11
+ return result.model_dump_json(indent=2).encode("utf-8")
12
+
13
+
14
+ def to_csv_bytes(result: AnalysisResult) -> bytes:
15
+ buf = io.StringIO()
16
+ w = csv.writer(buf)
17
+ w.writerow(["rank", "label", "confidence", "lat", "lon", "reasoning"])
18
+ for c in result.candidates:
19
+ w.writerow([c.rank, c.label, c.confidence, c.lat or "", c.lon or "", c.reasoning])
20
+ return ("" + buf.getvalue()).encode("utf-8")
21
+
22
+
23
+ def to_pdf_bytes(result: AnalysisResult) -> bytes:
24
+ """Render a clean one/two-page PDF report. Requires reportlab."""
25
+ from reportlab.lib import colors
26
+ from reportlab.lib.pagesizes import A4
27
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
28
+ from reportlab.lib.units import mm
29
+ from reportlab.platypus import (Paragraph, SimpleDocTemplate, Spacer, Table,
30
+ TableStyle)
31
+
32
+ buf = io.BytesIO()
33
+ doc = SimpleDocTemplate(buf, pagesize=A4, title="GeoVision Pro Report",
34
+ leftMargin=18 * mm, rightMargin=18 * mm,
35
+ topMargin=16 * mm, bottomMargin=16 * mm)
36
+ styles = getSampleStyleSheet()
37
+ h1 = ParagraphStyle("h1", parent=styles["Heading1"], textColor=colors.HexColor("#0b1f3a"))
38
+ h2 = ParagraphStyle("h2", parent=styles["Heading2"], textColor=colors.HexColor("#274060"))
39
+ body = styles["BodyText"]
40
+ small = ParagraphStyle("small", parent=body, fontSize=8, textColor=colors.grey)
41
+
42
+ elems = [Paragraph("GeoVision Pro — Standortbericht", h1)]
43
+ elems.append(Paragraph(f"Quelle: {result.source_name or '—'} &nbsp;|&nbsp; "
44
+ f"Typ: {result.kind} &nbsp;|&nbsp; Modell: {result.model_used}", small))
45
+ elems.append(Spacer(1, 8))
46
+
47
+ # Executive summary
48
+ elems.append(Paragraph("Executive Summary", h2))
49
+ best = result.candidates[0] if result.candidates else None
50
+ summary = (f"Wahrscheinlichster Ort: <b>{best.label}</b> "
51
+ f"(Konfidenz {best.confidence:.0%}). " if best else "Kein Standortkandidat. ")
52
+ summary += f"Quelle der Standortbestimmung: <b>{result.location_source}</b>. "
53
+ summary += f"Unsicherheit: {result.uncertainty}"
54
+ elems.append(Paragraph(summary, body))
55
+ elems.append(Spacer(1, 6))
56
+
57
+ # Hierarchy
58
+ h = result.hierarchy
59
+ rows = [["Ebene", "Wert"]]
60
+ for lbl, val in [("Kontinent", h.continent), ("Land", h.country),
61
+ ("Region", h.region), ("Stadt", h.city), ("Stadtteil", h.district)]:
62
+ rows.append([lbl, val or "— (nicht bestimmbar)"])
63
+ t = Table(rows, colWidths=[40 * mm, 120 * mm])
64
+ t.setStyle(TableStyle([
65
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#274060")),
66
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
67
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#c9d4e3")),
68
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
69
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#eef3f9")]),
70
+ ]))
71
+ elems.append(Paragraph("Standort-Hierarchie", h2))
72
+ elems.append(t)
73
+ if h.note:
74
+ elems.append(Paragraph(h.note, small))
75
+ elems.append(Spacer(1, 8))
76
+
77
+ # Candidates
78
+ elems.append(Paragraph("Standort-Hypothesen (Top 10)", h2))
79
+ crows = [["#", "Ort", "Konfidenz", "Begründung"]]
80
+ for c in result.candidates:
81
+ crows.append([str(c.rank), Paragraph(str(c.label), small),
82
+ f"{c.confidence:.0%}", Paragraph(c.reasoning, small)])
83
+ ct = Table(crows, colWidths=[8 * mm, 52 * mm, 20 * mm, 80 * mm])
84
+ ct.setStyle(TableStyle([
85
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#274060")),
86
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
87
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#c9d4e3")),
88
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
89
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
90
+ ]))
91
+ elems.append(ct)
92
+ elems.append(Spacer(1, 8))
93
+
94
+ # Signal weights
95
+ if result.signals:
96
+ elems.append(Paragraph("Erklärung — Gewichtung der Bildmerkmale", h2))
97
+ srows = [["Kategorie", "Top-Merkmal", "Gewicht"]]
98
+ for g in result.signals:
99
+ top = g.top[0].label if g.top else "—"
100
+ srows.append([g.name, top, f"{g.weight:.0%}"])
101
+ st = Table(srows, colWidths=[45 * mm, 75 * mm, 25 * mm])
102
+ st.setStyle(TableStyle([
103
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#274060")),
104
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
105
+ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#c9d4e3")),
106
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
107
+ ]))
108
+ elems.append(st)
109
+
110
+ elems.append(Spacer(1, 10))
111
+ elems.append(Paragraph(
112
+ "Hinweis: GeoVision Pro liefert exakte Orte nur bei GPS-Metadaten oder lesbaren "
113
+ "Ortsschildern. Reine Bildinferenz erreicht Land-/Regionsebene, nicht Hausnummern.",
114
+ small))
115
+
116
+ doc.build(elems)
117
+ return buf.getvalue()
backend/app/services/video.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video frame sampling via OpenCV.
2
+
3
+ Honest scope: we sample evenly spaced, sharp frames and analyse them as images,
4
+ then aggregate. We do NOT reconstruct a precise travel route from pixels — that is
5
+ not reliably possible. The aggregate location is the consensus of analysed frames.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import tempfile
12
+
13
+ import numpy as np
14
+ from PIL import Image
15
+
16
+ from ..config import get_settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+ settings = get_settings()
20
+
21
+
22
+ def _sharpness(gray: "np.ndarray") -> float:
23
+ import cv2
24
+
25
+ return float(cv2.Laplacian(gray, cv2.CV_64F).var())
26
+
27
+
28
+ def extract_keyframes(data: bytes, max_frames: int | None = None) -> list[Image.Image]:
29
+ """Sample up to `max_frames` evenly spaced, reasonably sharp frames."""
30
+ import cv2
31
+
32
+ max_frames = max_frames or settings.max_video_frames
33
+ tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
34
+ try:
35
+ tmp.write(data)
36
+ tmp.flush()
37
+ tmp.close()
38
+ cap = cv2.VideoCapture(tmp.name)
39
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0
40
+ if total <= 0:
41
+ # Fallback: read sequentially
42
+ frames = []
43
+ ok, frame = cap.read()
44
+ while ok and len(frames) < max_frames:
45
+ frames.append(frame)
46
+ for _ in range(15):
47
+ ok, frame = cap.read()
48
+ cap.release()
49
+ return [_to_pil(f) for f in frames]
50
+
51
+ # Sample 3x candidate positions, keep the sharpest in each bucket.
52
+ positions = np.linspace(0, total - 1, num=min(max_frames * 3, total)).astype(int)
53
+ picked: list[tuple[float, "np.ndarray"]] = []
54
+ for pos in positions:
55
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(pos))
56
+ ok, frame = cap.read()
57
+ if not ok:
58
+ continue
59
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
60
+ picked.append((_sharpness(gray), frame))
61
+ cap.release()
62
+
63
+ picked.sort(key=lambda x: x[0], reverse=True)
64
+ chosen = [f for _, f in picked[:max_frames]]
65
+ return [_to_pil(f) for f in chosen]
66
+ finally:
67
+ try:
68
+ os.unlink(tmp.name)
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ def _to_pil(frame_bgr) -> Image.Image:
74
+ import cv2
75
+
76
+ rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
77
+ return Image.fromarray(rgb)
backend/app/services/vision.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vision engine: CLIP / StreetCLIP zero-shot geolocation + scene analysis.
2
+
3
+ This is the honest core. StreetCLIP gives strong COUNTRY / REGION level signals.
4
+ It does NOT pinpoint streets or buildings — that is a research limitation, not a bug.
5
+ The engine exposes:
6
+ * zero_shot(image, labels, template) -> ranked (label, score)
7
+ * embed_image(image) -> L2-normalized numpy vector (for reference similarity)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import threading
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+ from PIL import Image
17
+
18
+ from ..config import get_settings
19
+
20
+ logger = logging.getLogger(__name__)
21
+ settings = get_settings()
22
+
23
+
24
+ class VisionEngine:
25
+ def __init__(self) -> None:
26
+ self._model = None
27
+ self._processor = None
28
+ self._device = "cpu"
29
+ self._model_name = ""
30
+ self._text_cache: dict[tuple, "np.ndarray"] = {}
31
+ self._logit_scale = 100.0
32
+ self._lock = threading.Lock()
33
+
34
+ # ---- lifecycle -------------------------------------------------------
35
+ @property
36
+ def model_name(self) -> str:
37
+ return self._model_name
38
+
39
+ @property
40
+ def loaded(self) -> bool:
41
+ return self._model is not None
42
+
43
+ def load(self) -> None:
44
+ if self._model is not None:
45
+ return
46
+ with self._lock:
47
+ if self._model is not None:
48
+ return
49
+ import torch
50
+ from transformers import CLIPModel, CLIPProcessor
51
+
52
+ if settings.device == "auto":
53
+ self._device = "cuda" if torch.cuda.is_available() else "cpu"
54
+ else:
55
+ self._device = settings.device
56
+
57
+ for name in (settings.vision_model, settings.vision_fallback_model):
58
+ try:
59
+ logger.info("Loading vision model %s on %s ...", name, self._device)
60
+ self._model = CLIPModel.from_pretrained(name).to(self._device).eval()
61
+ self._processor = CLIPProcessor.from_pretrained(name)
62
+ self._model_name = name
63
+ self._logit_scale = float(self._model.logit_scale.exp().item())
64
+ logger.info("Vision model ready: %s", name)
65
+ return
66
+ except Exception as exc: # try fallback
67
+ logger.warning("Failed to load %s: %s", name, exc)
68
+ self._model = None
69
+ raise RuntimeError("No vision model could be loaded (check network / disk / model name).")
70
+
71
+ # ---- inference -------------------------------------------------------
72
+ def _encode_text(self, labels: tuple[str, ...], template: str) -> "np.ndarray":
73
+ key = (self._model_name, template, labels)
74
+ cached = self._text_cache.get(key)
75
+ if cached is not None:
76
+ return cached
77
+ import torch
78
+
79
+ prompts = [template.format(lbl) for lbl in labels]
80
+ inputs = self._processor(text=prompts, return_tensors="pt", padding=True).to(self._device)
81
+ with torch.no_grad():
82
+ feats = self._model.get_text_features(**inputs)
83
+ feats = feats / feats.norm(p=2, dim=-1, keepdim=True)
84
+ arr = feats.cpu().numpy().astype("float32")
85
+ self._text_cache[key] = arr
86
+ return arr
87
+
88
+ def embed_image(self, image: Image.Image) -> "np.ndarray":
89
+ self.load()
90
+ import torch
91
+
92
+ inputs = self._processor(images=image, return_tensors="pt").to(self._device)
93
+ with torch.no_grad():
94
+ feats = self._model.get_image_features(**inputs)
95
+ feats = feats / feats.norm(p=2, dim=-1, keepdim=True)
96
+ return feats.cpu().numpy().astype("float32")[0]
97
+
98
+ def zero_shot(
99
+ self,
100
+ image: Image.Image,
101
+ labels: list[str],
102
+ template: str = "a photo of {}",
103
+ top_k: Optional[int] = None,
104
+ ) -> list[tuple[str, float]]:
105
+ """Return labels ranked by softmax probability (sums to 1 over `labels`)."""
106
+ self.load()
107
+ img_vec = self.embed_image(image) # (D,)
108
+ txt = self._encode_text(tuple(labels), template) # (N, D)
109
+ logits = (txt @ img_vec) * self._logit_scale # (N,)
110
+ logits = logits - logits.max()
111
+ probs = np.exp(logits)
112
+ probs = probs / probs.sum()
113
+ ranked = sorted(zip(labels, probs.tolist()), key=lambda x: x[1], reverse=True)
114
+ return ranked[:top_k] if top_k else ranked
115
+
116
+
117
+ _engine: Optional[VisionEngine] = None
118
+
119
+
120
+ def get_engine() -> VisionEngine:
121
+ global _engine
122
+ if _engine is None:
123
+ _engine = VisionEngine()
124
+ if not settings.model_lazy_load:
125
+ _engine.load()
126
+ return _engine
backend/pytest.ini ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [pytest]
2
+ pythonpath = .
3
+ testpaths = tests
4
+ addopts = -q
backend/requirements.txt ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core API
2
+ fastapi==0.115.6
3
+ uvicorn[standard]==0.34.0
4
+ python-multipart==0.0.18
5
+ pydantic==2.10.4
6
+ pydantic-settings==2.7.1
7
+
8
+ # Database (async)
9
+ SQLAlchemy==2.0.36
10
+ asyncpg==0.30.0 # PostgreSQL (docker-compose / production)
11
+ aiosqlite==0.20.0 # SQLite (single-container / Hugging Face Space)
12
+
13
+ # HTTP client (geocoding)
14
+ httpx==0.28.1
15
+
16
+ # Imaging
17
+ Pillow==11.1.0
18
+ pillow-heif==0.21.0 # HEIC/HEIF support
19
+ numpy==2.2.1
20
+
21
+ # Vision model (CLIP / StreetCLIP)
22
+ torch==2.5.1
23
+ torchvision==0.20.1 # required by GeoCLIP
24
+ transformers==4.48.0
25
+
26
+ # GeoCLIP — real GPS-coordinate prediction (the GeoSpy-style core).
27
+ # Pulls its own weights from HuggingFace on first use. Optional at runtime:
28
+ # the service degrades to StreetCLIP country inference if it cannot load.
29
+ geoclip==1.2.0
30
+
31
+ # Video frame extraction
32
+ opencv-python-headless==4.11.0.86
33
+
34
+ # Reports
35
+ reportlab==4.2.5
36
+
37
+ # Optional OCR (also needs the `tesseract-ocr` system package + language data)
38
+ pytesseract==0.3.13
backend/sql/schema.sql ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- GeoVision Pro — PostgreSQL schema (production reference).
2
+ -- The app can also create these via SQLAlchemy on startup (dev convenience),
3
+ -- but for production run this file or an Alembic migration.
4
+
5
+ CREATE TABLE IF NOT EXISTS analyses (
6
+ id SERIAL PRIMARY KEY,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ kind VARCHAR(16) NOT NULL DEFAULT 'image',
9
+ source_name VARCHAR(255) NOT NULL DEFAULT '',
10
+ best_label VARCHAR(255),
11
+ best_lat DOUBLE PRECISION,
12
+ best_lon DOUBLE PRECISION,
13
+ best_confidence DOUBLE PRECISION,
14
+ location_source VARCHAR(32) NOT NULL DEFAULT 'inference',
15
+ result JSONB NOT NULL DEFAULT '{}'::jsonb
16
+ );
17
+
18
+ CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON analyses (created_at DESC);
19
+
20
+ CREATE TABLE IF NOT EXISTS candidates (
21
+ id SERIAL PRIMARY KEY,
22
+ analysis_id INTEGER NOT NULL REFERENCES analyses (id) ON DELETE CASCADE,
23
+ rank INTEGER NOT NULL,
24
+ label VARCHAR(255) NOT NULL,
25
+ confidence DOUBLE PRECISION NOT NULL,
26
+ lat DOUBLE PRECISION,
27
+ lon DOUBLE PRECISION,
28
+ reasoning TEXT NOT NULL DEFAULT ''
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_candidates_analysis_id ON candidates (analysis_id);
backend/tests/test_fusion.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for pure logic that does NOT require the vision model."""
2
+ from app.services.labels import COUNTRY_TO_CONTINENT, COUNTRY_NAMES
3
+ from app.services.ocr import candidate_queries
4
+
5
+
6
+ def test_continent_mapping_complete():
7
+ assert all(c in COUNTRY_TO_CONTINENT for c in COUNTRY_NAMES)
8
+ assert COUNTRY_TO_CONTINENT["Germany"] == "Europe"
9
+ assert COUNTRY_TO_CONTINENT["Japan"] == "Asia"
10
+
11
+
12
+ def test_candidate_queries_extracts_lines():
13
+ text = "Hotel Bellevue\nZermatt\n!!\nab"
14
+ q = candidate_queries(text)
15
+ assert any("Zermatt" in s for s in q)
16
+ # the joined query of meaningful lines should be first
17
+ assert "Hotel Bellevue" in q[0]
backend/tests/test_health.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test for the health endpoint (no model download required)."""
2
+ import os
3
+
4
+ os.environ.setdefault("GEOVISION_DATABASE_URL", "sqlite+aiosqlite:///./test.db")
5
+
6
+ from fastapi.testclient import TestClient # noqa: E402
7
+
8
+ from app.main import app # noqa: E402
9
+
10
+
11
+ def test_health():
12
+ with TestClient(app) as client:
13
+ r = client.get("/api/health")
14
+ assert r.status_code == 200
15
+ assert r.json()["status"] == "ok"
backend/tests/test_picarta_reference.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the new Picarta + reference logic (no model / network needed)."""
2
+ from app.services.picarta import parse_response
3
+ from app.services.reference import coords_from_name
4
+
5
+
6
+ def test_parse_response_topk():
7
+ data = {
8
+ "ai_lat": 48.8584, "ai_lon": 2.2945, "ai_country": "France", "ai_city": "Paris",
9
+ "topk_predictions_dict": {
10
+ "1": {"gps": [48.8584, 2.2945], "confidence": 0.62,
11
+ "address": {"country": "France", "city": "Paris", "province": "Île-de-France"}},
12
+ "2": {"gps": [45.764, 4.8357], "confidence": 0.18,
13
+ "address": {"country": "France", "city": "Lyon"}},
14
+ },
15
+ }
16
+ preds = parse_response(data)
17
+ assert len(preds) == 2
18
+ assert preds[0]["city"] == "Paris"
19
+ assert preds[0]["confidence"] == 0.62
20
+ assert abs(preds[0]["lat"] - 48.8584) < 1e-6
21
+ assert preds[1]["city"] == "Lyon"
22
+
23
+
24
+ def test_parse_response_toplevel_fallback():
25
+ # no topk dict -> use the single ai_* prediction
26
+ data = {"ai_lat": 35.6895, "ai_lon": 139.6917, "ai_country": "Japan", "ai_city": "Tokyo"}
27
+ preds = parse_response(data)
28
+ assert len(preds) == 1
29
+ assert preds[0]["country"] == "Japan"
30
+ assert abs(preds[0]["lon"] - 139.6917) < 1e-6
31
+
32
+
33
+ def test_parse_response_empty():
34
+ assert parse_response({}) == []
35
+ assert parse_response({"topk_predictions_dict": {}}) == []
36
+ assert parse_response(None) == []
37
+
38
+
39
+ def test_coords_from_name():
40
+ assert coords_from_name("cafe_48.8584_2.2945.jpg") == (48.8584, 2.2945)
41
+ assert coords_from_name("48.8584,2.2945.png") == (48.8584, 2.2945)
42
+ # negative coordinates
43
+ assert coords_from_name("spot_-33.8688_151.2093.jpg") == (-33.8688, 151.2093)
44
+
45
+
46
+ def test_coords_from_name_none():
47
+ assert coords_from_name("holiday_photo.jpg") == (None, None)
48
+ # out-of-range rejected
49
+ assert coords_from_name("x_999.123_2.234.jpg") == (None, None)
frontend/Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # --- build stage ---
2
+ FROM node:20-alpine AS build
3
+ WORKDIR /app
4
+ COPY package.json ./
5
+ RUN npm install
6
+ COPY . .
7
+ RUN npm run build
8
+
9
+ # --- serve stage ---
10
+ FROM nginx:1.27-alpine
11
+ COPY --from=build /app/dist /usr/share/nginx/html
12
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
13
+ EXPOSE 80
frontend/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="de">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>GeoVision Pro</title>
7
+ <link rel="preconnect" href="https://unpkg.com" />
8
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/main.tsx"></script>
13
+ </body>
14
+ </html>
frontend/nginx.conf ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name _;
4
+ root /usr/share/nginx/html;
5
+ index index.html;
6
+
7
+ # SPA fallback
8
+ location / {
9
+ try_files $uri $uri/ /index.html;
10
+ }
11
+
12
+ # Proxy API to the backend service (docker-compose network name: backend)
13
+ location /api/ {
14
+ proxy_pass http://backend:8000/api/;
15
+ proxy_set_header Host $host;
16
+ proxy_set_header X-Real-IP $remote_addr;
17
+ client_max_body_size 64m;
18
+ proxy_read_timeout 300s;
19
+ }
20
+ }
frontend/package-lock.json ADDED
@@ -0,0 +1,2805 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "geovision-pro-frontend",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "geovision-pro-frontend",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "leaflet": "^1.9.4",
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1"
14
+ },
15
+ "devDependencies": {
16
+ "@types/leaflet": "^1.9.15",
17
+ "@types/react": "^18.3.18",
18
+ "@types/react-dom": "^18.3.5",
19
+ "@vitejs/plugin-react": "^4.3.4",
20
+ "autoprefixer": "^10.4.20",
21
+ "postcss": "^8.4.49",
22
+ "tailwindcss": "^3.4.17",
23
+ "typescript": "^5.7.2",
24
+ "vite": "^6.0.5"
25
+ }
26
+ },
27
+ "node_modules/@alloc/quick-lru": {
28
+ "version": "5.2.0",
29
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
30
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
31
+ "dev": true,
32
+ "license": "MIT",
33
+ "engines": {
34
+ "node": ">=10"
35
+ },
36
+ "funding": {
37
+ "url": "https://github.com/sponsors/sindresorhus"
38
+ }
39
+ },
40
+ "node_modules/@babel/code-frame": {
41
+ "version": "7.29.7",
42
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
43
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
44
+ "dev": true,
45
+ "license": "MIT",
46
+ "dependencies": {
47
+ "@babel/helper-validator-identifier": "^7.29.7",
48
+ "js-tokens": "^4.0.0",
49
+ "picocolors": "^1.1.1"
50
+ },
51
+ "engines": {
52
+ "node": ">=6.9.0"
53
+ }
54
+ },
55
+ "node_modules/@babel/compat-data": {
56
+ "version": "7.29.7",
57
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
58
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
59
+ "dev": true,
60
+ "license": "MIT",
61
+ "engines": {
62
+ "node": ">=6.9.0"
63
+ }
64
+ },
65
+ "node_modules/@babel/core": {
66
+ "version": "7.29.7",
67
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
68
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
69
+ "dev": true,
70
+ "license": "MIT",
71
+ "dependencies": {
72
+ "@babel/code-frame": "^7.29.7",
73
+ "@babel/generator": "^7.29.7",
74
+ "@babel/helper-compilation-targets": "^7.29.7",
75
+ "@babel/helper-module-transforms": "^7.29.7",
76
+ "@babel/helpers": "^7.29.7",
77
+ "@babel/parser": "^7.29.7",
78
+ "@babel/template": "^7.29.7",
79
+ "@babel/traverse": "^7.29.7",
80
+ "@babel/types": "^7.29.7",
81
+ "@jridgewell/remapping": "^2.3.5",
82
+ "convert-source-map": "^2.0.0",
83
+ "debug": "^4.1.0",
84
+ "gensync": "^1.0.0-beta.2",
85
+ "json5": "^2.2.3",
86
+ "semver": "^6.3.1"
87
+ },
88
+ "engines": {
89
+ "node": ">=6.9.0"
90
+ },
91
+ "funding": {
92
+ "type": "opencollective",
93
+ "url": "https://opencollective.com/babel"
94
+ }
95
+ },
96
+ "node_modules/@babel/generator": {
97
+ "version": "7.29.7",
98
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
99
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
100
+ "dev": true,
101
+ "license": "MIT",
102
+ "dependencies": {
103
+ "@babel/parser": "^7.29.7",
104
+ "@babel/types": "^7.29.7",
105
+ "@jridgewell/gen-mapping": "^0.3.12",
106
+ "@jridgewell/trace-mapping": "^0.3.28",
107
+ "jsesc": "^3.0.2"
108
+ },
109
+ "engines": {
110
+ "node": ">=6.9.0"
111
+ }
112
+ },
113
+ "node_modules/@babel/helper-compilation-targets": {
114
+ "version": "7.29.7",
115
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
116
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
117
+ "dev": true,
118
+ "license": "MIT",
119
+ "dependencies": {
120
+ "@babel/compat-data": "^7.29.7",
121
+ "@babel/helper-validator-option": "^7.29.7",
122
+ "browserslist": "^4.24.0",
123
+ "lru-cache": "^5.1.1",
124
+ "semver": "^6.3.1"
125
+ },
126
+ "engines": {
127
+ "node": ">=6.9.0"
128
+ }
129
+ },
130
+ "node_modules/@babel/helper-globals": {
131
+ "version": "7.29.7",
132
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
133
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
134
+ "dev": true,
135
+ "license": "MIT",
136
+ "engines": {
137
+ "node": ">=6.9.0"
138
+ }
139
+ },
140
+ "node_modules/@babel/helper-module-imports": {
141
+ "version": "7.29.7",
142
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
143
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
144
+ "dev": true,
145
+ "license": "MIT",
146
+ "dependencies": {
147
+ "@babel/traverse": "^7.29.7",
148
+ "@babel/types": "^7.29.7"
149
+ },
150
+ "engines": {
151
+ "node": ">=6.9.0"
152
+ }
153
+ },
154
+ "node_modules/@babel/helper-module-transforms": {
155
+ "version": "7.29.7",
156
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
157
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
158
+ "dev": true,
159
+ "license": "MIT",
160
+ "dependencies": {
161
+ "@babel/helper-module-imports": "^7.29.7",
162
+ "@babel/helper-validator-identifier": "^7.29.7",
163
+ "@babel/traverse": "^7.29.7"
164
+ },
165
+ "engines": {
166
+ "node": ">=6.9.0"
167
+ },
168
+ "peerDependencies": {
169
+ "@babel/core": "^7.0.0"
170
+ }
171
+ },
172
+ "node_modules/@babel/helper-plugin-utils": {
173
+ "version": "7.29.7",
174
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
175
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
176
+ "dev": true,
177
+ "license": "MIT",
178
+ "engines": {
179
+ "node": ">=6.9.0"
180
+ }
181
+ },
182
+ "node_modules/@babel/helper-string-parser": {
183
+ "version": "7.29.7",
184
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
185
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
186
+ "dev": true,
187
+ "license": "MIT",
188
+ "engines": {
189
+ "node": ">=6.9.0"
190
+ }
191
+ },
192
+ "node_modules/@babel/helper-validator-identifier": {
193
+ "version": "7.29.7",
194
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
195
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
196
+ "dev": true,
197
+ "license": "MIT",
198
+ "engines": {
199
+ "node": ">=6.9.0"
200
+ }
201
+ },
202
+ "node_modules/@babel/helper-validator-option": {
203
+ "version": "7.29.7",
204
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
205
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
206
+ "dev": true,
207
+ "license": "MIT",
208
+ "engines": {
209
+ "node": ">=6.9.0"
210
+ }
211
+ },
212
+ "node_modules/@babel/helpers": {
213
+ "version": "7.29.7",
214
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
215
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
216
+ "dev": true,
217
+ "license": "MIT",
218
+ "dependencies": {
219
+ "@babel/template": "^7.29.7",
220
+ "@babel/types": "^7.29.7"
221
+ },
222
+ "engines": {
223
+ "node": ">=6.9.0"
224
+ }
225
+ },
226
+ "node_modules/@babel/parser": {
227
+ "version": "7.29.7",
228
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
229
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
230
+ "dev": true,
231
+ "license": "MIT",
232
+ "dependencies": {
233
+ "@babel/types": "^7.29.7"
234
+ },
235
+ "bin": {
236
+ "parser": "bin/babel-parser.js"
237
+ },
238
+ "engines": {
239
+ "node": ">=6.0.0"
240
+ }
241
+ },
242
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
243
+ "version": "7.29.7",
244
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
245
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
246
+ "dev": true,
247
+ "license": "MIT",
248
+ "dependencies": {
249
+ "@babel/helper-plugin-utils": "^7.29.7"
250
+ },
251
+ "engines": {
252
+ "node": ">=6.9.0"
253
+ },
254
+ "peerDependencies": {
255
+ "@babel/core": "^7.0.0-0"
256
+ }
257
+ },
258
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
259
+ "version": "7.29.7",
260
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
261
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
262
+ "dev": true,
263
+ "license": "MIT",
264
+ "dependencies": {
265
+ "@babel/helper-plugin-utils": "^7.29.7"
266
+ },
267
+ "engines": {
268
+ "node": ">=6.9.0"
269
+ },
270
+ "peerDependencies": {
271
+ "@babel/core": "^7.0.0-0"
272
+ }
273
+ },
274
+ "node_modules/@babel/template": {
275
+ "version": "7.29.7",
276
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
277
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
278
+ "dev": true,
279
+ "license": "MIT",
280
+ "dependencies": {
281
+ "@babel/code-frame": "^7.29.7",
282
+ "@babel/parser": "^7.29.7",
283
+ "@babel/types": "^7.29.7"
284
+ },
285
+ "engines": {
286
+ "node": ">=6.9.0"
287
+ }
288
+ },
289
+ "node_modules/@babel/traverse": {
290
+ "version": "7.29.7",
291
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
292
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
293
+ "dev": true,
294
+ "license": "MIT",
295
+ "dependencies": {
296
+ "@babel/code-frame": "^7.29.7",
297
+ "@babel/generator": "^7.29.7",
298
+ "@babel/helper-globals": "^7.29.7",
299
+ "@babel/parser": "^7.29.7",
300
+ "@babel/template": "^7.29.7",
301
+ "@babel/types": "^7.29.7",
302
+ "debug": "^4.3.1"
303
+ },
304
+ "engines": {
305
+ "node": ">=6.9.0"
306
+ }
307
+ },
308
+ "node_modules/@babel/types": {
309
+ "version": "7.29.7",
310
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
311
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
312
+ "dev": true,
313
+ "license": "MIT",
314
+ "dependencies": {
315
+ "@babel/helper-string-parser": "^7.29.7",
316
+ "@babel/helper-validator-identifier": "^7.29.7"
317
+ },
318
+ "engines": {
319
+ "node": ">=6.9.0"
320
+ }
321
+ },
322
+ "node_modules/@esbuild/aix-ppc64": {
323
+ "version": "0.25.12",
324
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
325
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
326
+ "cpu": [
327
+ "ppc64"
328
+ ],
329
+ "dev": true,
330
+ "license": "MIT",
331
+ "optional": true,
332
+ "os": [
333
+ "aix"
334
+ ],
335
+ "engines": {
336
+ "node": ">=18"
337
+ }
338
+ },
339
+ "node_modules/@esbuild/android-arm": {
340
+ "version": "0.25.12",
341
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
342
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
343
+ "cpu": [
344
+ "arm"
345
+ ],
346
+ "dev": true,
347
+ "license": "MIT",
348
+ "optional": true,
349
+ "os": [
350
+ "android"
351
+ ],
352
+ "engines": {
353
+ "node": ">=18"
354
+ }
355
+ },
356
+ "node_modules/@esbuild/android-arm64": {
357
+ "version": "0.25.12",
358
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
359
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
360
+ "cpu": [
361
+ "arm64"
362
+ ],
363
+ "dev": true,
364
+ "license": "MIT",
365
+ "optional": true,
366
+ "os": [
367
+ "android"
368
+ ],
369
+ "engines": {
370
+ "node": ">=18"
371
+ }
372
+ },
373
+ "node_modules/@esbuild/android-x64": {
374
+ "version": "0.25.12",
375
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
376
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
377
+ "cpu": [
378
+ "x64"
379
+ ],
380
+ "dev": true,
381
+ "license": "MIT",
382
+ "optional": true,
383
+ "os": [
384
+ "android"
385
+ ],
386
+ "engines": {
387
+ "node": ">=18"
388
+ }
389
+ },
390
+ "node_modules/@esbuild/darwin-arm64": {
391
+ "version": "0.25.12",
392
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
393
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
394
+ "cpu": [
395
+ "arm64"
396
+ ],
397
+ "dev": true,
398
+ "license": "MIT",
399
+ "optional": true,
400
+ "os": [
401
+ "darwin"
402
+ ],
403
+ "engines": {
404
+ "node": ">=18"
405
+ }
406
+ },
407
+ "node_modules/@esbuild/darwin-x64": {
408
+ "version": "0.25.12",
409
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
410
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
411
+ "cpu": [
412
+ "x64"
413
+ ],
414
+ "dev": true,
415
+ "license": "MIT",
416
+ "optional": true,
417
+ "os": [
418
+ "darwin"
419
+ ],
420
+ "engines": {
421
+ "node": ">=18"
422
+ }
423
+ },
424
+ "node_modules/@esbuild/freebsd-arm64": {
425
+ "version": "0.25.12",
426
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
427
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
428
+ "cpu": [
429
+ "arm64"
430
+ ],
431
+ "dev": true,
432
+ "license": "MIT",
433
+ "optional": true,
434
+ "os": [
435
+ "freebsd"
436
+ ],
437
+ "engines": {
438
+ "node": ">=18"
439
+ }
440
+ },
441
+ "node_modules/@esbuild/freebsd-x64": {
442
+ "version": "0.25.12",
443
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
444
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
445
+ "cpu": [
446
+ "x64"
447
+ ],
448
+ "dev": true,
449
+ "license": "MIT",
450
+ "optional": true,
451
+ "os": [
452
+ "freebsd"
453
+ ],
454
+ "engines": {
455
+ "node": ">=18"
456
+ }
457
+ },
458
+ "node_modules/@esbuild/linux-arm": {
459
+ "version": "0.25.12",
460
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
461
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
462
+ "cpu": [
463
+ "arm"
464
+ ],
465
+ "dev": true,
466
+ "license": "MIT",
467
+ "optional": true,
468
+ "os": [
469
+ "linux"
470
+ ],
471
+ "engines": {
472
+ "node": ">=18"
473
+ }
474
+ },
475
+ "node_modules/@esbuild/linux-arm64": {
476
+ "version": "0.25.12",
477
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
478
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
479
+ "cpu": [
480
+ "arm64"
481
+ ],
482
+ "dev": true,
483
+ "license": "MIT",
484
+ "optional": true,
485
+ "os": [
486
+ "linux"
487
+ ],
488
+ "engines": {
489
+ "node": ">=18"
490
+ }
491
+ },
492
+ "node_modules/@esbuild/linux-ia32": {
493
+ "version": "0.25.12",
494
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
495
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
496
+ "cpu": [
497
+ "ia32"
498
+ ],
499
+ "dev": true,
500
+ "license": "MIT",
501
+ "optional": true,
502
+ "os": [
503
+ "linux"
504
+ ],
505
+ "engines": {
506
+ "node": ">=18"
507
+ }
508
+ },
509
+ "node_modules/@esbuild/linux-loong64": {
510
+ "version": "0.25.12",
511
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
512
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
513
+ "cpu": [
514
+ "loong64"
515
+ ],
516
+ "dev": true,
517
+ "license": "MIT",
518
+ "optional": true,
519
+ "os": [
520
+ "linux"
521
+ ],
522
+ "engines": {
523
+ "node": ">=18"
524
+ }
525
+ },
526
+ "node_modules/@esbuild/linux-mips64el": {
527
+ "version": "0.25.12",
528
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
529
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
530
+ "cpu": [
531
+ "mips64el"
532
+ ],
533
+ "dev": true,
534
+ "license": "MIT",
535
+ "optional": true,
536
+ "os": [
537
+ "linux"
538
+ ],
539
+ "engines": {
540
+ "node": ">=18"
541
+ }
542
+ },
543
+ "node_modules/@esbuild/linux-ppc64": {
544
+ "version": "0.25.12",
545
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
546
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
547
+ "cpu": [
548
+ "ppc64"
549
+ ],
550
+ "dev": true,
551
+ "license": "MIT",
552
+ "optional": true,
553
+ "os": [
554
+ "linux"
555
+ ],
556
+ "engines": {
557
+ "node": ">=18"
558
+ }
559
+ },
560
+ "node_modules/@esbuild/linux-riscv64": {
561
+ "version": "0.25.12",
562
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
563
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
564
+ "cpu": [
565
+ "riscv64"
566
+ ],
567
+ "dev": true,
568
+ "license": "MIT",
569
+ "optional": true,
570
+ "os": [
571
+ "linux"
572
+ ],
573
+ "engines": {
574
+ "node": ">=18"
575
+ }
576
+ },
577
+ "node_modules/@esbuild/linux-s390x": {
578
+ "version": "0.25.12",
579
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
580
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
581
+ "cpu": [
582
+ "s390x"
583
+ ],
584
+ "dev": true,
585
+ "license": "MIT",
586
+ "optional": true,
587
+ "os": [
588
+ "linux"
589
+ ],
590
+ "engines": {
591
+ "node": ">=18"
592
+ }
593
+ },
594
+ "node_modules/@esbuild/linux-x64": {
595
+ "version": "0.25.12",
596
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
597
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
598
+ "cpu": [
599
+ "x64"
600
+ ],
601
+ "dev": true,
602
+ "license": "MIT",
603
+ "optional": true,
604
+ "os": [
605
+ "linux"
606
+ ],
607
+ "engines": {
608
+ "node": ">=18"
609
+ }
610
+ },
611
+ "node_modules/@esbuild/netbsd-arm64": {
612
+ "version": "0.25.12",
613
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
614
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
615
+ "cpu": [
616
+ "arm64"
617
+ ],
618
+ "dev": true,
619
+ "license": "MIT",
620
+ "optional": true,
621
+ "os": [
622
+ "netbsd"
623
+ ],
624
+ "engines": {
625
+ "node": ">=18"
626
+ }
627
+ },
628
+ "node_modules/@esbuild/netbsd-x64": {
629
+ "version": "0.25.12",
630
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
631
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
632
+ "cpu": [
633
+ "x64"
634
+ ],
635
+ "dev": true,
636
+ "license": "MIT",
637
+ "optional": true,
638
+ "os": [
639
+ "netbsd"
640
+ ],
641
+ "engines": {
642
+ "node": ">=18"
643
+ }
644
+ },
645
+ "node_modules/@esbuild/openbsd-arm64": {
646
+ "version": "0.25.12",
647
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
648
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
649
+ "cpu": [
650
+ "arm64"
651
+ ],
652
+ "dev": true,
653
+ "license": "MIT",
654
+ "optional": true,
655
+ "os": [
656
+ "openbsd"
657
+ ],
658
+ "engines": {
659
+ "node": ">=18"
660
+ }
661
+ },
662
+ "node_modules/@esbuild/openbsd-x64": {
663
+ "version": "0.25.12",
664
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
665
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
666
+ "cpu": [
667
+ "x64"
668
+ ],
669
+ "dev": true,
670
+ "license": "MIT",
671
+ "optional": true,
672
+ "os": [
673
+ "openbsd"
674
+ ],
675
+ "engines": {
676
+ "node": ">=18"
677
+ }
678
+ },
679
+ "node_modules/@esbuild/openharmony-arm64": {
680
+ "version": "0.25.12",
681
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
682
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
683
+ "cpu": [
684
+ "arm64"
685
+ ],
686
+ "dev": true,
687
+ "license": "MIT",
688
+ "optional": true,
689
+ "os": [
690
+ "openharmony"
691
+ ],
692
+ "engines": {
693
+ "node": ">=18"
694
+ }
695
+ },
696
+ "node_modules/@esbuild/sunos-x64": {
697
+ "version": "0.25.12",
698
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
699
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
700
+ "cpu": [
701
+ "x64"
702
+ ],
703
+ "dev": true,
704
+ "license": "MIT",
705
+ "optional": true,
706
+ "os": [
707
+ "sunos"
708
+ ],
709
+ "engines": {
710
+ "node": ">=18"
711
+ }
712
+ },
713
+ "node_modules/@esbuild/win32-arm64": {
714
+ "version": "0.25.12",
715
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
716
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
717
+ "cpu": [
718
+ "arm64"
719
+ ],
720
+ "dev": true,
721
+ "license": "MIT",
722
+ "optional": true,
723
+ "os": [
724
+ "win32"
725
+ ],
726
+ "engines": {
727
+ "node": ">=18"
728
+ }
729
+ },
730
+ "node_modules/@esbuild/win32-ia32": {
731
+ "version": "0.25.12",
732
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
733
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
734
+ "cpu": [
735
+ "ia32"
736
+ ],
737
+ "dev": true,
738
+ "license": "MIT",
739
+ "optional": true,
740
+ "os": [
741
+ "win32"
742
+ ],
743
+ "engines": {
744
+ "node": ">=18"
745
+ }
746
+ },
747
+ "node_modules/@esbuild/win32-x64": {
748
+ "version": "0.25.12",
749
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
750
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
751
+ "cpu": [
752
+ "x64"
753
+ ],
754
+ "dev": true,
755
+ "license": "MIT",
756
+ "optional": true,
757
+ "os": [
758
+ "win32"
759
+ ],
760
+ "engines": {
761
+ "node": ">=18"
762
+ }
763
+ },
764
+ "node_modules/@jridgewell/gen-mapping": {
765
+ "version": "0.3.13",
766
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
767
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
768
+ "dev": true,
769
+ "license": "MIT",
770
+ "dependencies": {
771
+ "@jridgewell/sourcemap-codec": "^1.5.0",
772
+ "@jridgewell/trace-mapping": "^0.3.24"
773
+ }
774
+ },
775
+ "node_modules/@jridgewell/remapping": {
776
+ "version": "2.3.5",
777
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
778
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
779
+ "dev": true,
780
+ "license": "MIT",
781
+ "dependencies": {
782
+ "@jridgewell/gen-mapping": "^0.3.5",
783
+ "@jridgewell/trace-mapping": "^0.3.24"
784
+ }
785
+ },
786
+ "node_modules/@jridgewell/resolve-uri": {
787
+ "version": "3.1.2",
788
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
789
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
790
+ "dev": true,
791
+ "license": "MIT",
792
+ "engines": {
793
+ "node": ">=6.0.0"
794
+ }
795
+ },
796
+ "node_modules/@jridgewell/sourcemap-codec": {
797
+ "version": "1.5.5",
798
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
799
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
800
+ "dev": true,
801
+ "license": "MIT"
802
+ },
803
+ "node_modules/@jridgewell/trace-mapping": {
804
+ "version": "0.3.31",
805
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
806
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
807
+ "dev": true,
808
+ "license": "MIT",
809
+ "dependencies": {
810
+ "@jridgewell/resolve-uri": "^3.1.0",
811
+ "@jridgewell/sourcemap-codec": "^1.4.14"
812
+ }
813
+ },
814
+ "node_modules/@nodelib/fs.scandir": {
815
+ "version": "2.1.5",
816
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
817
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
818
+ "dev": true,
819
+ "license": "MIT",
820
+ "dependencies": {
821
+ "@nodelib/fs.stat": "2.0.5",
822
+ "run-parallel": "^1.1.9"
823
+ },
824
+ "engines": {
825
+ "node": ">= 8"
826
+ }
827
+ },
828
+ "node_modules/@nodelib/fs.stat": {
829
+ "version": "2.0.5",
830
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
831
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
832
+ "dev": true,
833
+ "license": "MIT",
834
+ "engines": {
835
+ "node": ">= 8"
836
+ }
837
+ },
838
+ "node_modules/@nodelib/fs.walk": {
839
+ "version": "1.2.8",
840
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
841
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
842
+ "dev": true,
843
+ "license": "MIT",
844
+ "dependencies": {
845
+ "@nodelib/fs.scandir": "2.1.5",
846
+ "fastq": "^1.6.0"
847
+ },
848
+ "engines": {
849
+ "node": ">= 8"
850
+ }
851
+ },
852
+ "node_modules/@rolldown/pluginutils": {
853
+ "version": "1.0.0-beta.27",
854
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
855
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
856
+ "dev": true,
857
+ "license": "MIT"
858
+ },
859
+ "node_modules/@rollup/rollup-android-arm-eabi": {
860
+ "version": "4.61.0",
861
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz",
862
+ "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==",
863
+ "cpu": [
864
+ "arm"
865
+ ],
866
+ "dev": true,
867
+ "license": "MIT",
868
+ "optional": true,
869
+ "os": [
870
+ "android"
871
+ ]
872
+ },
873
+ "node_modules/@rollup/rollup-android-arm64": {
874
+ "version": "4.61.0",
875
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz",
876
+ "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==",
877
+ "cpu": [
878
+ "arm64"
879
+ ],
880
+ "dev": true,
881
+ "license": "MIT",
882
+ "optional": true,
883
+ "os": [
884
+ "android"
885
+ ]
886
+ },
887
+ "node_modules/@rollup/rollup-darwin-arm64": {
888
+ "version": "4.61.0",
889
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz",
890
+ "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==",
891
+ "cpu": [
892
+ "arm64"
893
+ ],
894
+ "dev": true,
895
+ "license": "MIT",
896
+ "optional": true,
897
+ "os": [
898
+ "darwin"
899
+ ]
900
+ },
901
+ "node_modules/@rollup/rollup-darwin-x64": {
902
+ "version": "4.61.0",
903
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz",
904
+ "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==",
905
+ "cpu": [
906
+ "x64"
907
+ ],
908
+ "dev": true,
909
+ "license": "MIT",
910
+ "optional": true,
911
+ "os": [
912
+ "darwin"
913
+ ]
914
+ },
915
+ "node_modules/@rollup/rollup-freebsd-arm64": {
916
+ "version": "4.61.0",
917
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz",
918
+ "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==",
919
+ "cpu": [
920
+ "arm64"
921
+ ],
922
+ "dev": true,
923
+ "license": "MIT",
924
+ "optional": true,
925
+ "os": [
926
+ "freebsd"
927
+ ]
928
+ },
929
+ "node_modules/@rollup/rollup-freebsd-x64": {
930
+ "version": "4.61.0",
931
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz",
932
+ "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==",
933
+ "cpu": [
934
+ "x64"
935
+ ],
936
+ "dev": true,
937
+ "license": "MIT",
938
+ "optional": true,
939
+ "os": [
940
+ "freebsd"
941
+ ]
942
+ },
943
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
944
+ "version": "4.61.0",
945
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz",
946
+ "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==",
947
+ "cpu": [
948
+ "arm"
949
+ ],
950
+ "dev": true,
951
+ "license": "MIT",
952
+ "optional": true,
953
+ "os": [
954
+ "linux"
955
+ ]
956
+ },
957
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
958
+ "version": "4.61.0",
959
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz",
960
+ "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==",
961
+ "cpu": [
962
+ "arm"
963
+ ],
964
+ "dev": true,
965
+ "license": "MIT",
966
+ "optional": true,
967
+ "os": [
968
+ "linux"
969
+ ]
970
+ },
971
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
972
+ "version": "4.61.0",
973
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz",
974
+ "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==",
975
+ "cpu": [
976
+ "arm64"
977
+ ],
978
+ "dev": true,
979
+ "license": "MIT",
980
+ "optional": true,
981
+ "os": [
982
+ "linux"
983
+ ]
984
+ },
985
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
986
+ "version": "4.61.0",
987
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz",
988
+ "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==",
989
+ "cpu": [
990
+ "arm64"
991
+ ],
992
+ "dev": true,
993
+ "license": "MIT",
994
+ "optional": true,
995
+ "os": [
996
+ "linux"
997
+ ]
998
+ },
999
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
1000
+ "version": "4.61.0",
1001
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz",
1002
+ "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==",
1003
+ "cpu": [
1004
+ "loong64"
1005
+ ],
1006
+ "dev": true,
1007
+ "license": "MIT",
1008
+ "optional": true,
1009
+ "os": [
1010
+ "linux"
1011
+ ]
1012
+ },
1013
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
1014
+ "version": "4.61.0",
1015
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz",
1016
+ "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==",
1017
+ "cpu": [
1018
+ "loong64"
1019
+ ],
1020
+ "dev": true,
1021
+ "license": "MIT",
1022
+ "optional": true,
1023
+ "os": [
1024
+ "linux"
1025
+ ]
1026
+ },
1027
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
1028
+ "version": "4.61.0",
1029
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz",
1030
+ "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==",
1031
+ "cpu": [
1032
+ "ppc64"
1033
+ ],
1034
+ "dev": true,
1035
+ "license": "MIT",
1036
+ "optional": true,
1037
+ "os": [
1038
+ "linux"
1039
+ ]
1040
+ },
1041
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
1042
+ "version": "4.61.0",
1043
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz",
1044
+ "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==",
1045
+ "cpu": [
1046
+ "ppc64"
1047
+ ],
1048
+ "dev": true,
1049
+ "license": "MIT",
1050
+ "optional": true,
1051
+ "os": [
1052
+ "linux"
1053
+ ]
1054
+ },
1055
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1056
+ "version": "4.61.0",
1057
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz",
1058
+ "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==",
1059
+ "cpu": [
1060
+ "riscv64"
1061
+ ],
1062
+ "dev": true,
1063
+ "license": "MIT",
1064
+ "optional": true,
1065
+ "os": [
1066
+ "linux"
1067
+ ]
1068
+ },
1069
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1070
+ "version": "4.61.0",
1071
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz",
1072
+ "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==",
1073
+ "cpu": [
1074
+ "riscv64"
1075
+ ],
1076
+ "dev": true,
1077
+ "license": "MIT",
1078
+ "optional": true,
1079
+ "os": [
1080
+ "linux"
1081
+ ]
1082
+ },
1083
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1084
+ "version": "4.61.0",
1085
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz",
1086
+ "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==",
1087
+ "cpu": [
1088
+ "s390x"
1089
+ ],
1090
+ "dev": true,
1091
+ "license": "MIT",
1092
+ "optional": true,
1093
+ "os": [
1094
+ "linux"
1095
+ ]
1096
+ },
1097
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1098
+ "version": "4.61.0",
1099
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz",
1100
+ "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==",
1101
+ "cpu": [
1102
+ "x64"
1103
+ ],
1104
+ "dev": true,
1105
+ "license": "MIT",
1106
+ "optional": true,
1107
+ "os": [
1108
+ "linux"
1109
+ ]
1110
+ },
1111
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1112
+ "version": "4.61.0",
1113
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz",
1114
+ "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==",
1115
+ "cpu": [
1116
+ "x64"
1117
+ ],
1118
+ "dev": true,
1119
+ "license": "MIT",
1120
+ "optional": true,
1121
+ "os": [
1122
+ "linux"
1123
+ ]
1124
+ },
1125
+ "node_modules/@rollup/rollup-openbsd-x64": {
1126
+ "version": "4.61.0",
1127
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz",
1128
+ "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==",
1129
+ "cpu": [
1130
+ "x64"
1131
+ ],
1132
+ "dev": true,
1133
+ "license": "MIT",
1134
+ "optional": true,
1135
+ "os": [
1136
+ "openbsd"
1137
+ ]
1138
+ },
1139
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1140
+ "version": "4.61.0",
1141
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz",
1142
+ "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==",
1143
+ "cpu": [
1144
+ "arm64"
1145
+ ],
1146
+ "dev": true,
1147
+ "license": "MIT",
1148
+ "optional": true,
1149
+ "os": [
1150
+ "openharmony"
1151
+ ]
1152
+ },
1153
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1154
+ "version": "4.61.0",
1155
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz",
1156
+ "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==",
1157
+ "cpu": [
1158
+ "arm64"
1159
+ ],
1160
+ "dev": true,
1161
+ "license": "MIT",
1162
+ "optional": true,
1163
+ "os": [
1164
+ "win32"
1165
+ ]
1166
+ },
1167
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1168
+ "version": "4.61.0",
1169
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz",
1170
+ "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==",
1171
+ "cpu": [
1172
+ "ia32"
1173
+ ],
1174
+ "dev": true,
1175
+ "license": "MIT",
1176
+ "optional": true,
1177
+ "os": [
1178
+ "win32"
1179
+ ]
1180
+ },
1181
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1182
+ "version": "4.61.0",
1183
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz",
1184
+ "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==",
1185
+ "cpu": [
1186
+ "x64"
1187
+ ],
1188
+ "dev": true,
1189
+ "license": "MIT",
1190
+ "optional": true,
1191
+ "os": [
1192
+ "win32"
1193
+ ]
1194
+ },
1195
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1196
+ "version": "4.61.0",
1197
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz",
1198
+ "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==",
1199
+ "cpu": [
1200
+ "x64"
1201
+ ],
1202
+ "dev": true,
1203
+ "license": "MIT",
1204
+ "optional": true,
1205
+ "os": [
1206
+ "win32"
1207
+ ]
1208
+ },
1209
+ "node_modules/@types/babel__core": {
1210
+ "version": "7.20.5",
1211
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1212
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1213
+ "dev": true,
1214
+ "license": "MIT",
1215
+ "dependencies": {
1216
+ "@babel/parser": "^7.20.7",
1217
+ "@babel/types": "^7.20.7",
1218
+ "@types/babel__generator": "*",
1219
+ "@types/babel__template": "*",
1220
+ "@types/babel__traverse": "*"
1221
+ }
1222
+ },
1223
+ "node_modules/@types/babel__generator": {
1224
+ "version": "7.27.0",
1225
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1226
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1227
+ "dev": true,
1228
+ "license": "MIT",
1229
+ "dependencies": {
1230
+ "@babel/types": "^7.0.0"
1231
+ }
1232
+ },
1233
+ "node_modules/@types/babel__template": {
1234
+ "version": "7.4.4",
1235
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1236
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1237
+ "dev": true,
1238
+ "license": "MIT",
1239
+ "dependencies": {
1240
+ "@babel/parser": "^7.1.0",
1241
+ "@babel/types": "^7.0.0"
1242
+ }
1243
+ },
1244
+ "node_modules/@types/babel__traverse": {
1245
+ "version": "7.28.0",
1246
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1247
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1248
+ "dev": true,
1249
+ "license": "MIT",
1250
+ "dependencies": {
1251
+ "@babel/types": "^7.28.2"
1252
+ }
1253
+ },
1254
+ "node_modules/@types/estree": {
1255
+ "version": "1.0.9",
1256
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1257
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1258
+ "dev": true,
1259
+ "license": "MIT"
1260
+ },
1261
+ "node_modules/@types/geojson": {
1262
+ "version": "7946.0.16",
1263
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1264
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1265
+ "dev": true,
1266
+ "license": "MIT"
1267
+ },
1268
+ "node_modules/@types/leaflet": {
1269
+ "version": "1.9.21",
1270
+ "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
1271
+ "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
1272
+ "dev": true,
1273
+ "license": "MIT",
1274
+ "dependencies": {
1275
+ "@types/geojson": "*"
1276
+ }
1277
+ },
1278
+ "node_modules/@types/prop-types": {
1279
+ "version": "15.7.15",
1280
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1281
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1282
+ "dev": true,
1283
+ "license": "MIT"
1284
+ },
1285
+ "node_modules/@types/react": {
1286
+ "version": "18.3.30",
1287
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.30.tgz",
1288
+ "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==",
1289
+ "dev": true,
1290
+ "license": "MIT",
1291
+ "dependencies": {
1292
+ "@types/prop-types": "*",
1293
+ "csstype": "^3.2.2"
1294
+ }
1295
+ },
1296
+ "node_modules/@types/react-dom": {
1297
+ "version": "18.3.7",
1298
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1299
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1300
+ "dev": true,
1301
+ "license": "MIT",
1302
+ "peerDependencies": {
1303
+ "@types/react": "^18.0.0"
1304
+ }
1305
+ },
1306
+ "node_modules/@vitejs/plugin-react": {
1307
+ "version": "4.7.0",
1308
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1309
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1310
+ "dev": true,
1311
+ "license": "MIT",
1312
+ "dependencies": {
1313
+ "@babel/core": "^7.28.0",
1314
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1315
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1316
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1317
+ "@types/babel__core": "^7.20.5",
1318
+ "react-refresh": "^0.17.0"
1319
+ },
1320
+ "engines": {
1321
+ "node": "^14.18.0 || >=16.0.0"
1322
+ },
1323
+ "peerDependencies": {
1324
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1325
+ }
1326
+ },
1327
+ "node_modules/any-promise": {
1328
+ "version": "1.3.0",
1329
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
1330
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
1331
+ "dev": true,
1332
+ "license": "MIT"
1333
+ },
1334
+ "node_modules/anymatch": {
1335
+ "version": "3.1.3",
1336
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
1337
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
1338
+ "dev": true,
1339
+ "license": "ISC",
1340
+ "dependencies": {
1341
+ "normalize-path": "^3.0.0",
1342
+ "picomatch": "^2.0.4"
1343
+ },
1344
+ "engines": {
1345
+ "node": ">= 8"
1346
+ }
1347
+ },
1348
+ "node_modules/arg": {
1349
+ "version": "5.0.2",
1350
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
1351
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
1352
+ "dev": true,
1353
+ "license": "MIT"
1354
+ },
1355
+ "node_modules/autoprefixer": {
1356
+ "version": "10.5.0",
1357
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
1358
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
1359
+ "dev": true,
1360
+ "funding": [
1361
+ {
1362
+ "type": "opencollective",
1363
+ "url": "https://opencollective.com/postcss/"
1364
+ },
1365
+ {
1366
+ "type": "tidelift",
1367
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
1368
+ },
1369
+ {
1370
+ "type": "github",
1371
+ "url": "https://github.com/sponsors/ai"
1372
+ }
1373
+ ],
1374
+ "license": "MIT",
1375
+ "dependencies": {
1376
+ "browserslist": "^4.28.2",
1377
+ "caniuse-lite": "^1.0.30001787",
1378
+ "fraction.js": "^5.3.4",
1379
+ "picocolors": "^1.1.1",
1380
+ "postcss-value-parser": "^4.2.0"
1381
+ },
1382
+ "bin": {
1383
+ "autoprefixer": "bin/autoprefixer"
1384
+ },
1385
+ "engines": {
1386
+ "node": "^10 || ^12 || >=14"
1387
+ },
1388
+ "peerDependencies": {
1389
+ "postcss": "^8.1.0"
1390
+ }
1391
+ },
1392
+ "node_modules/baseline-browser-mapping": {
1393
+ "version": "2.10.33",
1394
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
1395
+ "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
1396
+ "dev": true,
1397
+ "license": "Apache-2.0",
1398
+ "bin": {
1399
+ "baseline-browser-mapping": "dist/cli.cjs"
1400
+ },
1401
+ "engines": {
1402
+ "node": ">=6.0.0"
1403
+ }
1404
+ },
1405
+ "node_modules/binary-extensions": {
1406
+ "version": "2.3.0",
1407
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
1408
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
1409
+ "dev": true,
1410
+ "license": "MIT",
1411
+ "engines": {
1412
+ "node": ">=8"
1413
+ },
1414
+ "funding": {
1415
+ "url": "https://github.com/sponsors/sindresorhus"
1416
+ }
1417
+ },
1418
+ "node_modules/braces": {
1419
+ "version": "3.0.3",
1420
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
1421
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
1422
+ "dev": true,
1423
+ "license": "MIT",
1424
+ "dependencies": {
1425
+ "fill-range": "^7.1.1"
1426
+ },
1427
+ "engines": {
1428
+ "node": ">=8"
1429
+ }
1430
+ },
1431
+ "node_modules/browserslist": {
1432
+ "version": "4.28.2",
1433
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
1434
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
1435
+ "dev": true,
1436
+ "funding": [
1437
+ {
1438
+ "type": "opencollective",
1439
+ "url": "https://opencollective.com/browserslist"
1440
+ },
1441
+ {
1442
+ "type": "tidelift",
1443
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1444
+ },
1445
+ {
1446
+ "type": "github",
1447
+ "url": "https://github.com/sponsors/ai"
1448
+ }
1449
+ ],
1450
+ "license": "MIT",
1451
+ "dependencies": {
1452
+ "baseline-browser-mapping": "^2.10.12",
1453
+ "caniuse-lite": "^1.0.30001782",
1454
+ "electron-to-chromium": "^1.5.328",
1455
+ "node-releases": "^2.0.36",
1456
+ "update-browserslist-db": "^1.2.3"
1457
+ },
1458
+ "bin": {
1459
+ "browserslist": "cli.js"
1460
+ },
1461
+ "engines": {
1462
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1463
+ }
1464
+ },
1465
+ "node_modules/camelcase-css": {
1466
+ "version": "2.0.1",
1467
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
1468
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
1469
+ "dev": true,
1470
+ "license": "MIT",
1471
+ "engines": {
1472
+ "node": ">= 6"
1473
+ }
1474
+ },
1475
+ "node_modules/caniuse-lite": {
1476
+ "version": "1.0.30001793",
1477
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
1478
+ "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
1479
+ "dev": true,
1480
+ "funding": [
1481
+ {
1482
+ "type": "opencollective",
1483
+ "url": "https://opencollective.com/browserslist"
1484
+ },
1485
+ {
1486
+ "type": "tidelift",
1487
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1488
+ },
1489
+ {
1490
+ "type": "github",
1491
+ "url": "https://github.com/sponsors/ai"
1492
+ }
1493
+ ],
1494
+ "license": "CC-BY-4.0"
1495
+ },
1496
+ "node_modules/chokidar": {
1497
+ "version": "3.6.0",
1498
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
1499
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
1500
+ "dev": true,
1501
+ "license": "MIT",
1502
+ "dependencies": {
1503
+ "anymatch": "~3.1.2",
1504
+ "braces": "~3.0.2",
1505
+ "glob-parent": "~5.1.2",
1506
+ "is-binary-path": "~2.1.0",
1507
+ "is-glob": "~4.0.1",
1508
+ "normalize-path": "~3.0.0",
1509
+ "readdirp": "~3.6.0"
1510
+ },
1511
+ "engines": {
1512
+ "node": ">= 8.10.0"
1513
+ },
1514
+ "funding": {
1515
+ "url": "https://paulmillr.com/funding/"
1516
+ },
1517
+ "optionalDependencies": {
1518
+ "fsevents": "~2.3.2"
1519
+ }
1520
+ },
1521
+ "node_modules/chokidar/node_modules/glob-parent": {
1522
+ "version": "5.1.2",
1523
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1524
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1525
+ "dev": true,
1526
+ "license": "ISC",
1527
+ "dependencies": {
1528
+ "is-glob": "^4.0.1"
1529
+ },
1530
+ "engines": {
1531
+ "node": ">= 6"
1532
+ }
1533
+ },
1534
+ "node_modules/commander": {
1535
+ "version": "4.1.1",
1536
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
1537
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
1538
+ "dev": true,
1539
+ "license": "MIT",
1540
+ "engines": {
1541
+ "node": ">= 6"
1542
+ }
1543
+ },
1544
+ "node_modules/convert-source-map": {
1545
+ "version": "2.0.0",
1546
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1547
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1548
+ "dev": true,
1549
+ "license": "MIT"
1550
+ },
1551
+ "node_modules/cssesc": {
1552
+ "version": "3.0.0",
1553
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
1554
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
1555
+ "dev": true,
1556
+ "license": "MIT",
1557
+ "bin": {
1558
+ "cssesc": "bin/cssesc"
1559
+ },
1560
+ "engines": {
1561
+ "node": ">=4"
1562
+ }
1563
+ },
1564
+ "node_modules/csstype": {
1565
+ "version": "3.2.3",
1566
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1567
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1568
+ "dev": true,
1569
+ "license": "MIT"
1570
+ },
1571
+ "node_modules/debug": {
1572
+ "version": "4.4.3",
1573
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1574
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1575
+ "dev": true,
1576
+ "license": "MIT",
1577
+ "dependencies": {
1578
+ "ms": "^2.1.3"
1579
+ },
1580
+ "engines": {
1581
+ "node": ">=6.0"
1582
+ },
1583
+ "peerDependenciesMeta": {
1584
+ "supports-color": {
1585
+ "optional": true
1586
+ }
1587
+ }
1588
+ },
1589
+ "node_modules/didyoumean": {
1590
+ "version": "1.2.2",
1591
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
1592
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
1593
+ "dev": true,
1594
+ "license": "Apache-2.0"
1595
+ },
1596
+ "node_modules/dlv": {
1597
+ "version": "1.1.3",
1598
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
1599
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
1600
+ "dev": true,
1601
+ "license": "MIT"
1602
+ },
1603
+ "node_modules/electron-to-chromium": {
1604
+ "version": "1.5.366",
1605
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.366.tgz",
1606
+ "integrity": "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==",
1607
+ "dev": true,
1608
+ "license": "ISC"
1609
+ },
1610
+ "node_modules/es-errors": {
1611
+ "version": "1.3.0",
1612
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
1613
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
1614
+ "dev": true,
1615
+ "license": "MIT",
1616
+ "engines": {
1617
+ "node": ">= 0.4"
1618
+ }
1619
+ },
1620
+ "node_modules/esbuild": {
1621
+ "version": "0.25.12",
1622
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
1623
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1624
+ "dev": true,
1625
+ "hasInstallScript": true,
1626
+ "license": "MIT",
1627
+ "bin": {
1628
+ "esbuild": "bin/esbuild"
1629
+ },
1630
+ "engines": {
1631
+ "node": ">=18"
1632
+ },
1633
+ "optionalDependencies": {
1634
+ "@esbuild/aix-ppc64": "0.25.12",
1635
+ "@esbuild/android-arm": "0.25.12",
1636
+ "@esbuild/android-arm64": "0.25.12",
1637
+ "@esbuild/android-x64": "0.25.12",
1638
+ "@esbuild/darwin-arm64": "0.25.12",
1639
+ "@esbuild/darwin-x64": "0.25.12",
1640
+ "@esbuild/freebsd-arm64": "0.25.12",
1641
+ "@esbuild/freebsd-x64": "0.25.12",
1642
+ "@esbuild/linux-arm": "0.25.12",
1643
+ "@esbuild/linux-arm64": "0.25.12",
1644
+ "@esbuild/linux-ia32": "0.25.12",
1645
+ "@esbuild/linux-loong64": "0.25.12",
1646
+ "@esbuild/linux-mips64el": "0.25.12",
1647
+ "@esbuild/linux-ppc64": "0.25.12",
1648
+ "@esbuild/linux-riscv64": "0.25.12",
1649
+ "@esbuild/linux-s390x": "0.25.12",
1650
+ "@esbuild/linux-x64": "0.25.12",
1651
+ "@esbuild/netbsd-arm64": "0.25.12",
1652
+ "@esbuild/netbsd-x64": "0.25.12",
1653
+ "@esbuild/openbsd-arm64": "0.25.12",
1654
+ "@esbuild/openbsd-x64": "0.25.12",
1655
+ "@esbuild/openharmony-arm64": "0.25.12",
1656
+ "@esbuild/sunos-x64": "0.25.12",
1657
+ "@esbuild/win32-arm64": "0.25.12",
1658
+ "@esbuild/win32-ia32": "0.25.12",
1659
+ "@esbuild/win32-x64": "0.25.12"
1660
+ }
1661
+ },
1662
+ "node_modules/escalade": {
1663
+ "version": "3.2.0",
1664
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1665
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1666
+ "dev": true,
1667
+ "license": "MIT",
1668
+ "engines": {
1669
+ "node": ">=6"
1670
+ }
1671
+ },
1672
+ "node_modules/fast-glob": {
1673
+ "version": "3.3.3",
1674
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
1675
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
1676
+ "dev": true,
1677
+ "license": "MIT",
1678
+ "dependencies": {
1679
+ "@nodelib/fs.stat": "^2.0.2",
1680
+ "@nodelib/fs.walk": "^1.2.3",
1681
+ "glob-parent": "^5.1.2",
1682
+ "merge2": "^1.3.0",
1683
+ "micromatch": "^4.0.8"
1684
+ },
1685
+ "engines": {
1686
+ "node": ">=8.6.0"
1687
+ }
1688
+ },
1689
+ "node_modules/fast-glob/node_modules/glob-parent": {
1690
+ "version": "5.1.2",
1691
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1692
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1693
+ "dev": true,
1694
+ "license": "ISC",
1695
+ "dependencies": {
1696
+ "is-glob": "^4.0.1"
1697
+ },
1698
+ "engines": {
1699
+ "node": ">= 6"
1700
+ }
1701
+ },
1702
+ "node_modules/fastq": {
1703
+ "version": "1.20.1",
1704
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
1705
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
1706
+ "dev": true,
1707
+ "license": "ISC",
1708
+ "dependencies": {
1709
+ "reusify": "^1.0.4"
1710
+ }
1711
+ },
1712
+ "node_modules/fill-range": {
1713
+ "version": "7.1.1",
1714
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
1715
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
1716
+ "dev": true,
1717
+ "license": "MIT",
1718
+ "dependencies": {
1719
+ "to-regex-range": "^5.0.1"
1720
+ },
1721
+ "engines": {
1722
+ "node": ">=8"
1723
+ }
1724
+ },
1725
+ "node_modules/fraction.js": {
1726
+ "version": "5.3.4",
1727
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
1728
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
1729
+ "dev": true,
1730
+ "license": "MIT",
1731
+ "engines": {
1732
+ "node": "*"
1733
+ },
1734
+ "funding": {
1735
+ "type": "github",
1736
+ "url": "https://github.com/sponsors/rawify"
1737
+ }
1738
+ },
1739
+ "node_modules/fsevents": {
1740
+ "version": "2.3.3",
1741
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1742
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1743
+ "dev": true,
1744
+ "hasInstallScript": true,
1745
+ "license": "MIT",
1746
+ "optional": true,
1747
+ "os": [
1748
+ "darwin"
1749
+ ],
1750
+ "engines": {
1751
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1752
+ }
1753
+ },
1754
+ "node_modules/function-bind": {
1755
+ "version": "1.1.2",
1756
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1757
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1758
+ "dev": true,
1759
+ "license": "MIT",
1760
+ "funding": {
1761
+ "url": "https://github.com/sponsors/ljharb"
1762
+ }
1763
+ },
1764
+ "node_modules/gensync": {
1765
+ "version": "1.0.0-beta.2",
1766
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1767
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1768
+ "dev": true,
1769
+ "license": "MIT",
1770
+ "engines": {
1771
+ "node": ">=6.9.0"
1772
+ }
1773
+ },
1774
+ "node_modules/glob-parent": {
1775
+ "version": "6.0.2",
1776
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1777
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1778
+ "dev": true,
1779
+ "license": "ISC",
1780
+ "dependencies": {
1781
+ "is-glob": "^4.0.3"
1782
+ },
1783
+ "engines": {
1784
+ "node": ">=10.13.0"
1785
+ }
1786
+ },
1787
+ "node_modules/hasown": {
1788
+ "version": "2.0.4",
1789
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
1790
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
1791
+ "dev": true,
1792
+ "license": "MIT",
1793
+ "dependencies": {
1794
+ "function-bind": "^1.1.2"
1795
+ },
1796
+ "engines": {
1797
+ "node": ">= 0.4"
1798
+ }
1799
+ },
1800
+ "node_modules/is-binary-path": {
1801
+ "version": "2.1.0",
1802
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
1803
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
1804
+ "dev": true,
1805
+ "license": "MIT",
1806
+ "dependencies": {
1807
+ "binary-extensions": "^2.0.0"
1808
+ },
1809
+ "engines": {
1810
+ "node": ">=8"
1811
+ }
1812
+ },
1813
+ "node_modules/is-core-module": {
1814
+ "version": "2.16.2",
1815
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
1816
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
1817
+ "dev": true,
1818
+ "license": "MIT",
1819
+ "dependencies": {
1820
+ "hasown": "^2.0.3"
1821
+ },
1822
+ "engines": {
1823
+ "node": ">= 0.4"
1824
+ },
1825
+ "funding": {
1826
+ "url": "https://github.com/sponsors/ljharb"
1827
+ }
1828
+ },
1829
+ "node_modules/is-extglob": {
1830
+ "version": "2.1.1",
1831
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1832
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1833
+ "dev": true,
1834
+ "license": "MIT",
1835
+ "engines": {
1836
+ "node": ">=0.10.0"
1837
+ }
1838
+ },
1839
+ "node_modules/is-glob": {
1840
+ "version": "4.0.3",
1841
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1842
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1843
+ "dev": true,
1844
+ "license": "MIT",
1845
+ "dependencies": {
1846
+ "is-extglob": "^2.1.1"
1847
+ },
1848
+ "engines": {
1849
+ "node": ">=0.10.0"
1850
+ }
1851
+ },
1852
+ "node_modules/is-number": {
1853
+ "version": "7.0.0",
1854
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
1855
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
1856
+ "dev": true,
1857
+ "license": "MIT",
1858
+ "engines": {
1859
+ "node": ">=0.12.0"
1860
+ }
1861
+ },
1862
+ "node_modules/jiti": {
1863
+ "version": "1.21.7",
1864
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
1865
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
1866
+ "dev": true,
1867
+ "license": "MIT",
1868
+ "bin": {
1869
+ "jiti": "bin/jiti.js"
1870
+ }
1871
+ },
1872
+ "node_modules/js-tokens": {
1873
+ "version": "4.0.0",
1874
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1875
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1876
+ "license": "MIT"
1877
+ },
1878
+ "node_modules/jsesc": {
1879
+ "version": "3.1.0",
1880
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1881
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1882
+ "dev": true,
1883
+ "license": "MIT",
1884
+ "bin": {
1885
+ "jsesc": "bin/jsesc"
1886
+ },
1887
+ "engines": {
1888
+ "node": ">=6"
1889
+ }
1890
+ },
1891
+ "node_modules/json5": {
1892
+ "version": "2.2.3",
1893
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1894
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1895
+ "dev": true,
1896
+ "license": "MIT",
1897
+ "bin": {
1898
+ "json5": "lib/cli.js"
1899
+ },
1900
+ "engines": {
1901
+ "node": ">=6"
1902
+ }
1903
+ },
1904
+ "node_modules/leaflet": {
1905
+ "version": "1.9.4",
1906
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
1907
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
1908
+ "license": "BSD-2-Clause"
1909
+ },
1910
+ "node_modules/lilconfig": {
1911
+ "version": "3.1.3",
1912
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
1913
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
1914
+ "dev": true,
1915
+ "license": "MIT",
1916
+ "engines": {
1917
+ "node": ">=14"
1918
+ },
1919
+ "funding": {
1920
+ "url": "https://github.com/sponsors/antonk52"
1921
+ }
1922
+ },
1923
+ "node_modules/lines-and-columns": {
1924
+ "version": "1.2.4",
1925
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
1926
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
1927
+ "dev": true,
1928
+ "license": "MIT"
1929
+ },
1930
+ "node_modules/loose-envify": {
1931
+ "version": "1.4.0",
1932
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1933
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1934
+ "license": "MIT",
1935
+ "dependencies": {
1936
+ "js-tokens": "^3.0.0 || ^4.0.0"
1937
+ },
1938
+ "bin": {
1939
+ "loose-envify": "cli.js"
1940
+ }
1941
+ },
1942
+ "node_modules/lru-cache": {
1943
+ "version": "5.1.1",
1944
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1945
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1946
+ "dev": true,
1947
+ "license": "ISC",
1948
+ "dependencies": {
1949
+ "yallist": "^3.0.2"
1950
+ }
1951
+ },
1952
+ "node_modules/merge2": {
1953
+ "version": "1.4.1",
1954
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
1955
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
1956
+ "dev": true,
1957
+ "license": "MIT",
1958
+ "engines": {
1959
+ "node": ">= 8"
1960
+ }
1961
+ },
1962
+ "node_modules/micromatch": {
1963
+ "version": "4.0.8",
1964
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
1965
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
1966
+ "dev": true,
1967
+ "license": "MIT",
1968
+ "dependencies": {
1969
+ "braces": "^3.0.3",
1970
+ "picomatch": "^2.3.1"
1971
+ },
1972
+ "engines": {
1973
+ "node": ">=8.6"
1974
+ }
1975
+ },
1976
+ "node_modules/ms": {
1977
+ "version": "2.1.3",
1978
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1979
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1980
+ "dev": true,
1981
+ "license": "MIT"
1982
+ },
1983
+ "node_modules/mz": {
1984
+ "version": "2.7.0",
1985
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
1986
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
1987
+ "dev": true,
1988
+ "license": "MIT",
1989
+ "dependencies": {
1990
+ "any-promise": "^1.0.0",
1991
+ "object-assign": "^4.0.1",
1992
+ "thenify-all": "^1.0.0"
1993
+ }
1994
+ },
1995
+ "node_modules/nanoid": {
1996
+ "version": "3.3.12",
1997
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
1998
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
1999
+ "dev": true,
2000
+ "funding": [
2001
+ {
2002
+ "type": "github",
2003
+ "url": "https://github.com/sponsors/ai"
2004
+ }
2005
+ ],
2006
+ "license": "MIT",
2007
+ "bin": {
2008
+ "nanoid": "bin/nanoid.cjs"
2009
+ },
2010
+ "engines": {
2011
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
2012
+ }
2013
+ },
2014
+ "node_modules/node-releases": {
2015
+ "version": "2.0.47",
2016
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
2017
+ "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==",
2018
+ "dev": true,
2019
+ "license": "MIT",
2020
+ "engines": {
2021
+ "node": ">=18"
2022
+ }
2023
+ },
2024
+ "node_modules/normalize-path": {
2025
+ "version": "3.0.0",
2026
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
2027
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
2028
+ "dev": true,
2029
+ "license": "MIT",
2030
+ "engines": {
2031
+ "node": ">=0.10.0"
2032
+ }
2033
+ },
2034
+ "node_modules/object-assign": {
2035
+ "version": "4.1.1",
2036
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
2037
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
2038
+ "dev": true,
2039
+ "license": "MIT",
2040
+ "engines": {
2041
+ "node": ">=0.10.0"
2042
+ }
2043
+ },
2044
+ "node_modules/object-hash": {
2045
+ "version": "3.0.0",
2046
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
2047
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
2048
+ "dev": true,
2049
+ "license": "MIT",
2050
+ "engines": {
2051
+ "node": ">= 6"
2052
+ }
2053
+ },
2054
+ "node_modules/path-parse": {
2055
+ "version": "1.0.7",
2056
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
2057
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
2058
+ "dev": true,
2059
+ "license": "MIT"
2060
+ },
2061
+ "node_modules/picocolors": {
2062
+ "version": "1.1.1",
2063
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2064
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2065
+ "dev": true,
2066
+ "license": "ISC"
2067
+ },
2068
+ "node_modules/picomatch": {
2069
+ "version": "2.3.2",
2070
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
2071
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
2072
+ "dev": true,
2073
+ "license": "MIT",
2074
+ "engines": {
2075
+ "node": ">=8.6"
2076
+ },
2077
+ "funding": {
2078
+ "url": "https://github.com/sponsors/jonschlinkert"
2079
+ }
2080
+ },
2081
+ "node_modules/pify": {
2082
+ "version": "2.3.0",
2083
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
2084
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
2085
+ "dev": true,
2086
+ "license": "MIT",
2087
+ "engines": {
2088
+ "node": ">=0.10.0"
2089
+ }
2090
+ },
2091
+ "node_modules/pirates": {
2092
+ "version": "4.0.7",
2093
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
2094
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
2095
+ "dev": true,
2096
+ "license": "MIT",
2097
+ "engines": {
2098
+ "node": ">= 6"
2099
+ }
2100
+ },
2101
+ "node_modules/postcss": {
2102
+ "version": "8.5.15",
2103
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
2104
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
2105
+ "dev": true,
2106
+ "funding": [
2107
+ {
2108
+ "type": "opencollective",
2109
+ "url": "https://opencollective.com/postcss/"
2110
+ },
2111
+ {
2112
+ "type": "tidelift",
2113
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2114
+ },
2115
+ {
2116
+ "type": "github",
2117
+ "url": "https://github.com/sponsors/ai"
2118
+ }
2119
+ ],
2120
+ "license": "MIT",
2121
+ "dependencies": {
2122
+ "nanoid": "^3.3.12",
2123
+ "picocolors": "^1.1.1",
2124
+ "source-map-js": "^1.2.1"
2125
+ },
2126
+ "engines": {
2127
+ "node": "^10 || ^12 || >=14"
2128
+ }
2129
+ },
2130
+ "node_modules/postcss-import": {
2131
+ "version": "15.1.0",
2132
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
2133
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
2134
+ "dev": true,
2135
+ "license": "MIT",
2136
+ "dependencies": {
2137
+ "postcss-value-parser": "^4.0.0",
2138
+ "read-cache": "^1.0.0",
2139
+ "resolve": "^1.1.7"
2140
+ },
2141
+ "engines": {
2142
+ "node": ">=14.0.0"
2143
+ },
2144
+ "peerDependencies": {
2145
+ "postcss": "^8.0.0"
2146
+ }
2147
+ },
2148
+ "node_modules/postcss-js": {
2149
+ "version": "4.1.0",
2150
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
2151
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
2152
+ "dev": true,
2153
+ "funding": [
2154
+ {
2155
+ "type": "opencollective",
2156
+ "url": "https://opencollective.com/postcss/"
2157
+ },
2158
+ {
2159
+ "type": "github",
2160
+ "url": "https://github.com/sponsors/ai"
2161
+ }
2162
+ ],
2163
+ "license": "MIT",
2164
+ "dependencies": {
2165
+ "camelcase-css": "^2.0.1"
2166
+ },
2167
+ "engines": {
2168
+ "node": "^12 || ^14 || >= 16"
2169
+ },
2170
+ "peerDependencies": {
2171
+ "postcss": "^8.4.21"
2172
+ }
2173
+ },
2174
+ "node_modules/postcss-load-config": {
2175
+ "version": "6.0.1",
2176
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
2177
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
2178
+ "dev": true,
2179
+ "funding": [
2180
+ {
2181
+ "type": "opencollective",
2182
+ "url": "https://opencollective.com/postcss/"
2183
+ },
2184
+ {
2185
+ "type": "github",
2186
+ "url": "https://github.com/sponsors/ai"
2187
+ }
2188
+ ],
2189
+ "license": "MIT",
2190
+ "dependencies": {
2191
+ "lilconfig": "^3.1.1"
2192
+ },
2193
+ "engines": {
2194
+ "node": ">= 18"
2195
+ },
2196
+ "peerDependencies": {
2197
+ "jiti": ">=1.21.0",
2198
+ "postcss": ">=8.0.9",
2199
+ "tsx": "^4.8.1",
2200
+ "yaml": "^2.4.2"
2201
+ },
2202
+ "peerDependenciesMeta": {
2203
+ "jiti": {
2204
+ "optional": true
2205
+ },
2206
+ "postcss": {
2207
+ "optional": true
2208
+ },
2209
+ "tsx": {
2210
+ "optional": true
2211
+ },
2212
+ "yaml": {
2213
+ "optional": true
2214
+ }
2215
+ }
2216
+ },
2217
+ "node_modules/postcss-nested": {
2218
+ "version": "6.2.0",
2219
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
2220
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
2221
+ "dev": true,
2222
+ "funding": [
2223
+ {
2224
+ "type": "opencollective",
2225
+ "url": "https://opencollective.com/postcss/"
2226
+ },
2227
+ {
2228
+ "type": "github",
2229
+ "url": "https://github.com/sponsors/ai"
2230
+ }
2231
+ ],
2232
+ "license": "MIT",
2233
+ "dependencies": {
2234
+ "postcss-selector-parser": "^6.1.1"
2235
+ },
2236
+ "engines": {
2237
+ "node": ">=12.0"
2238
+ },
2239
+ "peerDependencies": {
2240
+ "postcss": "^8.2.14"
2241
+ }
2242
+ },
2243
+ "node_modules/postcss-selector-parser": {
2244
+ "version": "6.1.2",
2245
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
2246
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
2247
+ "dev": true,
2248
+ "license": "MIT",
2249
+ "dependencies": {
2250
+ "cssesc": "^3.0.0",
2251
+ "util-deprecate": "^1.0.2"
2252
+ },
2253
+ "engines": {
2254
+ "node": ">=4"
2255
+ }
2256
+ },
2257
+ "node_modules/postcss-value-parser": {
2258
+ "version": "4.2.0",
2259
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
2260
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
2261
+ "dev": true,
2262
+ "license": "MIT"
2263
+ },
2264
+ "node_modules/queue-microtask": {
2265
+ "version": "1.2.3",
2266
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
2267
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
2268
+ "dev": true,
2269
+ "funding": [
2270
+ {
2271
+ "type": "github",
2272
+ "url": "https://github.com/sponsors/feross"
2273
+ },
2274
+ {
2275
+ "type": "patreon",
2276
+ "url": "https://www.patreon.com/feross"
2277
+ },
2278
+ {
2279
+ "type": "consulting",
2280
+ "url": "https://feross.org/support"
2281
+ }
2282
+ ],
2283
+ "license": "MIT"
2284
+ },
2285
+ "node_modules/react": {
2286
+ "version": "18.3.1",
2287
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
2288
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
2289
+ "license": "MIT",
2290
+ "dependencies": {
2291
+ "loose-envify": "^1.1.0"
2292
+ },
2293
+ "engines": {
2294
+ "node": ">=0.10.0"
2295
+ }
2296
+ },
2297
+ "node_modules/react-dom": {
2298
+ "version": "18.3.1",
2299
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
2300
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
2301
+ "license": "MIT",
2302
+ "dependencies": {
2303
+ "loose-envify": "^1.1.0",
2304
+ "scheduler": "^0.23.2"
2305
+ },
2306
+ "peerDependencies": {
2307
+ "react": "^18.3.1"
2308
+ }
2309
+ },
2310
+ "node_modules/react-refresh": {
2311
+ "version": "0.17.0",
2312
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
2313
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
2314
+ "dev": true,
2315
+ "license": "MIT",
2316
+ "engines": {
2317
+ "node": ">=0.10.0"
2318
+ }
2319
+ },
2320
+ "node_modules/read-cache": {
2321
+ "version": "1.0.0",
2322
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
2323
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
2324
+ "dev": true,
2325
+ "license": "MIT",
2326
+ "dependencies": {
2327
+ "pify": "^2.3.0"
2328
+ }
2329
+ },
2330
+ "node_modules/readdirp": {
2331
+ "version": "3.6.0",
2332
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
2333
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
2334
+ "dev": true,
2335
+ "license": "MIT",
2336
+ "dependencies": {
2337
+ "picomatch": "^2.2.1"
2338
+ },
2339
+ "engines": {
2340
+ "node": ">=8.10.0"
2341
+ }
2342
+ },
2343
+ "node_modules/resolve": {
2344
+ "version": "1.22.12",
2345
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
2346
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
2347
+ "dev": true,
2348
+ "license": "MIT",
2349
+ "dependencies": {
2350
+ "es-errors": "^1.3.0",
2351
+ "is-core-module": "^2.16.1",
2352
+ "path-parse": "^1.0.7",
2353
+ "supports-preserve-symlinks-flag": "^1.0.0"
2354
+ },
2355
+ "bin": {
2356
+ "resolve": "bin/resolve"
2357
+ },
2358
+ "engines": {
2359
+ "node": ">= 0.4"
2360
+ },
2361
+ "funding": {
2362
+ "url": "https://github.com/sponsors/ljharb"
2363
+ }
2364
+ },
2365
+ "node_modules/reusify": {
2366
+ "version": "1.1.0",
2367
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
2368
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
2369
+ "dev": true,
2370
+ "license": "MIT",
2371
+ "engines": {
2372
+ "iojs": ">=1.0.0",
2373
+ "node": ">=0.10.0"
2374
+ }
2375
+ },
2376
+ "node_modules/rollup": {
2377
+ "version": "4.61.0",
2378
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
2379
+ "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==",
2380
+ "dev": true,
2381
+ "license": "MIT",
2382
+ "dependencies": {
2383
+ "@types/estree": "1.0.9"
2384
+ },
2385
+ "bin": {
2386
+ "rollup": "dist/bin/rollup"
2387
+ },
2388
+ "engines": {
2389
+ "node": ">=18.0.0",
2390
+ "npm": ">=8.0.0"
2391
+ },
2392
+ "optionalDependencies": {
2393
+ "@rollup/rollup-android-arm-eabi": "4.61.0",
2394
+ "@rollup/rollup-android-arm64": "4.61.0",
2395
+ "@rollup/rollup-darwin-arm64": "4.61.0",
2396
+ "@rollup/rollup-darwin-x64": "4.61.0",
2397
+ "@rollup/rollup-freebsd-arm64": "4.61.0",
2398
+ "@rollup/rollup-freebsd-x64": "4.61.0",
2399
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.0",
2400
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.0",
2401
+ "@rollup/rollup-linux-arm64-gnu": "4.61.0",
2402
+ "@rollup/rollup-linux-arm64-musl": "4.61.0",
2403
+ "@rollup/rollup-linux-loong64-gnu": "4.61.0",
2404
+ "@rollup/rollup-linux-loong64-musl": "4.61.0",
2405
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.0",
2406
+ "@rollup/rollup-linux-ppc64-musl": "4.61.0",
2407
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.0",
2408
+ "@rollup/rollup-linux-riscv64-musl": "4.61.0",
2409
+ "@rollup/rollup-linux-s390x-gnu": "4.61.0",
2410
+ "@rollup/rollup-linux-x64-gnu": "4.61.0",
2411
+ "@rollup/rollup-linux-x64-musl": "4.61.0",
2412
+ "@rollup/rollup-openbsd-x64": "4.61.0",
2413
+ "@rollup/rollup-openharmony-arm64": "4.61.0",
2414
+ "@rollup/rollup-win32-arm64-msvc": "4.61.0",
2415
+ "@rollup/rollup-win32-ia32-msvc": "4.61.0",
2416
+ "@rollup/rollup-win32-x64-gnu": "4.61.0",
2417
+ "@rollup/rollup-win32-x64-msvc": "4.61.0",
2418
+ "fsevents": "~2.3.2"
2419
+ }
2420
+ },
2421
+ "node_modules/run-parallel": {
2422
+ "version": "1.2.0",
2423
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
2424
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
2425
+ "dev": true,
2426
+ "funding": [
2427
+ {
2428
+ "type": "github",
2429
+ "url": "https://github.com/sponsors/feross"
2430
+ },
2431
+ {
2432
+ "type": "patreon",
2433
+ "url": "https://www.patreon.com/feross"
2434
+ },
2435
+ {
2436
+ "type": "consulting",
2437
+ "url": "https://feross.org/support"
2438
+ }
2439
+ ],
2440
+ "license": "MIT",
2441
+ "dependencies": {
2442
+ "queue-microtask": "^1.2.2"
2443
+ }
2444
+ },
2445
+ "node_modules/scheduler": {
2446
+ "version": "0.23.2",
2447
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
2448
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
2449
+ "license": "MIT",
2450
+ "dependencies": {
2451
+ "loose-envify": "^1.1.0"
2452
+ }
2453
+ },
2454
+ "node_modules/semver": {
2455
+ "version": "6.3.1",
2456
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2457
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2458
+ "dev": true,
2459
+ "license": "ISC",
2460
+ "bin": {
2461
+ "semver": "bin/semver.js"
2462
+ }
2463
+ },
2464
+ "node_modules/source-map-js": {
2465
+ "version": "1.2.1",
2466
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2467
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2468
+ "dev": true,
2469
+ "license": "BSD-3-Clause",
2470
+ "engines": {
2471
+ "node": ">=0.10.0"
2472
+ }
2473
+ },
2474
+ "node_modules/sucrase": {
2475
+ "version": "3.35.1",
2476
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
2477
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
2478
+ "dev": true,
2479
+ "license": "MIT",
2480
+ "dependencies": {
2481
+ "@jridgewell/gen-mapping": "^0.3.2",
2482
+ "commander": "^4.0.0",
2483
+ "lines-and-columns": "^1.1.6",
2484
+ "mz": "^2.7.0",
2485
+ "pirates": "^4.0.1",
2486
+ "tinyglobby": "^0.2.11",
2487
+ "ts-interface-checker": "^0.1.9"
2488
+ },
2489
+ "bin": {
2490
+ "sucrase": "bin/sucrase",
2491
+ "sucrase-node": "bin/sucrase-node"
2492
+ },
2493
+ "engines": {
2494
+ "node": ">=16 || 14 >=14.17"
2495
+ }
2496
+ },
2497
+ "node_modules/supports-preserve-symlinks-flag": {
2498
+ "version": "1.0.0",
2499
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
2500
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
2501
+ "dev": true,
2502
+ "license": "MIT",
2503
+ "engines": {
2504
+ "node": ">= 0.4"
2505
+ },
2506
+ "funding": {
2507
+ "url": "https://github.com/sponsors/ljharb"
2508
+ }
2509
+ },
2510
+ "node_modules/tailwindcss": {
2511
+ "version": "3.4.19",
2512
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
2513
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
2514
+ "dev": true,
2515
+ "license": "MIT",
2516
+ "dependencies": {
2517
+ "@alloc/quick-lru": "^5.2.0",
2518
+ "arg": "^5.0.2",
2519
+ "chokidar": "^3.6.0",
2520
+ "didyoumean": "^1.2.2",
2521
+ "dlv": "^1.1.3",
2522
+ "fast-glob": "^3.3.2",
2523
+ "glob-parent": "^6.0.2",
2524
+ "is-glob": "^4.0.3",
2525
+ "jiti": "^1.21.7",
2526
+ "lilconfig": "^3.1.3",
2527
+ "micromatch": "^4.0.8",
2528
+ "normalize-path": "^3.0.0",
2529
+ "object-hash": "^3.0.0",
2530
+ "picocolors": "^1.1.1",
2531
+ "postcss": "^8.4.47",
2532
+ "postcss-import": "^15.1.0",
2533
+ "postcss-js": "^4.0.1",
2534
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
2535
+ "postcss-nested": "^6.2.0",
2536
+ "postcss-selector-parser": "^6.1.2",
2537
+ "resolve": "^1.22.8",
2538
+ "sucrase": "^3.35.0"
2539
+ },
2540
+ "bin": {
2541
+ "tailwind": "lib/cli.js",
2542
+ "tailwindcss": "lib/cli.js"
2543
+ },
2544
+ "engines": {
2545
+ "node": ">=14.0.0"
2546
+ }
2547
+ },
2548
+ "node_modules/thenify": {
2549
+ "version": "3.3.1",
2550
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
2551
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
2552
+ "dev": true,
2553
+ "license": "MIT",
2554
+ "dependencies": {
2555
+ "any-promise": "^1.0.0"
2556
+ }
2557
+ },
2558
+ "node_modules/thenify-all": {
2559
+ "version": "1.6.0",
2560
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
2561
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
2562
+ "dev": true,
2563
+ "license": "MIT",
2564
+ "dependencies": {
2565
+ "thenify": ">= 3.1.0 < 4"
2566
+ },
2567
+ "engines": {
2568
+ "node": ">=0.8"
2569
+ }
2570
+ },
2571
+ "node_modules/tinyglobby": {
2572
+ "version": "0.2.17",
2573
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
2574
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
2575
+ "dev": true,
2576
+ "license": "MIT",
2577
+ "dependencies": {
2578
+ "fdir": "^6.5.0",
2579
+ "picomatch": "^4.0.4"
2580
+ },
2581
+ "engines": {
2582
+ "node": ">=12.0.0"
2583
+ },
2584
+ "funding": {
2585
+ "url": "https://github.com/sponsors/SuperchupuDev"
2586
+ }
2587
+ },
2588
+ "node_modules/tinyglobby/node_modules/fdir": {
2589
+ "version": "6.5.0",
2590
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2591
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2592
+ "dev": true,
2593
+ "license": "MIT",
2594
+ "engines": {
2595
+ "node": ">=12.0.0"
2596
+ },
2597
+ "peerDependencies": {
2598
+ "picomatch": "^3 || ^4"
2599
+ },
2600
+ "peerDependenciesMeta": {
2601
+ "picomatch": {
2602
+ "optional": true
2603
+ }
2604
+ }
2605
+ },
2606
+ "node_modules/tinyglobby/node_modules/picomatch": {
2607
+ "version": "4.0.4",
2608
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2609
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2610
+ "dev": true,
2611
+ "license": "MIT",
2612
+ "engines": {
2613
+ "node": ">=12"
2614
+ },
2615
+ "funding": {
2616
+ "url": "https://github.com/sponsors/jonschlinkert"
2617
+ }
2618
+ },
2619
+ "node_modules/to-regex-range": {
2620
+ "version": "5.0.1",
2621
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
2622
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
2623
+ "dev": true,
2624
+ "license": "MIT",
2625
+ "dependencies": {
2626
+ "is-number": "^7.0.0"
2627
+ },
2628
+ "engines": {
2629
+ "node": ">=8.0"
2630
+ }
2631
+ },
2632
+ "node_modules/ts-interface-checker": {
2633
+ "version": "0.1.13",
2634
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
2635
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
2636
+ "dev": true,
2637
+ "license": "Apache-2.0"
2638
+ },
2639
+ "node_modules/typescript": {
2640
+ "version": "5.9.3",
2641
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2642
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2643
+ "dev": true,
2644
+ "license": "Apache-2.0",
2645
+ "bin": {
2646
+ "tsc": "bin/tsc",
2647
+ "tsserver": "bin/tsserver"
2648
+ },
2649
+ "engines": {
2650
+ "node": ">=14.17"
2651
+ }
2652
+ },
2653
+ "node_modules/update-browserslist-db": {
2654
+ "version": "1.2.3",
2655
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2656
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2657
+ "dev": true,
2658
+ "funding": [
2659
+ {
2660
+ "type": "opencollective",
2661
+ "url": "https://opencollective.com/browserslist"
2662
+ },
2663
+ {
2664
+ "type": "tidelift",
2665
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2666
+ },
2667
+ {
2668
+ "type": "github",
2669
+ "url": "https://github.com/sponsors/ai"
2670
+ }
2671
+ ],
2672
+ "license": "MIT",
2673
+ "dependencies": {
2674
+ "escalade": "^3.2.0",
2675
+ "picocolors": "^1.1.1"
2676
+ },
2677
+ "bin": {
2678
+ "update-browserslist-db": "cli.js"
2679
+ },
2680
+ "peerDependencies": {
2681
+ "browserslist": ">= 4.21.0"
2682
+ }
2683
+ },
2684
+ "node_modules/util-deprecate": {
2685
+ "version": "1.0.2",
2686
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
2687
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
2688
+ "dev": true,
2689
+ "license": "MIT"
2690
+ },
2691
+ "node_modules/vite": {
2692
+ "version": "6.4.3",
2693
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
2694
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
2695
+ "dev": true,
2696
+ "license": "MIT",
2697
+ "dependencies": {
2698
+ "esbuild": "^0.25.0",
2699
+ "fdir": "^6.4.4",
2700
+ "picomatch": "^4.0.2",
2701
+ "postcss": "^8.5.3",
2702
+ "rollup": "^4.34.9",
2703
+ "tinyglobby": "^0.2.13"
2704
+ },
2705
+ "bin": {
2706
+ "vite": "bin/vite.js"
2707
+ },
2708
+ "engines": {
2709
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2710
+ },
2711
+ "funding": {
2712
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2713
+ },
2714
+ "optionalDependencies": {
2715
+ "fsevents": "~2.3.3"
2716
+ },
2717
+ "peerDependencies": {
2718
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2719
+ "jiti": ">=1.21.0",
2720
+ "less": "*",
2721
+ "lightningcss": "^1.21.0",
2722
+ "sass": "*",
2723
+ "sass-embedded": "*",
2724
+ "stylus": "*",
2725
+ "sugarss": "*",
2726
+ "terser": "^5.16.0",
2727
+ "tsx": "^4.8.1",
2728
+ "yaml": "^2.4.2"
2729
+ },
2730
+ "peerDependenciesMeta": {
2731
+ "@types/node": {
2732
+ "optional": true
2733
+ },
2734
+ "jiti": {
2735
+ "optional": true
2736
+ },
2737
+ "less": {
2738
+ "optional": true
2739
+ },
2740
+ "lightningcss": {
2741
+ "optional": true
2742
+ },
2743
+ "sass": {
2744
+ "optional": true
2745
+ },
2746
+ "sass-embedded": {
2747
+ "optional": true
2748
+ },
2749
+ "stylus": {
2750
+ "optional": true
2751
+ },
2752
+ "sugarss": {
2753
+ "optional": true
2754
+ },
2755
+ "terser": {
2756
+ "optional": true
2757
+ },
2758
+ "tsx": {
2759
+ "optional": true
2760
+ },
2761
+ "yaml": {
2762
+ "optional": true
2763
+ }
2764
+ }
2765
+ },
2766
+ "node_modules/vite/node_modules/fdir": {
2767
+ "version": "6.5.0",
2768
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2769
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2770
+ "dev": true,
2771
+ "license": "MIT",
2772
+ "engines": {
2773
+ "node": ">=12.0.0"
2774
+ },
2775
+ "peerDependencies": {
2776
+ "picomatch": "^3 || ^4"
2777
+ },
2778
+ "peerDependenciesMeta": {
2779
+ "picomatch": {
2780
+ "optional": true
2781
+ }
2782
+ }
2783
+ },
2784
+ "node_modules/vite/node_modules/picomatch": {
2785
+ "version": "4.0.4",
2786
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2787
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2788
+ "dev": true,
2789
+ "license": "MIT",
2790
+ "engines": {
2791
+ "node": ">=12"
2792
+ },
2793
+ "funding": {
2794
+ "url": "https://github.com/sponsors/jonschlinkert"
2795
+ }
2796
+ },
2797
+ "node_modules/yallist": {
2798
+ "version": "3.1.1",
2799
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2800
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2801
+ "dev": true,
2802
+ "license": "ISC"
2803
+ }
2804
+ }
2805
+ }
frontend/package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "geovision-pro-frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "leaflet": "^1.9.4",
13
+ "react": "^18.3.1",
14
+ "react-dom": "^18.3.1"
15
+ },
16
+ "devDependencies": {
17
+ "@types/leaflet": "^1.9.15",
18
+ "@types/react": "^18.3.18",
19
+ "@types/react-dom": "^18.3.5",
20
+ "@vitejs/plugin-react": "^4.3.4",
21
+ "autoprefixer": "^10.4.20",
22
+ "postcss": "^8.4.49",
23
+ "tailwindcss": "^3.4.17",
24
+ "typescript": "^5.7.2",
25
+ "vite": "^6.0.5"
26
+ }
27
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
frontend/src/App.tsx ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import {
3
+ analyzeBatch, analyzeImage, analyzeVideo, deleteJob, listJobs,
4
+ } from "./api";
5
+ import type { AnalysisResult, JobListItem } from "./types";
6
+ import UploadPanel from "./components/UploadPanel";
7
+ import ReferencePanel from "./components/ReferencePanel";
8
+ import MapView from "./components/MapView";
9
+ import CandidateList from "./components/CandidateList";
10
+ import Explain from "./components/Explain";
11
+ import HistoryPanel from "./components/HistoryPanel";
12
+ import ReportButtons from "./components/ReportButtons";
13
+
14
+ export default function App() {
15
+ const [busy, setBusy] = useState(false);
16
+ const [error, setError] = useState("");
17
+ const [result, setResult] = useState<AnalysisResult | null>(null);
18
+ const [batch, setBatch] = useState<AnalysisResult[]>([]);
19
+ const [jobs, setJobs] = useState<JobListItem[]>([]);
20
+
21
+ const refreshJobs = useCallback(async () => {
22
+ try { setJobs(await listJobs()); } catch { /* ignore */ }
23
+ }, []);
24
+
25
+ useEffect(() => { refreshJobs(); }, [refreshJobs]);
26
+
27
+ const run = useCallback(async (fn: () => Promise<void>) => {
28
+ setBusy(true); setError("");
29
+ try { await fn(); } catch (e) { setError((e as Error).message); }
30
+ finally { setBusy(false); refreshJobs(); }
31
+ }, [refreshJobs]);
32
+
33
+ const onImages = (files: File[]) => run(async () => {
34
+ if (files.length === 1) {
35
+ const r = await analyzeImage(files[0]);
36
+ setResult(r); setBatch([]);
37
+ } else {
38
+ const rs = await analyzeBatch(files);
39
+ setBatch(rs); setResult(rs[0] || null);
40
+ }
41
+ });
42
+
43
+ const onVideo = (file: File) => run(async () => {
44
+ const r = await analyzeVideo(file);
45
+ setResult(r); setBatch([]);
46
+ });
47
+
48
+ const openJob = (id: number) => run(async () => {
49
+ const r = await fetch(`${import.meta.env.VITE_API_BASE || "/api"}/jobs/${id}`).then((x) => x.json());
50
+ setResult(r); setBatch([]);
51
+ });
52
+
53
+ const removeJob = async (id: number) => { await deleteJob(id); refreshJobs(); if (result?.id === id) setResult(null); };
54
+
55
+ return (
56
+ <div className="min-h-full">
57
+ <header className="border-b border-edge bg-panel/60 backdrop-blur sticky top-0 z-[1000]">
58
+ <div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
59
+ <div className="font-extrabold text-xl">Geo<span className="text-accent">Vision</span> Pro</div>
60
+ <div className="text-xs text-muted hidden sm:block">
61
+ Land/Region zuverlässig · Stadt nur bei GPS/Schild · transparente Unsicherheit
62
+ </div>
63
+ </div>
64
+ </header>
65
+
66
+ <main className="max-w-6xl mx-auto px-4 py-5 grid lg:grid-cols-3 gap-4">
67
+ {/* Left: map + candidates (main focus) */}
68
+ <section className="lg:col-span-2 space-y-4">
69
+ <div className="card">
70
+ <div className="flex items-center justify-between mb-3">
71
+ <h2 className="font-bold">Karte</h2>
72
+ {result && <ReportButtons id={result.id} />}
73
+ </div>
74
+ <MapView candidates={result?.candidates ?? []} />
75
+ {result?.model_used && (
76
+ <p className="text-xs text-muted mt-2">Modell: {result.model_used}</p>
77
+ )}
78
+ </div>
79
+ {result && <CandidateList result={result} />}
80
+ {batch.length > 1 && (
81
+ <div className="card">
82
+ <h2 className="font-bold mb-2">Batch-Ergebnisse ({batch.length})</h2>
83
+ <div className="space-y-1 text-sm">
84
+ {batch.map((b, i) => (
85
+ <button key={i} onClick={() => setResult(b)}
86
+ className="w-full text-left bg-panel2 border border-edge rounded-lg px-3 py-2 hover:border-accent">
87
+ <span className="font-semibold">{b.candidates[0]?.label || "—"}</span>
88
+ <span className="text-muted"> · {b.source_name}</span>
89
+ </button>
90
+ ))}
91
+ </div>
92
+ </div>
93
+ )}
94
+ </section>
95
+
96
+ {/* Right: upload + explain + history */}
97
+ <section className="space-y-4">
98
+ <UploadPanel busy={busy} onImages={onImages} onVideo={onVideo} />
99
+ {error && (
100
+ <div className="card border-rose-700/50 text-rose-300 text-sm">Fehler: {error}</div>
101
+ )}
102
+ <ReferencePanel />
103
+ {result && <Explain result={result} />}
104
+ <HistoryPanel jobs={jobs} onOpen={openJob} onDelete={removeJob} />
105
+ </section>
106
+ </main>
107
+
108
+ <footer className="max-w-6xl mx-auto px-4 py-6 text-xs text-muted">
109
+ Keine Personenidentifikation · Kennzeichen nur als Format/Farbe, keine OCR konkreter Nummern ·
110
+ Karte © OpenStreetMap · Geocoding Nominatim
111
+ </footer>
112
+ </div>
113
+ );
114
+ }
frontend/src/api.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AnalysisResult, JobListItem } from "./types";
2
+
3
+ const BASE = import.meta.env.VITE_API_BASE || "/api";
4
+
5
+ async function handle<T>(res: Response): Promise<T> {
6
+ if (!res.ok) {
7
+ let detail = res.statusText;
8
+ try { detail = (await res.json()).detail || detail; } catch { /* ignore */ }
9
+ throw new Error(detail);
10
+ }
11
+ return res.json() as Promise<T>;
12
+ }
13
+
14
+ export async function analyzeImage(file: File): Promise<AnalysisResult> {
15
+ const fd = new FormData();
16
+ fd.append("file", file);
17
+ return handle(await fetch(`${BASE}/analyze/image`, { method: "POST", body: fd }));
18
+ }
19
+
20
+ export async function analyzeBatch(files: File[]): Promise<AnalysisResult[]> {
21
+ const fd = new FormData();
22
+ files.forEach((f) => fd.append("files", f));
23
+ return handle(await fetch(`${BASE}/analyze/batch`, { method: "POST", body: fd }));
24
+ }
25
+
26
+ export async function analyzeVideo(file: File): Promise<AnalysisResult> {
27
+ const fd = new FormData();
28
+ fd.append("file", file);
29
+ return handle(await fetch(`${BASE}/analyze/video`, { method: "POST", body: fd }));
30
+ }
31
+
32
+ export async function listJobs(limit = 50): Promise<JobListItem[]> {
33
+ return handle(await fetch(`${BASE}/jobs?limit=${limit}`));
34
+ }
35
+
36
+ export async function deleteJob(id: number): Promise<void> {
37
+ await fetch(`${BASE}/jobs/${id}`, { method: "DELETE" });
38
+ }
39
+
40
+ export function reportUrl(id: number, fmt: "pdf" | "csv" | "json"): string {
41
+ return `${BASE}/report/${id}.${fmt}`;
42
+ }
43
+
44
+ export async function getStatus(): Promise<Record<string, unknown>> {
45
+ return handle(await fetch(`${BASE}/status`));
46
+ }
47
+
48
+ export interface ReferenceEntry { name: string; lat: number | null; lon: number | null; }
49
+ export interface ReferenceList {
50
+ reference_images: number;
51
+ reference_geolocated: number;
52
+ entries: ReferenceEntry[];
53
+ }
54
+
55
+ export async function listReference(): Promise<ReferenceList> {
56
+ return handle(await fetch(`${BASE}/reference/list`));
57
+ }
58
+
59
+ export async function addReference(
60
+ file: File,
61
+ opts: { place?: string; lat?: number; lon?: number },
62
+ ): Promise<Record<string, unknown>> {
63
+ const fd = new FormData();
64
+ fd.append("file", file);
65
+ if (opts.place) fd.append("place", opts.place);
66
+ if (opts.lat !== undefined && !Number.isNaN(opts.lat)) fd.append("lat", String(opts.lat));
67
+ if (opts.lon !== undefined && !Number.isNaN(opts.lon)) fd.append("lon", String(opts.lon));
68
+ return handle(await fetch(`${BASE}/reference/add`, { method: "POST", body: fd }));
69
+ }
frontend/src/components/CandidateList.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AnalysisResult } from "../types";
2
+
3
+ const SOURCE_LABEL: Record<string, string> = {
4
+ exif: "GPS-Metadaten (exakt)",
5
+ ocr: "Schildtext (geocodiert)",
6
+ reference: "Referenzgalerie (Bild-Retrieval)",
7
+ picarta: "Picarta-API (GeoSpy-Klasse)",
8
+ geoclip: "GeoCLIP-Koordinaten (Modell-Schätzung)",
9
+ inference: "Bildinferenz (Land/Region)",
10
+ };
11
+
12
+ export default function CandidateList({ result }: { result: AnalysisResult }) {
13
+ const h = result.hierarchy;
14
+ return (
15
+ <div className="card">
16
+ <div className="flex items-center justify-between mb-3">
17
+ <h2 className="font-bold">Standort-Hypothesen</h2>
18
+ <span className="text-xs px-2 py-1 rounded-full border border-edge text-muted">
19
+ {SOURCE_LABEL[result.location_source] || result.location_source}
20
+ </span>
21
+ </div>
22
+
23
+ {/* Hierarchy */}
24
+ <div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-3 text-center text-sm">
25
+ {[["Kontinent", h.continent], ["Land", h.country], ["Region", h.region],
26
+ ["Stadt", h.city], ["Stadtteil", h.district]].map(([k, v]) => (
27
+ <div key={k as string} className="bg-panel2 rounded-lg p-2 border border-edge">
28
+ <div className="text-muted text-[11px]">{k}</div>
29
+ <div className="font-semibold truncate">{(v as string) || "—"}</div>
30
+ </div>
31
+ ))}
32
+ </div>
33
+ {h.note && <p className="text-xs text-muted mb-3">{h.note}</p>}
34
+
35
+ {/* Candidates */}
36
+ <div className="space-y-2">
37
+ {result.candidates.map((c) => (
38
+ <div key={c.rank} className="bg-panel2 rounded-lg p-3 border border-edge">
39
+ <div className="flex items-center gap-3">
40
+ <span className="text-muted font-bold w-5">{c.rank}</span>
41
+ <span className="flex-1 font-semibold truncate">{c.label}</span>
42
+ <span className="text-muted text-sm tabular-nums">{Math.round(c.confidence * 100)}%</span>
43
+ </div>
44
+ <div className="bar mt-2"><span style={{ width: `${Math.round(c.confidence * 100)}%` }} /></div>
45
+ {c.reasoning && <p className="text-xs text-muted mt-2">{c.reasoning}</p>}
46
+ </div>
47
+ ))}
48
+ {result.candidates.length === 0 && (
49
+ <p className="text-muted text-sm">Keine Kandidaten ermittelt.</p>
50
+ )}
51
+ </div>
52
+
53
+ {result.uncertainty && (
54
+ <div className="mt-3 text-xs text-amber-300/90 bg-amber-500/5 border border-amber-700/40 rounded-lg p-2.5">
55
+ ⚠️ Unsicherheit: {result.uncertainty}
56
+ </div>
57
+ )}
58
+ </div>
59
+ );
60
+ }
frontend/src/components/Explain.tsx ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AnalysisResult } from "../types";
2
+
3
+ export default function Explain({ result }: { result: AnalysisResult }) {
4
+ return (
5
+ <div className="card">
6
+ <h2 className="font-bold mb-3">Erklärung (Explainable AI)</h2>
7
+ <p className="text-xs text-muted mb-3">
8
+ Gewichte = relative Sicherheit jeder Bildmerkmal-Kategorie. Sie zeigen, welche Hinweise die
9
+ Schätzung getragen haben — keine erfundenen Zahlen.
10
+ </p>
11
+ <div className="space-y-3">
12
+ {result.signals.map((g) => (
13
+ <div key={g.name}>
14
+ <div className="flex justify-between text-sm">
15
+ <span className="font-semibold">{g.name}</span>
16
+ <span className="text-muted tabular-nums">{Math.round(g.weight * 100)}%</span>
17
+ </div>
18
+ <div className="bar mt-1"><span style={{ width: `${Math.round(g.weight * 100)}%` }} /></div>
19
+ <div className="text-xs text-muted mt-1">
20
+ {g.top.map((t) => `${t.label} (${Math.round(t.score * 100)}%)`).join(" · ")}
21
+ </div>
22
+ </div>
23
+ ))}
24
+ {result.signals.length === 0 && <p className="text-muted text-sm">Keine Merkmalsanalyse (z. B. Video).</p>}
25
+ </div>
26
+
27
+ {result.ocr_text && (
28
+ <div className="mt-4">
29
+ <div className="font-semibold text-sm mb-1">Erkannter Text (OCR)</div>
30
+ <pre className="text-xs bg-panel2 border border-edge rounded-lg p-2 whitespace-pre-wrap">{result.ocr_text}</pre>
31
+ </div>
32
+ )}
33
+
34
+ <div className="mt-4">
35
+ <div className="font-semibold text-sm mb-1">Referenzvergleich</div>
36
+ {result.reference_matches.length > 0 ? (
37
+ <ul className="text-xs text-muted space-y-1">
38
+ {result.reference_matches.map((m) => (
39
+ <li key={m.name} className="flex justify-between">
40
+ <span className="truncate">{m.name}</span>
41
+ <span className="tabular-nums">{Math.round(m.similarity * 100)}%</span>
42
+ </li>
43
+ ))}
44
+ </ul>
45
+ ) : (
46
+ <p className="text-xs text-muted">
47
+ Keine eigene Referenz-Bilddatenbank konfiguriert. Für „ähnliche Bilder weltweit" das Foto
48
+ in eine Bildsuche geben:{" "}
49
+ <a className="text-accent2" href="https://lens.google.com/" target="_blank" rel="noopener">Google Lens</a>.
50
+ </p>
51
+ )}
52
+ </div>
53
+ </div>
54
+ );
55
+ }
frontend/src/components/HistoryPanel.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { JobListItem } from "../types";
2
+
3
+ interface Props {
4
+ jobs: JobListItem[];
5
+ onOpen: (id: number) => void;
6
+ onDelete: (id: number) => void;
7
+ }
8
+
9
+ export default function HistoryPanel({ jobs, onOpen, onDelete }: Props) {
10
+ return (
11
+ <div className="card">
12
+ <h2 className="font-bold mb-3">Verlauf</h2>
13
+ {jobs.length === 0 && <p className="text-muted text-sm">Noch keine Analysen.</p>}
14
+ <div className="space-y-2 max-h-[360px] overflow-auto pr-1">
15
+ {jobs.map((j) => (
16
+ <div key={j.id} className="flex items-center gap-2 bg-panel2 border border-edge rounded-lg p-2">
17
+ <button onClick={() => onOpen(j.id)} className="flex-1 text-left min-w-0">
18
+ <div className="font-semibold text-sm truncate">{j.best_label || "—"}</div>
19
+ <div className="text-xs text-muted">
20
+ {new Date(j.created_at).toLocaleString("de-DE")} · {j.kind} · {j.location_source}
21
+ {j.best_confidence != null && ` · ${Math.round(j.best_confidence * 100)}%`}
22
+ </div>
23
+ </button>
24
+ <button onClick={() => onDelete(j.id)} className="text-rose-400 px-2 text-lg" title="Löschen">×</button>
25
+ </div>
26
+ ))}
27
+ </div>
28
+ </div>
29
+ );
30
+ }
frontend/src/components/MapView.tsx ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+ import L from "leaflet";
3
+ import type { LocationCandidate } from "../types";
4
+
5
+ interface Props {
6
+ candidates: LocationCandidate[];
7
+ }
8
+
9
+ // Interactive world map: best candidate emphasized, alternatives shown,
10
+ // a probability radius drawn around the top hit, and a light heat overlay.
11
+ export default function MapView({ candidates }: Props) {
12
+ const elRef = useRef<HTMLDivElement>(null);
13
+ const mapRef = useRef<L.Map | null>(null);
14
+ const layerRef = useRef<L.LayerGroup | null>(null);
15
+
16
+ useEffect(() => {
17
+ if (!elRef.current || mapRef.current) return;
18
+ const map = L.map(elRef.current, { worldCopyJump: true }).setView([20, 0], 2);
19
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
20
+ maxZoom: 19,
21
+ attribution: "© OpenStreetMap-Mitwirkende",
22
+ }).addTo(map);
23
+ layerRef.current = L.layerGroup().addTo(map);
24
+ mapRef.current = map;
25
+ }, []);
26
+
27
+ useEffect(() => {
28
+ const map = mapRef.current;
29
+ const layer = layerRef.current;
30
+ if (!map || !layer) return;
31
+ layer.clearLayers();
32
+
33
+ const located = candidates.filter((c) => c.lat != null && c.lon != null);
34
+ if (located.length === 0) {
35
+ map.setView([20, 0], 2);
36
+ return;
37
+ }
38
+
39
+ located.forEach((c, i) => {
40
+ const isTop = i === 0;
41
+ const radiusKm = Math.max(40, (1 - c.confidence) * 600); // higher uncertainty -> bigger radius
42
+ L.circle([c.lat!, c.lon!], {
43
+ radius: radiusKm * 1000,
44
+ color: isTop ? "#56d4c4" : "#6c8cff",
45
+ weight: isTop ? 2 : 1,
46
+ opacity: isTop ? 0.9 : 0.4,
47
+ fillColor: isTop ? "#56d4c4" : "#6c8cff",
48
+ fillOpacity: isTop ? 0.18 : 0.07,
49
+ }).addTo(layer);
50
+
51
+ L.marker([c.lat!, c.lon!])
52
+ .addTo(layer)
53
+ .bindPopup(`<b>#${c.rank} ${c.label}</b><br/>${Math.round(c.confidence * 100)}%`);
54
+ });
55
+
56
+ const top = located[0];
57
+ map.setView([top.lat!, top.lon!], located.length === 1 ? 6 : 4);
58
+ }, [candidates]);
59
+
60
+ return <div ref={elRef} className="w-full h-[440px] rounded-xl border border-edge" />;
61
+ }
frontend/src/components/ReferencePanel.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { addReference, listReference } from "../api";
3
+ import type { ReferenceList } from "../api";
4
+
5
+ /**
6
+ * Grow the app's accuracy with your own geotagged photos — the practical,
7
+ * free "train it with more images" path. Pick a photo, say where it was taken
8
+ * (place name OR coordinates OR rely on the photo's own GPS), and add it.
9
+ */
10
+ export default function ReferencePanel() {
11
+ const [info, setInfo] = useState<ReferenceList | null>(null);
12
+ const [file, setFile] = useState<File | null>(null);
13
+ const [place, setPlace] = useState("");
14
+ const [busy, setBusy] = useState(false);
15
+ const [msg, setMsg] = useState("");
16
+ const [err, setErr] = useState("");
17
+ const inputRef = useRef<HTMLInputElement>(null);
18
+
19
+ const refresh = useCallback(async () => {
20
+ try { setInfo(await listReference()); } catch { /* ignore */ }
21
+ }, []);
22
+ useEffect(() => { refresh(); }, [refresh]);
23
+
24
+ const add = useCallback(async () => {
25
+ if (!file) { setErr("Bitte zuerst ein Foto wählen."); return; }
26
+ setBusy(true); setErr(""); setMsg("");
27
+ try {
28
+ // Allow "lat, lon" typed directly into the place field.
29
+ const m = place.match(/^\s*(-?\d{1,2}\.\d+)\s*[,; ]\s*(-?\d{1,3}\.\d+)\s*$/);
30
+ const opts = m
31
+ ? { lat: parseFloat(m[1]), lon: parseFloat(m[2]) }
32
+ : { place: place.trim() || undefined };
33
+ const r = await addReference(file, opts);
34
+ setMsg(`Hinzugefügt ✓ (${r.reference_images} Fotos in der Galerie, Quelle: ${r.source})`);
35
+ setFile(null); setPlace("");
36
+ if (inputRef.current) inputRef.current.value = "";
37
+ refresh();
38
+ } catch (e) {
39
+ setErr((e as Error).message);
40
+ } finally { setBusy(false); }
41
+ }, [file, place, refresh]);
42
+
43
+ return (
44
+ <div className="card">
45
+ <h2 className="font-bold mb-1">Eigene Galerie (genauer machen)</h2>
46
+ <p className="text-xs text-muted mb-3">
47
+ Füge geotaggte Fotos hinzu — die App erkennt diese Orte danach deutlich
48
+ genauer. Je mehr, desto besser.
49
+ {info != null && (
50
+ <> {" "}<span className="text-accent">{info.reference_images} Fotos</span>
51
+ {" "}({info.reference_geolocated} mit Ort).</>
52
+ )}
53
+ </p>
54
+
55
+ <input
56
+ ref={inputRef}
57
+ type="file"
58
+ accept="image/*,.heic,.heif"
59
+ className="block w-full text-sm mb-2 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg
60
+ file:border-0 file:bg-accent file:text-black file:font-semibold"
61
+ onChange={(e) => setFile(e.target.files?.[0] ?? null)}
62
+ />
63
+ <input
64
+ type="text"
65
+ value={place}
66
+ onChange={(e) => setPlace(e.target.value)}
67
+ placeholder='Ort (z. B. "Marienplatz, München") oder "48.1372, 11.5755"'
68
+ className="w-full bg-panel2 border border-edge rounded-lg px-3 py-2 text-sm mb-1"
69
+ />
70
+ <p className="text-[11px] text-muted mb-2">
71
+ Leer lassen, wenn das Foto bereits GPS-Daten enthält.
72
+ </p>
73
+
74
+ <button
75
+ onClick={add}
76
+ disabled={busy}
77
+ className="w-full bg-accent text-black font-semibold rounded-lg py-2 text-sm
78
+ disabled:opacity-50"
79
+ >
80
+ {busy ? "Füge hinzu …" : "Zur Galerie hinzufügen"}
81
+ </button>
82
+
83
+ {msg && <div className="text-emerald-400 text-xs mt-2">{msg}</div>}
84
+ {err && <div className="text-rose-300 text-xs mt-2">Fehler: {err}</div>}
85
+
86
+ {info && info.entries.length > 0 && (
87
+ <div className="mt-3 max-h-32 overflow-auto text-xs text-muted space-y-1">
88
+ {info.entries.slice().reverse().map((e, i) => (
89
+ <div key={i} className="flex justify-between gap-2">
90
+ <span className="truncate">{e.name}</span>
91
+ <span className="shrink-0">
92
+ {e.lat != null && e.lon != null ? `${e.lat.toFixed(3)}, ${e.lon.toFixed(3)}` : "—"}
93
+ </span>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ )}
98
+ </div>
99
+ );
100
+ }