package spc import ( "bytes" "encoding/json" "fmt" "strconv" "strings" ) // GeoJSONFeatureCollection is the minimal SPC outlook FeatureCollection shape // needed by weatherfeeder. type GeoJSONFeatureCollection struct { Type string `json:"type"` Features []GeoJSONFeature `json:"features"` } // GeoJSONFeature preserves typed SPC properties and compact raw geometry. type GeoJSONFeature struct { Type string `json:"type"` Properties GeoJSONProperties `json:"properties"` Geometry json.RawMessage `json:"geometry"` } // GeoJSONProperties contains the SPC fields used by canonical mapping. type GeoJSONProperties struct { ValidISO string `json:"VALID_ISO"` ExpireISO string `json:"EXPIRE_ISO"` IssueISO string `json:"ISSUE_ISO"` Forecaster string `json:"FORECASTER"` Label string `json:"LABEL"` Label2 string `json:"LABEL2"` DN *int `json:"DN"` } type geometryMetadata struct { Type string `json:"type"` Geometries []json.RawMessage `json:"geometries"` } // DecodeGeoJSON decodes an SPC GeoJSON outlook product and compacts feature // geometry JSON for stable downstream storage. func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) { var collection GeoJSONFeatureCollection if err := json.Unmarshal(raw, &collection); err != nil { return GeoJSONFeatureCollection{}, fmt.Errorf("decode geojson: %w", err) } for i := range collection.Features { geom, err := compactJSON(collection.Features[i].Geometry) if err != nil { return GeoJSONFeatureCollection{}, fmt.Errorf("features[%d].geometry: %w", i, err) } collection.Features[i].Geometry = geom } return collection, nil } // IsEmptyGeometryCollection reports whether raw is SPC's no-polygon placeholder // geometry shape: a GeometryCollection with no child geometries. func IsEmptyGeometryCollection(raw json.RawMessage) bool { var meta geometryMetadata if err := json.Unmarshal(raw, &meta); err != nil { return false } return meta.Type == "GeometryCollection" && len(meta.Geometries) == 0 } func (p *GeoJSONProperties) UnmarshalJSON(raw []byte) error { type alias GeoJSONProperties var aux struct { alias DN any `json:"DN"` } if err := json.Unmarshal(raw, &aux); err != nil { return err } *p = GeoJSONProperties(aux.alias) dn, err := parseSeverityRank(aux.DN) if err != nil { return err } p.DN = dn return nil } func parseSeverityRank(value any) (*int, error) { switch v := value.(type) { case nil: return nil, nil case float64: rank := int(v) if float64(rank) != v { return nil, fmt.Errorf("DN must be an integer, got %v", v) } return &rank, nil case string: v = strings.TrimSpace(v) if v == "" { return nil, nil } rank, err := strconv.Atoi(v) if err != nil { return nil, fmt.Errorf("DN must be an integer, got %q", v) } return &rank, nil default: return nil, fmt.Errorf("DN must be an integer or string, got %T", value) } } func compactJSON(raw json.RawMessage) (json.RawMessage, error) { if len(raw) == 0 { return nil, fmt.Errorf("missing") } var buf bytes.Buffer if err := json.Compact(&buf, raw); err != nil { return nil, err } return json.RawMessage(buf.Bytes()), nil }