Merge pull request #14 from derekslenk/feature/mapbox-static-maps

Add Mapbox static map generation for non-JavaScript users
This commit is contained in:
Deco Vander 2025-07-06 00:37:04 -04:00 committed by GitHub
commit 10c6e54062
12 changed files with 1043 additions and 53 deletions

View file

@ -86,11 +86,12 @@ npm run test:coverage
Before running the application, you must configure environment variables:
```bash
cp .env.example .env
# Edit .env to add your MapBox token and admin password
# Edit .env to add your Mapbox token and admin password
```
Required environment variables:
- `MAPBOX_ACCESS_TOKEN`: MapBox API token for geocoding (get free token at https://account.mapbox.com/access-tokens/)
- `MAPBOX_ACCESS_TOKEN`: Mapbox API token for geocoding and static map generation (get free token at https://account.mapbox.com/access-tokens/)
- **Important**: For server-side static map generation, use an unrestricted token (no URL restrictions)
- `ADMIN_PASSWORD`: Password for admin panel access at /admin
- `PORT`: Server port (default: 3000)
@ -116,6 +117,7 @@ Routes are organized as factory functions accepting dependencies with full TypeS
- **src/models/ProfanityWord.ts**: Type-safe database operations for profanity words
- **src/services/DatabaseService.ts**: Centralized database connection management
- **src/services/ProfanityFilterService.ts**: Content moderation with type safety
- **src/services/MapImageService.ts**: Server-side static map generation using Mapbox Static Images API
- **src/types/index.ts**: Shared TypeScript interfaces and type definitions
### Database Schema
@ -136,33 +138,45 @@ CREATE TABLE locations (
**Profanity Database (`profanity.db`)**:
Managed by the `ProfanityFilter` class for content moderation.
### Frontend (Vanilla JavaScript)
Multiple map implementations for flexibility:
### Frontend (Progressive Enhancement)
The application uses progressive enhancement to work with and without JavaScript:
**JavaScript-Enhanced Experience:**
- **public/app.js**: Main implementation using Leaflet.js
- Auto-detects available geocoding services (MapBox preferred, Nominatim fallback)
- **public/app-mapbox.js**: MapBox GL JS implementation for enhanced features
- Auto-detects available geocoding services (Mapbox preferred, Nominatim fallback)
- Interactive map with real-time updates
- Autocomplete and form validation
- **public/app-mapbox.js**: Mapbox GL JS implementation for enhanced features
- **public/app-google.js**: Google Maps implementation (alternative)
- **public/admin.js**: Admin panel functionality
- Location management (view, edit, delete)
- Persistent location toggle
- Profanity word management
- **public/utils.js**: Shared utilities across implementations
**Non-JavaScript Fallback:**
- **Server-side /table route**: Complete HTML table view of all locations
- **HTML form submission**: Works via POST to /submit-report endpoint
- **Static map generation**: Auto-fitted Mapbox static images showing all report locations
- **Progressive enhancement**: noscript tags and "Basic View" button for accessibility
### API Endpoints
**Public endpoints:**
- `GET /api/config`: Returns MapBox token for frontend geocoding
- `GET /api/config`: Returns Mapbox token for frontend geocoding
- `GET /api/locations`: Active locations (< 48 hours old or persistent)
- `POST /api/locations`: Submit new location report (rate limited: 10/15min per IP)
- **Input Validation:** Address ≤500 chars, Description ≤1000 chars
- **Input Validation:** Address ≤500 chars, Description ≤1000 chars, coordinate validation
- **Profanity Filtering:** Automatic content moderation with rejection
- **Security:** Rate limiting prevents spam and DoS attacks
**Server-side routes (Progressive Enhancement):**
- `GET /`: Main application page with JavaScript-enhanced features
- `GET /table`: Non-JavaScript table view with static map and HTML forms
- `POST /submit-report`: Server-side form submission for non-JavaScript users
- `GET /map-image.png`: Dynamic static map generation using Mapbox Static Images API
- **Auto-fit positioning:** Centers on actual location coordinates
- **Numbered pins:** Color-coded markers (red=regular, orange=persistent)
- **Query parameters:** `?width=800&height=600&padding=50` for customization
**Admin endpoints (require Bearer token):**
- `POST /api/admin/login`: Authenticate and receive token
- `GET /api/admin/locations`: All locations including expired
@ -188,13 +202,15 @@ SCSS files are in `src/scss/`:
### Key Design Patterns
1. **TypeScript-First Architecture**: Full type safety with strict type checking
2. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
3. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
4. **Dual Database Design**: Separate databases for application data and content moderation
5. **Type-Safe Database Operations**: All database interactions use typed models
6. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
7. **Graceful Degradation**: Fallback geocoding providers and error handling
8. **Automated Maintenance**: Cron-based cleanup of expired reports
2. **Progressive Enhancement**: Works completely without JavaScript via server-side rendering
3. **Security-by-Design**: Rate limiting, input validation, and authentication built into core routes
4. **Modular Route Architecture**: Routes accept dependencies as parameters for testability
5. **Dual Database Design**: Separate databases for application data and content moderation
6. **Type-Safe Database Operations**: All database interactions use typed models
7. **Comprehensive Testing**: 125+ tests covering units, integration, and security scenarios
8. **Graceful Degradation**: Fallback geocoding providers and error handling
9. **Automated Maintenance**: Cron-based cleanup of expired reports
10. **Accessibility-First**: noscript fallbacks and server-side static map generation
### Deployment
- Automated deployment script for Debian 12 ARM64 in `scripts/deploy.sh`

View file

@ -5,10 +5,13 @@ A community-driven web application for tracking winter road conditions and icy h
## Features
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with Mapbox API
- 🚫 **JavaScript-Free Mode** - Complete functionality without JavaScript via server-side rendering
- 🖼️ **Static Maps** - Auto-generated Mapbox static images for non-JS users
- 🔄 **Auto-Expiration** - Reports automatically removed after 48 hours
- 👨‍💼 **Admin Panel** - Manage and moderate location reports
- 📱 **Responsive Design** - Works on desktop and mobile devices
- ♿ **Accessibility First** - Progressive enhancement with noscript fallbacks
- 🔒 **Privacy-Focused** - No user tracking, community safety oriented
- 🛡️ **Enhanced Security** - Coordinate validation, rate limiting, and content filtering
@ -16,7 +19,8 @@ A community-driven web application for tracking winter road conditions and icy h
### Prerequisites
- Node.js 18+
- MapBox API token (free tier available)
- Mapbox API token (free tier available)
- **Important**: For server-side static maps, use an unrestricted token (no URL restrictions)
### Local Development
@ -35,7 +39,7 @@ A community-driven web application for tracking winter road conditions and icy h
3. **Configure environment variables:**
```bash
cp .env.example .env
# Edit .env with your MapBox token
# Edit .env with your Mapbox token
```
4. **Start the server:**
@ -89,7 +93,7 @@ SCSS files are organized in `src/scss/`:
## Environment Variables
```bash
# Required for fast geocoding
# Required for geocoding and static map generation
MAPBOX_ACCESS_TOKEN=pk.your_mapbox_token_here
# Admin panel access
@ -99,6 +103,11 @@ ADMIN_PASSWORD=your_secure_password
PORT=3000
```
**Mapbox Token Requirements:**
- For interactive geocoding: Token can have URL restrictions
- For server-side static maps: Must use unrestricted token (no URL restrictions)
- Recommended: Use one unrestricted token for both features
## Deployment
### Automated Deployment (Debian 12 ARM64)
@ -140,6 +149,15 @@ See `docs/deployment.md` for detailed manual deployment instructions.
- `POST /api/locations` - Submit new location report (rate limited: 10/15min per IP)
- `GET /api/config` - Get API configuration
### Progressive Enhancement Routes
- `GET /` - Main application page with JavaScript-enhanced features
- `GET /table` - Non-JavaScript table view with static map and HTML forms
- `POST /submit-report` - Server-side form submission for non-JavaScript users
- `GET /map-image.png` - Dynamic static map generation using Mapbox Static Images API
- Query parameters: `?width=800&height=600&padding=50` for customization
- Auto-fit positioning centers on actual location coordinates
- Color-coded pins: red for regular reports, orange for persistent
### Admin Endpoints (Authentication Required)
- `GET /admin` - Admin panel (password protected)
- `GET /api/admin/locations` - Get all location reports
@ -157,12 +175,16 @@ Interactive API documentation available at `/api-docs` when running the server.
## Technology Stack
- **Backend:** Node.js, Express.js, SQLite, TypeScript
- **Frontend:** Vanilla JavaScript, Leaflet.js
- **Geocoding:** MapBox API (with Nominatim fallback)
- **Frontend:** Progressive Enhancement (Vanilla JavaScript + Server-side rendering)
- **Enhanced:** Leaflet.js interactive maps with real-time updates
- **Fallback:** Server-side HTML tables with static Mapbox images
- **Geocoding:** Mapbox API (with Nominatim fallback)
- **Maps:** Leaflet.js (interactive) + Mapbox Static Images API (server-side)
- **Security:** Rate limiting, input validation, authentication
- **Testing:** Jest, TypeScript, 128+ tests with 76% coverage
- **Reverse Proxy:** Caddy (automatic HTTPS)
- **Database:** SQLite (lightweight, serverless)
- **Accessibility:** Full progressive enhancement with noscript support
## Contributing

505
package-lock.json generated
View file

@ -10,11 +10,13 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"canvas": "^3.1.2",
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"node-cron": "^3.0.3",
"sharp": "^0.34.2",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"
@ -727,6 +729,16 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
@ -1047,6 +1059,402 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz",
"integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.1.0"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz",
"integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.1.0"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz",
"integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz",
"integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz",
"integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz",
"integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz",
"integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz",
"integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz",
"integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz",
"integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz",
"integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.1.0"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz",
"integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.1.0"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz",
"integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.1.0"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz",
"integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.1.0"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz",
"integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz",
"integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.1.0"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz",
"integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.4.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz",
"integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz",
"integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz",
"integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@ -3532,6 +3940,20 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.1.2.tgz",
"integrity": "sha512-Z/tzFAcBzoCvJlOSlCnoekh1Gu8YMn0J51+UAuXJAbW1Z6I9l2mZgdD7738MepoeeIcUdDtbMnOg6cC7GJxy/g==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.0.0",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": "^18.12.0 || >= 20.9.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -3682,11 +4104,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@ -3699,9 +4133,18 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@ -8220,6 +8663,47 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz",
"integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.4",
"semver": "^7.7.2"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.2",
"@img/sharp-darwin-x64": "0.34.2",
"@img/sharp-libvips-darwin-arm64": "1.1.0",
"@img/sharp-libvips-darwin-x64": "1.1.0",
"@img/sharp-libvips-linux-arm": "1.1.0",
"@img/sharp-libvips-linux-arm64": "1.1.0",
"@img/sharp-libvips-linux-ppc64": "1.1.0",
"@img/sharp-libvips-linux-s390x": "1.1.0",
"@img/sharp-libvips-linux-x64": "1.1.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0",
"@img/sharp-libvips-linuxmusl-x64": "1.1.0",
"@img/sharp-linux-arm": "0.34.2",
"@img/sharp-linux-arm64": "0.34.2",
"@img/sharp-linux-s390x": "0.34.2",
"@img/sharp-linux-x64": "0.34.2",
"@img/sharp-linuxmusl-arm64": "0.34.2",
"@img/sharp-linuxmusl-x64": "0.34.2",
"@img/sharp-wasm32": "0.34.2",
"@img/sharp-win32-arm64": "0.34.2",
"@img/sharp-win32-ia32": "0.34.2",
"@img/sharp-win32-x64": "0.34.2"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -8380,6 +8864,21 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT"
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@ -9082,7 +9581,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"devOptional": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {

View file

@ -22,11 +22,13 @@
"postinstall": "npm run build-css"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.0.1",
"express": "^4.18.2",
"express-rate-limit": "^7.5.1",
"node-cron": "^3.0.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1"

View file

@ -1,5 +1,5 @@
document.addEventListener('DOMContentLoaded', () => {
const map = L.map('map').setView([42.9634, -85.6681], 10);
const map = L.map('map').setView([42.96008, -85.67403], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,

View file

@ -20,6 +20,12 @@
}
})();
</script>
<noscript>
<style>
.js-only { display: none !important; }
.nojs-fallback { display: block !important; }
</style>
</noscript>
</head>
<body>
<div class="container">
@ -29,23 +35,31 @@
<h1>❄️ Great Lakes Ice Report</h1>
<p>Community-reported ICEy road conditions and winter hazards (auto-expire after 48 hours)</p>
</div>
<button id="theme-toggle" class="theme-toggle" title="Toggle dark mode">
<button id="theme-toggle" class="theme-toggle js-only" title="Toggle dark mode">
<span class="theme-icon">🌙</span>
</button>
</div>
</header>
<div class="content">
<!-- Non-JavaScript fallback notice -->
<noscript>
<div class="nojs-notice">
<p><strong>JavaScript is disabled.</strong> For the best experience with interactive maps, please enable JavaScript.</p>
<p><a href="/table" style="color: #007bff; text-decoration: underline;">Click here to view the table-only version →</a></p>
</div>
</noscript>
<div class="form-section">
<h2>Report ICEy Conditions</h2>
<form id="location-form">
<form id="location-form" method="POST" action="/submit-report">
<div class="form-group">
<label for="address">Address or Location *</label>
<div class="autocomplete-container">
<input type="text" id="address" name="address" required
placeholder="Enter address, intersection (e.g., Main St & Second St, City), or landmark"
autocomplete="off">
<div id="autocomplete-list" class="autocomplete-list"></div>
<div id="autocomplete-list" class="autocomplete-list js-only"></div>
</div>
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City" or "CVS Pharmacy, City"</small>
</div>
@ -59,23 +73,38 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
<button type="submit" id="submit-btn">
<span id="submit-text">Report Location</span>
<span id="submit-loading" style="display: none;">Submitting...</span>
<span id="submit-loading" style="display: none;" class="js-only">Submitting...</span>
</button>
</form>
<div id="message" class="message"></div>
<div id="message" class="message js-only"></div>
</div>
<div class="map-section">
<div class="reports-header">
<h2>Current Reports</h2>
<div class="view-toggle">
<div class="view-toggle js-only">
<button id="map-view-btn" class="toggle-btn active">📍 Map View</button>
<button id="table-view-btn" class="toggle-btn">📋 Table View</button>
<a href="/table" class="toggle-btn" style="text-decoration: none; line-height: normal;" title="Server-side view that works without JavaScript">📊 Basic View</a>
</div>
<noscript>
<div class="nojs-fallback" style="display: block;">
<div style="text-align: center; margin: 24px 0;">
<h3>Static Map Overview</h3>
<img src="/map-image.png?width=600&height=400"
alt="Static map showing ice report locations"
style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<p style="font-size: 14px; color: #666; margin-top: 8px;">
Red markers: Regular reports | Orange markers: Persistent reports
</p>
</div>
<p style="text-align: center;"><a href="/table" style="color: #007bff; text-decoration: underline;">📋 View Detailed Table Format →</a></p>
</div>
</noscript>
</div>
<div id="map-view" class="view-container">
<div id="map-view" class="view-container js-only">
<div id="map"></div>
<div class="map-info">
<p><strong>🔴 Red markers:</strong> Icy conditions reported</p>
@ -84,7 +113,7 @@ placeholder="Enter address, intersection (e.g., Main St & Second St, City), or l
</div>
</div>
<div id="table-view" class="view-container" style="display: none;">
<div id="table-view" class="view-container js-only" style="display: none;">
<div class="table-controls">
<div class="table-info">
<p id="table-location-count">Loading locations...</p>

View file

@ -10,7 +10,7 @@ export default (): Router => {
* tags:
* - Public API
* summary: Get API configuration
* description: Returns public API configuration including MapBox access token for geocoding
* description: Returns public API configuration including Mapbox access token for geocoding
* responses:
* 200:
* description: API configuration retrieved successfully
@ -20,12 +20,12 @@ export default (): Router => {
* $ref: '#/components/schemas/ApiConfig'
* examples:
* with_mapbox:
* summary: Configuration with MapBox token
* summary: Configuration with Mapbox token
* value:
* mapboxAccessToken: "pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJhYmNkZWZnIn0.example"
* hasMapbox: true
* without_mapbox:
* summary: Configuration without MapBox token
* summary: Configuration without Mapbox token
* value:
* mapboxAccessToken: null
* hasMapbox: false
@ -40,11 +40,11 @@ export default (): Router => {
console.log('📡 API Config requested');
const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined;
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
console.log('Mapbox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('Mapbox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
// Mapbox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only

View file

@ -187,3 +187,23 @@ button {
.w-100 { width: 100%; }
.h-100 { height: 100%; }
// Progressive enhancement styles
.js-only {
// Show by default (when JavaScript is available)
display: block;
}
.nojs-fallback {
// Hide by default (only show when JavaScript is disabled via noscript)
display: none;
}
.nojs-notice {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
text-align: center;
}

View file

@ -124,7 +124,7 @@
// Theme Toggle Mixin (consolidates duplicated theme toggle styles)
@mixin theme-toggle-styles($width: 40px, $height: 40px) {
@include button($bg-color: transparent);
@include button($bg-color: var(--card-bg), $text-color: var(--text-color));
border: 2px solid var(--border-color);
border-radius: $border-radius-full;
width: $width;
@ -132,11 +132,27 @@
@include flex-center;
transition: all 0.3s ease;
box-shadow: 0 2px 4px var(--shadow);
background-color: var(--card-bg);
color: var(--text-color);
padding: 0; // Remove padding to ensure centering
// Ensure the icon is centered
.theme-icon {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
}
&:hover {
background-color: var(--table-hover);
transform: none;
}
.theme-toggle:hover & {
background-color: var(--table-hover);
}
}
// Back-link button mixin (shared component)

View file

@ -13,6 +13,7 @@ dotenv.config();
// Import services and models
import DatabaseService from './services/DatabaseService';
import ProfanityFilterService from './services/ProfanityFilterService';
import MapImageService from './services/MapImageService';
// Import route modules
import configRoutes from './routes/config';
@ -26,11 +27,12 @@ const PORT: number = parseInt(process.env.PORT || '3000', 10);
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
app.use(express.static(path.join(__dirname, '../public')));
// Database and services setup
const databaseService = new DatabaseService();
let profanityFilter: ProfanityFilterService | FallbackFilter;
const mapImageService = new MapImageService();
// Fallback filter interface for type safety
interface FallbackFilter {
@ -181,17 +183,271 @@ function setupRoutes(): void {
// Static page routes
app.get('/', (req: Request, res: Response): void => {
console.log('Serving the main page');
res.sendFile(path.join(__dirname, '../../public', 'index.html'));
res.sendFile(path.join(__dirname, '../public', 'index.html'));
});
// Non-JavaScript table view route
app.get('/table', async (req: Request, res: Response): Promise<void> => {
console.log('Serving table view for non-JS users');
try {
const locations = await locationModel.getActive();
const formatTimeRemaining = (createdAt: string): string => {
const created = new Date(createdAt);
const now = new Date();
const diffMs = (48 * 60 * 60 * 1000) - (now.getTime() - created.getTime());
if (diffMs <= 0) return 'Expired';
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m`;
}
};
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const escapeHtml = (text: string): string => {
return text.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
const tableRows = locations.map((location, index) => `
<tr>
<td style="text-align: center; font-weight: bold;">${index + 1}</td>
<td>${escapeHtml(location.address)}</td>
<td>${escapeHtml(location.description || 'No additional details')}</td>
<td>${location.created_at ? formatDate(location.created_at) : 'Unknown'}</td>
<td>${location.persistent ? 'Persistent' : (location.created_at ? formatTimeRemaining(location.created_at) : 'Unknown')}</td>
</tr>
`).join('');
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Great Lakes Ice Report - Table View</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<div class="header-content">
<div class="header-text">
<h1> Great Lakes Ice Report</h1>
<p>Community-reported ICEy road conditions and winter hazards</p>
</div>
</div>
</header>
<div class="content">
<div class="form-section">
<h2>Report ICEy Conditions</h2>
<form method="POST" action="/submit-report">
<div class="form-group">
<label for="address">Address or Location *</label>
<input type="text" id="address" name="address" required
placeholder="Enter address, intersection, or landmark">
<small class="input-help">Examples: "123 Main St, City" or "Main St & Oak Ave, City"</small>
</div>
<div class="form-group">
<label for="description">Additional Details (Optional)</label>
<textarea id="description" name="description" rows="3"
placeholder="Number of vehicles, time observed, etc."></textarea>
<small class="input-help">Keep descriptions appropriate and relevant to road conditions.</small>
</div>
<button type="submit">Report Location</button>
</form>
</div>
<div class="map-section">
<div class="reports-header">
<h2>Current Reports (${locations.length} active)</h2>
<p><a href="/"> Back to Interactive Map</a></p>
</div>
${locations.length > 0 ? `
<div style="text-align: center; margin: 24px 0;">
<h3>Static Map Overview</h3>
<img src="/map-image.png?width=800&height=400"
alt="Static map showing ice report locations"
style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<p style="font-size: 14px; color: #666; margin-top: 8px;">
Red markers: Regular reports | Orange markers: Persistent reports
</p>
</div>
` : ''}
<div class="table-container">
<table class="reports-table">
<thead>
<tr>
<th>#</th>
<th>Location</th>
<th>Details</th>
<th>Reported</th>
<th>Time Remaining</th>
</tr>
</thead>
<tbody>
${tableRows || '<tr><td colspan="5">No reports currently available</td></tr>'}
</tbody>
</table>
</div>
</div>
</div>
<footer>
<p><strong>Safety Notice:</strong> This is a community tool for awareness. Stay safe and verify information independently.</p>
<div class="disclaimer">
<small>Reports are automatically deleted after 48 hours. <a href="/privacy">Privacy Policy</a></small>
</div>
</footer>
</div>
</body>
</html>
`;
res.send(html);
} catch (err) {
console.error('Error serving table view:', err);
res.status(500).send('Internal server error');
}
});
// Handle form submission for non-JS users
app.post('/submit-report', async (req: Request, res: Response): Promise<void> => {
console.log('Handling form submission for non-JS users');
const { address, description } = req.body;
// Validate input
if (!address || typeof address !== 'string' || address.trim().length === 0) {
res.status(400).send(`
<html>
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>Error</h1>
<p>Address is required.</p>
<p><a href="/table"> Go Back</a></p>
</div>
</body>
</html>
`);
return;
}
// Check for profanity if description provided
if (description && profanityFilter) {
try {
const analysis = profanityFilter.analyzeProfanity(description);
if (analysis.hasProfanity) {
res.status(400).send(`
<html>
<head><title>Submission Rejected</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>Submission Rejected</h1>
<p>Your description contains inappropriate language and cannot be posted.</p>
<p>Please revise your description to focus on road conditions and keep it professional.</p>
<p><a href="/table"> Go Back</a></p>
</div>
</body>
</html>
`);
return;
}
} catch (filterError) {
console.error('Error checking profanity:', filterError);
}
}
try {
await locationModel.create({
address: address.trim(),
description: description?.trim() || null
});
res.send(`
<html>
<head><title>Report Submitted</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1> Report Submitted Successfully</h1>
<p>Your ice condition report has been added to the system.</p>
<p><a href="/table"> View All Reports</a></p>
</div>
</body>
</html>
`);
} catch (err) {
console.error('Error creating location:', err);
res.status(500).send(`
<html>
<head><title>Error</title><link rel="stylesheet" href="style.css"></head>
<body>
<div class="container">
<h1>Error</h1>
<p>Failed to submit report. Please try again.</p>
<p><a href="/table"> Go Back</a></p>
</div>
</body>
</html>
`);
}
});
// Static map image generation route
app.get('/map-image.png', async (req: Request, res: Response): Promise<void> => {
console.log('Generating static map image');
try {
const locations = await locationModel.getActive();
// Parse query parameters for customization
const width = parseInt(req.query.width as string) || 800;
const height = parseInt(req.query.height as string) || 600;
const padding = parseInt(req.query.padding as string) || 50;
const imageBuffer = await mapImageService.generateMapImage(locations, {
width: Math.min(Math.max(width, 400), 1200), // Clamp between 400-1200
height: Math.min(Math.max(height, 300), 900), // Clamp between 300-900
padding: Math.min(Math.max(padding, 20), 100) // Clamp between 20-100
});
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.send(imageBuffer);
} catch (err) {
console.error('Error generating map image:', err);
res.status(500).send('Error generating map image');
}
});
app.get('/admin', (req: Request, res: Response): void => {
console.log('Serving the admin page');
res.sendFile(path.join(__dirname, '../../public', 'admin.html'));
res.sendFile(path.join(__dirname, '../public', 'admin.html'));
});
app.get('/privacy', (req: Request, res: Response): void => {
console.log('Serving the privacy policy page');
res.sendFile(path.join(__dirname, '../../public', 'privacy.html'));
res.sendFile(path.join(__dirname, '../public', 'privacy.html'));
});
}

View file

@ -0,0 +1,130 @@
import https from 'https';
import { Location } from '../types';
interface MapOptions {
width: number;
height: number;
padding?: number; // Reintroduced padding property
}
export class MapImageService {
private defaultOptions: MapOptions = {
width: 800,
height: 600
};
/**
* Generate a static map image using Mapbox Static Maps API
*/
async generateMapImage(locations: Location[], options: Partial<MapOptions> = {}): Promise<Buffer> {
const opts = { ...this.defaultOptions, ...options };
console.info('Generating Mapbox static map focused on location data');
console.info('Canvas size:', opts.width, 'x', opts.height);
console.info('Number of locations:', locations.length);
const mapboxBuffer = await this.fetchMapboxStaticMapAutoFit(opts, locations);
if (mapboxBuffer) {
return mapboxBuffer;
} else {
// Return a simple error image if Mapbox fails
return this.generateErrorImage(opts);
}
}
/**
* Fetch map using Mapbox Static Maps API with auto-fit to location data
*/
private async fetchMapboxStaticMapAutoFit(options: MapOptions, locations: Location[]): Promise<Buffer | null> {
const mapboxToken = process.env.MAPBOX_ACCESS_TOKEN;
if (!mapboxToken) {
console.error('No Mapbox token available');
return null;
}
// Build overlay string for all locations with correct format
let overlays = '';
locations.forEach((location, index) => {
if (location.latitude && location.longitude) {
console.info(`Location ${index + 1}: ${location.latitude}, ${location.longitude} (${location.address})`);
// Correct format: pin-s-label+color(lng,lat)
const color = location.persistent ? 'ff9800' : 'ff0000'; // Orange for persistent, red for regular
const label = (index + 1).toString();
overlays += `pin-s-${label}+${color}(${location.longitude},${location.latitude}),`;
}
});
// Remove trailing comma
overlays = overlays.replace(/,$/, '');
console.info('Generated overlays string:', overlays);
// Build Mapbox Static Maps URL with auto-fit
let mapboxUrl;
if (overlays) {
// Use auto-fit to center on all pins
mapboxUrl = `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/${overlays}/auto/${options.width}x${options.height}?access_token=${mapboxToken}`;
} else {
// No locations, use Grand Rapids as fallback
const fallbackLat = 42.960081464833195;
const fallbackLng = -85.67402711517647;
mapboxUrl = `https://api.mapbox.com/styles/v1/mapbox/streets-v12/static/${fallbackLng},${fallbackLat},10/${options.width}x${options.height}?access_token=${mapboxToken}`;
}
console.info('Fetching Mapbox static map with auto-fit...');
console.info('URL:', mapboxUrl.replace(mapboxToken, 'TOKEN_HIDDEN'));
return new Promise((resolve) => {
const request = https.get(mapboxUrl, { timeout: 10000 }, (response) => {
if (response.statusCode === 200) {
const chunks: Buffer[] = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
console.info('Mapbox static map fetched successfully');
resolve(Buffer.concat(chunks));
});
} else {
console.error('Mapbox API error:', response.statusCode);
resolve(null);
}
});
request.on('error', (err) => {
console.error('Error fetching Mapbox map:', err.message);
resolve(null);
});
request.on('timeout', () => {
console.error('Mapbox request timeout');
request.destroy();
resolve(null);
});
});
}
/**
* Generate a simple error image when Mapbox fails
*/
private generateErrorImage(options: MapOptions): Buffer {
// Generate a simple 1x1 transparent PNG as fallback
// This is a valid PNG header + IHDR + IDAT + IEND for a 1x1 transparent pixel
const transparentPng = Buffer.from([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 dimensions
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, // RGBA, no compression
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, // Compressed data
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, // (transparent pixel)
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, // IEND chunk
0x42, 0x60, 0x82
]);
console.info('Generated transparent PNG fallback due to Mapbox failure');
return transparentPng;
}
}
export default MapImageService;

View file

@ -17,7 +17,7 @@ const options: swaggerJsdoc.Options = {
- Retrieve active ice reports (< 48 hours or persistent)
- Admin panel for content moderation and management
- Profanity filtering for content safety
- Geographic data with MapBox integration
- Geographic data with Mapbox integration
`,
contact: {
name: 'Great Lakes Ice Report',
@ -222,13 +222,13 @@ const options: swaggerJsdoc.Options = {
properties: {
mapboxAccessToken: {
type: 'string',
description: 'MapBox API token for geocoding (null if not configured)',
description: 'Mapbox API token for geocoding (null if not configured)',
example: 'pk.eyJ1IjoiZXhhbXBsZSIsImEiOiJhYmNkZWZnIn0.example',
nullable: true
},
hasMapbox: {
type: 'boolean',
description: 'Whether MapBox token is configured',
description: 'Whether Mapbox token is configured',
example: true
}
}