
Geospatial Data Pipeline
- 256 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Design and implement geospatial ETL pipelines that ingest, transform, validate, and publish location datasets for maps, routing, and spatial analytics workloads.
About
Guides Claude through building end-to-end geospatial data pipelines: sourcing satellite, census, or vendor GIS feeds, normalizing coordinates, running spatial joins, and publishing layers for APIs, dashboards, or map clients with reproducible transforms.
- CRS and projection handling
- Spatial indexing and tiling
- GeoJSON/Shapefile ingestion
- PostGIS or tile-server outputs
- Quality checks for geometry validity
Geospatial Data Pipeline by the numbers
- 256 all-time installs (skills.sh)
- Ranked #607 of 2,064 Data Science & ML skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill geospatial-data-pipelineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 256 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Design and implement geospatial ETL pipelines that ingest, transform, validate, and publish location datasets for maps, routing, and spatial analytics workloads.
Files
Geospatial Data Pipeline
Expert in processing, optimizing, and visualizing geospatial data at scale.
When to Use
✅ Use for:
- Drone imagery processing and annotation
- GPS track analysis and visualization
- Location-based search (find nearby X)
- Map tile generation for web/mobile
- Coordinate system transformations
- Geofencing and spatial queries
- GeoJSON optimization for web
❌ NOT for:
- Simple address validation (use address APIs)
- Basic distance calculations (use Haversine formula)
- Static map embeds (use Mapbox Static API)
- Geocoding (use Nominatim or Google Geocoding API)
---
Technology Selection
Database: PostGIS vs MongoDB Geospatial
| Feature | PostGIS | MongoDB |
|---|---|---|
| Spatial indexes | GiST, SP-GiST | 2dsphere |
| Query language | SQL + spatial functions | Aggregation pipeline |
| Geometry types | 20+ (full OGC support) | Basic (Point, Line, Polygon) |
| Coordinate systems | 6000+ via EPSG | WGS84 only |
| Performance (10M points) | <100ms | <200ms |
| Best for | Complex spatial analysis | Document-centric apps |
Timeline:
- 2005: PostGIS 1.0 released
- 2012: MongoDB adds geospatial indexes
- 2020: PostGIS 3.0 with improved performance
- 2024: PostGIS remains gold standard for GIS workloads
---
Common Anti-Patterns
Anti-Pattern 1: Storing Coordinates as Strings
Novice thinking: "I'll just store lat/lon as text, it's simple"
Problem: Can't use spatial indexes, queries are slow, no validation.
Wrong approach:
// ❌ String storage, no spatial features
interface Location {
id: string;
name: string;
latitude: string; // "37.7749"
longitude: string; // "-122.4194"
}
// Linear scan for "nearby" queries
async function findNearby(lat: string, lon: string): Promise<Location[]> {
const all = await db.locations.findAll();
return all.filter(loc => {
const distance = calculateDistance(
parseFloat(lat),
parseFloat(lon),
parseFloat(loc.latitude),
parseFloat(loc.longitude)
);
return distance < 5000; // 5km
});
}Why wrong: O(N) linear scan, no spatial index, string parsing overhead.
Correct approach:
// ✅ PostGIS GEOGRAPHY type with spatial index
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
location GEOGRAPHY(POINT, 4326) -- WGS84 coordinates
);
-- Spatial index (GiST)
CREATE INDEX idx_locations_geography ON locations USING GIST(location);
-- TypeScript query
async function findNearby(lat: number, lon: number, radiusMeters: number): Promise<Location[]> {
const query = `
SELECT id, name, ST_AsGeoJSON(location) as geojson
FROM locations
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
$3
)
ORDER BY location <-> ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
LIMIT 100
`;
return db.query(query, [lon, lat, radiusMeters]); // <10ms with index
}Timeline context:
- 2000s: Stored lat/lon as FLOAT columns, did math in app code
- 2010s: PostGIS adoption, spatial indexes
- 2024:
GEOGRAPHYtype handles Earth curvature automatically
---
Anti-Pattern 2: Not Using Spatial Indexes
Problem: Proximity queries do full table scans.
Wrong approach:
-- ❌ No index, sequential scan
CREATE TABLE drone_images (
id SERIAL PRIMARY KEY,
image_url VARCHAR(255),
location GEOGRAPHY(POINT, 4326)
);
-- This query scans ALL rows
SELECT * FROM drone_images
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
1000 -- 1km
);EXPLAIN output: Seq Scan on drone_images (cost=0.00..1234.56 rows=1 width=123)
Correct approach:
-- ✅ GiST index for spatial queries
CREATE INDEX idx_drone_images_location ON drone_images USING GIST(location);
-- Same query, now uses index
SELECT * FROM drone_images
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
1000
);EXPLAIN output: Bitmap Index Scan on idx_drone_images_location (cost=4.30..78.30 rows=50 width=123)
Performance impact: 10M points, 5km radius query
- Without index: 3.2 seconds (full scan)
- With GiST index: 12ms (99.6% faster)
---
Anti-Pattern 3: Mixing Coordinate Systems
Novice thinking: "Coordinates are just numbers, I can mix them"
Problem: Incorrect distances, misaligned map features.
Wrong approach:
// ❌ Mixing EPSG:4326 (WGS84) and EPSG:3857 (Web Mercator)
const userLocation = {
lat: 37.7749, // WGS84
lon: -122.4194
};
const droneImage = {
x: -13634876, // Web Mercator (EPSG:3857)
y: 4545684
};
// Comparing apples to oranges!
const distance = Math.sqrt(
Math.pow(userLocation.lon - droneImage.x, 2) +
Math.pow(userLocation.lat - droneImage.y, 2)
);Result: Wildly incorrect distance (millions of "units").
Correct approach:
-- ✅ Transform to common coordinate system
SELECT ST_Distance(
ST_Transform(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326), -- WGS84
3857 -- Transform to Web Mercator
),
ST_SetSRID(ST_MakePoint(-13634876, 4545684), 3857) -- Already Web Mercator
) AS distance_meters;Or better: Always store in one system (WGS84), transform on display only.
Timeline:
- 2005: Web Mercator (EPSG:3857) introduced by Google Maps
- 2010: Confusion peaks as apps mix WGS84 data with Web Mercator tiles
- 2024: Best practice: Store WGS84, transform to 3857 only for tile rendering
---
Anti-Pattern 4: Loading Huge GeoJSON Files
Problem: 50MB GeoJSON file crashes browser.
Wrong approach:
// ❌ Load entire file into memory
const geoJson = await fetch('/drone-survey-data.geojson').then(r => r.json());
// 50MB of GeoJSON = browser freeze
map.addSource('drone-data', {
type: 'geojson',
data: geoJson // All 10,000 polygons loaded at once
});Correct approach 1: Vector tiles (pre-chunked)
// ✅ Serve as vector tiles (MBTiles or PMTiles)
map.addSource('drone-data', {
type: 'vector',
tiles: ['https://api.example.com/tiles/{z}/{x}/{y}.pbf'],
minzoom: 10,
maxzoom: 18
});
// Browser only loads visible tilesCorrect approach 2: GeoJSON simplification + chunking
# Simplify geometry (reduce points)
npm install -g @mapbox/geojson-precision
geojson-precision -p 5 input.geojson output.geojson
# Split into tiles
npm install -g geojson-vt
# Generate tiles programmatically (see scripts/tile_generator.ts)Correct approach 3: Server-side filtering
// ✅ Only fetch visible bounds
async function fetchVisibleFeatures(bounds: Bounds): Promise<GeoJSON> {
const response = await fetch(
`/api/features?bbox=${bounds.west},${bounds.south},${bounds.east},${bounds.north}`
);
return response.json();
}
map.on('moveend', async () => {
const bounds = map.getBounds();
const geojson = await fetchVisibleFeatures(bounds);
map.getSource('dynamic-data').setData(geojson);
});---
Anti-Pattern 5: Euclidean Distance on Spherical Earth
Novice thinking: "Distance is just Pythagorean theorem"
Problem: Incorrect at scale, worse near poles.
Wrong approach:
// ❌ Flat Earth distance (wrong!)
function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const dx = lon2 - lon1;
const dy = lat2 - lat1;
return Math.sqrt(dx * dx + dy * dy) * 111.32; // 111.32 km/degree (WRONG)
}
// Example: San Francisco to New York
const distance = distanceKm(37.7749, -122.4194, 40.7128, -74.0060);
// Returns: ~55 km (WRONG! Actual: ~4,130 km)Why wrong: Earth is a sphere, not a flat plane.
Correct approach 1: Haversine formula (great circle distance)
// ✅ Haversine formula (spherical Earth)
function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Earth radius in km
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// San Francisco to New York
const distance = haversineKm(37.7749, -122.4194, 40.7128, -74.0060);
// Returns: ~4,130 km ✅Correct approach 2: PostGIS (handles curvature automatically)
-- ✅ PostGIS ST_Distance with GEOGRAPHY
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
ST_SetSRID(ST_MakePoint(-74.0060, 40.7128), 4326)::geography
) / 1000 AS distance_km;
-- Returns: 4130.137 km ✅Accuracy comparison:
| Method | SF to NYC | Error |
|---|---|---|
| Euclidean (flat) | 55 km | 98.7% wrong |
| Haversine (sphere) | 4,130 km | ✅ Correct |
| PostGIS (ellipsoid) | 4,135 km | Most accurate |
---
Production Checklist
□ PostGIS extension installed and spatial indexes created
□ All coordinates stored in consistent SRID (recommend: 4326)
□ GeoJSON files optimized (<1MB) or served as vector tiles
□ Coordinate transformations use ST_Transform, not manual math
□ Distance calculations use ST_Distance with GEOGRAPHY type
□ Bounding box queries use ST_MakeEnvelope + ST_Intersects
□ Large geometries chunked (not >100KB per feature)
□ Map tiles pre-generated for common zoom levels
□ CORS configured for tile servers
□ Rate limiting on geocoding/reverse geocoding endpoints---
When to Use vs Avoid
| Scenario | Appropriate? |
|---|---|
| Drone imagery annotation and search | ✅ Yes - process survey data |
| GPS track visualization | ✅ Yes - optimize paths |
| Find nearest coffee shops | ✅ Yes - spatial queries |
| Jurisdiction boundary lookups | ✅ Yes - point-in-polygon |
| Simple address autocomplete | ❌ No - use Mapbox/Google |
| Embed static map on page | ❌ No - use Static API |
| Geocode single address | ❌ No - use geocoding API |
---
References
/references/coordinate-systems.md- EPSG codes, transformations, Web Mercator vs WGS84/references/postgis-guide.md- PostGIS setup, spatial indexes, common queries/references/geojson-optimization.md- Simplification, chunking, vector tiles
Scripts
scripts/geospatial_processor.ts- Process drone imagery, GPS tracks, GeoJSON validationscripts/tile_generator.ts- Generate vector tiles (MBTiles/PMTiles) from GeoJSON
---
This skill guides: Geospatial data | PostGIS | GeoJSON | Map tiles | Coordinate systems | Drone data processing | Spatial queries
Coordinate Systems & Transformations
Complete guide to EPSG codes, coordinate systems, and transformations for geospatial data.
The Problem
Different coordinate systems measure the Earth differently.
- WGS84 (EPSG:4326): Latitude/longitude on spherical Earth
- Web Mercator (EPSG:3857): Projected coordinates for web maps
- UTM zones: Localized projections with minimal distortion
Mixing them causes:
- Incorrect distances
- Misaligned map features
- Wrong spatial queries
---
Common Coordinate Systems
EPSG:4326 (WGS84)
What it is: Geographic coordinates (lat/lon) on an ellipsoid
Units: Degrees
- Longitude: -180° to 180°
- Latitude: -90° to 90°
Used for:
- GPS coordinates
- GeoJSON standard
- Database storage
Characteristics:
- Spherical/ellipsoidal model of Earth
- Not equal-area (distorts at poles)
- Not conformal (distorts shapes)
- Global coverage
Example:
{
"type": "Point",
"coordinates": [-122.4194, 37.7749] // [lon, lat] in degrees
}Distance calculation: Use great circle distance (Haversine or Vincenty)
---
EPSG:3857 (Web Mercator / Pseudo-Mercator)
What it is: Projected coordinates for web mapping
Units: Meters (but not true meters - see note)
- X (easting): -20,037,508 to 20,037,508
- Y (northing): -20,037,508 to 20,037,508
Used for:
- Google Maps, Mapbox, OpenStreetMap
- Map tiles (z/x/y scheme)
- Web map rendering
Characteristics:
- Cylindrical projection
- Conformal (preserves angles/shapes)
- NOT equal-area (distorts at poles)
- Cuts off at ~85°N/S
Example:
{
"type": "Point",
"coordinates": [-13634876, 4545684] // [x, y] in meters (projected)
}Important: Distances in Web Mercator are NOT accurate. Always transform to WGS84 for distance calculations.
---
UTM Zones (EPSG:326xx / 327xx)
What it is: Universal Transverse Mercator - localized projections
Coverage: 60 zones, each 6° wide
- Northern Hemisphere: EPSG:32601-32660
- Southern Hemisphere: EPSG:32701-32760
Units: Meters
Used for:
- Engineering projects
- Survey data
- High-accuracy local mapping
Characteristics:
- Conformal (preserves shapes)
- Minimal distortion within zone
- Accurate distance calculations
- Limited coverage (one zone at a time)
Example (San Francisco is in UTM Zone 10N):
-- Transform WGS84 to UTM 10N
SELECT ST_Transform(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326),
32610 -- UTM 10N
);
-- Result: POINT(551420 4182976)---
Coordinate Order
CRITICAL: Different systems use different order!
| System | Order | Example |
|---|---|---|
| GeoJSON | [lon, lat] | [-122.4194, 37.7749] |
| PostGIS (WGS84) | (lon, lat) | POINT(-122.4194 37.7749) |
| PostGIS (traditional) | (lat, lon) | VARIES |
| Mapbox/Leaflet | [lat, lon] | [37.7749, -122.4194] |
Rule of thumb: GeoJSON and PostGIS use (X, Y) = (lon, lat)
Common mistake:
// ❌ WRONG - swapped coordinates
const point = {
type: "Point",
coordinates: [37.7749, -122.4194] // lat, lon (WRONG!)
};
// ✅ CORRECT - GeoJSON is [lon, lat]
const point = {
type: "Point",
coordinates: [-122.4194, 37.7749] // lon, lat ✅
};---
Transformations
PostGIS Transformations
Convert between coordinate systems:
-- WGS84 → Web Mercator
SELECT ST_Transform(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326),
3857
);
-- Result: POINT(-13634876 4545684)
-- Web Mercator → WGS84
SELECT ST_Transform(
ST_SetSRID(ST_MakePoint(-13634876, 4545684), 3857),
4326
);
-- Result: POINT(-122.4194 37.7749)
-- WGS84 → UTM 10N
SELECT ST_Transform(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326),
32610
);
-- Result: POINT(551420 4182976)Always specify SRID:
-- ❌ WRONG - no SRID, PostGIS doesn't know what it is
SELECT ST_Transform(ST_MakePoint(-122.4194, 37.7749), 3857);
-- ✅ CORRECT - explicit SRID
SELECT ST_Transform(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326),
3857
);---
JavaScript Transformations (proj4js)
Install:
npm install proj4Usage:
import proj4 from 'proj4';
// Define projections (built-in for common ones)
const wgs84 = 'EPSG:4326';
const webMercator = 'EPSG:3857';
// WGS84 → Web Mercator
const [x, y] = proj4(wgs84, webMercator, [-122.4194, 37.7749]);
console.log(x, y); // -13634876, 4545684
// Web Mercator → WGS84
const [lon, lat] = proj4(webMercator, wgs84, [-13634876, 4545684]);
console.log(lon, lat); // -122.4194, 37.7749Custom projections:
// Define UTM 10N
proj4.defs([
[
'EPSG:32610',
'+proj=utm +zone=10 +datum=WGS84 +units=m +no_defs'
]
]);
// Transform
const [easting, northing] = proj4('EPSG:4326', 'EPSG:32610', [-122.4194, 37.7749]);---
Finding the Right UTM Zone
Formula: Zone = floor((longitude + 180) / 6) + 1
function getUTMZone(lon: number, lat: number): number {
const zone = Math.floor((lon + 180) / 6) + 1;
// Northern hemisphere: 32600 + zone
// Southern hemisphere: 32700 + zone
const epsg = lat >= 0 ? 32600 + zone : 32700 + zone;
return epsg;
}
// San Francisco (-122.4194, 37.7749)
const zone = getUTMZone(-122.4194, 37.7749);
// Returns: 32610 (UTM 10N)---
Distance Calculations
Wrong: Euclidean Distance in WGS84
// ❌ WRONG - treats degrees as Cartesian units
function badDistance(lon1, lat1, lon2, lat2) {
const dx = lon2 - lon1;
const dy = lat2 - lat1;
return Math.sqrt(dx * dx + dy * dy) * 111320; // WRONG!
}Correct: Haversine Formula
// ✅ CORRECT - great circle distance
function haversineDistance(lon1, lat1, lon2, lat2) {
const R = 6371000; // Earth radius in meters
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}Best: PostGIS with GEOGRAPHY
-- PostGIS handles everything
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
ST_SetSRID(ST_MakePoint(-74.0060, 40.7128), 4326)::geography
) AS distance_meters;Note: ::geography tells PostGIS to use spheroid model
---
Common Scenarios
Scenario 1: Storing User Locations
Best practice: Store in WGS84 (EPSG:4326)
CREATE TABLE user_locations (
id SERIAL PRIMARY KEY,
user_id INTEGER,
location GEOGRAPHY(POINT, 4326), -- WGS84
created_at TIMESTAMP
);
-- Index for spatial queries
CREATE INDEX idx_user_locations ON user_locations USING GIST(location);
-- Insert (from GPS)
INSERT INTO user_locations (user_id, location)
VALUES (123, ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326));Why WGS84:
- GPS coordinates are in WGS84
- Database can handle distance calculations
- Global coverage
---
Scenario 2: Rendering Map Tiles
Convert to Web Mercator for rendering:
// Client-side: Transform WGS84 → Web Mercator for Mapbox
const features = await fetch('/api/features').then(r => r.json());
// Mapbox expects WGS84, but tiles are in Web Mercator
map.addSource('features', {
type: 'geojson',
data: features // GeoJSON in WGS84
});
// Mapbox handles transformation internallyServer-side: Generate tiles in Web Mercator
-- Tile query (Web Mercator)
SELECT ST_AsMVT(q, 'layer', 4096, 'geom') AS mvt
FROM (
SELECT
id,
name,
ST_AsMVTGeom(
ST_Transform(location, 3857), -- Transform to Web Mercator
ST_TileEnvelope(:z, :x, :y),
4096,
256
) AS geom
FROM locations
WHERE ST_Intersects(
location,
ST_Transform(ST_TileEnvelope(:z, :x, :y), 4326)
)
) AS q;---
Scenario 3: Engineering/Survey Data
Use local UTM zone for accuracy:
-- Survey points in UTM 10N (San Francisco area)
CREATE TABLE survey_points (
id SERIAL PRIMARY KEY,
point_id VARCHAR(50),
location GEOMETRY(POINT, 32610), -- UTM 10N
elevation FLOAT,
surveyed_at TIMESTAMP
);
-- Accurate distance calculation (within UTM zone)
SELECT ST_Distance(
(SELECT location FROM survey_points WHERE point_id = 'A'),
(SELECT location FROM survey_points WHERE point_id = 'B')
) AS distance_meters;---
Debugging Coordinate Issues
Check SRID
SELECT ST_SRID(location) FROM locations LIMIT 1;
-- Should return 4326 for WGS84Visualize Coordinates
// If coordinates look huge, probably Web Mercator
const coords = [-13634876, 4545684]; // Web Mercator
console.log('Magnitude:', Math.abs(coords[0])); // ~13 million = Web Mercator
// If coordinates are small, probably WGS84
const coords2 = [-122.4194, 37.7749]; // WGS84
console.log('Magnitude:', Math.abs(coords2[0])); // ~122 = WGS84Validate Bounds
// WGS84 bounds
const isValidWGS84 = (lon, lat) => {
return lon >= -180 && lon <= 180 && lat >= -90 && lat <= 90;
};
// Web Mercator bounds
const isValidWebMercator = (x, y) => {
const limit = 20037508.34;
return Math.abs(x) <= limit && Math.abs(y) <= limit;
};---
Quick Reference
| Task | Use This |
|---|---|
| Store GPS coordinates | WGS84 (4326) |
| Render web maps | Web Mercator (3857) |
| Engineering/survey | Local UTM zone (326xx/327xx) |
| Distance calculations | WGS84 with GEOGRAPHY type |
| Tile generation | Web Mercator (3857) |
| Global analysis | Equal-area projection (e.g., Mollweide) |
---
Resources
- EPSG.io - Search coordinate systems
- Proj4 - Transformation library
- PostGIS Transformations
- Understanding Web Mercator
GeoJSON Optimization
Techniques for optimizing GeoJSON files for web performance: simplification, chunking, precision, and vector tiles.
The Problem
Large GeoJSON files crash browsers:
- 50MB GeoJSON = browser freeze
- 10,000 polygons = slow rendering
- High precision coordinates = wasted bytes
Symptoms:
- Map takes 10+ seconds to load
- Browser tab crashes
- Laggy panning/zooming
---
Optimization Strategies
1. Coordinate Precision
Problem: Default precision is often excessive.
// ❌ Excessive precision (8 decimals = ~1mm accuracy)
{
"type": "Point",
"coordinates": [-122.41941234, 37.77492345]
}
// ✅ Appropriate precision (5 decimals = ~1m accuracy)
{
"type": "Point",
"coordinates": [-122.4194, 37.7749]
}Precision vs Accuracy:
| Decimals | Accuracy | Use Case |
|---|---|---|
| 2 | ~1 km | Country-level maps |
| 3 | ~100 m | City-level maps |
| 4 | ~10 m | Neighborhood maps |
| 5 | ~1 m | Web maps (default) |
| 6 | ~10 cm | Engineering, surveying |
| 7 | ~1 cm | Precision agriculture |
| 8 | ~1 mm | Unnecessary for most apps |
Tool: geojson-precision
npm install -g @mapbox/geojson-precision
# Reduce to 5 decimals
geojson-precision -p 5 input.geojson output.geojsonSavings: 8 decimals → 5 decimals = ~40% file size reduction
---
2. Geometry Simplification
Problem: Polygons with too many vertices.
Douglas-Peucker Algorithm: Remove points that don't significantly change shape.
npm install -g @mapbox/simplify-geojson
# Tolerance: higher = more aggressive
simplify-geojson -t 0.001 input.geojson output.geojsonExample:
- Before: 10,000 points
- After (tolerance 0.001): 2,500 points
- Reduction: 75%
Visual comparison:
Before: ⚫⚫⚫⚫⚫⚫⚫⚫⚫⚫ (10,000 points)
After: ⚫-----⚫-----⚫ (2,500 points)PostGIS:
-- Simplify geometry
SELECT ST_Simplify(geometry, 0.001) FROM polygons;
-- Simplify preserving topology (prevents self-intersections)
SELECT ST_SimplifyPreserveTopology(geometry, 0.001) FROM polygons;Tuning tolerance:
0.0001: Subtle simplification (~10% reduction)0.001: Moderate simplification (~40% reduction)0.01: Aggressive simplification (~70% reduction)
---
3. Remove Unnecessary Properties
Problem: Feature properties bloat file size.
// ❌ Before: 500 bytes per feature
{
"type": "Feature",
"properties": {
"id": 123,
"name": "Feature 123",
"description": "Long description...",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-16T14:20:00Z",
"metadata": { /* ... */ },
"tags": ["tag1", "tag2", "tag3"]
},
"geometry": { /* ... */ }
}
// ✅ After: 100 bytes per feature
{
"type": "Feature",
"properties": {
"id": 123,
"name": "Feature 123"
},
"geometry": { /* ... */ }
}Savings: 500 bytes → 100 bytes = 80% reduction per feature
Script:
const geojson = require('./input.json');
geojson.features = geojson.features.map(f => ({
type: 'Feature',
properties: {
id: f.properties.id,
name: f.properties.name
},
geometry: f.geometry
}));
fs.writeFileSync('output.json', JSON.stringify(geojson));---
4. Vector Tiles
Problem: Loading entire dataset at once.
Solution: Split into zoom-dependent tiles.
Benefits:
- Only load visible area
- Progressive loading
- Smaller payloads
Tile Pyramid:
z0: 1 tile (whole world)
z5: 1,024 tiles
z10: 1,048,576 tiles
z14: 268,435,456 tilesGenerate with Tippecanoe:
npm install -g @mapbox/tippecanoe
# Generate tiles
tippecanoe -o output.mbtiles -zg --drop-densest-as-needed input.geojson
# Options:
# -zg: Auto-detect max zoom
# --drop-densest-as-needed: Thin out features at low zoom
# -Z<minzoom> -z<maxzoom>: Set zoom rangeServe tiles:
// Express endpoint
app.get('/tiles/:z/:x/:y.pbf', async (req, res) => {
const { z, x, y } = req.params;
// Query PostGIS for tile
const tile = await db.query(
'SELECT ST_AsMVT(q, $1) FROM (...) q',
['layer']
);
res.set('Content-Type', 'application/x-protobuf');
res.send(tile.rows[0].st_asmvt);
});Client-side:
// Mapbox GL JS
map.addSource('tiles', {
type: 'vector',
tiles: ['https://api.example.com/tiles/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14
});---
5. Feature Chunking
Problem: Single GeoJSON file with 10,000 features.
Solution: Split into multiple files.
Strategy 1: Spatial chunking (by bounds)
// Split by bounding box grid
const chunks = [
{ name: 'nw', bounds: [-180, 0, -90, 90] },
{ name: 'ne', bounds: [-90, 0, 0, 90] },
{ name: 'sw', bounds: [-180, -90, -90, 0] },
{ name: 'se', bounds: [-90, -90, 0, 0] }
];
chunks.forEach(chunk => {
const features = allFeatures.filter(f =>
featureIntersectsBounds(f, chunk.bounds)
);
fs.writeFileSync(
`${chunk.name}.geojson`,
JSON.stringify({ type: 'FeatureCollection', features })
);
});Strategy 2: Zoom-based chunking
// High-detail features for high zoom only
const detailedFeatures = features.filter(f => getComplexity(f) > 100);
const simpleFeatures = features.filter(f => getComplexity(f) <= 100);
// Save separately
fs.writeFileSync('simple.geojson', JSON.stringify({ type: 'FeatureCollection', features: simpleFeatures }));
fs.writeFileSync('detailed.geojson', JSON.stringify({ type: 'FeatureCollection', features: detailedFeatures }));
// Load conditionally
if (map.getZoom() > 12) {
loadDetailed();
} else {
loadSimple();
}---
6. Compression
Gzip Compression:
# Compress
gzip -k input.geojson # Creates input.geojson.gz
# Server: nginx auto-gzip
gzip on;
gzip_types application/json;Brotli Compression (better than gzip):
# Install
npm install -g brotli-cli
# Compress
brotli input.geojson
# Server: nginx brotli module
brotli on;
brotli_types application/json;Comparison:
| Method | 10MB GeoJSON |
|---|---|
| Uncompressed | 10 MB |
| Gzip | 2.5 MB (75% reduction) |
| Brotli | 2 MB (80% reduction) |
---
7. TopoJSON
Problem: Adjacent polygons share borders = duplicate coordinates.
Solution: TopoJSON encodes topology once.
npm install -g topojson
# Convert GeoJSON → TopoJSON
geo2topo input.geojson > output.topojson
# Simplify topology
toposimplify -s 0.001 output.topojson > simplified.topojsonSavings: 30-80% file size reduction (depends on shared borders)
Use when:
- Choropleth maps (countries, states, counties)
- Adjacent polygons with shared boundaries
- NOT useful for scattered points
Client-side:
import { feature } from 'topojson-client';
// Load TopoJSON
const topology = await fetch('/data.topojson').then(r => r.json());
// Convert to GeoJSON
const geojson = feature(topology, topology.objects.layer);
// Add to map
map.addSource('data', { type: 'geojson', data: geojson });---
Optimization Workflow
Step 1: Analyze
# Check file size
ls -lh input.geojson
# Count features
jq '.features | length' input.geojson
# Check coordinate precision
jq '.features[0].geometry.coordinates' input.geojsonStep 2: Reduce Precision
geojson-precision -p 5 input.geojson temp1.geojsonStep 3: Simplify Geometry
npx @mapbox/geojson-precision -t 0.001 temp1.geojson temp2.geojsonStep 4: Remove Properties
const data = require('./temp2.geojson');
data.features = data.features.map(f => ({
type: 'Feature',
properties: { id: f.properties.id, name: f.properties.name },
geometry: f.geometry
}));
fs.writeFileSync('temp3.geojson', JSON.stringify(data));Step 5: Compress
gzip -k temp3.geojsonStep 6: Measure
ls -lh input.geojson input.geojson.gz temp3.geojson temp3.geojson.gz---
Performance Benchmarks
Test: 50MB GeoJSON with 10,000 polygons
| Optimization | File Size | Load Time |
|---|---|---|
| Original | 50 MB | 12 seconds |
| Precision (5 decimals) | 30 MB | 8 seconds |
| + Simplify (0.001) | 10 MB | 3 seconds |
| + Remove properties | 5 MB | 1.5 seconds |
| + Gzip | 1.5 MB | 0.5 seconds |
| Vector tiles | 50 KB per tile | <100ms |
Winner: Vector tiles (100x improvement)
---
When to Use Each Strategy
| Strategy | Use When | Savings |
|---|---|---|
| Reduce precision | Always | 30-40% |
| Simplify geometry | Rendering at low zoom | 40-70% |
| Remove properties | Properties not displayed | 20-80% |
| Vector tiles | >1000 features or >5MB | 90%+ |
| Chunking | Mixed complexity features | Varies |
| Compression | Always (server-side) | 70-80% |
| TopoJSON | Adjacent polygons | 30-80% |
---
Checklist
□ Reduce coordinate precision to 5 decimals
□ Simplify geometry for features >100 points
□ Remove unused properties from features
□ Enable gzip/brotli compression on server
□ Consider vector tiles for files >5MB
□ Use TopoJSON for adjacent polygons
□ Split large files into zoom-based chunks
□ Test load time on slow connection (3G)---
Resources
PostGIS Guide
Complete guide to setting up PostGIS, creating spatial indexes, and writing efficient spatial queries.
Installation
macOS (Homebrew)
# Install PostgreSQL with PostGIS
brew install postgresql postgis
# Start PostgreSQL
brew services start postgresql
# Create database
createdb myapp_dev
# Enable PostGIS extension
psql myapp_dev -c "CREATE EXTENSION postgis;"
psql myapp_dev -c "CREATE EXTENSION postgis_topology;" # Optional
# Verify
psql myapp_dev -c "SELECT PostGIS_version();"Ubuntu/Debian
# Install
sudo apt-get update
sudo apt-get install postgresql postgresql-contrib postgis
# Enable extension
sudo -u postgres psql -d myapp_dev -c "CREATE EXTENSION postgis;"Docker
# Official PostGIS image
docker run --name postgis -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgis/postgis
# Connect
psql -h localhost -U postgres---
Data Types
GEOMETRY vs GEOGRAPHY
| Type | Units | Use When |
|---|---|---|
GEOMETRY | Cartesian (meters/degrees) | Projected data, local areas |
GEOGRAPHY | Spheroid (meters) | Global data, accurate distances |
GEOMETRY (projected, flat Earth):
CREATE TABLE locations_geometry (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
location GEOMETRY(POINT, 4326) -- Stores as degrees, treats as flat
);
-- Distance in "degree units" (NOT meters!)
SELECT ST_Distance(
ST_MakePoint(-122.4194, 37.7749),
ST_MakePoint(-122.4294, 37.7849)
);
-- Returns: 0.0141 (degrees, meaningless for distance)GEOGRAPHY (spherical, curved Earth):
CREATE TABLE locations_geography (
id SERIAL PRIMARY KEY,
name VARCHAR(255),
location GEOGRAPHY(POINT, 4326) -- Treats Earth as sphere
);
-- Distance in meters (accurate!)
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
ST_SetSRID(ST_MakePoint(-122.4294, 37.7849), 4326)::geography
);
-- Returns: 1568.5 (meters) ✅Rule of thumb:
- Use
GEOGRAPHYfor WGS84 data (GPS coordinates) - Use
GEOMETRYfor projected data (UTM, Web Mercator)
---
Geometry Types
-- Point
GEOMETRY(POINT, 4326)
-- Example: ST_MakePoint(-122.4194, 37.7749)
-- LineString
GEOMETRY(LINESTRING, 4326)
-- Example: ST_MakeLine(ARRAY[point1, point2, point3])
-- Polygon
GEOMETRY(POLYGON, 4326)
-- Example: ST_MakePolygon(ST_MakeLine(ARRAY[p1, p2, p3, p4, p1]))
-- MultiPoint
GEOMETRY(MULTIPOINT, 4326)
-- MultiLineString (e.g., roads network)
GEOMETRY(MULTILINESTRING, 4326)
-- MultiPolygon (e.g., islands)
GEOMETRY(MULTIPOLYGON, 4326)
-- GeometryCollection (mixed types)
GEOMETRY(GEOMETRYCOLLECTION, 4326)---
Creating Spatial Data
From Lat/Lon
-- Single point
INSERT INTO locations (name, location)
VALUES ('San Francisco', ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326));
-- From coordinates table
UPDATE locations
SET location = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)
WHERE location IS NULL;From GeoJSON
-- Insert GeoJSON feature
INSERT INTO locations (name, location)
VALUES (
'Golden Gate Park',
ST_SetSRID(
ST_GeomFromGeoJSON('{"type":"Point","coordinates":[-122.4862,37.7694]}'),
4326
)
);
-- Bulk import from GeoJSON file
\copy locations(name, location)
FROM PROGRAM 'cat features.geojson | jq -r ''.features[] | [.properties.name, .geometry | tostring] | @csv'''
WITH CSV DELIMITER ',';From WKT (Well-Known Text)
INSERT INTO locations (name, location)
VALUES (
'Line',
ST_SetSRID(
ST_GeomFromText('LINESTRING(-122.4194 37.7749, -122.4294 37.7849)'),
4326
)
);---
Spatial Indexes
CRITICAL: Without indexes, spatial queries are O(N) sequential scans.
GiST Index (Most Common)
-- Create table
CREATE TABLE drone_images (
id SERIAL PRIMARY KEY,
image_url VARCHAR(255),
location GEOGRAPHY(POINT, 4326),
captured_at TIMESTAMP
);
-- GiST index for spatial queries
CREATE INDEX idx_drone_images_location ON drone_images USING GIST(location);
-- Analyze table (update statistics)
ANALYZE drone_images;Performance impact:
- 10M points, 5km radius query
- Without index: 3.2 seconds (seq scan)
- With GiST index: 12ms (99.6% faster)
SP-GiST Index (Space-Partitioning)
-- Better for point data with high cardinality
CREATE INDEX idx_locations_spgist ON locations USING SPGIST(location);When to use:
- GiST: General-purpose, works for all geometry types
- SP-GiST: Optimized for points, faster for kNN queries
---
Common Spatial Queries
1. Find Nearby (Within Radius)
-- Find all locations within 5km of point
SELECT id, name, ST_AsGeoJSON(location) as geojson
FROM locations
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
5000 -- 5000 meters = 5km
)
ORDER BY location <-> ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography
LIMIT 10;Key functions:
ST_DWithin: Returns true if distance <= threshold<->: Distance operator (for ORDER BY)
---
2. Calculate Distance
-- Distance between two points (meters)
SELECT ST_Distance(
(SELECT location FROM locations WHERE id = 1)::geography,
(SELECT location FROM locations WHERE id = 2)::geography
) AS distance_meters;
-- Distance from all locations to a point
SELECT
id,
name,
ST_Distance(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography
) / 1000 AS distance_km
FROM locations
ORDER BY distance_km
LIMIT 10;---
3. Point in Polygon
-- Check if point is inside polygon
SELECT ST_Within(
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326),
(SELECT boundary FROM jurisdictions WHERE name = 'San Francisco')
) AS is_inside;
-- Find all points inside a polygon
SELECT p.id, p.name
FROM points p
WHERE ST_Within(
p.location,
(SELECT boundary FROM jurisdictions WHERE name = 'California')
);---
4. Bounding Box Query
-- Find all points in bounding box
SELECT id, name, ST_AsGeoJSON(location) as geojson
FROM locations
WHERE ST_Intersects(
location,
ST_MakeEnvelope(
-122.5194, 37.7049, -- west, south
-122.3194, 37.8449, -- east, north
4326
)
);Optimization: Bounding box queries use spatial index efficiently.
---
5. Nearest Neighbor (kNN)
-- Find 10 nearest locations
SELECT id, name, ST_AsGeoJSON(location) as geojson
FROM locations
ORDER BY location <-> ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography
LIMIT 10;Note: <-> operator uses index for fast kNN search.
---
6. Clustering (Group by Proximity)
-- Group locations within 1km of each other
SELECT
ST_ClusterDBSCAN(location, eps := 1000, minpoints := 2) OVER () AS cluster_id,
id,
name,
ST_AsGeoJSON(location) as geojson
FROM locations;---
Output Formats
GeoJSON
-- Single feature
SELECT ST_AsGeoJSON(location) FROM locations WHERE id = 1;
-- Feature with properties
SELECT json_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(location)::json,
'properties', json_build_object('name', name, 'id', id)
) FROM locations WHERE id = 1;
-- FeatureCollection
SELECT json_build_object(
'type', 'FeatureCollection',
'features', json_agg(
json_build_object(
'type', 'Feature',
'geometry', ST_AsGeoJSON(location)::json,
'properties', json_build_object('name', name, 'id', id)
)
)
) FROM locations;WKT (Well-Known Text)
SELECT ST_AsText(location) FROM locations WHERE id = 1;
-- Returns: POINT(-122.4194 37.7749)Coordinates
-- Longitude
SELECT ST_X(location) FROM locations WHERE id = 1;
-- Latitude
SELECT ST_Y(location) FROM locations WHERE id = 1;---
Performance Optimization
Use Bounding Box Before Expensive Operations
-- ❌ Slow: ST_Distance on all rows
SELECT id, name
FROM locations
WHERE ST_Distance(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography
) < 5000;
-- ✅ Fast: Bounding box pre-filter + ST_Distance
SELECT id, name
FROM locations
WHERE ST_DWithin(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography,
5000
)
AND ST_Distance(
location,
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography
) < 5000;Why: ST_DWithin uses bounding box from index, then ST_Distance refines.
---
Simplify Geometries
-- Simplify polygon (reduce points)
SELECT ST_Simplify(geometry, 0.001) FROM complex_polygons;
-- Simplify preserving topology
SELECT ST_SimplifyPreserveTopology(geometry, 0.001) FROM complex_polygons;Use when: Rendering at low zoom levels (don't need full detail).
---
Avoid GEOGRAPHY for Local Queries
-- ❌ Slow: GEOGRAPHY on local data
CREATE TABLE local_points (
location GEOGRAPHY(POINT, 4326)
);
-- ✅ Fast: GEOMETRY in local UTM projection
CREATE TABLE local_points (
location GEOMETRY(POINT, 32610) -- UTM 10N
);Why: GEOGRAPHY calculations are slower (spheroid math). For local areas, projected coordinates are faster and accurate.
---
Vector Tile Generation
Mapbox Vector Tiles (MVT)
-- Generate tile at z/x/y
SELECT ST_AsMVT(q, 'layer', 4096, 'geom') AS mvt
FROM (
SELECT
id,
name,
ST_AsMVTGeom(
ST_Transform(location, 3857), -- Transform to Web Mercator
ST_TileEnvelope(:z, :x, :y),
4096, -- Tile extent
256 -- Buffer
) AS geom
FROM locations
WHERE ST_Intersects(
location,
ST_Transform(ST_TileEnvelope(:z, :x, :y), 4326)
)
) AS q;Use in Express:
app.get('/tiles/:z/:x/:y.mvt', async (req, res) => {
const { z, x, y } = req.params;
const result = await db.query(
'SELECT ST_AsMVT(q, $1, 4096, $2) AS mvt FROM ...',
['layer', 'geom']
);
res.set('Content-Type', 'application/x-protobuf');
res.send(result.rows[0].mvt);
});---
Common Pitfalls
1. Forgetting to Set SRID
-- ❌ WRONG - no SRID
INSERT INTO locations (location) VALUES (ST_MakePoint(-122.4194, 37.7749));
-- ✅ CORRECT
INSERT INTO locations (location) VALUES (
ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)
);2. Mixing GEOMETRY and GEOGRAPHY
-- ❌ WRONG - can't compare GEOMETRY with GEOGRAPHY
SELECT ST_Distance(geometry_col, geography_col);
-- ✅ CORRECT - cast to same type
SELECT ST_Distance(geometry_col::geography, geography_col);3. Using ST_Distance Without Index
-- ❌ SLOW - ST_Distance doesn't use index
SELECT * FROM locations
WHERE ST_Distance(location, point) < 5000;
-- ✅ FAST - ST_DWithin uses index
SELECT * FROM locations
WHERE ST_DWithin(location, point, 5000);---
Troubleshooting
Check if Extension is Enabled
SELECT * FROM pg_extension WHERE extname = 'postgis';Verify SRID
SELECT ST_SRID(location) FROM locations LIMIT 1;
-- Should return 4326 for WGS84Explain Query Plan
EXPLAIN ANALYZE
SELECT * FROM locations
WHERE ST_DWithin(location, ST_SetSRID(ST_MakePoint(-122.4194, 37.7749), 4326)::geography, 5000);Look for: "Index Scan using idx_..." (good) vs "Seq Scan" (bad)
---
Resources
#!/usr/bin/env node
/**
* Geospatial Data Processor
*
* Process drone imagery, GPS tracks, and GeoJSON files for analysis and visualization.
*
* Usage:
* npx tsx geospatial_processor.ts validate <file.geojson>
* npx tsx geospatial_processor.ts simplify <input.geojson> <output.geojson> <tolerance>
* npx tsx geospatial_processor.ts bbox <file.geojson>
* npx tsx geospatial_processor.ts analyze-drone <directory>
* npx tsx geospatial_processor.ts gps-stats <track.geojson>
*
* Examples:
* npx tsx geospatial_processor.ts validate survey-data.geojson
* npx tsx geospatial_processor.ts simplify detailed.geojson simple.geojson 0.001
* npx tsx geospatial_processor.ts bbox survey-area.geojson
*/
import * as fs from 'fs';
import * as path from 'path';
interface GeoJSONFeature {
type: 'Feature';
geometry: {
type: string;
coordinates: any;
};
properties: Record<string, any>;
}
interface GeoJSONFeatureCollection {
type: 'FeatureCollection';
features: GeoJSONFeature[];
}
interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
stats: {
featureCount: number;
geometryTypes: Record<string, number>;
totalPoints: number;
boundingBox: number[];
};
}
interface GPSTrackStats {
totalDistance: number; // meters
duration: number; // seconds
avgSpeed: number; // m/s
maxSpeed: number; // m/s
elevation: {
min: number;
max: number;
gain: number;
loss: number;
};
bounds: number[]; // [minLon, minLat, maxLon, maxLat]
}
class GeospatialProcessor {
/**
* Validate GeoJSON structure and geometry
*/
validate(filePath: string): ValidationResult {
const content = fs.readFileSync(filePath, 'utf-8');
let geojson: GeoJSONFeatureCollection;
const errors: string[] = [];
const warnings: string[] = [];
try {
geojson = JSON.parse(content);
} catch (e) {
return {
valid: false,
errors: ['Invalid JSON: ' + (e as Error).message],
warnings: [],
stats: { featureCount: 0, geometryTypes: {}, totalPoints: 0, boundingBox: [] }
};
}
// Check structure
if (geojson.type !== 'FeatureCollection') {
errors.push('Root type must be "FeatureCollection"');
}
if (!Array.isArray(geojson.features)) {
errors.push('Missing or invalid "features" array');
return {
valid: false,
errors,
warnings,
stats: { featureCount: 0, geometryTypes: {}, totalPoints: 0, boundingBox: [] }
};
}
// Validate features
const geometryTypes: Record<string, number> = {};
let totalPoints = 0;
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
geojson.features.forEach((feature, i) => {
// Check feature structure
if (!feature.type || feature.type !== 'Feature') {
errors.push(`Feature ${i}: Missing or invalid "type"`);
}
if (!feature.geometry) {
errors.push(`Feature ${i}: Missing "geometry"`);
return;
}
const geomType = feature.geometry.type;
geometryTypes[geomType] = (geometryTypes[geomType] || 0) + 1;
// Validate coordinates
const points = this.extractPoints(feature.geometry.coordinates);
totalPoints += points.length;
points.forEach((point, j) => {
const [lon, lat] = point;
// Validate coordinate ranges
if (lon < -180 || lon > 180) {
errors.push(`Feature ${i}, point ${j}: Longitude out of range: ${lon}`);
}
if (lat < -90 || lat > 90) {
errors.push(`Feature ${i}, point ${j}: Latitude out of range: ${lat}`);
}
// Update bounding box
minLon = Math.min(minLon, lon);
minLat = Math.min(minLat, lat);
maxLon = Math.max(maxLon, lon);
maxLat = Math.max(maxLat, lat);
});
// Warn about large features
if (points.length > 10000) {
warnings.push(`Feature ${i}: Very large feature (${points.length} points). Consider simplifying.`);
}
});
// Warn about file size
const fileSizeKB = fs.statSync(filePath).size / 1024;
if (fileSizeKB > 1000) {
warnings.push(`File size is ${fileSizeKB.toFixed(0)}KB. Consider splitting or serving as tiles.`);
}
return {
valid: errors.length === 0,
errors,
warnings,
stats: {
featureCount: geojson.features.length,
geometryTypes,
totalPoints,
boundingBox: [minLon, minLat, maxLon, maxLat]
}
};
}
/**
* Simplify GeoJSON geometry using Douglas-Peucker algorithm
*/
simplify(inputPath: string, outputPath: string, tolerance: number): void {
const content = fs.readFileSync(inputPath, 'utf-8');
const geojson: GeoJSONFeatureCollection = JSON.parse(content);
let totalPointsBefore = 0;
let totalPointsAfter = 0;
geojson.features = geojson.features.map(feature => {
const pointsBefore = this.countPoints(feature.geometry.coordinates);
totalPointsBefore += pointsBefore;
feature.geometry.coordinates = this.simplifyCoordinates(
feature.geometry.coordinates,
feature.geometry.type,
tolerance
);
const pointsAfter = this.countPoints(feature.geometry.coordinates);
totalPointsAfter += pointsAfter;
return feature;
});
fs.writeFileSync(outputPath, JSON.stringify(geojson, null, 2));
console.log(`\n✅ Simplified GeoJSON`);
console.log(` Points before: ${totalPointsBefore}`);
console.log(` Points after: ${totalPointsAfter}`);
console.log(` Reduction: ${((1 - totalPointsAfter / totalPointsBefore) * 100).toFixed(1)}%\n`);
}
/**
* Douglas-Peucker line simplification
*/
private douglasPeucker(points: number[][], tolerance: number): number[][] {
if (points.length <= 2) return points;
const firstPoint = points[0];
const lastPoint = points[points.length - 1];
let maxDistance = 0;
let maxIndex = 0;
for (let i = 1; i < points.length - 1; i++) {
const distance = this.perpendicularDistance(points[i], firstPoint, lastPoint);
if (distance > maxDistance) {
maxDistance = distance;
maxIndex = i;
}
}
if (maxDistance > tolerance) {
const left = this.douglasPeucker(points.slice(0, maxIndex + 1), tolerance);
const right = this.douglasPeucker(points.slice(maxIndex), tolerance);
return [...left.slice(0, -1), ...right];
} else {
return [firstPoint, lastPoint];
}
}
/**
* Calculate perpendicular distance from point to line
*/
private perpendicularDistance(point: number[], lineStart: number[], lineEnd: number[]): number {
const [px, py] = point;
const [x1, y1] = lineStart;
const [x2, y2] = lineEnd;
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) {
param = dot / lenSq;
}
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Simplify coordinates based on geometry type
*/
private simplifyCoordinates(coords: any, geomType: string, tolerance: number): any {
switch (geomType) {
case 'LineString':
return this.douglasPeucker(coords, tolerance);
case 'Polygon':
return coords.map((ring: number[][]) => this.douglasPeucker(ring, tolerance));
case 'MultiLineString':
return coords.map((line: number[][]) => this.douglasPeucker(line, tolerance));
case 'MultiPolygon':
return coords.map((polygon: number[][][]) =>
polygon.map((ring: number[][]) => this.douglasPeucker(ring, tolerance))
);
default:
return coords; // Point, MultiPoint (no simplification)
}
}
/**
* Calculate bounding box
*/
calculateBoundingBox(filePath: string): number[] {
const validation = this.validate(filePath);
return validation.stats.boundingBox;
}
/**
* Analyze drone imagery directory
*/
analyzeDroneDirectory(dirPath: string): void {
const files = fs.readdirSync(dirPath)
.filter(f => f.endsWith('.geojson') || f.endsWith('.json'));
console.log(`\n📂 Analyzing drone data directory: ${dirPath}\n`);
console.log(`Found ${files.length} GeoJSON files:\n`);
let totalFeatures = 0;
let totalImages = 0;
files.forEach(file => {
const fullPath = path.join(dirPath, file);
const content = fs.readFileSync(fullPath, 'utf-8');
const geojson: GeoJSONFeatureCollection = JSON.parse(content);
const images = geojson.features.filter(f =>
f.properties && (f.properties.image_url || f.properties.thumbnail)
);
totalFeatures += geojson.features.length;
totalImages += images.length;
console.log(` ${file}`);
console.log(` Features: ${geojson.features.length}`);
console.log(` Images: ${images.length}`);
if (images.length > 0) {
const bounds = this.calculateBounds(geojson.features);
console.log(` Bounds: [${bounds.map(n => n.toFixed(4)).join(', ')}]`);
}
console.log('');
});
console.log(`\n📊 Summary:`);
console.log(` Total features: ${totalFeatures}`);
console.log(` Total images: ${totalImages}\n`);
}
/**
* Analyze GPS track statistics
*/
analyzeGPSTrack(filePath: string): GPSTrackStats {
const content = fs.readFileSync(filePath, 'utf-8');
const geojson: GeoJSONFeatureCollection = JSON.parse(content);
// Assume LineString geometry with coordinates as [lon, lat, elevation?, timestamp?]
const track = geojson.features.find(f => f.geometry.type === 'LineString');
if (!track) {
throw new Error('No LineString feature found in GPS track');
}
const coords = track.geometry.coordinates;
let totalDistance = 0;
let elevationGain = 0;
let elevationLoss = 0;
let minElevation = Infinity;
let maxElevation = -Infinity;
let maxSpeed = 0;
const speeds: number[] = [];
for (let i = 1; i < coords.length; i++) {
const [lon1, lat1, elev1, time1] = coords[i - 1];
const [lon2, lat2, elev2, time2] = coords[i];
// Calculate distance
const distance = this.haversineDistance(lat1, lon1, lat2, lon2);
totalDistance += distance;
// Calculate speed (if timestamps available)
if (time1 && time2) {
const timeDelta = time2 - time1; // seconds
const speed = distance / timeDelta; // m/s
speeds.push(speed);
maxSpeed = Math.max(maxSpeed, speed);
}
// Elevation stats
if (elev1 !== undefined && elev2 !== undefined) {
minElevation = Math.min(minElevation, elev1, elev2);
maxElevation = Math.max(maxElevation, elev1, elev2);
const elevChange = elev2 - elev1;
if (elevChange > 0) {
elevationGain += elevChange;
} else {
elevationLoss += Math.abs(elevChange);
}
}
}
const avgSpeed = speeds.length > 0
? speeds.reduce((sum, s) => sum + s, 0) / speeds.length
: 0;
const duration = coords.length > 0 && coords[0][3] && coords[coords.length - 1][3]
? coords[coords.length - 1][3] - coords[0][3]
: 0;
const bounds = this.calculateBounds([track]);
return {
totalDistance,
duration,
avgSpeed,
maxSpeed,
elevation: {
min: minElevation,
max: maxElevation,
gain: elevationGain,
loss: elevationLoss
},
bounds
};
}
/**
* Haversine distance in meters
*/
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000; // Earth radius in meters
const dLat = this.toRadians(lat2 - lat1);
const dLon = this.toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
private toRadians(degrees: number): number {
return degrees * Math.PI / 180;
}
/**
* Calculate bounds for features
*/
private calculateBounds(features: GeoJSONFeature[]): number[] {
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
features.forEach(feature => {
const points = this.extractPoints(feature.geometry.coordinates);
points.forEach(([lon, lat]) => {
minLon = Math.min(minLon, lon);
minLat = Math.min(minLat, lat);
maxLon = Math.max(maxLon, lon);
maxLat = Math.max(maxLat, lat);
});
});
return [minLon, minLat, maxLon, maxLat];
}
/**
* Extract all points from coordinates (recursive)
*/
private extractPoints(coords: any): number[][] {
if (typeof coords[0] === 'number') {
return [coords]; // Single point
}
return coords.flatMap((c: any) => this.extractPoints(c));
}
/**
* Count points in coordinates
*/
private countPoints(coords: any): number {
if (typeof coords[0] === 'number') {
return 1;
}
return coords.reduce((sum: number, c: any) => sum + this.countPoints(c), 0);
}
}
// CLI entry point
if (require.main === module) {
const args = process.argv.slice(2);
const command = args[0];
const processor = new GeospatialProcessor();
switch (command) {
case 'validate': {
if (args.length < 2) {
console.error('Usage: npx tsx geospatial_processor.ts validate <file.geojson>');
process.exit(1);
}
const result = processor.validate(args[1]);
console.log(`\n📍 GeoJSON Validation: ${args[1]}\n`);
if (result.valid) {
console.log('✅ Valid GeoJSON\n');
} else {
console.log('❌ Invalid GeoJSON\n');
console.log('Errors:');
result.errors.forEach(err => console.log(` • ${err}`));
console.log('');
}
if (result.warnings.length > 0) {
console.log('⚠️ Warnings:');
result.warnings.forEach(warn => console.log(` • ${warn}`));
console.log('');
}
console.log('📊 Statistics:');
console.log(` Features: ${result.stats.featureCount}`);
console.log(` Total points: ${result.stats.totalPoints}`);
console.log(` Geometry types: ${JSON.stringify(result.stats.geometryTypes)}`);
console.log(` Bounding box: [${result.stats.boundingBox.map(n => n.toFixed(4)).join(', ')}]\n`);
process.exit(result.valid ? 0 : 1);
}
case 'simplify': {
if (args.length < 4) {
console.error('Usage: npx tsx geospatial_processor.ts simplify <input.geojson> <output.geojson> <tolerance>');
process.exit(1);
}
processor.simplify(args[1], args[2], parseFloat(args[3]));
break;
}
case 'bbox': {
if (args.length < 2) {
console.error('Usage: npx tsx geospatial_processor.ts bbox <file.geojson>');
process.exit(1);
}
const bbox = processor.calculateBoundingBox(args[1]);
console.log(`\nBounding Box: [${bbox.map(n => n.toFixed(6)).join(', ')}]\n`);
break;
}
case 'analyze-drone': {
if (args.length < 2) {
console.error('Usage: npx tsx geospatial_processor.ts analyze-drone <directory>');
process.exit(1);
}
processor.analyzeDroneDirectory(args[1]);
break;
}
case 'gps-stats': {
if (args.length < 2) {
console.error('Usage: npx tsx geospatial_processor.ts gps-stats <track.geojson>');
process.exit(1);
}
const stats = processor.analyzeGPSTrack(args[1]);
console.log(`\n🚴 GPS Track Statistics\n`);
console.log(`Distance: ${(stats.totalDistance / 1000).toFixed(2)} km`);
console.log(`Duration: ${(stats.duration / 60).toFixed(0)} minutes`);
console.log(`Avg Speed: ${(stats.avgSpeed * 3.6).toFixed(1)} km/h`);
console.log(`Max Speed: ${(stats.maxSpeed * 3.6).toFixed(1)} km/h`);
console.log(`Elevation:`);
console.log(` Min: ${stats.elevation.min.toFixed(0)} m`);
console.log(` Max: ${stats.elevation.max.toFixed(0)} m`);
console.log(` Gain: ${stats.elevation.gain.toFixed(0)} m`);
console.log(` Loss: ${stats.elevation.loss.toFixed(0)} m`);
console.log(`Bounds: [${stats.bounds.map(n => n.toFixed(4)).join(', ')}]\n`);
break;
}
default:
console.error('Unknown command. Available commands:');
console.error(' validate <file.geojson>');
console.error(' simplify <input.geojson> <output.geojson> <tolerance>');
console.error(' bbox <file.geojson>');
console.error(' analyze-drone <directory>');
console.error(' gps-stats <track.geojson>');
process.exit(1);
}
}
export { GeospatialProcessor, ValidationResult, GPSTrackStats };
#!/usr/bin/env node
/**
* Vector Tile Generator
*
* Convert GeoJSON files into vector tiles (MBTiles format) for efficient web/mobile map rendering.
*
* Usage:
* npx tsx tile_generator.ts generate <input.geojson> <output.mbtiles> [options]
* npx tsx tile_generator.ts info <tiles.mbtiles>
*
* Options:
* --minzoom <z> Minimum zoom level (default: 0)
* --maxzoom <z> Maximum zoom level (default: 14)
* --name <name> Tileset name
* --attribution <text> Attribution text
*
* Examples:
* npx tsx tile_generator.ts generate survey.geojson tiles.mbtiles --minzoom 10 --maxzoom 18
* npx tsx tile_generator.ts info tiles.mbtiles
*/
import * as fs from 'fs';
import * as path from 'path';
interface Tile {
z: number; // Zoom level
x: number; // Tile X coordinate
y: number; // Tile Y coordinate
features: GeoJSONFeature[];
}
interface GeoJSONFeature {
type: 'Feature';
geometry: {
type: string;
coordinates: any;
};
properties: Record<string, any>;
}
interface GeoJSONFeatureCollection {
type: 'FeatureCollection';
features: GeoJSONFeature[];
}
interface TileGeneratorOptions {
minZoom: number;
maxZoom: number;
name?: string;
attribution?: string;
bufferSize?: number; // Tile buffer in pixels
}
interface TilesetMetadata {
name: string;
description?: string;
version: string;
attribution?: string;
type: 'overlay' | 'baselayer';
format: 'pbf' | 'geojson';
minzoom: number;
maxzoom: number;
bounds: number[]; // [west, south, east, north]
center: number[]; // [lon, lat, zoom]
}
class TileGenerator {
private options: TileGeneratorOptions;
constructor(options: TileGeneratorOptions) {
this.options = options;
}
/**
* Generate tiles from GeoJSON
*/
async generate(inputPath: string, outputPath: string): Promise<void> {
console.log(`\n🗺️ Generating vector tiles...\n`);
console.log(`Input: ${inputPath}`);
console.log(`Output: ${outputPath}`);
console.log(`Zoom: ${this.options.minZoom} - ${this.options.maxZoom}\n`);
// Read GeoJSON
const content = fs.readFileSync(inputPath, 'utf-8');
const geojson: GeoJSONFeatureCollection = JSON.parse(content);
console.log(`Loaded ${geojson.features.length} features\n`);
// Calculate bounds
const bounds = this.calculateBounds(geojson.features);
console.log(`Bounds: [${bounds.map(n => n.toFixed(4)).join(', ')}]`);
// Generate tiles for each zoom level
const allTiles: Tile[] = [];
for (let z = this.options.minZoom; z <= this.options.maxZoom; z++) {
console.log(`\nGenerating zoom level ${z}...`);
const tiles = this.generateTilesForZoom(geojson.features, z, bounds);
allTiles.push(...tiles);
console.log(` Created ${tiles.length} tiles`);
}
console.log(`\n✅ Generated ${allTiles.length} total tiles`);
// Write to MBTiles (simplified - in production use better-sqlite3 or similar)
this.writeMBTiles(outputPath, allTiles, bounds);
console.log(`\n📦 Tileset written to ${outputPath}\n`);
}
/**
* Generate tiles for a specific zoom level
*/
private generateTilesForZoom(
features: GeoJSONFeature[],
z: number,
bounds: number[]
): Tile[] {
const tiles: Map<string, Tile> = new Map();
features.forEach(feature => {
// Get all tile coordinates this feature intersects
const tileCoords = this.getIntersectingTiles(feature, z, bounds);
tileCoords.forEach(({ x, y }) => {
const key = `${z}/${x}/${y}`;
if (!tiles.has(key)) {
tiles.set(key, { z, x, y, features: [] });
}
// Clip feature to tile bounds (simplified - in production use proper clipping)
const clipped = this.clipFeatureToTile(feature, z, x, y);
if (clipped) {
tiles.get(key)!.features.push(clipped);
}
});
});
return Array.from(tiles.values());
}
/**
* Get tile coordinates that intersect with feature
*/
private getIntersectingTiles(
feature: GeoJSONFeature,
z: number,
bounds: number[]
): Array<{ x: number; y: number }> {
const coords: Array<{ x: number; y: number }> = [];
// Get feature bounds
const [minLon, minLat, maxLon, maxLat] = this.getFeatureBounds(feature);
// Convert to tile coordinates
const minTile = this.lonLatToTile(minLon, maxLat, z); // maxLat because Y is inverted
const maxTile = this.lonLatToTile(maxLon, minLat, z);
// Get all tiles in bounding box
for (let x = minTile.x; x <= maxTile.x; x++) {
for (let y = minTile.y; y <= maxTile.y; y++) {
coords.push({ x, y });
}
}
return coords;
}
/**
* Convert lon/lat to tile coordinates
*/
private lonLatToTile(lon: number, lat: number, z: number): { x: number; y: number } {
const n = Math.pow(2, z);
const x = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
return {
x: Math.max(0, Math.min(n - 1, x)),
y: Math.max(0, Math.min(n - 1, y))
};
}
/**
* Convert tile coordinates to lon/lat bounds
*/
private tileToBounds(z: number, x: number, y: number): number[] {
const n = Math.pow(2, z);
const west = x / n * 360 - 180;
const east = (x + 1) / n * 360 - 180;
const northLat = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
const southLat = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n))) * 180 / Math.PI;
return [west, southLat, east, northLat];
}
/**
* Clip feature to tile bounds (simplified)
*/
private clipFeatureToTile(
feature: GeoJSONFeature,
z: number,
x: number,
y: number
): GeoJSONFeature | null {
const tileBounds = this.tileToBounds(z, x, y);
// Check if feature intersects tile
const featureBounds = this.getFeatureBounds(feature);
if (!this.boundsIntersect(featureBounds, tileBounds)) {
return null;
}
// For simplicity, return entire feature
// In production, implement proper geometry clipping
return feature;
}
/**
* Check if two bounding boxes intersect
*/
private boundsIntersect(bounds1: number[], bounds2: number[]): boolean {
const [w1, s1, e1, n1] = bounds1;
const [w2, s2, e2, n2] = bounds2;
return !(e1 < w2 || e2 < w1 || n1 < s2 || n2 < s1);
}
/**
* Get bounding box for a feature
*/
private getFeatureBounds(feature: GeoJSONFeature): number[] {
const points = this.extractPoints(feature.geometry.coordinates);
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
points.forEach(([lon, lat]) => {
minLon = Math.min(minLon, lon);
minLat = Math.min(minLat, lat);
maxLon = Math.max(maxLon, lon);
maxLat = Math.max(maxLat, lat);
});
return [minLon, minLat, maxLon, maxLat];
}
/**
* Calculate bounds for all features
*/
private calculateBounds(features: GeoJSONFeature[]): number[] {
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
features.forEach(feature => {
const [w, s, e, n] = this.getFeatureBounds(feature);
minLon = Math.min(minLon, w);
minLat = Math.min(minLat, s);
maxLon = Math.max(maxLon, e);
maxLat = Math.max(maxLat, n);
});
return [minLon, minLat, maxLon, maxLat];
}
/**
* Extract all points from coordinates (recursive)
*/
private extractPoints(coords: any): number[][] {
if (typeof coords[0] === 'number') {
return [coords];
}
return coords.flatMap((c: any) => this.extractPoints(c));
}
/**
* Write tiles to MBTiles format (simplified)
*/
private writeMBTiles(outputPath: string, tiles: Tile[], bounds: number[]): void {
const metadata: TilesetMetadata = {
name: this.options.name || path.basename(outputPath, '.mbtiles'),
version: '1.0.0',
attribution: this.options.attribution,
type: 'overlay',
format: 'geojson',
minzoom: this.options.minZoom,
maxzoom: this.options.maxZoom,
bounds,
center: [
(bounds[0] + bounds[2]) / 2,
(bounds[1] + bounds[3]) / 2,
Math.floor((this.options.minZoom + this.options.maxZoom) / 2)
]
};
// For this example, write as directory structure
// In production, use SQLite database (better-sqlite3)
const tilesDir = outputPath.replace('.mbtiles', '_tiles');
if (!fs.existsSync(tilesDir)) {
fs.mkdirSync(tilesDir, { recursive: true });
}
// Write metadata
fs.writeFileSync(
path.join(tilesDir, 'metadata.json'),
JSON.stringify(metadata, null, 2)
);
// Write tiles
tiles.forEach(tile => {
const tileDir = path.join(tilesDir, `${tile.z}`, `${tile.x}`);
if (!fs.existsSync(tileDir)) {
fs.mkdirSync(tileDir, { recursive: true });
}
const tilePath = path.join(tileDir, `${tile.y}.geojson`);
const featureCollection: GeoJSONFeatureCollection = {
type: 'FeatureCollection',
features: tile.features
};
fs.writeFileSync(tilePath, JSON.stringify(featureCollection));
});
console.log(`\nTiles written to ${tilesDir}/`);
}
/**
* Display tileset info
*/
static info(mbtilesPath: string): void {
const tilesDir = mbtilesPath.replace('.mbtiles', '_tiles');
if (!fs.existsSync(tilesDir)) {
console.error(`Tileset not found: ${tilesDir}`);
process.exit(1);
}
const metadataPath = path.join(tilesDir, 'metadata.json');
const metadata: TilesetMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
console.log(`\n📦 Tileset Info\n`);
console.log(`Name: ${metadata.name}`);
console.log(`Version: ${metadata.version}`);
console.log(`Format: ${metadata.format}`);
console.log(`Zoom: ${metadata.minzoom} - ${metadata.maxzoom}`);
console.log(`Bounds: [${metadata.bounds.map(n => n.toFixed(4)).join(', ')}]`);
console.log(`Center: [${metadata.center.map(n => n.toFixed(4)).join(', ')}]`);
if (metadata.attribution) {
console.log(`Attribution: ${metadata.attribution}`);
}
// Count tiles
let tileCount = 0;
for (let z = metadata.minzoom; z <= metadata.maxzoom; z++) {
const zoomDir = path.join(tilesDir, `${z}`);
if (fs.existsSync(zoomDir)) {
const xDirs = fs.readdirSync(zoomDir);
xDirs.forEach(xDir => {
const yFiles = fs.readdirSync(path.join(zoomDir, xDir));
tileCount += yFiles.length;
});
}
}
console.log(`Tiles: ${tileCount}\n`);
}
}
// CLI entry point
if (require.main === module) {
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'generate': {
if (args.length < 3) {
console.error('Usage: npx tsx tile_generator.ts generate <input.geojson> <output.mbtiles> [options]');
console.error('\nOptions:');
console.error(' --minzoom <z> Minimum zoom level (default: 0)');
console.error(' --maxzoom <z> Maximum zoom level (default: 14)');
console.error(' --name <name> Tileset name');
console.error(' --attribution <text> Attribution text');
process.exit(1);
}
const inputPath = args[1];
const outputPath = args[2];
// Parse options
const options: TileGeneratorOptions = {
minZoom: 0,
maxZoom: 14
};
for (let i = 3; i < args.length; i += 2) {
const flag = args[i];
const value = args[i + 1];
switch (flag) {
case '--minzoom':
options.minZoom = parseInt(value);
break;
case '--maxzoom':
options.maxZoom = parseInt(value);
break;
case '--name':
options.name = value;
break;
case '--attribution':
options.attribution = value;
break;
}
}
const generator = new TileGenerator(options);
generator.generate(inputPath, outputPath);
break;
}
case 'info': {
if (args.length < 2) {
console.error('Usage: npx tsx tile_generator.ts info <tiles.mbtiles>');
process.exit(1);
}
TileGenerator.info(args[1]);
break;
}
default:
console.error('Unknown command. Available commands:');
console.error(' generate <input.geojson> <output.mbtiles> [options]');
console.error(' info <tiles.mbtiles>');
process.exit(1);
}
}
export { TileGenerator, TileGeneratorOptions, TilesetMetadata };