RMO frontend checkpoint

* Create a nwe AddressAndMapLocator which abstracts out the behavior of
   selecting a location
 * Fix the overlay causing render errors on the MapLocator by getting
   rid of the overlay and just using a lock indicator
 * Fix MapLocator zooming in to the wrong place by not framing the
   markers
 * Remove Latlng from platform and just use Location with optional
   accuracy
 * Use nested types with form-encoded POST
 * Fix styles on water report page
This commit is contained in:
Eli Ribble 2026-04-09 17:21:35 +00:00
parent cb9e5146bf
commit 9dccd21cee
No known key found for this signature in database
25 changed files with 828 additions and 598 deletions

View file

@ -5,7 +5,7 @@ import (
"strings"
"github.com/Gleipnir-Technology/go-geojson2h3/v2"
"github.com/tidwall/geojson"
//"github.com/tidwall/geojson"
"github.com/uber/h3-go/v4"
)
@ -37,29 +37,6 @@ func H3ToGeoJSON(indexes []h3.Cell) (interface{}, error) {
return featureCollection.JSON(), nil
}
func main2() {
resolution := 9
object, err := geojson.Parse(`{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"shape":"Polygon","name":"Unnamed Layer","category":"default"},"geometry":{"type":"Polygon","coordinates":[[[-73.901303,40.756892],[-73.893924,40.743755],[-73.871476,40.756278],[-73.863378,40.764175],[-73.871444,40.768467],[-73.879852,40.760014],[-73.885515,40.764045],[-73.891522,40.761054],[-73.901303,40.756892]]]},"id":"a6ca1b7e-9ddf-4425-ad07-8a895f7d6ccf"}]}`, nil)
if err != nil {
panic(err)
}
indexes, err := geojson2h3.ToH3(resolution, object)
if err != nil {
panic(err)
}
for _, index := range indexes {
fmt.Printf("h3index: %s\n", index.String())
}
featureCollection, err := geojson2h3.ToFeatureCollection(indexes)
if err != nil {
panic(err)
}
fmt.Println("Polyfill:")
fmt.Println(featureCollection.JSON())
}
// Given a cell at a smaller resolution remap it to the larger resolution
func scaleCell(cell h3.Cell, resolution int) (h3.Cell, error) {
r := cell.Resolution()

View file

@ -39,7 +39,7 @@ func DistrictForLocation(ctx context.Context, lng float64, lat float64) (*models
return nil, errors.New("too many organizations")
}
}
func MatchDistrict(ctx context.Context, longitude, latitude *float64, images []ImageUpload) (*int32, error) {
func MatchDistrict(ctx context.Context, longitude, latitude float64, images []ImageUpload) (*int32, error) {
var err error
var org *models.Organization
for _, image := range images {
@ -58,7 +58,7 @@ func MatchDistrict(ctx context.Context, longitude, latitude *float64, images []I
return &org.ID, nil
}
}
if longitude == nil || latitude == nil {
if longitude == 0 || latitude == 0 {
org, err = DistrictCatchall(ctx)
if err != nil {
return nil, fmt.Errorf("get catchall: %w", err)
@ -66,7 +66,7 @@ func MatchDistrict(ctx context.Context, longitude, latitude *float64, images []I
log.Debug().Int32("id", org.ID).Msg("No location from images, no latlng for the report itself, using catchall")
return &org.ID, nil
}
org, err = DistrictForLocation(ctx, *longitude, *latitude)
org, err = DistrictForLocation(ctx, longitude, latitude)
if err != nil {
log.Warn().Err(err).Msg("Failed to get district for location")
return nil, fmt.Errorf("Failed to get district for location: %w", err)
@ -76,9 +76,9 @@ func MatchDistrict(ctx context.Context, longitude, latitude *float64, images []I
if err != nil {
return nil, fmt.Errorf("get catchall: %w", err)
}
log.Debug().Err(err).Float64("lng", *longitude).Float64("lat", *latitude).Int32("id", org.ID).Msg("No district match by report location, using catchall")
log.Debug().Err(err).Float64("lng", longitude).Float64("lat", latitude).Int32("id", org.ID).Msg("No district match by report location, using catchall")
return &org.ID, nil
}
log.Debug().Err(err).Int32("org_id", org.ID).Float64("lng", *longitude).Float64("lat", *latitude).Msg("Found district match by report location")
log.Debug().Err(err).Int32("org_id", org.ID).Float64("lng", longitude).Float64("lat", latitude).Msg("Found district match by report location")
return &org.ID, nil
}

View file

@ -1,13 +1,7 @@
package platform
import (
"errors"
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
"github.com/rs/zerolog/log"
"github.com/uber/h3-go/v4"
)
type LatLng struct {
@ -17,41 +11,3 @@ type LatLng struct {
AccuracyValue float64
AccuracyType enums.PublicreportAccuracytype
}
func (l LatLng) Resolution() uint {
switch l.AccuracyType {
// These accuracy_type strings come from the Mapbox Geocoding API definition and
// are far from scientific
case enums.PublicreportAccuracytypeRooftop:
return 14
case enums.PublicreportAccuracytypeParcel:
return 13
case enums.PublicreportAccuracytypePoint:
return 13
case enums.PublicreportAccuracytypeInterpolated:
return 12
case enums.PublicreportAccuracytypeApproximate:
return 11
case enums.PublicreportAccuracytypeIntersection:
return 10
// This is a special indicator that we got our location from the browser measurements
case enums.PublicreportAccuracytypeBrowser:
return uint(h3utils.MeterAccuracyToH3Resolution(l.AccuracyValue))
default:
log.Warn().Str("accuracy-type", string(l.AccuracyType)).Msg("unrecognized accuracy type, this indicates either a weird client or misbehaving web page. Defaulting to resolution 13")
return 13
}
}
func (l LatLng) H3Cell() (*h3.Cell, error) {
if l.Longitude == nil || l.Latitude == nil {
return nil, errors.New("nil lat/lng")
}
result, err := h3utils.GetCell(*l.Longitude, *l.Latitude, int(l.Resolution()))
return &result, err
}
func (l LatLng) GeometryQuery() (string, error) {
if l.Longitude == nil || l.Latitude == nil {
return "", errors.New("nil lat/lng")
}
return fmt.Sprintf("ST_Point(%f, %f, 4326)", *l.Longitude, *l.Latitude), nil
}

View file

@ -84,8 +84,8 @@ func PublicReportMessageCreate(ctx context.Context, user User, report_id, messag
func PublicReportReporterUpdated(ctx context.Context, org_id int32, report_id string) {
event.Updated(event.TypeRMOReport, org_id, report_id)
}
func ReportNuisanceCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_nuisance models.PublicreportNuisanceSetter, latlng LatLng, address Address, images []ImageUpload) (*models.PublicreportReport, error) {
return reportCreate(ctx, setter_report, latlng, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) error {
func ReportNuisanceCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_nuisance models.PublicreportNuisanceSetter, location types.Location, address Address, images []ImageUpload) (*models.PublicreportReport, error) {
return reportCreate(ctx, setter_report, location, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) error {
setter_nuisance.ReportID = omit.From(report_id)
_, err := models.PublicreportNuisances.Insert(&setter_nuisance).One(ctx, txn)
if err != nil {
@ -95,8 +95,8 @@ func ReportNuisanceCreate(ctx context.Context, setter_report models.Publicreport
})
}
func ReportWaterCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_water models.PublicreportWaterSetter, latlng LatLng, address Address, images []ImageUpload) (*models.PublicreportReport, error) {
return reportCreate(ctx, setter_report, latlng, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) error {
func ReportWaterCreate(ctx context.Context, setter_report models.PublicreportReportSetter, setter_water models.PublicreportWaterSetter, location types.Location, address Address, images []ImageUpload) (*models.PublicreportReport, error) {
return reportCreate(ctx, setter_report, location, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) error {
setter_water.ReportID = omit.From(report_id)
_, err := models.PublicreportWaters.Insert(&setter_water).One(ctx, txn)
if err != nil {
@ -108,7 +108,7 @@ func ReportWaterCreate(ctx context.Context, setter_report models.PublicreportRep
type funcSetReportDetail = func(context.Context, bob.Executor, int32) error
func reportCreate(ctx context.Context, setter_report models.PublicreportReportSetter, latlng LatLng, address Address, images []ImageUpload, detail_setter funcSetReportDetail) (result *models.PublicreportReport, err error) {
func reportCreate(ctx context.Context, setter_report models.PublicreportReportSetter, location types.Location, address Address, images []ImageUpload, detail_setter funcSetReportDetail) (result *models.PublicreportReport, err error) {
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("create txn: %w", err)
@ -123,10 +123,10 @@ func reportCreate(ctx context.Context, setter_report models.PublicreportReportSe
// If we've got an locality value it was set by geocoding so we should save it
var a *models.Address
if address.Locality != "" && latlng.Latitude != nil && latlng.Longitude != nil {
if address.Locality != "" && location.Latitude != 0 && location.Longitude != 0 {
a, err = geocode.EnsureAddress(ctx, txn, address, types.Location{
Latitude: *latlng.Latitude,
Longitude: *latlng.Longitude,
Latitude: location.Latitude,
Longitude: location.Longitude,
})
if err != nil {
return nil, fmt.Errorf("Failed to ensure address: %w", err)
@ -138,7 +138,7 @@ func reportCreate(ctx context.Context, setter_report models.PublicreportReportSe
return nil, fmt.Errorf("Failed to save image uploads: %w", err)
}
var organization_id *int32
organization_id, err = MatchDistrict(ctx, latlng.Longitude, latlng.Latitude, images)
organization_id, err = MatchDistrict(ctx, location.Longitude, location.Latitude, images)
if err != nil {
log.Warn().Err(err).Msg("Failed to match district")
}
@ -153,9 +153,9 @@ func reportCreate(ctx context.Context, setter_report models.PublicreportReportSe
if err != nil {
return nil, fmt.Errorf("Failed to create report database record: %w", err)
}
if latlng.Latitude != nil && latlng.Longitude != nil {
h3cell, _ := latlng.H3Cell()
geom_query, _ := latlng.GeometryQuery()
if location.Latitude != 0 && location.Longitude != 0 {
h3cell, _ := location.H3Cell()
geom_query, _ := location.GeometryQuery()
_, err = psql.Update(
um.Table("publicreport.report"),
um.SetCol("h3cell").ToArg(h3cell),

View file

@ -8,11 +8,11 @@ import (
type Address struct {
Country string `db:"country" json:"country"`
GID string `db:"gid" json:"gid"`
GID string `db:"gid" json:"gid" schema:"gid"`
Locality string `db:"locality" json:"locality"`
Number string `db:"number" json:"number"`
PostalCode string `db:"postal_code" json:"postal_code"`
Raw string `db:"raw" json:"raw"`
Raw string `db:"raw" json:"raw" schema:"raw"`
Region string `db:"region" json:"region"`
Street string `db:"street" json:"street"`
Unit string `db:"unit" json:"unit"`

View file

@ -2,13 +2,33 @@ package types
import (
"fmt"
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
//"github.com/rs/zerolog/log"
"github.com/uber/h3-go/v4"
)
type Location struct {
Latitude float64 `db:"latitude" json:"latitude"`
Longitude float64 `db:"longitude" json:"longitude"`
Accuracy *float32 `db:"accuracy" json:"accuracy" schema:"accuracy"`
Latitude float64 `db:"latitude" json:"latitude" schema:"latitude"`
Longitude float64 `db:"longitude" json:"longitude" schema:"longitude"`
}
func (l Location) String() string {
return fmt.Sprintf("%f %f", l.Longitude, l.Latitude)
}
func (l Location) Resolution() uint {
if l.Accuracy != nil {
return uint(h3utils.MeterAccuracyToH3Resolution(float64(*l.Accuracy)))
} else {
return uint(0)
}
}
func (l Location) H3Cell() (*h3.Cell, error) {
result, err := h3utils.GetCell(l.Longitude, l.Latitude, int(l.Resolution()))
return &result, err
}
func (l Location) GeometryQuery() (string, error) {
return fmt.Sprintf("ST_Point(%f, %f, 4326)", l.Longitude, l.Latitude), nil
}

View file

@ -2,10 +2,8 @@ package resource
import (
"context"
"fmt"
"net/http"
"slices"
"strconv"
"time"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
@ -13,6 +11,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/html"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
@ -32,84 +31,25 @@ type nuisance struct {
ID string `json:"id"`
URI string `json:"uri"`
}
type nuisanceForm struct {
AdditionalInfo string `schema:"additional-info"`
AddressGID string `schema:"address-gid"`
Address string `schema:"address"`
Duration string `schema:"duration"`
Latitude string `schema:"latitude"`
Longitude string `schema:"longitude"`
LatlngAccuracyType string `schema:"latlng-accuracy-type"`
LatlngAccuracyValue string `schema:"latlng-accuracy-value"`
MapZoom string `schema:"map-zoom"`
SourceStagnant bool `schema:"source-stagnant"`
SourceContainer bool `schema:"source-container"`
SourceDescription string `schema:"source-description"`
SourceGutters bool `schema:"source-gutters"`
SourceLocations []string `schema:"source-location"`
TODEarly bool `schema:"tod-early"`
TODDay bool `schema:"tod-day"`
TODEvening bool `schema:"tod-evening"`
TODNight bool `schema:"tod-night"`
type Locator struct {
Address types.Address `schema:"address"`
Location types.Location `schema:"location"`
}
func parseLatLng(r *http.Request) (platform.LatLng, error) {
result := platform.LatLng{
AccuracyType: enums.PublicreportAccuracytypeNone,
AccuracyValue: 0.0,
Latitude: nil,
Longitude: nil,
MapZoom: 0.0,
}
latitude_str := r.FormValue("latitude")
longitude_str := r.FormValue("longitude")
latlng_accuracy_type_str := r.PostFormValue("latlng-accuracy-type")
latlng_accuracy_value_str := r.PostFormValue("latlng-accuracy-value")
map_zoom_str := r.PostFormValue("map-zoom")
var err error
if latlng_accuracy_type_str != "" {
err := result.AccuracyType.Scan(latlng_accuracy_type_str)
if err != nil {
return result, fmt.Errorf("Failed to parse accuracy type '%s': %w", latlng_accuracy_type_str, err)
}
}
if latlng_accuracy_value_str != "" {
var t float64
t, err = strconv.ParseFloat(latlng_accuracy_value_str, 32)
if err != nil {
return result, fmt.Errorf("Failed to parse latlng_accuracy_value '%s': %w", latlng_accuracy_value_str, err)
}
result.AccuracyValue = float64(t)
}
if latitude_str != "" {
var t float64
t, err = strconv.ParseFloat(latitude_str, 64)
if err != nil {
return result, fmt.Errorf("Failed to parse latitude '%s': %w", latitude_str, err)
}
result.Latitude = &t
}
if longitude_str != "" {
var t float64
t, err := strconv.ParseFloat(longitude_str, 64)
if err != nil {
return result, fmt.Errorf("Failed to parse longitude '%s': %w", longitude_str, err)
}
result.Longitude = &t
}
if map_zoom_str != "" {
var t float64
t, err = strconv.ParseFloat(map_zoom_str, 32)
if err != nil {
return result, fmt.Errorf("Failed to parse map_zoom_str '%s': %w", map_zoom_str, err)
} else {
result.MapZoom = float32(t)
}
}
return result, nil
type nuisanceForm struct {
AdditionalInfo string `schema:"additional-info"`
Duration string `schema:"duration"`
Location types.Location `schema:"location"`
Locator Locator `schema:"locator"`
MapZoom string `schema:"map-zoom"`
SourceStagnant bool `schema:"source-stagnant"`
SourceContainer bool `schema:"source-container"`
SourceDescription string `schema:"source-description"`
SourceGutters bool `schema:"source-gutters"`
SourceLocations []string `schema:"source-location"`
TODEarly bool `schema:"tod-early"`
TODDay bool `schema:"tod-day"`
TODEvening bool `schema:"tod-evening"`
TODNight bool `schema:"tod-night"`
}
func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceForm) (*nuisance, *nhttp.ErrorWithStatus) {
@ -120,22 +60,25 @@ func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceFor
is_location_pool := slices.Contains(n.SourceLocations, "pool-area")
is_location_other := slices.Contains(n.SourceLocations, "other")
latlng, err := parseLatLng(r)
err = duration.Scan(n.Duration)
err := duration.Scan(n.Duration)
if err != nil {
log.Warn().Err(err).Str("duration_str", n.Duration).Msg("Failed to interpret 'duration'")
}
uploads, err := html.ExtractImageUploads(r)
log.Info().Int("len", len(uploads)).Msg("extracted uploads")
log.Info().Int("len", len(uploads)).Msg("extracted nuisance uploads")
if err != nil {
return nil, nhttp.NewError("Failed to extract image uploads: %w", err)
}
address := platform.Address{
GID: n.AddressGID,
Raw: n.Address,
GID: n.Locator.Address.GID,
Raw: n.Locator.Address.Raw,
}
accuracy := float32(0.0)
if n.Location.Accuracy != nil {
accuracy = *n.Location.Accuracy
}
log.Info().Str("address.raw", address.Raw).Str("address.gid", address.GID).Msg("making nuisance")
setter_report := models.PublicreportReportSetter{
//AddressID: omitnull.From(latlng.Cell.String()),
AddressCountry: omit.From(""),
@ -148,11 +91,11 @@ func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceFor
AddressStreet: omit.From(""),
Created: omit.From(time.Now()),
//H3cell: omitnull.From(latlng.Cell.String()),
LatlngAccuracyType: omit.From(latlng.AccuracyType),
LatlngAccuracyValue: omit.From(float32(latlng.AccuracyValue)),
LatlngAccuracyType: omit.From(enums.PublicreportAccuracytypeBrowser),
LatlngAccuracyValue: omit.From(accuracy),
//Location: omitnull.From(fmt.Sprintf("ST_GeometryFromText(Point(%s %s))", longitude, latitude)),
Location: omitnull.FromPtr[string](nil),
MapZoom: omit.From(latlng.MapZoom),
MapZoom: omit.From(float32(0.0)),
//OrganizationID: omitnull.FromPtr(organization_id),
//PublicID: omit.From(public_id),
ReporterEmail: omit.From(""),
@ -179,7 +122,7 @@ func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceFor
TodEvening: omit.From(n.TODEvening),
TodNight: omit.From(n.TODNight),
}
report, err := platform.ReportNuisanceCreate(ctx, setter_report, setter_nuisance, latlng, address, uploads)
report, err := platform.ReportNuisanceCreate(ctx, setter_report, setter_nuisance, n.Location, address, uploads)
if err != nil {
return nil, nhttp.NewError("create nuisance report: %w", err)
}

View file

@ -10,9 +10,10 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/html"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/aarondl/opt/omit"
//"github.com/aarondl/opt/omitnull"
//"github.com/rs/zerolog/log"
"github.com/aarondl/opt/omitnull"
"github.com/rs/zerolog/log"
)
func Water(r *router) *waterR {
@ -25,69 +26,64 @@ type waterR struct {
router *router
}
type water struct {
ID string `json:"id"`
District string `json:"district"`
ID string `json:"id"`
URI string `json:"uri"`
}
type waterForm struct {
AccessComments string `schema:"access-comments"`
AccessDog bool `schema:"access-dog"`
AccessFence bool `schema:"access-fence"`
AccessGate bool `schema:"access-gate"`
AccessLocked bool `schema:"access-locked"`
AccessOther bool `schema:"access-other"`
AddressRaw string `schema:"address"`
AddressCountry string `schema:"address-country"`
AddressLocality string `schema:"address-locality"`
AddressNumber string `schema:"address-number"`
AddressPostalCode string `schema:"address-postalcode"`
AddressRegion string `schema:"address-region"`
AddressStreet string `schema:"address-street"`
Comments string `schema:"comments"`
HasAdult bool `schema:"has-adult"`
HasBackyardPermission bool `schema:"backyard-permission"`
HasLarvae bool `schema:"has-larvae"`
HasPupae bool `schema:"has-pupae"`
IsReporterConfidential bool `schema:"reporter-confidential"`
IsReporter_owner bool `schema:"property-ownership"`
OwnerEmail string `schema:"owner-email"`
OwnerName string `schema:"owner-name"`
OwnerPhone string `schema:"owner-phone"`
AccessComments string `schema:"access-comments"`
AccessDog bool `schema:"access-dog"`
AccessFence bool `schema:"access-fence"`
AccessGate bool `schema:"access-gate"`
AccessLocked bool `schema:"access-locked"`
AccessOther bool `schema:"access-other"`
Address string `schema:"address"`
AddressGID string `schema:"address-gid"`
Comments string `schema:"comments"`
HasAdult bool `schema:"has-adult"`
HasBackyardPermission bool `schema:"backyard-permission"`
HasLarvae bool `schema:"has-larvae"`
HasPupae bool `schema:"has-pupae"`
IsReporterConfidential bool `schema:"reporter-confidential"`
IsReporter_owner bool `schema:"property-ownership"`
Location types.Location `schema:"location"`
Locator Locator `schema:"locator"`
OwnerEmail string `schema:"owner-email"`
OwnerName string `schema:"owner-name"`
OwnerPhone string `schema:"owner-phone"`
}
func (res *waterR) Create(ctx context.Context, r *http.Request, w waterForm) (*water, *nhttp.ErrorWithStatus) {
latlng, err := parseLatLng(r)
if err != nil {
return nil, nhttp.NewError("Failed to parse lat lng for water report: %w", err)
}
uploads, err := html.ExtractImageUploads(r)
log.Info().Int("len", len(uploads)).Msg("extracted water uploads")
if err != nil {
return nil, nhttp.NewError("Failed to extract image uploads: %w", err)
}
address := platform.Address{
Country: w.AddressCountry,
Locality: w.AddressLocality,
Number: w.AddressNumber,
PostalCode: w.AddressPostalCode,
Raw: w.AddressRaw,
Region: w.AddressRegion,
Street: w.AddressStreet,
Unit: "",
GID: w.AddressGID,
Raw: w.Address,
}
accuracy := float32(0.0)
if w.Location.Accuracy != nil {
accuracy = *w.Location.Accuracy
}
setter_report := models.PublicreportReportSetter{
AddressRaw: omit.From(address.Raw),
AddressCountry: omit.From(address.Country),
AddressNumber: omit.From(address.Number),
AddressLocality: omit.From(address.Locality),
AddressPostalCode: omit.From(address.PostalCode),
AddressRegion: omit.From(address.Region),
AddressStreet: omit.From(address.Street),
AddressCountry: omit.From(""),
AddressNumber: omit.From(""),
AddressLocality: omit.From(""),
AddressPostalCode: omit.From(""),
AddressRegion: omit.From(""),
AddressStreet: omit.From(""),
Created: omit.From(time.Now()),
//H3cell: omitnull.From(geospatial.Cell.String()),
LatlngAccuracyType: omit.From(latlng.AccuracyType),
LatlngAccuracyValue: omit.From(float32(latlng.AccuracyValue)),
LatlngAccuracyType: omit.From(enums.PublicreportAccuracytypeBrowser),
LatlngAccuracyValue: omit.From(accuracy),
//Location: add later
MapZoom: omit.From(latlng.MapZoom),
Location: omitnull.FromPtr[string](nil),
MapZoom: omit.From(float32(0.0)),
//OrganizationID: omitnull.FromPtr(organization_id),
//PublicID: omit.From(public_id),
ReporterEmail: omit.From(""),
@ -115,11 +111,21 @@ func (res *waterR) Create(ctx context.Context, r *http.Request, w waterForm) (*w
OwnerPhone: omit.From(w.OwnerPhone),
//ReportID omit.Val[int32]
}
report, err := platform.ReportWaterCreate(ctx, setter_report, setter_water, latlng, address, uploads)
report, err := platform.ReportWaterCreate(ctx, setter_report, setter_water, w.Location, address, uploads)
if err != nil {
return nil, nhttp.NewError("Failed to save new report: %w", err)
}
uri, err := res.router.IDStrToURI("publicreport.ByIDGet", report.PublicID)
if err != nil {
return nil, nhttp.NewError("generate uri: %w", err)
}
district_uri, err := res.router.IDToURI("district.ByIDGet", int(report.OrganizationID))
if err != nil {
return nil, nhttp.NewError("generate district uri: %w", err)
}
return &water{
ID: report.PublicID,
District: district_uri,
ID: report.PublicID,
URI: uri,
}, nil
}

View file

View file

@ -1,39 +0,0 @@
.banner {
display: block;
}
@include media-breakpoint-up(xs) {
.banner {
width: 100%;
}
}
@include media-breakpoint-up(xl) {
.banner {
height: 100%;
}
}
.service-card {
transition: transform 0.3s;
height: 100%;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.district-logo {
max-height: 80px;
width: auto;
}
.quick-report-mobile {
background-color: #ff9800;
}
.quick-report-desktop {
background-color: #ffefd5;
border-left: 4px solid #ff9800;
}
.banner-container {
position: relative;
width: 100%;
background-color: #f76436;
overflow: hidden;
}

View file

@ -1,66 +0,0 @@
.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;
}
.search-box {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
@media (max-width: 768px) {
.map-container {
height: 300px;
}
}
/* Base styles for circular checkboxes */
.custom-circle-checkbox .form-check-input {
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
margin-top: 0.25rem;
background-image: none; /* Remove Bootstrap's default checkmark */
position: relative;
}
/* Style for when checked */
.custom-circle-checkbox .form-check-input:checked {
background-color: currentColor;
border-color: currentColor;
}
/* Style for when unchecked - just an outline */
.custom-circle-checkbox .form-check-input:not(:checked) {
background-color: transparent;
}
/* Colors based on data attribute */
.custom-circle-checkbox .form-check-input[data-color="danger"] {
border-color: $red;
color: $red;
}
.custom-circle-checkbox .form-check-input[data-color="success"] {
border-color: $blue;
color: $blue;
}
.custom-circle-checkbox .form-check-input[data-color="info"] {
border-color: $green;
color: $green;
}

View file

@ -30,7 +30,7 @@
width: 100%;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
z-index: 3;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background: white;
}

View file

@ -1,4 +1,4 @@
<style scoped>
<style scoped lang="scss">
@import url("https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
.map-wrapper {
position: relative;
@ -24,18 +24,18 @@
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 2;
cursor: pointer;
transition: background 0.2s ease;
}
.map-overlay:hover {
background: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.65);
}
.overlay-content {
@ -71,7 +71,6 @@
top: 10px;
right: 10px;
z-index: 999;
background: #ffc107;
border: none;
color: #000;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
@ -79,6 +78,12 @@
padding: 0.375rem 0.75rem;
transition: all 0.2s;
}
.map-status-btn.locked {
background: $warning;
}
.map-status-btn.unlocked {
background: $primary;
}
.map-status-btn:hover {
background: #ffb300;
@ -120,38 +125,30 @@
<template>
<div class="map-wrapper" ref="mapWrapper">
<!-- Tap-to-activate overlay -->
<div
class="map-overlay"
@click="activateMap"
ref="mapOverlay"
@touchstart.prevent="activateMap"
v-if="!mapInteractive"
>
<div class="overlay-content">
<i class="bi bi-hand-index-thumb"></i>
<p class="mb-0">Tap to select location</p>
</div>
</div>
<!-- Map container -->
<div
ref="mapContainer"
class="map-container"
:class="{ 'map-inactive': !mapInteractive }"
></div>
<div ref="mapContainer" class="map-container"></div>
<!-- Lock/unlock indicator button -->
<button
v-if="mapInteractive"
type="button"
class="btn btn-sm map-status-btn"
class="btn btn-sm map-status-btn unlocked"
@click="deactivateMap"
title="Lock map to enable page scrolling"
>
<i class="bi bi-unlock-fill"></i>
<span class="d-none d-md-inline ms-1">Map Active</span>
</button>
<button
v-if="!mapInteractive"
type="button"
class="btn btn-sm map-status-btn locked"
@click="activateMap"
title="Unlock map to enable map pan/zoom"
>
<i class="bi bi-lock-fill"></i>
<span class="d-none d-md-inline ms-1">Map Locked</span>
</button>
</div>
</template>
@ -198,7 +195,6 @@ const mapInteractive = ref(false);
const mapMarkers: Ref<Map<string, maplibregl.Marker>> = shallowRef<
Map<string, maplibregl.Marker>
>(new Map());
const mapOverlay = useTemplateRef("mapOverlay");
const mapWrapper = useTemplateRef("mapWrapper");
function activateMap() {
@ -225,29 +221,6 @@ function deactivateMap() {
map.value.doubleClickZoom.disable();
}
// Handle clicks outside the map to deactivate
function handleOutsideClick(event: MouseEvent | TouchEvent) {
if (!event.target) {
console.log("Didn't click on anything");
return;
}
if (!mapWrapper.value) {
console.log("Somehow map wrapper is null");
return;
}
const target = event.target as HTMLElement;
const cls = target.className ?? "";
if (
mapInteractive.value &&
mapContainer.value &&
!(mapWrapper.value.contains(target) || cls == "map-overlay")
) {
console.log("deactivate map: outside map click", target, cls);
deactivateMap();
} else {
console.log("click is inside the map, ignoring");
}
}
// Initialize map
function initializeMap() {
if (!mapContainer.value) return;
@ -306,8 +279,6 @@ function initializeMap() {
});
// Listen for clicks outside the map
document.addEventListener("mousedown", handleOutsideClick);
document.addEventListener("touchstart", handleOutsideClick);
}
function updateModel(_map: maplibregl.Map) {
@ -382,6 +353,7 @@ const updateMarkers = () => {
mapMarkers.value.set(markerData.id, marker);
}
});
frameMarkers();
};
// Frame all markers in view
@ -416,6 +388,7 @@ const frameMarkers = () => {
watch(
() => props.modelValue,
(newCamera) => {
console.log("New map camera", newCamera);
if (map.value && newCamera) {
map.value.panTo(
{
@ -434,6 +407,7 @@ watch(
watch(
() => props.markers,
() => {
console.log("New map markers", props.markers);
updateMarkers();
},
{ deep: true },
@ -451,8 +425,6 @@ onUnmounted(() => {
if (clickTimeout.value) {
clearTimeout(clickTimeout.value);
}
document.removeEventListener("click", handleOutsideClick);
document.removeEventListener("touchstart", handleOutsideClick);
// Remove all markers
mapMarkers.value.forEach((marker) => marker.remove());

57
ts/components/Tooltip.vue Normal file
View file

@ -0,0 +1,57 @@
<template>
<span
ref="tooltipElement"
data-bs-toggle="tooltip"
:data-bs-placement="placement"
:title="title"
>
<slot></slot>
</span>
</template>
<script>
import { Tooltip } from "bootstrap";
export default {
name: "BsTooltip",
props: {
title: {
type: String,
required: true,
},
placement: {
type: String,
default: "top",
validator(value) {
return ["top", "bottom", "left", "right"].includes(value);
},
},
},
data() {
return {
tooltip: null,
};
},
mounted() {
this.tooltip = new Tooltip(this.$refs.tooltipElement);
},
beforeUnmount() {
if (this.tooltip) {
this.tooltip.dispose();
}
},
watch: {
title(newTitle) {
if (this.tooltip) {
this.tooltip.dispose();
this.tooltip = new Tooltip(this.$refs.tooltipElement);
}
},
},
};
</script>

View file

@ -0,0 +1,172 @@
<style scoped>
#address-input {
font-size: 16px;
}
.map-container {
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-bottom: 20px;
margin-top: 20px;
align-items: center;
justify-content: center;
/* Prevent touch scrolling issues */
touch-action: pan-y pinch-zoom;
}
#map {
width: 100%;
height: 100%;
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.map-container {
height: 400px;
margin-bottom: 15px;
margin-top: 15px;
}
}
/* Extra small devices */
@media (max-width: 576px) {
.map-container {
height: 350px;
border-radius: 5px;
}
}
</style>
<template>
<div class="mb-4">
<AddressSuggestion
v-model="address"
placeholder="Start typing an address (min 3 characters)"
@suggestion-selected="doAddressSuggestionSelected"
>
</AddressSuggestion>
</div>
<!-- Map Placeholder -->
<div class="mb-4">
<label class="form-label fw-semibold">Location Preview</label>
<div class="map-container">
<MapLocator
v-model="currentCamera"
:markers="markers"
@click="doMapClick"
@marker-drag-end="doMapMarkerDragEnd"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import AddressSuggestion from "@/components/AddressSuggestion.vue";
import MapLocator from "@/components/MapLocator.vue";
import type { Address, Geocode, GeocodeSuggestion, Location } from "@/type/api";
import { useGeocodeStore } from "@/store/geocode";
import { useLocationStore } from "@/store/location";
import type { Camera, Locator } from "@/type/map";
import type { Marker } from "@/types";
interface Emits {
(e: "update:modelValue", value: Locator): void;
}
interface Props {
modelValue: Locator | null;
}
const address = ref<string>("");
const currentCamera = ref<Camera | null>(null);
const emit = defineEmits<Emits>();
const geocode = useGeocodeStore();
const marker = ref<Marker | null>(null);
const markers = computed((): Marker[] => {
if (marker.value) {
return [marker.value];
} else {
return [];
}
});
const props = defineProps<Props>();
const selectedSuggestion = ref<GeocodeSuggestion | null>(null);
function doAddressSuggestionSelected(suggestion: GeocodeSuggestion) {
console.log("Address suggestion selected", suggestion);
doAddressSuggestionDetails(suggestion);
}
async function doAddressSuggestionDetails(suggestion: GeocodeSuggestion) {
// Fetch full details for the selected suggestion
selectedSuggestion.value = suggestion;
const url = `/api/geocode/by-gid/${suggestion.gid}`;
const response = await fetch(url);
if (!response.ok) {
console.error("Failed to get suggestion detail", response.statusText);
return;
}
const data = (await response.json()) as Geocode;
if (currentCamera.value) {
console.log("suggestion located, zooming", data);
currentCamera.value.zoom = 15;
}
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: data.location,
};
updateModel();
}
function doMapClick(location: Location) {
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: location,
};
geocode
.reverse(location)
.then((code: Geocode) => {
address.value = code.address.raw;
selectedSuggestion.value = {
detail: code.address.number + " " + code.address.street,
gid: code.address.gid,
locality: code.address.locality,
type: "address",
};
updateModel();
console.log("reverse geocoded", code);
})
.catch((e) => {
console.error("failed to reverse geocode after map click", e);
});
}
function doMapMarkerDragEnd(location: Location) {
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: location,
};
updateModel();
}
function updateModel() {
const newLocator: Locator = {
address: {
country: "",
gid: selectedSuggestion ? (selectedSuggestion.value?.gid ?? "") : "",
locality: "",
number: "",
postal_code: "",
raw: address.value,
region: "",
street: "",
unit: "",
},
location: marker.value?.location ?? {
latitude: 0,
longitude: 0,
},
};
emit("update:modelValue", newLocator);
}
</script>

View file

@ -1,3 +1,24 @@
<style scoped>
.banner {
display: block;
}
.banner-container {
position: relative;
width: 100%;
background-color: #f76436;
overflow: hidden;
}
@include media-breakpoint-up(xs) {
.banner {
width: 100%;
}
}
@include media-breakpoint-up(xl) {
.banner {
height: 100%;
}
}
</style>
<template>
<section class="py-2 bg-primary text-white">
<div class="banner-container d-flex justify-content-center">

View file

@ -3,6 +3,14 @@
height: 48px;
width: 48px;
}
.service-card {
transition: transform 0.3s;
height: 100%;
}
.service-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
</style>
<template>
<main>

View file

@ -1,8 +1,4 @@
<style scoped>
.district-logo {
max-height: 80px;
width: auto;
}
.form-section {
margin-bottom: 2.5rem;
padding-bottom: 2rem;
@ -92,38 +88,6 @@ select.tall {
border-left: 4px solid #0d6efd;
background-color: #f8f9fa;
}
.map-container {
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-bottom: 20px;
margin-top: 20px;
align-items: center;
justify-content: center;
/* Prevent touch scrolling issues */
touch-action: pan-y pinch-zoom;
}
#map {
width: 100%;
height: 100%;
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.map-container {
height: 400px;
margin-bottom: 15px;
margin-top: 15px;
}
}
/* Extra small devices */
@media (max-width: 576px) {
.map-container {
height: 350px;
border-radius: 5px;
}
}
</style>
<template>
@ -139,9 +103,9 @@ select.tall {
<!-- Report Form -->
<form
@submit.prevent="doSubmit"
enctype="multipart/form-data"
ref="formElement"
@submit.prevent="doSubmit"
>
<!-- Location Section -->
<div class="form-section">
@ -157,29 +121,11 @@ select.tall {
</p>
</div>
</div>
<div class="col-md-6">
<div class="mb-3 position-relative">
<AddressSuggestion
v-model="address"
placeholder="Start typing an address (min 3 characters)"
@suggestion-selected="doAddressSuggestionSelected"
>
</AddressSuggestion>
</div>
</div>
</div>
<p class="small text-muted mb-2">
You can also click on the map to mark the location precisely
</p>
<div class="map-container">
<MapLocator
v-model="currentCamera"
:markers="markers"
@click="doMapClick"
@marker-drag-end="doMapMarkerDragEnd"
/>
</div>
<AddressAndMapLocator v-model="locator" />
<!-- Mosquito Activity Section -->
<div class="form-section">
@ -321,11 +267,11 @@ select.tall {
</div>
<button
id="toggle-additional"
class="btn btn-warning"
v-if="!showMore"
type="button"
@click="showMore = true"
id="toggle-additional"
type="button"
v-if="!showMore"
>
Click here to answer a few more questions to better help us solve your
mosquito problem
@ -521,6 +467,7 @@ import { computed, onMounted, ref } from "vue";
import AddressSuggestion from "@/components/AddressSuggestion.vue";
import ImageUpload, { Image } from "@/components/ImageUpload.vue";
import MapLocator from "@/components/MapLocator.vue";
import AddressAndMapLocator from "@/rmo/components/AddressAndMapLocator.vue";
import { useGeocodeStore } from "@/store/geocode";
import { useLocationStore } from "@/store/location";
import { useStorePublicReport } from "@/store/publicreport";
@ -532,88 +479,21 @@ import type {
Location,
PublicReport,
} from "@/type/api";
import type { Camera } from "@/type/map";
import type { Camera, Locator } from "@/type/map";
const address = ref<string>("");
const currentCamera = ref<Camera | null>(null);
const currentLocation = ref<Location | null>(null);
const errorMessage = ref("");
const formElement = ref<HTMLFormElement | null>(null);
const images = ref<Image[]>([]);
const isSubmitting = ref(false);
const marker = ref<Marker | null>(null);
const locationStore = useLocationStore();
const locator = ref<Locator | null>(null);
const showMore = ref<boolean>(false);
const selectedSuggestion = ref<GeocodeSuggestion | null>(null);
const locationStore = useLocationStore();
const storePublicReport = useStorePublicReport();
const geocode = useGeocodeStore();
const markers = computed((): Marker[] => {
if (marker.value) {
return [marker.value];
} else {
return [];
}
});
const router = useRouter();
function doAddressSuggestionSelected(suggestion: GeocodeSuggestion) {
console.log("Address suggestion selected", suggestion);
doAddressSuggestionDetails(suggestion);
}
async function doAddressSuggestionDetails(suggestion: GeocodeSuggestion) {
// Fetch full details for the selected suggestion
//const url = `https://api.stadiamaps.com/geocoding/v2/place_details?ids=${suggestion.properties.gid}`;
selectedSuggestion.value = suggestion;
const url = `/api/geocode/by-gid/${suggestion.gid}`;
const response = await fetch(url);
if (!response.ok) {
console.error("Failed to get suggestion detail", response.statusText);
return;
}
const data = (await response.json()) as Geocode;
if (currentCamera.value) {
currentCamera.value.zoom = 15;
}
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: data.location,
};
}
function doMapClick(location: Location) {
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: location,
};
geocode
.reverse(location)
.then((code: Geocode) => {
address.value = code.address.raw;
selectedSuggestion.value = {
detail: code.address.number + " " + code.address.street,
gid: code.address.gid,
locality: code.address.locality,
type: "address",
};
console.log("reverse geocoded", code);
})
.catch((e) => {
console.error("failed to reverse geocode after map click", e);
});
}
function doMapMarkerDragEnd(location: Location) {
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: location,
};
}
async function doSubmit() {
if (!formElement.value) return;
@ -621,17 +501,37 @@ async function doSubmit() {
errorMessage.value = "";
try {
const formData = new FormData(formElement.value);
if (selectedSuggestion.value) {
formData.append("address-gid", selectedSuggestion.value.gid);
}
if (currentLocation.value) {
formData.append("latitude", currentLocation.value.latitude.toString());
formData.append("longitude", currentLocation.value.longitude.toString());
if (locator.value) {
if (locator.value.address) {
formData.append("locator.address.gid", locator.value.address.gid);
formData.append("locator.address.raw", locator.value.address.raw);
}
if (locator.value.location) {
formData.append(
"locator.location.latitude",
locator.value.location.latitude.toString(),
);
formData.append(
"locator.location.longitude",
locator.value.location.longitude.toString(),
);
}
}
formData.append(
"location.accuracy",
currentLocation.value?.accuracy?.toString() ?? "0",
);
formData.append(
"location.latitude",
currentLocation.value?.latitude.toString() ?? "0",
);
formData.append(
"location.longitude",
currentLocation.value?.longitude.toString() ?? "0",
);
images.value.forEach((image, index) => {
formData.append(`image[${index}]`, image.file, image.name);
});
formData.append("address", address.value);
const resp = await fetch("/api/rmo/nuisance", {
method: "POST",
body: formData,

View file

@ -1,3 +1,71 @@
<style scoped>
.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;
}
.search-box {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
@media (max-width: 768px) {
.map-container {
height: 300px;
}
}
/* Base styles for circular checkboxes */
.custom-circle-checkbox .form-check-input {
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
margin-top: 0.25rem;
background-image: none; /* Remove Bootstrap's default checkmark */
position: relative;
}
/* Style for when checked */
.custom-circle-checkbox .form-check-input:checked {
background-color: currentColor;
border-color: currentColor;
}
/* Style for when unchecked - just an outline */
.custom-circle-checkbox .form-check-input:not(:checked) {
background-color: transparent;
}
/* Colors based on data attribute */
.custom-circle-checkbox .form-check-input[data-color="danger"] {
border-color: $red;
color: $red;
}
.custom-circle-checkbox .form-check-input[data-color="success"] {
border-color: $blue;
color: $blue;
}
.custom-circle-checkbox .form-check-input[data-color="info"] {
border-color: $green;
color: $green;
}
</style>
<template>
<div class="container my-4">
<!-- Search Box -->

View file

@ -1,4 +1,93 @@
<style scoped>
.form-section {
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid #dee2e6;
}
.form-section:last-child {
border-bottom: none;
margin-bottom: 1rem;
padding-bottom: 0;
}
.section-heading {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
}
.section-heading i {
margin-right: 10px;
font-size: 1.5rem;
color: #0d6efd;
}
.submit-container {
background-color: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin-top: 2rem;
}
.source-card {
height: 100%;
transition: transform 0.3s;
}
.source-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.source-icon {
font-size: 2rem;
margin-bottom: 1rem;
color: #0d6efd;
}
.time-of-day-btn {
width: 100%;
margin-bottom: 10px;
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 0;
}
.time-of-day-icon {
font-size: 1.5rem;
margin-bottom: 8px;
}
.time-label {
font-size: 0.9rem;
}
select.tall {
height: 160px;
}
.severity-item {
text-align: center;
padding: 10px;
}
.severity-scale {
display: flex;
justify-content: space-between;
margin: 20px 0;
}
.btn-check:checked + .btn.time-of-day-btn {
background-color: $info;
color: white;
}
.inspection-type-card {
cursor: pointer;
border: 1px solid #dee2e6;
padding: 20px;
border-radius: 5px;
height: 100%;
transition: all 0.3s;
}
.inspection-type-card.selected {
border-color: #0d6efd;
background-color: rgba(13, 110, 253, 0.05);
}
.inspection-type-card:hover {
border-color: #0d6efd;
}
.card-highlight {
border-left: 4px solid #0d6efd;
background-color: #f8f9fa;
}
.map-container {
background-color: #e9ecef;
border-radius: 10px;
@ -7,12 +96,32 @@
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
margin-top: 20px;
/* Prevent touch scrolling issues */
touch-action: pan-y pinch-zoom;
}
#map {
width: 100%;
height: 100%;
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.map-container {
height: 400px;
margin-bottom: 15px;
margin-top: 15px;
}
}
/* Extra small devices */
@media (max-width: 576px) {
.map-container {
height: 350px;
border-radius: 5px;
}
}
</style>
<template>
<main class="py-5">
@ -31,10 +140,9 @@
<!-- Report Form -->
<form
id="standingWater"
action="{{ .URL.WaterSubmit }}"
method="POST"
enctype="multipart/form-data"
ref="formElement"
@submit.prevent="doSubmit"
>
<!-- Photo Upload Section -->
<div class="form-section">
@ -47,7 +155,7 @@
location data that can help us find the production source.
</p>
<div class="mb-4">
<photo-upload />
<ImageUpload v-model="images" />
</div>
</div>
@ -129,10 +237,12 @@
/>
<div class="col-md-6">
<div class="mb-3 position-relative">
<address-input
<AddressSuggestion
v-model="address"
placeholder="Start typing an address (min 3 characters)"
@suggestion-selected="doAddressSuggestionSelected"
>
</address-input>
</AddressSuggestion>
</div>
</div>
</div>
@ -141,21 +251,27 @@
You can also click on the map to mark the location precisely
</p>
<div class="map-container">
<map-locator id="map"></map-locator>
<MapLocator
v-model="currentCamera"
:markers="markers"
@click="doMapClick"
@marker-drag-end="doMapMarkerDragEnd"
/>
</div>
<input type="hidden" id="map-zoom" name="map-zoom" />
</div>
<button
id="toggle-additional"
class="btn btn-warning"
@click="showMore = true"
id="toggle-additional"
type="button"
@click="toggleCollapse()"
v-if="!showMore"
>
Click here to answer a few more questions to better help us solve your
mosquito problem
</button>
<div class="collapse" id="collapse-additional-fields">
<div :class="{ collapse: !showMore }" id="collapse-additional-fields">
<!-- Source Details Section -->
<div class="form-section">
<div class="section-heading">
@ -395,12 +511,12 @@
type="checkbox"
/>
<label class="form-check-label" for="reporter-confidential">
<i
class="bi bi-info-circle-fill text-primary ms-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
<Tooltip
placement="top"
title="We share your information with mosquito control districts so they can follow up with any questions they may have about your report. Check this box if you would like the district to be careful not to share your information outside of the district operations team."
></i>
>
<i class="bi bi-info-circle-fill text-primary ms-1"></i>
</Tooltip>
I would like my personal information kept
confidential.</label
>
@ -519,10 +635,154 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import AddressSuggestion from "@/components/AddressSuggestion.vue";
import ImageUpload, { Image } from "@/components/ImageUpload.vue";
import MapLocator from "@/components/MapLocator.vue";
import Tooltip from "@/components/Tooltip.vue";
import { useGeocodeStore } from "@/store/geocode";
import { useLocationStore } from "@/store/location";
import { useStorePublicReport } from "@/store/publicreport";
import type { Marker } from "@/types";
import type {
Address,
Geocode,
GeocodeSuggestion,
Location,
PublicReport,
} from "@/type/api";
import type { Camera } from "@/type/map";
const isCollapsed = ref<boolean>(true);
const toggleCollapse = () => {
isCollapsed.value = !isCollapsed.value;
};
const address = ref<string>("");
const currentCamera = ref<Camera | null>(null);
const currentLocation = ref<Location | null>(null);
const errorMessage = ref("");
const formElement = ref<HTMLFormElement | null>(null);
const geocode = useGeocodeStore();
const images = ref<Image[]>([]);
const isSubmitting = ref(false);
const marker = ref<Marker | null>(null);
const markers = computed((): Marker[] => {
if (marker.value) {
return [marker.value];
} else {
return [];
}
});
const locationStore = useLocationStore();
const router = useRouter();
const selectedSuggestion = ref<GeocodeSuggestion | null>(null);
const showMore = ref<boolean>(false);
const storePublicReport = useStorePublicReport();
function doAddressSuggestionSelected(suggestion: GeocodeSuggestion) {
console.log("Address suggestion selected", suggestion);
doAddressSuggestionDetails(suggestion);
}
async function doAddressSuggestionDetails(suggestion: GeocodeSuggestion) {
// Fetch full details for the selected suggestion
selectedSuggestion.value = suggestion;
const url = `/api/geocode/by-gid/${suggestion.gid}`;
const response = await fetch(url);
if (!response.ok) {
console.error("Failed to get suggestion detail", response.statusText);
return;
}
const data = (await response.json()) as Geocode;
if (currentCamera.value) {
currentCamera.value.zoom = 15;
}
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: data.location,
};
}
function doMapClick(location: Location) {
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: location,
};
geocode
.reverse(location)
.then((code: Geocode) => {
address.value = code.address.raw;
selectedSuggestion.value = {
detail: code.address.number + " " + code.address.street,
gid: code.address.gid,
locality: code.address.locality,
type: "address",
};
console.log("reverse geocoded", code);
})
.catch((e) => {
console.error("failed to reverse geocode after map click", e);
});
}
function doMapMarkerDragEnd(location: Location) {
marker.value = {
color: "#FF0000",
draggable: true,
id: "x",
location: location,
};
}
async function doSubmit() {
if (!formElement.value) return;
isSubmitting.value = true;
errorMessage.value = "";
try {
const formData = new FormData(formElement.value);
if (selectedSuggestion.value) {
formData.append("address-gid", selectedSuggestion.value.gid);
}
if (currentLocation.value) {
formData.append("latitude", currentLocation.value.latitude.toString());
formData.append("longitude", currentLocation.value.longitude.toString());
}
images.value.forEach((image, index) => {
formData.append(`image[${index}]`, image.file, image.name);
});
formData.append("address", address.value);
const resp = await fetch("/api/rmo/water", {
method: "POST",
body: formData,
// Don't set Content-Type, the borwser should do it
});
const data: PublicReport = (await resp.json()) as PublicReport;
storePublicReport.add(data);
router.push("/submitted/" + data.id);
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : "Upload failed";
} finally {
isSubmitting.value = false;
}
}
onMounted(() => {
locationStore
.get()
.then((loc: GeolocationPosition) => {
console.log("user geolocation", loc);
const coords = loc.coords;
currentLocation.value = coords;
currentCamera.value = {
location: coords,
zoom: 15,
};
})
.catch((e) => {
console.log("failed to get location", e);
});
});
</script>

View file

@ -1,21 +1,3 @@
<style scoped>
.map-placeholder {
width: 100%;
height: 250px;
background-color: #e9ecef;
border: 2px dashed #6c757d;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 14px;
}
#address-input {
font-size: 16px;
}
</style>
<template>
<div class="container-fluid px-3 py-3">
<HeaderCompliance :district="district" />
@ -28,53 +10,37 @@
Please enter the address so we can match your response with our records.
</p>
<form id="address-form" method="POST" action="/compliance/address">
<div class="mb-4">
<label for="address-input" class="form-label fw-semibold">
Property Address
</label>
<input
type="text"
class="form-control form-control-lg"
id="address-input"
name="address"
placeholder="Start typing your address..."
autocomplete="off"
data-autocomplete="true"
required
/>
<div class="form-text">
Begin typing and select your address from the suggestions
</div>
</div>
<AddressAndMapLocator v-model="locator" />
<!-- Map Placeholder -->
<div class="mb-4">
<label class="form-label fw-semibold">Location Preview</label>
<div class="map-placeholder" id="map-container">
<span>Map will appear here after address is selected</span>
</div>
</div>
<!-- Navigation Buttons -->
<div class="d-flex gap-2 mt-4">
<RouterLink class="btn btn-outline-secondary" to="../compliance">
Back
</RouterLink>
<RouterLink class="btn btn-primary flex-grow-1" to="./concern">
Continue
</RouterLink>
</div>
</form>
<div class="d-flex gap-2 mt-4">
<RouterLink class="btn btn-outline-secondary" to="../compliance">
Back
</RouterLink>
<button class="btn btn-primary flex-grow-1" @click="doContinue">
Continue
</button>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { District } from "@/type/api";
import HeaderCompliance from "@/rmo/components/HeaderCompliance.vue";
import ProgressBarCompliance from "@/rmo/components/ProgressBarCompliance.vue";
import AddressAndMapLocator from "@/rmo/components/AddressAndMapLocator.vue";
import { Locator } from "@/type/map";
interface Emits {
(e: "doLocator", locator: Locator | null): void;
}
interface Props {
district: District;
}
const emit = defineEmits<Emits>();
const error = ref<string>("");
const props = defineProps<Props>();
const locator = ref<Locator | null>(null);
function doContinue() {
emit("doLocator", locator.value);
}
</script>

View file

@ -18,7 +18,7 @@ body > .container-fluid {
<template>
<template v-if="district">
<router-view v-slot="{ Component }">
<component :is="Component" :district="district" />
<component :is="Component" :district="district" @doLocator="doLocator" />
</router-view>
</template>
<template v-else>
@ -33,6 +33,8 @@ import { computedAsync } from "@vueuse/core";
import { useStoreDistrict } from "@/rmo/store/district";
import Intro from "@/rmo/content/compliance/Intro.vue";
import type { District } from "@/type/api";
import { Locator } from "@/type/map";
interface Props {
slug: string;
}
@ -44,4 +46,7 @@ const district = computedAsync(async (): Promise<District | undefined> => {
const districts = await districtStore.list();
return districts.find((district: District) => district.slug == props.slug);
});
function doLocator(locator: Locator | null) {
console.log("locator", locator);
}
</script>

View file

@ -204,9 +204,9 @@
You can check the status of your report at any time using your
Report ID.
</p>
<a :href="`/status/${id}`" class="btn btn-outline-primary">
<RouterLink :to="`/status/${id}`" class="btn btn-outline-primary">
Check Status
</a>
</RouterLink>
</div>
<div class="row">

View file

@ -27,6 +27,7 @@ export interface District {
url_website: string;
}
export interface Location {
accuracy?: number;
latitude: number;
longitude: number;
}

View file

@ -1,11 +1,14 @@
import maplibregl from "maplibre-gl";
import type { Location } from "@/type/api";
import type { Address, Location } from "@/type/api";
export interface Camera {
location: Location;
zoom: number;
}
export interface Locator {
address: Address;
location: Location;
}
export type MoveEndEventInternal = maplibregl.MapLibreEvent<
| maplibregl.MapMouseEvent
| maplibregl.MapTouchEvent