diff --git a/background/summary.go b/background/summary.go index fc6b528a..dc6b701f 100644 --- a/background/summary.go +++ b/background/summary.go @@ -20,6 +20,7 @@ import ( func updateSummaryTables(ctx context.Context, org *models.Organization) { updateSummaryMosquitoSource(ctx, org) updateSummaryServiceRequest(ctx, org) + updateSummaryTrap(ctx, org) } func aggregateAtResolution(ctx context.Context, resolution int, org_id int32, type_ enums.H3aggregationtype, cells []h3.Cell) error { @@ -70,7 +71,6 @@ func aggregateAtResolution(ctx context.Context, resolution int, org_id int32, ty } func updateSummaryMosquitoSource(ctx context.Context, org *models.Organization) { - log.Info().Int("org_id", int(org.ID)).Msg("Getting point locations") point_locations, err := org.Pointlocations().All(ctx, db.PGInstance.BobDB) if err != nil { log.Error().Err(err).Msg("Failed to get all point locations") @@ -80,7 +80,6 @@ func updateSummaryMosquitoSource(ctx context.Context, org *models.Organization) log.Info().Int("org_id", int(org.ID)).Msg("No updates to perform") return } - log.Info().Int("count", len(point_locations)).Msg("Summarizing point locations") cells := make([]h3.Cell, 0) for _, p := range point_locations { @@ -104,7 +103,6 @@ func updateSummaryMosquitoSource(ctx context.Context, org *models.Organization) } func updateSummaryServiceRequest(ctx context.Context, org *models.Organization) { - log.Info().Int("org_id", int(org.ID)).Msg("Getting service requests") service_requests, err := org.Servicerequests().All(ctx, db.PGInstance.BobDB) if err != nil { log.Error().Err(err).Msg("Failed to get all service requests") @@ -114,7 +112,6 @@ func updateSummaryServiceRequest(ctx context.Context, org *models.Organization) log.Info().Int("org_id", int(org.ID)).Msg("No updates to perform") return } - log.Info().Int("count", len(service_requests)).Msg("Summarizing point locations") cells := make([]h3.Cell, 0) for _, p := range service_requests { @@ -135,3 +132,34 @@ func updateSummaryServiceRequest(ctx context.Context, org *models.Organization) } } } + +func updateSummaryTrap(ctx context.Context, org *models.Organization) { + traps, err := org.Traplocations().All(ctx, db.PGInstance.BobDB) + if err != nil { + log.Error().Err(err).Msg("Failed to get all trap locations") + return + } + if len(traps) == 0 { + log.Info().Int("org_id", int(org.ID)).Msg("No updates to perform") + return + } + + cells := make([]h3.Cell, 0) + for _, t := range traps { + if t.H3cell.IsNull() { + continue + } + cell, err := h3utils.ToCell(t.H3cell.MustGet()) + if err != nil { + log.Error().Err(err).Msg("Failed to get geometry point") + continue + } + cells = append(cells, cell) + } + for i := range 16 { + err = aggregateAtResolution(ctx, i, org.ID, enums.H3aggregationtypeTrap, cells) + if err != nil { + log.Error().Err(err).Int("resolution", i).Msg("Failed to aggregate trap") + } + } +} diff --git a/db/dbinfo/fieldseeker.traplocation.bob.go b/db/dbinfo/fieldseeker.traplocation.bob.go index cc3cda90..27aa9951 100644 --- a/db/dbinfo/fieldseeker.traplocation.bob.go +++ b/db/dbinfo/fieldseeker.traplocation.bob.go @@ -321,6 +321,15 @@ var FieldseekerTraplocations = Table[ Generated: false, AutoIncr: false, }, + H3cell: column{ + Name: "h3cell", + DBType: "h3index", + Default: "GENERATED", + Comment: "", + Nullable: true, + Generated: true, + AutoIncr: false, + }, }, Indexes: fieldseekerTraplocationIndexes{ TraplocationPkey: index{ @@ -401,11 +410,12 @@ type fieldseekerTraplocationColumns struct { Geospatial column Version column OrganizationID column + H3cell column } func (c fieldseekerTraplocationColumns) AsSlice() []column { return []column{ - c.Objectid, c.Name, c.Zone, c.Habitat, c.Priority, c.Usetype, c.Active, c.Description, c.Accessdesc, c.Comments, c.Externalid, c.Nextactiondatescheduled, c.Zone2, c.Locationnumber, c.Globalid, c.CreatedUser, c.CreatedDate, c.LastEditedUser, c.LastEditedDate, c.Gatewaysync, c.Route, c.SetDow, c.RouteOrder, c.Vectorsurvsiteid, c.Creationdate, c.Creator, c.Editdate, c.Editor, c.H3R7, c.H3R8, c.Geometry, c.Geospatial, c.Version, c.OrganizationID, + c.Objectid, c.Name, c.Zone, c.Habitat, c.Priority, c.Usetype, c.Active, c.Description, c.Accessdesc, c.Comments, c.Externalid, c.Nextactiondatescheduled, c.Zone2, c.Locationnumber, c.Globalid, c.CreatedUser, c.CreatedDate, c.LastEditedUser, c.LastEditedDate, c.Gatewaysync, c.Route, c.SetDow, c.RouteOrder, c.Vectorsurvsiteid, c.Creationdate, c.Creator, c.Editdate, c.Editor, c.H3R7, c.H3R8, c.Geometry, c.Geospatial, c.Version, c.OrganizationID, c.H3cell, } } diff --git a/db/enums/enums.bob.go b/db/enums/enums.bob.go index 34c6ec6f..0fe4f29e 100644 --- a/db/enums/enums.bob.go +++ b/db/enums/enums.bob.go @@ -197,12 +197,14 @@ func (e *Audiodatatype) Scan(value any) error { const ( H3aggregationtypeMosquitosource H3aggregationtype = "MosquitoSource" H3aggregationtypeServicerequest H3aggregationtype = "ServiceRequest" + H3aggregationtypeTrap H3aggregationtype = "Trap" ) func AllH3aggregationtype() []H3aggregationtype { return []H3aggregationtype{ H3aggregationtypeMosquitosource, H3aggregationtypeServicerequest, + H3aggregationtypeTrap, } } @@ -215,7 +217,8 @@ func (e H3aggregationtype) String() string { func (e H3aggregationtype) Valid() bool { switch e { case H3aggregationtypeMosquitosource, - H3aggregationtypeServicerequest: + H3aggregationtypeServicerequest, + H3aggregationtypeTrap: return true default: return false diff --git a/db/factory/bobfactory_main.bob.go b/db/factory/bobfactory_main.bob.go index 17d49c70..d3822ee8 100644 --- a/db/factory/bobfactory_main.bob.go +++ b/db/factory/bobfactory_main.bob.go @@ -1680,6 +1680,7 @@ func (f *Factory) FromExistingFieldseekerTraplocation(m *models.FieldseekerTrapl o.Geospatial = func() null.Val[string] { return m.Geospatial } o.Version = func() int32 { return m.Version } o.OrganizationID = func() int32 { return m.OrganizationID } + o.H3cell = func() null.Val[string] { return m.H3cell } ctx := context.Background() if m.R.Organization != nil { diff --git a/db/factory/fieldseeker.traplocation.bob.go b/db/factory/fieldseeker.traplocation.bob.go index 621c0929..178079d8 100644 --- a/db/factory/fieldseeker.traplocation.bob.go +++ b/db/factory/fieldseeker.traplocation.bob.go @@ -74,6 +74,7 @@ type FieldseekerTraplocationTemplate struct { Geospatial func() null.Val[string] Version func() int32 OrganizationID func() int32 + H3cell func() null.Val[string] r fieldseekerTraplocationR f *Factory @@ -372,6 +373,9 @@ func (o FieldseekerTraplocationTemplate) Build() *models.FieldseekerTraplocation if o.OrganizationID != nil { m.OrganizationID = o.OrganizationID() } + if o.H3cell != nil { + m.H3cell = o.H3cell() + } o.setModelRels(m) @@ -561,6 +565,7 @@ func (m fieldseekerTraplocationMods) RandomizeAllColumns(f *faker.Faker) Fieldse FieldseekerTraplocationMods.RandomGeospatial(f), FieldseekerTraplocationMods.RandomVersion(f), FieldseekerTraplocationMods.RandomOrganizationID(f), + FieldseekerTraplocationMods.RandomH3cell(f), } } @@ -2256,6 +2261,59 @@ func (m fieldseekerTraplocationMods) RandomOrganizationID(f *faker.Faker) Fields }) } +// Set the model columns to this value +func (m fieldseekerTraplocationMods) H3cell(val null.Val[string]) FieldseekerTraplocationMod { + return FieldseekerTraplocationModFunc(func(_ context.Context, o *FieldseekerTraplocationTemplate) { + o.H3cell = func() null.Val[string] { return val } + }) +} + +// Set the Column from the function +func (m fieldseekerTraplocationMods) H3cellFunc(f func() null.Val[string]) FieldseekerTraplocationMod { + return FieldseekerTraplocationModFunc(func(_ context.Context, o *FieldseekerTraplocationTemplate) { + o.H3cell = f + }) +} + +// Clear any values for the column +func (m fieldseekerTraplocationMods) UnsetH3cell() FieldseekerTraplocationMod { + return FieldseekerTraplocationModFunc(func(_ context.Context, o *FieldseekerTraplocationTemplate) { + o.H3cell = nil + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +// The generated value is sometimes null +func (m fieldseekerTraplocationMods) RandomH3cell(f *faker.Faker) FieldseekerTraplocationMod { + return FieldseekerTraplocationModFunc(func(_ context.Context, o *FieldseekerTraplocationTemplate) { + o.H3cell = func() null.Val[string] { + if f == nil { + f = &defaultFaker + } + + val := random_string(f) + return null.From(val) + } + }) +} + +// Generates a random value for the column using the given faker +// if faker is nil, a default faker is used +// The generated value is never null +func (m fieldseekerTraplocationMods) RandomH3cellNotNull(f *faker.Faker) FieldseekerTraplocationMod { + return FieldseekerTraplocationModFunc(func(_ context.Context, o *FieldseekerTraplocationTemplate) { + o.H3cell = func() null.Val[string] { + if f == nil { + f = &defaultFaker + } + + val := random_string(f) + return null.From(val) + } + }) +} + func (m fieldseekerTraplocationMods) WithParentsCascading() FieldseekerTraplocationMod { return FieldseekerTraplocationModFunc(func(ctx context.Context, o *FieldseekerTraplocationTemplate) { if isDone, _ := fieldseekerTraplocationWithParentsCascadingCtx.Value(ctx); isDone { diff --git a/db/migrations/00032_h3_aggregation_add_trap.sql b/db/migrations/00032_h3_aggregation_add_trap.sql new file mode 100644 index 00000000..03ae36f9 --- /dev/null +++ b/db/migrations/00032_h3_aggregation_add_trap.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TYPE H3AggregationType ADD VALUE 'Trap' AFTER 'ServiceRequest'; + +-- +goose Down +ALTER TYPE H3AggregationType DROP VALUE 'Trap'; diff --git a/db/migrations/00033_h3cell_fieldseeker_traplocation.sql b/db/migrations/00033_h3cell_fieldseeker_traplocation.sql new file mode 100644 index 00000000..e7063021 --- /dev/null +++ b/db/migrations/00033_h3cell_fieldseeker_traplocation.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE fieldseeker.traplocation ADD COLUMN h3cell h3index GENERATED ALWAYS AS (h3_latlng_to_cell(geospatial, 15)) STORED; + +-- +goose Down +ALTER TABLE fieldseeker.traplocation DROP COLUMN h3cell; diff --git a/db/models/fieldseeker.traplocation.bob.go b/db/models/fieldseeker.traplocation.bob.go index a3e56cda..ba711df6 100644 --- a/db/models/fieldseeker.traplocation.bob.go +++ b/db/models/fieldseeker.traplocation.bob.go @@ -92,6 +92,7 @@ type FieldseekerTraplocation struct { Geospatial null.Val[string] `db:"geospatial" ` Version int32 `db:"version,pk" ` OrganizationID int32 `db:"organization_id" ` + H3cell null.Val[string] `db:"h3cell,generated" ` R fieldseekerTraplocationR `db:"-" ` } @@ -114,7 +115,7 @@ type fieldseekerTraplocationR struct { func buildFieldseekerTraplocationColumns(alias string) fieldseekerTraplocationColumns { return fieldseekerTraplocationColumns{ ColumnsExpr: expr.NewColumnsExpr( - "objectid", "name", "zone", "habitat", "priority", "usetype", "active", "description", "accessdesc", "comments", "externalid", "nextactiondatescheduled", "zone2", "locationnumber", "globalid", "created_user", "created_date", "last_edited_user", "last_edited_date", "gatewaysync", "route", "set_dow", "route_order", "vectorsurvsiteid", "creationdate", "creator", "editdate", "editor", "h3r7", "h3r8", "geometry", "geospatial", "version", "organization_id", + "objectid", "name", "zone", "habitat", "priority", "usetype", "active", "description", "accessdesc", "comments", "externalid", "nextactiondatescheduled", "zone2", "locationnumber", "globalid", "created_user", "created_date", "last_edited_user", "last_edited_date", "gatewaysync", "route", "set_dow", "route_order", "vectorsurvsiteid", "creationdate", "creator", "editdate", "editor", "h3r7", "h3r8", "geometry", "geospatial", "version", "organization_id", "h3cell", ).WithParent("fieldseeker.traplocation"), tableAlias: alias, Objectid: psql.Quote(alias, "objectid"), @@ -151,6 +152,7 @@ func buildFieldseekerTraplocationColumns(alias string) fieldseekerTraplocationCo Geospatial: psql.Quote(alias, "geospatial"), Version: psql.Quote(alias, "version"), OrganizationID: psql.Quote(alias, "organization_id"), + H3cell: psql.Quote(alias, "h3cell"), } } @@ -191,6 +193,7 @@ type fieldseekerTraplocationColumns struct { Geospatial psql.Expression Version psql.Expression OrganizationID psql.Expression + H3cell psql.Expression } func (c fieldseekerTraplocationColumns) Alias() string { @@ -1256,6 +1259,7 @@ type fieldseekerTraplocationWhere[Q psql.Filterable] struct { Geospatial psql.WhereNullMod[Q, string] Version psql.WhereMod[Q, int32] OrganizationID psql.WhereMod[Q, int32] + H3cell psql.WhereNullMod[Q, string] } func (fieldseekerTraplocationWhere[Q]) AliasedAs(alias string) fieldseekerTraplocationWhere[Q] { @@ -1298,6 +1302,7 @@ func buildFieldseekerTraplocationWhere[Q psql.Filterable](cols fieldseekerTraplo Geospatial: psql.WhereNull[Q, string](cols.Geospatial), Version: psql.Where[Q, int32](cols.Version), OrganizationID: psql.Where[Q, int32](cols.OrganizationID), + H3cell: psql.WhereNull[Q, string](cols.H3cell), } } diff --git a/htmlpage/static/js/map-aggregate.js b/htmlpage/static/js/map-aggregate.js new file mode 100644 index 00000000..3a8d1090 --- /dev/null +++ b/htmlpage/static/js/map-aggregate.js @@ -0,0 +1,223 @@ +var map = null; +// A map that can be used to locate a single point by setting its location explicitly +// or by allowing the user to move a marker. +class MapAggregate extends HTMLElement { + constructor() { + super(); + + // Create a shadow DOM + this.attachShadow({mode: "open" }); + + // Initial render + this.render(); + } + + // 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(); + } + } + + // Lifecycle: watch these attributes for changes + static get observedAttributes() { + return ["api-key", "latitude", "longitude", "organization-id", "tegola", "zoom"]; + } + + // Lifecycle: respond to attribute changes + attributeChangedCallback(name, oldValue, newValue) { + // Only handle if map exists and values actually changed + if (!this._map || oldValue === newValue) return; + + if (name === 'api-key') { + this._apiKey = newValue; + } + + if (name === 'latitude' || name === 'longitude') { + if (this.hasAttribute('latitude') && this.hasAttribute('longitude')) { + const lat = Number(this.getAttribute('latitude')); + const lng = Number(this.getAttribute('longitude')); + this._map.setCenter([lat, lng]); + } + } + + if (name === 'organization-id') { + this._organizationID = newValue; + } + + if (name === 'tegola') { + this._tegola = newValue; + } + + if (name === 'zoom') { + this._map.setZoom(Number(newValue)); + } + } + + _initializeMap() { + const apiKey = this.getAttribute("api-key"); + const lat = Number(this.getAttribute("latitude") || 36.2); + const lng = Number(this.getAttribute("longitude") || -119.2); + const organization_id = Number(this.getAttribute("organization-id") || 0); + const tegola = this.getAttribute("tegola") + const zoom = Number(this.getAttribute("zoom") || 15); + + mapboxgl.accessToken = apiKey; + const mapElement = this.shadowRoot.querySelector("#map"); + map = new mapboxgl.Map({ + container: mapElement, + center: { + lat: lat, + lng: lng, + }, + style: 'mapbox://styles/mapbox/streets-v12', // style URL + zoom: zoom, + }); + map.on("load", function() { + map.addSource('tegola', { + 'type': 'vector', + 'tiles': [ + `https://${tegola}/maps/nidus/{z}/{x}/{y}?organization_id=${organization_id}` + ] + }); + map.addInteraction('nidus-mouseenter-interaction', { + type: 'mouseenter', + target: { layerId: 'nidus' }, + handler: () => { + map.getCanvas().style.cursor = 'pointer'; + } + }); + map.addInteraction('nidus-mouseleave-interaction', { + type: 'mouseleave', + target: { layerId: 'nidus' }, + handler: () => { + map.getCanvas().style.cursor = ''; + } + }); + map.addLayer({ + 'id': 'mosquito_source', + 'type': 'fill', + 'filter': ['==', ['zoom'], ['+', 2, ['to-number', ['get', 'resolution']]]], + 'source': 'tegola', + 'source-layer': 'mosquito_source', + 'paint': { + 'fill-opacity': 0.4, + 'fill-color': '#dc3545' + } + }); + map.addLayer({ + 'id': 'service_request', + 'type': 'fill', + 'filter': ['==', ['zoom'], ['+', 2, ['to-number', ['get', 'resolution']]]], + 'source': 'tegola', + 'source-layer': 'service_request', + 'paint': { + 'fill-opacity': 0.4, + 'fill-color': '#ffc107' + } + }); + map.addLayer({ + 'id': 'trap', + 'type': 'fill', + 'filter': ['==', ['zoom'], ['+', 2, ['to-number', ['get', 'resolution']]]], + 'source': 'tegola', + 'source-layer': 'trap', + 'paint': { + 'fill-opacity': 0.4, + 'fill-color': '#0dcaf0' + } + }); + map.addInteraction("nidus-click-interaction", { + type: 'click', + target: { layerId: 'nidus' }, + handler: (e) => { + const coordinates = e.feature.geometry.coordinates.slice(); + const properties = e.feature.properties; + //console.log("Coordinates", coordinates[0]); + //console.log("Properties", properties.cell, properties.count_); + /*new mapboxgl.Popup() + .setLngLat(coordinates[0][0]) + .setHTML("Cell: " + properties.cell) + .addTo(map);*/ + window.location.href = '/cell/' + properties.cell; + } + }); + + /* + this.dispatchEvent(new CustomEvent('load') { + bubbles: true, + composed: true, // Allows event to cross shadow DOM boundary + detail: { + map: this + } + }); + */ + }); + } + + // Initial render of component + render() { + this.shadowRoot.innerHTML = ` + + +