Add proxy for managing tiles

This commit is contained in:
Eli Ribble 2026-03-11 14:28:59 +00:00
parent d6407933f8
commit 3743d63692
No known key found for this signature in database
10 changed files with 316 additions and 198 deletions

View file

@ -24,7 +24,7 @@ func AddRoutes(r chi.Router) {
r.Method("POST", "/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentPost))
r.Method("GET", "/leads", authenticatedHandlerJSON(listLead))
r.Method("POST", "/leads", authenticatedHandlerJSONPost(postLeads))
r.Method("GET", "/tile//{z}/{y}/{x}", auth.NewEnsureAuth(getTile))
r.Method("GET", "/tile/{z}/{y}/{x}", auth.NewEnsureAuth(getTile))
// Unauthenticated endpoints
r.Get("/district", apiGetDistrict)

View file

@ -1,12 +1,21 @@
package api
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/platform/imagetile"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"
)
func getTile(w http.ResponseWriter, r *http.Request, org *models.Organization, user *models.User) {
@ -29,5 +38,61 @@ func getTile(w http.ResponseWriter, r *http.Request, org *models.Organization, u
http.Error(w, "can't parse x as an integer", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "%d, %d, %d", x, y, z)
err = handleTile(r.Context(), w, org, uint(z), uint(y), uint(x))
if err != nil {
log.Error().Err(err).Msg("failed to do tile")
http.Error(w, "failed to do tile", http.StatusInternalServerError)
return
}
}
func handleTile(ctx context.Context, w http.ResponseWriter, org *models.Organization, z, y, x uint) error {
if org.ArcgisMapServiceID.IsNull() {
return fmt.Errorf("no map service ID set")
}
map_service_id := org.ArcgisMapServiceID.MustGet()
tile_path := fmt.Sprintf("%s/tile-cache/%s/%d/%d/%d.raw", config.FilesDirectory, map_service_id, z, y, x)
file, err := os.Open(tile_path)
if err == nil {
defer file.Close()
img, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("readall from %s: %w", tile_path, err)
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(img)))
_, err = io.Copy(w, bytes.NewBuffer(img))
if err != nil {
return fmt.Errorf("copy bytes from %s: %w", tile_path)
}
return nil
}
content, err := imagetile.ImageAtTile(ctx, org, uint(z), uint(y), uint(x))
if err != nil {
if errors.Is(err, imagetile.ErrNoTile) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
_, err = io.Copy(w, bytes.NewBuffer(content))
if err != nil {
return fmt.Errorf("write image file: %w", err)
}
return nil
}
return fmt.Errorf("image at tile: %w", err)
}
parent := filepath.Dir(tile_path)
err = os.MkdirAll(parent, 0750)
if err != nil {
return fmt.Errorf("mkdirall: %w", err)
}
err = os.WriteFile(tile_path, content, 0644)
if err != nil {
return fmt.Errorf("write image file: %w", err)
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
_, err = io.Copy(w, bytes.NewBuffer(content))
if err != nil {
return fmt.Errorf("write image file: %w", err)
}
return nil
}

View file

@ -0,0 +1,153 @@
// A map that shows multiple single point locations.
// Points have additional detail popups.
// The background layer is proxied from Arcgis
class MapProxiedArcgisTile extends HTMLElement {
static observedAttributes = ["latitude", "longitude"];
constructor() {
super();
// Create a shadow DOM
this.attachShadow({ mode: "open" });
// Initial render
this.render();
// Keep track of any 'on' calls to add to the map as soon as we create it.
this._preOns = [];
this._map = null;
this._markers = [];
}
attributeChangedCallback(name, old_value, new_value) {
//console.log("map-arcgis-tile: attribute changed", name, old_value, new_value);
if ((name == "latitude" || name == "longitude") && this._map != null) {
const latitude = parseFloat(this.getAttribute("latitude"));
const longitude = parseFloat(this.getAttribute("longitude"));
this._map.jumpTo({
center: [longitude, latitude],
zoom: 19,
});
}
}
// Lifecycle: when element is added to the DOM
connectedCallback() {
// Initialize the map when the element is added to the DOM
setTimeout(() => this._initializeMap(), 0);
}
disconnectedCallback() {
if (this._map) {
this._map.remove();
}
}
_initializeMap() {
const latitude = parseFloat(this.getAttribute("latitude"));
const longitude = parseFloat(this.getAttribute("longitude"));
const organization_id = Number(this.getAttribute("organization-id") || 0);
const tegola = this.getAttribute("tegola");
const url_tiles = this.getAttribute("url-tiles");
const mapElement = this.shadowRoot.querySelector("#map");
this._map = new maplibregl.Map({
center: [longitude, latitude],
container: mapElement,
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
zoom: 19,
});
this._map.on("load", () => {
if (organization_id != 0) {
this._map.addSource("tegola", {
type: "vector",
tiles: [
`${tegola}maps/nidus/{z}/{x}/{y}?id=${organization_id}&organization_id=${organization_id}`,
],
});
this._map.addLayer({
id: "service-area",
source: "tegola",
"source-layer": "service-area-bounds",
type: "line",
paint: {
"line-color": "#f00",
},
});
}
this._map.addSource("flyover", {
type: "raster",
tiles: [url_tiles],
});
this._map.addLayer({
id: "flyover-layer",
source: "flyover",
type: "raster",
});
this.dispatchEvent(new CustomEvent("load"), {
bubbles: true,
composed: true, // Allows event to cross shadow DOM boundary
detail: {
map: this,
},
});
});
for (const on of this._preOns) {
this._map.on(on.a, on.b);
}
}
// Initial render of component
render() {
this.shadowRoot.innerHTML = `
<style>
@import url("//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
#map {
height: 100%;
width: 100%;
}
</style>
<div id="map"></div>
`;
}
addLayer(a) {
return this._map.addLayer(a);
}
addSource(a, b) {
return this._map.addSource(a, b);
}
jumpTo(args) {
return this._map.jumpTo(args);
}
on(a, b) {
if (this._map != null) {
return this._map.on(a, b);
} else {
this._preOns.push({ a: a, b: b });
}
}
once(a, b) {
return this._map.once(a, b);
}
queryRenderedFeatures(a) {
return this._map.queryRenderedFeatures(a);
}
FitBounds(bounds, options) {
return this._map.fitBounds(bounds, options);
}
SetLayoutProperty(layout, property, value) {
return this._map.setLayoutProperty(layout, property, value);
}
SetMarkers(markers) {
console.log("Setting map markers", markers);
this._markers.forEach((marker) => marker.remove());
this._markers = markers;
for (let m of markers) {
m.addTo(this._map);
}
}
}
customElements.define("map-proxied-arcgis-tile", MapProxiedArcgisTile);

View file

@ -1,114 +0,0 @@
var map = null;
// A map that just shows a bunch of markers, it can't change them
class MapWithMarkers extends HTMLElement {
constructor() {
super();
// Create a shadow DOM
this.attachShadow({mode: "open" });
// Initial render
this.render();
this._map = null;
// markers shown on the map. Should be none or 1, generally.
this._markers = [];
}
// Lifecycle: when element is added to the DOM
connectedCallback() {
// Initialize the map when the element is added to the DOM
setTimeout(() => this._initializeMap(), 0);
}
disconnectedCallback() {
if (this._map) {
this._map.remove();
}
}
_initializeMap() {
console.log("Setting up the map...");
const apiKey = this.getAttribute("api-key");
const lat = Number(this.getAttribute('latitude') || 36.2);
const lng = Number(this.getAttribute('longitude') || -119.2);
const zoom = Number(this.getAttribute('zoom') || 15);
mapboxgl.accessToken = apiKey;
const mapElement = this.shadowRoot.querySelector("#map");
this._map = new mapboxgl.Map({
container: mapElement,
center: {
lat: lat,
lng: lng,
},
style: 'mapbox://styles/mapbox/streets-v12', // style URL
zoom: zoom,
});
this._map.on("load", () => {
console.log("map loaded");
this.dispatchEvent(new CustomEvent('load'), {
bubbles: true,
composed: true, // Allows event to cross shadow DOM boundary
detail: {
map: this
}
});
});
this._markers = [];
}
// Initial render of component
render() {
this.shadowRoot.innerHTML = `
<link href='https://api.tiles.mapbox.com/mapbox-gl-js/v0.53.0/mapbox-gl.css' rel='stylesheet' />
<style>
.map-container {
background-color: #e9ecef;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
height: 500px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 20px;
}
#map {
height: 500px;
width:100%;
margin-bottom: 10px;
}
#map img {
max-width: none;
min-width: 0px;
height: auto;
}
</style>
<div id="map-container" class="map-container">
<div id="map"></div>
</div>
`;
}
jumpTo(args) {
this._map.jumpTo(args);
}
clearMarkers() {
this._markers.forEach((marker) => marker.remove());
}
addMarker(coords, color) {
console.log("Add marker", coords, color);
const el = document.createElement("div");
el.id = "marker";
const marker = new mapboxgl.Marker({
color: color,
scale: 1.5,
}).setLngLat(coords).addTo(this._map);
this._markers.push(marker);
}
}
customElements.define('map-with-markers', MapWithMarkers);

View file

@ -10,8 +10,7 @@
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="https://unpkg.com/@esri/maplibre-arcgis@1.1.0/dist/umd/maplibre-arcgis.min.js"></script>
<script src="/static/js/map-arcgis-tile.js"></script>
<script src="/static/js/map-proxied-arcgis-tile.js"></script>
<script src="/static/js/map-multipoint.js"></script>
<script src="/static/js/time-relative.js"></script>
<script>
@ -38,9 +37,13 @@
// Form fields for the selected task
form: {
poolCondition: "",
poolShape: "",
poolLocation: {
latitude: 0,
longitude: 0,
},
ownerContact: "",
residentContact: "",
poolShape: "",
},
// Computed: track which fields have changed
@ -123,6 +126,8 @@
// Populate form with task values
this.form = {
latitude: task.location.latitude,
longitude: task.location.longitude,
poolCondition: task.condition || "",
ownerContact: task.ownerContact || "",
residentContact: task.residentContact || "",
@ -274,7 +279,7 @@
longitude: event.detail.lng,
};
map.SetMarkers([loc]);
this.poolLocations[signal_id] = loc;
this.form.poolLocation[signal_id] = loc;
},
};
}
@ -427,6 +432,30 @@
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold"
>Longitude:</label
>
<div class="col-sm-9">
<input
type="text"
class="form-control"
x-model="form.longitude"
:class="{ 'border-warning': form.longitude !== originalValues.longitude }"
/>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Latitude:</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
x-model="form.latitude"
:class="{ 'border-warning': form.latitude !== originalValues.latitude }"
/>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold"
>Pool Condition:</label
@ -497,16 +526,17 @@
<!-- Aerial Image Placeholder -->
<div class="map-container">
<map-arcgis-tile
<map-proxied-arcgis-tile
class="map"
arcgis-access-token="{{ .C.ArcgisAccessToken }}"
organization-id="{{ .Organization.ID }}"
tegola="{{ .URL.Tegola }}"
{{ .C.URLTiles }}
@map-click="updatePoolLocation($event, selectedTask.id)"
:latitude="selectedTask?.location.latitude ?? 0"
:longitude="selectedTask?.location.longitude ?? 0"
>
</map-arcgis-tile>
</map-proxied-arcgis-tile>
</div>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -2,15 +2,36 @@ package imagetile
import (
"context"
"embed"
"errors"
"fmt"
"github.com/Gleipnir-Technology/arcgis-go"
"github.com/Gleipnir-Technology/arcgis-go/fieldseeker"
"github.com/Gleipnir-Technology/nidus-sync/background"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
//"github.com/rs/zerolog/log"
"github.com/rs/zerolog/log"
)
//go:embed empty-tile.png
var emptyTileFS embed.FS
var ErrNoTile = errors.New("used placeholder tile")
var clientByOrgID = make(map[int32]*fieldseeker.FieldSeeker, 0)
func ImageAtPoint(ctx context.Context, org *models.Organization, level uint, lat, lng float64) ([]byte, error) {
fssync, err := getFieldseeker(ctx, org)
if err != nil {
return []byte{}, fmt.Errorf("create fssync: %w", err)
}
map_service, err := aerialImageService(ctx, fssync.Arcgis)
if err != nil {
return []byte{}, fmt.Errorf("no map service: %w", err)
}
return map_service.TileGPS(ctx, level, lat, lng)
}
func ImageAtTile(ctx context.Context, org *models.Organization, level, y, x uint) ([]byte, error) {
oauth, err := background.GetOAuthForOrg(ctx, org)
if err != nil {
return []byte{}, fmt.Errorf("get oauth for org: %w", err)
@ -26,7 +47,20 @@ func ImageAtPoint(ctx context.Context, org *models.Organization, level uint, lat
if err != nil {
return []byte{}, fmt.Errorf("no map service: %w", err)
}
return map_service.TileGPS(ctx, level, lat, lng)
data, e := map_service.Tile(ctx, level, y, x)
if e != nil {
log.Error().Err(e).Msg("error getting tile")
return []byte{}, fmt.Errorf("tile: %w", e)
}
// No data at this location, so supply the empty tile placeholder
if len(data) == 0 {
empty, err := emptyTileFS.ReadFile("empty-tile.png")
if err != nil {
return []byte{}, fmt.Errorf("read empty tile: %w", err)
}
return empty, ErrNoTile
}
return data, nil
}
func aerialImageService(ctx context.Context, gis *arcgis.ArcGIS) (*arcgis.MapService, error) {
@ -39,3 +73,19 @@ func aerialImageService(ctx context.Context, gis *arcgis.ArcGIS) (*arcgis.MapSer
}
return nil, fmt.Errorf("non found")
}
func getFieldseeker(ctx context.Context, org *models.Organization) (*fieldseeker.FieldSeeker, error) {
fssync, ok := clientByOrgID[org.ID]
if ok {
return fssync, nil
}
oauth, err := background.GetOAuthForOrg(ctx, org)
if err != nil {
return nil, fmt.Errorf("get oauth for org: %w", err)
}
fssync, err = background.NewFieldSeeker(
ctx,
oauth,
)
clientByOrgID[org.ID] = fssync
return fssync, nil
}

View file

@ -16,7 +16,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/db/sql"
"github.com/Gleipnir-Technology/nidus-sync/html"
"github.com/go-chi/chi/v5"
//"github.com/rs/zerolog/log"
"github.com/rs/zerolog/log"
"github.com/stephenafamo/scan"
/*
"github.com/Gleipnir-Technology/nidus-sync/db"
@ -314,79 +314,6 @@ func getStatusByID(w http.ResponseWriter, r *http.Request) {
)
}
/*
func postQuick(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(32 << 10) // 32 MB buffer
if err != nil {
respondError(w, "Failed to parse form", err, http.StatusBadRequest)
return
}
lat := r.FormValue("latitude")
lng := r.FormValue("longitude")
comments := r.FormValue("comments")
//photos := r.FormValue("photos")
latitude, err := strconv.ParseFloat(lat, 64)
if err != nil {
respondError(w, "Failed to create parse latitude", err, http.StatusBadRequest)
return
}
longitude, err := strconv.ParseFloat(lng, 64)
if err != nil {
respondError(w, "Failed to create parse longitude", err, http.StatusBadRequest)
return
}
u, err := GenerateReportID()
if err != nil {
respondError(w, "Failed to create quick report public ID", err, http.StatusInternalServerError)
return
}
c, err := h3utils.GetCell(longitude, latitude, 15)
setter := models.PublicreportQuickSetter{
Created: omit.From(time.Now()),
Comments: omit.From(comments),
//Location: omitnull.From(fmt.Sprintf("ST_GeometryFromText(Point(%s %s))", longitude, latitude)),
H3cell: omitnull.From(c.String()),
PublicID: omit.From(u),
ReporterEmail: omit.From(""),
ReporterPhone: omit.From(""),
}
quick, err := models.PublicreportQuicks.Insert(&setter).One(r.Context(), db.PGInstance.BobDB)
if err != nil {
respondError(w, "Failed to create database record", err, http.StatusInternalServerError)
return
}
_, err = psql.Update(
um.Table("publicreport.quick"),
um.SetCol("location").To(fmt.Sprintf("ST_GeometryFromText('Point(%f %f)')", longitude, latitude)),
um.Where(psql.Quote("id").EQ(psql.Arg(quick.ID))),
).Exec(r.Context(), db.PGInstance.BobDB)
if err != nil {
respondError(w, "Failed to insert publicreport", err, http.StatusInternalServerError)
return
}
log.Info().Float64("latitude", latitude).Float64("longitude", longitude).Msg("Got upload")
photoSetters := make([]*models.PublicreportQuickPhotoSetter, 0)
uploads, err := extractPhotoUploads(r)
if err != nil {
respondError(w, "Failed to extract photo uploads", err, http.StatusInternalServerError)
return
}
for _, u := range uploads {
photoSetters = append(photoSetters, &models.PublicreportQuickPhotoSetter{
Filename: omit.From(u.Filename),
Size: omit.From(u.Size),
UUID: omit.From(u.UUID),
})
}
err = quick.InsertQuickPhotos(r.Context(), db.PGInstance.BobDB, photoSetters...)
if err != nil {
respondError(w, "Failed to create photo records", err, http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", u), http.StatusFound)
}
*/
func sanitizeReportID(r string) string {
result := ""
for _, char := range r {

View file

@ -2,9 +2,12 @@ package sync
import (
"context"
"fmt"
"html/template"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/background"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/html"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
@ -13,6 +16,7 @@ import (
type contentReviewPool struct {
ArcgisAccessToken string
URLTiles template.HTMLAttr
}
type contentReviewRoot struct{}
@ -30,6 +34,7 @@ func getReviewPool(ctx context.Context, r *http.Request, org *models.Organizatio
}
return html.NewResponse("sync/review/pool.html", contentReviewPool{
ArcgisAccessToken: access_token,
URLTiles: template.HTMLAttr(fmt.Sprintf(`url-tiles="%s"`, config.MakeURLNidus("/api/tile/{z}/{y}/{x}"))),
}), nil
}
func getReviewRoot(ctx context.Context, r *http.Request, org *models.Organization, user *models.User) (*html.Response[contentReviewRoot], *nhttp.ErrorWithStatus) {

View file

@ -16,6 +16,7 @@ func audioFileContentWrite(audioUUID uuid.UUID, body io.Reader) error {
}
var collectionToExtension map[Collection]string = map[Collection]string{
CollectionAudioNormalized: "ogg",
CollectionAudioRaw: "raw",
CollectionAudioTranscoded: "ogg",
CollectionCSV: "csv",
@ -24,6 +25,7 @@ var collectionToExtension map[Collection]string = map[Collection]string{
CollectionImageRaw: "raw",
}
var collectionToSubdir map[Collection]string = map[Collection]string{
CollectionAudioNormalized: "audio-normalized",
CollectionAudioRaw: "audio-raw",
CollectionAudioTranscoded: "audio-transcoded",
CollectionCSV: "csv",