fix: Change geojson properties structure
This commit is contained in:
parent
8d9e11db0c
commit
f74253140f
@ -81,7 +81,7 @@ the KATO column and the year columns:
|
|||||||
`?kato_code=`).
|
`?kato_code=`).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -X POST localhost:8080/datasets/<id>/mapping -H 'Content-Type: application/json' -d '{
|
curl -X POST https://dssgis.dwh.kz/datasets/<id>/mapping -H 'Content-Type: application/json' -d '{
|
||||||
"kato_column": "като",
|
"kato_column": "като",
|
||||||
"year_columns": [
|
"year_columns": [
|
||||||
{"column": "F_2023", "date": "2023-01-01"},
|
{"column": "F_2023", "date": "2023-01-01"},
|
||||||
@ -131,7 +131,7 @@ server runs it is served at `/openapi.yaml`, with an interactive **Redoc** UI at
|
|||||||
Example upload:
|
Example upload:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -X POST localhost:8080/datasets \
|
curl -X POST https://dssgis.dwh.kz/datasets \
|
||||||
-F file=@sample.geojson -F file_type=vector -F category_id=<uuid> \
|
-F file=@sample.geojson -F file_type=vector -F category_id=<uuid> \
|
||||||
-F code=POP_2026 -F name=Population -F description="Resident population" -F unit=people
|
-F code=POP_2026 -F name=Population -F description="Resident population" -F unit=people
|
||||||
```
|
```
|
||||||
|
|||||||
@ -22,8 +22,8 @@ info:
|
|||||||
name: Proprietary
|
name: Proprietary
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: http://localhost:8080
|
- url: https://dssgis.dwh.kz/
|
||||||
description: Local development
|
description: Production
|
||||||
|
|
||||||
tags:
|
tags:
|
||||||
- name: Health
|
- name: Health
|
||||||
@ -291,12 +291,13 @@ paths:
|
|||||||
|
|
||||||
A `vector_with_kato` dataset is built from its observations. When it has
|
A `vector_with_kato` dataset is built from its observations. When it has
|
||||||
its own (dissolved) geometry, the observations are taken to describe that
|
its own (dissolved) geometry, the observations are taken to describe that
|
||||||
whole geometry: a single Feature wraps it, and its properties hold only
|
whole geometry: a single Feature wraps it, and its properties nest the
|
||||||
the observations, keyed by KATO code at the top level (each KATO mapping
|
observations under a `data` object, keyed by KATO code (each KATO mapping
|
||||||
to its date->value pairs). Otherwise one Feature is emitted per KATO, its
|
to its district `name` and its own `data` map of date->value pairs).
|
||||||
boundary taken from the `districts` table and the per-year values
|
Otherwise one Feature is emitted per KATO, its
|
||||||
flattened into the Feature's properties keyed by date; KATO codes with
|
boundary taken from the `districts` table and the per-year values nested
|
||||||
no matching district are skipped.
|
under a `data` object (keyed by date) alongside `kato` and `name`; KATO
|
||||||
|
codes with no matching district are skipped.
|
||||||
|
|
||||||
Only `ready` datasets are served: a dataset still being processed
|
Only `ready` datasets are served: a dataset still being processed
|
||||||
returns 409. Other file types (e.g. `raster`) return 422.
|
returns 409. Other file types (e.g. `raster`) return 422.
|
||||||
@ -321,9 +322,9 @@ paths:
|
|||||||
Like `/datasets/{id}.geojson`, but ignores any geometry the dataset
|
Like `/datasets/{id}.geojson`, but ignores any geometry the dataset
|
||||||
carries and always joins the `districts` table on KATO code: one Feature
|
carries and always joins the `districts` table on KATO code: one Feature
|
||||||
is emitted per KATO, its boundary taken from the matching district and
|
is emitted per KATO, its boundary taken from the matching district and
|
||||||
the per-year observation values mapped into the Feature's properties
|
the per-year observation values nested under a `data` object (keyed by
|
||||||
keyed by date (alongside `kato` and `name`). KATO codes with no matching
|
date) in the Feature's properties, alongside `kato` and `name`. KATO
|
||||||
district are skipped.
|
codes with no matching district are skipped.
|
||||||
|
|
||||||
Only `ready` datasets are served: a dataset still being processed
|
Only `ready` datasets are served: a dataset still being processed
|
||||||
returns 409. Other file types return 422.
|
returns 409. Other file types return 422.
|
||||||
|
|||||||
@ -628,11 +628,13 @@ func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, kat
|
|||||||
//
|
//
|
||||||
// A vector_with_kato dataset is built from its observations: when it carries its
|
// A vector_with_kato dataset is built from its observations: when it carries its
|
||||||
// own (dissolved) geometry the observations are taken to describe that whole
|
// own (dissolved) geometry the observations are taken to describe that whole
|
||||||
// geometry, yielding a single Feature whose properties hold only the
|
// geometry, yielding a single Feature whose properties nest the observations
|
||||||
// observations keyed by KATO code (each KATO mapping to its date->value pairs);
|
// under `data`, keyed by KATO code (each KATO mapping to its district `name` and
|
||||||
// otherwise one Feature is emitted per KATO, its boundary taken from the
|
// its own `data` map of date->value pairs); otherwise one Feature is emitted per
|
||||||
// districts table and its observation values placed into the Feature's
|
// KATO, its boundary taken from the
|
||||||
// properties keyed by date. KATO codes with no matching district are skipped.
|
// districts table and its observation values nested under a `data` object (keyed
|
||||||
|
// by date) alongside `kato` and `name`. KATO codes with no matching district are
|
||||||
|
// skipped.
|
||||||
//
|
//
|
||||||
// Only ready datasets are served; a dataset still being processed yields a
|
// Only ready datasets are served; a dataset still being processed yields a
|
||||||
// conflict.
|
// conflict.
|
||||||
@ -665,24 +667,33 @@ func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.Feat
|
|||||||
|
|
||||||
// The dataset has its own geometry (the dissolved union of all features): the
|
// The dataset has its own geometry (the dissolved union of all features): the
|
||||||
// observations describe that whole geometry, so emit a single Feature wrapping
|
// observations describe that whole geometry, so emit a single Feature wrapping
|
||||||
// it whose properties hold only the observations, keyed by KATO code.
|
// it whose properties nest the observations under `data`, keyed by KATO code.
|
||||||
|
// Each KATO entry carries the district `name` alongside its own `data` map of
|
||||||
|
// date->value pairs.
|
||||||
if hasGeometry(dataset.Geometry) {
|
if hasGeometry(dataset.Geometry) {
|
||||||
props := make(map[string]any, len(order))
|
districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
|
||||||
|
if err != nil {
|
||||||
|
return domain.FeatureCollection{}, err
|
||||||
|
}
|
||||||
|
data := make(map[string]any, len(order))
|
||||||
for _, kato := range order {
|
for _, kato := range order {
|
||||||
props[kato] = grouped[kato]
|
data[kato] = map[string]any{
|
||||||
|
"name": districts[kato].Name,
|
||||||
|
"data": grouped[kato],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return domain.FeatureCollection{
|
return domain.FeatureCollection{
|
||||||
Type: domain.GeoJSONFeatureCollection,
|
Type: domain.GeoJSONFeatureCollection,
|
||||||
Features: []domain.Feature{{
|
Features: []domain.Feature{{
|
||||||
Type: domain.GeoJSONFeature,
|
Type: domain.GeoJSONFeature,
|
||||||
Geometry: dataset.Geometry,
|
Geometry: dataset.Geometry,
|
||||||
Properties: props,
|
Properties: map[string]any{"data": data},
|
||||||
}},
|
}},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No geometry: build one Feature per KATO from the districts table.
|
// No geometry: build one Feature per KATO from the districts table.
|
||||||
features, err := s.districtFeatures(ctx, grouped, order)
|
features, err := s.districtFeatures(ctx, grouped, order, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.FeatureCollection{}, err
|
return domain.FeatureCollection{}, err
|
||||||
}
|
}
|
||||||
@ -692,9 +703,9 @@ func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.Feat
|
|||||||
// KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a
|
// KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a
|
||||||
// vector_with_kato dataset by always joining the districts table on KATO code,
|
// vector_with_kato dataset by always joining the districts table on KATO code,
|
||||||
// ignoring any geometry the dataset carries. One Feature is emitted per KATO,
|
// ignoring any geometry the dataset carries. One Feature is emitted per KATO,
|
||||||
// its boundary taken from the districts table and its observation values placed
|
// its boundary taken from the districts table and its observation values nested
|
||||||
// into the Feature's properties keyed by date (alongside `kato` and `name`).
|
// under a `data` object (keyed by date) in the Feature's properties, alongside
|
||||||
// KATO codes with no matching district are skipped. Plain vector datasets are
|
// `kato` and `name`. KATO codes with no matching district are skipped. Plain vector datasets are
|
||||||
// not supported (they have no KATO observations). Only ready datasets are
|
// not supported (they have no KATO observations). Only ready datasets are
|
||||||
// served; a dataset still being processed yields a conflict.
|
// served; a dataset still being processed yields a conflict.
|
||||||
func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
|
func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
|
||||||
@ -706,7 +717,7 @@ func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.
|
|||||||
return domain.FeatureCollection{}, err
|
return domain.FeatureCollection{}, err
|
||||||
}
|
}
|
||||||
grouped, order := groupObservationsByKato(obs)
|
grouped, order := groupObservationsByKato(obs)
|
||||||
features, err := s.districtFeatures(ctx, grouped, order)
|
features, err := s.districtFeatures(ctx, grouped, order, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.FeatureCollection{}, err
|
return domain.FeatureCollection{}, err
|
||||||
}
|
}
|
||||||
@ -738,11 +749,13 @@ func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID, a
|
|||||||
return dataset, nil
|
return dataset, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// districtFeatures builds one Feature per KATO from the districts table, placing
|
// districtFeatures builds one Feature per KATO from the districts table,
|
||||||
// the grouped observation values into each Feature's properties keyed by date
|
// alongside `kato` and `name` in each Feature's properties. When nestData is
|
||||||
// (alongside `kato` and `name`). KATO codes with no matching district are
|
// true the grouped observation values (keyed by date) are placed under a nested
|
||||||
// skipped. order drives the deterministic feature order.
|
// `data` object; otherwise they are spread as flat date-keyed properties. KATO
|
||||||
func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[string]map[string]any, order []string) ([]domain.Feature, error) {
|
// codes with no matching district are skipped. order drives the deterministic
|
||||||
|
// feature order.
|
||||||
|
func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[string]map[string]any, order []string, nestData bool) ([]domain.Feature, error) {
|
||||||
districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
|
districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -754,8 +767,12 @@ func (s *DatasetService) districtFeatures(ctx context.Context, grouped map[strin
|
|||||||
continue // skip KATO codes with no district boundary
|
continue // skip KATO codes with no district boundary
|
||||||
}
|
}
|
||||||
props := map[string]any{"kato": kato, "name": dist.Name}
|
props := map[string]any{"kato": kato, "name": dist.Name}
|
||||||
for date, value := range grouped[kato] {
|
if nestData {
|
||||||
props[date] = value
|
props["data"] = grouped[kato]
|
||||||
|
} else {
|
||||||
|
for date, value := range grouped[kato] {
|
||||||
|
props[date] = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
features = append(features, domain.Feature{
|
features = append(features, domain.Feature{
|
||||||
Type: domain.GeoJSONFeature,
|
Type: domain.GeoJSONFeature,
|
||||||
|
|||||||
@ -843,8 +843,12 @@ func TestDatasetService_GeoJSON_DistrictJoin(t *testing.T) {
|
|||||||
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
|
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
|
||||||
t.Fatalf("unexpected properties: %+v", f.Properties)
|
t.Fatalf("unexpected properties: %+v", f.Properties)
|
||||||
}
|
}
|
||||||
if f.Properties["2020-01-01"] != 100.0 || f.Properties["2021-01-01"] != 150.0 {
|
data, ok := f.Properties["data"].(map[string]any)
|
||||||
t.Fatalf("year values not flattened into properties: %+v", f.Properties)
|
if !ok {
|
||||||
|
t.Fatalf("observations not nested under data: %+v", f.Properties)
|
||||||
|
}
|
||||||
|
if data["2020-01-01"] != 100.0 || data["2021-01-01"] != 150.0 {
|
||||||
|
t.Fatalf("year values not nested under data: %+v", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole thing must marshal to valid GeoJSON.
|
// The whole thing must marshal to valid GeoJSON.
|
||||||
@ -867,6 +871,7 @@ func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) {
|
|||||||
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
|
{KatoCode: "710000000", Date: "2020-01-01", Value: &v},
|
||||||
{KatoCode: "710000000", Date: "2021-01-01", Value: &v},
|
{KatoCode: "710000000", Date: "2021-01-01", Value: &v},
|
||||||
}
|
}
|
||||||
|
repo.districts["710000000"] = domain.District{Kato: "710000000", Name: "Astana"}
|
||||||
|
|
||||||
svc := newDatasetService(repo, &stubStore{}, true)
|
svc := newDatasetService(repo, &stubStore{}, true)
|
||||||
fc, err := svc.GeoJSON(ctx, id)
|
fc, err := svc.GeoJSON(ctx, id)
|
||||||
@ -880,16 +885,28 @@ func TestDatasetService_GeoJSON_UsesDatasetGeometry(t *testing.T) {
|
|||||||
if string(f.Geometry) != string(geom) {
|
if string(f.Geometry) != string(geom) {
|
||||||
t.Fatalf("feature should reuse dataset geometry, got %s", f.Geometry)
|
t.Fatalf("feature should reuse dataset geometry, got %s", f.Geometry)
|
||||||
}
|
}
|
||||||
// Properties hold only the observations, keyed by KATO at the top level.
|
// Properties nest the observations under `data`, keyed by KATO; each KATO
|
||||||
if _, ok := f.Properties["name"]; ok {
|
// entry carries the district name and its own nested `data` map.
|
||||||
t.Fatalf("properties should contain only observations, got metadata: %+v", f.Properties)
|
if _, flat := f.Properties["710000000"]; flat {
|
||||||
|
t.Fatalf("observations must be nested under data, not at top level: %+v", f.Properties)
|
||||||
}
|
}
|
||||||
kato, ok := f.Properties["710000000"].(map[string]any)
|
data, ok := f.Properties["data"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("observations not keyed by KATO at top level: %+v", f.Properties)
|
t.Fatalf("observations not nested under data: %+v", f.Properties)
|
||||||
}
|
}
|
||||||
if kato["2020-01-01"] != 42.0 || kato["2021-01-01"] != 42.0 {
|
kato, ok := data["710000000"].(map[string]any)
|
||||||
t.Fatalf("year values missing under KATO: %+v", kato)
|
if !ok {
|
||||||
|
t.Fatalf("observations not keyed by KATO under data: %+v", data)
|
||||||
|
}
|
||||||
|
if kato["name"] != "Astana" {
|
||||||
|
t.Fatalf("district name missing per KATO: %+v", kato)
|
||||||
|
}
|
||||||
|
values, ok := kato["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("per-KATO observations not nested under data: %+v", kato)
|
||||||
|
}
|
||||||
|
if values["2020-01-01"] != 42.0 || values["2021-01-01"] != 42.0 {
|
||||||
|
t.Fatalf("year values missing under KATO data: %+v", values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -925,8 +942,15 @@ func TestDatasetService_KatoGeoJSON_IgnoresDatasetGeometry(t *testing.T) {
|
|||||||
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
|
if f.Properties["kato"] != "710000000" || f.Properties["name"] != "Astana" {
|
||||||
t.Fatalf("unexpected properties: %+v", f.Properties)
|
t.Fatalf("unexpected properties: %+v", f.Properties)
|
||||||
}
|
}
|
||||||
if f.Properties["2020-01-01"] != 100.0 {
|
data, ok := f.Properties["data"].(map[string]any)
|
||||||
t.Fatalf("observation value not mapped onto polygon: %+v", f.Properties)
|
if !ok {
|
||||||
|
t.Fatalf("observations must be nested under data: %+v", f.Properties)
|
||||||
|
}
|
||||||
|
if data["2020-01-01"] != 100.0 {
|
||||||
|
t.Fatalf("observation value not mapped under data: %+v", data)
|
||||||
|
}
|
||||||
|
if _, flat := f.Properties["2020-01-01"]; flat {
|
||||||
|
t.Fatalf("observation dates must not be flat in properties: %+v", f.Properties)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user