diff --git a/docs/guides/generating-mosaics.md b/docs/guides/generating-mosaics.md
new file mode 100644
index 00000000..448b73f4
--- /dev/null
+++ b/docs/guides/generating-mosaics.md
@@ -0,0 +1,28 @@
+# Generating mosaics
+
+Once layers are grouped into layersets, and each layerset is trimmed in the [multimask](./trimming.md) interface, the final step is to collapse the content into a single mosaic per layerset, like finally gluing down all the individual clippings in a collage. You'll find these within within a map's **Mosaic** → **Derivatives** (see [New Iberia, 1885](https://oldinsurancemaps.net/map/sanborn03375_001#mosaic)).
+
+Currently, OIM supports the creation of two different derivatives per layerset:
+
+- **Cloud Optimized GeoTIFFs (COG)**
+ - This is the primary output, and when present, is used within OIM in certain contexts
+- **Static XYZ Tileset**
+ - This is a rendering of the latest COG mosaic to actual PNG tiles in a {z}/{y}/{x}.png folder structure.
+
+!!! note
+
+ You may be wondering what happens to Key Maps and other categories of maps, i.e. other layersets. Though these are handled on the backend exactly like the Main Content layerset, they are only shown in the derivatives list if they have more than one layer.
+
+## Keeping derivatives up-to-date
+
+The creation of a single geotiff (or static XYZ tileset) is effectively a snapshot of the work that has been performed on a map's layers and masks up to that point. Timestamps are displayed showing when masks were last updated, and when derivatives were last generated.
+
+
+
+However, this means that if the ground control points are subsequently updated for a layer, or the masks are adjusted, the derivative artifact will be out-of-date. In this case, the interface will show timestamps of out-of-date artifacts in red and a button will be available allowing you to queue this artifact to be rebuilt.
+
+
+
+Because the process to create a single geotiff (or xyz tileset) from dozens or hundreds of layers can take several hours, we use a queueing system to ensure the server is never overloaded. You can see all mosaicking processes (queued, running, or completed) in [Jobs](https://oldinsurancemaps.net/jobs).
+
+In the current setup, only two jobs are allowed to run at any time, and queued mosaic operations are processed with a first in, first out policy.
diff --git a/docs/guides/services.md b/docs/guides/services.md
new file mode 100644
index 00000000..6c8c98b3
--- /dev/null
+++ b/docs/guides/services.md
@@ -0,0 +1,48 @@
+
+# Downloads & web services
+
+Historical map content can be accessed in a number of different ways and used in many different applications. Throughout the platform you will find places to download or link to:
+
+1. Individual layers that have been georeferenced
+ - Find these within a map's **Georeferenced** section: https://oldinsurancemaps.net/map/sanborn03375_001#georeferenced
+2. Unified mosaics made from multiple layers
+ - Find these within a map's **Mosaic** → **Derivatives** section: https://oldinsurancemaps.net/map/sanborn03375_001#mosaic
+
+!!! warning
+
+ When downloading or otherwise accessing maps, please keep in mind that **OldInsuranceMaps.net provides no guarantee as to the accuracy of georeferenced content.**
+
+!!! important
+
+ Over time, someone may come along and improve the georeferencing on a layer, or update multimask boundaries and regenerated a mosaic. When this happens the original file is deleted and a new one is created. Because many of the endpoints described below contain the actual filename, **tile service links in 3rd-party integrations may need to be updated after the source layer or mosaic has been updated**.
+
+## Downloadable geotiffs (COGs)
+
+OIM relies heavily on [cloud optimized geotiffs](https://cogeo.org/), or "COGs", and you can download these files at any time and easily use them directly in software like QGIS or ArcGIS Desktop. All COGs use JPEG compression in order to keep file sizes manageable.
+
+## Tile services
+
+OIM provides service endpoints that can be used in many different applications. The particular service endpoint you use will largely depend on where you are trying to use it, as not all applications support all types of endpoints.
+
+Generally speaking, XYZ tiles are the most widely supported, TileJSON is a superset spec that wraps an XYZ tile endpoint with some extra information, and WMS is an older standard still in use in many applications.
+
+OIM uses TiTiler to dynamically produce tiled services from the same source COG files that you can download as described above.
+
+### XYZ tiles
+
+XYZ tiles are a very widely supported standard supported by most geospatial software and mapping applications. Here is more info for how to use these URLs in desktop software like [QGIS](https://docs.qgis.org/3.22/en/docs/user_manual/managing_data_source/opening_data.html#using-xyz-tile-services) and [ArcGIS](https://esribelux.com/2021/04/16/xyz-tile-layers-in-arcgis-platform/), as well as web mapping applications like [Leaflet](https://leafletjs.com/reference.html#tilelayer), [OpenLayers](https://openlayers.org/en/latest/examples/xyz.html), [Mapbox/Maplibre GL JS](https://maplibre.org/maplibre-gl-js-docs/example/map-tiles/), [ArcGIS Online](https://doc.arcgis.com/en/arcgis-online/create-maps/add-layers-from-url.htm#ESRI_SECTION1_13A68145A4F94A338AB2002AD5F9844D), and [OpenHistoricalMap (iD Editor)](https://learnosm.org/en/beginner/id-editor/#configuring-the-background-layer).
+
+!!! note
+
+ Some mosaics will also have an **Static XYZ Tileset** available. This is a static, pre-rendered tileset that you can download, unpack, and host on your own infrastructure. It is different from the XYZ tiles endpoints in that it does not rely on any dynamic tiling service, like TiTiler.
+
+### TileJSON
+
+[TileJSON](https://github.com/mapbox/tilejson-spec) is a useful spec that wraps an XYZ endpoint with a few other pieces of information.
+
+Currently, this is the most reliable OIM-provided endpoint to use because while the underlying XYZ url which may change (as described above), the TileJSON url itself will not change. However, TileJSON is not as widely supported as XYZ tiles.
+
+
+## IIIF Georeference Extension
+
+For each layer and mosaic you can also access a collection of the georeferencing information formatted as the [IIIF Georeference Extension](https://iiif.io/api/extension/georef/). This annotation will includes the IIIF URLs from the source resources in the Library of Congress and when you open it in Allmaps Viewer, the tiles will be coming directly from LOC and being warped in the browser according to control points and masks created in OIM. Learn more in this slideshow: [creating IIIF georeference annotations](https://docs.google.com/presentation/d/18FRpq1Lcz3T7WincZNRvTjdL87ULy1bU68BCxr425dk/edit?usp=sharing).
\ No newline at end of file
diff --git a/docs/guides/trimming.md b/docs/guides/trimming.md
index 8d9e9643..67620b45 100644
--- a/docs/guides/trimming.md
+++ b/docs/guides/trimming.md
@@ -2,4 +2,8 @@
Overlapping edges of layers cause messy mosaics, so a mask must be added to each layer to remove its margins. These masks should all be contiguous (sharing adjacent borders and vertices) so it is best to handle them at the map-level, not by masking each layer individually. This is the idea behind a **MultiMask**, which is a unified collection of masks for all layers within a map.
-
\ No newline at end of file
+
+
+## Masking across multiple volumes
+
+In large cities it is common to have multiple volumes for a single atlas edition, each volume only covering a subset of the entire city. In the OIM data model, these volumes are treated as separate entities, so while the pages within each one can be carefully masked to form a mosaic, there is no affordance for combining those mosaics across all volumes within an edition. This is something we hope to support in the future.
\ No newline at end of file
diff --git a/docs/images/mosaic-cog-fresh.png b/docs/images/mosaic-cog-fresh.png
new file mode 100644
index 00000000..fe4842bb
Binary files /dev/null and b/docs/images/mosaic-cog-fresh.png differ
diff --git a/docs/images/mosaic-cog-stale.png b/docs/images/mosaic-cog-stale.png
new file mode 100644
index 00000000..ddd87565
Binary files /dev/null and b/docs/images/mosaic-cog-stale.png differ
diff --git a/ohmg/api/filters.py b/ohmg/api/filters.py
index 22a15c80..8042a7ba 100644
--- a/ohmg/api/filters.py
+++ b/ohmg/api/filters.py
@@ -17,6 +17,11 @@ class FilterSessionSchema(FilterSchema):
type: Optional[str]
+class FilterJobSchema(FilterSchema):
+ operation: Optional[str] = Field(q="operation")
+ stage: Optional[str] = Field(q="stage")
+
+
class FilterDocumentSchema(FilterSchema):
map: str = Field(q="map_id")
prepared: Optional[bool] = Field(q="prepared")
diff --git a/ohmg/api/routes.py b/ohmg/api/routes.py
index db1b8d52..c40a43de 100644
--- a/ohmg/api/routes.py
+++ b/ohmg/api/routes.py
@@ -21,6 +21,7 @@
Region,
)
from ohmg.georeference.models import (
+ Job,
SessionBase,
SessionLock,
)
@@ -29,6 +30,7 @@
from .filters import (
FilterAllDocumentsSchema,
FilterDocumentSchema,
+ FilterJobSchema,
FilterRegionSchema,
FilterSessionSchema,
)
@@ -39,6 +41,7 @@
)
from .schemas import (
DocumentSchema,
+ JobSchema,
LayerSchema,
LayerSetSchema,
MapFullSchema,
@@ -76,6 +79,14 @@ def authenticate(self, request, key):
)
+@beta2.get("jobs/", response=List[JobSchema], url_name="job_list")
+@paginate
+def list_jobs(request, filters: FilterJobSchema = Query(...)):
+ queryset = Job.objects.all().order_by("-date_queued")
+ queryset = filters.filter(queryset)
+ return queryset
+
+
@beta2.get("sessions/", response=List[SessionSchema], url_name="session_list")
@paginate(SessionPagination)
def list_sessions(request, filters: FilterSessionSchema = Query(...), date_range: str = ""):
diff --git a/ohmg/api/schemas.py b/ohmg/api/schemas.py
index f4b5d916..8a10676a 100644
--- a/ohmg/api/schemas.py
+++ b/ohmg/api/schemas.py
@@ -29,6 +29,13 @@
logger = logging.getLogger(__name__)
+def _datefield_to_timestamp(obj, field: str) -> float | None:
+ try:
+ return getattr(obj, field).timestamp()
+ except (AttributeError, ValueError):
+ return None
+
+
class UserSchema(Schema):
username: str
profile_url: str
@@ -476,6 +483,7 @@ def resolve_urls(obj):
class LayerSetSchema(Schema):
+ pk: int
id: str
name: str
map_id: str
@@ -487,6 +495,9 @@ class LayerSetSchema(Schema):
multimask_extent: Optional[tuple]
mosaic_cog_url: Optional[str]
xyz_tiles_url: Optional[str]
+ multimask_date: Optional[float]
+ latest_cog_job: Optional["JobSchema"]
+ latest_xyz_job: Optional["JobSchema"]
@staticmethod
def resolve_id(obj):
@@ -504,6 +515,18 @@ def resolve_layers_masked_ct(obj):
def resolve_name(obj):
return str(obj.category)
+ @staticmethod
+ def resolve_multimask_date(obj):
+ return _datefield_to_timestamp(obj, "multimask_date")
+
+ @staticmethod
+ def resolve_latest_cog_job(obj):
+ return obj.get_latest_cog_job()
+
+ @staticmethod
+ def resolve_latest_xyz_job(obj):
+ return obj.get_latest_xyz_job()
+
class LayerSetDisplaySchema(Schema):
map_id: str
@@ -861,6 +884,49 @@ def resolve_status(obj):
return "split"
+class JobTargetSchema(Schema):
+ name: str
+ url: str
+
+ @staticmethod
+ def resolve_name(obj):
+ return str(obj)
+
+ @staticmethod
+ def resolve_url(obj):
+ return f"/map/{obj.map.pk}"
+
+
+class JobSchema(Schema):
+ id: int
+ operation: str
+ stage: str
+ message: Optional[str]
+ target: JobTargetSchema
+ date_created: Optional[float]
+ date_queued: Optional[float]
+ date_started: Optional[float]
+ date_ended: Optional[float]
+ run_duration: Optional[int]
+
+ @staticmethod
+ def resolve_date_created(obj):
+ return _datefield_to_timestamp(obj, "date_created")
+
+ @staticmethod
+ def resolve_date_queued(obj):
+ return _datefield_to_timestamp(obj, "date_queued")
+
+ @staticmethod
+ def resolve_date_started(obj):
+ return _datefield_to_timestamp(obj, "date_started")
+
+ @staticmethod
+ def resolve_date_ended(obj):
+ return _datefield_to_timestamp(obj, "date_ended")
+
+
DocumentFullSchema.update_forward_refs()
RegionFullSchema.update_forward_refs()
MapListSchema2.update_forward_refs()
+LayerSetSchema.update_forward_refs()
diff --git a/ohmg/conf/http.py b/ohmg/conf/http.py
index 128d8d30..99db8a2b 100644
--- a/ohmg/conf/http.py
+++ b/ohmg/conf/http.py
@@ -38,7 +38,7 @@ def user_info_from_request(request):
user_info["api_keys"] = user.api_keys
user_info["is_authenticated"] = True
user_info["is_staff"] = user.is_staff
- user_info["perms"] = list(user.get_all_permissions())
+ user_info["perms"] = sorted(list(user.get_all_permissions()))
else:
user_info = {
"is_authenticated": False,
@@ -90,7 +90,7 @@ def __init__(self, message="object not found"):
class JsonResponseUnauthorized(JsonResponse):
def __init__(self, message="unauthorized"):
- self.status_code = HTTPStatus.NOT_FOUND
+ self.status_code = HTTPStatus.UNAUTHORIZED
super().__init__(
{
"success": False,
@@ -101,7 +101,7 @@ def __init__(self, message="unauthorized"):
class JsonResponseBadRequest(JsonResponse):
def __init__(self, message="bad request"):
- self.status_code = HTTPStatus.NOT_FOUND
+ self.status_code = HTTPStatus.BAD_REQUEST
super().__init__(
{
"success": False,
diff --git a/ohmg/conf/management/commands/configure-services.py b/ohmg/conf/management/commands/configure-services.py
index 270da862..ce181973 100644
--- a/ohmg/conf/management/commands/configure-services.py
+++ b/ohmg/conf/management/commands/configure-services.py
@@ -220,7 +220,7 @@ def generate_celery_mosaic_service(self, state_path: Path):
--statedb={str(state_path.resolve())}/celery_mosaic_worker.state \\
--loglevel=INFO \\
--logfile={log_dir}/celery_mosaic.log \\
- --concurrency=1 -n worker2@%h
+ --concurrency={settings.MAX_CONCURRENT_MOSAIC_JOBS} -n worker2@%h
Restart=always
[Install]
diff --git a/ohmg/conf/settings.py b/ohmg/conf/settings.py
index 9b64e0a7..6bae135c 100644
--- a/ohmg/conf/settings.py
+++ b/ohmg/conf/settings.py
@@ -303,6 +303,8 @@
# this will be removed once Django is upgraded
SWAP_COORDINATE_ORDER = ast.literal_eval(os.getenv("SWAP_COORDINATE_ORDER", "False"))
+MAX_CONCURRENT_MOSAIC_JOBS = os.getenv("MAX_CONCURRENT_MOSAIC_JOBS", 1)
+
# CONFIGURE CELERY
CELERY_BROKER_URL = os.getenv("BROKER_URL")
CELERY_RESULT_BACKEND = "rpc://"
@@ -327,6 +329,7 @@
"ohmg.georeference.tasks.delete_stale_sessions": {"queue": "background"},
"ohmg.georeference.tasks.delete_preview_vrts": {"queue": "background"},
"ohmg.georeference.tasks.cleanup_existing_tileset": {"queue": "background"},
+ "ohmg.georeference.tasks.run_queued_mosaic_jobs": {"queue": "background"},
"ohmg.georeference.tasks.create_mosaic_cog": {"queue": "mosaic"},
"ohmg.georeference.tasks.create_mosaic_tileset": {"queue": "mosaic"},
}
@@ -336,7 +339,11 @@
"remove_stale_sessions": {
"task": "ohmg.georeference.tasks.delete_stale_sessions",
"schedule": 60.0,
- }
+ },
+ "run_queued_mosaic_jobs": {
+ "task": "ohmg.georeference.tasks.run_queued_mosaic_jobs",
+ "schedule": 15.0,
+ },
}
# note: this is app_label.ModelClass,
diff --git a/ohmg/core/admin.py b/ohmg/core/admin.py
index 5ec5a4a5..84b61476 100644
--- a/ohmg/core/admin.py
+++ b/ohmg/core/admin.py
@@ -61,12 +61,18 @@ class LayerAdmin(admin.ModelAdmin):
class LayerSetAdmin(admin.ModelAdmin):
+ list_display = (
+ "map",
+ "category",
+ "multimask_date",
+ )
raw_id_fields = ("map",)
readonly_fields = (
"layer_display_list",
"extent",
"multimask_extent",
"xyz_tiles_url",
+ "multimask_date",
)
search_fields = ("map__title",)
list_filter = ("category",)
diff --git a/ohmg/core/migrations/0014_alter_permissions_options_and_more.py b/ohmg/core/migrations/0014_alter_permissions_options_and_more.py
new file mode 100644
index 00000000..e692984f
--- /dev/null
+++ b/ohmg/core/migrations/0014_alter_permissions_options_and_more.py
@@ -0,0 +1,32 @@
+# Generated by Django 4.2.27 on 2026-06-23 09:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0013_permissions'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='permissions',
+ options={'default_permissions': (), 'managed': False, 'permissions': (('use_helmert', 'Can use Helmert transformation'), ('queue_mosaic_cog', 'Can queue creation of mosaic COG'), ('queue_mosaic_xyz', 'Can queue creation of mosaic XYZ tileset'))},
+ ),
+ migrations.AddField(
+ model_name='layerset',
+ name='mosaic_geotiff_date',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='layerset',
+ name='multimask_date',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='layerset',
+ name='xyz_tiles_date',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py b/ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py
new file mode 100644
index 00000000..dced664b
--- /dev/null
+++ b/ohmg/core/migrations/0015_remove_layerset_mosaic_geotiff_date_and_more.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.2.27 on 2026-06-23 18:07
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0014_alter_permissions_options_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='layerset',
+ name='mosaic_geotiff_date',
+ ),
+ migrations.RemoveField(
+ model_name='layerset',
+ name='xyz_tiles_date',
+ ),
+ ]
diff --git a/ohmg/core/models/layerset.py b/ohmg/core/models/layerset.py
index a61ecb5b..38e43022 100644
--- a/ohmg/core/models/layerset.py
+++ b/ohmg/core/models/layerset.py
@@ -3,6 +3,7 @@
from typing import TYPE_CHECKING, Iterable, Union
from django.conf import settings
+from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.db import models
from django.contrib.gis.geos import MultiPolygon, Polygon
from django.contrib.postgres.fields import ArrayField
@@ -70,6 +71,7 @@ class Meta:
null=True,
)
tilejson = models.JSONField(null=True, blank=True)
+ multimask_date = models.DateTimeField(blank=True, null=True)
def __str__(self):
return f"{self.map} - {self.category}"
@@ -86,6 +88,28 @@ def layer_display_list(self):
def get_layers(self) -> Iterable["Layer"]:
return self.layer_set.all()
+ def get_latest_cog_job(self):
+ from ohmg.georeference.models import Job
+
+ ct = ContentType.objects.get_for_model(self)
+ job = (
+ Job.objects.filter(operation="layerset_to_cog", target_type=ct, target_id=self.pk)
+ .order_by("-date_queued")
+ .first()
+ )
+ return job
+
+ def get_latest_xyz_job(self):
+ from ohmg.georeference.models import Job
+
+ ct = ContentType.objects.get_for_model(self)
+ job = (
+ Job.objects.filter(operation="layerset_to_xyz", target_type=ct, target_id=self.pk)
+ .order_by("-date_queued")
+ .first()
+ )
+ return job
+
@property
def xyz_tiles_url(self):
if self.xyz_tiles_prefix:
@@ -97,6 +121,17 @@ def xyz_tiles_url(self):
else:
return None
+ @property
+ def xyz_tiles_download_url(self):
+ if self.xyz_tiles_prefix:
+ if settings.ENABLE_S3_STORAGE:
+ base_url = f"{settings.AWS_S3_ENDPOINT_URL}/{settings.AWS_STORAGE_BUCKET_NAME}"
+ else:
+ base_url = f"{settings.SITEURL.rstrip('/')}{settings.MEDIA_URL}"
+ return f"{base_url.rstrip('/')}/{self.xyz_tiles_prefix}/"
+ else:
+ return None
+
@cached_property
def centroid(self):
return Polygon.from_bbox(self.extent).centroid
@@ -146,6 +181,38 @@ def multimask_geojson(self) -> dict:
fc["features"].append(mask_geojson)
return fc
+ def queue_mosaic_cog(self) -> int:
+ """Creates a new job to generate a mosaic cog from this layerset.
+ If a job already exists for this action, it is re-queued.
+
+ Returns the job id."""
+ from ohmg.georeference.models import Job
+
+ j, created = Job.objects.get_or_create(
+ stage="queued",
+ operation="layerset_to_cog",
+ target_id=self.pk,
+ target_type=ContentType.objects.get_for_model(self),
+ )
+ j.enqueue()
+ return j.pk
+
+ def queue_mosaic_tileset(self) -> int:
+ """Creates a new job to generate a XYZ tileset from this layerset.
+ If a job already exists for this action, it is re-queued.
+
+ Returns the job id."""
+ from ohmg.georeference.models import Job
+
+ j, created = Job.objects.get_or_create(
+ stage="queued",
+ operation="layerset_to_xyz",
+ target_id=self.pk,
+ target_type=ContentType.objects.get_for_model(self),
+ )
+ j.enqueue()
+ return j.pk
+
def save(self, set_tilejson: bool = False, *args, **kwargs):
if self._state.adding is False:
extents = self.get_layers().values_list("extent", flat=True)
diff --git a/ohmg/core/models/permissions.py b/ohmg/core/models/permissions.py
index 2b68bbb3..912ba342 100644
--- a/ohmg/core/models/permissions.py
+++ b/ohmg/core/models/permissions.py
@@ -16,4 +16,8 @@ class Meta:
default_permissions = () # disable "add", "change", "delete"
# and "view" default permissions
- permissions = (("use_helmert", "Can use Helmert transformation"),)
+ permissions = (
+ ("use_helmert", "Can use Helmert transformation"),
+ ("queue_mosaic_cog", "Can queue creation of mosaic COG"),
+ ("queue_mosaic_xyz", "Can queue creation of mosaic XYZ tileset"),
+ )
diff --git a/ohmg/core/utils/s3.py b/ohmg/core/utils/s3.py
index 743a6b59..515c7ae3 100644
--- a/ohmg/core/utils/s3.py
+++ b/ohmg/core/utils/s3.py
@@ -1,10 +1,12 @@
+from pathlib import Path
+
from django.conf import settings
+
def get_boto3_s3_client():
import boto3
if settings.AWS_ACCESS_KEY_ID:
-
return boto3.client(
"s3",
region_name=settings.AWS_S3_REGION_NAME,
@@ -14,3 +16,26 @@ def get_boto3_s3_client():
)
else:
return None
+
+
+def upload_file_to_bucket(local_path, bucket_path, client=None):
+ if not client:
+ client = get_boto3_s3_client()
+ client.upload_file(local_path, settings.AWS_STORAGE_BUCKET_NAME, bucket_path)
+
+
+# def upload_directory_to_bucket(local_path, bucket_path, client=None):
+# if not client:
+# client = get_boto3_s3_client()
+
+
+def upload_directory_to_bucket(local_path: Path, bucket_path: str, client=None):
+ if not client:
+ client = get_boto3_s3_client()
+ for file_path in local_path.rglob("*"):
+ if file_path.is_file():
+ # Get relative path, format for S3 (replace backslashes)
+ s3_key = str(file_path.relative_to(local_path)).replace("\\", "/")
+ if bucket_path:
+ s3_key = f"{bucket_path}/{s3_key}"
+ upload_file_to_bucket(file_path, s3_key, client=client)
diff --git a/ohmg/core/views.py b/ohmg/core/views.py
index baf30225..fe6fee49 100644
--- a/ohmg/core/views.py
+++ b/ohmg/core/views.py
@@ -1,5 +1,6 @@
import json
import logging
+from datetime import datetime
from urllib.parse import quote
from django.conf import settings
@@ -25,6 +26,7 @@
JsonResponseFail,
JsonResponseNotFound,
JsonResponseSuccess,
+ JsonResponseUnauthorized,
generate_ohmg_context,
validate_post_request,
)
@@ -415,7 +417,16 @@ def get(self, request, pk, derivative, resource=""):
class LayerSetView(View):
- @method_decorator(validate_post_request(operations=["bulk-classify-layers", "set-mask"]))
+ @method_decorator(
+ validate_post_request(
+ operations=[
+ "bulk-classify-layers",
+ "set-mask",
+ "queue-cog-creation",
+ "queue-tileset-creation",
+ ]
+ )
+ )
def post(self, request):
body = json.loads(request.body)
operation = body.get("operation")
@@ -475,9 +486,29 @@ def post(self, request):
logger.debug(f"removing mask from layer {layer.slug} ({layer.pk})")
layer.mask = None
layer.save(set_extent=False, skip_map_lookup_update=True)
+ layerset.multimask_date = datetime.now()
+ layerset.save()
layerset.map.update_item_lookup()
return JsonResponseSuccess()
+ if operation == "queue-cog-creation":
+ if not request.user.has_perm("core.queue_mosaic_cog"):
+ return JsonResponseUnauthorized()
+ layerset = LayerSet.objects.get(
+ map_id=payload["map-id"], category__slug=payload["category"]
+ )
+ job_id = layerset.queue_mosaic_cog()
+ return JsonResponseSuccess(payload={"job": job_id})
+
+ if operation == "queue-tileset-creation":
+ if not request.user.has_perm("core.queue_mosaic_xyz"):
+ return JsonResponseUnauthorized()
+ layerset = LayerSet.objects.get(
+ map_id=payload["map-id"], category__slug=payload["category"]
+ )
+ job_id = layerset.queue_mosaic_tileset()
+ return JsonResponseSuccess(payload={"job": job_id})
+
class LayersetDerivativeView(View):
def get(self, request, mapid, category, derivative):
diff --git a/ohmg/frontend/static/css/fancy-icons.css b/ohmg/frontend/static/css/fancy-icons.css
index 15807af4..c6a9ee7b 100644
--- a/ohmg/frontend/static/css/fancy-icons.css
+++ b/ohmg/frontend/static/css/fancy-icons.css
@@ -14,6 +14,7 @@
--pushpin-svg: url(/static/icons/pushpin-60.svg);
--envelope-svg: url(/static/icons/envelope-60.svg);
--clipboard-svg: url(/static/icons/clipboard-60.svg);
+ --wrench-svg: url(/static/icons/noun-spanner-937070.svg);
}
i.fancy {
@@ -120,3 +121,8 @@ i.fancy-xs {
mask-image: var(--clipboard-svg);
-webkit-mask-image: var(--clipboard-svg);
}
+
+.i-wrench {
+ mask-image: var(--wrench-svg);
+ -webkit-mask-image: var(--wrench-svg);
+}
diff --git a/ohmg/frontend/static/fonts/fira-code/fira-code.css b/ohmg/frontend/static/fonts/fira-code/fira-code.css
new file mode 100644
index 00000000..12202ee9
--- /dev/null
+++ b/ohmg/frontend/static/fonts/fira-code/fira-code.css
@@ -0,0 +1,63 @@
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bh0NSDulI.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bh2dSDulI.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bh0dSDulI.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bh3tSDulI.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* symbols2 */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bhZ_Wmh2uX.woff2) format('woff2');
+ unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bh09SDulI.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Fira Code';
+ font-style: normal;
+ font-weight: 300 700;
+ font-display: swap;
+ src: url(./uU9NCBsR6Z2vfE9aq3bh3dSD.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh09SDulI.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh09SDulI.woff2
new file mode 100644
index 00000000..eac15ae2
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh09SDulI.woff2 differ
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh0NSDulI.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh0NSDulI.woff2
new file mode 100644
index 00000000..23c93fe6
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh0NSDulI.woff2 differ
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh0dSDulI.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh0dSDulI.woff2
new file mode 100644
index 00000000..522f3e1a
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh0dSDulI.woff2 differ
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh2dSDulI.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh2dSDulI.woff2
new file mode 100644
index 00000000..de53c244
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh2dSDulI.woff2 differ
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh3dSD.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh3dSD.woff2
new file mode 100644
index 00000000..ed70084f
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh3dSD.woff2 differ
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh3tSDulI.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh3tSDulI.woff2
new file mode 100644
index 00000000..6c75fd94
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bh3tSDulI.woff2 differ
diff --git a/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bhZ_Wmh2uX.woff2 b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bhZ_Wmh2uX.woff2
new file mode 100644
index 00000000..048e4cc4
Binary files /dev/null and b/ohmg/frontend/static/fonts/fira-code/uU9NCBsR6Z2vfE9aq3bhZ_Wmh2uX.woff2 differ
diff --git a/ohmg/frontend/static/fonts/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMZhLw.woff2 b/ohmg/frontend/static/fonts/josefin/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMZhLw.woff2
similarity index 100%
rename from ohmg/frontend/static/fonts/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMZhLw.woff2
rename to ohmg/frontend/static/fonts/josefin/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMZhLw.woff2
diff --git a/ohmg/frontend/static/fonts/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMhhLzTs.woff2 b/ohmg/frontend/static/fonts/josefin/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMhhLzTs.woff2
similarity index 100%
rename from ohmg/frontend/static/fonts/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMhhLzTs.woff2
rename to ohmg/frontend/static/fonts/josefin/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMhhLzTs.woff2
diff --git a/ohmg/frontend/static/fonts/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMlhLzTs.woff2 b/ohmg/frontend/static/fonts/josefin/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMlhLzTs.woff2
similarity index 100%
rename from ohmg/frontend/static/fonts/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMlhLzTs.woff2
rename to ohmg/frontend/static/fonts/josefin/Qw3PZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQbMlhLzTs.woff2
diff --git a/ohmg/frontend/static/fonts/josephine.css b/ohmg/frontend/static/fonts/josefin/josefin.css
similarity index 100%
rename from ohmg/frontend/static/fonts/josephine.css
rename to ohmg/frontend/static/fonts/josefin/josefin.css
diff --git a/ohmg/frontend/static/icons/noun-spanner-937070.svg b/ohmg/frontend/static/icons/noun-spanner-937070.svg
new file mode 100644
index 00000000..390e0748
--- /dev/null
+++ b/ohmg/frontend/static/icons/noun-spanner-937070.svg
@@ -0,0 +1,22 @@
+
+
diff --git a/ohmg/frontend/svelte_components/package.json b/ohmg/frontend/svelte_components/package.json
index 417630bd..143e1693 100644
--- a/ohmg/frontend/svelte_components/package.json
+++ b/ohmg/frontend/svelte_components/package.json
@@ -23,7 +23,8 @@
"rollup-plugin-css-only": "^3.1.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.5",
- "svelte": "^4.0.0"
+ "svelte": "^4.0.0",
+ "typescript": "^5.0.0"
},
"dependencies": {
"@petamoriken/float16": "3.4.7",
@@ -37,6 +38,7 @@
"ol-pmtiles": "^1.0.2",
"sirv-cli": "^1.0.0",
"svelte-markdown": "^0.4.1",
+ "svelte-preprocess": "^6.0.5",
"svelte-select": "^5.8.3",
"svelte-tablesort": "^1.1.0"
}
diff --git a/ohmg/frontend/svelte_components/pnpm-lock.yaml b/ohmg/frontend/svelte_components/pnpm-lock.yaml
index 085eed19..32a87ad8 100644
--- a/ohmg/frontend/svelte_components/pnpm-lock.yaml
+++ b/ohmg/frontend/svelte_components/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
svelte-markdown:
specifier: ^0.4.1
version: 0.4.1(svelte@4.2.20)
+ svelte-preprocess:
+ specifier: ^6.0.5
+ version: 6.0.5(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8))(postcss@8.5.8)(svelte@4.2.20)(typescript@5.9.3)
svelte-select:
specifier: ^5.8.3
version: 5.8.3
@@ -99,6 +102,9 @@ importers:
svelte:
specifier: ^4.0.0
version: 4.2.20
+ typescript:
+ specifier: ^5.0.0
+ version: 5.9.3
packages:
@@ -1160,6 +1166,43 @@ packages:
peerDependencies:
svelte: ^4.0.0
+ svelte-preprocess@6.0.5:
+ resolution: {integrity: sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg==}
+ engines: {node: '>= 18.0.0'}
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ postcss: ^7 || ^8
+ postcss-load-config: '>=3'
+ pug: ^3.0.0
+ sass: ^1.26.8
+ stylus: '>=0.55'
+ sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0
+ svelte: ^4.0.0 || ^5.0.0-next.100 || ^5.0.0
+ typescript: ^5.0.0 || ^6.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+
svelte-select@5.8.3:
resolution: {integrity: sha512-nQsvflWmTCOZjssdrNptzfD1Ok45hHVMTL5IHay5DINk7dfu5Er+8KsVJnZMJdSircqtR0YlT4YkCFlxOUhVPA==}
@@ -1198,6 +1241,11 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
unzipit@1.4.3:
resolution: {integrity: sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==}
engines: {node: '>=12'}
@@ -2294,6 +2342,15 @@ snapshots:
marked: 5.1.2
svelte: 4.2.20
+ svelte-preprocess@6.0.5(@babel/core@7.29.0)(postcss-load-config@3.1.4(postcss@8.5.8))(postcss@8.5.8)(svelte@4.2.20)(typescript@5.9.3):
+ dependencies:
+ svelte: 4.2.20
+ optionalDependencies:
+ '@babel/core': 7.29.0
+ postcss: 8.5.8
+ postcss-load-config: 3.1.4(postcss@8.5.8)
+ typescript: 5.9.3
+
svelte-select@5.8.3:
dependencies:
svelte-floating-ui: 1.5.8
@@ -2344,6 +2401,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
+ typescript@5.9.3: {}
+
unzipit@1.4.3:
dependencies:
uzip-module: 1.0.3
diff --git a/ohmg/frontend/svelte_components/rollup.config.js b/ohmg/frontend/svelte_components/rollup.config.js
index fadb672f..85378337 100644
--- a/ohmg/frontend/svelte_components/rollup.config.js
+++ b/ohmg/frontend/svelte_components/rollup.config.js
@@ -4,6 +4,7 @@ import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
import livereload from 'rollup-plugin-livereload';
import css from 'rollup-plugin-css-only';
+import { sveltePreprocess } from 'svelte-preprocess';
const production = !process.env.ROLLUP_WATCH;
@@ -19,6 +20,7 @@ function componentExportDetails(componentName) {
},
plugins: [
svelte({
+ preprocess: sveltePreprocess(),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
@@ -73,6 +75,7 @@ export default (cliArgs) => {
'Map',
'Sessions',
'Maps',
+ 'Jobs',
'Profiles',
'LatestBlogPosts',
'MapBrowse',
diff --git a/ohmg/frontend/svelte_components/src/components/base/Modal.svelte b/ohmg/frontend/svelte_components/src/components/base/Modal.svelte
index 96112bbb..d16d7cbb 100644
--- a/ohmg/frontend/svelte_components/src/components/base/Modal.svelte
+++ b/ohmg/frontend/svelte_components/src/components/base/Modal.svelte
@@ -1,4 +1,4 @@
-
-
+
+ Information here Job submitted. You can track its completion on the jobs page. Submit mosaic COG generation to queue? Submit XYZ tileset generation to queue?
Once layers have been trimmed in the MultiMask they can be combined into a single + mosaic. We provide access to this mosaic in the form of file downloads, web service endpoints, and direct integrations + into other platforms. Read more in the docs
+If the MultiMask has been updated after one of these artifacts was generated, dates will be shown in + red and you can queue that mosaic to be rebuilt.
+- Once layers have been trimmed in the MultiMask, a background process can be run - to combine them into a single mosaic file, which serves as a basis for downloads and web services. - If you see n/a below, the mosaic has not yet been created. You can still access - individual layers through the Georeferenced section, or view the mosaic in Allmaps - (powered by IIIF). -
-{`${ls.xyz_tiles_url}/{z}/{x}/{y}.png`}
- Masks saved successfully.
-Error! MultiMask not saved. The following layers have errors:
{@html errMsg}
You must fix these errors before you can save your work. Keep in mind it is sometimes easier to remove and recreate a mask than track down and fix specific issues.
-+ +
{xyzUrl}
+