planisphere 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +546 -0
- data/exe/planisphere +11 -0
- data/lib/planisphere/platforms.rb +11 -0
- data/lib/planisphere/version.rb +5 -0
- data/lib/planisphere.rb +26 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0f892c4538b11a9eaea839ce16b677e0034b04540040dd9cecd7f81465f5ed07
|
|
4
|
+
data.tar.gz: c792adef698e4a0ce0711a81612779fafa278f3a72c3ed70de697fd4b903eac1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 98b61037052e9e549f0d1ccd8fc1d94e8c63757907adab7bb3178df18f0a555b3313561667a734c48d82f522cc7c9fc4c964f8f9041c346eed324f6c9b727798
|
|
7
|
+
data.tar.gz: 71ed9d21706d7a5451d86f0ce5cca2528b9da5cc53f1ecaa79e1ef3b1d8ce4707734a57cc843ee59c5b6203167c372054a7abc132f5514c8fd972ae939f77a93
|
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Abdelkader Boudih
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
# planisphere
|
|
2
|
+
|
|
3
|
+
A headless map server for **PostGIS**. Your layers are SQL functions;
|
|
4
|
+
planisphere serves them as vector tiles, OGC API Tiles, and WMS images. It's a modern
|
|
5
|
+
replacement for `mapserv`-style CGI servers. No mapfiles, no GDAL, no GUI.
|
|
6
|
+
|
|
7
|
+
- **Vector tiles** (MVT) with TileJSON 3.0, ETags, gzip
|
|
8
|
+
- **OGC API - Tiles** with the `WebMercatorQuad` tile matrix set (passes the OGC conformance suite)
|
|
9
|
+
- **WMS 1.3.0 / 1.1.1** `GetMap` (PNG, PNG8, JPEG) with labels, and `GetFeatureInfo`,
|
|
10
|
+
rendered server-side and styled with a MapLibre style (passes the OGC WMS 1.3.0 suite)
|
|
11
|
+
- **Resilient**: adaptive concurrency with queue shedding, circuit breaker around PostGIS,
|
|
12
|
+
startup backoff, per-client rate limits
|
|
13
|
+
- **Observable**: live `/status` dashboard, readiness probe, and an admin MCP endpoint
|
|
14
|
+
- **PostgreSQL 18 + PostGIS 3.6 only**: no compatibility layers for older versions
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
docker compose up --build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Open <http://127.0.0.1:8080/viewer> for a map of the demo layers and
|
|
23
|
+
<http://127.0.0.1:8080/status> for live server state. The database starts with sample data
|
|
24
|
+
from [`dev/fixtures.sql`](dev/fixtures.sql) (loaded once, on a fresh volume).
|
|
25
|
+
|
|
26
|
+
To develop against the same database with cargo:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
docker compose up -d postgis
|
|
30
|
+
cargo run -- -c planisphere.example.toml serve
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Layers are SQL functions
|
|
34
|
+
|
|
35
|
+
Any function in a configured schema with this signature becomes a layer, named after the function:
|
|
36
|
+
|
|
37
|
+
```sql
|
|
38
|
+
fn(z integer, x integer, y integer [, query json | jsonb]) RETURNS bytea
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It's the same contract as Martin and pg_tileserv, so existing functions work unchanged:
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
CREATE FUNCTION tiles.roads(z integer, x integer, y integer)
|
|
45
|
+
RETURNS bytea LANGUAGE sql STABLE PARALLEL SAFE AS $$
|
|
46
|
+
SELECT ST_AsMVT(t, 'roads', 4096, 'geom') FROM (
|
|
47
|
+
SELECT name, ST_AsMVTGeom(geom, ST_TileEnvelope(z, x, y), 4096, 64) AS geom
|
|
48
|
+
FROM roads
|
|
49
|
+
WHERE geom && ST_TileEnvelope(z, x, y, margin => 64.0 / 4096)
|
|
50
|
+
) t WHERE geom IS NOT NULL
|
|
51
|
+
$$;
|
|
52
|
+
|
|
53
|
+
COMMENT ON FUNCTION tiles.roads(integer, integer, integer) IS
|
|
54
|
+
'{"description": "Road network", "minzoom": 4, "maxzoom": 16,
|
|
55
|
+
"vector_layers": [{"id": "roads", "fields": {"name": "String"}}]}';
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Metadata** is optional JSON in the function comment: `title`, `description`,
|
|
59
|
+
`attribution`, `minzoom`, `maxzoom` (≤ 24), `bounds` (WGS84 `[w, s, e, n]`; a
|
|
60
|
+
single-point extent is widened by 0.0001°), `vector_layers`. A plain-text comment
|
|
61
|
+
becomes the description.
|
|
62
|
+
- **Query parameters** reach the optional `query` argument as JSON, so
|
|
63
|
+
`/tiles/roads/10/512/340.mvt?kind=motorway` sees `{"kind": "motorway"}`.
|
|
64
|
+
- Mark functions `STABLE PARALLEL SAFE`, not `IMMUTABLE`, since they read tables.
|
|
65
|
+
- Use `&&` against an envelope **in the table's SRID**. Transforming the indexed column
|
|
66
|
+
(`ST_Transform(geom, 3857) && …`) disables the spatial index.
|
|
67
|
+
- Return an empty `bytea` for an empty tile; planisphere answers `204 No Content`.
|
|
68
|
+
|
|
69
|
+
`planisphere check` lists the layers it found.
|
|
70
|
+
|
|
71
|
+
The scripts in [`sql/`](sql) create their helpers in a `planisphere` schema. PostgreSQL's
|
|
72
|
+
default `search_path` is `"$user", public`, so a role named `planisphere` creates its
|
|
73
|
+
unqualified tables in that schema instead of `public`. Don't run application code as a
|
|
74
|
+
role called `planisphere`, or pin it:
|
|
75
|
+
`ALTER ROLE planisphere IN DATABASE <db> SET search_path = public;`.
|
|
76
|
+
|
|
77
|
+
### Live layers
|
|
78
|
+
|
|
79
|
+
Create, change or drop a tile function and planisphere picks it up without a restart. Each
|
|
80
|
+
reload rediscovers layers and extensions and reloads the WMS style file, then swaps the whole
|
|
81
|
+
catalog atomically. In-flight requests finish on the catalog they started with. A reload that
|
|
82
|
+
fails (for example, a broken style) is logged and the current catalog keeps serving.
|
|
83
|
+
|
|
84
|
+
There are three ways to reload:
|
|
85
|
+
|
|
86
|
+
- **Automatically:** install [`sql/planisphere-reload.sql`](sql/planisphere-reload.sql). An event
|
|
87
|
+
trigger sends `NOTIFY planisphere_reload` on function, comment, grant and extension DDL.
|
|
88
|
+
planisphere listens on a dedicated connection, coalesces bursts (such as a migration) into
|
|
89
|
+
one reload, and catches up after reconnecting. Event triggers need a superuser; on
|
|
90
|
+
Amazon RDS, install it as the master user. Drop the trigger before a major-version
|
|
91
|
+
upgrade, and run DDL against the primary, since read replicas don't fire event triggers.
|
|
92
|
+
- `kill -HUP <pid>`, or `NOTIFY planisphere_reload` from any session.
|
|
93
|
+
- The MCP `reload` tool, which also reports what changed.
|
|
94
|
+
|
|
95
|
+
`/status` shows the catalog generation and when it was loaded.
|
|
96
|
+
|
|
97
|
+
## Query functions
|
|
98
|
+
|
|
99
|
+
Request-shaped work that isn't a tile (routes, lookups, statistics) uses a second
|
|
100
|
+
contract, discovered the same way in `[functions] schemas` (default `["api"]`):
|
|
101
|
+
|
|
102
|
+
```sql
|
|
103
|
+
fn(params jsonb) RETURNS jsonb
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- `GET /functions/{name}?a=1&b=2` passes `{"a": "1", "b": "2"}`, and
|
|
107
|
+
`POST /functions/{name}` passes the JSON body. `GET /functions` lists them.
|
|
108
|
+
- They follow the same rules as tile functions: `"public": true` in the comment, verified
|
|
109
|
+
claims set in the transaction (so row-level security applies), `statement_timeout`,
|
|
110
|
+
the circuit breaker, and hot reload.
|
|
111
|
+
- A `RAISE EXCEPTION` (or a data error) returns `400` with the function's message, for
|
|
112
|
+
example "no route between those points". Other failures return `500`.
|
|
113
|
+
- A result whose `type` is `FeatureCollection` is served as `application/geo+json`.
|
|
114
|
+
|
|
115
|
+
The demo fixtures route over a street grid with pgRouting
|
|
116
|
+
(`/functions/route?from=4.405,51.882&to=4.545,51.955`). They use only functions
|
|
117
|
+
present in both pgRouting 3.8 (Amazon RDS) and 4.0.
|
|
118
|
+
|
|
119
|
+
## Authentication and tenant isolation
|
|
120
|
+
|
|
121
|
+
Each app signs its own users' tokens, planisphere verifies them, and PostgreSQL enforces
|
|
122
|
+
isolation. planisphere never sees your user database.
|
|
123
|
+
|
|
124
|
+
1. **Your app mints a short-lived JWT** (5–15 minutes) after its own login. It must be
|
|
125
|
+
signed with an asymmetric key (`EdDSA`, `ES256`/`ES384`, `RS*`, `PS*`); `HS*` tokens
|
|
126
|
+
are always refused, because holding a verification key must never let anyone mint
|
|
127
|
+
tokens. Required claims are `iss`, `aud` (`planisphere`) and `exp`; `nbf` is checked
|
|
128
|
+
if present. Add `sub`, `tenant`, and anything your policies need, such as `roles` or
|
|
129
|
+
`nation`.
|
|
130
|
+
2. **planisphere verifies it** against `[[auth.issuers]]`: a PEM public key, a JWKS file,
|
|
131
|
+
or a JWKS URL, with keys selected by `kid` for rotation. Key files are re-read on
|
|
132
|
+
`SIGHUP` or the MCP `reload` tool. A `jwks_url` is fetched at startup (planisphere
|
|
133
|
+
won't start if it can't be fetched), then refetched every `jwks_refresh_secs`
|
|
134
|
+
(default 300). A token naming an unknown `kid` triggers an early refetch, at most once
|
|
135
|
+
every 30 seconds, and gets a `401` until then. To rotate, publish the new key before
|
|
136
|
+
signing with it. A failed refresh keeps the last good keys. Use `https://`; plain
|
|
137
|
+
`http://` is only accepted for loopback addresses. Clients send
|
|
138
|
+
`Authorization: Bearer <token>`; with MapLibre, set it in `transformRequest`.
|
|
139
|
+
3. **PostgreSQL enforces isolation.** planisphere runs each authenticated tile query in a
|
|
140
|
+
transaction with `request.jwt.claims` set to the verified claims. Install
|
|
141
|
+
[`sql/planisphere-auth.sql`](sql/planisphere-auth.sql) and write policies with its helpers:
|
|
142
|
+
|
|
143
|
+
```sql
|
|
144
|
+
ALTER TABLE depots ENABLE ROW LEVEL SECURITY;
|
|
145
|
+
CREATE POLICY depots_tenant ON depots FOR SELECT USING (tenant = planisphere.tenant());
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Outside an authenticated request the helpers return `NULL`, so policies fail closed.
|
|
149
|
+
|
|
150
|
+
Rules that follow:
|
|
151
|
+
|
|
152
|
+
- A layer marked `"public": true` in its comment needs no token. Every other layer
|
|
153
|
+
returns `401` without a valid token, and an invalid token is always a `401`.
|
|
154
|
+
- Authenticated responses are `Cache-Control: private` with `Vary: Authorization`, so a
|
|
155
|
+
CDN never serves one tenant's tiles to another.
|
|
156
|
+
- **planisphere refuses to start** with auth on when its database role is a superuser or
|
|
157
|
+
`BYPASSRLS`, because row-level security would silently not apply. It also warns about
|
|
158
|
+
RLS tables the role owns without `FORCE ROW LEVEL SECURITY`, and about
|
|
159
|
+
`SECURITY DEFINER` tile functions, which run as their owner.
|
|
160
|
+
- `planisphere token --key k.pem --iss dev --tenant acme` mints a token for development.
|
|
161
|
+
`dev/keys.sh` creates a dev keypair.
|
|
162
|
+
- The MCP `probe_tile` tool takes `claims` (for example `{"tenant": "acme"}`) to show
|
|
163
|
+
what a tenant would see.
|
|
164
|
+
|
|
165
|
+
The demo fixtures include `tiles.depots`, protected by row-level security, with rows for
|
|
166
|
+
two tenants.
|
|
167
|
+
|
|
168
|
+
## Live updates
|
|
169
|
+
|
|
170
|
+
Maps repaint when your data changes, without polling.
|
|
171
|
+
|
|
172
|
+
1. Install [`sql/planisphere-changes.sql`](sql/planisphere-changes.sql) and attach its trigger
|
|
173
|
+
to the tables behind a layer, naming the layer and, optionally, the tenant column:
|
|
174
|
+
|
|
175
|
+
```sql
|
|
176
|
+
CREATE TRIGGER depots_changed AFTER INSERT OR UPDATE OR DELETE ON depots
|
|
177
|
+
FOR EACH ROW EXECUTE FUNCTION planisphere.notify_layer_change('depots', 'tenant');
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Or call `planisphere.notify_change(layer, tenant, bbox)` yourself. Notifications go out
|
|
181
|
+
at `COMMIT`, so nothing is sent for a rollback. Identical ones collapse per transaction:
|
|
182
|
+
a bulk update costs one event per (layer, tenant), not one per row.
|
|
183
|
+
2. Clients subscribe to `GET /events` (SSE), optionally with `?layers=a,b`. The stream
|
|
184
|
+
carries these events:
|
|
185
|
+
- `ready`;
|
|
186
|
+
- `change` (`{"layer", "bbox"?}`): refetch that layer, for example with MapLibre's
|
|
187
|
+
`map.refreshTiles(layer)`;
|
|
188
|
+
- `catalog`: layers were added or changed;
|
|
189
|
+
- `resync`: the client fell behind, so refetch everything;
|
|
190
|
+
- `expired`.
|
|
191
|
+
3. Each event reaches only clients allowed to read the layer. A change with a `tenant`
|
|
192
|
+
reaches only that tenant, and events never carry tenant ids. `EventSource` cannot set
|
|
193
|
+
headers, so `/events` also accepts `?access_token=`. The stream closes when the token
|
|
194
|
+
expires; reconnect with a fresh one.
|
|
195
|
+
|
|
196
|
+
`/viewer` subscribes automatically; add `?token=<jwt>` to see protected layers.
|
|
197
|
+
|
|
198
|
+
### Tile cache
|
|
199
|
+
|
|
200
|
+
Tiles are cached in memory (`tiles.cache_max_mb`, default 256 MB), keyed by layer, tile,
|
|
201
|
+
query and the caller's claims. Two users of one tenant with different roles never share
|
|
202
|
+
an entry, because row-level security may read any claim. Concurrent requests for the same
|
|
203
|
+
uncached tile share one database query.
|
|
204
|
+
|
|
205
|
+
- A `change` event refreshes that layer for the affected tenant. Other tenants keep
|
|
206
|
+
their cached tiles.
|
|
207
|
+
- A catalog reload (any function DDL) drops everything.
|
|
208
|
+
- `tiles.cache_ttl_secs` (default 1 hour) bounds staleness for data changed without a
|
|
209
|
+
trigger.
|
|
210
|
+
|
|
211
|
+
Invalidation bumps version counters rather than deleting entries, so a query that was
|
|
212
|
+
already running when the change committed can never serve its stale result to anyone
|
|
213
|
+
afterwards. `/status` shows the hit rate and size.
|
|
214
|
+
|
|
215
|
+
## Extensions
|
|
216
|
+
|
|
217
|
+
PostGIS is the only requirement. planisphere also detects the established extensions it can
|
|
218
|
+
build on: h3, pgRouting, pgvector, pg_cron, pg_partman, pgTAP, hypopg, pg_repack and others.
|
|
219
|
+
Features use these rather than reimplementing them. Every extension on the list is available
|
|
220
|
+
on Amazon RDS for PostgreSQL 18, so nothing locks you out of managed Postgres (which is why
|
|
221
|
+
TimescaleDB is not on the list). `planisphere check`, `/status` and the MCP `status` tool show
|
|
222
|
+
what is installed, what could be, and version caveats. For example, pgRouting 4.x removed
|
|
223
|
+
`pgr_createTopology`, which RDS's 3.8 still has.
|
|
224
|
+
|
|
225
|
+
## Endpoints
|
|
226
|
+
|
|
227
|
+
| Path | What |
|
|
228
|
+
|---|---|
|
|
229
|
+
| `/tiles` | layer list |
|
|
230
|
+
| `/tiles/{layer}.json` | TileJSON 3.0 |
|
|
231
|
+
| `/tiles/{layer}/{z}/{x}/{y}.mvt` | vector tile |
|
|
232
|
+
| `/fonts/{fontstack}/{start}-{end}.pbf` | MapLibre glyph range, generated from the label font |
|
|
233
|
+
| `/`, `/conformance`, `/api` | OGC API landing page, conformance classes, OpenAPI 3.0 |
|
|
234
|
+
| `/collections/{layer}/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}` | OGC API tile |
|
|
235
|
+
| `/tileMatrixSets/WebMercatorQuad` | tile matrix set definition (OGC 17-083r4) |
|
|
236
|
+
| `/wms` | WMS 1.3.0 / 1.1.1: `GetCapabilities`, `GetMap`, `GetFeatureInfo`, `GetLegendGraphic` |
|
|
237
|
+
| `/functions`, `/functions/{name}` | query functions (`GET` params or `POST` JSON) |
|
|
238
|
+
| `/events` | live `change` / `catalog` events (SSE), filtered per tenant |
|
|
239
|
+
| `/viewer` | MapLibre map of every layer, refreshed live |
|
|
240
|
+
| `/status` | live dashboard (Datastar over SSE) |
|
|
241
|
+
| `/metrics` | Prometheus metrics |
|
|
242
|
+
| `/health`, `/ready` | liveness; readiness is 503 while the database circuit is open |
|
|
243
|
+
| `/mcp` | admin MCP endpoint (see below) |
|
|
244
|
+
|
|
245
|
+
Errors are `application/problem+json`, except WMS, which returns OGC XML exception reports.
|
|
246
|
+
|
|
247
|
+
## WMS
|
|
248
|
+
|
|
249
|
+
`GetMap` renders from the same tile functions: it fetches the tiles a MapLibre client would
|
|
250
|
+
load for the requested view, then draws them with [tiny-skia](https://github.com/linebender/tiny-skia).
|
|
251
|
+
Supported CRSs are `EPSG:3857`, `EPSG:4326` and `CRS:84`. For `EPSG:4326` the BBOX is lat,lon
|
|
252
|
+
in 1.3.0 and lon,lat in 1.1.1, as the specs require. `FORMAT` is `image/png`,
|
|
253
|
+
`image/png; mode=8bit` (palette PNG, also `image/png8`; usually a third of the size) or
|
|
254
|
+
`image/jpeg` (opaque: `TRANSPARENT` is ignored).
|
|
255
|
+
|
|
256
|
+
`GetFeatureInfo` fetches the one tile under the clicked pixel for each `QUERY_LAYERS`
|
|
257
|
+
entry, at the zoom `GetMap` used, so it's normally a cache hit. It reports the features
|
|
258
|
+
drawn there, topmost first:
|
|
259
|
+
|
|
260
|
+
- polygons that contain the pixel;
|
|
261
|
+
- lines and circles within their styled width plus 3 px.
|
|
262
|
+
|
|
263
|
+
Style filters apply, so hidden features are never reported. `INFO_FORMAT` is
|
|
264
|
+
`text/plain`, `text/html`, `text/xml`, `application/json` or `application/geo+json`;
|
|
265
|
+
the JSON forms include lon/lat geometry at tile resolution. `FEATURE_COUNT` is capped
|
|
266
|
+
at 50.
|
|
267
|
+
|
|
268
|
+
Styling uses a [MapLibre style](https://maplibre.org/maplibre-style-spec/) (`[wms] style`).
|
|
269
|
+
Each source name is a planisphere layer id, and `source-layer` is the MVT layer inside it.
|
|
270
|
+
Without a style file every layer gets an automatic one. The supported subset:
|
|
271
|
+
|
|
272
|
+
- **Layer types:** `background`, `fill`, `line`, `circle`, `symbol` (text and icons)
|
|
273
|
+
- **Expressions:** `get`, `has`, `geometry-type`, `zoom`, comparisons, `all`, `any`, `!`, `case`,
|
|
274
|
+
`match`, `coalesce`, `to-number`, `to-string`, `concat`, `upcase`, `downcase`, `step`,
|
|
275
|
+
`interpolate` (`linear`, `exponential`)
|
|
276
|
+
|
|
277
|
+
**Labels** come from `symbol` layers:
|
|
278
|
+
|
|
279
|
+
- **Text:** `text-field`, as an expression or a `"{name}"` template.
|
|
280
|
+
- **Layout:** `text-size`, `text-anchor`, `text-offset`, `text-transform`,
|
|
281
|
+
`text-max-width` (wraps at spaces), `text-allow-overlap`, `symbol-placement`,
|
|
282
|
+
`symbol-spacing` and `text-max-angle`.
|
|
283
|
+
- **Paint:** `text-color`, `text-opacity`, `text-halo-color` and `text-halo-width`.
|
|
284
|
+
|
|
285
|
+
Placement:
|
|
286
|
+
|
|
287
|
+
- Labels are placed after all geometry, topmost layer first, and a label that would
|
|
288
|
+
collide with one already placed is dropped.
|
|
289
|
+
- A label cut by the image edge is dropped too, so tiled WMS clients never show half
|
|
290
|
+
a label.
|
|
291
|
+
- With `symbol-placement: point` (the default), points are labelled at the point, lines
|
|
292
|
+
at their midpoint, polygons at the centroid of their largest part.
|
|
293
|
+
- With `symbol-placement: line`, labels follow lines and polygon outlines glyph by glyph,
|
|
294
|
+
repeating every `symbol-spacing` px (default 250). They always read left to right, and
|
|
295
|
+
vertical lines read bottom to top. A label is dropped where the line bends more than
|
|
296
|
+
`text-max-angle` (default 45°) between two glyphs, or where it's longer than the line.
|
|
297
|
+
`line-center` places one label at the middle of each line.
|
|
298
|
+
|
|
299
|
+
`text-font` is ignored. The bundled font is Noto Sans (Latin, Greek, Cyrillic; SIL OFL);
|
|
300
|
+
point `[wms] font` at another TrueType/OpenType file for other scripts. There is no text
|
|
301
|
+
shaping, so scripts that need it (Arabic, Indic) render unjoined.
|
|
302
|
+
|
|
303
|
+
**Icons** come from the style's `sprite`: a local path without extension, resolved
|
|
304
|
+
relative to the style file, naming `<path>.json` and `<path>.png` (MapLibre's sprite
|
|
305
|
+
format). Remote sprite URLs aren't fetched.
|
|
306
|
+
|
|
307
|
+
- **Properties:** `icon-image` (an expression, so `match` or `step` can pick an icon
|
|
308
|
+
per feature), `icon-size`, `icon-anchor`, `icon-offset`, `icon-opacity` and
|
|
309
|
+
`icon-allow-overlap`. SDF icons are tinted with `icon-color`.
|
|
310
|
+
- **With text:** a symbol can have an icon, text, or both. With both, they're placed
|
|
311
|
+
together, so both fit or neither is drawn.
|
|
312
|
+
- **Limits:** icons need `symbol-placement: point`.
|
|
313
|
+
- **Legends:** legends show icons, one row per class when `icon-image` depends on a
|
|
314
|
+
property.
|
|
315
|
+
|
|
316
|
+
**Glyphs for MapLibre clients:** planisphere serves the label font as MapLibre glyph
|
|
317
|
+
ranges, so client-side styles can draw text without a font CDN:
|
|
318
|
+
|
|
319
|
+
```json
|
|
320
|
+
"glyphs": "https://maps.example.com/fonts/{fontstack}/{range}.pbf"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Every fontstack name gets the same font: `[wms] font`, else the bundled Noto Sans.
|
|
324
|
+
The glyphs are signed distance fields made the way MapLibre's TinySDF makes them.
|
|
325
|
+
Each range is rendered once and cached for the life of the process.
|
|
326
|
+
|
|
327
|
+
Unsupported style syntax is rejected at startup, naming the layer. See
|
|
328
|
+
[`dev/style.json`](dev/style.json) for an example.
|
|
329
|
+
|
|
330
|
+
`GetLegendGraphic` draws one row per style layer of `LAYER`, with a swatch in its own
|
|
331
|
+
paint. A colour driven by a property (`interpolate`, `step` or `match` over `get`) becomes
|
|
332
|
+
one row per stop or category, such as `< 5,000,000` or `motorway, trunk`. `SCALE` picks
|
|
333
|
+
the zoom; otherwise the legend is drawn at zoom 10. `WIDTH`/`HEIGHT` size the swatches,
|
|
334
|
+
and `TRANSPARENT` and `BGCOLOR` work as in `GetMap`. Each layer's style advertises its
|
|
335
|
+
legend through `LegendURL`, which QGIS and web clients pick up.
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
## Configuration
|
|
339
|
+
|
|
340
|
+
`planisphere -c <file>` (or `$PLANISPHERE_CONFIG`, default `planisphere.toml`).
|
|
341
|
+
[`planisphere.example.toml`](planisphere.example.toml) documents every option. The main ones:
|
|
342
|
+
|
|
343
|
+
| Key | Default | |
|
|
344
|
+
|---|---|---|
|
|
345
|
+
| `listen` | (required) | address to bind |
|
|
346
|
+
| `public_url` | from `Host` | base URL used in links |
|
|
347
|
+
| `database.url` | (required) | overridden by `$PLANISPHERE_DATABASE_URL` |
|
|
348
|
+
| `database.pool_size` | 8 | |
|
|
349
|
+
| `database.statement_timeout_ms` | 10000 | per connection; 0 disables |
|
|
350
|
+
| `database.connect_attempts` | 10 | `serve` retries startup with exponential backoff |
|
|
351
|
+
| `database.breaker.*` | 5 failures / 30 s, open 10 s | trips only on database-health errors |
|
|
352
|
+
| `tiles.queue_target_ms`, `queue_interval_ms` | 50, 500 | shed tile queries once waits stay above target for a whole interval |
|
|
353
|
+
| `tiles.queue_max_wait_ms` | 2000 | longest any tile query waits for a slot |
|
|
354
|
+
| `tiles.schemas` | `["public"]` | where tile functions are discovered |
|
|
355
|
+
| `rate_limit.requests_per_sec`, `burst` | 50, 100 | per client IP; 0 disables |
|
|
356
|
+
| `rate_limit.trust_x_forwarded_for` | false | enable only behind a proxy that sets it |
|
|
357
|
+
| `wms.style` | automatic | MapLibre style file, relative to the config file |
|
|
358
|
+
| `wms.max_size`, `max_tiles` | 4096, 64 | GetMap limits |
|
|
359
|
+
| `wms.font` | bundled Noto Sans | Label font (TrueType/OpenType), relative to the config file |
|
|
360
|
+
| `mcp.enabled`, `token_env` | true, `PLANISPHERE_MCP_TOKEN` | |
|
|
361
|
+
|
|
362
|
+
`RUST_LOG` controls logging (default `info`).
|
|
363
|
+
|
|
364
|
+
### Database TLS
|
|
365
|
+
|
|
366
|
+
TLS is configured with libpq parameters in `database.url`, which mean what libpq says
|
|
367
|
+
they mean:
|
|
368
|
+
|
|
369
|
+
| `sslmode` | encrypted | server verified |
|
|
370
|
+
|---|---|---|
|
|
371
|
+
| `disable` | no | – |
|
|
372
|
+
| `prefer` (default) | if the server offers it | no |
|
|
373
|
+
| `require` | yes | no, unless `sslrootcert` is set (then like `verify-ca`) |
|
|
374
|
+
| `verify-ca` | yes | certificate chain |
|
|
375
|
+
| `verify-full` | yes | chain and host name |
|
|
376
|
+
|
|
377
|
+
`sslrootcert` names the CA file; without it, the system trust store is used.
|
|
378
|
+
`sslcert` and `sslkey` enable client certificates. For Amazon RDS, download the
|
|
379
|
+
[RDS CA bundle](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html)
|
|
380
|
+
and use `?sslmode=verify-full&sslrootcert=/path/to/global-bundle.pem`. Every
|
|
381
|
+
certificate in the bundle is trusted. planisphere logs a warning whenever the server
|
|
382
|
+
certificate is not verified.
|
|
383
|
+
|
|
384
|
+
### RDS IAM authentication
|
|
385
|
+
|
|
386
|
+
With `database.iam_auth = true`, planisphere authenticates to RDS with IAM instead of a
|
|
387
|
+
stored password. It needs TLS (`sslmode=require` or stricter) and a database user granted
|
|
388
|
+
`rds_iam`:
|
|
389
|
+
|
|
390
|
+
```toml
|
|
391
|
+
[database]
|
|
392
|
+
url = "postgres://planisphere_reader@mydb.abc123.eu-west-1.rds.amazonaws.com/gis?sslmode=verify-full&sslrootcert=/etc/ssl/rds-global-bundle.pem"
|
|
393
|
+
iam_auth = true
|
|
394
|
+
region = "eu-west-1" # else $AWS_REGION / $AWS_DEFAULT_REGION
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```sql
|
|
398
|
+
CREATE ROLE planisphere_reader LOGIN;
|
|
399
|
+
GRANT rds_iam TO planisphere_reader;
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- **Tokens:** each new connection, pooled or the `LISTEN` one, signs a 15-minute token
|
|
403
|
+
(the same token `aws rds generate-db-auth-token` prints). A token is reused for up to
|
|
404
|
+
10 minutes.
|
|
405
|
+
- **Credentials:** found the way the AWS SDKs find them:
|
|
406
|
+
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (/ `AWS_SESSION_TOKEN`);
|
|
407
|
+
- EKS IRSA (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`, exchanged with STS);
|
|
408
|
+
- the ECS task role or EKS Pod Identity endpoint;
|
|
409
|
+
- EC2 instance metadata (IMDSv2).
|
|
410
|
+
- **Caching and limits:** temporary credentials are cached until five minutes before
|
|
411
|
+
they expire. Profiles and SSO aren't read, and no AWS SDK is linked.
|
|
412
|
+
- **The IAM policy** needs `rds-db:connect` on
|
|
413
|
+
`arn:aws:rds-db:<region>:<account>:dbuser:<resource id>/planisphere_reader`.
|
|
414
|
+
|
|
415
|
+
## Metrics
|
|
416
|
+
|
|
417
|
+
`/metrics` serves Prometheus text format. It is not rate-limited and needs no token. It
|
|
418
|
+
includes:
|
|
419
|
+
|
|
420
|
+
- tile requests by outcome;
|
|
421
|
+
- cache hits, misses, entries and bytes;
|
|
422
|
+
- circuit state and pool use;
|
|
423
|
+
- open event streams and delivered events, and whether the `LISTEN` connection behind them
|
|
424
|
+
and hot reload is up (`planisphere_listener_connected`);
|
|
425
|
+
- catalog reloads and generation;
|
|
426
|
+
- `planisphere_tile_db_seconds{layer}`, a histogram of database time per layer (cache
|
|
427
|
+
misses only).
|
|
428
|
+
|
|
429
|
+
### Admission control
|
|
430
|
+
|
|
431
|
+
How many tile queries run at once is learned, not configured
|
|
432
|
+
([shed-machines](https://crates.io/crates/shed-machines)):
|
|
433
|
+
|
|
434
|
+
- **The limit** starts at one query per pooled connection. It grows by one each time a
|
|
435
|
+
query succeeds with every slot busy, up to `database.breaker.max_concurrency`
|
|
436
|
+
(default 4 per pooled connection). It drops to three quarters on overload: a pool
|
|
437
|
+
wait timeout, a statement timeout, or a lost connection. Latency alone doesn't move
|
|
438
|
+
it, because layers legitimately differ a hundredfold.
|
|
439
|
+
- **Requests over the limit wait** instead of failing, because a map view asks for dozens
|
|
440
|
+
of tiles at once and MapLibre doesn't retry a refused tile. They're served in arrival
|
|
441
|
+
order, so a new request never overtakes one already waiting. CoDel watches those waits:
|
|
442
|
+
a burst passes, but once waits stay above `tiles.queue_target_ms` for a whole
|
|
443
|
+
`tiles.queue_interval_ms`, requests are shed with `503` and `Retry-After`. No request
|
|
444
|
+
waits longer than `tiles.queue_max_wait_ms`.
|
|
445
|
+
- **Visibility:** `/status` and `/metrics` show the limit, the queries running and the
|
|
446
|
+
count shed (`planisphere_tile_concurrency_limit`,
|
|
447
|
+
`planisphere_tile_queries_in_flight`, `planisphere_tiles_total{outcome="shed"}`).
|
|
448
|
+
|
|
449
|
+
Tile queries slower than `tiles.slow_ms` (default 1000 ms) are logged with the layer and
|
|
450
|
+
z/x/y.
|
|
451
|
+
|
|
452
|
+
## Admin MCP endpoint
|
|
453
|
+
|
|
454
|
+
`/mcp` serves [MCP](https://modelcontextprotocol.io) tools built on
|
|
455
|
+
[mcp-host](https://crates.io/crates/mcp-host):
|
|
456
|
+
|
|
457
|
+
| Tool | |
|
|
458
|
+
|---|---|
|
|
459
|
+
| `status` | the `/status` snapshot: circuit, pool, counters, layers, extensions |
|
|
460
|
+
| `list_layers`, `describe_layer` | layer metadata, including the function's SQL source |
|
|
461
|
+
| `probe_tile` | render one tile through the circuit breaker and time it |
|
|
462
|
+
| `hot_tiles` | the most requested tiles since start (Space-Saving heavy hitters over 64 counters, with each count's possible overestimate): candidates to pre-render or cache longer |
|
|
463
|
+
| `layer_stats` | per-layer tile timings (count, mean, t-digest p50/p95/p99, max) beside `pg_stat_statements` for the same query (calls, mean, stddev, max, rows, cache hit ratio), slowest first. The database side needs `pg_stat_statements`, which RDS preloads by default |
|
|
464
|
+
| `diagnose_layer` | a behavior tree: describe the layer, probe its centre tile at min/mid/max zoom in parallel, then ask the client's model for an assessment (skipped if the client can't sample) |
|
|
465
|
+
| `reload` | rediscover layers and extensions now, and report what changed |
|
|
466
|
+
|
|
467
|
+
On a loopback listener no token is needed. On any other address planisphere refuses to start
|
|
468
|
+
without a bearer token in `$PLANISPHERE_MCP_TOKEN` (or set `mcp.enabled = false`).
|
|
469
|
+
|
|
470
|
+
```sh
|
|
471
|
+
claude mcp add --transport http planisphere http://127.0.0.1:8080/mcp
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Ruby
|
|
475
|
+
|
|
476
|
+
The `planisphere` gem ships the precompiled server for Ruby and Rails apps. Platform gems
|
|
477
|
+
exist for `arm64-darwin` and `aarch64-linux` (glibc 2.17+); other platforms follow.
|
|
478
|
+
Bundler picks the right one.
|
|
479
|
+
|
|
480
|
+
```ruby
|
|
481
|
+
# Gemfile
|
|
482
|
+
gem 'planisphere'
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
```sh
|
|
486
|
+
bundle exec planisphere -c config/planisphere.toml serve
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
`Planisphere.executable` returns the binary's path, for Procfiles or process supervisors.
|
|
490
|
+
On other platforms, install the server with `cargo install planisphere` and set
|
|
491
|
+
`PLANISPHERE_EXECUTABLE` to it.
|
|
492
|
+
|
|
493
|
+
## Performance
|
|
494
|
+
|
|
495
|
+
`examples/load.rs` drives keep-alive HTTP/1.1 connections at random tiles of one layer:
|
|
496
|
+
|
|
497
|
+
```sh
|
|
498
|
+
cargo run --release --example load -- http://127.0.0.1:8080 cities 0-6 32 10
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Indicative numbers: an 8-core Apple M1, PostgreSQL 18 in Docker on the same machine, 32
|
|
502
|
+
connections, a 16-connection pool, rate limiting off.
|
|
503
|
+
|
|
504
|
+
| layer | tile cache | throughput | p50 | p99 |
|
|
505
|
+
|---|---|---|---|---|
|
|
506
|
+
| `cities` (points), z0–6 | off | 17,000 req/s | 1.7 ms | 3.9 ms |
|
|
507
|
+
| `hexbins` (generated polygons), z0–5 | off | 1,000 req/s | 31 ms | 51 ms |
|
|
508
|
+
| `cities` | on | 129,000 req/s | 0.23 ms | 0.53 ms |
|
|
509
|
+
| `hexbins` | on | 93,000 req/s | 0.27 ms | 0.58 ms |
|
|
510
|
+
|
|
511
|
+
Uncached throughput is whatever the tile functions allow: `hexbins` builds its hexagons per
|
|
512
|
+
request. The MCP `layer_stats` tool shows which layer spends where.
|
|
513
|
+
|
|
514
|
+
## Development
|
|
515
|
+
|
|
516
|
+
```sh
|
|
517
|
+
cargo test
|
|
518
|
+
cargo clippy --all-targets
|
|
519
|
+
bundle exec rake test # gem stub
|
|
520
|
+
bundle exec rake native:build # platform gem for this machine, into pkg/
|
|
521
|
+
dev/cite-tiles.sh # OGC API - Tiles conformance suite (Docker, amd64 image)
|
|
522
|
+
dev/cite-wms.sh # WMS 1.3.0 suite: loads the CITE dataset into schema `cite`, runs it
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### Fault injection
|
|
526
|
+
|
|
527
|
+
A debug build with the `chaos` feature injects faults into tile queries, for hardening
|
|
528
|
+
drills ([rogue-machines](https://crates.io/crates/rogue-machines)). Release builds refuse
|
|
529
|
+
the feature, so it never reaches a binary or a gem.
|
|
530
|
+
|
|
531
|
+
```sh
|
|
532
|
+
cargo run --features chaos -- -c planisphere.example.toml serve
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
It adds three MCP tools (MCP must be enabled):
|
|
536
|
+
|
|
537
|
+
| Tool | |
|
|
538
|
+
|---|---|
|
|
539
|
+
| `chaos_arm` | for `seconds` (at most 3600), make `percent` of one layer's tile queries `raise` (fail like a lost connection), `delay` (run `delay_ms` late, holding their slot) or `garble` (succeed with a body that isn't a tile) |
|
|
540
|
+
| `chaos_disarm` | end one layer's experiment, or all of them |
|
|
541
|
+
| `chaos_status` | live experiments, with what is left of each |
|
|
542
|
+
|
|
543
|
+
Faults run inside the admission gate and the circuit breaker, so they get the same
|
|
544
|
+
reaction a real fault would: five `raise` failures open the circuit for every layer, and a
|
|
545
|
+
`delay` longer than the queue target makes CoDel shed. A garbled tile is cached like a
|
|
546
|
+
real one until it is evicted or the catalog reloads. `examples/load.rs` drives the traffic.
|
data/exe/planisphere
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Planisphere
|
|
4
|
+
# Platform gems published for each release: RubyGems platform => Rust target.
|
|
5
|
+
# Trimmed to the fleet's hosts while the gem is proven out; the full list
|
|
6
|
+
# (x86_64 darwin/linux, musl, amd64-freebsd) returns afterwards.
|
|
7
|
+
PLATFORMS = {
|
|
8
|
+
'arm64-darwin' => 'aarch64-apple-darwin',
|
|
9
|
+
'aarch64-linux' => 'aarch64-unknown-linux-gnu'
|
|
10
|
+
}.freeze
|
|
11
|
+
end
|
data/lib/planisphere.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'planisphere/version'
|
|
4
|
+
require_relative 'planisphere/platforms'
|
|
5
|
+
|
|
6
|
+
# Ruby packaging of the planisphere map server. Platform gems carry the compiled
|
|
7
|
+
# server in libexec/; this module only finds it.
|
|
8
|
+
module Planisphere
|
|
9
|
+
class UnsupportedPlatform < StandardError; end
|
|
10
|
+
|
|
11
|
+
BUNDLED = File.expand_path('../libexec/planisphere', __dir__)
|
|
12
|
+
|
|
13
|
+
# Path of the server binary: $PLANISPHERE_EXECUTABLE if set, else the one bundled
|
|
14
|
+
# in this platform gem.
|
|
15
|
+
def self.executable(bundled: BUNDLED)
|
|
16
|
+
override = ENV.fetch('PLANISPHERE_EXECUTABLE', nil)
|
|
17
|
+
return override if override && !override.empty?
|
|
18
|
+
return bundled if File.executable?(bundled)
|
|
19
|
+
|
|
20
|
+
raise UnsupportedPlatform, <<~MESSAGE.chomp
|
|
21
|
+
planisphere #{VERSION} has no precompiled binary for #{Gem::Platform.local}.
|
|
22
|
+
Supported: #{PLATFORMS.keys.join(', ')}.
|
|
23
|
+
Otherwise install the server with `cargo install planisphere` and set PLANISPHERE_EXECUTABLE.
|
|
24
|
+
MESSAGE
|
|
25
|
+
end
|
|
26
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: planisphere
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Abdelkader Boudih
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Ships the precompiled planisphere server for use from Ruby and Rails
|
|
13
|
+
apps.
|
|
14
|
+
email:
|
|
15
|
+
- terminale@gmail.com
|
|
16
|
+
executables:
|
|
17
|
+
- planisphere
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- exe/planisphere
|
|
24
|
+
- lib/planisphere.rb
|
|
25
|
+
- lib/planisphere/platforms.rb
|
|
26
|
+
- lib/planisphere/version.rb
|
|
27
|
+
homepage: https://github.com/seuros/planisphere
|
|
28
|
+
licenses:
|
|
29
|
+
- MIT
|
|
30
|
+
metadata:
|
|
31
|
+
homepage_uri: https://github.com/seuros/planisphere
|
|
32
|
+
source_code_uri: https://github.com/seuros/planisphere
|
|
33
|
+
changelog_uri: https://github.com/seuros/planisphere/blob/master/CHANGELOG.md
|
|
34
|
+
rubygems_mfa_required: 'true'
|
|
35
|
+
rdoc_options: []
|
|
36
|
+
require_paths:
|
|
37
|
+
- lib
|
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: 4.0.0
|
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
requirements: []
|
|
49
|
+
rubygems_version: 4.0.20
|
|
50
|
+
specification_version: 4
|
|
51
|
+
summary: 'Headless PostGIS map server: vector tiles, OGC API Tiles and WMS'
|
|
52
|
+
test_files: []
|