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"
|
||||
|
||||
"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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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>
|
||||
<section class="py-2 bg-primary text-white">
|
||||
<div class="banner-container d-flex justify-content-center">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface District {
|
|||
url_website: string;
|
||||
}
|
||||
export interface Location {
|
||||
accuracy?: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue