feat: Districts table and geojson stub
This commit is contained in:
@@ -38,6 +38,8 @@ type DatasetRepository interface {
|
||||
ReplaceObservations(ctx context.Context, datasetID uuid.UUID, obs []domain.Observation) error
|
||||
ListObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string, limit, offset int) ([]domain.Observation, error)
|
||||
CountObservations(ctx context.Context, datasetID uuid.UUID, katoCode *string) (int, error)
|
||||
ListAllObservations(ctx context.Context, datasetID uuid.UUID) ([]domain.Observation, error)
|
||||
DistrictGeometriesByKato(ctx context.Context, katos []string) (map[string]domain.District, error)
|
||||
}
|
||||
|
||||
// Pagination defaults for dataset listings.
|
||||
@@ -616,6 +618,221 @@ func (s *DatasetService) ListObservations(ctx context.Context, id uuid.UUID, kat
|
||||
return ObservationPage{Items: items, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// GeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a vector or
|
||||
// vector_with_kato dataset.
|
||||
//
|
||||
// A plain vector dataset has no KATO mapping or observations, so the result is a
|
||||
// single geometry-only Feature wrapping the dataset's own (dissolved) geometry,
|
||||
// with empty properties (or an empty collection when the dataset has no
|
||||
// geometry).
|
||||
//
|
||||
// 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
|
||||
// geometry, yielding a single Feature whose properties hold only the
|
||||
// observations keyed by KATO code (each KATO mapping to its date->value pairs);
|
||||
// otherwise one Feature is emitted per KATO, its boundary taken from the
|
||||
// districts table and its observation values placed into the Feature's
|
||||
// properties keyed by date. KATO codes with no matching district are skipped.
|
||||
//
|
||||
// Only ready datasets are served; a dataset still being processed yields a
|
||||
// conflict.
|
||||
func (s *DatasetService) GeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
|
||||
dataset, err := s.loadGeoJSONDataset(ctx, id, true)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
|
||||
// Plain vector: no KATO mapping or observations. Return the dataset's own
|
||||
// geometry as a single Feature, exposing the extracted attribute table (e.g.
|
||||
// a GeoPackage's table data) as the Feature's top-level properties.
|
||||
if dataset.FileType == domain.FileTypeVector {
|
||||
fc := domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: []domain.Feature{}}
|
||||
if hasGeometry(dataset.Geometry) {
|
||||
fc.Features = append(fc.Features, domain.Feature{
|
||||
Type: domain.GeoJSONFeature,
|
||||
Geometry: dataset.Geometry,
|
||||
Properties: vectorFeatureProperties(dataset.Properties),
|
||||
})
|
||||
}
|
||||
return fc, nil
|
||||
}
|
||||
|
||||
obs, err := s.repo.ListAllObservations(ctx, id)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
grouped, order := groupObservationsByKato(obs)
|
||||
|
||||
// The dataset has its own geometry (the dissolved union of all features): the
|
||||
// observations describe that whole geometry, so emit a single Feature wrapping
|
||||
// it whose properties hold only the observations, keyed by KATO code.
|
||||
if hasGeometry(dataset.Geometry) {
|
||||
props := make(map[string]any, len(order))
|
||||
for _, kato := range order {
|
||||
props[kato] = grouped[kato]
|
||||
}
|
||||
return domain.FeatureCollection{
|
||||
Type: domain.GeoJSONFeatureCollection,
|
||||
Features: []domain.Feature{{
|
||||
Type: domain.GeoJSONFeature,
|
||||
Geometry: dataset.Geometry,
|
||||
Properties: props,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No geometry: build one Feature per KATO from the districts table.
|
||||
features, err := s.districtFeatures(ctx, grouped, order)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
return domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: features}, nil
|
||||
}
|
||||
|
||||
// KatoGeoJSON assembles a GeoJSON FeatureCollection (RFC 7946) for a
|
||||
// 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,
|
||||
// its boundary taken from the districts table and its observation values placed
|
||||
// into the Feature's properties keyed by date (alongside `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
|
||||
// served; a dataset still being processed yields a conflict.
|
||||
func (s *DatasetService) KatoGeoJSON(ctx context.Context, id uuid.UUID) (domain.FeatureCollection, error) {
|
||||
if _, err := s.loadGeoJSONDataset(ctx, id, false); err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
obs, err := s.repo.ListAllObservations(ctx, id)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
grouped, order := groupObservationsByKato(obs)
|
||||
features, err := s.districtFeatures(ctx, grouped, order)
|
||||
if err != nil {
|
||||
return domain.FeatureCollection{}, err
|
||||
}
|
||||
return domain.FeatureCollection{Type: domain.GeoJSONFeatureCollection, Features: features}, nil
|
||||
}
|
||||
|
||||
// loadGeoJSONDataset fetches a dataset for a GeoJSON endpoint and validates that
|
||||
// it is ready and of a supported file type. vector_with_kato is always
|
||||
// accepted; plain vector is accepted only when allowVector is true (the
|
||||
// .kato.geojson endpoint requires KATO observations, which plain vector lacks).
|
||||
func (s *DatasetService) loadGeoJSONDataset(ctx context.Context, id uuid.UUID, allowVector bool) (domain.Dataset, error) {
|
||||
dataset, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
|
||||
supported := dataset.FileType == domain.FileTypeVectorWithKato ||
|
||||
(allowVector && dataset.FileType == domain.FileTypeVector)
|
||||
if !supported {
|
||||
allowed := "vector_with_kato"
|
||||
if allowVector {
|
||||
allowed = "vector and vector_with_kato"
|
||||
}
|
||||
return domain.Dataset{}, fmt.Errorf("%w: geojson is only available for %s datasets", domain.ErrValidation, allowed)
|
||||
}
|
||||
if dataset.Status != domain.DatasetStatusReady {
|
||||
return domain.Dataset{}, fmt.Errorf("%w: dataset is not ready (status %q)", domain.ErrConflict, dataset.Status)
|
||||
}
|
||||
return dataset, nil
|
||||
}
|
||||
|
||||
// districtFeatures builds one Feature per KATO from the districts table, placing
|
||||
// the grouped observation values into each Feature's properties keyed by date
|
||||
// (alongside `kato` and `name`). KATO 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) ([]domain.Feature, error) {
|
||||
districts, err := s.repo.DistrictGeometriesByKato(ctx, order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
features := make([]domain.Feature, 0, len(order))
|
||||
for _, kato := range order {
|
||||
dist, ok := districts[kato]
|
||||
if !ok {
|
||||
continue // skip KATO codes with no district boundary
|
||||
}
|
||||
props := map[string]any{"kato": kato, "name": dist.Name}
|
||||
for date, value := range grouped[kato] {
|
||||
props[date] = value
|
||||
}
|
||||
features = append(features, domain.Feature{
|
||||
Type: domain.GeoJSONFeature,
|
||||
Geometry: dist.Geometry,
|
||||
Properties: props,
|
||||
})
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
// hasGeometry reports whether a dataset's GeoJSON geometry (as produced by
|
||||
// ST_AsGeoJSON) is a real geometry rather than absent or JSON null.
|
||||
func hasGeometry(geom json.RawMessage) bool {
|
||||
t := bytes.TrimSpace(geom)
|
||||
return len(t) > 0 && !bytes.Equal(t, []byte("null"))
|
||||
}
|
||||
|
||||
// vectorFeatureProperties turns a plain vector dataset's stored attribute table
|
||||
// into a Feature's properties object. The table is persisted as a JSON array of
|
||||
// row objects: a single row becomes the top-level properties directly, multiple
|
||||
// rows are kept under a "rows" key (so no data is lost while the value stays a
|
||||
// valid GeoJSON properties object), and an empty/absent table yields {}.
|
||||
func vectorFeatureProperties(raw json.RawMessage) map[string]any {
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
var rows []map[string]any
|
||||
if err := json.Unmarshal(raw, &rows); err == nil {
|
||||
switch len(rows) {
|
||||
case 0:
|
||||
return map[string]any{}
|
||||
case 1:
|
||||
return rows[0]
|
||||
default:
|
||||
return map[string]any{"rows": rows}
|
||||
}
|
||||
}
|
||||
// Fallback: the column already holds a plain object.
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err == nil {
|
||||
return obj
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// groupObservationsByKato groups observations by KATO code into date->value
|
||||
// maps. The value is the numeric Value when present, otherwise ValueText,
|
||||
// otherwise nil (an empty cell). order lists KATO codes in first-seen order so
|
||||
// the resulting feature order is deterministic.
|
||||
func groupObservationsByKato(obs []domain.Observation) (map[string]map[string]any, []string) {
|
||||
grouped := make(map[string]map[string]any)
|
||||
order := make([]string, 0)
|
||||
for _, o := range obs {
|
||||
values, ok := grouped[o.KatoCode]
|
||||
if !ok {
|
||||
values = make(map[string]any)
|
||||
grouped[o.KatoCode] = values
|
||||
order = append(order, o.KatoCode)
|
||||
}
|
||||
values[o.Date] = observationValue(o)
|
||||
}
|
||||
return grouped, order
|
||||
}
|
||||
|
||||
// observationValue returns the typed cell value: numeric Value, else ValueText,
|
||||
// else nil.
|
||||
func observationValue(o domain.Observation) any {
|
||||
switch {
|
||||
case o.Value != nil:
|
||||
return *o.Value
|
||||
case o.ValueText != nil:
|
||||
return *o.ValueText
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a dataset by id.
|
||||
func (s *DatasetService) Get(ctx context.Context, id uuid.UUID) (domain.Dataset, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
|
||||
Reference in New Issue
Block a user