Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
116 changes: 116 additions & 0 deletions MARTIN_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Martin Tile Server Setup

This document explains how to use Martin tile server to serve vector tiles from your PostGIS database.

## Overview

Martin is a tile server that generates and serves vector tiles on the fly from PostGIS databases. It's configured to:
- Run in the same Docker network as your PostGIS database
- Automatically discover tables and views with geometry columns (SRID 4326)
- Serve them as Mapbox Vector Tiles

## Setup

### 1. Database Views

**Analysis View**: A database view `analysis_view` has been created for data analysis. The migration is in `drizzle/0023_create_analysis_view.sql`.

The view provides a denormalized structure with:
- `createdAt`: measurement timestamp
- `boxId`: device ID
- `tags`: device tags array
- `geometry`: location point (SRID 4326)
- `measurements`: JSONB object containing all sensor measurements

**Note**: Martin automatically discovers tables and views with geometry columns that have SRID 4326. The `analysis_view` geometry column is properly configured for Martin.

### 2. Docker Configuration

Martin is configured in `docker-compose.yml`:
- Runs on port `3001` (host) mapped to port `3000` (container)
- Connects to the database specified in your `DATABASE_URL` (defaults to `opensensemap`)
- Uses the same Docker network (`app-network`)
- Waits for Postgres to be healthy before starting

### 3. Environment Variables

Add to your `.env` file (optional):
```bash
MARTIN_URL=http://localhost:3001
```

If not set, it defaults to `http://localhost:3001`.

**Note**: Martin runs on port `3001` to avoid conflicts with the frontend dev server (port `3000`).

**Note**: Martin's `DATABASE_URL` in `docker-compose.yml` should match your application's database name. Currently configured for `opensensemap` database.

## Usage

### Accessing Martin

Martin is accessible directly at `http://localhost:3001` (or your configured `MARTIN_URL`):

- **TileJSON**: `http://localhost:3001/{source_name}`
- Returns metadata about the tile source
- Example: `http://localhost:3001/analysis_view`

- **Tiles**: `http://localhost:3001/{source_name}/{z}/{x}/{y}.pbf`
- Returns vector tile data for a specific tile
- Example: `http://localhost:3001/analysis_view/10/512/512.pbf`

## Starting the Services

1. **Start Docker services**:
```bash
docker-compose up -d
```
This will start both Postgres and Martin. Martin will wait for Postgres to be healthy.

2. **Ensure PostGIS is enabled**:
```bash
docker exec frontend-postgres-1 psql -U postgres -d opensensemap -c "CREATE EXTENSION IF NOT EXISTS postgis CASCADE;"
```

3. **Run migrations** (if not already done):
```bash
npx tsx ./db/migrate.ts
```
This will create the `analysis_view` and other database structures.

4. **Verify Martin is running and discovering views**:
```bash
curl http://localhost:3001/catalog
```
Should return JSON with available tile sources in the `tiles` object. You should see `analysis_view` listed if it has data with geometry.

To check if the view is properly configured:
```bash
docker exec frontend-postgres-1 psql -U postgres -d opensensemap -c "SELECT Find_SRID('public', 'analysis_view', 'geometry');"
```
Should return `4326`.

5. **Start the frontend**:
```bash
npm run dev
```

## View Structure

The `analysis_view` groups measurements by time and device, aggregating all sensor measurements into a JSONB object:

```sql
SELECT
"createdAt", -- timestamp
"boxId", -- device ID
tags, -- device tags array
geometry, -- PostGIS Point (SRID 4326)
measurements -- JSONB: { "sensor_name": { "value": ..., "unit": ..., "sensor_id": ... } }
FROM analysis_view;
```

Each sensor measurement in the `measurements` JSONB object contains:
- `value`: The measurement value
- `unit`: The unit of measurement
- `sensor_id`: The sensor ID

89 changes: 89 additions & 0 deletions app/routes/api.analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { type LoaderFunctionArgs } from "react-router";
import { drizzleClient } from "~/db.server";
import { sql } from "drizzle-orm";

Check warning on line 3 in app/routes/api.analysis.ts

View workflow job for this annotation

GitHub Actions / ⬣ Lint

`drizzle-orm` import should occur before import of `react-router`

export async function loader({ request }: LoaderFunctionArgs) {
try {
const url = new URL(request.url);
const limitParam = url.searchParams.get("limit");
const offsetParam = url.searchParams.get("offset");
const boxIdParam = url.searchParams.get("boxId");
const hasGeometryParam = url.searchParams.get("hasGeometry");

const limit = limitParam ? Math.min(parseInt(limitParam, 10), 1000) : 100;
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
const hasGeometry = hasGeometryParam?.toLowerCase() === "true";

let query = sql`
SELECT
"createdAt",
"boxId",
tags,
CASE
WHEN geometry IS NOT NULL
THEN ST_AsGeoJSON(geometry)::jsonb
ELSE NULL
END as geometry,
measurements
FROM analysis_view
WHERE 1=1
`;

if (boxIdParam) {
query = sql`${query} AND "boxId" = ${boxIdParam}`;
}

if (hasGeometry) {
query = sql`${query} AND geometry IS NOT NULL`;
}

query = sql`${query} ORDER BY "createdAt" DESC LIMIT ${limit} OFFSET ${offset}`;

const results = await drizzleClient.execute(query);

// Get total count for pagination
let countQuery = sql`SELECT COUNT(*) as total FROM analysis_view WHERE 1=1`;
if (boxIdParam) {
countQuery = sql`${countQuery} AND "boxId" = ${boxIdParam}`;
}
if (hasGeometry) {
countQuery = sql`${countQuery} AND geometry IS NOT NULL`;
}
const [countResult] = await drizzleClient.execute(countQuery);
const total = Number(countResult.total);

return Response.json(
{
data: results,
pagination: {
limit,
offset,
total,
hasMore: offset + limit < total,
},
},
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
},
);
} catch (e) {
console.warn(e);
return Response.json(
{
error: "Internal Server Error",
message:
"The server was unable to complete your request. Please try again later.",
},
{
status: 500,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
},
);
}
}

2 changes: 2 additions & 0 deletions app/utils/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const schema = z.object({
MYBADGES_ISSUERID_OSEM: z.string(),
MYBADGES_CLIENT_ID: z.string(),
MYBADGES_CLIENT_SECRET: z.string(),
MARTIN_URL: z.string().url().optional(),
});

declare global {
Expand Down Expand Up @@ -45,6 +46,7 @@ export function getEnv() {
MYBADGES_API_URL: process.env.MYBADGES_API_URL,
MYBADGES_URL: process.env.MYBADGES_URL,
SENSORWIKI_API_URL: process.env.SENSORWIKI_API_URL,
MARTIN_URL: process.env.MARTIN_URL || "http://localhost:3001",
};
}

Expand Down
24 changes: 24 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,27 @@ services:
volumes:
# - ./pgdata:/home/postgres/pgdata
- ./db/imports:/home/postgres/imports
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5

martin:
image: ghcr.io/maplibre/martin:latest
restart: always
ports:
- "3001:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/opensensemap?sslmode=disable
depends_on:
postgres:
condition: service_healthy
networks:
- app-network

networks:
app-network:
driver: bridge
65 changes: 65 additions & 0 deletions drizzle/0023_create_analysis_view.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
-- Create an analysis view that flattens measurements with device, location, and sensor data
-- This view provides a denormalized structure for data analysis
--
-- Structure:
-- - createdAt: measurement timestamp
-- - boxId: device ID
-- - tags: device tags array
-- - geometry: location point (SRID 4326) - uses the location from measurement if available
-- - measurements: JSONB object containing all sensor measurements
-- Each sensor is a key with value, unit, and sensor_id metadata
--
-- Note: Groups measurements by time and device. If multiple locations exist for the same
-- time/device, uses the location from the first measurement with a location.
CREATE OR REPLACE VIEW analysis_view AS
WITH grouped_measurements AS (
SELECT
m.time,
d.id AS device_id,
d.tags,
MAX(m.location_id) AS location_id,
-- JSONB object for all sensor measurements
-- Key: sensor_wiki_phenomenon or sensor_type or title or sensor_id
-- Value: object with value, unit, and sensor_id
COALESCE(
jsonb_object_agg(
COALESCE(
NULLIF(s.sensor_wiki_phenomenon, ''),
NULLIF(s.sensor_type, ''),
NULLIF(s.title, ''),
s.id::text
),
jsonb_build_object(
'value', m.value,
'unit', COALESCE(s.unit, ''),
'sensor_id', s.id
)
),
'{}'::jsonb
) AS measurements
FROM measurement m
INNER JOIN sensor s ON m.sensor_id = s.id
INNER JOIN device d ON s.device_id = d.id
GROUP BY
m.time,
d.id,
d.tags
)
SELECT
gm.time AS "createdAt",
gm.device_id AS "boxId",
gm.tags,
l.location::geometry(Point, 4326) AS geometry,
gm.measurements
FROM grouped_measurements gm
LEFT JOIN location l ON gm.location_id = l.id;

-- Add comment to help identify this view
COMMENT ON VIEW analysis_view IS 'Denormalized view for data analysis combining measurements, devices, sensors, and locations. All sensor measurements are stored in a JSONB object with value, unit, and sensor_id metadata.';

-- Create index on the view's key columns for better query performance
-- Note: You may want to add indexes on the underlying tables instead:
-- CREATE INDEX idx_measurement_time ON measurement(time);
-- CREATE INDEX idx_measurement_location_id ON measurement(location_id);
-- CREATE INDEX idx_sensor_device_id ON sensor(device_id);

7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@
"when": 1761122113831,
"tag": "0022_odd_sugar_man",
"breakpoints": true
},
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: I am not sure it needs to be commited

{
"idx": 23,
"version": "7",
"when": 1761122113832,
"tag": "0023_create_analysis_view",
"breakpoints": true
}
]
}
Loading