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:
parent
cb9e5146bf
commit
9dccd21cee
25 changed files with 828 additions and 598 deletions
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Gleipnir-Technology/go-geojson2h3/v2"
|
"github.com/Gleipnir-Technology/go-geojson2h3/v2"
|
||||||
"github.com/tidwall/geojson"
|
//"github.com/tidwall/geojson"
|
||||||
"github.com/uber/h3-go/v4"
|
"github.com/uber/h3-go/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,29 +37,6 @@ func H3ToGeoJSON(indexes []h3.Cell) (interface{}, error) {
|
||||||
return featureCollection.JSON(), nil
|
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
|
// Given a cell at a smaller resolution remap it to the larger resolution
|
||||||
func scaleCell(cell h3.Cell, resolution int) (h3.Cell, error) {
|
func scaleCell(cell h3.Cell, resolution int) (h3.Cell, error) {
|
||||||
r := cell.Resolution()
|
r := cell.Resolution()
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ func DistrictForLocation(ctx context.Context, lng float64, lat float64) (*models
|
||||||
return nil, errors.New("too many organizations")
|
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 err error
|
||||||
var org *models.Organization
|
var org *models.Organization
|
||||||
for _, image := range images {
|
for _, image := range images {
|
||||||
|
|
@ -58,7 +58,7 @@ func MatchDistrict(ctx context.Context, longitude, latitude *float64, images []I
|
||||||
return &org.ID, nil
|
return &org.ID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if longitude == nil || latitude == nil {
|
if longitude == 0 || latitude == 0 {
|
||||||
org, err = DistrictCatchall(ctx)
|
org, err = DistrictCatchall(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get catchall: %w", err)
|
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")
|
log.Debug().Int32("id", org.ID).Msg("No location from images, no latlng for the report itself, using catchall")
|
||||||
return &org.ID, nil
|
return &org.ID, nil
|
||||||
}
|
}
|
||||||
org, err = DistrictForLocation(ctx, *longitude, *latitude)
|
org, err = DistrictForLocation(ctx, longitude, latitude)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Failed to get district for location")
|
log.Warn().Err(err).Msg("Failed to get district for location")
|
||||||
return nil, fmt.Errorf("Failed to get district for location: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get catchall: %w", err)
|
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
|
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
|
return &org.ID, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
|
"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 {
|
type LatLng struct {
|
||||||
|
|
@ -17,41 +11,3 @@ type LatLng struct {
|
||||||
AccuracyValue float64
|
AccuracyValue float64
|
||||||
AccuracyType enums.PublicreportAccuracytype
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func PublicReportReporterUpdated(ctx context.Context, org_id int32, report_id string) {
|
||||||
event.Updated(event.TypeRMOReport, org_id, report_id)
|
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) {
|
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, latlng, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) 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)
|
setter_nuisance.ReportID = omit.From(report_id)
|
||||||
_, err := models.PublicreportNuisances.Insert(&setter_nuisance).One(ctx, txn)
|
_, err := models.PublicreportNuisances.Insert(&setter_nuisance).One(ctx, txn)
|
||||||
if err != nil {
|
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) {
|
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, latlng, address, images, func(ctx context.Context, txn bob.Executor, report_id int32) 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)
|
setter_water.ReportID = omit.From(report_id)
|
||||||
_, err := models.PublicreportWaters.Insert(&setter_water).One(ctx, txn)
|
_, err := models.PublicreportWaters.Insert(&setter_water).One(ctx, txn)
|
||||||
if err != nil {
|
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
|
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)
|
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create txn: %w", err)
|
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
|
// If we've got an locality value it was set by geocoding so we should save it
|
||||||
var a *models.Address
|
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{
|
a, err = geocode.EnsureAddress(ctx, txn, address, types.Location{
|
||||||
Latitude: *latlng.Latitude,
|
Latitude: location.Latitude,
|
||||||
Longitude: *latlng.Longitude,
|
Longitude: location.Longitude,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to ensure address: %w", err)
|
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)
|
return nil, fmt.Errorf("Failed to save image uploads: %w", err)
|
||||||
}
|
}
|
||||||
var organization_id *int32
|
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 {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Failed to match district")
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to create report database record: %w", err)
|
return nil, fmt.Errorf("Failed to create report database record: %w", err)
|
||||||
}
|
}
|
||||||
if latlng.Latitude != nil && latlng.Longitude != nil {
|
if location.Latitude != 0 && location.Longitude != 0 {
|
||||||
h3cell, _ := latlng.H3Cell()
|
h3cell, _ := location.H3Cell()
|
||||||
geom_query, _ := latlng.GeometryQuery()
|
geom_query, _ := location.GeometryQuery()
|
||||||
_, err = psql.Update(
|
_, err = psql.Update(
|
||||||
um.Table("publicreport.report"),
|
um.Table("publicreport.report"),
|
||||||
um.SetCol("h3cell").ToArg(h3cell),
|
um.SetCol("h3cell").ToArg(h3cell),
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import (
|
||||||
|
|
||||||
type Address struct {
|
type Address struct {
|
||||||
Country string `db:"country" json:"country"`
|
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"`
|
Locality string `db:"locality" json:"locality"`
|
||||||
Number string `db:"number" json:"number"`
|
Number string `db:"number" json:"number"`
|
||||||
PostalCode string `db:"postal_code" json:"postal_code"`
|
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"`
|
Region string `db:"region" json:"region"`
|
||||||
Street string `db:"street" json:"street"`
|
Street string `db:"street" json:"street"`
|
||||||
Unit string `db:"unit" json:"unit"`
|
Unit string `db:"unit" json:"unit"`
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,33 @@ package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/h3utils"
|
||||||
|
//"github.com/rs/zerolog/log"
|
||||||
|
"github.com/uber/h3-go/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Location struct {
|
type Location struct {
|
||||||
Latitude float64 `db:"latitude" json:"latitude"`
|
Accuracy *float32 `db:"accuracy" json:"accuracy" schema:"accuracy"`
|
||||||
Longitude float64 `db:"longitude" json:"longitude"`
|
Latitude float64 `db:"latitude" json:"latitude" schema:"latitude"`
|
||||||
|
Longitude float64 `db:"longitude" json:"longitude" schema:"longitude"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l Location) String() string {
|
func (l Location) String() string {
|
||||||
return fmt.Sprintf("%f %f", l.Longitude, l.Latitude)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ package resource
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
|
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
|
||||||
|
|
@ -13,6 +11,7 @@ import (
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/html"
|
"github.com/Gleipnir-Technology/nidus-sync/html"
|
||||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||||
"github.com/aarondl/opt/omit"
|
"github.com/aarondl/opt/omit"
|
||||||
"github.com/aarondl/opt/omitnull"
|
"github.com/aarondl/opt/omitnull"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
@ -32,84 +31,25 @@ type nuisance struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
URI string `json:"uri"`
|
URI string `json:"uri"`
|
||||||
}
|
}
|
||||||
type nuisanceForm struct {
|
type Locator struct {
|
||||||
AdditionalInfo string `schema:"additional-info"`
|
Address types.Address `schema:"address"`
|
||||||
AddressGID string `schema:"address-gid"`
|
Location types.Location `schema:"location"`
|
||||||
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 nuisanceForm struct {
|
||||||
func parseLatLng(r *http.Request) (platform.LatLng, error) {
|
AdditionalInfo string `schema:"additional-info"`
|
||||||
result := platform.LatLng{
|
Duration string `schema:"duration"`
|
||||||
AccuracyType: enums.PublicreportAccuracytypeNone,
|
Location types.Location `schema:"location"`
|
||||||
AccuracyValue: 0.0,
|
Locator Locator `schema:"locator"`
|
||||||
Latitude: nil,
|
MapZoom string `schema:"map-zoom"`
|
||||||
Longitude: nil,
|
SourceStagnant bool `schema:"source-stagnant"`
|
||||||
MapZoom: 0.0,
|
SourceContainer bool `schema:"source-container"`
|
||||||
}
|
SourceDescription string `schema:"source-description"`
|
||||||
latitude_str := r.FormValue("latitude")
|
SourceGutters bool `schema:"source-gutters"`
|
||||||
longitude_str := r.FormValue("longitude")
|
SourceLocations []string `schema:"source-location"`
|
||||||
latlng_accuracy_type_str := r.PostFormValue("latlng-accuracy-type")
|
TODEarly bool `schema:"tod-early"`
|
||||||
latlng_accuracy_value_str := r.PostFormValue("latlng-accuracy-value")
|
TODDay bool `schema:"tod-day"`
|
||||||
map_zoom_str := r.PostFormValue("map-zoom")
|
TODEvening bool `schema:"tod-evening"`
|
||||||
|
TODNight bool `schema:"tod-night"`
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceForm) (*nuisance, *nhttp.ErrorWithStatus) {
|
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_pool := slices.Contains(n.SourceLocations, "pool-area")
|
||||||
is_location_other := slices.Contains(n.SourceLocations, "other")
|
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 {
|
if err != nil {
|
||||||
log.Warn().Err(err).Str("duration_str", n.Duration).Msg("Failed to interpret 'duration'")
|
log.Warn().Err(err).Str("duration_str", n.Duration).Msg("Failed to interpret 'duration'")
|
||||||
}
|
}
|
||||||
|
|
||||||
uploads, err := html.ExtractImageUploads(r)
|
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 {
|
if err != nil {
|
||||||
return nil, nhttp.NewError("Failed to extract image uploads: %w", err)
|
return nil, nhttp.NewError("Failed to extract image uploads: %w", err)
|
||||||
}
|
}
|
||||||
address := platform.Address{
|
address := platform.Address{
|
||||||
GID: n.AddressGID,
|
GID: n.Locator.Address.GID,
|
||||||
Raw: n.Address,
|
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{
|
setter_report := models.PublicreportReportSetter{
|
||||||
//AddressID: omitnull.From(latlng.Cell.String()),
|
//AddressID: omitnull.From(latlng.Cell.String()),
|
||||||
AddressCountry: omit.From(""),
|
AddressCountry: omit.From(""),
|
||||||
|
|
@ -148,11 +91,11 @@ func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceFor
|
||||||
AddressStreet: omit.From(""),
|
AddressStreet: omit.From(""),
|
||||||
Created: omit.From(time.Now()),
|
Created: omit.From(time.Now()),
|
||||||
//H3cell: omitnull.From(latlng.Cell.String()),
|
//H3cell: omitnull.From(latlng.Cell.String()),
|
||||||
LatlngAccuracyType: omit.From(latlng.AccuracyType),
|
LatlngAccuracyType: omit.From(enums.PublicreportAccuracytypeBrowser),
|
||||||
LatlngAccuracyValue: omit.From(float32(latlng.AccuracyValue)),
|
LatlngAccuracyValue: omit.From(accuracy),
|
||||||
//Location: omitnull.From(fmt.Sprintf("ST_GeometryFromText(Point(%s %s))", longitude, latitude)),
|
//Location: omitnull.From(fmt.Sprintf("ST_GeometryFromText(Point(%s %s))", longitude, latitude)),
|
||||||
Location: omitnull.FromPtr[string](nil),
|
Location: omitnull.FromPtr[string](nil),
|
||||||
MapZoom: omit.From(latlng.MapZoom),
|
MapZoom: omit.From(float32(0.0)),
|
||||||
//OrganizationID: omitnull.FromPtr(organization_id),
|
//OrganizationID: omitnull.FromPtr(organization_id),
|
||||||
//PublicID: omit.From(public_id),
|
//PublicID: omit.From(public_id),
|
||||||
ReporterEmail: omit.From(""),
|
ReporterEmail: omit.From(""),
|
||||||
|
|
@ -179,7 +122,7 @@ func (res *nuisanceR) Create(ctx context.Context, r *http.Request, n nuisanceFor
|
||||||
TodEvening: omit.From(n.TODEvening),
|
TodEvening: omit.From(n.TODEvening),
|
||||||
TodNight: omit.From(n.TODNight),
|
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 {
|
if err != nil {
|
||||||
return nil, nhttp.NewError("create nuisance report: %w", err)
|
return nil, nhttp.NewError("create nuisance report: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ import (
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/html"
|
"github.com/Gleipnir-Technology/nidus-sync/html"
|
||||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||||
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
||||||
|
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||||
"github.com/aarondl/opt/omit"
|
"github.com/aarondl/opt/omit"
|
||||||
//"github.com/aarondl/opt/omitnull"
|
"github.com/aarondl/opt/omitnull"
|
||||||
//"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Water(r *router) *waterR {
|
func Water(r *router) *waterR {
|
||||||
|
|
@ -25,69 +26,64 @@ type waterR struct {
|
||||||
router *router
|
router *router
|
||||||
}
|
}
|
||||||
type water struct {
|
type water struct {
|
||||||
ID string `json:"id"`
|
District string `json:"district"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
URI string `json:"uri"`
|
||||||
}
|
}
|
||||||
type waterForm struct {
|
type waterForm struct {
|
||||||
AccessComments string `schema:"access-comments"`
|
AccessComments string `schema:"access-comments"`
|
||||||
AccessDog bool `schema:"access-dog"`
|
AccessDog bool `schema:"access-dog"`
|
||||||
AccessFence bool `schema:"access-fence"`
|
AccessFence bool `schema:"access-fence"`
|
||||||
AccessGate bool `schema:"access-gate"`
|
AccessGate bool `schema:"access-gate"`
|
||||||
AccessLocked bool `schema:"access-locked"`
|
AccessLocked bool `schema:"access-locked"`
|
||||||
AccessOther bool `schema:"access-other"`
|
AccessOther bool `schema:"access-other"`
|
||||||
AddressRaw string `schema:"address"`
|
Address string `schema:"address"`
|
||||||
AddressCountry string `schema:"address-country"`
|
AddressGID string `schema:"address-gid"`
|
||||||
AddressLocality string `schema:"address-locality"`
|
Comments string `schema:"comments"`
|
||||||
AddressNumber string `schema:"address-number"`
|
HasAdult bool `schema:"has-adult"`
|
||||||
AddressPostalCode string `schema:"address-postalcode"`
|
HasBackyardPermission bool `schema:"backyard-permission"`
|
||||||
AddressRegion string `schema:"address-region"`
|
HasLarvae bool `schema:"has-larvae"`
|
||||||
AddressStreet string `schema:"address-street"`
|
HasPupae bool `schema:"has-pupae"`
|
||||||
Comments string `schema:"comments"`
|
IsReporterConfidential bool `schema:"reporter-confidential"`
|
||||||
HasAdult bool `schema:"has-adult"`
|
IsReporter_owner bool `schema:"property-ownership"`
|
||||||
HasBackyardPermission bool `schema:"backyard-permission"`
|
Location types.Location `schema:"location"`
|
||||||
HasLarvae bool `schema:"has-larvae"`
|
Locator Locator `schema:"locator"`
|
||||||
HasPupae bool `schema:"has-pupae"`
|
OwnerEmail string `schema:"owner-email"`
|
||||||
IsReporterConfidential bool `schema:"reporter-confidential"`
|
OwnerName string `schema:"owner-name"`
|
||||||
IsReporter_owner bool `schema:"property-ownership"`
|
OwnerPhone string `schema:"owner-phone"`
|
||||||
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) {
|
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)
|
uploads, err := html.ExtractImageUploads(r)
|
||||||
|
log.Info().Int("len", len(uploads)).Msg("extracted water uploads")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nhttp.NewError("Failed to extract image uploads: %w", err)
|
return nil, nhttp.NewError("Failed to extract image uploads: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
address := platform.Address{
|
address := platform.Address{
|
||||||
Country: w.AddressCountry,
|
GID: w.AddressGID,
|
||||||
Locality: w.AddressLocality,
|
Raw: w.Address,
|
||||||
Number: w.AddressNumber,
|
}
|
||||||
PostalCode: w.AddressPostalCode,
|
accuracy := float32(0.0)
|
||||||
Raw: w.AddressRaw,
|
if w.Location.Accuracy != nil {
|
||||||
Region: w.AddressRegion,
|
accuracy = *w.Location.Accuracy
|
||||||
Street: w.AddressStreet,
|
|
||||||
Unit: "",
|
|
||||||
}
|
}
|
||||||
setter_report := models.PublicreportReportSetter{
|
setter_report := models.PublicreportReportSetter{
|
||||||
AddressRaw: omit.From(address.Raw),
|
AddressRaw: omit.From(address.Raw),
|
||||||
AddressCountry: omit.From(address.Country),
|
AddressCountry: omit.From(""),
|
||||||
AddressNumber: omit.From(address.Number),
|
AddressNumber: omit.From(""),
|
||||||
AddressLocality: omit.From(address.Locality),
|
AddressLocality: omit.From(""),
|
||||||
AddressPostalCode: omit.From(address.PostalCode),
|
AddressPostalCode: omit.From(""),
|
||||||
AddressRegion: omit.From(address.Region),
|
AddressRegion: omit.From(""),
|
||||||
AddressStreet: omit.From(address.Street),
|
AddressStreet: omit.From(""),
|
||||||
Created: omit.From(time.Now()),
|
Created: omit.From(time.Now()),
|
||||||
//H3cell: omitnull.From(geospatial.Cell.String()),
|
//H3cell: omitnull.From(geospatial.Cell.String()),
|
||||||
LatlngAccuracyType: omit.From(latlng.AccuracyType),
|
LatlngAccuracyType: omit.From(enums.PublicreportAccuracytypeBrowser),
|
||||||
LatlngAccuracyValue: omit.From(float32(latlng.AccuracyValue)),
|
LatlngAccuracyValue: omit.From(accuracy),
|
||||||
//Location: add later
|
//Location: add later
|
||||||
MapZoom: omit.From(latlng.MapZoom),
|
Location: omitnull.FromPtr[string](nil),
|
||||||
|
MapZoom: omit.From(float32(0.0)),
|
||||||
//OrganizationID: omitnull.FromPtr(organization_id),
|
//OrganizationID: omitnull.FromPtr(organization_id),
|
||||||
//PublicID: omit.From(public_id),
|
//PublicID: omit.From(public_id),
|
||||||
ReporterEmail: omit.From(""),
|
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),
|
OwnerPhone: omit.From(w.OwnerPhone),
|
||||||
//ReportID omit.Val[int32]
|
//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 {
|
if err != nil {
|
||||||
return nil, nhttp.NewError("Failed to save new report: %w", err)
|
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{
|
return &water{
|
||||||
ID: report.PublicID,
|
District: district_uri,
|
||||||
|
ID: report.PublicID,
|
||||||
|
URI: uri,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 3;
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
@import url("https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
@import url("https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
|
||||||
.map-wrapper {
|
.map-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -24,18 +24,18 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.45);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-overlay:hover {
|
.map-overlay:hover {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-content {
|
.overlay-content {
|
||||||
|
|
@ -71,7 +71,6 @@
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
background: #ffc107;
|
|
||||||
border: none;
|
border: none;
|
||||||
color: #000;
|
color: #000;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
|
@ -79,6 +78,12 @@
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
.map-status-btn.locked {
|
||||||
|
background: $warning;
|
||||||
|
}
|
||||||
|
.map-status-btn.unlocked {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
.map-status-btn:hover {
|
.map-status-btn:hover {
|
||||||
background: #ffb300;
|
background: #ffb300;
|
||||||
|
|
@ -120,38 +125,30 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="map-wrapper" ref="mapWrapper">
|
<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 -->
|
<!-- Map container -->
|
||||||
<div
|
<div ref="mapContainer" class="map-container"></div>
|
||||||
ref="mapContainer"
|
|
||||||
class="map-container"
|
|
||||||
:class="{ 'map-inactive': !mapInteractive }"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Lock/unlock indicator button -->
|
<!-- Lock/unlock indicator button -->
|
||||||
<button
|
<button
|
||||||
v-if="mapInteractive"
|
v-if="mapInteractive"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm map-status-btn"
|
class="btn btn-sm map-status-btn unlocked"
|
||||||
@click="deactivateMap"
|
@click="deactivateMap"
|
||||||
title="Lock map to enable page scrolling"
|
title="Lock map to enable page scrolling"
|
||||||
>
|
>
|
||||||
<i class="bi bi-unlock-fill"></i>
|
<i class="bi bi-unlock-fill"></i>
|
||||||
<span class="d-none d-md-inline ms-1">Map Active</span>
|
<span class="d-none d-md-inline ms-1">Map Active</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -198,7 +195,6 @@ const mapInteractive = ref(false);
|
||||||
const mapMarkers: Ref<Map<string, maplibregl.Marker>> = shallowRef<
|
const mapMarkers: Ref<Map<string, maplibregl.Marker>> = shallowRef<
|
||||||
Map<string, maplibregl.Marker>
|
Map<string, maplibregl.Marker>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
const mapOverlay = useTemplateRef("mapOverlay");
|
|
||||||
const mapWrapper = useTemplateRef("mapWrapper");
|
const mapWrapper = useTemplateRef("mapWrapper");
|
||||||
|
|
||||||
function activateMap() {
|
function activateMap() {
|
||||||
|
|
@ -225,29 +221,6 @@ function deactivateMap() {
|
||||||
map.value.doubleClickZoom.disable();
|
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
|
// Initialize map
|
||||||
function initializeMap() {
|
function initializeMap() {
|
||||||
if (!mapContainer.value) return;
|
if (!mapContainer.value) return;
|
||||||
|
|
@ -306,8 +279,6 @@ function initializeMap() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for clicks outside the map
|
// Listen for clicks outside the map
|
||||||
document.addEventListener("mousedown", handleOutsideClick);
|
|
||||||
document.addEventListener("touchstart", handleOutsideClick);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateModel(_map: maplibregl.Map) {
|
function updateModel(_map: maplibregl.Map) {
|
||||||
|
|
@ -382,6 +353,7 @@ const updateMarkers = () => {
|
||||||
mapMarkers.value.set(markerData.id, marker);
|
mapMarkers.value.set(markerData.id, marker);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
frameMarkers();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Frame all markers in view
|
// Frame all markers in view
|
||||||
|
|
@ -416,6 +388,7 @@ const frameMarkers = () => {
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newCamera) => {
|
(newCamera) => {
|
||||||
|
console.log("New map camera", newCamera);
|
||||||
if (map.value && newCamera) {
|
if (map.value && newCamera) {
|
||||||
map.value.panTo(
|
map.value.panTo(
|
||||||
{
|
{
|
||||||
|
|
@ -434,6 +407,7 @@ watch(
|
||||||
watch(
|
watch(
|
||||||
() => props.markers,
|
() => props.markers,
|
||||||
() => {
|
() => {
|
||||||
|
console.log("New map markers", props.markers);
|
||||||
updateMarkers();
|
updateMarkers();
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
|
|
@ -451,8 +425,6 @@ onUnmounted(() => {
|
||||||
if (clickTimeout.value) {
|
if (clickTimeout.value) {
|
||||||
clearTimeout(clickTimeout.value);
|
clearTimeout(clickTimeout.value);
|
||||||
}
|
}
|
||||||
document.removeEventListener("click", handleOutsideClick);
|
|
||||||
document.removeEventListener("touchstart", handleOutsideClick);
|
|
||||||
|
|
||||||
// Remove all markers
|
// Remove all markers
|
||||||
mapMarkers.value.forEach((marker) => marker.remove());
|
mapMarkers.value.forEach((marker) => marker.remove());
|
||||||
|
|
|
||||||
57
ts/components/Tooltip.vue
Normal file
57
ts/components/Tooltip.vue
Normal 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>
|
||||||
172
ts/rmo/components/AddressAndMapLocator.vue
Normal file
172
ts/rmo/components/AddressAndMapLocator.vue
Normal 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>
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<section class="py-2 bg-primary text-white">
|
<section class="py-2 bg-primary text-white">
|
||||||
<div class="banner-container d-flex justify-content-center">
|
<div class="banner-container d-flex justify-content-center">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@
|
||||||
height: 48px;
|
height: 48px;
|
||||||
width: 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>
|
</style>
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.district-logo {
|
|
||||||
max-height: 80px;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.form-section {
|
.form-section {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
|
|
@ -92,38 +88,6 @@ select.tall {
|
||||||
border-left: 4px solid #0d6efd;
|
border-left: 4px solid #0d6efd;
|
||||||
background-color: #f8f9fa;
|
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>
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -139,9 +103,9 @@ select.tall {
|
||||||
|
|
||||||
<!-- Report Form -->
|
<!-- Report Form -->
|
||||||
<form
|
<form
|
||||||
@submit.prevent="doSubmit"
|
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
ref="formElement"
|
ref="formElement"
|
||||||
|
@submit.prevent="doSubmit"
|
||||||
>
|
>
|
||||||
<!-- Location Section -->
|
<!-- Location Section -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
|
@ -157,29 +121,11 @@ select.tall {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<p class="small text-muted mb-2">
|
<p class="small text-muted mb-2">
|
||||||
You can also click on the map to mark the location precisely
|
You can also click on the map to mark the location precisely
|
||||||
</p>
|
</p>
|
||||||
<div class="map-container">
|
<AddressAndMapLocator v-model="locator" />
|
||||||
<MapLocator
|
|
||||||
v-model="currentCamera"
|
|
||||||
:markers="markers"
|
|
||||||
@click="doMapClick"
|
|
||||||
@marker-drag-end="doMapMarkerDragEnd"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mosquito Activity Section -->
|
<!-- Mosquito Activity Section -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
|
@ -321,11 +267,11 @@ select.tall {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="toggle-additional"
|
|
||||||
class="btn btn-warning"
|
class="btn btn-warning"
|
||||||
v-if="!showMore"
|
|
||||||
type="button"
|
|
||||||
@click="showMore = true"
|
@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
|
Click here to answer a few more questions to better help us solve your
|
||||||
mosquito problem
|
mosquito problem
|
||||||
|
|
@ -521,6 +467,7 @@ import { computed, onMounted, ref } from "vue";
|
||||||
import AddressSuggestion from "@/components/AddressSuggestion.vue";
|
import AddressSuggestion from "@/components/AddressSuggestion.vue";
|
||||||
import ImageUpload, { Image } from "@/components/ImageUpload.vue";
|
import ImageUpload, { Image } from "@/components/ImageUpload.vue";
|
||||||
import MapLocator from "@/components/MapLocator.vue";
|
import MapLocator from "@/components/MapLocator.vue";
|
||||||
|
import AddressAndMapLocator from "@/rmo/components/AddressAndMapLocator.vue";
|
||||||
import { useGeocodeStore } from "@/store/geocode";
|
import { useGeocodeStore } from "@/store/geocode";
|
||||||
import { useLocationStore } from "@/store/location";
|
import { useLocationStore } from "@/store/location";
|
||||||
import { useStorePublicReport } from "@/store/publicreport";
|
import { useStorePublicReport } from "@/store/publicreport";
|
||||||
|
|
@ -532,88 +479,21 @@ import type {
|
||||||
Location,
|
Location,
|
||||||
PublicReport,
|
PublicReport,
|
||||||
} from "@/type/api";
|
} 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 currentCamera = ref<Camera | null>(null);
|
||||||
const currentLocation = ref<Location | null>(null);
|
const currentLocation = ref<Location | null>(null);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const formElement = ref<HTMLFormElement | null>(null);
|
const formElement = ref<HTMLFormElement | null>(null);
|
||||||
const images = ref<Image[]>([]);
|
const images = ref<Image[]>([]);
|
||||||
const isSubmitting = ref(false);
|
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 showMore = ref<boolean>(false);
|
||||||
const selectedSuggestion = ref<GeocodeSuggestion | null>(null);
|
|
||||||
const locationStore = useLocationStore();
|
|
||||||
const storePublicReport = useStorePublicReport();
|
const storePublicReport = useStorePublicReport();
|
||||||
const geocode = useGeocodeStore();
|
const geocode = useGeocodeStore();
|
||||||
const markers = computed((): Marker[] => {
|
|
||||||
if (marker.value) {
|
|
||||||
return [marker.value];
|
|
||||||
} else {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const router = useRouter();
|
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() {
|
async function doSubmit() {
|
||||||
if (!formElement.value) return;
|
if (!formElement.value) return;
|
||||||
|
|
||||||
|
|
@ -621,17 +501,37 @@ async function doSubmit() {
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
try {
|
try {
|
||||||
const formData = new FormData(formElement.value);
|
const formData = new FormData(formElement.value);
|
||||||
if (selectedSuggestion.value) {
|
if (locator.value) {
|
||||||
formData.append("address-gid", selectedSuggestion.value.gid);
|
if (locator.value.address) {
|
||||||
}
|
formData.append("locator.address.gid", locator.value.address.gid);
|
||||||
if (currentLocation.value) {
|
formData.append("locator.address.raw", locator.value.address.raw);
|
||||||
formData.append("latitude", currentLocation.value.latitude.toString());
|
}
|
||||||
formData.append("longitude", currentLocation.value.longitude.toString());
|
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) => {
|
images.value.forEach((image, index) => {
|
||||||
formData.append(`image[${index}]`, image.file, image.name);
|
formData.append(`image[${index}]`, image.file, image.name);
|
||||||
});
|
});
|
||||||
formData.append("address", address.value);
|
|
||||||
const resp = await fetch("/api/rmo/nuisance", {
|
const resp = await fetch("/api/rmo/nuisance", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<div class="container my-4">
|
<div class="container my-4">
|
||||||
<!-- Search Box -->
|
<!-- Search Box -->
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,93 @@
|
||||||
<style scoped>
|
<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 {
|
.map-container {
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
@ -7,12 +96,32 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
/* Prevent touch scrolling issues */
|
||||||
|
touch-action: pan-y pinch-zoom;
|
||||||
}
|
}
|
||||||
#map {
|
#map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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>
|
</style>
|
||||||
<template>
|
<template>
|
||||||
<main class="py-5">
|
<main class="py-5">
|
||||||
|
|
@ -31,10 +140,9 @@
|
||||||
|
|
||||||
<!-- Report Form -->
|
<!-- Report Form -->
|
||||||
<form
|
<form
|
||||||
id="standingWater"
|
|
||||||
action="{{ .URL.WaterSubmit }}"
|
|
||||||
method="POST"
|
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
|
ref="formElement"
|
||||||
|
@submit.prevent="doSubmit"
|
||||||
>
|
>
|
||||||
<!-- Photo Upload Section -->
|
<!-- Photo Upload Section -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
|
@ -47,7 +155,7 @@
|
||||||
location data that can help us find the production source.
|
location data that can help us find the production source.
|
||||||
</p>
|
</p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<photo-upload />
|
<ImageUpload v-model="images" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -129,10 +237,12 @@
|
||||||
/>
|
/>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="mb-3 position-relative">
|
<div class="mb-3 position-relative">
|
||||||
<address-input
|
<AddressSuggestion
|
||||||
|
v-model="address"
|
||||||
placeholder="Start typing an address (min 3 characters)"
|
placeholder="Start typing an address (min 3 characters)"
|
||||||
|
@suggestion-selected="doAddressSuggestionSelected"
|
||||||
>
|
>
|
||||||
</address-input>
|
</AddressSuggestion>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,21 +251,27 @@
|
||||||
You can also click on the map to mark the location precisely
|
You can also click on the map to mark the location precisely
|
||||||
</p>
|
</p>
|
||||||
<div class="map-container">
|
<div class="map-container">
|
||||||
<map-locator id="map"></map-locator>
|
<MapLocator
|
||||||
|
v-model="currentCamera"
|
||||||
|
:markers="markers"
|
||||||
|
@click="doMapClick"
|
||||||
|
@marker-drag-end="doMapMarkerDragEnd"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="map-zoom" name="map-zoom" />
|
<input type="hidden" id="map-zoom" name="map-zoom" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="toggle-additional"
|
|
||||||
class="btn btn-warning"
|
class="btn btn-warning"
|
||||||
|
@click="showMore = true"
|
||||||
|
id="toggle-additional"
|
||||||
type="button"
|
type="button"
|
||||||
@click="toggleCollapse()"
|
v-if="!showMore"
|
||||||
>
|
>
|
||||||
Click here to answer a few more questions to better help us solve your
|
Click here to answer a few more questions to better help us solve your
|
||||||
mosquito problem
|
mosquito problem
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse" id="collapse-additional-fields">
|
<div :class="{ collapse: !showMore }" id="collapse-additional-fields">
|
||||||
<!-- Source Details Section -->
|
<!-- Source Details Section -->
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
|
|
@ -395,12 +511,12 @@
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="reporter-confidential">
|
<label class="form-check-label" for="reporter-confidential">
|
||||||
<i
|
<Tooltip
|
||||||
class="bi bi-info-circle-fill text-primary ms-1"
|
placement="top"
|
||||||
data-bs-toggle="tooltip"
|
|
||||||
data-bs-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."
|
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
|
I would like my personal information kept
|
||||||
confidential.</label
|
confidential.</label
|
||||||
>
|
>
|
||||||
|
|
@ -519,10 +635,154 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 isCollapsed = ref<boolean>(true);
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
isCollapsed.value = !isCollapsed.value;
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<div class="container-fluid px-3 py-3">
|
<div class="container-fluid px-3 py-3">
|
||||||
<HeaderCompliance :district="district" />
|
<HeaderCompliance :district="district" />
|
||||||
|
|
@ -28,53 +10,37 @@
|
||||||
Please enter the address so we can match your response with our records.
|
Please enter the address so we can match your response with our records.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="address-form" method="POST" action="/compliance/address">
|
<AddressAndMapLocator v-model="locator" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Map Placeholder -->
|
<div class="d-flex gap-2 mt-4">
|
||||||
<div class="mb-4">
|
<RouterLink class="btn btn-outline-secondary" to="../compliance">
|
||||||
<label class="form-label fw-semibold">Location Preview</label>
|
Back
|
||||||
<div class="map-placeholder" id="map-container">
|
</RouterLink>
|
||||||
<span>Map will appear here after address is selected</span>
|
<button class="btn btn-primary flex-grow-1" @click="doContinue">
|
||||||
</div>
|
Continue
|
||||||
</div>
|
</button>
|
||||||
|
</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>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
import type { District } from "@/type/api";
|
import type { District } from "@/type/api";
|
||||||
import HeaderCompliance from "@/rmo/components/HeaderCompliance.vue";
|
import HeaderCompliance from "@/rmo/components/HeaderCompliance.vue";
|
||||||
import ProgressBarCompliance from "@/rmo/components/ProgressBarCompliance.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 {
|
interface Props {
|
||||||
district: District;
|
district: District;
|
||||||
}
|
}
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
const error = ref<string>("");
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
const locator = ref<Locator | null>(null);
|
||||||
|
function doContinue() {
|
||||||
|
emit("doLocator", locator.value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ body > .container-fluid {
|
||||||
<template>
|
<template>
|
||||||
<template v-if="district">
|
<template v-if="district">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<component :is="Component" :district="district" />
|
<component :is="Component" :district="district" @doLocator="doLocator" />
|
||||||
</router-view>
|
</router-view>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
@ -33,6 +33,8 @@ import { computedAsync } from "@vueuse/core";
|
||||||
import { useStoreDistrict } from "@/rmo/store/district";
|
import { useStoreDistrict } from "@/rmo/store/district";
|
||||||
import Intro from "@/rmo/content/compliance/Intro.vue";
|
import Intro from "@/rmo/content/compliance/Intro.vue";
|
||||||
import type { District } from "@/type/api";
|
import type { District } from "@/type/api";
|
||||||
|
import { Locator } from "@/type/map";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
@ -44,4 +46,7 @@ const district = computedAsync(async (): Promise<District | undefined> => {
|
||||||
const districts = await districtStore.list();
|
const districts = await districtStore.list();
|
||||||
return districts.find((district: District) => district.slug == props.slug);
|
return districts.find((district: District) => district.slug == props.slug);
|
||||||
});
|
});
|
||||||
|
function doLocator(locator: Locator | null) {
|
||||||
|
console.log("locator", locator);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -204,9 +204,9 @@
|
||||||
You can check the status of your report at any time using your
|
You can check the status of your report at any time using your
|
||||||
Report ID.
|
Report ID.
|
||||||
</p>
|
</p>
|
||||||
<a :href="`/status/${id}`" class="btn btn-outline-primary">
|
<RouterLink :to="`/status/${id}`" class="btn btn-outline-primary">
|
||||||
Check Status
|
Check Status
|
||||||
</a>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface District {
|
||||||
url_website: string;
|
url_website: string;
|
||||||
}
|
}
|
||||||
export interface Location {
|
export interface Location {
|
||||||
|
accuracy?: number;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import maplibregl from "maplibre-gl";
|
import maplibregl from "maplibre-gl";
|
||||||
import type { Location } from "@/type/api";
|
import type { Address, Location } from "@/type/api";
|
||||||
|
|
||||||
export interface Camera {
|
export interface Camera {
|
||||||
location: Location;
|
location: Location;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
}
|
}
|
||||||
|
export interface Locator {
|
||||||
|
address: Address;
|
||||||
|
location: Location;
|
||||||
|
}
|
||||||
export type MoveEndEventInternal = maplibregl.MapLibreEvent<
|
export type MoveEndEventInternal = maplibregl.MapLibreEvent<
|
||||||
| maplibregl.MapMouseEvent
|
| maplibregl.MapMouseEvent
|
||||||
| maplibregl.MapTouchEvent
|
| maplibregl.MapTouchEvent
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue