From bfff7a2398651696cc19aead30cc821bf1a76e47 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Tue, 21 Apr 2026 10:43:40 -0400 Subject: [PATCH 1/5] Add summary routes for on-load optimizations --- tilemaker/client/simple.py | 1 + tilemaker/metadata/definitions.py | 40 +++++- tilemaker/metadata/generation.py | 6 +- tilemaker/server/app.py | 6 + tilemaker/server/bands.py | 38 ++++++ tilemaker/server/layers.py | 195 ++++++++++++++++++++++++++++++ tilemaker/server/map_groups.py | 57 +++++++++ tilemaker/server/maps.py | 173 ++++---------------------- 8 files changed, 359 insertions(+), 157 deletions(-) create mode 100644 tilemaker/server/bands.py create mode 100644 tilemaker/server/layers.py create mode 100644 tilemaker/server/map_groups.py diff --git a/tilemaker/client/simple.py b/tilemaker/client/simple.py index 8078d5b..8d90306 100644 --- a/tilemaker/client/simple.py +++ b/tilemaker/client/simple.py @@ -15,6 +15,7 @@ def create_sample_metadata(filename: str): return [ MapGroup( + map_group_id="example-map-group", name="Map Group", description="Example", maps=[ diff --git a/tilemaker/metadata/definitions.py b/tilemaker/metadata/definitions.py index 6da2f31..759418a 100644 --- a/tilemaker/metadata/definitions.py +++ b/tilemaker/metadata/definitions.py @@ -81,11 +81,13 @@ def auth(self, grants: set[str]): return self.grant is None or self.grant in grants -class Layer(AuthenticatedModel): +class LayerSummary(AuthenticatedModel): layer_id: str name: str description: str | None = None + +class Layer(LayerSummary): provider: FITSLayerProvider | FITSCombinationLayerProvider bounding_left: float | None = None @@ -115,26 +117,52 @@ def model_post_init(self, _): self.tile_size, self.number_of_levels = self.provider.calculate_tile_size() -class Band(AuthenticatedModel): +class LayerDefault(AuthenticatedModel): + map_group_id: str + map_id: str + band_id: str + layer: Layer + + +class BandBase(AuthenticatedModel): band_id: str name: str description: str + +class BandSummary(BandBase): + layer_ids: list[str] + + +class Band(BandBase): layers: list[Layer] -class Map(AuthenticatedModel): +class MapBase(AuthenticatedModel): map_id: str name: str description: str + +class MapSummary(MapBase): + band_ids: list[str] + + +class Map(MapBase): bands: list[Band] -class MapGroup(AuthenticatedModel): - name: str - description: str +class MapGroupBase(AuthenticatedModel): + map_group_id: str + name: str + description: str + + +class MapGroupSummary(MapGroupBase): + map_ids: list[str] + +class MapGroup(MapGroupBase): maps: list[Map] def get_layer(self, layer_id: str) -> Layer | None: diff --git a/tilemaker/metadata/generation.py b/tilemaker/metadata/generation.py index 5567253..92cecb7 100644 --- a/tilemaker/metadata/generation.py +++ b/tilemaker/metadata/generation.py @@ -3,6 +3,7 @@ """ import os +import uuid from hashlib import md5 from pathlib import Path from typing import Any, Literal @@ -76,7 +77,10 @@ def map_group_from_fits( ) return MapGroup( - name="Auto-Populated", description="No description provided", maps=maps + map_group_id=f"map-group-{uuid.uuid4()}", + name="Auto-Populated", + description="No description provided", + maps=maps ) diff --git a/tilemaker/server/app.py b/tilemaker/server/app.py index 4165d17..672f571 100644 --- a/tilemaker/server/app.py +++ b/tilemaker/server/app.py @@ -14,7 +14,10 @@ from .auth import setup_auth from .highlights import highlights_router from .histogram import histogram_router +from .map_groups import map_groups_router from .maps import maps_router +from .bands import bands_router +from .layers import layers_router from .sources import sources_router @@ -61,7 +64,10 @@ async def lifespan(app: FastAPI): app.include_router(highlights_router) app.include_router(histogram_router) app.include_router(sources_router) +app.include_router(map_groups_router) app.include_router(maps_router) +app.include_router(bands_router) +app.include_router(layers_router) app.include_router(analysis_router) if settings.serve_frontend: diff --git a/tilemaker/server/bands.py b/tilemaker/server/bands.py new file mode 100644 index 0000000..4e00a16 --- /dev/null +++ b/tilemaker/server/bands.py @@ -0,0 +1,38 @@ +""" +Endpoint for summary data of a band's layers +""" + +from tilemaker.metadata.definitions import BandSummary, LayerSummary + +from fastapi import ( + APIRouter, + Request, +) + +bands_router = APIRouter(prefix="/bands", tags=["List of Bands"]) + + +@bands_router.get( + "/{band_id}/layers", + response_model=list[LayerSummary], + summary="Get the list of layer summaries associated with a Band.", + description="Retrieve a list of LayerSummary objects that belong to a particular Band." +) +def get_layer_summaries_of_band( + band_id: str, + request: Request +): + for map_group in request.app.config.map_groups: + for map in map_group.maps: + for band in map.bands: + if (band.band_id == band_id and map_group.auth(request.auth.scopes)): + layer_summaries = [] + for layer in band.layers: + layer_summary = LayerSummary( + layer_id=layer.layer_id, + name=layer.name, + description=layer.description, + ) + layer_summaries.append(layer_summary) + return layer_summaries + return [] \ No newline at end of file diff --git a/tilemaker/server/layers.py b/tilemaker/server/layers.py new file mode 100644 index 0000000..59eb947 --- /dev/null +++ b/tilemaker/server/layers.py @@ -0,0 +1,195 @@ +""" +Endpoint for layer and tile data +""" +import io +from typing import Literal + +from astropy.io import fits +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + HTTPException, + Request, + Response, +) + +from tilemaker.metadata.definitions import Layer, LayerDefault +from tilemaker.processing.extractor import extract +from tilemaker.providers.fits import PullableTile + +from ..processing.renderer import Renderer, RenderOptions + +renderer = Renderer(format="webp") + +layers_router = APIRouter(prefix="/layers", tags=["Layers and Tiles"]) + + +@layers_router.get( + "/default", + response_model=LayerDefault, + summary="Get the hierarchy and layer data for a default layer.", + description="Gets layer data needed for the menu and map when first loaded." +) +def get_default_layer(request: Request): + for map_group in request.app.config.map_groups: + for map in map_group.maps: + for band in map.bands: + for layer in band.layers: + default_layer = LayerDefault( + map_group_id=map_group.map_group_id, + map_id=map.map_id, + band_id=band.band_id, + layer=layer, + ) + return default_layer + + +@layers_router.get( + "/{layer_id}", + response_model=Layer, + summary="Get the Layer data.", + description="Retrieve the Layer data to be rendered in the mapping client." +) +def get_layer_summaries_of_band( + layer_id: str, + request: Request +): + for map_group in request.app.config.map_groups: + for map in map_group.maps: + for band in map.bands: + for layer in band.layers: + if (layer.layer_id == layer_id and map_group.auth(request.auth.scopes)): + return layer + + +@layers_router.get( + "/{layer_id}/submap/{left}/{right}/{top}/{bottom}/image.{ext}", + summary="Generate a cut-out of the map.", + description="Download and extract (from the base map) a rendered cut-out. Downloads at the full resolution of the underlying map with no additional filter function.", +) +def get_submap( + layer_id: str, + left: float, + right: float, + top: float, + bottom: float, + ext: Literal["jpg", "webp", "png", "fits"], + request: Request, + bt: BackgroundTasks, + render_options: RenderOptions = Depends(RenderOptions), + show_grid: bool = False, +): + """ + Get a submap of the specified band. + """ + + submap, pushables = extract( + layer_id=layer_id, + left=left, + right=right, + top=top, + bottom=bottom, + tiles=request.app.tiles, + grants=request.auth.scopes, + metadata=request.app.config, + show_grid=show_grid, + ) + + bt.add_task(request.app.tiles.push, pushables) + + if ext == "jpg": + with io.BytesIO() as output: + renderer.render(output, submap, render_options=render_options) + return Response(content=output.getvalue(), media_type="image/jpg") + elif ext == "webp": + with io.BytesIO() as output: + renderer.render(output, submap, render_options=render_options) + return Response(content=output.getvalue(), media_type="image/webp") + elif ext == "png": + with io.BytesIO() as output: + renderer.render(output, submap, render_options=render_options) + return Response(content=output.getvalue(), media_type="image/png") + elif ext == "fits": + with io.BytesIO() as output: + hdu = fits.PrimaryHDU(submap) + hdu.writeto(output) + return Response(content=output.getvalue(), media_type="image/fits") + + +def core_tile_retrieval( + layer_id: str, + level: int, + y: int, + x: int, + bt: BackgroundTasks, + request: Request, +): + tile, pushables = request.app.tiles.pull( + PullableTile(layer_id=layer_id, x=x, y=y, level=level, grants=request.auth.scopes) + ) + + bt.add_task(request.app.tiles.push, pushables) + + return tile.data + + +@layers_router.get( + "/{layer_id}/{level}/{y}/{x}/tile.{ext}", + summary="Retrieve an individual tile.", + description="Individual tiles are hosted at a layer level, with them having three axes: `level`, `y`, and `x`. We support extensions of 'png', 'webp', and 'jpg'.", +) +def get_tile( + layer_id: str, + level: int, + y: int, + x: int, + ext: str, + request: Request, + bt: BackgroundTasks, + render_options: RenderOptions = Depends(RenderOptions), +): + """ + Grab an individual tile. This should be very fast, because we use + a composite primary key for band, level, x, and y. + + Supported extensions: + - jpg + - webp + - png + + Note: This does not support FITS tiles, as they are not + typically used for rendering. If you need FITS images, please + use the `/maps/{map}/{band}/submap/{left}/{right}/{top}/{bottom}/image.fits` + endpoint instead. + """ + + if render_options.flip: + # Flipping is really a reconfiguration of -180 < RA < 180 to 360 < RA < 0; + # it's a card-folding operation. + if level != 0: + # Level of zero requires no flipping apart from at the tile level. + midpoint = 2 ** (level) + if x < midpoint: + x = (2 ** (level) - 1) - x + else: + x = (2 ** (level) - 1) - (x - midpoint) + midpoint + + if ext not in ["jpg", "webp", "png"]: + raise HTTPException(status_code=400, detail="Not an acceptable extension") + + numpy_buf = core_tile_retrieval( + layer=layer_id, + level=level, + y=y, + x=x, + request=request, + bt=bt, + ) + + if numpy_buf is None: + raise HTTPException(status_code=404, detail="Tile not found") + + with io.BytesIO() as output: + renderer.render(output, numpy_buf, render_options=render_options) + return Response(content=output.getvalue(), media_type=f"image/{ext}") \ No newline at end of file diff --git a/tilemaker/server/map_groups.py b/tilemaker/server/map_groups.py new file mode 100644 index 0000000..756177a --- /dev/null +++ b/tilemaker/server/map_groups.py @@ -0,0 +1,57 @@ +""" +Endpoints for getting list of map group summaries and a list of a map group's map summaries. +""" + +from tilemaker.metadata.definitions import MapGroupSummary, MapSummary + +from fastapi import ( + APIRouter, + Request, +) + +map_groups_router = APIRouter(prefix="/map-groups", tags=["List of Map Groups"]) + + +@map_groups_router.get( + "", + response_model=list[MapGroupSummary], + summary="Get the list of map group summaries.", + description="Retrieve a list of MapGroupSummary objects." +) +def get_map_group_summaries(request: Request): + map_group_summaries = [] + for x in request.app.config.map_groups: + if (x.auth(request.auth.scopes)): + map_group_summary = MapGroupSummary( + map_group_id=x.map_group_id, + name=x.name, + description=x.description, + map_ids=[map.map_id for map in x.maps] + ) + map_group_summaries.append(map_group_summary) + + return map_group_summaries + + +@map_groups_router.get( + "/{map_group_id}/maps", + response_model=list[MapSummary], + summary="Get the list of map summaries associated with a Map Group.", + description="Retrieve a list of MapSummary objects that belong to a particular Map Group." +) +def get_map_summaries_of_map_group( + map_group_id: str, + request: Request +): + map_summaries = [] + for map_group in request.app.config.map_groups: + if (map_group.map_group_id == map_group_id and map_group.auth(request.auth.scopes)): + for map in map_group.maps: + map_summary = MapSummary( + map_id=map.map_id, + name=map.name, + description=map.description, + band_ids=[band.band_id for band in map.bands] + ) + map_summaries.append(map_summary) + return map_summaries \ No newline at end of file diff --git a/tilemaker/server/maps.py b/tilemaker/server/maps.py index e56df78..3d3322a 100644 --- a/tilemaker/server/maps.py +++ b/tilemaker/server/maps.py @@ -5,164 +5,37 @@ import io from typing import Literal -from astropy.io import fits from fastapi import ( APIRouter, - BackgroundTasks, - Depends, - HTTPException, - Request, - Response, + Request ) -from tilemaker.metadata.definitions import MapGroup -from tilemaker.processing.extractor import extract -from tilemaker.providers.fits import PullableTile - -from ..processing.renderer import Renderer, RenderOptions - -renderer = Renderer(format="webp") +from tilemaker.metadata.definitions import BandSummary maps_router = APIRouter(prefix="/maps", tags=["Maps and Tiles"]) @maps_router.get( - "", - response_model=list[MapGroup], - summary="Get the list of map groups.", - description="Retrieve a list of MapGroup shaped objects, each containing a list of Maps, with a list of Bands, and finally a list of Layers.", -) -def get_maps(request: Request): - return [x for x in request.app.config.map_groups if x.auth(request.auth.scopes)] - - -@maps_router.get( - "/{layer_id}/submap/{left}/{right}/{top}/{bottom}/image.{ext}", - summary="Generate a cut-out of the map.", - description="Download and extract (from the base map) a rendered cut-out. Downloads at the full resolution of the underlying map with no additional filter function.", -) -def get_submap( - layer_id: str, - left: float, - right: float, - top: float, - bottom: float, - ext: Literal["jpg", "webp", "png", "fits"], - request: Request, - bt: BackgroundTasks, - render_options: RenderOptions = Depends(RenderOptions), - show_grid: bool = False, -): - """ - Get a submap of the specified band. - """ - - submap, pushables = extract( - layer_id=layer_id, - left=left, - right=right, - top=top, - bottom=bottom, - tiles=request.app.tiles, - grants=request.auth.scopes, - metadata=request.app.config, - show_grid=show_grid, - ) - - bt.add_task(request.app.tiles.push, pushables) - - if ext == "jpg": - with io.BytesIO() as output: - renderer.render(output, submap, render_options=render_options) - return Response(content=output.getvalue(), media_type="image/jpg") - elif ext == "webp": - with io.BytesIO() as output: - renderer.render(output, submap, render_options=render_options) - return Response(content=output.getvalue(), media_type="image/webp") - elif ext == "png": - with io.BytesIO() as output: - renderer.render(output, submap, render_options=render_options) - return Response(content=output.getvalue(), media_type="image/png") - elif ext == "fits": - with io.BytesIO() as output: - hdu = fits.PrimaryHDU(submap) - hdu.writeto(output) - return Response(content=output.getvalue(), media_type="image/fits") - - -def core_tile_retrieval( - layer: str, - level: int, - y: int, - x: int, - bt: BackgroundTasks, - request: Request, -): - tile, pushables = request.app.tiles.pull( - PullableTile(layer_id=layer, x=x, y=y, level=level, grants=request.auth.scopes) - ) - - bt.add_task(request.app.tiles.push, pushables) - - return tile.data - - -@maps_router.get( - "/{layer}/{level}/{y}/{x}/tile.{ext}", - summary="Retrieve an individual tile.", - description="Individual tiles are hosted at a layer level, with them having three axes: `level`, `y`, and `x`. We support extensions of 'png', 'webp', and 'jpg'.", + "/{map_id}/bands", + response_model=list[BandSummary], + summary="Get the list of band summaries associated with a Map.", + description="Retrieve a list of BandSummary objects that belong to a particular Map." ) -def get_tile( - layer: str, - level: int, - y: int, - x: int, - ext: str, - request: Request, - bt: BackgroundTasks, - render_options: RenderOptions = Depends(RenderOptions), +def get_layer_summaries_of_band( + map_id: str, + request: Request ): - """ - Grab an individual tile. This should be very fast, because we use - a composite primary key for band, level, x, and y. - - Supported extensions: - - jpg - - webp - - png - - Note: This does not support FITS tiles, as they are not - typically used for rendering. If you need FITS images, please - use the `/maps/{map}/{band}/submap/{left}/{right}/{top}/{bottom}/image.fits` - endpoint instead. - """ - - if render_options.flip: - # Flipping is really a reconfiguration of -180 < RA < 180 to 360 < RA < 0; - # it's a card-folding operation. - if level != 0: - # Level of zero requires no flipping apart from at the tile level. - midpoint = 2 ** (level) - if x < midpoint: - x = (2 ** (level) - 1) - x - else: - x = (2 ** (level) - 1) - (x - midpoint) + midpoint - - if ext not in ["jpg", "webp", "png"]: - raise HTTPException(status_code=400, detail="Not an acceptable extension") - - numpy_buf = core_tile_retrieval( - layer=layer, - level=level, - y=y, - x=x, - request=request, - bt=bt, - ) - - if numpy_buf is None: - raise HTTPException(status_code=404, detail="Tile not found") - - with io.BytesIO() as output: - renderer.render(output, numpy_buf, render_options=render_options) - return Response(content=output.getvalue(), media_type=f"image/{ext}") + for map_group in request.app.config.map_groups: + for map in map_group.maps: + if (map.map_id == map_id and map_group.auth(request.auth.scopes)): + for band in map.bands: + band_summaries = [] + band_summary = BandSummary( + band_id=band.band_id, + name=band.name, + description=band.description, + layer_ids=[layer.layer_id for layer in band.layers] + ) + band_summaries.append(band_summary) + return band_summaries + return [] \ No newline at end of file From c63010eda330d8680d83fe9d25d01761afd7f63c Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Mon, 27 Apr 2026 09:47:23 -0400 Subject: [PATCH 2/5] Tweak routes for client layer menu --- tilemaker/metadata/definitions.py | 39 +++++------ tilemaker/server/bands.py | 2 +- tilemaker/server/layers.py | 104 +++++++++++++++++++++++++----- tilemaker/server/map_groups.py | 12 ++-- tilemaker/server/maps.py | 7 +- 5 files changed, 117 insertions(+), 47 deletions(-) diff --git a/tilemaker/metadata/definitions.py b/tilemaker/metadata/definitions.py index 759418a..7b64a59 100644 --- a/tilemaker/metadata/definitions.py +++ b/tilemaker/metadata/definitions.py @@ -117,51 +117,40 @@ def model_post_init(self, _): self.tile_size, self.number_of_levels = self.provider.calculate_tile_size() -class LayerDefault(AuthenticatedModel): - map_group_id: str - map_id: str - band_id: str - layer: Layer - - class BandBase(AuthenticatedModel): band_id: str name: str description: str - - -class BandSummary(BandBase): - layer_ids: list[str] class Band(BandBase): layers: list[Layer] +class BandMenuState(BandBase): + layers: list[LayerSummary] + + class MapBase(AuthenticatedModel): map_id: str name: str description: str -class MapSummary(MapBase): - band_ids: list[str] - - class Map(MapBase): bands: list[Band] +class MapMenuState(MapBase): + bands: list[BandMenuState] + + class MapGroupBase(AuthenticatedModel): map_group_id: str name: str description: str -class MapGroupSummary(MapGroupBase): - map_ids: list[str] - - class MapGroup(MapGroupBase): maps: list[Map] @@ -173,3 +162,15 @@ def get_layer(self, layer_id: str) -> Layer | None: return layer return None + + +class MapGroupMenuState(MapGroupBase): + maps: list[MapMenuState] + + +class LayerDefault(AuthenticatedModel): + layer: Layer + default_layer_menu: list[MapGroupMenuState] + default_map_group_id: str + default_map_id: str + default_band_id: str \ No newline at end of file diff --git a/tilemaker/server/bands.py b/tilemaker/server/bands.py index 4e00a16..b1efda4 100644 --- a/tilemaker/server/bands.py +++ b/tilemaker/server/bands.py @@ -2,7 +2,7 @@ Endpoint for summary data of a band's layers """ -from tilemaker.metadata.definitions import BandSummary, LayerSummary +from tilemaker.metadata.definitions import LayerSummary from fastapi import ( APIRouter, diff --git a/tilemaker/server/layers.py b/tilemaker/server/layers.py index 59eb947..7df2a09 100644 --- a/tilemaker/server/layers.py +++ b/tilemaker/server/layers.py @@ -14,7 +14,7 @@ Response, ) -from tilemaker.metadata.definitions import Layer, LayerDefault +from tilemaker.metadata.definitions import Layer, LayerDefault, MapGroupMenuState, LayerSummary, BandMenuState, MapMenuState, MapGroupBase from tilemaker.processing.extractor import extract from tilemaker.providers.fits import PullableTile @@ -25,6 +25,11 @@ layers_router = APIRouter(prefix="/layers", tags=["Layers and Tiles"]) +""" + Naively set to the first layer found according to the structure of + map groups -> maps -> bands -> layers. Basically, it's the first index + of each tier until reaching the layer tier. +""" @layers_router.get( "/default", response_model=LayerDefault, @@ -32,17 +37,83 @@ description="Gets layer data needed for the menu and map when first loaded." ) def get_default_layer(request: Request): - for map_group in request.app.config.map_groups: - for map in map_group.maps: - for band in map.bands: - for layer in band.layers: - default_layer = LayerDefault( - map_group_id=map_group.map_group_id, + default_map_group_id = None + default_map_id = None + default_band_id = None + default_layer = None + map_groups = [] + + for group_idx, map_group in enumerate(request.app.config.map_groups): + if map_group.auth(request.auth.scopes) is False: + continue + + maps = [] + + if group_idx == 0: + default_map_group_id = map_group.map_group_id + for map_idx, map in enumerate(map_group.maps): + bands = [] + + if map_idx == 0: + default_map_id = map.map_id + for band_idx, band in enumerate(map.bands): + layers = [] + + if band_idx == 0: + default_band_id = band.band_id + for layer_idx, layer in enumerate(band.layers): + layers.append( + LayerSummary( + layer_id=layer.layer_id, + name=layer.name, + description=layer.description, + ) + ) + if group_idx == 0 and map_idx == 0 and band_idx == 0 and layer_idx == 0: + default_layer = layer + + bands.append( + BandMenuState( + band_id=band.band_id, + name=band.name, + description=band.description, + layers=layers + ) + ) + + maps.append( + MapMenuState( map_id=map.map_id, - band_id=band.band_id, - layer=layer, + name=map.name, + description=map.description, + bands=bands ) - return default_layer + ) + + map_groups.append( + MapGroupMenuState( + map_group_id=map_group.map_group_id, + name=map_group.name, + description=map_group.description, + maps=maps + ) + ) + else: + map_groups.append( + MapGroupMenuState( + map_group_id=map_group.map_group_id, + name=map_group.name, + description=map_group.description, + maps=[] + ) + ) + return LayerDefault( + layer=default_layer, + default_layer_menu=map_groups, + default_map_group_id=default_map_group_id, + default_map_id=default_map_id, + default_band_id=default_band_id, + ) @layers_router.get( @@ -56,11 +127,12 @@ def get_layer_summaries_of_band( request: Request ): for map_group in request.app.config.map_groups: - for map in map_group.maps: - for band in map.bands: - for layer in band.layers: - if (layer.layer_id == layer_id and map_group.auth(request.auth.scopes)): - return layer + if map_group.auth(request.auth.scopes): + for map in map_group.maps: + for band in map.bands: + for layer in band.layers: + if (layer.layer_id == layer_id and map_group.auth(request.auth.scopes)): + return layer @layers_router.get( @@ -179,7 +251,7 @@ def get_tile( raise HTTPException(status_code=400, detail="Not an acceptable extension") numpy_buf = core_tile_retrieval( - layer=layer_id, + layer_id=layer_id, level=level, y=y, x=x, diff --git a/tilemaker/server/map_groups.py b/tilemaker/server/map_groups.py index 756177a..71ec2dd 100644 --- a/tilemaker/server/map_groups.py +++ b/tilemaker/server/map_groups.py @@ -2,7 +2,7 @@ Endpoints for getting list of map group summaries and a list of a map group's map summaries. """ -from tilemaker.metadata.definitions import MapGroupSummary, MapSummary +from tilemaker.metadata.definitions import MapGroupBase, MapBase from fastapi import ( APIRouter, @@ -14,7 +14,7 @@ @map_groups_router.get( "", - response_model=list[MapGroupSummary], + response_model=list[MapGroupBase], summary="Get the list of map group summaries.", description="Retrieve a list of MapGroupSummary objects." ) @@ -22,11 +22,10 @@ def get_map_group_summaries(request: Request): map_group_summaries = [] for x in request.app.config.map_groups: if (x.auth(request.auth.scopes)): - map_group_summary = MapGroupSummary( + map_group_summary = MapGroupBase( map_group_id=x.map_group_id, name=x.name, description=x.description, - map_ids=[map.map_id for map in x.maps] ) map_group_summaries.append(map_group_summary) @@ -35,7 +34,7 @@ def get_map_group_summaries(request: Request): @map_groups_router.get( "/{map_group_id}/maps", - response_model=list[MapSummary], + response_model=list[MapBase], summary="Get the list of map summaries associated with a Map Group.", description="Retrieve a list of MapSummary objects that belong to a particular Map Group." ) @@ -47,11 +46,10 @@ def get_map_summaries_of_map_group( for map_group in request.app.config.map_groups: if (map_group.map_group_id == map_group_id and map_group.auth(request.auth.scopes)): for map in map_group.maps: - map_summary = MapSummary( + map_summary = MapBase( map_id=map.map_id, name=map.name, description=map.description, - band_ids=[band.band_id for band in map.bands] ) map_summaries.append(map_summary) return map_summaries \ No newline at end of file diff --git a/tilemaker/server/maps.py b/tilemaker/server/maps.py index 3d3322a..ea32e63 100644 --- a/tilemaker/server/maps.py +++ b/tilemaker/server/maps.py @@ -10,14 +10,14 @@ Request ) -from tilemaker.metadata.definitions import BandSummary +from tilemaker.metadata.definitions import BandBase maps_router = APIRouter(prefix="/maps", tags=["Maps and Tiles"]) @maps_router.get( "/{map_id}/bands", - response_model=list[BandSummary], + response_model=list[BandBase], summary="Get the list of band summaries associated with a Map.", description="Retrieve a list of BandSummary objects that belong to a particular Map." ) @@ -30,11 +30,10 @@ def get_layer_summaries_of_band( if (map.map_id == map_id and map_group.auth(request.auth.scopes)): for band in map.bands: band_summaries = [] - band_summary = BandSummary( + band_summary = BandBase( band_id=band.band_id, name=band.name, description=band.description, - layer_ids=[layer.layer_id for layer in band.layers] ) band_summaries.append(band_summary) return band_summaries From 37840a6328c0ef4fc556873d19cf3c52be3d86e6 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Wed, 29 Apr 2026 15:19:13 -0400 Subject: [PATCH 3/5] Add search endpoint to filter layer menu --- tilemaker/metadata/definitions.py | 13 +++++- tilemaker/server/app.py | 2 + tilemaker/server/layers.py | 13 ++++-- tilemaker/server/search.py | 72 +++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tilemaker/server/search.py diff --git a/tilemaker/metadata/definitions.py b/tilemaker/metadata/definitions.py index 7b64a59..6eaa589 100644 --- a/tilemaker/metadata/definitions.py +++ b/tilemaker/metadata/definitions.py @@ -117,6 +117,12 @@ def model_post_init(self, _): self.tile_size, self.number_of_levels = self.provider.calculate_tile_size() +class LayerWithMenuState(Layer): + map_group_id: str + map_id: str + band_id: str + + class BandBase(AuthenticatedModel): band_id: str name: str @@ -173,4 +179,9 @@ class LayerDefault(AuthenticatedModel): default_layer_menu: list[MapGroupMenuState] default_map_group_id: str default_map_id: str - default_band_id: str \ No newline at end of file + default_band_id: str + + +class SearchResponse(AuthenticatedModel): + filtered_layer_menu: list[MapGroupMenuState] + matched_ids: list[str] \ No newline at end of file diff --git a/tilemaker/server/app.py b/tilemaker/server/app.py index 672f571..ac1b751 100644 --- a/tilemaker/server/app.py +++ b/tilemaker/server/app.py @@ -18,6 +18,7 @@ from .maps import maps_router from .bands import bands_router from .layers import layers_router +from .search import search_router from .sources import sources_router @@ -68,6 +69,7 @@ async def lifespan(app: FastAPI): app.include_router(maps_router) app.include_router(bands_router) app.include_router(layers_router) +app.include_router(search_router) app.include_router(analysis_router) if settings.serve_frontend: diff --git a/tilemaker/server/layers.py b/tilemaker/server/layers.py index 7df2a09..7db45b8 100644 --- a/tilemaker/server/layers.py +++ b/tilemaker/server/layers.py @@ -14,7 +14,7 @@ Response, ) -from tilemaker.metadata.definitions import Layer, LayerDefault, MapGroupMenuState, LayerSummary, BandMenuState, MapMenuState, MapGroupBase +from tilemaker.metadata.definitions import Layer, LayerDefault, MapGroupMenuState, LayerSummary, BandMenuState, MapMenuState, LayerWithMenuState from tilemaker.processing.extractor import extract from tilemaker.providers.fits import PullableTile @@ -118,11 +118,11 @@ def get_default_layer(request: Request): @layers_router.get( "/{layer_id}", - response_model=Layer, + response_model=LayerWithMenuState, summary="Get the Layer data.", description="Retrieve the Layer data to be rendered in the mapping client." ) -def get_layer_summaries_of_band( +def get_layer_with_menu_state( layer_id: str, request: Request ): @@ -132,7 +132,12 @@ def get_layer_summaries_of_band( for band in map.bands: for layer in band.layers: if (layer.layer_id == layer_id and map_group.auth(request.auth.scopes)): - return layer + return LayerWithMenuState( + **layer.model_dump(), + map_group_id=map_group.map_group_id, + map_id=map.map_id, + band_id=band.band_id, + ) @layers_router.get( diff --git a/tilemaker/server/search.py b/tilemaker/server/search.py new file mode 100644 index 0000000..b1d3d2e --- /dev/null +++ b/tilemaker/server/search.py @@ -0,0 +1,72 @@ +""" +Endpoint to search map groups and its children +""" +from fastapi import ( + APIRouter, + Request, + Query, +) + +from tilemaker.metadata.definitions import SearchResponse + + +search_router = APIRouter(prefix="/search", tags=["Search map groups"]) + + +@search_router.get("", response_model=SearchResponse) +def search_layers(request: Request, q: str = Query(..., min_length=1)): + raw_groups = [g.dict() for g in request.app.config.map_groups if g.auth(request.auth.scopes)] + result = filter_map_groups(raw_groups, q) + + return SearchResponse( + filtered_layer_menu=result["filtered_map_groups"], + matched_ids=list(result["matched_ids"]), + ) + + +def match(name: str, query: str) -> bool: + """Case-insensitive substring match — swap in rapidfuzz here later if needed.""" + return query.lower() in name.lower() + + +def filter_map_groups(map_groups: list, query: str) -> dict: + matched_ids: set[str] = set() + filtered_groups = [] + + for group in map_groups: + # Group name matches — keep entire subtree intact + if match(group["name"], query): + matched_ids.add(group["name"]) + filtered_groups.append(group) + continue + + filtered_maps = [] + for map in group.get("maps", []): + # Map name matches — keep entire subtree intact + if match(map["name"], query): + matched_ids.add(map["map_id"]) + filtered_maps.append(map) + continue + + filtered_bands = [] + for band in map.get("bands", []): + # Band name matches — keep entire subtree intact + if match(band["name"], query): + matched_ids.add(band["band_id"]) + filtered_bands.append(band) + continue + + filtered_layers = [ + layer for layer in band.get("layers", []) + if match(layer["name"], query) and matched_ids.add(layer["layer_id"]) is None + ] + if filtered_layers: + filtered_bands.append({**band, "layers": filtered_layers}) + + if filtered_bands: + filtered_maps.append({**map, "bands": filtered_bands}) + + if filtered_maps: + filtered_groups.append({**group, "maps": filtered_maps}) + + return {"filtered_map_groups": filtered_groups, "matched_ids": matched_ids} \ No newline at end of file From 008600603828eea03ecb7582a939221fff87cd79 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Fri, 1 May 2026 13:52:06 -0400 Subject: [PATCH 4/5] Format and tweak menu endpoints --- tilemaker/metadata/definitions.py | 10 +++--- tilemaker/metadata/generation.py | 6 ++-- tilemaker/server/app.py | 4 +-- tilemaker/server/bands.py | 15 ++++----- tilemaker/server/layers.py | 54 ++++++++++++++++++++----------- tilemaker/server/map_groups.py | 21 ++++++------ tilemaker/server/maps.py | 19 +++-------- tilemaker/server/search.py | 20 +++++++----- 8 files changed, 78 insertions(+), 71 deletions(-) diff --git a/tilemaker/metadata/definitions.py b/tilemaker/metadata/definitions.py index 6eaa589..b643f9e 100644 --- a/tilemaker/metadata/definitions.py +++ b/tilemaker/metadata/definitions.py @@ -127,7 +127,7 @@ class BandBase(AuthenticatedModel): band_id: str name: str description: str - + class Band(BandBase): layers: list[Layer] @@ -152,9 +152,9 @@ class MapMenuState(MapBase): class MapGroupBase(AuthenticatedModel): - map_group_id: str - name: str - description: str + map_group_id: str + name: str + description: str class MapGroup(MapGroupBase): @@ -184,4 +184,4 @@ class LayerDefault(AuthenticatedModel): class SearchResponse(AuthenticatedModel): filtered_layer_menu: list[MapGroupMenuState] - matched_ids: list[str] \ No newline at end of file + matched_ids: list[str] diff --git a/tilemaker/metadata/generation.py b/tilemaker/metadata/generation.py index 92cecb7..fe4e59c 100644 --- a/tilemaker/metadata/generation.py +++ b/tilemaker/metadata/generation.py @@ -78,9 +78,9 @@ def map_group_from_fits( return MapGroup( map_group_id=f"map-group-{uuid.uuid4()}", - name="Auto-Populated", - description="No description provided", - maps=maps + name="Auto-Populated", + description="No description provided", + maps=maps, ) diff --git a/tilemaker/server/app.py b/tilemaker/server/app.py index ac1b751..b10312f 100644 --- a/tilemaker/server/app.py +++ b/tilemaker/server/app.py @@ -12,12 +12,12 @@ from ..settings import settings from .analysis import analysis_router from .auth import setup_auth +from .bands import bands_router from .highlights import highlights_router from .histogram import histogram_router +from .layers import layers_router from .map_groups import map_groups_router from .maps import maps_router -from .bands import bands_router -from .layers import layers_router from .search import search_router from .sources import sources_router diff --git a/tilemaker/server/bands.py b/tilemaker/server/bands.py index b1efda4..22f766e 100644 --- a/tilemaker/server/bands.py +++ b/tilemaker/server/bands.py @@ -2,13 +2,13 @@ Endpoint for summary data of a band's layers """ -from tilemaker.metadata.definitions import LayerSummary - from fastapi import ( APIRouter, Request, ) +from tilemaker.metadata.definitions import LayerSummary + bands_router = APIRouter(prefix="/bands", tags=["List of Bands"]) @@ -16,16 +16,13 @@ "/{band_id}/layers", response_model=list[LayerSummary], summary="Get the list of layer summaries associated with a Band.", - description="Retrieve a list of LayerSummary objects that belong to a particular Band." + description="Retrieve a list of LayerSummary objects that belong to a particular Band.", ) -def get_layer_summaries_of_band( - band_id: str, - request: Request -): +def get_layer_summaries_of_band(band_id: str, request: Request): for map_group in request.app.config.map_groups: for map in map_group.maps: for band in map.bands: - if (band.band_id == band_id and map_group.auth(request.auth.scopes)): + if band.band_id == band_id and map_group.auth(request.auth.scopes): layer_summaries = [] for layer in band.layers: layer_summary = LayerSummary( @@ -35,4 +32,4 @@ def get_layer_summaries_of_band( ) layer_summaries.append(layer_summary) return layer_summaries - return [] \ No newline at end of file + return [] diff --git a/tilemaker/server/layers.py b/tilemaker/server/layers.py index 7db45b8..c078acb 100644 --- a/tilemaker/server/layers.py +++ b/tilemaker/server/layers.py @@ -1,6 +1,7 @@ """ Endpoint for layer and tile data """ + import io from typing import Literal @@ -14,7 +15,14 @@ Response, ) -from tilemaker.metadata.definitions import Layer, LayerDefault, MapGroupMenuState, LayerSummary, BandMenuState, MapMenuState, LayerWithMenuState +from tilemaker.metadata.definitions import ( + BandMenuState, + LayerDefault, + LayerSummary, + LayerWithMenuState, + MapGroupMenuState, + MapMenuState, +) from tilemaker.processing.extractor import extract from tilemaker.providers.fits import PullableTile @@ -30,11 +38,13 @@ map groups -> maps -> bands -> layers. Basically, it's the first index of each tier until reaching the layer tier. """ + + @layers_router.get( "/default", response_model=LayerDefault, summary="Get the hierarchy and layer data for a default layer.", - description="Gets layer data needed for the menu and map when first loaded." + description="Gets layer data needed for the menu and map when first loaded.", ) def get_default_layer(request: Request): default_map_group_id = None @@ -44,7 +54,7 @@ def get_default_layer(request: Request): map_groups = [] for group_idx, map_group in enumerate(request.app.config.map_groups): - if map_group.auth(request.auth.scopes) is False: + if map_group.auth(request.auth.scopes) is False: continue maps = [] @@ -69,7 +79,12 @@ def get_default_layer(request: Request): description=layer.description, ) ) - if group_idx == 0 and map_idx == 0 and band_idx == 0 and layer_idx == 0: + if ( + group_idx == 0 + and map_idx == 0 + and band_idx == 0 + and layer_idx == 0 + ): default_layer = layer bands.append( @@ -77,7 +92,7 @@ def get_default_layer(request: Request): band_id=band.band_id, name=band.name, description=band.description, - layers=layers + layers=layers, ) ) @@ -86,7 +101,7 @@ def get_default_layer(request: Request): map_id=map.map_id, name=map.name, description=map.description, - bands=bands + bands=bands, ) ) @@ -95,7 +110,7 @@ def get_default_layer(request: Request): map_group_id=map_group.map_group_id, name=map_group.name, description=map_group.description, - maps=maps + maps=maps, ) ) else: @@ -104,7 +119,7 @@ def get_default_layer(request: Request): map_group_id=map_group.map_group_id, name=map_group.name, description=map_group.description, - maps=[] + maps=[], ) ) return LayerDefault( @@ -113,32 +128,31 @@ def get_default_layer(request: Request): default_map_group_id=default_map_group_id, default_map_id=default_map_id, default_band_id=default_band_id, - ) - + ) + @layers_router.get( "/{layer_id}", response_model=LayerWithMenuState, summary="Get the Layer data.", - description="Retrieve the Layer data to be rendered in the mapping client." + description="Retrieve the Layer data to be rendered in the mapping client.", ) -def get_layer_with_menu_state( - layer_id: str, - request: Request -): +def get_layer_with_menu_state(layer_id: str, request: Request): for map_group in request.app.config.map_groups: if map_group.auth(request.auth.scopes): for map in map_group.maps: for band in map.bands: for layer in band.layers: - if (layer.layer_id == layer_id and map_group.auth(request.auth.scopes)): + if layer.layer_id == layer_id and map_group.auth( + request.auth.scopes + ): return LayerWithMenuState( **layer.model_dump(), map_group_id=map_group.map_group_id, map_id=map.map_id, band_id=band.band_id, ) - + @layers_router.get( "/{layer_id}/submap/{left}/{right}/{top}/{bottom}/image.{ext}", @@ -203,7 +217,9 @@ def core_tile_retrieval( request: Request, ): tile, pushables = request.app.tiles.pull( - PullableTile(layer_id=layer_id, x=x, y=y, level=level, grants=request.auth.scopes) + PullableTile( + layer_id=layer_id, x=x, y=y, level=level, grants=request.auth.scopes + ) ) bt.add_task(request.app.tiles.push, pushables) @@ -269,4 +285,4 @@ def get_tile( with io.BytesIO() as output: renderer.render(output, numpy_buf, render_options=render_options) - return Response(content=output.getvalue(), media_type=f"image/{ext}") \ No newline at end of file + return Response(content=output.getvalue(), media_type=f"image/{ext}") diff --git a/tilemaker/server/map_groups.py b/tilemaker/server/map_groups.py index 71ec2dd..2a28b57 100644 --- a/tilemaker/server/map_groups.py +++ b/tilemaker/server/map_groups.py @@ -2,13 +2,13 @@ Endpoints for getting list of map group summaries and a list of a map group's map summaries. """ -from tilemaker.metadata.definitions import MapGroupBase, MapBase - from fastapi import ( APIRouter, Request, ) +from tilemaker.metadata.definitions import MapBase, MapGroupBase + map_groups_router = APIRouter(prefix="/map-groups", tags=["List of Map Groups"]) @@ -16,12 +16,12 @@ "", response_model=list[MapGroupBase], summary="Get the list of map group summaries.", - description="Retrieve a list of MapGroupSummary objects." + description="Retrieve a list of MapGroupSummary objects.", ) def get_map_group_summaries(request: Request): map_group_summaries = [] for x in request.app.config.map_groups: - if (x.auth(request.auth.scopes)): + if x.auth(request.auth.scopes): map_group_summary = MapGroupBase( map_group_id=x.map_group_id, name=x.name, @@ -36,15 +36,14 @@ def get_map_group_summaries(request: Request): "/{map_group_id}/maps", response_model=list[MapBase], summary="Get the list of map summaries associated with a Map Group.", - description="Retrieve a list of MapSummary objects that belong to a particular Map Group." + description="Retrieve a list of MapSummary objects that belong to a particular Map Group.", ) -def get_map_summaries_of_map_group( - map_group_id: str, - request: Request -): +def get_map_summaries_of_map_group(map_group_id: str, request: Request): map_summaries = [] for map_group in request.app.config.map_groups: - if (map_group.map_group_id == map_group_id and map_group.auth(request.auth.scopes)): + if map_group.map_group_id == map_group_id and map_group.auth( + request.auth.scopes + ): for map in map_group.maps: map_summary = MapBase( map_id=map.map_id, @@ -52,4 +51,4 @@ def get_map_summaries_of_map_group( description=map.description, ) map_summaries.append(map_summary) - return map_summaries \ No newline at end of file + return map_summaries diff --git a/tilemaker/server/maps.py b/tilemaker/server/maps.py index ea32e63..93a6208 100644 --- a/tilemaker/server/maps.py +++ b/tilemaker/server/maps.py @@ -2,13 +2,7 @@ Endpoints for maps. """ -import io -from typing import Literal - -from fastapi import ( - APIRouter, - Request -) +from fastapi import APIRouter, Request from tilemaker.metadata.definitions import BandBase @@ -19,15 +13,12 @@ "/{map_id}/bands", response_model=list[BandBase], summary="Get the list of band summaries associated with a Map.", - description="Retrieve a list of BandSummary objects that belong to a particular Map." + description="Retrieve a list of BandSummary objects that belong to a particular Map.", ) -def get_layer_summaries_of_band( - map_id: str, - request: Request -): +def get_layer_summaries_of_band(map_id: str, request: Request): for map_group in request.app.config.map_groups: for map in map_group.maps: - if (map.map_id == map_id and map_group.auth(request.auth.scopes)): + if map.map_id == map_id and map_group.auth(request.auth.scopes): for band in map.bands: band_summaries = [] band_summary = BandBase( @@ -37,4 +28,4 @@ def get_layer_summaries_of_band( ) band_summaries.append(band_summary) return band_summaries - return [] \ No newline at end of file + return [] diff --git a/tilemaker/server/search.py b/tilemaker/server/search.py index b1d3d2e..f6752bb 100644 --- a/tilemaker/server/search.py +++ b/tilemaker/server/search.py @@ -1,21 +1,23 @@ """ Endpoint to search map groups and its children """ + from fastapi import ( APIRouter, - Request, Query, + Request, ) from tilemaker.metadata.definitions import SearchResponse - search_router = APIRouter(prefix="/search", tags=["Search map groups"]) - + @search_router.get("", response_model=SearchResponse) def search_layers(request: Request, q: str = Query(..., min_length=1)): - raw_groups = [g.dict() for g in request.app.config.map_groups if g.auth(request.auth.scopes)] + raw_groups = [ + g.dict() for g in request.app.config.map_groups if g.auth(request.auth.scopes) + ] result = filter_map_groups(raw_groups, q) return SearchResponse( @@ -36,7 +38,7 @@ def filter_map_groups(map_groups: list, query: str) -> dict: for group in map_groups: # Group name matches — keep entire subtree intact if match(group["name"], query): - matched_ids.add(group["name"]) + matched_ids.add(group["map_group_id"]) filtered_groups.append(group) continue @@ -57,8 +59,10 @@ def filter_map_groups(map_groups: list, query: str) -> dict: continue filtered_layers = [ - layer for layer in band.get("layers", []) - if match(layer["name"], query) and matched_ids.add(layer["layer_id"]) is None + layer + for layer in band.get("layers", []) + if match(layer["name"], query) + and matched_ids.add(layer["layer_id"]) is None ] if filtered_layers: filtered_bands.append({**band, "layers": filtered_layers}) @@ -69,4 +73,4 @@ def filter_map_groups(map_groups: list, query: str) -> dict: if filtered_maps: filtered_groups.append({**group, "maps": filtered_maps}) - return {"filtered_map_groups": filtered_groups, "matched_ids": matched_ids} \ No newline at end of file + return {"filtered_map_groups": filtered_groups, "matched_ids": matched_ids} From 561e633c3822bcc49e2d58849ca4aed11f3ef232 Mon Sep 17 00:00:00 2001 From: Jeremy Myers Date: Mon, 4 May 2026 16:07:47 -0400 Subject: [PATCH 5/5] Add map_group_id field to ORM --- tilemaker/metadata/database.py | 2 ++ tilemaker/metadata/orm.py | 1 + 2 files changed, 3 insertions(+) diff --git a/tilemaker/metadata/database.py b/tilemaker/metadata/database.py index a5fa531..83f89db 100644 --- a/tilemaker/metadata/database.py +++ b/tilemaker/metadata/database.py @@ -262,6 +262,7 @@ def _orm_to_map_group(self, session: Session, orm_group: MapGroupORM) -> MapGrou description=orm_group.description, maps=maps, grant=orm_group.grant, + map_group_id=orm_group.map_group_id, ) def populate_from_config(self, config: "DataConfiguration") -> None: @@ -286,6 +287,7 @@ def populate_from_config(self, config: "DataConfiguration") -> None: name=map_group.name, description=map_group.description, grant=map_group.grant, + map_group_id=map_group.map_group_id, ) session.add(orm_group) session.flush() diff --git a/tilemaker/metadata/orm.py b/tilemaker/metadata/orm.py index f84b6f1..0f81a40 100644 --- a/tilemaker/metadata/orm.py +++ b/tilemaker/metadata/orm.py @@ -21,6 +21,7 @@ class MapGroupORM(Base): __tablename__ = "map_groups" id = Column(Integer, primary_key=True) + map_group_id = Column(String, unique=True, nullable=False) name = Column(String, nullable=False) description = Column(String) grant = Column(String)