Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ Path: `/collections/{cid}/items`
Multiple property filters are ANDed together.
* `filter=cql-expr` - filters features via a CQL expression
* `filter-crs=SRID` - specifies the CRS for geometry values in the CQL filter
* `datetime=INSTANT | INTERVAL` - specify a time range to filter the data by (must have a datetime column configured. see [Database](config/pg_featureserv.toml.example) section of config)
* exact match: `datetime=2025-01-02T00:00:00Z`
* between: `datetime=2025-01-02T00:00:00Z/2025-02-02T00:00:00Z`
* before: `datetime=../2025-01-02T00:00:00Z`
* after: `datetime=2025-01-02T00:00:00Z/..`
* `transform=fun1[,args][|fun2,args...]` - transform the feature geometry by a geometry function pipeline.
* `groupby=PROP-NAME` - group results on a property.
Usually used with an aggregate `transform` function.
Expand Down
2 changes: 1 addition & 1 deletion FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ It includes [*OGC API - Features*](http://docs.opengeospatial.org/is/17-069r3/17
- [x] `bbox=x1,y1,x2,y2`
- [ ] `bbox` (6 numbers)
- [x] `bbox-crs=srid`
- [ ] `datetime`
- [x] `datetime`
- [x] `properties` list
- restricts properties included in response
- [x] `sortby` to sort output by a property
Expand Down
121 changes: 118 additions & 3 deletions assets/items.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@
<input type='checkbox' id='chk-bbox'>
</td>
</tr>
{{if .context.TimeAware}}
<tr>
<td class='param-title' title='Filter results to a single timestamp'>Datetime</td>
<td>
<input type='datetime-local' id='datetime-instant' placeholder='Instant (UTC)'>
</td>
</tr>
<tr>
<td class='param-title' title='Filter results to a start/end interval'>Datetime range</td>
<td>
<input type='datetime-local' id='datetime-start' placeholder='Start (UTC)'>
<span style='margin: 0 4px;'>to</span>
<input type='datetime-local' id='datetime-end' placeholder='End (UTC)'>
<div style='font-size: 10px; font-style: italic;'>Leave a field blank for an open interval.</div>
</td>
</tr>
{{end}}
{{template "funArgs" .}}
</table>
</div>
Expand Down Expand Up @@ -78,20 +95,24 @@ function onMapLoad() {
document.getElementById('feature-count').innerHTML = numFeat;

}
populateDatetimeControls();
function doQuery() {
var url = window.location.pathname;
var newUrl = addFunctionArgs(url);

var select = document.getElementById('item-limit');
var lim = select.options[select.selectedIndex].value;
newurl = addQueryParam(newUrl, 'limit', lim);
newUrl = addQueryParam(newUrl, 'limit', lim);

var datetimeVal = buildDatetimeParam();
newUrl = addQueryParam(newUrl, 'datetime', datetimeVal);

var useBbox = document.getElementById('chk-bbox').checked;
if (useBbox) {
var bbox = bboxStr(5);
newurl = addQueryParam(newurl, 'bbox', bbox);
newUrl = addQueryParam(newUrl, 'bbox', bbox);
}
window.location.assign(newurl);
window.location.assign(newUrl);
}
function addQueryParam(url, name, value) {
if (! value || value.length <= 0) return url;
Expand All @@ -100,6 +121,100 @@ function addQueryParam(url, name, value) {
let newUrl = `${url}${delim}${name}=${value}`;
return newUrl;
}

function buildDatetimeParam() {
var instantRaw = document.getElementById('datetime-instant').value;
var startRaw = document.getElementById('datetime-start').value;
var endRaw = document.getElementById('datetime-end').value;

var instant = toIsoString(instantRaw);
var start = toIsoString(startRaw);
var end = toIsoString(endRaw);

if (instant) {
return instant;
}
if (!start && !end) {
return '';
}
var startPart = start ? start : '..';
var endPart = end ? end : '..';
if (startPart === '..' && endPart === '..') {
return '';
}
return `${startPart}/${endPart}`;
}

function toIsoString(value) {
if (!value) {
return '';
}
var dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return '';
}
return dt.toISOString();
}

function populateDatetimeControls() {
var params = new URL(window.location.href).searchParams;
var value = params.get('datetime');
if (!value) {
return;
}
if (value.indexOf('/') >= 0) {
var parts = value.split('/');
if (parts.length === 2) {
if (parts[0] && parts[0] !== '..') {
setDatetimeInput('datetime-start', parts[0]);
}
if (parts[1] && parts[1] !== '..') {
setDatetimeInput('datetime-end', parts[1]);
}
}
} else {
setDatetimeInput('datetime-instant', value);
}
}

function setDatetimeInput(id, value) {
var input = document.getElementById(id);
if (!input) {
return;
}
var dt = parseDateTime(value);
if (!dt) {
return;
}
input.value = formatForInput(dt);
}

function parseDateTime(value) {
if (!value) {
return null;
}
var dt = new Date(value);
if (Number.isNaN(dt.getTime())) {
return null;
}
return dt;
}

function formatForInput(date) {
var pad = function(num) {
return String(num).padStart(2, '0');
};
var yyyy = date.getFullYear();
var mm = pad(date.getMonth() + 1);
var dd = pad(date.getDate());
var hh = pad(date.getHours());
var min = pad(date.getMinutes());
var sec = pad(date.getSeconds());
if (sec === '00') {
return `${yyyy}-${mm}-${dd}T${hh}:${min}`;
}
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}`;
}
</script>
{{ end }}
{{define "funArgs"}}
Expand Down
8 changes: 8 additions & 0 deletions config/pg_featureserv.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ WriteTimeoutSec = 30
# Publish functions from these schemas (default is publish postgisftw)
# FunctionIncludes = [ "postgisftw", "schema2" ]

# Assign time columns for tables with temporal data
# These should be timestamp or timestamptz columns in the table
# Columns to be used for feature start and end of time intervals
StartTimeColumns = [ "start_time" ]
EndTimeColumns = [ "end_time" ]
# Columns to be used for (instantaneous) feature timestamps
TimeColumns = [ "time" ]

[Paging]
# The default number of features in a response
LimitDefault = 20
Expand Down
47 changes: 44 additions & 3 deletions demo/initdb/04-views.sql
Original file line number Diff line number Diff line change
@@ -1,14 +1,55 @@
CREATE TABLE cities (
id serial PRIMARY KEY,
name text,
name text PRIMARY KEY,
geom geometry(Point, 4326)
);

CREATE TABLE trips (
id serial PRIMARY KEY,
city text REFERENCES cities(name),
time timestamptz,
start_time timestamptz,
end_time timestamptz
);

CREATE TABLE receipts (
id serial PRIMARY KEY,
trip_id int REFERENCES trips(id),
time timestamptz,
amount numeric
);


INSERT INTO cities (name, geom) VALUES
('Paris', ST_SetSRID(ST_MakePoint(2.3522, 48.8566), 4326)),
('London', ST_SetSRID(ST_MakePoint(-0.1276, 51.5074), 4326)),
('Tokyo', ST_SetSRID(ST_MakePoint(139.6917, 35.6895), 4326)),
('Sydney', ST_SetSRID(ST_MakePoint(151.2093, -33.8688), 4326)),
('NYC', ST_SetSRID(ST_MakePoint(-74.0060, 40.7128), 4326));



INSERT INTO trips (city, time, start_time, end_time) VALUES
('Paris', '2025-01-01 12:00:00', '2024-01-01 12:00:00', '2025-01-01 12:00:00'),
('London', '2025-02-01 12:00:00', '2024-02-01 12:00:00', '2025-02-01 12:00:00'),
('Tokyo', '2025-03-01 12:00:00', '2024-03-01 12:00:00', '2025-03-01 12:00:00'),
('Sydney', '2025-04-01 12:00:00', '2024-04-01 12:00:00', '2025-04-01 12:00:00'),
('NYC', '2025-05-01 12:00:00', '2024-05-01 12:00:00', '2025-05-01 12:00:00');


INSERT INTO receipts (trip_id, time, amount) VALUES
(1, '2024-06-01 12:00:00', 100.00),
(1, '2024-07-01 12:00:00', 150.00),
(2, '2024-06-15 12:00:00', 200.00),
(3, '2024-08-01 12:00:00', 250.00),
(4, '2024-09-01 12:00:00', 300.00),
(5, '2024-10-01 12:00:00', 350.00);

-- View with geometry and featureID column (no PK)
CREATE VIEW cities_view AS
SELECT id AS id, name, geom FROM cities;
SELECT * FROM cities;

CREATE VIEW trips_view AS
SELECT trips.*, cities.geom FROM trips LEFT JOIN cities ON trips.city = cities.name;

CREATE VIEW receipts_view AS
SELECT receipts.*, cities.geom FROM receipts LEFT JOIN trips ON receipts.trip_id = trips.id LEFT JOIN cities ON trips.city = cities.name;
27 changes: 27 additions & 0 deletions hugo/content/installation/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ WriteTimeoutSec = 30
# Publish functions from these schemas (default is publish postgisftw)
# FunctionIncludes = [ "postgisftw", "schema2" ]

# Assign time columns for tables with temporal data
# These should be timestamp or timestamptz columns in the table
# Columns to be used for feature start and end of time intervals
# StartTimeColumns = [ "start_time" ]
# EndTimeColumns = [ "end_time" ]
# Columns to be used for (instantaneous) feature timestamps
# TimeColumns = [ "time" ]


[Paging]
# The default number of features in a response
LimitDefault = 20
Expand Down Expand Up @@ -243,6 +252,24 @@ Overrides items specified in `TableIncludes`.
A list of the schemas to publish functions from.
The default is to publish functions in the `postgisftw` schema.

#### StartTimeColumns

Specifies the column(s) that represent the start time for temporal features.
Use this to identify when a feature becomes active or relevant.
The first found column is used.

#### EndTimeColumns

Specifies the column(s) that represent the end time for temporal features.
Use this to indicate when a feature is no longer active or relevant.
The first found column is used.

#### TimeColumns

Specifies the column(s) that contain time or timestamp information for features.
Useful for filtering or querying features based on specific time values.
The first found column is used.

#### LimitDefault

The default number of features in a response,
Expand Down
38 changes: 35 additions & 3 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const (
ParamBboxCrs = "bbox-crs"
ParamFilter = "filter"
ParamFilterCrs = "filter-crs"
ParamDateTime = "datetime"
ParamGroupBy = "groupby"
ParamOrderBy = "orderby"
ParamPrecision = "precision"
Expand Down Expand Up @@ -98,6 +99,7 @@ var ParamReservedNames = []string{
ParamBbox,
ParamBboxCrs,
ParamFilter,
ParamDateTime,
ParamGroupBy,
ParamOrderBy,
ParamPrecision,
Expand Down Expand Up @@ -194,13 +196,19 @@ var ParameterSchema openapi3.Schema = openapi3.Schema{

// Bbox for extent
type Bbox struct {
Crs string `json:"crs"`
Crs string `json:"crs"`
Extent [][]float64 `json:"bbox"`
}

type TemporalExtent struct {
Trs string `json:"trs"`
Interval []*string `json:"interval"`
}

// Extent OAPIF Extent structure (partial)
type Extent struct {
Spatial *Bbox `json:"spatial"`
Spatial *Bbox `json:"spatial"`
Temporal *TemporalExtent `json:"temporal,omitempty"`
}

// --- @See https://raw.githubusercontent.com/opengeospatial/WFS_FES/master/core/openapi/schemas/bbox.yaml
Expand Down Expand Up @@ -245,6 +253,7 @@ type RequestParam struct {
Properties []string
Filter string
FilterCrs int
DateTime string
GroupBy []string
SortBy []data.Sorting
Precision int
Expand Down Expand Up @@ -494,6 +503,28 @@ func toBbox(cc *data.Table) *Bbox {
}
}

func toTemporalExtent(cc *data.Table) *TemporalExtent {
if cc.TemporalExtent.Start.IsZero() && cc.TemporalExtent.End.IsZero() {
return nil
}
var startStr, endStr *string
if !cc.TemporalExtent.Start.IsZero() {
s := cc.TemporalExtent.Start.Format(time.RFC3339)
startStr = &s
}
if !cc.TemporalExtent.End.IsZero() {
e := cc.TemporalExtent.End.Format(time.RFC3339)
endStr = &e
}
interval := make([]*string, 2)
interval[0] = startStr
interval[1] = endStr
return &TemporalExtent{
Trs: "http://www.opengis.net/def/uom/ISO-8601/0/Gregorian",
Interval: interval,
}
}

func NewLink(href string, rel string, conType string, title string) *Link {
return &Link{
Href: href,
Expand Down Expand Up @@ -525,7 +556,8 @@ func NewCollectionInfo(tbl *data.Table) *CollectionInfo {
Title: tbl.Title,
Description: tbl.Description,
Extent: &Extent{
Spatial: toBbox(tbl),
Spatial: toBbox(tbl),
Temporal: toTemporalExtent(tbl),
},
}
return &doc
Expand Down
Loading
Loading