feat: Districts table and geojson stub

This commit is contained in:
2026-07-01 00:23:22 +05:00
parent 0b45636e5b
commit 27a8d8b4f5
7 changed files with 731 additions and 0 deletions

View File

@@ -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)