Skip to content

Commit 694d80d

Browse files
authored
Self hosted maptile generation with tippecanoe (#1263)
1 parent 52b19cb commit 694d80d

File tree

8 files changed

+263
-0
lines changed

8 files changed

+263
-0
lines changed

backend/serverless.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package:
44
patterns:
55
- 'certs/**'
66
- '!chromium/**'
7+
- '!tippecanoe-layer.zip'
78

89
provider:
910
name: aws
@@ -42,6 +43,13 @@ plugins:
4243
- serverless-domain-manager
4344
- serverless-offline
4445

46+
layers:
47+
tippecanoe:
48+
package:
49+
artifact: tippecanoe-layer.zip
50+
name: tippecanoe-layer
51+
description: felt/tippecanoe
52+
4553
functions:
4654
app:
4755
handler: appHandler.handler
@@ -93,6 +101,16 @@ functions:
93101
events:
94102
- schedule: cron(0 12 * * ? *)
95103
timeout: 900
104+
syncMapSelfHosted:
105+
stages:
106+
- production
107+
- staging
108+
handler: syncMapSelfHosted.handler
109+
events:
110+
- schedule: cron(10 12 * * ? *)
111+
timeout: 900
112+
layers:
113+
- Ref: TippecanoeLambdaLayer
96114
generateStoryTitles:
97115
stages:
98116
- production
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Tippecanoe options based on the official documentation
3+
* @see https://github.com/felt/tippecanoe
4+
*/
5+
export interface TippecanoeOptions {
6+
/** Minimum zoom level (default: 0) */
7+
minZoom?: number;
8+
/** Maximum zoom level (default: 14) */
9+
maxZoom?: number;
10+
/** Base zoom level (default: 14) */
11+
baseZoom?: number;
12+
/** Drop rate (default: 2) */
13+
dropRate?: number;
14+
/** Buffer size in pixels (default: 5) */
15+
buffer?: number;
16+
/** Simplification tolerance in pixels (default: 0) */
17+
tolerance?: number;
18+
/** Layer name for the output tiles */
19+
layer?: string;
20+
/** Read input as newline-delimited GeoJSON */
21+
newlineDelimited?: boolean;
22+
/** Drop densest features as needed when tiles become too large */
23+
dropDensestAsNeeded?: boolean;
24+
/** Extend zoom levels if features are still being dropped */
25+
extendZoomsIfStillDropping?: boolean;
26+
/** Additional tippecanoe arguments as key-value pairs */
27+
additionalArgs?: Record<string, string | number | boolean>;
28+
}
29+
30+
export interface TippecanoeResult {
31+
/** Path to the generated PMTiles file */
32+
outputPath: string;
33+
/** Symbol for automatic disposal */
34+
[Symbol.dispose](): void;
35+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { spawn } from 'child_process';
2+
import { randomUUID } from 'crypto';
3+
import { unlink } from 'fs/promises';
4+
import { tmpdir } from 'os';
5+
import { join } from 'path';
6+
import { TippecanoeOptions, TippecanoeResult } from './TippecanoeTypes';
7+
8+
// Map of option names to their CLI argument names
9+
const OPTION_MAP: Record<string, string> = {
10+
minZoom: '--minimum-zoom',
11+
maxZoom: '--maximum-zoom',
12+
baseZoom: '--base-zoom',
13+
dropRate: '--drop-rate',
14+
buffer: '--buffer',
15+
tolerance: '--tolerance',
16+
layer: '--layer',
17+
dropDensestAsNeeded: '--drop-densest-as-needed',
18+
extendZoomsIfStillDropping: '--extend-zooms-if-still-dropping',
19+
};
20+
21+
/**
22+
* Process a GeoJSON stream into a PMTiles file using tippecanoe CLI
23+
*
24+
* Usage examples:
25+
*
26+
* 1. Basic usage with automatic cleanup (recommended):
27+
* ```typescript
28+
* await using result = await tippecanoe(geojsonStream);
29+
* // Use result.outputPath to access the PMTiles file
30+
* // File is automatically cleaned up when result goes out of scope
31+
* ```
32+
*
33+
* 2. Process with options:
34+
* ```typescript
35+
* await using result = await tippecanoe(geojsonStream, {
36+
* minZoom: 0,
37+
* maxZoom: 16
38+
* });
39+
* ```
40+
*
41+
* 3. Process newline-delimited GeoJSON:
42+
* ```typescript
43+
* await using result = await tippecanoe(geojsonStream, {
44+
* newlineDelimited: true,
45+
* minZoom: 5,
46+
* maxZoom: 14
47+
* });
48+
* ```
49+
*
50+
* 4. Error handling with automatic cleanup:
51+
* ```typescript
52+
* try {
53+
* await using result = await tippecanoe(geojsonStream);
54+
* console.log('PMTiles generation completed successfully');
55+
* // Cleanup happens automatically, even if an error is thrown
56+
* } catch (error) {
57+
* console.error('Tippecanoe process error:', error);
58+
* }
59+
* ```
60+
*
61+
* @param inputStream - NodeJS.ReadableStream containing GeoJSON data
62+
* @param options - Tippecanoe configuration options
63+
* @returns Promise that resolves to result object with output path and automatic cleanup
64+
*/
65+
export default async function tippecanoe(
66+
inputStream: NodeJS.ReadableStream,
67+
options: TippecanoeOptions = {}
68+
): Promise<TippecanoeResult> {
69+
const { newlineDelimited, additionalArgs = {} } = options;
70+
71+
// Create temporary output file
72+
const outputPath = join(tmpdir(), `tippecanoe-${randomUUID()}.pmtiles`);
73+
74+
// Build tippecanoe command arguments - only add if explicitly provided
75+
const args: string[] = ['--output', outputPath];
76+
77+
// Add numeric options
78+
Object.entries(OPTION_MAP).forEach(([optionName, cliArg]) => {
79+
const value = options[optionName as keyof TippecanoeOptions];
80+
if (value !== undefined) {
81+
if (typeof value === 'boolean') {
82+
if (value) {
83+
args.push(cliArg);
84+
}
85+
} else {
86+
args.push(cliArg, value.toString());
87+
}
88+
}
89+
});
90+
91+
if (newlineDelimited) {
92+
args.push('--newline-delimited');
93+
}
94+
95+
// Add additional arguments with validation
96+
for (const [key, value] of Object.entries(additionalArgs)) {
97+
if (value === undefined || value === null) {
98+
continue; // Skip undefined/null values
99+
}
100+
101+
if (typeof value === 'boolean') {
102+
if (value) {
103+
args.push(`--${key}`);
104+
}
105+
} else if (typeof value === 'string' || typeof value === 'number') {
106+
args.push(`--${key}`, value.toString());
107+
}
108+
}
109+
110+
// Spawn tippecanoe process
111+
const tippecanoeProcess = spawn('tippecanoe', args, {
112+
stdio: ['pipe', 'pipe', 'pipe'],
113+
});
114+
115+
// Set up progress monitoring (stderr is where tippecanoe writes progress)
116+
tippecanoeProcess.stderr?.on('data', (data: Buffer) => {
117+
// Forward tippecanoe's stderr to the parent process stderr
118+
process.stderr.write(data);
119+
});
120+
121+
// Pipe input stream to tippecanoe stdin
122+
inputStream.pipe(tippecanoeProcess.stdin);
123+
124+
// Handle process completion
125+
const processPromise = new Promise<void>((resolve, reject) => {
126+
tippecanoeProcess.on('error', (error) => {
127+
reject(new Error(`Tippecanoe process error: ${error.message}`));
128+
});
129+
130+
tippecanoeProcess.on('exit', (code) => {
131+
if (code === 0) {
132+
resolve();
133+
} else {
134+
reject(new Error(`Tippecanoe exited with code ${code ?? 'unknown'}`));
135+
}
136+
});
137+
});
138+
139+
// Wait for process to complete
140+
await processPromise;
141+
142+
return {
143+
outputPath,
144+
[Symbol.dispose]() {
145+
// Automatic cleanup when using 'await using'
146+
unlink(outputPath).catch((error) => {
147+
console.warn(`Failed to cleanup temporary file ${outputPath}:`, error);
148+
});
149+
},
150+
};
151+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
2+
import { createReadStream } from 'fs';
3+
import { getRepository } from 'typeorm';
4+
import GeojsonEncoder from '../business/geodata/GeojsonEncoder';
5+
import tippecanoe from '../business/utils/tippecanoe';
6+
import EffectiveAddress from '../entities/EffectiveAddress';
7+
import EffectiveGeocode from '../entities/EffectiveGeocode';
8+
9+
const s3 = new S3Client();
10+
11+
export default async function syncMap(): Promise<void> {
12+
console.log('Refreshing effective addresses...');
13+
await getRepository(EffectiveAddress).query(
14+
'REFRESH MATERIALIZED VIEW effective_addresses_view WITH DATA'
15+
);
16+
17+
console.log('Refreshing effective geocodes...');
18+
await getRepository(EffectiveGeocode).query(
19+
'REFRESH MATERIALIZED VIEW effective_geocodes_view WITH DATA'
20+
);
21+
22+
const encoder = new GeojsonEncoder('newline-delimited-geojson');
23+
24+
console.log('Beginning sync of map data...');
25+
26+
const dataStream = await encoder.createGeojson('1940');
27+
28+
console.log('Generating PMTiles file...');
29+
// eslint-disable-next-line prettier/prettier
30+
await using result = await tippecanoe(dataStream, {
31+
layer: 'photos-1940s',
32+
dropDensestAsNeeded: true,
33+
extendZoomsIfStillDropping: true,
34+
});
35+
36+
console.log('Uploading PMTiles to S3...');
37+
const pmtilesStream = createReadStream(result.outputPath);
38+
39+
await s3.send(
40+
new PutObjectCommand({
41+
Bucket: 'fourties-maps',
42+
Key: 'photos-1940s.pmtiles',
43+
Body: pmtilesStream,
44+
ContentType: 'application/vnd.pmtiles',
45+
CacheControl: 'max-age=86400',
46+
})
47+
);
48+
49+
console.log('Sync of map data complete.');
50+
}

backend/syncMapSelfHosted.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import syncMapImpl from './src/cron/syncMapSelfHosted';
2+
3+
// Importing @sentry/tracing patches the global hub for tracing to work.
4+
import '@sentry/tracing';
5+
import withSetup from './withSetup';
6+
7+
export const handler = withSetup(syncMapImpl);

backend/tippecanoe-layer.zip

6.72 MB
Binary file not shown.

backend/webpack.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module.exports = {
1515
checkStaleStories: './checkStaleStories.ts',
1616
checkMerchQueue: './checkMerchQueue.ts',
1717
syncMap: './syncMap.ts',
18+
syncMapSelfHosted: './syncMapSelfHosted.ts',
1819
generateStoryTitles: './generateStoryTitles.ts',
1920
sendEmailCampaigns: './sendEmailCampaigns.ts',
2021
renderMerchPrintfiles: './renderMerchPrintfiles.ts',

backend/withSetup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'reflect-metadata';
2+
23
import 'source-map-support/register';
34
import createConnection from './src/createConnection';
45

0 commit comments

Comments
 (0)