Add schema and tooling for storing administrative boundaries in PostGIS: - 002_postgis_boundaries.sql: Complete PostGIS schema with: - boundary_countries (ISO 3166-1) - boundary_admin1 (states/provinces/regions) - boundary_admin2 (municipalities/districts) - boundary_historical (HALC pre-modern territories) - custodian_service_areas (computed werkgebied geometries) - geonames_settlements (reverse geocoding) - Spatial functions: find_admin_for_point, find_nearest_settlement - Views for API access - load_boundaries_postgis.py: Python loader supporting: - GADM (Global Administrative Areas) - primary global source - CBS (Dutch municipality boundaries) - GeoNames settlements for reverse geocoding - Cached downloads and upsert logic - POSTGIS_BOUNDARY_ARCHITECTURE.md: Design documentation This replaces the static GeoJSON approach for international coverage.
622 lines
24 KiB
PL/PgSQL
622 lines
24 KiB
PL/PgSQL
-- PostGIS International Boundary Schema for Heritage Custodian Service Areas
|
|
-- Migration: 002_postgis_boundaries.sql
|
|
-- Created: 2025-12-07
|
|
--
|
|
-- Stores administrative boundaries for computing heritage custodian service areas
|
|
-- Supports global coverage with multiple data sources (GADM, Natural Earth, OSM, CBS)
|
|
-- Enables spatial queries: point-in-polygon, intersection, containment
|
|
|
|
-- ============================================================================
|
|
-- Enable PostGIS Extension
|
|
-- ============================================================================
|
|
|
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
|
CREATE EXTENSION IF NOT EXISTS postgis_topology;
|
|
|
|
-- ============================================================================
|
|
-- Data Source Registry
|
|
-- ============================================================================
|
|
-- Track data sources for provenance and updates
|
|
|
|
CREATE TABLE IF NOT EXISTS boundary_data_sources (
|
|
id SERIAL PRIMARY KEY,
|
|
source_code VARCHAR(50) NOT NULL UNIQUE, -- e.g., "GADM", "CBS", "OSM", "HALC"
|
|
source_name VARCHAR(255) NOT NULL, -- Full name
|
|
source_url TEXT, -- Official website
|
|
license_type VARCHAR(100), -- e.g., "CC-BY-4.0", "ODbL"
|
|
license_url TEXT,
|
|
description TEXT,
|
|
coverage_scope VARCHAR(50), -- "GLOBAL", "EUROPE", "NETHERLANDS", etc.
|
|
last_updated DATE,
|
|
attribution_required TEXT, -- Required attribution text
|
|
metadata JSONB DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
-- Insert known data sources
|
|
INSERT INTO boundary_data_sources (source_code, source_name, source_url, license_type, coverage_scope, description) VALUES
|
|
('GADM', 'Global Administrative Areas Database', 'https://gadm.org', 'CC-BY-NC-4.0', 'GLOBAL', 'Global administrative boundaries at multiple levels (country, region, district)'),
|
|
('NATURAL_EARTH', 'Natural Earth', 'https://www.naturalearthdata.com', 'Public Domain', 'GLOBAL', 'Free vector and raster map data at 1:10m, 1:50m, and 1:110m scales'),
|
|
('OSM', 'OpenStreetMap', 'https://www.openstreetmap.org', 'ODbL', 'GLOBAL', 'Crowdsourced global geographic data'),
|
|
('CBS', 'CBS Wijken en Buurten', 'https://www.cbs.nl/nl-nl/dossier/nederland-regionaal/geografische-data', 'CC-BY-4.0', 'NETHERLANDS', 'Official Dutch municipality boundaries from CBS'),
|
|
('HALC', 'Historical Atlas of the Low Countries', NULL, 'Academic', 'LOW_COUNTRIES', 'Historical administrative boundaries for Netherlands, Belgium, Luxembourg'),
|
|
('EUROSTAT', 'Eurostat NUTS/LAU', 'https://ec.europa.eu/eurostat/web/gisco', 'Eurostat', 'EUROPE', 'European statistical regions (NUTS) and local administrative units (LAU)')
|
|
ON CONFLICT (source_code) DO NOTHING;
|
|
|
|
-- ============================================================================
|
|
-- Countries Table
|
|
-- ============================================================================
|
|
-- ISO 3166-1 countries with boundaries
|
|
|
|
CREATE TABLE IF NOT EXISTS boundary_countries (
|
|
id SERIAL PRIMARY KEY,
|
|
iso_a2 CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2 (e.g., "NL", "DE")
|
|
iso_a3 CHAR(3) NOT NULL, -- ISO 3166-1 alpha-3 (e.g., "NLD", "DEU")
|
|
iso_n3 CHAR(3), -- ISO 3166-1 numeric (e.g., "528")
|
|
country_name VARCHAR(255) NOT NULL, -- English name
|
|
country_name_local VARCHAR(255), -- Native language name
|
|
|
|
-- Geometry
|
|
geom GEOMETRY(MULTIPOLYGON, 4326), -- WGS84 boundary polygon
|
|
centroid GEOMETRY(POINT, 4326), -- Computed centroid
|
|
bbox BOX2D, -- Bounding box for quick filtering
|
|
|
|
-- Metadata
|
|
source_id INTEGER REFERENCES boundary_data_sources(id),
|
|
source_feature_id VARCHAR(100), -- ID in source dataset
|
|
area_km2 NUMERIC, -- Computed area
|
|
|
|
-- Versioning
|
|
valid_from DATE,
|
|
valid_to DATE, -- NULL = current
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
UNIQUE(iso_a2, valid_to) -- Allow multiple versions, only one current
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_countries_iso_a2 ON boundary_countries(iso_a2);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_countries_geom ON boundary_countries USING GIST(geom);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_countries_centroid ON boundary_countries USING GIST(centroid);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_countries_valid ON boundary_countries(valid_from, valid_to);
|
|
|
|
-- ============================================================================
|
|
-- Administrative Level 1 (States/Provinces/Regions)
|
|
-- ============================================================================
|
|
-- ISO 3166-2 regions
|
|
|
|
CREATE TABLE IF NOT EXISTS boundary_admin1 (
|
|
id SERIAL PRIMARY KEY,
|
|
country_id INTEGER REFERENCES boundary_countries(id),
|
|
|
|
-- Identifiers
|
|
iso_3166_2 VARCHAR(10), -- e.g., "NL-NH", "DE-BY", "JP-13"
|
|
admin1_code VARCHAR(20) NOT NULL, -- Internal code (varies by source)
|
|
geonames_id INTEGER, -- GeoNames ID for linking
|
|
wikidata_id VARCHAR(20), -- Wikidata Q-number
|
|
|
|
-- Names
|
|
admin1_name VARCHAR(255) NOT NULL, -- English/standard name
|
|
admin1_name_local VARCHAR(255), -- Native language name
|
|
admin1_type VARCHAR(100), -- e.g., "Province", "State", "Prefecture", "Land"
|
|
|
|
-- Geometry
|
|
geom GEOMETRY(MULTIPOLYGON, 4326),
|
|
centroid GEOMETRY(POINT, 4326),
|
|
bbox BOX2D,
|
|
|
|
-- Metadata
|
|
source_id INTEGER REFERENCES boundary_data_sources(id),
|
|
source_feature_id VARCHAR(100),
|
|
area_km2 NUMERIC,
|
|
|
|
-- Versioning
|
|
valid_from DATE,
|
|
valid_to DATE,
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
UNIQUE(country_id, admin1_code, valid_to)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_country ON boundary_admin1(country_id);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_iso ON boundary_admin1(iso_3166_2);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_geonames ON boundary_admin1(geonames_id);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_geom ON boundary_admin1 USING GIST(geom);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin1_centroid ON boundary_admin1 USING GIST(centroid);
|
|
|
|
-- ============================================================================
|
|
-- Administrative Level 2 (Districts/Counties/Municipalities)
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS boundary_admin2 (
|
|
id SERIAL PRIMARY KEY,
|
|
admin1_id INTEGER REFERENCES boundary_admin1(id),
|
|
country_id INTEGER REFERENCES boundary_countries(id),
|
|
|
|
-- Identifiers
|
|
admin2_code VARCHAR(50) NOT NULL, -- Internal code
|
|
nuts_code VARCHAR(10), -- NUTS code (EU)
|
|
lau_code VARCHAR(20), -- LAU code (EU)
|
|
geonames_id INTEGER,
|
|
wikidata_id VARCHAR(20),
|
|
|
|
-- CBS-specific (Netherlands)
|
|
cbs_gemeente_code VARCHAR(10), -- Dutch municipality code (GM0363)
|
|
|
|
-- Names
|
|
admin2_name VARCHAR(255) NOT NULL,
|
|
admin2_name_local VARCHAR(255),
|
|
admin2_type VARCHAR(100), -- e.g., "Municipality", "District", "County"
|
|
|
|
-- Geometry
|
|
geom GEOMETRY(MULTIPOLYGON, 4326),
|
|
centroid GEOMETRY(POINT, 4326),
|
|
bbox BOX2D,
|
|
|
|
-- Metadata
|
|
source_id INTEGER REFERENCES boundary_data_sources(id),
|
|
source_feature_id VARCHAR(100),
|
|
area_km2 NUMERIC,
|
|
population INTEGER, -- If available from source
|
|
|
|
-- Versioning
|
|
valid_from DATE,
|
|
valid_to DATE,
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
UNIQUE(admin1_id, admin2_code, valid_to)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_admin1 ON boundary_admin2(admin1_id);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_country ON boundary_admin2(country_id);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_geonames ON boundary_admin2(geonames_id);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_cbs ON boundary_admin2(cbs_gemeente_code);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_geom ON boundary_admin2 USING GIST(geom);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_admin2_centroid ON boundary_admin2 USING GIST(centroid);
|
|
|
|
-- ============================================================================
|
|
-- Historical Boundaries
|
|
-- ============================================================================
|
|
-- Pre-modern administrative boundaries (e.g., HALC 1500 data)
|
|
|
|
CREATE TABLE IF NOT EXISTS boundary_historical (
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
-- Identifiers
|
|
historical_id VARCHAR(100) NOT NULL, -- Source-specific ID
|
|
halc_adm1_code VARCHAR(20), -- HALC ADM1 territory code (e.g., "VI")
|
|
halc_adm2_name VARCHAR(255), -- HALC ADM2 district name
|
|
wikidata_id VARCHAR(20),
|
|
|
|
-- Location context
|
|
modern_country_iso_a2 CHAR(2), -- Modern country this is within
|
|
|
|
-- Names
|
|
territory_name VARCHAR(255) NOT NULL,
|
|
territory_name_historical VARCHAR(255), -- Name in historical period
|
|
territory_type VARCHAR(100), -- e.g., "County", "Duchy", "Lordship"
|
|
|
|
-- Temporal extent
|
|
period_start SMALLINT, -- Year (e.g., 1500)
|
|
period_end SMALLINT, -- Year (e.g., 1795)
|
|
period_description VARCHAR(255), -- e.g., "Ancien Regime", "Medieval"
|
|
|
|
-- Geometry
|
|
geom GEOMETRY(MULTIPOLYGON, 4326),
|
|
centroid GEOMETRY(POINT, 4326),
|
|
bbox BOX2D,
|
|
|
|
-- Metadata
|
|
source_id INTEGER REFERENCES boundary_data_sources(id),
|
|
source_feature_id VARCHAR(100),
|
|
area_km2 NUMERIC,
|
|
certainty VARCHAR(50), -- "HIGH", "MEDIUM", "LOW", "APPROXIMATE"
|
|
notes TEXT,
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
UNIQUE(historical_id, source_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_historical_halc ON boundary_historical(halc_adm1_code);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_historical_country ON boundary_historical(modern_country_iso_a2);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_historical_period ON boundary_historical(period_start, period_end);
|
|
CREATE INDEX IF NOT EXISTS idx_boundary_historical_geom ON boundary_historical USING GIST(geom);
|
|
|
|
-- ============================================================================
|
|
-- Custodian Service Areas (Computed/Custom)
|
|
-- ============================================================================
|
|
-- Stores pre-computed or custom service area geometries for heritage custodians
|
|
|
|
CREATE TABLE IF NOT EXISTS custodian_service_areas (
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
-- Custodian link (GHCID)
|
|
ghcid VARCHAR(100) NOT NULL, -- e.g., "NL-NH-HAA-A-NHA"
|
|
ghcid_uuid UUID, -- UUID v5 of GHCID
|
|
|
|
-- Service area identity
|
|
service_area_name VARCHAR(255) NOT NULL,
|
|
service_area_type VARCHAR(50) NOT NULL, -- From ServiceAreaTypeEnum
|
|
|
|
-- Geometry (can be computed union or custom-drawn)
|
|
geom GEOMETRY(MULTIPOLYGON, 4326),
|
|
centroid GEOMETRY(POINT, 4326),
|
|
bbox BOX2D,
|
|
|
|
-- Composition (which admin units make up this service area)
|
|
admin2_ids INTEGER[], -- Array of boundary_admin2.id
|
|
admin1_ids INTEGER[], -- Array of boundary_admin1.id
|
|
historical_ids INTEGER[], -- Array of boundary_historical.id
|
|
|
|
-- Properties
|
|
is_historical BOOLEAN DEFAULT FALSE,
|
|
is_custom BOOLEAN DEFAULT FALSE, -- True if hand-drawn, not computed from admin units
|
|
|
|
-- Temporal extent
|
|
valid_from DATE,
|
|
valid_to DATE, -- NULL = current
|
|
|
|
-- Display properties
|
|
display_fill_color VARCHAR(20) DEFAULT '#3498db',
|
|
display_fill_opacity NUMERIC(3,2) DEFAULT 0.20,
|
|
display_line_color VARCHAR(20) DEFAULT '#2980b9',
|
|
display_line_width NUMERIC(3,1) DEFAULT 2.0,
|
|
|
|
-- Metadata
|
|
source_dataset VARCHAR(100), -- Data provenance
|
|
notes TEXT,
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
|
|
UNIQUE(ghcid, valid_to)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_custodian_sa_ghcid ON custodian_service_areas(ghcid);
|
|
CREATE INDEX IF NOT EXISTS idx_custodian_sa_uuid ON custodian_service_areas(ghcid_uuid);
|
|
CREATE INDEX IF NOT EXISTS idx_custodian_sa_geom ON custodian_service_areas USING GIST(geom);
|
|
CREATE INDEX IF NOT EXISTS idx_custodian_sa_type ON custodian_service_areas(service_area_type);
|
|
CREATE INDEX IF NOT EXISTS idx_custodian_sa_historical ON custodian_service_areas(is_historical);
|
|
|
|
-- ============================================================================
|
|
-- GeoNames Settlements (for reverse geocoding and linking)
|
|
-- ============================================================================
|
|
-- Subset of GeoNames populated places for settlement lookups
|
|
|
|
CREATE TABLE IF NOT EXISTS geonames_settlements (
|
|
geonames_id INTEGER PRIMARY KEY,
|
|
|
|
-- Names
|
|
name VARCHAR(200) NOT NULL,
|
|
ascii_name VARCHAR(200),
|
|
alternate_names TEXT, -- Comma-separated
|
|
|
|
-- Location
|
|
latitude NUMERIC(10, 6) NOT NULL,
|
|
longitude NUMERIC(10, 6) NOT NULL,
|
|
geom GEOMETRY(POINT, 4326), -- Computed from lat/lon
|
|
|
|
-- Classification
|
|
feature_class CHAR(1), -- 'P' = populated place
|
|
feature_code VARCHAR(10), -- 'PPL', 'PPLA', 'PPLC', etc.
|
|
|
|
-- Administrative hierarchy
|
|
country_code CHAR(2),
|
|
admin1_code VARCHAR(20), -- GeoNames admin1
|
|
admin2_code VARCHAR(80),
|
|
admin3_code VARCHAR(20),
|
|
admin4_code VARCHAR(20),
|
|
|
|
-- Metadata
|
|
population INTEGER,
|
|
elevation INTEGER,
|
|
timezone VARCHAR(40),
|
|
modification_date DATE,
|
|
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_country ON geonames_settlements(country_code);
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_admin1 ON geonames_settlements(country_code, admin1_code);
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_feature ON geonames_settlements(feature_class, feature_code);
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_geom ON geonames_settlements USING GIST(geom);
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_name ON geonames_settlements(name);
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_ascii ON geonames_settlements(ascii_name);
|
|
CREATE INDEX IF NOT EXISTS idx_geonames_population ON geonames_settlements(population DESC);
|
|
|
|
-- ============================================================================
|
|
-- Spatial Functions
|
|
-- ============================================================================
|
|
|
|
-- Function: Find admin units containing a point
|
|
CREATE OR REPLACE FUNCTION find_admin_for_point(
|
|
p_lon NUMERIC,
|
|
p_lat NUMERIC,
|
|
p_country_code CHAR(2) DEFAULT NULL
|
|
)
|
|
RETURNS TABLE (
|
|
admin_level INTEGER,
|
|
admin_code VARCHAR,
|
|
admin_name VARCHAR,
|
|
iso_code VARCHAR,
|
|
geonames_id INTEGER
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
|
|
-- Admin Level 1
|
|
SELECT
|
|
1::INTEGER AS admin_level,
|
|
a.admin1_code,
|
|
a.admin1_name,
|
|
a.iso_3166_2,
|
|
a.geonames_id
|
|
FROM boundary_admin1 a
|
|
JOIN boundary_countries c ON a.country_id = c.id
|
|
WHERE ST_Contains(a.geom, ST_SetSRID(ST_Point(p_lon, p_lat), 4326))
|
|
AND a.valid_to IS NULL
|
|
AND (p_country_code IS NULL OR c.iso_a2 = p_country_code)
|
|
|
|
UNION ALL
|
|
|
|
-- Admin Level 2
|
|
SELECT
|
|
2::INTEGER,
|
|
a.admin2_code,
|
|
a.admin2_name,
|
|
a.nuts_code,
|
|
a.geonames_id
|
|
FROM boundary_admin2 a
|
|
JOIN boundary_countries c ON a.country_id = c.id
|
|
WHERE ST_Contains(a.geom, ST_SetSRID(ST_Point(p_lon, p_lat), 4326))
|
|
AND a.valid_to IS NULL
|
|
AND (p_country_code IS NULL OR c.iso_a2 = p_country_code)
|
|
|
|
ORDER BY admin_level;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Function: Find nearest settlement
|
|
CREATE OR REPLACE FUNCTION find_nearest_settlement(
|
|
p_lon NUMERIC,
|
|
p_lat NUMERIC,
|
|
p_country_code CHAR(2) DEFAULT NULL,
|
|
p_max_distance_km NUMERIC DEFAULT 50
|
|
)
|
|
RETURNS TABLE (
|
|
geonames_id INTEGER,
|
|
name VARCHAR,
|
|
feature_code VARCHAR,
|
|
population INTEGER,
|
|
distance_km NUMERIC
|
|
) AS $$
|
|
DECLARE
|
|
v_point GEOMETRY;
|
|
BEGIN
|
|
v_point := ST_SetSRID(ST_Point(p_lon, p_lat), 4326);
|
|
|
|
RETURN QUERY
|
|
SELECT
|
|
g.geonames_id,
|
|
g.name,
|
|
g.feature_code,
|
|
g.population,
|
|
ROUND((ST_Distance(g.geom::geography, v_point::geography) / 1000)::NUMERIC, 2) AS distance_km
|
|
FROM geonames_settlements g
|
|
WHERE g.feature_class = 'P'
|
|
AND g.feature_code IN ('PPL', 'PPLA', 'PPLA2', 'PPLA3', 'PPLA4', 'PPLC', 'PPLS', 'PPLG')
|
|
AND (p_country_code IS NULL OR g.country_code = p_country_code)
|
|
AND ST_DWithin(g.geom::geography, v_point::geography, p_max_distance_km * 1000)
|
|
ORDER BY g.geom <-> v_point
|
|
LIMIT 5;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Function: Compute service area from admin units
|
|
CREATE OR REPLACE FUNCTION compute_service_area_geometry(
|
|
p_admin2_ids INTEGER[],
|
|
p_admin1_ids INTEGER[] DEFAULT NULL
|
|
)
|
|
RETURNS GEOMETRY AS $$
|
|
DECLARE
|
|
v_geom GEOMETRY;
|
|
BEGIN
|
|
-- Union all admin2 geometries
|
|
IF p_admin2_ids IS NOT NULL AND array_length(p_admin2_ids, 1) > 0 THEN
|
|
SELECT ST_Union(geom) INTO v_geom
|
|
FROM boundary_admin2
|
|
WHERE id = ANY(p_admin2_ids)
|
|
AND valid_to IS NULL;
|
|
END IF;
|
|
|
|
-- Add admin1 geometries if specified
|
|
IF p_admin1_ids IS NOT NULL AND array_length(p_admin1_ids, 1) > 0 THEN
|
|
SELECT ST_Union(COALESCE(v_geom, 'GEOMETRYCOLLECTION EMPTY'::GEOMETRY), ST_Union(geom))
|
|
INTO v_geom
|
|
FROM boundary_admin1
|
|
WHERE id = ANY(p_admin1_ids)
|
|
AND valid_to IS NULL;
|
|
END IF;
|
|
|
|
RETURN v_geom;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Function: Get service area GeoJSON for a custodian
|
|
CREATE OR REPLACE FUNCTION get_custodian_service_area_geojson(
|
|
p_ghcid VARCHAR,
|
|
p_include_historical BOOLEAN DEFAULT FALSE
|
|
)
|
|
RETURNS JSONB AS $$
|
|
DECLARE
|
|
v_result JSONB;
|
|
BEGIN
|
|
SELECT jsonb_build_object(
|
|
'type', 'FeatureCollection',
|
|
'features', COALESCE(jsonb_agg(
|
|
jsonb_build_object(
|
|
'type', 'Feature',
|
|
'geometry', ST_AsGeoJSON(sa.geom)::jsonb,
|
|
'properties', jsonb_build_object(
|
|
'ghcid', sa.ghcid,
|
|
'name', sa.service_area_name,
|
|
'type', sa.service_area_type,
|
|
'is_historical', sa.is_historical,
|
|
'valid_from', sa.valid_from,
|
|
'valid_to', sa.valid_to,
|
|
'fill_color', sa.display_fill_color,
|
|
'fill_opacity', sa.display_fill_opacity,
|
|
'line_color', sa.display_line_color,
|
|
'line_width', sa.display_line_width
|
|
)
|
|
)
|
|
), '[]'::jsonb)
|
|
)
|
|
INTO v_result
|
|
FROM custodian_service_areas sa
|
|
WHERE sa.ghcid = p_ghcid
|
|
AND (p_include_historical OR sa.is_historical = FALSE);
|
|
|
|
RETURN v_result;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- ============================================================================
|
|
-- Views for API Access
|
|
-- ============================================================================
|
|
|
|
-- Current admin1 boundaries
|
|
CREATE OR REPLACE VIEW v_current_admin1 AS
|
|
SELECT
|
|
a.id,
|
|
c.iso_a2 AS country_code,
|
|
a.iso_3166_2,
|
|
a.admin1_code,
|
|
a.admin1_name,
|
|
a.admin1_type,
|
|
a.geonames_id,
|
|
a.wikidata_id,
|
|
ST_AsGeoJSON(a.geom)::jsonb AS geometry,
|
|
ST_AsGeoJSON(a.centroid)::jsonb AS centroid,
|
|
a.area_km2,
|
|
s.source_code AS data_source
|
|
FROM boundary_admin1 a
|
|
JOIN boundary_countries c ON a.country_id = c.id
|
|
LEFT JOIN boundary_data_sources s ON a.source_id = s.id
|
|
WHERE a.valid_to IS NULL AND c.valid_to IS NULL;
|
|
|
|
-- Current admin2 boundaries
|
|
CREATE OR REPLACE VIEW v_current_admin2 AS
|
|
SELECT
|
|
a2.id,
|
|
c.iso_a2 AS country_code,
|
|
a1.iso_3166_2 AS admin1_iso,
|
|
a2.admin2_code,
|
|
a2.admin2_name,
|
|
a2.admin2_type,
|
|
a2.cbs_gemeente_code,
|
|
a2.nuts_code,
|
|
a2.lau_code,
|
|
a2.geonames_id,
|
|
a2.wikidata_id,
|
|
ST_AsGeoJSON(a2.geom)::jsonb AS geometry,
|
|
ST_AsGeoJSON(a2.centroid)::jsonb AS centroid,
|
|
a2.area_km2,
|
|
a2.population,
|
|
s.source_code AS data_source
|
|
FROM boundary_admin2 a2
|
|
JOIN boundary_admin1 a1 ON a2.admin1_id = a1.id
|
|
JOIN boundary_countries c ON a2.country_id = c.id
|
|
LEFT JOIN boundary_data_sources s ON a2.source_id = s.id
|
|
WHERE a2.valid_to IS NULL AND a1.valid_to IS NULL AND c.valid_to IS NULL;
|
|
|
|
-- Custodian service areas
|
|
CREATE OR REPLACE VIEW v_custodian_service_areas AS
|
|
SELECT
|
|
sa.ghcid,
|
|
sa.ghcid_uuid,
|
|
sa.service_area_name,
|
|
sa.service_area_type,
|
|
sa.is_historical,
|
|
sa.is_custom,
|
|
sa.valid_from,
|
|
sa.valid_to,
|
|
ST_AsGeoJSON(sa.geom)::jsonb AS geometry,
|
|
ST_AsGeoJSON(sa.centroid)::jsonb AS centroid,
|
|
sa.display_fill_color,
|
|
sa.display_fill_opacity,
|
|
sa.display_line_color,
|
|
sa.display_line_width,
|
|
sa.source_dataset,
|
|
sa.notes
|
|
FROM custodian_service_areas sa
|
|
WHERE sa.valid_to IS NULL;
|
|
|
|
-- ============================================================================
|
|
-- Statistics
|
|
-- ============================================================================
|
|
|
|
CREATE OR REPLACE VIEW v_boundary_stats AS
|
|
SELECT
|
|
'countries' AS table_name,
|
|
COUNT(*) AS total_rows,
|
|
COUNT(*) FILTER (WHERE valid_to IS NULL) AS current_rows
|
|
FROM boundary_countries
|
|
UNION ALL
|
|
SELECT 'admin1', COUNT(*), COUNT(*) FILTER (WHERE valid_to IS NULL) FROM boundary_admin1
|
|
UNION ALL
|
|
SELECT 'admin2', COUNT(*), COUNT(*) FILTER (WHERE valid_to IS NULL) FROM boundary_admin2
|
|
UNION ALL
|
|
SELECT 'historical', COUNT(*), COUNT(*) FROM boundary_historical
|
|
UNION ALL
|
|
SELECT 'service_areas', COUNT(*), COUNT(*) FILTER (WHERE valid_to IS NULL) FROM custodian_service_areas
|
|
UNION ALL
|
|
SELECT 'geonames_settlements', COUNT(*), COUNT(*) FROM geonames_settlements;
|
|
|
|
-- Country coverage summary
|
|
CREATE OR REPLACE VIEW v_boundary_coverage AS
|
|
SELECT
|
|
c.iso_a2,
|
|
c.country_name,
|
|
COUNT(DISTINCT a1.id) AS admin1_count,
|
|
COUNT(DISTINCT a2.id) AS admin2_count,
|
|
(SELECT COUNT(*) FROM geonames_settlements g WHERE g.country_code = c.iso_a2) AS settlement_count,
|
|
(SELECT COUNT(*) FROM custodian_service_areas sa WHERE sa.ghcid LIKE c.iso_a2 || '-%') AS service_area_count
|
|
FROM boundary_countries c
|
|
LEFT JOIN boundary_admin1 a1 ON a1.country_id = c.id AND a1.valid_to IS NULL
|
|
LEFT JOIN boundary_admin2 a2 ON a2.country_id = c.id AND a2.valid_to IS NULL
|
|
WHERE c.valid_to IS NULL
|
|
GROUP BY c.id, c.iso_a2, c.country_name
|
|
ORDER BY c.country_name;
|
|
|
|
-- ============================================================================
|
|
-- Permissions
|
|
-- ============================================================================
|
|
|
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO glam_api;
|
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO glam_api;
|
|
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO glam_api;
|
|
|
|
-- ============================================================================
|
|
-- Table Comments
|
|
-- ============================================================================
|
|
|
|
COMMENT ON TABLE boundary_data_sources IS 'Registry of data sources for boundary data (GADM, CBS, OSM, etc.)';
|
|
COMMENT ON TABLE boundary_countries IS 'Country boundaries with ISO 3166-1 codes';
|
|
COMMENT ON TABLE boundary_admin1 IS 'First-level administrative divisions (states, provinces, regions)';
|
|
COMMENT ON TABLE boundary_admin2 IS 'Second-level administrative divisions (municipalities, districts, counties)';
|
|
COMMENT ON TABLE boundary_historical IS 'Historical administrative boundaries (e.g., HALC 1500 data)';
|
|
COMMENT ON TABLE custodian_service_areas IS 'Pre-computed or custom service area geometries for heritage custodians';
|
|
COMMENT ON TABLE geonames_settlements IS 'GeoNames populated places for settlement lookups';
|
|
|
|
COMMENT ON FUNCTION find_admin_for_point IS 'Find administrative units containing a geographic point';
|
|
COMMENT ON FUNCTION find_nearest_settlement IS 'Find nearest GeoNames settlement to a point';
|
|
COMMENT ON FUNCTION compute_service_area_geometry IS 'Compute union geometry from admin unit IDs';
|
|
COMMENT ON FUNCTION get_custodian_service_area_geojson IS 'Get GeoJSON for a custodian service area';
|