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"