diff --git a/application/factory.py b/application/factory.py
index bf310ea6..76032ac1 100644
--- a/application/factory.py
+++ b/application/factory.py
@@ -1,343 +1,345 @@
-import logging
-import sentry_sdk
-
-from datetime import timedelta
-
-from sqlalchemy.orm import Session
-from fastapi import FastAPI, Request, status, Depends
-from fastapi.encoders import jsonable_encoder
-from fastapi.exception_handlers import http_exception_handler
-from fastapi.exceptions import RequestValidationError
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
-from fastapi.staticfiles import StaticFiles
-from pydantic import ValidationError
-from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
-from starlette.exceptions import HTTPException as StarletteHTTPException
-from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
-from starlette.responses import Response
-from http import HTTPStatus
-
-from application.db.session import get_session
-from application.core.templates import templates
-from application.db.models import EntityOrm
-from application.exceptions import DigitalLandValidationError
-from application.routers import (
- entity,
- dataset,
- map_,
- curie,
- organisation,
- fact,
- guidance_,
- about_,
- osMapOAuth,
-)
-from application.settings import get_settings
-
-logger = logging.getLogger(__name__)
-settings = get_settings()
-
-SECONDS_IN_TWO_YEARS = timedelta(days=365 * 2).total_seconds()
-
-# Add markdown here
-description = """
-## About this API
-"""
-
-tags_metadata = [
- {
- "name": "Search entity",
- "description": "find entities by location, type or date",
- },
- {
- "name": "Get entity",
- "description": "get entity by id",
- },
- {
- "name": "List datasets",
- "description": "list all datasets",
- },
- {
- "name": "Get dataset",
- "description": "get dataset by id",
- },
-]
-
-
-def create_app():
- app = FastAPI(
- title="planning.data.gov.uk API",
- description=description,
- version="0.1.0",
- contact={
- "name": "planning.data.gov.uk team",
- "email": "digitalland@levellingup.gov.uk",
- "url": "https://www.planning.data.gov.uk",
- },
- license_info={
- "name": "MIT",
- "url": "https://opensource.org/licenses/MIT",
- },
- openapi_tags=tags_metadata,
- docs_url=None,
- redoc_url=None,
- servers=[{"url": "https://www.planning.data.gov.uk"}],
- )
- add_base_routes(app)
- add_routers(app)
- add_static(app)
- app = add_middleware(app)
- return app
-
-
-def add_base_routes(app):
- @app.get("/", response_class=HTMLResponse, include_in_schema=False)
- def home(request: Request):
- return templates.TemplateResponse(
- "homepage.html", {"request": request, "opengraph_image": True}
- )
-
- @app.get("/health", response_class=JSONResponse, include_in_schema=False)
- def health(session: Session = Depends(get_session)):
- from sqlalchemy.sql import select
-
- try:
- sql = select(EntityOrm.entity).limit(1)
- result = session.execute(sql).fetchone()
- status = {
- "status": "OK",
- "entities_present": "OK" if result is not None else "FAIL",
- }
- logger.info(f"healthcheck {status}")
- return status
- except Exception as e:
- logger.exception(e)
- raise e
-
- @app.get(
- "/invalid-geometries", response_class=JSONResponse, include_in_schema=False
- )
- def invalid_geometries(session: Session = Depends(get_session)):
- from application.core.models import entity_factory
- from sqlalchemy import func
- from sqlalchemy import and_
- from sqlalchemy import not_
-
- try:
- query_args = [
- EntityOrm,
- func.ST_IsValidReason(EntityOrm.geometry).label("invalid_reason"),
- ]
- query = session.query(*query_args)
- query = query.filter(
- and_(
- EntityOrm.geometry.is_not(None),
- not_(func.ST_IsValid(EntityOrm.geometry)),
- )
- )
- entities = query.all()
- return [
- {
- "entity": entity_factory(e.EntityOrm),
- "invalid_reason": e.invalid_reason,
- }
- for e in entities
- ]
- except Exception as e:
- logger.exception(e)
- return {"message": "There was an error checking for invalid geometries"}
-
- @app.get("/cookies", response_class=HTMLResponse, include_in_schema=False)
- def cookies(request: Request):
- return templates.TemplateResponse(
- "pages/cookies.html",
- {"request": request},
- )
-
- @app.get(
- "/accessibility-statement", response_class=HTMLResponse, include_in_schema=False
- )
- def accessibility_statement(request: Request):
- return templates.TemplateResponse(
- "pages/accessibility-statement.html",
- {"request": request},
- )
-
- @app.get("/service-status", response_class=HTMLResponse, include_in_schema=False)
- def service_status(request: Request):
- return templates.TemplateResponse(
- "pages/service-status.html",
- {"request": request},
- )
-
- @app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
- def docs(request: Request):
- open_api_dict = app.openapi()
- return templates.TemplateResponse(
- "pages/docs.html",
- {
- "request": request,
- "paths": open_api_dict["paths"],
- "components": open_api_dict["components"],
- },
- )
-
- @app.get("/robots.txt", response_class=FileResponse, include_in_schema=False)
- def robots():
- return FileResponse("static/robots.txt")
-
- @app.exception_handler(StarletteHTTPException)
- async def custom_404_exception_handler(
- request: Request, exc: StarletteHTTPException
- ):
- if exc.status_code == 404:
- return templates.TemplateResponse(
- "404.html",
- {"request": request},
- status_code=exc.status_code,
- )
- else:
- # Just use FastAPI's built-in handler for other errors
- return await http_exception_handler(request, exc)
-
- # FastAPI disapproves of handling ValidationErrors as they leak internal info to users
- # Unfortunately, the errors raised by the validator bound to QueryFilters are not caught and
- # reraised as RequestValidationError, so we handle that subset of ValidationErrors manually here
- @app.exception_handler(ValidationError)
- async def custom_validation_error_handler(request: Request, exc: ValidationError):
- if all(
- [
- isinstance(raw_error.exc, DigitalLandValidationError)
- for raw_error in exc.raw_errors
- ]
- ):
- try:
- extension_path_param = request.path_params["extension"]
- except KeyError:
- extension_path_param = None
- if extension_path_param in ["json", "geojson"]:
- return JSONResponse(
- status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
- content=jsonable_encoder({"detail": exc.errors()}),
- )
- else:
- return templates.TemplateResponse(
- "404.html",
- {"request": request},
- status_code=status.HTTP_404_NOT_FOUND,
- )
-
- else:
- raise exc
-
- @app.exception_handler(RequestValidationError)
- async def custom_request_validation_error_handler(request, exc):
- try:
- extension_path_param = request.path_params["extension"]
- except KeyError:
- extension_path_param = None
-
- if extension_path_param in ["json", "geojson"]:
- return JSONResponse(
- status_code=422,
- content=jsonable_encoder({"detail": exc.errors()}),
- )
- else:
- return templates.TemplateResponse(
- "404.html", {"request": request}, status_code=404
- )
-
- # catch all handler - for any unhandled exceptions return 500 template
- @app.exception_handler(Exception)
- async def custom_catch_all_exception_handler(request: Request, exc: Exception):
- return templates.TemplateResponse(
- "500.html", {"request": request}, status_code=500
- )
-
-
-def add_routers(app):
- app.include_router(entity.router, prefix="/entity")
- app.include_router(dataset.router, prefix="/dataset")
- app.include_router(curie.router, prefix="/curie")
- app.include_router(curie.router, prefix="/prefix")
- app.include_router(organisation.router, prefix="/organisation")
- app.include_router(fact.router, prefix="/fact")
-
- # not added to /docs
- app.include_router(osMapOAuth.router, prefix="/os", include_in_schema=False)
- app.include_router(map_.router, prefix="/map", include_in_schema=False)
- app.include_router(guidance_.router, prefix="/guidance", include_in_schema=False)
- app.include_router(about_.router, prefix="/about", include_in_schema=False)
-
-
-def add_static(app):
- app.mount(
- "/static",
- StaticFiles(directory="static"),
- name="static",
- )
-
-
-def add_middleware(app):
- app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
- )
-
- @app.middleware("http")
- async def add_strict_transport_security_header(request: Request, call_next):
- response = await call_next(request)
- response.headers[
- "Strict-Transport-Security"
- ] = f"max-age={SECONDS_IN_TWO_YEARS}; includeSubDomains; preload"
- return response
-
- @app.middleware("http")
- async def add_x_frame_options_header(request: Request, call_next):
- response = await call_next(request)
- response.headers["X-Frame-Options"] = "sameorigin"
- return response
-
- @app.middleware("http")
- async def add_x_content_type_options_header(request: Request, call_next):
- response = await call_next(request)
- response.headers["X-Content-Type-Options"] = "nosniff"
- return response
-
- # this has to registered after the first middleware but before sentry?
- app.add_middleware(SuppressClientDisconnectNoResponseReturnedMiddleware)
-
- if settings.SENTRY_DSN:
- sentry_sdk.init(
- dsn=settings.SENTRY_DSN,
- environment=settings.ENVIRONMENT,
- traces_sample_rate=settings.SENTRY_TRACE_SAMPLE_RATE,
- release=settings.RELEASE_TAG,
- )
- app.add_middleware(SentryAsgiMiddleware)
-
- return app
-
-
-# Supress "no response returned" error when client disconnects
-# discussion and sample code found here
-# https://github.com/encode/starlette/discussions/1527
-class SuppressClientDisconnectNoResponseReturnedMiddleware(BaseHTTPMiddleware):
- async def dispatch(
- self, request: Request, call_next: RequestResponseEndpoint
- ) -> Response:
- try:
- response = await call_next(request)
- except RuntimeError as e:
- if await request.is_disconnected() and str(e) == "No response returned.":
- logger.warning(
- "Error 'No response returned' detected - but client already disconnected"
- )
- return Response(status_code=HTTPStatus.NO_CONTENT)
- else:
- raise
- return response
+import logging
+import sentry_sdk
+
+from datetime import timedelta
+
+from sqlalchemy.orm import Session
+from fastapi import FastAPI, Request, status, Depends
+from fastapi.encoders import jsonable_encoder
+from fastapi.exception_handlers import http_exception_handler
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import ValidationError
+from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
+from starlette.exceptions import HTTPException as StarletteHTTPException
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+from starlette.responses import Response
+from http import HTTPStatus
+
+from application.db.session import get_session
+from application.core.templates import templates
+from application.db.models import EntityOrm
+from application.exceptions import DigitalLandValidationError
+from application.routers import (
+ entity,
+ dataset,
+ map_,
+ curie,
+ organisation,
+ fact,
+ guidance_,
+ about_,
+ tiles,
+ osMapOAuth,
+)
+from application.settings import get_settings
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+SECONDS_IN_TWO_YEARS = timedelta(days=365 * 2).total_seconds()
+
+# Add markdown here
+description = """
+## About this API
+"""
+
+tags_metadata = [
+ {
+ "name": "Search entity",
+ "description": "find entities by location, type or date",
+ },
+ {
+ "name": "Get entity",
+ "description": "get entity by id",
+ },
+ {
+ "name": "List datasets",
+ "description": "list all datasets",
+ },
+ {
+ "name": "Get dataset",
+ "description": "get dataset by id",
+ },
+]
+
+
+def create_app():
+ app = FastAPI(
+ title="planning.data.gov.uk API",
+ description=description,
+ version="0.1.0",
+ contact={
+ "name": "planning.data.gov.uk team",
+ "email": "digitalland@levellingup.gov.uk",
+ "url": "https://www.planning.data.gov.uk",
+ },
+ license_info={
+ "name": "MIT",
+ "url": "https://opensource.org/licenses/MIT",
+ },
+ openapi_tags=tags_metadata,
+ docs_url=None,
+ redoc_url=None,
+ servers=[{"url": "https://www.planning.data.gov.uk"}],
+ )
+ add_base_routes(app)
+ add_routers(app)
+ add_static(app)
+ app = add_middleware(app)
+ return app
+
+
+def add_base_routes(app):
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
+ def home(request: Request):
+ return templates.TemplateResponse(
+ "homepage.html", {"request": request, "opengraph_image": True}
+ )
+
+ @app.get("/health", response_class=JSONResponse, include_in_schema=False)
+ def health(session: Session = Depends(get_session)):
+ from sqlalchemy.sql import select
+
+ try:
+ sql = select(EntityOrm.entity).limit(1)
+ result = session.execute(sql).fetchone()
+ status = {
+ "status": "OK",
+ "entities_present": "OK" if result is not None else "FAIL",
+ }
+ logger.info(f"healthcheck {status}")
+ return status
+ except Exception as e:
+ logger.exception(e)
+ raise e
+
+ @app.get(
+ "/invalid-geometries", response_class=JSONResponse, include_in_schema=False
+ )
+ def invalid_geometries(session: Session = Depends(get_session)):
+ from application.core.models import entity_factory
+ from sqlalchemy import func
+ from sqlalchemy import and_
+ from sqlalchemy import not_
+
+ try:
+ query_args = [
+ EntityOrm,
+ func.ST_IsValidReason(EntityOrm.geometry).label("invalid_reason"),
+ ]
+ query = session.query(*query_args)
+ query = query.filter(
+ and_(
+ EntityOrm.geometry.is_not(None),
+ not_(func.ST_IsValid(EntityOrm.geometry)),
+ )
+ )
+ entities = query.all()
+ return [
+ {
+ "entity": entity_factory(e.EntityOrm),
+ "invalid_reason": e.invalid_reason,
+ }
+ for e in entities
+ ]
+ except Exception as e:
+ logger.exception(e)
+ return {"message": "There was an error checking for invalid geometries"}
+
+ @app.get("/cookies", response_class=HTMLResponse, include_in_schema=False)
+ def cookies(request: Request):
+ return templates.TemplateResponse(
+ "pages/cookies.html",
+ {"request": request},
+ )
+
+ @app.get(
+ "/accessibility-statement", response_class=HTMLResponse, include_in_schema=False
+ )
+ def accessibility_statement(request: Request):
+ return templates.TemplateResponse(
+ "pages/accessibility-statement.html",
+ {"request": request},
+ )
+
+ @app.get("/service-status", response_class=HTMLResponse, include_in_schema=False)
+ def service_status(request: Request):
+ return templates.TemplateResponse(
+ "pages/service-status.html",
+ {"request": request},
+ )
+
+ @app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
+ def docs(request: Request):
+ open_api_dict = app.openapi()
+ return templates.TemplateResponse(
+ "pages/docs.html",
+ {
+ "request": request,
+ "paths": open_api_dict["paths"],
+ "components": open_api_dict["components"],
+ },
+ )
+
+ @app.get("/robots.txt", response_class=FileResponse, include_in_schema=False)
+ def robots():
+ return FileResponse("static/robots.txt")
+
+ @app.exception_handler(StarletteHTTPException)
+ async def custom_404_exception_handler(
+ request: Request, exc: StarletteHTTPException
+ ):
+ if exc.status_code == 404:
+ return templates.TemplateResponse(
+ "404.html",
+ {"request": request},
+ status_code=exc.status_code,
+ )
+ else:
+ # Just use FastAPI's built-in handler for other errors
+ return await http_exception_handler(request, exc)
+
+ # FastAPI disapproves of handling ValidationErrors as they leak internal info to users
+ # Unfortunately, the errors raised by the validator bound to QueryFilters are not caught and
+ # reraised as RequestValidationError, so we handle that subset of ValidationErrors manually here
+ @app.exception_handler(ValidationError)
+ async def custom_validation_error_handler(request: Request, exc: ValidationError):
+ if all(
+ [
+ isinstance(raw_error.exc, DigitalLandValidationError)
+ for raw_error in exc.raw_errors
+ ]
+ ):
+ try:
+ extension_path_param = request.path_params["extension"]
+ except KeyError:
+ extension_path_param = None
+ if extension_path_param in ["json", "geojson"]:
+ return JSONResponse(
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ content=jsonable_encoder({"detail": exc.errors()}),
+ )
+ else:
+ return templates.TemplateResponse(
+ "404.html",
+ {"request": request},
+ status_code=status.HTTP_404_NOT_FOUND,
+ )
+
+ else:
+ raise exc
+
+ @app.exception_handler(RequestValidationError)
+ async def custom_request_validation_error_handler(request, exc):
+ try:
+ extension_path_param = request.path_params["extension"]
+ except KeyError:
+ extension_path_param = None
+
+ if extension_path_param in ["json", "geojson"]:
+ return JSONResponse(
+ status_code=422,
+ content=jsonable_encoder({"detail": exc.errors()}),
+ )
+ else:
+ return templates.TemplateResponse(
+ "404.html", {"request": request}, status_code=404
+ )
+
+ # catch all handler - for any unhandled exceptions return 500 template
+ @app.exception_handler(Exception)
+ async def custom_catch_all_exception_handler(request: Request, exc: Exception):
+ return templates.TemplateResponse(
+ "500.html", {"request": request}, status_code=500
+ )
+
+
+def add_routers(app):
+ app.include_router(entity.router, prefix="/entity")
+ app.include_router(dataset.router, prefix="/dataset")
+ app.include_router(curie.router, prefix="/curie")
+ app.include_router(curie.router, prefix="/prefix")
+ app.include_router(organisation.router, prefix="/organisation")
+ app.include_router(fact.router, prefix="/fact")
+
+ # not added to /docs
+ app.include_router(osMapOAuth.router, prefix="/os", include_in_schema=False)
+ app.include_router(map_.router, prefix="/map", include_in_schema=False)
+ app.include_router(guidance_.router, prefix="/guidance", include_in_schema=False)
+ app.include_router(about_.router, prefix="/about", include_in_schema=False)
+ app.include_router(tiles.router, prefix="/tiles", include_in_schema=False)
+
+
+def add_static(app):
+ app.mount(
+ "/static",
+ StaticFiles(directory="static"),
+ name="static",
+ )
+
+
+def add_middleware(app):
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ @app.middleware("http")
+ async def add_strict_transport_security_header(request: Request, call_next):
+ response = await call_next(request)
+ response.headers["Strict-Transport-Security"] = (
+ f"max-age={SECONDS_IN_TWO_YEARS}; includeSubDomains; preload"
+ )
+ return response
+
+ @app.middleware("http")
+ async def add_x_frame_options_header(request: Request, call_next):
+ response = await call_next(request)
+ response.headers["X-Frame-Options"] = "sameorigin"
+ return response
+
+ @app.middleware("http")
+ async def add_x_content_type_options_header(request: Request, call_next):
+ response = await call_next(request)
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return response
+
+ # this has to registered after the first middleware but before sentry?
+ app.add_middleware(SuppressClientDisconnectNoResponseReturnedMiddleware)
+
+ if settings.SENTRY_DSN:
+ sentry_sdk.init(
+ dsn=settings.SENTRY_DSN,
+ environment=settings.ENVIRONMENT,
+ traces_sample_rate=settings.SENTRY_TRACE_SAMPLE_RATE,
+ release=settings.RELEASE_TAG,
+ )
+ app.add_middleware(SentryAsgiMiddleware)
+
+ return app
+
+
+# Supress "no response returned" error when client disconnects
+# discussion and sample code found here
+# https://github.com/encode/starlette/discussions/1527
+class SuppressClientDisconnectNoResponseReturnedMiddleware(BaseHTTPMiddleware):
+ async def dispatch(
+ self, request: Request, call_next: RequestResponseEndpoint
+ ) -> Response:
+ try:
+ response = await call_next(request)
+ except RuntimeError as e:
+ if await request.is_disconnected() and str(e) == "No response returned.":
+ logger.warning(
+ "Error 'No response returned' detected - but client already disconnected"
+ )
+ return Response(status_code=HTTPStatus.NO_CONTENT)
+ else:
+ raise
+ return response
diff --git a/application/routers/tiles.py b/application/routers/tiles.py
new file mode 100644
index 00000000..abe0d7b8
--- /dev/null
+++ b/application/routers/tiles.py
@@ -0,0 +1,99 @@
+from fastapi import APIRouter, HTTPException, Depends, Response
+from sqlalchemy.orm import Session
+import math
+from sqlalchemy import text
+from application.db.session import get_session
+
+router = APIRouter()
+
+
+def tile_is_valid(z, x, y, fmt):
+ max_tile = 2**z - 1
+ return 0 <= x <= max_tile and 0 <= y <= max_tile and fmt in ["pbf", "mvt"]
+
+
+def tile_bounds(z, x, y):
+ n = 2.0**z
+ lon_min = x / n * 360.0 - 180.0
+ lat_min = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
+ lon_max = (x + 1) / n * 360.0 - 180.0
+ lat_max = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
+ return lon_min, lat_min, lon_max, lat_max
+
+
+def build_db_query(tile, session: Session):
+ z, x, y, dataset = tile["zoom"], tile["x"], tile["y"], tile["dataset"]
+ lon_min, lat_min, lon_max, lat_max = tile_bounds(z, x, y)
+
+ geometry_column = "geometry"
+ if dataset == "tree":
+ geometry_column = "point"
+
+ tile_width = 256
+
+ mvt_geom_query = text(
+ f"""SELECT ST_AsMVT(q, :dataset, :tile_width, 'geom') FROM
+ (SELECT
+ ST_AsMVTGeom(
+ {geometry_column},
+ ST_MakeEnvelope(:lon_min, :lat_min, :lon_max, :lat_max, 4326),
+ :tile_width,
+ 4096,
+ true
+ ) as geom,
+ jsonb_build_object(
+ 'name', entity.name,
+ 'dataset', entity.dataset,
+ 'organisation-entity', entity.organisation_entity,
+ 'entity', entity.entity,
+ 'entry-date', entity.entry_date,
+ 'start-date', entity.start_date,
+ 'end-date', entity.end_date,
+ 'prefix', entity.prefix,
+ 'reference', entity.reference
+ ) AS properties
+ FROM entity
+ WHERE NOT EXISTS (
+ SELECT 1 FROM old_entity
+ WHERE entity.entity = old_entity.old_entity
+ )
+ AND dataset = :dataset
+ AND ST_Intersects({geometry_column}, ST_MakeEnvelope(:lon_min, :lat_min, :lon_max, :lat_max, 4326))
+ ) AS q
+ """
+ )
+
+ result = session.execute(
+ mvt_geom_query,
+ {
+ "lon_min": lon_min,
+ "lat_min": lat_min,
+ "lon_max": lon_max,
+ "lat_max": lat_max,
+ "dataset": dataset,
+ "tile_width": tile_width,
+ },
+ ).scalar()
+ return result
+
+
+@router.get("/{dataset}/{z}/{x}/{y}.vector.{fmt}")
+async def read_tiles(
+ dataset: str,
+ z: int,
+ x: int,
+ y: int,
+ fmt: str,
+ session: Session = Depends(get_session),
+):
+ if not tile_is_valid(z, x, y, fmt):
+ raise HTTPException(status_code=400, detail="Invalid tile path")
+
+ tile = {"dataset": dataset, "zoom": z, "x": x, "y": y, "format": fmt}
+ mvt_data = build_db_query(tile, session)
+ if not mvt_data:
+ raise HTTPException(status_code=404, detail="Tile data not found")
+
+ return Response(
+ content=mvt_data.tobytes(), media_type="application/vnd.mapbox-vector-tile"
+ )
diff --git a/application/settings.py b/application/settings.py
index aa407dc1..ea6bc3b0 100644
--- a/application/settings.py
+++ b/application/settings.py
@@ -16,7 +16,6 @@ class Settings(BaseSettings):
RELEASE_TAG: Optional[str] = None
ENVIRONMENT: str
DATASETTE_URL: HttpUrl
- DATASETTE_TILES_URL: Optional[HttpUrl]
DATA_FILE_URL: HttpUrl
GA_MEASUREMENT_ID: Optional[str] = None
OS_CLIENT_KEY: Optional[str] = None
@@ -28,6 +27,7 @@ def get_settings() -> Settings:
# TODO remove as Gov PaaS is no longer needed
# Gov.uk PaaS provides a URL to the postgres instance it provisions via DATABASE_URL
# See https://docs.cloud.service.gov.uk/deploying_services/postgresql/#connect-to-a-postgresql-service-from-your-app
+
if "DATABASE_URL" in os.environ:
database_url = os.environ["DATABASE_URL"].replace(
"postgres://", "postgresql://", 1
diff --git a/application/templates/components/map/macro.jinja b/application/templates/components/map/macro.jinja
index fcf3a2fb..dac4ce8e 100644
--- a/application/templates/components/map/macro.jinja
+++ b/application/templates/components/map/macro.jinja
@@ -53,14 +53,14 @@
params = {
...params,
baseTileStyleFilePath: "/static/javascripts/base-tile.json",
- vectorSource: "{{ params.DATASETTE_TILES_URL }}/-/tiles/dataset_tiles/{z}/{x}/{y}.vector.pbf",
- datasetVectorUrl: "{{ params.DATASETTE_TILES_URL }}/-/tiles/",
+ vectorSource: "https://www.development.digital-land.info/tiles/{z}/{x}/{y}.vector.pbf",
+ datasetVectorUrl: "https://www.development.digital-land.info/tiles/",
datasets: {{layers|tojson}}.map(d => d.dataset),
vectorTileSources: {{layers|tojson}}.map(d => {
d.paint_options = d.paint_options || {};
return {
name: d.dataset,
- vectorSource: "{{ params.DATASETTE_TILES_URL }}/-/tiles/" + d.dataset + "/{z}/{x}/{y}.vector.pbf",
+ vectorSource: "https://www.development.digital-land.info/tiles/" + d.dataset + "/{z}/{x}/{y}.vector.pbf",
dataType: d.paint_options.type,
styleProps: {
colour: d.paint_options.colour,
diff --git a/application/templates/national-map.html b/application/templates/national-map.html
index 24aee274..3857b2c7 100644
--- a/application/templates/national-map.html
+++ b/application/templates/national-map.html
@@ -1,58 +1,39 @@
-{% extends "layouts/layout--full-width.html" %}
-{% set templateName = "dl-info/national-map.html" %}
-
-{%- from "components/map/macro.jinja" import map %}
-
-{% set containerClasses = 'dl-container--full-width' %}
-{% set fullWidthHeader = true %}
-
-{% set includesMap = true %}
-{% block pageTitle %}Map of planning data for England | Planning Data{% endblock %}
-
-{%
- set notePanel = '
Find, understand and download the datasets used to create this map.
'
-%}
-
-{%- block mapAssets %}
-
-
- {{ super() }}
-{% endblock -%}
-
-{%- from "components/back-button/macro.jinja" import dlBackButton %}
-{% block breadcrumbs%}
- {{ dlBackButton({
- "parentHref": '/'
- })}}
-{% endblock %}
-
-{% block content %}
-
-
-
Map of planning data for England
-
+{% extends "layouts/layout--full-width.html" %} {% set templateName =
+"dl-info/national-map.html" %} {%- from "components/map/macro.jinja" import map
+%} {% set containerClasses = 'dl-container--full-width' %} {% set
+fullWidthHeader = true %} {% set includesMap = true %} {% block pageTitle %}Map
+of planning data for England | Planning Data{% endblock %} {% set notePanel = '
+
+ Find, understand and download the
+ datasets used to create this map.
+
+' %} {%- block mapAssets %}
+
+
+{{ super() }} {% endblock -%} {%- from "components/back-button/macro.jinja"
+import dlBackButton %} {% block breadcrumbs%} {{ dlBackButton({ "parentHref":
+'/' })}} {% endblock %} {% block content %}
+
+
+
+ Map of planning data for England
+
+
-
See the data we've collected and collated on a map.
-
- {{
- map({
- 'height': 700,
- 'layers': layers,
- 'DATASETTE_TILES_URL': settings.DATASETTE_TILES_URL,
- 'notePanel': notePanel,
- 'enableZoomControls': true,
- 'enableLayerControls': true,
- 'enableZoomCounter': true,
- })
- }}
-
-
This prototype map is automatically created using data from planning.data.gov.uk. Find out more about the Planning Data Platform
-
-{% endblock %}
+
+ See the data we've collected and collated on a map.
+
-{% block bodyEnd %}
-{{ super() }}
+{{ map({ 'height': 700, 'layers': layers, 'DATASETTE_TILES_URL':
+"https://www.development.digital-land.info", 'notePanel': notePanel,
+'enableZoomControls': true, 'enableLayerControls': true, 'enableZoomCounter':
+true, }) }}
+
+ This prototype map is automatically created using data from
+ planning.data.gov.uk. Find out more
+ about the Planning Data Platform
+
-{% endblock %}
+{% endblock %} {% block bodyEnd %} {{ super() }} {% endblock %}
diff --git a/assets/javascripts/MapController.js b/assets/javascripts/MapController.js
index f4728470..998a1254 100644
--- a/assets/javascripts/MapController.js
+++ b/assets/javascripts/MapController.js
@@ -1,590 +1,651 @@
-import BrandImageControl from "./BrandImageControl.js";
-import CopyrightControl from "./CopyrightControl.js";
-import LayerControls from "./LayerControls.js";
-import TiltControl from "./TiltControl.js";
-import { capitalizeFirstLetter, preventScroll } from "./utils.js";
-import { getApiToken, getFreshApiToken } from "./osApiToken.js";
-import {defaultPaintOptions} from "./defaultPaintOptions.js";
-
-export default class MapController {
- constructor(params) {
- // set the params applying default values where none were provided
- this.setParams(params);
-
- // create an array to store the geojson layers
- this.geojsonLayers = [];
-
- // create the maplibre map
- this.createMap();
- }
-
- setParams(params) {
- params = params || {};
- this.mapId = params.mapId || 'mapid';
- this.mapContainerSelector = params.mapContainerSelector || '.dl-map__wrapper';
- this.vectorTileSources = params.vectorTileSources || [];
- this.datasetVectorUrl = params.datasetVectorUrl || null;
- this.datasets = params.datasets || null;
- this.minMapZoom = params.minMapZoom || 5;
- this.maxMapZoom = params.maxMapZoom || 15;
- this.baseURL = params.baseURL || 'https://digital-land.github.io';
- this.baseTileStyleFilePath = params.baseTileStyleFilePath || '/static/javascripts/base-tile.json';
- this.popupWidth = params.popupWidth || '260px';
- this.popupMaxListLength = params.popupMaxListLength || 10;
- this.LayerControlOptions = params.LayerControlOptions || {enabled: false};
- this.ZoomControlsOptions = params.ZoomControlsOptions || {enabled: false};
- this.FullscreenControl = params.FullscreenControl || {enabled: false};
- this.geojsons = params.geojsons || [];
- this.images = params.images || [{src: '/static/images/location-pointer-sdf-256.png', name: 'custom-marker-256', size: 256}];
- this.paint_options = params.paint_options || null;
- this.customStyleJson = '/static/javascripts/OS_VTS_3857_3D.json';
- this.customStyleLayersToBringToFront = [
- 'OS/Names/National/Country',
- ];
- this.useOAuth2 = params.useOAuth2 || false;
- this.layers = params.layers || [];
- this.featuresHoveringOver = 0;
- }
-
- getViewFromUrl() {
- const urlObj = new URL(document.location)
- const hash = urlObj.hash
- if(hash){
- const [lat, lng, zoom] = hash.substring(1).split(',')
- return {centre: [parseFloat(lng), parseFloat(lat)], zoom: parseFloat(zoom)}
- }
- return {centre: undefined, zoom: undefined}
- }
-
- async createMap() {
- // Define the custom JSON style.
- // More styles can be found at https://github.com/OrdnanceSurvey/OS-Vector-Tile-API-Stylesheets.
-
- await getFreshApiToken();
-
- const viewFromUrl = this.getViewFromUrl()
-
- var map = new maplibregl.Map({
- container: this.mapId,
- minZoom: 5.5,
- maxZoom: 18,
- style: this.customStyleJson,
- maxBounds: [
- [ -15, 49 ],
- [ 13, 57 ]
- ],
- center: viewFromUrl.centre || [ -1, 52.9 ],
- zoom: viewFromUrl.zoom || 5.5,
- transformRequest: (url, resourceType) => {
- if(url.indexOf('api.os.uk') > -1){
- if(! /[?&]key=/.test(url) ) url += '?key=null'
-
- const requestToMake = {
- url: url + '&srs=3857',
- }
-
- if(this.useOAuth2){
- const token = getApiToken();
- requestToMake.headers = {
- 'Authorization': 'Bearer ' + token,
- }
- }
-
- return requestToMake;
- }
- }
- });
-
- map.getCanvas().ariaLabel = `${this.mapId}`;
- this.map = map;
-
- // once the maplibre map has loaded call the setup function
- var boundSetup = this.setup.bind(this);
- this.map.on('load', boundSetup);
-
- };
-
- async setup() {
- console.log('setup')
- try{
- await this.loadImages(this.images);
- }catch(e){
- console.log('error loading images: ' + e)
- }
- console.log('past load images')
- this.availableLayers = this.addVectorTileSources(this.vectorTileSources);
- this.geojsonLayers = this.addGeojsonSources(this.geojsons);
- if(this.geojsonLayers.length == 1){
- this.flyTo(this.geojsons[0]);
- }
- this.addControls()
- this.addClickHandlers();
- this.overwriteWheelEventsForControls();
-
- const handleMapMove = () => {
- const center = this.map.getCenter()
- const zoom = this.map.getZoom()
- const urlObj = new URL(document.location)
- const newURL = urlObj.origin + urlObj.pathname + urlObj.search + `#${center.lat},${center.lng},${zoom}z`;
- window.history.replaceState({}, '', newURL);
- }
- this.obscureScotland()
- this.obscureWales()
- this.addNeighbours()
- this.map.on('moveend',handleMapMove)
- };
-
- loadImages(imageSrc=[]) {
- console.log('loading images' + imageSrc.length + ' images')
- return new Promise((resolve, reject) => {
- const promiseArray = imageSrc.map(({src, name}) => {
- return new Promise((resolve, reject) => {
- this.map.loadImage(
- src,
- (error, image) => {
- if (error){
- console.log('error adding image: ' + error)
- reject(error);
- }
- console.log('added image')
- this.map.addImage(name, image, {sdf: true});
- resolve();
- }
- );
- })
- });
- Promise.all(promiseArray).then(() => {
- console.log('resolved')
- resolve();
- }).catch((error) => {
- console.log('rejected')
- reject(error);
- });
- })
- }
-
- addVectorTileSources(vectorTileSources = []) {
- let availableLayers = {};
- // add vector tile sources to map
- vectorTileSources.forEach(source => {
- let layers = this.addVectorTileSource(source);
- availableLayers[source.name] = layers;
- });
- return availableLayers;
- }
-
- obscureWales(){
- this.obscure('Wales_simplified', '#FFFFFF', 0.6);
- }
-
- obscureScotland(){
- this.obscure('Scotland_simplified');
- }
-
- addNeighbours(){
- this.obscure('UK_neighbours', '#FFFFFF', 0.9);
- }
-
-
- obscure(name, colour = '#FFFFFF', opacity = 0.8){
- this.map.addSource(name, {
- type: 'geojson',
- data: `/static/javascripts/geojsons/${name}.json`,
- buffer: 0,
- })
- const layerId = `${name}_Layer`
- this.map.addLayer({
- id: layerId,
- type: 'fill',
- source: name,
- layout: {},
- paint: {
- 'fill-color': colour,
- 'fill-opacity': opacity,
- }
- })
- this.map.moveLayer(layerId,'OS/Names/National/Country')
- }
-
-
- addGeojsonSources(geojsons = []) {
- // add geojsons sources to map
- const addedLayers = [];
- geojsons.forEach(geojson => {
- if(geojson.data.type == 'Point')
- addedLayers.push(this.addPoint(geojson, this.images[0]));
- else if(['Polygon', 'MultiPolygon'].includes(geojson.data.type))
- addedLayers.push(this.addPolygon(geojson));
- else
- throw new Error('Unsupported geometry type');
- });
- return addedLayers;
- }
-
- addControls() {
- this.map.addControl(new maplibregl.ScaleControl({
- container: document.getElementById(this.mapId)
- }), 'bottom-left');
-
- if(this.FullscreenControl.enabled){
- this.map.addControl(new maplibregl.FullscreenControl({
- container: document.getElementById(this.mapId)
- }), 'top-left');
- }
- this.map.addControl(new TiltControl(), 'top-left');
- this.map.addControl(new maplibregl.NavigationControl({
- container: document.getElementById(this.mapId)
- }), 'top-left');
-
- this.map.addControl(new CopyrightControl(), 'bottom-right');
-
- if(this.LayerControlOptions.enabled){
- this.layerControlsComponent = new LayerControls(this, this.sourceName, this.layers, this.availableLayers, this.LayerControlOptions);
- this.map.addControl(this.layerControlsComponent, 'top-right');
- }
- }
-
- overwriteWheelEventsForControls() {
- const mapEl = document.getElementById(this.mapId)
- const mapControlsArray = mapEl.querySelectorAll('.maplibregl-control-container')
- mapControlsArray.forEach((mapControls) => mapControls.addEventListener('wheel', preventScroll(['.dl-map__side-panel__content']), {passive: false}));
- }
-
- addClickHandlers() {
- if(this.layerControlsComponent){
- this.map.on('click', this.clickHandler.bind(this));
- }
- }
-
-
- flyTo(geometry){
- if(geometry.data.type == 'Point'){
- this.map.flyTo({
- center: geometry.data.coordinates,
- essential: true,
- animate: false,
- zoom: 15
- });
- } else {
- var bbox = turf.extent(geometry.data);
- this.map.fitBounds(bbox, {padding: 20, animate: false});
- }
- }
-
- addLayer({
- sourceName,
- layerType,
- paintOptions={},
- layoutOptions={},
- sourceLayer='',
- additionalOptions={}
- }){
- const layerName = `${sourceName}-${layerType}`;
- this.map.addLayer({
- id: layerName,
- type: layerType,
- source: sourceName,
- 'source-layer': sourceLayer,
- paint: paintOptions,
- layout: layoutOptions,
- ...additionalOptions
- });
-
- if(['fill', 'fill-extrusion', 'circle'].includes(layerType)){
- this.map.on('mouseover', layerName, () => {
- this.map.getCanvas().style.cursor = 'pointer'
- this.featuresHoveringOver++;
- })
- this.map.on('mouseout', layerName, () => {
- this.featuresHoveringOver--;
- if(this.featuresHoveringOver == 0)
- this.map.getCanvas().style.cursor = ''
- })
- }
-
- return layerName;
- }
-
- addPolygon(geometry) {
- this.map.addSource(geometry.name, {
- 'type': 'geojson',
- 'data': {
- 'type': 'Feature',
- 'geometry': geometry.data,
- 'properties': {
- 'entity': geometry.entity,
- 'name': geometry.name,
- }
- },
- });
-
- let colour = 'blue';
- if(this.paint_options)
- colour = this.paint_options.colour;
-
- let layer = this.addLayer({
- sourceName: geometry.name,
- layerType: 'fill-extrusion',
- paintOptions: {
- 'fill-extrusion-color': colour,
- 'fill-extrusion-opacity': 0.5,
- 'fill-extrusion-height': 1,
- 'fill-extrusion-base': 0,
- },
- });
-
- this.moveLayerBehindBuildings(layer)
-
- return layer;
- }
-
- moveLayerBehindBuildings(layer, buildingsLayer = 'OS/TopographicArea_1/Building/1_3D') {
- try{
- this.map.moveLayer(layer, buildingsLayer);
- } catch (e) {
- console.error(`Could not move layer behind ${buildingsLayer}: `, e);
- }
- }
-
- addPoint(geometry, image=undefined){
- this.map.addSource(geometry.name, {
- 'type': 'geojson',
- 'data': {
- 'type': 'Feature',
- 'geometry': geometry.data,
- 'properties': {
- 'entity': geometry.entity,
- 'name': geometry.name,
- }
- }
- });
-
- let iconColor = 'blue';
- if(this.paint_options)
- iconColor = this.paint_options.colour;
-
- let layerName
- // if an image is provided use that otherwise use a circle
- if(image){
- if(!this.map.hasImage(image.name)){
- throw new Error('Image not loaded, imageName: ' + image.name + ' not found');
- }
- layerName = this.addLayer(
- {
- sourceName: geometry.name,
- layerType: 'symbol',
- paintOptions: {
- 'icon-color': iconColor,
- 'icon-opacity': 1,
- },
- layoutOptions: {
- 'icon-image': image.name,
- 'icon-size': 256 / image.size * 0.15,
- 'icon-anchor': 'bottom',
- // get the year from the source's "year" property
- 'text-field': ['get', 'year'],
- 'text-font': [
- 'Open Sans Semibold',
- 'Arial Unicode MS Bold'
- ],
- 'text-offset': [0, 1.25],
- 'text-anchor': 'top'
- },
- })
- }else{
- layerName = this.addLayer({
- sourceName: geometry.name,
- layerType: 'circle',
- paintOptions: {
- 'circle-color': iconColor,
- "circle-radius": defaultPaintOptions['circle-radius'],
- }
- })
- }
- return layerName;
- }
-
- addVectorTileSource(source) {
- // add source
- this.map.addSource(`${source.name}-source`, {
- type: 'vector',
- tiles: [source.vectorSource],
- minzoom: this.minMapZoom,
- maxzoom: this.maxMapZoom
- });
-
- // add layer
- let layers;
- if (source.dataType === 'point') {
- let layerName = this.addLayer({
- sourceName: `${source.name}-source`,
- layerType: 'circle',
- paintOptions: {
- 'circle-color': source.styleProps.colour || defaultPaintOptions['fill-color'],
- 'circle-opacity': source.styleProps.opacity || defaultPaintOptions['fill-opacity'],
- 'circle-stroke-color': source.styleProps.colour || defaultPaintOptions['fill-color'],
- "circle-radius": defaultPaintOptions['circle-radius']
- },
- sourceLayer: `${source.name}`,
- });
-
- layers = [layerName];
- } else {
- // create fill layer
- let fillLayerName = this.addLayer({
- sourceName: `${source.name}-source`,
- layerType: 'fill-extrusion',
- paintOptions: {
- 'fill-extrusion-color': source.styleProps.colour || defaultPaintOptions['fill-color'],
- 'fill-extrusion-height': 1,
- 'fill-extrusion-base': 0,
- 'fill-extrusion-opacity': parseFloat(source.styleProps.opacity) || defaultPaintOptions['fill-opacity']
- },
- sourceLayer: `${source.name}`,
- });
-
- this.moveLayerBehindBuildings(fillLayerName)
-
- // create line layer
- let lineLayerName = this.addLayer({
- sourceName: `${source.name}-source`,
- layerType: 'line',
- paintOptions: {
- 'line-color': source.styleProps.colour || defaultPaintOptions['fill-color'],
- 'line-width': source.styleProps.weight || defaultPaintOptions['weight']
- },
- sourceLayer: `${source.name}`,
- });
-
- // create point layer for geometries
- let pointLayerName = this.addLayer({
- sourceName: `${source.name}-source`,
- layerType: 'circle',
- paintOptions: {
- 'circle-color': source.styleProps.colour || defaultPaintOptions['fill-color'],
- 'circle-opacity': source.styleProps.opacity || defaultPaintOptions['fill-opacity'],
- 'circle-stroke-color': source.styleProps.colour || defaultPaintOptions['fill-color'],
- "circle-radius": defaultPaintOptions['circle-radius']
- },
- sourceLayer: `${source.name}`,
- additionalOptions:{
- filter:[ "==", ["geometry-type"], "Point"]
- },
- });
- layers = [fillLayerName, lineLayerName, pointLayerName];
- }
- return layers;
- }
-
- clickHandler(e) {
- var map = this.map;
- var bbox = [[e.point.x - 5, e.point.y - 5], [e.point.x + 5, e.point.y + 5]];
-
- let clickableLayers = this.layerControlsComponent.getClickableLayers() || [];
-
- var features = map.queryRenderedFeatures(bbox, {
- layers: clickableLayers
- });
- var coordinates = e.lngLat;
-
- if (features.length) {
- // no need to show popup if not clicking on feature
- var popupDomElement = this.createFeaturesPopup(this.removeDuplicates(features));
- var popup = new maplibregl.Popup({
- maxWidth: this.popupWidth
- }).setLngLat(coordinates).setDOMContent(popupDomElement).addTo(map);
- popup.getElement().onwheel = preventScroll(['.app-popup-list']);
- }
- };
-
- // map.queryRenderedFeatures() can return duplicate features so we need to remove them
- removeDuplicates(features) {
- var uniqueEntities = [];
-
- return features.filter(function (feature) {
- if (uniqueEntities.indexOf(feature.properties.entity) === -1) {
- uniqueEntities.push(feature.properties.entity);
- return true;
- }
-
- return false;
- });
-
- };
-
- createFeaturesPopup(features) {
- const wrapper = document.createElement('div');
- wrapper.classList.add('app-popup');
-
- const featureOrFeatures = features.length > 1 ? 'features' : 'feature';
- const heading = document.createElement('h3');
- heading.classList.add('app-popup-heading');
- heading.textContent = `${features.length} ${featureOrFeatures} selected`;
- wrapper.appendChild(heading);
-
- if (features.length > this.popupMaxListLength) {
- const tooMany = document.createElement('p');
- tooMany.classList.add('govuk-body-s');
- tooMany.textContent = `You clicked on ${features.length} features.`;
- const tooMany2 = document.createElement('p');
- tooMany2.classList.add('govuk-body-s');
- tooMany2.textContent = 'Zoom in or turn off layers to narrow down your choice.';
- wrapper.appendChild(tooMany);
- wrapper.appendChild(tooMany2);
- return wrapper;
- }
-
- const list = document.createElement('ul');
- list.classList.add('app-popup-list');
- features.forEach((feature) => {
- const featureType = capitalizeFirstLetter(feature.sourceLayer || feature.source).replaceAll('-', ' ');
- const fillColour = this.getFillColour(feature);
-
- const featureName = feature.properties.name || feature.properties.reference || 'Not Named';
- const item = document.createElement('li');
- item.classList.add('app-popup-item');
- item.style.borderLeft = `5px solid ${fillColour}`;
-
- const secondaryText = document.createElement('p');
- secondaryText.classList.add('app-u-secondary-text', 'govuk-!-margin-bottom-0', 'govuk-!-margin-top-0');
- secondaryText.textContent = featureType;
- item.appendChild(secondaryText);
-
- const link = document.createElement('a');
- link.classList.add('govuk-link');
- link.href = `/entity/${feature.properties.entity}`;
- link.textContent = featureName;
- const smallText = document.createElement('p');
- smallText.classList.add('dl-small-text', 'govuk-!-margin-top-0', 'govuk-!-margin-bottom-0');
- smallText.appendChild(link);
- item.appendChild(smallText);
-
- list.appendChild(item);
- });
-
- wrapper.appendChild(list);
- return wrapper;
- };
-
- getFillColour(feature) {
- if(feature.layer.type === 'symbol')
- return this.map.getLayer(feature.layer.id).getPaintProperty('icon-color');
- else if(feature.layer.type === 'fill')
- return this.map.getLayer(feature.layer.id).getPaintProperty('fill-color');
- else if(feature.layer.type === 'fill-extrusion')
- return this.map.getLayer(feature.layer.id).getPaintProperty('fill-extrusion-color');
- else if(feature.layer.type === 'circle')
- return this.map.getLayer(feature.layer.id).getPaintProperty('circle-color');
- else
- throw new Error("could not get fill colour for feature of type " + feature.layer.type);
- };
-
- setLayerVisibility(layerName, visibility) {
- this.map.setLayoutProperty(
- layerName,
- 'visibility',
- visibility
- );
- };
-
-}
+import BrandImageControl from "./BrandImageControl.js";
+import CopyrightControl from "./CopyrightControl.js";
+import LayerControls from "./LayerControls.js";
+import TiltControl from "./TiltControl.js";
+import { capitalizeFirstLetter, preventScroll } from "./utils.js";
+import { getApiToken, getFreshApiToken } from "./osApiToken.js";
+import { defaultPaintOptions } from "./defaultPaintOptions.js";
+
+export default class MapController {
+ constructor(params) {
+ // set the params applying default values where none were provided
+ this.setParams(params);
+
+ // create an array to store the geojson layers
+ this.geojsonLayers = [];
+
+ // create the maplibre map
+ this.createMap();
+ }
+
+ setParams(params) {
+ params = params || {};
+ this.mapId = params.mapId || "mapid";
+ this.mapContainerSelector =
+ params.mapContainerSelector || ".dl-map__wrapper";
+ this.vectorTileSources = params.vectorTileSources || [];
+ this.datasetVectorUrl =
+ params.datasetVectorUrl || "http://
";
+ this.apiKey = params.apiKey || null;
+ this.datasets = params.datasets || null;
+ this.minMapZoom = params.minMapZoom || 5;
+ this.maxMapZoom = params.maxMapZoom || 15;
+ this.baseURL = params.baseURL || "https://digital-land.github.io";
+ this.baseTileStyleFilePath =
+ params.baseTileStyleFilePath || "/static/javascripts/base-tile.json";
+ this.popupWidth = params.popupWidth || "260px";
+ this.popupMaxListLength = params.popupMaxListLength || 10;
+ this.LayerControlOptions = params.LayerControlOptions || { enabled: false };
+ this.ZoomControlsOptions = params.ZoomControlsOptions || { enabled: false };
+ this.FullscreenControl = params.FullscreenControl || { enabled: false };
+ this.geojsons = params.geojsons || [];
+ this.images = params.images || [
+ {
+ src: "/static/images/location-pointer-sdf-256.png",
+ name: "custom-marker-256",
+ size: 256,
+ },
+ ];
+ this.paint_options = params.paint_options || null;
+ this.customStyleJson = "/static/javascripts/base-tile.json";
+ this.customStyleLayersToBringToFront = ["OS/Names/National/Country"];
+ this.useOAuth2 = params.useOAuth2 || false;
+ this.layers = params.layers || [];
+ this.featuresHoveringOver = 0;
+ }
+
+ getViewFromUrl() {
+ const urlObj = new URL(document.location);
+ const hash = urlObj.hash;
+ if (hash) {
+ const [lat, lng, zoom] = hash.substring(1).split(",");
+ return {
+ centre: [parseFloat(lng), parseFloat(lat)],
+ zoom: parseFloat(zoom),
+ };
+ }
+ return { centre: undefined, zoom: undefined };
+ }
+
+ async createMap() {
+ // Define the custom JSON style.
+ // More styles can be found at https://github.com/OrdnanceSurvey/OS-Vector-Tile-API-Stylesheets.
+
+ await getFreshApiToken();
+
+ const viewFromUrl = this.getViewFromUrl();
+
+ var map = new maplibregl.Map({
+ container: this.mapId,
+ minZoom: 5.5,
+ maxZoom: 18,
+ style: this.customStyleJson,
+ maxBounds: [
+ [-15, 49],
+ [13, 57],
+ ],
+ center: viewFromUrl.centre || [-1, 52.9],
+ zoom: viewFromUrl.zoom || 5.5,
+ transformRequest: (url, resourceType) => {
+ if (url.startsWith(this.datasetVectorUrl)) {
+ // Check if the request URL is for your tile server
+ const newUrl = new URL(url);
+ if (this.useOAuth2) {
+ return {
+ url: newUrl.toString(),
+ headers: { Authorization: "Bearer " + getApiToken() },
+ };
+ } else {
+ newUrl.searchParams.append("key", this.apiKey);
+ return { url: newUrl.toString() };
+ }
+ }
+ return { url };
+ },
+ });
+
+ map.getCanvas().ariaLabel = `${this.mapId}`;
+ this.map = map;
+
+ // once the maplibre map has loaded call the setup function
+ var boundSetup = this.setup.bind(this);
+ this.map.on("load", boundSetup);
+ }
+
+ async setup() {
+ console.log("setup");
+ try {
+ await this.loadImages(this.images);
+ } catch (e) {
+ console.log("error loading images: " + e);
+ }
+ console.log("past load images");
+ this.availableLayers = this.addVectorTileSources(this.vectorTileSources);
+ this.geojsonLayers = this.addGeojsonSources(this.geojsons);
+ if (this.geojsonLayers.length == 1) {
+ this.flyTo(this.geojsons[0]);
+ }
+ this.addControls();
+ this.addClickHandlers();
+ this.overwriteWheelEventsForControls();
+
+ const handleMapMove = () => {
+ const center = this.map.getCenter();
+ const zoom = this.map.getZoom();
+ const urlObj = new URL(document.location);
+ const newURL =
+ urlObj.origin +
+ urlObj.pathname +
+ urlObj.search +
+ `#${center.lat},${center.lng},${zoom}z`;
+ window.history.replaceState({}, "", newURL);
+ };
+ this.obscureScotland();
+ this.obscureWales();
+ this.addNeighbours();
+ this.map.on("moveend", handleMapMove);
+ }
+
+ loadImages(imageSrc = []) {
+ console.log("loading images" + imageSrc.length + " images");
+ return new Promise((resolve, reject) => {
+ const promiseArray = imageSrc.map(({ src, name }) => {
+ return new Promise((resolve, reject) => {
+ this.map.loadImage(src, (error, image) => {
+ if (error) {
+ console.log("error adding image: " + error);
+ reject(error);
+ }
+ console.log("added image");
+ this.map.addImage(name, image, { sdf: true });
+ resolve();
+ });
+ });
+ });
+ Promise.all(promiseArray)
+ .then(() => {
+ console.log("resolved");
+ resolve();
+ })
+ .catch((error) => {
+ console.log("rejected");
+ reject(error);
+ });
+ });
+ }
+
+ addVectorTileSources(vectorTileSources = []) {
+ let availableLayers = {};
+ // add vector tile sources to map
+ vectorTileSources.forEach((source) => {
+ let layers = this.addVectorTileSource(source);
+ availableLayers[source.name] = layers;
+ });
+ return availableLayers;
+ }
+
+ obscureWales() {
+ this.obscure("Wales_simplified", "#FFFFFF", 0.6);
+ }
+
+ obscureScotland() {
+ this.obscure("Scotland_simplified");
+ }
+
+ addNeighbours() {
+ this.obscure("UK_neighbours", "#FFFFFF", 0.9);
+ }
+
+ obscure(name, colour = "#FFFFFF", opacity = 0.8) {
+ this.map.addSource(name, {
+ type: "geojson",
+ data: `/static/javascripts/geojsons/${name}.json`,
+ buffer: 0,
+ });
+ const layerId = `${name}_Layer`;
+ this.map.addLayer({
+ id: layerId,
+ type: "fill",
+ source: name,
+ layout: {},
+ paint: {
+ "fill-color": colour,
+ "fill-opacity": opacity,
+ },
+ });
+ this.map.moveLayer(layerId, "OS/Names/National/Country");
+ }
+
+ addGeojsonSources(geojsons = []) {
+ // add geojsons sources to map
+ const addedLayers = [];
+ geojsons.forEach((geojson) => {
+ if (geojson.data.type == "Point")
+ addedLayers.push(this.addPoint(geojson, this.images[0]));
+ else if (["Polygon", "MultiPolygon"].includes(geojson.data.type))
+ addedLayers.push(this.addPolygon(geojson));
+ else throw new Error("Unsupported geometry type");
+ });
+ return addedLayers;
+ }
+
+ addControls() {
+ this.map.addControl(
+ new maplibregl.ScaleControl({
+ container: document.getElementById(this.mapId),
+ }),
+ "bottom-left"
+ );
+
+ if (this.FullscreenControl.enabled) {
+ this.map.addControl(
+ new maplibregl.FullscreenControl({
+ container: document.getElementById(this.mapId),
+ }),
+ "top-left"
+ );
+ }
+ this.map.addControl(new TiltControl(), "top-left");
+ this.map.addControl(
+ new maplibregl.NavigationControl({
+ container: document.getElementById(this.mapId),
+ }),
+ "top-left"
+ );
+
+ this.map.addControl(new CopyrightControl(), "bottom-right");
+
+ if (this.LayerControlOptions.enabled) {
+ this.layerControlsComponent = new LayerControls(
+ this,
+ this.sourceName,
+ this.layers,
+ this.availableLayers,
+ this.LayerControlOptions
+ );
+ this.map.addControl(this.layerControlsComponent, "top-right");
+ }
+ }
+
+ overwriteWheelEventsForControls() {
+ const mapEl = document.getElementById(this.mapId);
+ const mapControlsArray = mapEl.querySelectorAll(
+ ".maplibregl-control-container"
+ );
+ mapControlsArray.forEach((mapControls) =>
+ mapControls.addEventListener(
+ "wheel",
+ preventScroll([".dl-map__side-panel__content"]),
+ { passive: false }
+ )
+ );
+ }
+
+ addClickHandlers() {
+ if (this.layerControlsComponent) {
+ this.map.on("click", this.clickHandler.bind(this));
+ }
+ }
+
+ flyTo(geometry) {
+ if (geometry.data.type == "Point") {
+ this.map.flyTo({
+ center: geometry.data.coordinates,
+ essential: true,
+ animate: false,
+ zoom: 15,
+ });
+ } else {
+ var bbox = turf.extent(geometry.data);
+ this.map.fitBounds(bbox, { padding: 20, animate: false });
+ }
+ }
+
+ addLayer({
+ sourceName,
+ layerType,
+ paintOptions = {},
+ layoutOptions = {},
+ sourceLayer = "",
+ additionalOptions = {},
+ }) {
+ const layerName = `${sourceName}-${layerType}`;
+ this.map.addLayer({
+ id: layerName,
+ type: layerType,
+ source: sourceName,
+ "source-layer": sourceLayer,
+ paint: paintOptions,
+ layout: layoutOptions,
+ ...additionalOptions,
+ });
+
+ if (["fill", "fill-extrusion", "circle"].includes(layerType)) {
+ this.map.on("mouseover", layerName, () => {
+ this.map.getCanvas().style.cursor = "pointer";
+ this.featuresHoveringOver++;
+ });
+ this.map.on("mouseout", layerName, () => {
+ this.featuresHoveringOver--;
+ if (this.featuresHoveringOver == 0)
+ this.map.getCanvas().style.cursor = "";
+ });
+ }
+
+ return layerName;
+ }
+
+ addPolygon(geometry) {
+ this.map.addSource(geometry.name, {
+ type: "geojson",
+ data: {
+ type: "Feature",
+ geometry: geometry.data,
+ properties: {
+ entity: geometry.entity,
+ name: geometry.name,
+ },
+ },
+ });
+
+ let colour = "blue";
+ if (this.paint_options) colour = this.paint_options.colour;
+
+ let layer = this.addLayer({
+ sourceName: geometry.name,
+ layerType: "fill-extrusion",
+ paintOptions: {
+ "fill-extrusion-color": colour,
+ "fill-extrusion-opacity": 0.5,
+ "fill-extrusion-height": 1,
+ "fill-extrusion-base": 0,
+ },
+ });
+
+ this.moveLayerBehindBuildings(layer);
+
+ return layer;
+ }
+
+ moveLayerBehindBuildings(
+ layer,
+ buildingsLayer = "OS/TopographicArea_1/Building/1_3D"
+ ) {
+ try {
+ this.map.moveLayer(layer, buildingsLayer);
+ } catch (e) {
+ console.error(`Could not move layer behind ${buildingsLayer}: `, e);
+ }
+ }
+
+ addPoint(geometry, image = undefined) {
+ this.map.addSource(geometry.name, {
+ type: "geojson",
+ data: {
+ type: "Feature",
+ geometry: geometry.data,
+ properties: {
+ entity: geometry.entity,
+ name: geometry.name,
+ },
+ },
+ });
+
+ let iconColor = "blue";
+ if (this.paint_options) iconColor = this.paint_options.colour;
+
+ let layerName;
+ // if an image is provided use that otherwise use a circle
+ if (image) {
+ if (!this.map.hasImage(image.name)) {
+ throw new Error(
+ "Image not loaded, imageName: " + image.name + " not found"
+ );
+ }
+ layerName = this.addLayer({
+ sourceName: geometry.name,
+ layerType: "symbol",
+ paintOptions: {
+ "icon-color": iconColor,
+ "icon-opacity": 1,
+ },
+ layoutOptions: {
+ "icon-image": image.name,
+ "icon-size": (256 / image.size) * 0.15,
+ "icon-anchor": "bottom",
+ // get the year from the source's "year" property
+ "text-field": ["get", "year"],
+ "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
+ "text-offset": [0, 1.25],
+ "text-anchor": "top",
+ },
+ });
+ } else {
+ layerName = this.addLayer({
+ sourceName: geometry.name,
+ layerType: "circle",
+ paintOptions: {
+ "circle-color": iconColor,
+ "circle-radius": defaultPaintOptions["circle-radius"],
+ },
+ });
+ }
+ return layerName;
+ }
+
+ addVectorTileSource(source) {
+ // add source
+ this.map.addSource(`${source.name}-source`, {
+ type: "vector",
+ tiles: [source.vectorSource],
+ minzoom: this.minMapZoom,
+ maxzoom: this.maxMapZoom,
+ });
+
+ // add layer
+ let layers;
+ if (source.dataType === "point") {
+ let layerName = this.addLayer({
+ sourceName: `${source.name}-source`,
+ layerType: "circle",
+ paintOptions: {
+ "circle-color":
+ source.styleProps.colour || defaultPaintOptions["fill-color"],
+ "circle-opacity":
+ source.styleProps.opacity || defaultPaintOptions["fill-opacity"],
+ "circle-stroke-color":
+ source.styleProps.colour || defaultPaintOptions["fill-color"],
+ "circle-radius": defaultPaintOptions["circle-radius"],
+ },
+ sourceLayer: `${source.name}`,
+ });
+
+ layers = [layerName];
+ } else {
+ // create fill layer
+ let fillLayerName = this.addLayer({
+ sourceName: `${source.name}-source`,
+ layerType: "fill-extrusion",
+ paintOptions: {
+ "fill-extrusion-color":
+ source.styleProps.colour || defaultPaintOptions["fill-color"],
+ "fill-extrusion-height": 1,
+ "fill-extrusion-base": 0,
+ "fill-extrusion-opacity":
+ parseFloat(source.styleProps.opacity) ||
+ defaultPaintOptions["fill-opacity"],
+ },
+ sourceLayer: `${source.name}`,
+ });
+
+ this.moveLayerBehindBuildings(fillLayerName);
+
+ // create line layer
+ let lineLayerName = this.addLayer({
+ sourceName: `${source.name}-source`,
+ layerType: "line",
+ paintOptions: {
+ "line-color":
+ source.styleProps.colour || defaultPaintOptions["fill-color"],
+ "line-width":
+ source.styleProps.weight || defaultPaintOptions["weight"],
+ },
+ sourceLayer: `${source.name}`,
+ });
+
+ // create point layer for geometries
+ let pointLayerName = this.addLayer({
+ sourceName: `${source.name}-source`,
+ layerType: "circle",
+ paintOptions: {
+ "circle-color":
+ source.styleProps.colour || defaultPaintOptions["fill-color"],
+ "circle-opacity":
+ source.styleProps.opacity || defaultPaintOptions["fill-opacity"],
+ "circle-stroke-color":
+ source.styleProps.colour || defaultPaintOptions["fill-color"],
+ "circle-radius": defaultPaintOptions["circle-radius"],
+ },
+ sourceLayer: `${source.name}`,
+ additionalOptions: {
+ filter: ["==", ["geometry-type"], "Point"],
+ },
+ });
+ layers = [fillLayerName, lineLayerName, pointLayerName];
+ }
+ return layers;
+ }
+
+ clickHandler(e) {
+ var map = this.map;
+ var bbox = [
+ [e.point.x - 5, e.point.y - 5],
+ [e.point.x + 5, e.point.y + 5],
+ ];
+
+ let clickableLayers =
+ this.layerControlsComponent.getClickableLayers() || [];
+
+ var features = map.queryRenderedFeatures(bbox, {
+ layers: clickableLayers,
+ });
+ var coordinates = e.lngLat;
+
+ if (features.length) {
+ // no need to show popup if not clicking on feature
+ var popupDomElement = this.createFeaturesPopup(
+ this.removeDuplicates(features)
+ );
+ var popup = new maplibregl.Popup({
+ maxWidth: this.popupWidth,
+ })
+ .setLngLat(coordinates)
+ .setDOMContent(popupDomElement)
+ .addTo(map);
+ popup.getElement().onwheel = preventScroll([".app-popup-list"]);
+ }
+ }
+
+ // map.queryRenderedFeatures() can return duplicate features so we need to remove them
+ removeDuplicates(features) {
+ var uniqueEntities = [];
+
+ return features.filter(function (feature) {
+ if (uniqueEntities.indexOf(feature.properties.entity) === -1) {
+ uniqueEntities.push(feature.properties.entity);
+ return true;
+ }
+
+ return false;
+ });
+ }
+
+ createFeaturesPopup(features) {
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("app-popup");
+
+ const featureOrFeatures = features.length > 1 ? "features" : "feature";
+ const heading = document.createElement("h3");
+ heading.classList.add("app-popup-heading");
+ heading.textContent = `${features.length} ${featureOrFeatures} selected`;
+ wrapper.appendChild(heading);
+
+ if (features.length > this.popupMaxListLength) {
+ const tooMany = document.createElement("p");
+ tooMany.classList.add("govuk-body-s");
+ tooMany.textContent = `You clicked on ${features.length} features.`;
+ const tooMany2 = document.createElement("p");
+ tooMany2.classList.add("govuk-body-s");
+ tooMany2.textContent =
+ "Zoom in or turn off layers to narrow down your choice.";
+ wrapper.appendChild(tooMany);
+ wrapper.appendChild(tooMany2);
+ return wrapper;
+ }
+
+ const list = document.createElement("ul");
+ list.classList.add("app-popup-list");
+ features.forEach((feature) => {
+ const featureType = capitalizeFirstLetter(
+ feature.sourceLayer || feature.source
+ ).replaceAll("-", " ");
+ const fillColour = this.getFillColour(feature);
+
+ const featureName =
+ feature.properties.name || feature.properties.reference || "Not Named";
+ const item = document.createElement("li");
+ item.classList.add("app-popup-item");
+ item.style.borderLeft = `5px solid ${fillColour}`;
+
+ const secondaryText = document.createElement("p");
+ secondaryText.classList.add(
+ "app-u-secondary-text",
+ "govuk-!-margin-bottom-0",
+ "govuk-!-margin-top-0"
+ );
+ secondaryText.textContent = featureType;
+ item.appendChild(secondaryText);
+
+ const link = document.createElement("a");
+ link.classList.add("govuk-link");
+ link.href = `/entity/${feature.properties.entity}`;
+ link.textContent = featureName;
+ const smallText = document.createElement("p");
+ smallText.classList.add(
+ "dl-small-text",
+ "govuk-!-margin-top-0",
+ "govuk-!-margin-bottom-0"
+ );
+ smallText.appendChild(link);
+ item.appendChild(smallText);
+
+ list.appendChild(item);
+ });
+
+ wrapper.appendChild(list);
+ return wrapper;
+ }
+
+ getFillColour(feature) {
+ if (feature.layer.type === "symbol")
+ return this.map.getLayer(feature.layer.id).getPaintProperty("icon-color");
+ else if (feature.layer.type === "fill")
+ return this.map.getLayer(feature.layer.id).getPaintProperty("fill-color");
+ else if (feature.layer.type === "fill-extrusion")
+ return this.map
+ .getLayer(feature.layer.id)
+ .getPaintProperty("fill-extrusion-color");
+ else if (feature.layer.type === "circle")
+ return this.map
+ .getLayer(feature.layer.id)
+ .getPaintProperty("circle-color");
+ else
+ throw new Error(
+ "could not get fill colour for feature of type " + feature.layer.type
+ );
+ }
+
+ setLayerVisibility(layerName, visibility) {
+ this.map.setLayoutProperty(layerName, "visibility", visibility);
+ }
+}
diff --git a/assets/javascripts/utils.js b/assets/javascripts/utils.js
index a5e02401..3a21b1eb 100644
--- a/assets/javascripts/utils.js
+++ b/assets/javascripts/utils.js
@@ -1,59 +1,57 @@
-import MapController from './MapController.js';
-
-export const newMapController = (params = { layers: []}) => {
-
- const datasetUrl = params.DATASETTE_TILES_URL || '';
-
- let mapParams = {
- ...params,
- vectorSource: `${datasetUrl}/-/tiles/dataset_tiles/{z}/{x}/{y}.vector.pbf`,
- datasetVectorUrl: `${datasetUrl}/-/tiles/`,
- datasets: params.layers.map(d => d.dataset),
- sources: params.layers.map(d => {
- return {
- name: d.dataset + '-source',
- vectorSource: `${datasetUrl}/-/tiles/"${d.dataset}/{z}/{x}/{y}.vector.pbf`,
- }
- }),
- mapId: params.mapId || 'map',
- };
- return new MapController(mapParams);
-}
-
-export const capitalizeFirstLetter = (string) => {
- return string.charAt(0).toUpperCase() + string.slice(1);
-}
-
-export const convertNodeListToArray = (nl) => {
- return Array.prototype.slice.call(nl)
-}
-
-// Prevents scrolling of the page when the user triggers the wheel event on a div
-// while still allowing scrolling of any specified scrollable child elements.
-// Params:
-// scrollableChildElements: an array of class names of potential scrollable elements
-export const preventScroll = (scrollableChildElements = []) => {
- return (e) => {
- const closestClassName = scrollableChildElements.find((c) => {
- return e.target.closest(c) != null;
- });
-
- if(!closestClassName){
- e.preventDefault();
- return false
- }
-
- const list = e.target.closest(closestClassName);
-
- if(!list){
- e.preventDefault();
- return false
- }
-
- var verticalScroll = list.scrollHeight > list.clientHeight;
- if(!verticalScroll)
- e.preventDefault();
-
- return false;
- }
-}
+import MapController from "./MapController.js";
+
+export const newMapController = (params = { layers: [] }) => {
+ const datasetUrl = "https://www.development.digital-land.info";
+
+ let mapParams = {
+ ...params,
+ vectorSource: `${datasetUrl}/tiles/{z}/{x}/{y}.vector.pbf`,
+ datasetVectorUrl: `${datasetUrl}/`,
+ datasets: params.layers.map((d) => d.dataset),
+ sources: params.layers.map((d) => {
+ return {
+ name: d.dataset + "-source",
+ vectorSource: `${datasetUrl}/tiles/${d.dataset}/{z}/{x}/{y}.vector.pbf`,
+ };
+ }),
+ mapId: params.mapId || "map",
+ };
+ return new MapController(mapParams);
+};
+
+export const capitalizeFirstLetter = (string) => {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+};
+
+export const convertNodeListToArray = (nl) => {
+ return Array.prototype.slice.call(nl);
+};
+
+// Prevents scrolling of the page when the user triggers the wheel event on a div
+// while still allowing scrolling of any specified scrollable child elements.
+// Params:
+// scrollableChildElements: an array of class names of potential scrollable elements
+export const preventScroll = (scrollableChildElements = []) => {
+ return (e) => {
+ const closestClassName = scrollableChildElements.find((c) => {
+ return e.target.closest(c) != null;
+ });
+
+ if (!closestClassName) {
+ e.preventDefault();
+ return false;
+ }
+
+ const list = e.target.closest(closestClassName);
+
+ if (!list) {
+ e.preventDefault();
+ return false;
+ }
+
+ var verticalScroll = list.scrollHeight > list.clientHeight;
+ if (!verticalScroll) e.preventDefault();
+
+ return false;
+ };
+};
diff --git a/tests/integration/javascript/MapController.test.js b/tests/integration/javascript/MapController.test.js
index b2661c4e..00a9a7e5 100644
--- a/tests/integration/javascript/MapController.test.js
+++ b/tests/integration/javascript/MapController.test.js
@@ -1,25 +1,25 @@
// integration tests for the map controller
-import {describe, expect, test, vi, beforeEach} from 'vitest'
-import MapController from '../../../assets/javascripts/MapController'
-import TiltControl from '../../../assets/javascripts/TiltControl';
+import { describe, expect, test, vi, beforeEach } from "vitest";
+import MapController from "../../../assets/javascripts/MapController";
+import TiltControl from "../../../assets/javascripts/TiltControl";
import {
- getDomElementMock,
- getMapMock,
- getUrlDeleteMock,
- getUrlAppendMock,
- getPopupMock,
- stubGlobalMapLibre,
- stubGlobalWindow,
- stubGlobalUrl,
- stubGlobalDocument,
- stubGlobalFetch,
- waitForMapCreation
-} from '../../utils/mockUtils';
-import CopyrightControl from '../../../assets/javascripts/CopyrightControl';
+ getDomElementMock,
+ getMapMock,
+ getUrlDeleteMock,
+ getUrlAppendMock,
+ getPopupMock,
+ stubGlobalMapLibre,
+ stubGlobalWindow,
+ stubGlobalUrl,
+ stubGlobalDocument,
+ stubGlobalFetch,
+ waitForMapCreation,
+} from "../../utils/mockUtils";
+import CopyrightControl from "../../../assets/javascripts/CopyrightControl";
stubGlobalMapLibre();
-stubGlobalWindow('http://localhost', '')
+stubGlobalWindow("http://localhost", "");
const [urlDeleteMock, urlAppendMock] = stubGlobalUrl();
stubGlobalDocument();
stubGlobalFetch();
@@ -29,484 +29,548 @@ let mapMock = getMapMock();
let popupMock = getPopupMock();
beforeEach(() => {
- vi.clearAllMocks()
-})
-
-describe('Map Controller', () => {
- describe('Constructor', () => {
- test('Works as expected, applying default params', async () => {
- const mapController = new MapController({
- images: [
- {
- src: '/static/images/location-pointer-sdf.png',
- name: 'custom-marker',
- }
- ]
- })
- await waitForMapCreation(mapController)
- expect(mapController.map.events.load).toBeDefined()
-
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController).toBeDefined()
- expect(mapController.map).toBeDefined()
-
- expect(mapController.mapId).toEqual('mapid');
- expect(mapController.mapContainerSelector).toEqual('.dl-map__wrapper');
- expect(mapController.vectorTileSources).toEqual([]);
- expect(mapController.datasetVectorUrl).toEqual(null);
- expect(mapController.datasets).toEqual(null);
- expect(mapController.minMapZoom).toEqual(5);
- expect(mapController.maxMapZoom).toEqual(15);
- expect(mapController.baseURL).toEqual('https://digital-land.github.io');
- expect(mapController.baseTileStyleFilePath).toEqual('/static/javascripts/base-tile.json');
- expect(mapController.popupWidth).toEqual('260px');
- expect(mapController.popupMaxListLength).toEqual(10);
- expect(mapController.LayerControlOptions).toEqual({enabled: false});
- expect(mapController.ZoomControlsOptions).toEqual({enabled: false});
- expect(mapController.FullscreenControl).toEqual({enabled: false});
- expect(mapController.geojsons).toEqual([]);
- expect(mapController.images).toEqual([{src: '/static/images/location-pointer-sdf.png', name: 'custom-marker'}]);
- expect(mapController.paint_options).toEqual(null);
-
- expect(mapController.map.loadImage).toHaveBeenCalledOnce();
- expect(mapController.map.addImage).toHaveBeenCalledOnce();
- expect(mapController.map.loadImage).toHaveBeenCalledWith('/static/images/location-pointer-sdf.png', expect.any(Function));
- expect(mapController.map.addImage).toHaveBeenCalledWith('custom-marker', 'the Image', {sdf: true});
-
- expect(mapController.map.addControl).toHaveBeenCalledTimes(4);
- expect(mapController.map.addControl).toHaveBeenCalledWith(new maplibregl.ScaleControl, 'top-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new maplibregl.NavigationControl, 'top-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new TiltControl, 'top-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new CopyrightControl, 'bottom-right');
- })
-
- test('Works as expected, enabling full screen', async () => {
- const mapController = new MapController({
- FullscreenControl: {
- enabled: true,
- }
- })
- await waitForMapCreation(mapController)
- expect(mapController.map.events.load).toBeDefined()
-
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController).toBeDefined()
- expect(mapController.map).toBeDefined()
-
- expect(mapController.mapId).toEqual('mapid');
- expect(mapController.mapContainerSelector).toEqual('.dl-map__wrapper');
- expect(mapController.vectorTileSources).toEqual([]);
- expect(mapController.datasetVectorUrl).toEqual(null);
- expect(mapController.datasets).toEqual(null);
- expect(mapController.minMapZoom).toEqual(5);
- expect(mapController.maxMapZoom).toEqual(15);
- expect(mapController.baseURL).toEqual('https://digital-land.github.io');
- expect(mapController.baseTileStyleFilePath).toEqual('/static/javascripts/base-tile.json');
- expect(mapController.popupWidth).toEqual('260px');
- expect(mapController.popupMaxListLength).toEqual(10);
- expect(mapController.LayerControlOptions).toEqual({enabled: false});
- expect(mapController.ZoomControlsOptions).toEqual({enabled: false});
- expect(mapController.FullscreenControl).toEqual({enabled: true});
- expect(mapController.geojsons).toEqual([]);
- expect(mapController.images).toEqual([{src: '/static/images/location-pointer-sdf-256.png', name: 'custom-marker-256', size: 256}]);
- expect(mapController.paint_options).toEqual(null);
-
- expect(mapController.map.loadImage).toHaveBeenCalledOnce();
- expect(mapController.map.addImage).toHaveBeenCalledOnce();
- expect(mapController.map.loadImage).toHaveBeenCalledWith('/static/images/location-pointer-sdf-256.png', expect.any(Function));
- expect(mapController.map.addImage).toHaveBeenCalledWith('custom-marker-256', 'the Image', {sdf: true});
-
- expect(mapController.map.addControl).toHaveBeenCalledTimes(5);
- expect(mapController.map.addControl).toHaveBeenCalledWith(new maplibregl.ScaleControl, 'top-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new maplibregl.NavigationControl, 'top-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new maplibregl.FullscreenControl, 'bottom-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new TiltControl, 'top-left');
- expect(mapController.map.addControl).toHaveBeenCalledWith(new CopyrightControl, 'bottom-right');
- })
-
- test('Works with one geojson feature of type point', async () => {
- const params = {
- geojsons: [
- {
- name: 'testName',
- data: {
- type: 'Point',
- },
- entity: 'testEntity',
- }
- ],
- paint_options: {
- colour: '#0000ff',
- }
- }
-
- const mapController = new MapController(params)
- await waitForMapCreation(mapController)
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController.map.addSource).toHaveBeenCalledTimes(4);
- expect(mapController.map.addLayer).toHaveBeenCalledTimes(4);
- expect(mapController.map.addSource).toHaveBeenCalledWith(params.geojsons[0].name, {
- 'type': 'geojson',
- 'data': {
- 'type': 'Feature',
- 'geometry': params.geojsons[0].data,
- 'properties': {
- 'entity': params.geojsons[0].entity,
- 'name': params.geojsons[0].name,
- }
- }
- });
- const layerName = `${params.geojsons[0].name}-symbol`;
- expect(mapController.map.addLayer).toHaveBeenCalledWith({
- id: layerName,
- type: 'symbol',
- source: params.geojsons[0].name,
- 'source-layer': '',
- paint: {
- 'icon-color': params.paint_options.colour,
- 'icon-opacity': 1,
- },
- layout: {
- 'icon-image': 'custom-marker-256',
- 'icon-size': 0.15,
- 'icon-anchor': 'bottom',
- // get the year from the source's "year" property
- 'text-field': ['get', 'year'],
- 'text-font': [
- 'Open Sans Semibold',
- 'Arial Unicode MS Bold'
- ],
- 'text-offset': [0, 1.25],
- 'text-anchor': 'top'
- }
- })
- })
-
- test('Works with many geojson features of type point', async () => {
- const params = {
- geojsons: [
- {
- name: 'testName',
- data: {
- type: 'Point',
- }
- },
- {
- name: 'testName1',
- data: {
- type: 'Point',
- }
- },
- {
- name: 'testName2',
- data: {
- type: 'Point',
- }
- }
- ]
- }
-
- const mapController = new MapController(params)
- await waitForMapCreation(mapController)
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController.map.addSource).toHaveBeenCalledTimes(6);
- expect(mapController.map.addLayer).toHaveBeenCalledTimes(6);
-
- params.geojsons.forEach((geojson, index) => {
- expect(mapController.map.addSource).toHaveBeenCalledWith(params.geojsons[index].name, {
- 'type': 'geojson',
- 'data': {
- 'type': 'Feature',
- 'geometry': params.geojsons[index].data,
- 'properties': {
- 'entity': params.geojsons[index].entity,
- 'name': params.geojsons[index].name,
- }
- }
- });
- const layerName = `${params.geojsons[index].name}-symbol`;
- expect(mapController.map.addLayer).toHaveBeenCalledWith({
- id: layerName,
- type: 'symbol',
- source: params.geojsons[index].name,
- 'source-layer': '',
- paint: {
- 'icon-color': 'blue',
- 'icon-opacity': 1,
- },
- layout: {
- 'icon-image': 'custom-marker-256',
- 'icon-size': 0.15,
- 'icon-anchor': 'bottom',
- // get the year from the source's "year" property
- 'text-field': ['get', 'year'],
- 'text-font': [
- 'Open Sans Semibold',
- 'Arial Unicode MS Bold'
- ],
- 'text-offset': [0, 1.25],
- 'text-anchor': 'top'
- }
- })
- })
- })
-
- test('Works with many geojson features of type polygon/MultiPolygon', async () => {
- const params = {
- geojsons: [
- {
- name: 'testName',
- data: {
- type: 'Polygon',
- }
- },
- {
- name: 'testName1',
- data: {
- type: 'Polygon',
- }
- },
- {
- name: 'testName2',
- data: {
- type: 'MultiPolygon',
- }
- }
- ]
- }
-
- const mapController = new MapController(params)
- await waitForMapCreation(mapController)
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController.map.addSource).toHaveBeenCalledTimes(6);
- expect(mapController.map.addLayer).toHaveBeenCalledTimes(6);
-
- params.geojsons.forEach((geojson, index) => {
- expect(mapController.map.addSource).toHaveBeenCalledWith(params.geojsons[index].name, {
- 'type': 'geojson',
- 'data': {
- 'type': 'Feature',
- 'geometry': params.geojsons[index].data,
- 'properties': {
- 'entity': params.geojsons[index].entity,
- 'name': params.geojsons[index].name,
- }
- }
- });
- const layerName = `${params.geojsons[index].name}-fill-extrusion`;
- expect(mapController.map.addLayer).toHaveBeenCalledWith({
- id: layerName,
- type: 'fill-extrusion',
- source: params.geojsons[index].name,
- 'source-layer': '',
- paint: {
- 'fill-extrusion-color': 'blue',
- 'fill-extrusion-opacity': 0.5,
- 'fill-extrusion-height': 1,
- 'fill-extrusion-base': 0,
- },
- layout: {}
- })
- })
- })
-
- test('Works with many geojson features of type polygon with layer controls enabled', async () => {
- const params = {
- geojsons: [
- {
- name: 'testName',
- data: {
- type: 'Polygon',
- }
- },
- {
- name: 'testName1',
- data: {
- type: 'Polygon',
- }
- },
- {
- name: 'testName2',
- data: {
- type: 'MultiPolygon',
- }
- }
- ],
- LayerControlOptions: {
- enabled: true,
- },
- }
-
- const mapController = new MapController(params)
- await waitForMapCreation(mapController)
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController.layerControlsComponent).toBeDefined();
- })
-
- test('Works with a point vectorSource layer', async () => {
- const minMapZoom = 10;
- const maxMapZoom = 20;
- const params = {
- vectorTileSources: [
- {
- name: 'testName',
- vectorSource: 'testUrl',
- dataType: 'point',
- styleProps: {
- colour: '#0000ff',
- opacity: 0.5,
- }
- }
- ],
- LayerControlOptions: {
- enabled: true,
- },
- minMapZoom: minMapZoom,
- maxMapZoom: maxMapZoom,
- }
-
- const mapController = new MapController(params)
- await waitForMapCreation(mapController)
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController.map.addSource).toHaveBeenCalledTimes(4);
- expect(mapController.map.addLayer).toHaveBeenCalledTimes(4);
- expect(mapController.map.addSource).toHaveBeenCalledWith(params.vectorTileSources[0].name + '-source', {
- type: 'vector',
- tiles: [params.vectorTileSources[0].vectorSource],
- minzoom: minMapZoom,
- maxzoom: maxMapZoom
- });
- expect(mapController.map.addLayer).toHaveBeenCalledWith({
- id: `${params.vectorTileSources[0].name}-source-circle`,
- type: 'circle',
- source: `${params.vectorTileSources[0].name}-source`,
- 'source-layer': `${params.vectorTileSources[0].name}`,
- paint: {
- 'circle-color': params.vectorTileSources[0].styleProps.colour,
- 'circle-opacity': params.vectorTileSources[0].styleProps.opacity,
- 'circle-stroke-color': params.vectorTileSources[0].styleProps.colour,
- 'circle-radius': [
- "interpolate", ["linear"], ["zoom"],
- 6, 1,
- 14, 8,
- ],
- },
- layout: {}
- });
- expect(mapController.availableLayers).toEqual({
- [params.vectorTileSources[0].name]: [`${params.vectorTileSources[0].name}-source-circle`]
- })
- })
-
- test('Works with a polygon vectorSource layer', async () => {
- const minMapZoom = 10;
- const maxMapZoom = 20;
- const params = {
- vectorTileSources: [
- {
- name: 'testName',
- vectorSource: 'testUrl',
- dataType: 'polygon',
- styleProps: {
- colour: '#0000ff',
- opacity: 0.5,
- },
- },
- ],
- minMapZoom,
- maxMapZoom,
- LayerControlOptions: {
- enabled: true,
- }
- }
-
- const mapController = new MapController(params)
- await waitForMapCreation(mapController)
-
- await mapController.map.events.load() // initiate the load event
-
- expect(mapController.map.addSource).toHaveBeenCalledTimes(4);
- expect(mapController.map.addLayer).toHaveBeenCalledTimes(6);
- expect(mapController.map.addSource).toHaveBeenCalledWith(params.vectorTileSources[0].name + '-source', {
- type: 'vector',
- tiles: [params.vectorTileSources[0].vectorSource],
- minzoom: minMapZoom,
- maxzoom: maxMapZoom
- });
- expect(mapController.map.addLayer).toHaveBeenCalledWith({
- id: `${params.vectorTileSources[0].name}-source-fill-extrusion`,
- type: 'fill-extrusion',
- source: `${params.vectorTileSources[0].name}-source`,
- 'source-layer': `${params.vectorTileSources[0].name}`,
- paint: {
- 'fill-extrusion-color': params.vectorTileSources[0].styleProps.colour,
- 'fill-extrusion-opacity': params.vectorTileSources[0].styleProps.opacity,
- 'fill-extrusion-height': 1,
- 'fill-extrusion-base': 0,
- },
- layout: {}
- });
- expect(mapController.map.addLayer).toHaveBeenCalledWith({
- id: `${params.vectorTileSources[0].name}-source-line`,
- type: 'line',
- source: `${params.vectorTileSources[0].name}-source`,
- 'source-layer': `${params.vectorTileSources[0].name}`,
- paint: {
- 'line-color': params.vectorTileSources[0].styleProps.colour,
- 'line-width': 1,
- },
- layout: {}
- });
- expect(mapController.availableLayers).toEqual({
- [params.vectorTileSources[0].name]: [
- `${params.vectorTileSources[0].name}-source-fill-extrusion`,
- `${params.vectorTileSources[0].name}-source-line`,
- `${params.vectorTileSources[0].name}-source-circle`,
- ]
- })
- })
- })
-
- test('clickHandler works as expected', async () => {
- const mapController = new MapController({
- LayerControlOptions: {
- enabled: true,
+ vi.clearAllMocks();
+});
+
+describe("Map Controller", () => {
+ describe("Constructor", () => {
+ test("Works as expected, applying default params", async () => {
+ const mapController = new MapController({
+ images: [
+ {
+ src: "/static/images/location-pointer-sdf.png",
+ name: "custom-marker",
+ },
+ ],
+ });
+ await waitForMapCreation(mapController);
+ expect(mapController.map.events.load).toBeDefined();
+
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController).toBeDefined();
+ expect(mapController.map).toBeDefined();
+
+ expect(mapController.mapId).toEqual("mapid");
+ expect(mapController.mapContainerSelector).toEqual(".dl-map__wrapper");
+ expect(mapController.vectorTileSources).toEqual([]);
+ expect(mapController.datasetVectorUrl).toEqual("http://");
+ expect(mapController.datasets).toEqual(null);
+ expect(mapController.minMapZoom).toEqual(5);
+ expect(mapController.maxMapZoom).toEqual(15);
+ expect(mapController.baseURL).toEqual("https://digital-land.github.io");
+ expect(mapController.baseTileStyleFilePath).toEqual(
+ "/static/javascripts/base-tile.json"
+ );
+ expect(mapController.popupWidth).toEqual("260px");
+ expect(mapController.popupMaxListLength).toEqual(10);
+ expect(mapController.LayerControlOptions).toEqual({ enabled: false });
+ expect(mapController.ZoomControlsOptions).toEqual({ enabled: false });
+ expect(mapController.FullscreenControl).toEqual({ enabled: false });
+ expect(mapController.geojsons).toEqual([]);
+ expect(mapController.images).toEqual([
+ {
+ src: "/static/images/location-pointer-sdf.png",
+ name: "custom-marker",
+ },
+ ]);
+ expect(mapController.paint_options).toEqual(null);
+
+ expect(mapController.map.loadImage).toHaveBeenCalledOnce();
+ expect(mapController.map.addImage).toHaveBeenCalledOnce();
+ expect(mapController.map.loadImage).toHaveBeenCalledWith(
+ "/static/images/location-pointer-sdf.png",
+ expect.any(Function)
+ );
+ expect(mapController.map.addImage).toHaveBeenCalledWith(
+ "custom-marker",
+ "the Image",
+ { sdf: true }
+ );
+
+ expect(mapController.map.addControl).toHaveBeenCalledTimes(4);
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new maplibregl.ScaleControl(),
+ "top-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new maplibregl.NavigationControl(),
+ "top-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new TiltControl(),
+ "top-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new CopyrightControl(),
+ "bottom-right"
+ );
+ });
+
+ test("Works as expected, enabling full screen", async () => {
+ const mapController = new MapController({
+ FullscreenControl: {
+ enabled: true,
+ },
+ });
+ await waitForMapCreation(mapController);
+ expect(mapController.map.events.load).toBeDefined();
+
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController).toBeDefined();
+ expect(mapController.map).toBeDefined();
+
+ expect(mapController.mapId).toEqual("mapid");
+ expect(mapController.mapContainerSelector).toEqual(".dl-map__wrapper");
+ expect(mapController.vectorTileSources).toEqual([]);
+ expect(mapController.datasetVectorUrl).toEqual("http://");
+ expect(mapController.datasets).toEqual(null);
+ expect(mapController.minMapZoom).toEqual(5);
+ expect(mapController.maxMapZoom).toEqual(15);
+ expect(mapController.baseURL).toEqual("https://digital-land.github.io");
+ expect(mapController.baseTileStyleFilePath).toEqual(
+ "/static/javascripts/base-tile.json"
+ );
+ expect(mapController.popupWidth).toEqual("260px");
+ expect(mapController.popupMaxListLength).toEqual(10);
+ expect(mapController.LayerControlOptions).toEqual({ enabled: false });
+ expect(mapController.ZoomControlsOptions).toEqual({ enabled: false });
+ expect(mapController.FullscreenControl).toEqual({ enabled: true });
+ expect(mapController.geojsons).toEqual([]);
+ expect(mapController.images).toEqual([
+ {
+ src: "/static/images/location-pointer-sdf-256.png",
+ name: "custom-marker-256",
+ size: 256,
+ },
+ ]);
+ expect(mapController.paint_options).toEqual(null);
+
+ expect(mapController.map.loadImage).toHaveBeenCalledOnce();
+ expect(mapController.map.addImage).toHaveBeenCalledOnce();
+ expect(mapController.map.loadImage).toHaveBeenCalledWith(
+ "/static/images/location-pointer-sdf-256.png",
+ expect.any(Function)
+ );
+ expect(mapController.map.addImage).toHaveBeenCalledWith(
+ "custom-marker-256",
+ "the Image",
+ { sdf: true }
+ );
+
+ expect(mapController.map.addControl).toHaveBeenCalledTimes(5);
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new maplibregl.ScaleControl(),
+ "top-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new maplibregl.NavigationControl(),
+ "top-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new maplibregl.FullscreenControl(),
+ "bottom-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new TiltControl(),
+ "top-left"
+ );
+ expect(mapController.map.addControl).toHaveBeenCalledWith(
+ new CopyrightControl(),
+ "bottom-right"
+ );
+ });
+
+ test("Works with one geojson feature of type point", async () => {
+ const params = {
+ geojsons: [
+ {
+ name: "testName",
+ data: {
+ type: "Point",
},
- })
-
- await waitForMapCreation(mapController)
-
- await new Promise(resolve => setTimeout(resolve, 1000)) // wait for the map to load
-
- await mapController.map.events.load() // initiate the load event
-
- const mockClickEvent = {
- point: {
- x: 100,
- y: 100,
+ entity: "testEntity",
+ },
+ ],
+ paint_options: {
+ colour: "#0000ff",
+ },
+ };
+
+ const mapController = new MapController(params);
+ await waitForMapCreation(mapController);
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController.map.addSource).toHaveBeenCalledTimes(4);
+ expect(mapController.map.addLayer).toHaveBeenCalledTimes(4);
+ expect(mapController.map.addSource).toHaveBeenCalledWith(
+ params.geojsons[0].name,
+ {
+ type: "geojson",
+ data: {
+ type: "Feature",
+ geometry: params.geojsons[0].data,
+ properties: {
+ entity: params.geojsons[0].entity,
+ name: params.geojsons[0].name,
+ },
+ },
+ }
+ );
+ const layerName = `${params.geojsons[0].name}-symbol`;
+ expect(mapController.map.addLayer).toHaveBeenCalledWith({
+ id: layerName,
+ type: "symbol",
+ source: params.geojsons[0].name,
+ "source-layer": "",
+ paint: {
+ "icon-color": params.paint_options.colour,
+ "icon-opacity": 1,
+ },
+ layout: {
+ "icon-image": "custom-marker-256",
+ "icon-size": 0.15,
+ "icon-anchor": "bottom",
+ // get the year from the source's "year" property
+ "text-field": ["get", "year"],
+ "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
+ "text-offset": [0, 1.25],
+ "text-anchor": "top",
+ },
+ });
+ });
+
+ test("Works with many geojson features of type point", async () => {
+ const params = {
+ geojsons: [
+ {
+ name: "testName",
+ data: {
+ type: "Point",
+ },
+ },
+ {
+ name: "testName1",
+ data: {
+ type: "Point",
+ },
+ },
+ {
+ name: "testName2",
+ data: {
+ type: "Point",
+ },
+ },
+ ],
+ };
+
+ const mapController = new MapController(params);
+ await waitForMapCreation(mapController);
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController.map.addSource).toHaveBeenCalledTimes(6);
+ expect(mapController.map.addLayer).toHaveBeenCalledTimes(6);
+
+ params.geojsons.forEach((geojson, index) => {
+ expect(mapController.map.addSource).toHaveBeenCalledWith(
+ params.geojsons[index].name,
+ {
+ type: "geojson",
+ data: {
+ type: "Feature",
+ geometry: params.geojsons[index].data,
+ properties: {
+ entity: params.geojsons[index].entity,
+ name: params.geojsons[index].name,
+ },
+ },
+ }
+ );
+ const layerName = `${params.geojsons[index].name}-symbol`;
+ expect(mapController.map.addLayer).toHaveBeenCalledWith({
+ id: layerName,
+ type: "symbol",
+ source: params.geojsons[index].name,
+ "source-layer": "",
+ paint: {
+ "icon-color": "blue",
+ "icon-opacity": 1,
+ },
+ layout: {
+ "icon-image": "custom-marker-256",
+ "icon-size": 0.15,
+ "icon-anchor": "bottom",
+ // get the year from the source's "year" property
+ "text-field": ["get", "year"],
+ "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
+ "text-offset": [0, 1.25],
+ "text-anchor": "top",
+ },
+ });
+ });
+ });
+
+ test("Works with many geojson features of type polygon/MultiPolygon", async () => {
+ const params = {
+ geojsons: [
+ {
+ name: "testName",
+ data: {
+ type: "Polygon",
+ },
+ },
+ {
+ name: "testName1",
+ data: {
+ type: "Polygon",
},
- lngLat: {
- lng: 100,
- lat: 100,
+ },
+ {
+ name: "testName2",
+ data: {
+ type: "MultiPolygon",
},
+ },
+ ],
+ };
+
+ const mapController = new MapController(params);
+ await waitForMapCreation(mapController);
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController.map.addSource).toHaveBeenCalledTimes(6);
+ expect(mapController.map.addLayer).toHaveBeenCalledTimes(6);
+
+ params.geojsons.forEach((geojson, index) => {
+ expect(mapController.map.addSource).toHaveBeenCalledWith(
+ params.geojsons[index].name,
+ {
+ type: "geojson",
+ data: {
+ type: "Feature",
+ geometry: params.geojsons[index].data,
+ properties: {
+ entity: params.geojsons[index].entity,
+ name: params.geojsons[index].name,
+ },
+ },
+ }
+ );
+ const layerName = `${params.geojsons[index].name}-fill-extrusion`;
+ expect(mapController.map.addLayer).toHaveBeenCalledWith({
+ id: layerName,
+ type: "fill-extrusion",
+ source: params.geojsons[index].name,
+ "source-layer": "",
+ paint: {
+ "fill-extrusion-color": "blue",
+ "fill-extrusion-opacity": 0.5,
+ "fill-extrusion-height": 1,
+ "fill-extrusion-base": 0,
+ },
+ layout: {},
+ });
+ });
+ });
+
+ test("Works with many geojson features of type polygon with layer controls enabled", async () => {
+ const params = {
+ geojsons: [
+ {
+ name: "testName",
+ data: {
+ type: "Polygon",
+ },
+ },
+ {
+ name: "testName1",
+ data: {
+ type: "Polygon",
+ },
+ },
+ {
+ name: "testName2",
+ data: {
+ type: "MultiPolygon",
+ },
+ },
+ ],
+ LayerControlOptions: {
+ enabled: true,
+ },
+ };
+
+ const mapController = new MapController(params);
+ await waitForMapCreation(mapController);
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController.layerControlsComponent).toBeDefined();
+ });
+
+ test("Works with a point vectorSource layer", async () => {
+ const minMapZoom = 10;
+ const maxMapZoom = 20;
+ const params = {
+ vectorTileSources: [
+ {
+ name: "testName",
+ vectorSource: "testUrl",
+ dataType: "point",
+ styleProps: {
+ colour: "#0000ff",
+ opacity: 0.5,
+ },
+ },
+ ],
+ LayerControlOptions: {
+ enabled: true,
+ },
+ minMapZoom: minMapZoom,
+ maxMapZoom: maxMapZoom,
+ };
+
+ const mapController = new MapController(params);
+ await waitForMapCreation(mapController);
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController.map.addSource).toHaveBeenCalledTimes(4);
+ expect(mapController.map.addLayer).toHaveBeenCalledTimes(4);
+ expect(mapController.map.addSource).toHaveBeenCalledWith(
+ params.vectorTileSources[0].name + "-source",
+ {
+ type: "vector",
+ tiles: [params.vectorTileSources[0].vectorSource],
+ minzoom: minMapZoom,
+ maxzoom: maxMapZoom,
}
-
- mapController.clickHandler(mockClickEvent);
-
- expect(maplibregl.Popup).toHaveBeenCalledOnce();
- expect(popupMock.setLngLat).toHaveBeenCalledOnce();
- expect(popupMock.setDOMContent).toHaveBeenCalledOnce();
- expect(popupMock.addTo).toHaveBeenCalledOnce();
- expect(popupMock.setLngLat).toHaveBeenCalledWith(mockClickEvent.lngLat);
-
- expect(popupMock.setDOMContent).toHaveBeenCalledOnce();
- expect(popupMock.addTo).toHaveBeenCalledWith(mapController.map);
- })
-})
+ );
+ expect(mapController.map.addLayer).toHaveBeenCalledWith({
+ id: `${params.vectorTileSources[0].name}-source-circle`,
+ type: "circle",
+ source: `${params.vectorTileSources[0].name}-source`,
+ "source-layer": `${params.vectorTileSources[0].name}`,
+ paint: {
+ "circle-color": params.vectorTileSources[0].styleProps.colour,
+ "circle-opacity": params.vectorTileSources[0].styleProps.opacity,
+ "circle-stroke-color": params.vectorTileSources[0].styleProps.colour,
+ "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 1, 14, 8],
+ },
+ layout: {},
+ });
+ expect(mapController.availableLayers).toEqual({
+ [params.vectorTileSources[0].name]: [
+ `${params.vectorTileSources[0].name}-source-circle`,
+ ],
+ });
+ });
+
+ test("Works with a polygon vectorSource layer", async () => {
+ const minMapZoom = 10;
+ const maxMapZoom = 20;
+ const params = {
+ vectorTileSources: [
+ {
+ name: "testName",
+ vectorSource: "testUrl",
+ dataType: "polygon",
+ styleProps: {
+ colour: "#0000ff",
+ opacity: 0.5,
+ },
+ },
+ ],
+ minMapZoom,
+ maxMapZoom,
+ LayerControlOptions: {
+ enabled: true,
+ },
+ };
+
+ const mapController = new MapController(params);
+ await waitForMapCreation(mapController);
+
+ await mapController.map.events.load(); // initiate the load event
+
+ expect(mapController.map.addSource).toHaveBeenCalledTimes(4);
+ expect(mapController.map.addLayer).toHaveBeenCalledTimes(6);
+ expect(mapController.map.addSource).toHaveBeenCalledWith(
+ params.vectorTileSources[0].name + "-source",
+ {
+ type: "vector",
+ tiles: [params.vectorTileSources[0].vectorSource],
+ minzoom: minMapZoom,
+ maxzoom: maxMapZoom,
+ }
+ );
+ expect(mapController.map.addLayer).toHaveBeenCalledWith({
+ id: `${params.vectorTileSources[0].name}-source-fill-extrusion`,
+ type: "fill-extrusion",
+ source: `${params.vectorTileSources[0].name}-source`,
+ "source-layer": `${params.vectorTileSources[0].name}`,
+ paint: {
+ "fill-extrusion-color": params.vectorTileSources[0].styleProps.colour,
+ "fill-extrusion-opacity":
+ params.vectorTileSources[0].styleProps.opacity,
+ "fill-extrusion-height": 1,
+ "fill-extrusion-base": 0,
+ },
+ layout: {},
+ });
+ expect(mapController.map.addLayer).toHaveBeenCalledWith({
+ id: `${params.vectorTileSources[0].name}-source-line`,
+ type: "line",
+ source: `${params.vectorTileSources[0].name}-source`,
+ "source-layer": `${params.vectorTileSources[0].name}`,
+ paint: {
+ "line-color": params.vectorTileSources[0].styleProps.colour,
+ "line-width": 1,
+ },
+ layout: {},
+ });
+ expect(mapController.availableLayers).toEqual({
+ [params.vectorTileSources[0].name]: [
+ `${params.vectorTileSources[0].name}-source-fill-extrusion`,
+ `${params.vectorTileSources[0].name}-source-line`,
+ `${params.vectorTileSources[0].name}-source-circle`,
+ ],
+ });
+ });
+ });
+
+ test("clickHandler works as expected", async () => {
+ const mapController = new MapController({
+ LayerControlOptions: {
+ enabled: true,
+ },
+ });
+
+ await waitForMapCreation(mapController);
+
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for the map to load
+
+ await mapController.map.events.load(); // initiate the load event
+
+ const mockClickEvent = {
+ point: {
+ x: 100,
+ y: 100,
+ },
+ lngLat: {
+ lng: 100,
+ lat: 100,
+ },
+ };
+
+ mapController.clickHandler(mockClickEvent);
+
+ expect(maplibregl.Popup).toHaveBeenCalledOnce();
+ expect(popupMock.setLngLat).toHaveBeenCalledOnce();
+ expect(popupMock.setDOMContent).toHaveBeenCalledOnce();
+ expect(popupMock.addTo).toHaveBeenCalledOnce();
+ expect(popupMock.setLngLat).toHaveBeenCalledWith(mockClickEvent.lngLat);
+
+ expect(popupMock.setDOMContent).toHaveBeenCalledOnce();
+ expect(popupMock.addTo).toHaveBeenCalledWith(mapController.map);
+ });
+});
diff --git a/tests/unit/routers/test_tiles.py b/tests/unit/routers/test_tiles.py
new file mode 100644
index 00000000..f0b8c104
--- /dev/null
+++ b/tests/unit/routers/test_tiles.py
@@ -0,0 +1,96 @@
+# import pytest
+# from unittest.mock import patch, AsyncMock
+# from fastapi import HTTPException
+# from fastapi.responses import StreamingResponse
+# from sqlalchemy.orm import Session
+# from sqlalchemy.future import select
+
+# from application.routers.tiles import read_tiles_from_postgres, tile_is_valid
+# from application.db.models import EntityOrm
+# from application.db.session import get_session
+
+# # Constants for Testing
+# VALID_TILE_INFO = {
+# "x": 512,
+# "y": 512,
+# "zoom": 10,
+# "format": "pbf",
+# "dataset": "example-dataset",
+# }
+# INVALID_TILE_INFO = {
+# "x": -1,
+# "y": 512,
+# "zoom": 10,
+# "format": "jpg",
+# "dataset": "example-dataset",
+# }
+
+
+# @pytest.fixture
+# def valid_tile():
+# return VALID_TILE_INFO.copy()
+
+
+# @pytest.fixture
+# def invalid_tile():
+# return INVALID_TILE_INFO.copy()
+
+
+# @pytest.fixture
+# def mock_session_maker():
+# with patch("db.session._get_fastapi_sessionmaker") as mock:
+# mock.return_value = AsyncMock(get_db=AsyncMock())
+# yield mock
+
+
+# def test_tile_is_valid(valid_tile):
+# assert tile_is_valid(valid_tile), "Tile should be valid with correct parameters"
+
+
+# def test_tile_is_invalid(invalid_tile):
+# assert not tile_is_valid(
+# invalid_tile
+# ), "Tile should be invalid with incorrect parameters"
+
+
+# @pytest.mark.asyncio
+# @patch("application.routers.tiles.build_db_query", return_value=b"sample_pbf_data")
+# async def test_read_tiles_from_postgres_valid_tile(
+# mock_build_db_query, valid_tile, mock_session_maker
+# ):
+# session = (
+# mock_session_maker.return_value.get_db.return_value.__aenter__.return_value
+# )
+# response = await read_tiles_from_postgres(
+# valid_tile["dataset"],
+# valid_tile["zoom"],
+# valid_tile["x"],
+# valid_tile["y"],
+# valid_tile["format"],
+# session,
+# )
+
+# assert isinstance(response, StreamingResponse), "Should return a StreamingResponse"
+# assert (
+# response.status_code == 200
+# ), "Response status should be 200 for valid requests"
+# mock_build_db_query.assert_called_once_with(valid_tile, session)
+
+
+# @pytest.mark.asyncio
+# async def test_read_tiles_from_postgres_invalid_tile(invalid_tile, mock_session_maker):
+# session = (
+# mock_session_maker.return_value.get_db.return_value.__aenter__.return_value
+# )
+# with pytest.raises(HTTPException) as excinfo:
+# await read_tiles_from_postgres(
+# invalid_tile["dataset"],
+# invalid_tile["zoom"],
+# invalid_tile["x"],
+# invalid_tile["y"],
+# invalid_tile["format"],
+# session,
+# )
+# assert (
+# excinfo.value.status_code == 400
+# ), "Should raise HTTP 400 for invalid tile parameters"