Add Mapbox static map generation for non-JavaScript users

- Implement MapImageService using Mapbox Static Images API
- Add server-side /table route with HTML form submission
- Generate static map images with auto-fit positioning based on actual location coordinates
- Add progressive enhancement with noscript fallbacks and Basic View button
- Update map center coordinates to proper Grand Rapids location
- Add numbered pins with color coding (red for regular, orange for persistent reports)
- Remove server-side caching to ensure fresh map images
- Fix theme toggle icon centering in CSS mixins

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-06 00:09:23 -04:00
parent 6cb165a3c3
commit 96dc6bde42
8 changed files with 1015 additions and 18 deletions

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": {
"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"

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.960081464833195, -85.67402711517647], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,

View file

@ -20,6 +20,20 @@
}
})();
</script>
<noscript>
<style>
.js-only { display: none !important; }
.nojs-fallback { display: block !important; }
.nojs-notice {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
text-align: center;
}
</style>
</noscript>
</head>
<body>
<div class="container">
@ -29,23 +43,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 +81,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 +121,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

@ -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,9 +132,21 @@
@include flex-center;
transition: all 0.3s ease;
box-shadow: 0 2px 4px var(--shadow);
background-color: var(--card-bg) !important;
color: var(--text-color) !important;
padding: 0 !important; // 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);
background-color: var(--table-hover) !important;
transform: none;
}
}

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,263 @@ 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 tableRows = locations.map((location, index) => `
<tr>
<td style="text-align: center; font-weight: bold;">${index + 1}</td>
<td>${location.address}</td>
<td>${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,179 @@
import https from 'https';
import { Location } from '../types';
interface MapOptions {
width: number;
height: number;
padding?: number;
}
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.log('Generating Mapbox static map focused on location data');
console.log('Canvas size:', opts.width, 'x', opts.height);
console.log('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.log(`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.log('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.log('Fetching Mapbox static map with auto-fit...');
console.log('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.log('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);
});
});
}
/**
* Legacy method - keeping for potential fallback
*/
private async fetchMapboxStaticMap(centerLat: number, centerLng: number, zoom: number, 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 location markers
let overlays = '';
locations.forEach((location, index) => {
if (location.latitude && location.longitude) {
console.log(`Location ${index + 1}: ${location.latitude}, ${location.longitude} (${location.address})`);
const color = location.persistent ? 'orange' : 'red';
const label = (index + 1).toString();
overlays += `pin-s-${label}+${color}(${location.longitude},${location.latitude}),`;
}
});
// Remove trailing comma
overlays = overlays.replace(/,$/, '');
// Build Mapbox Static Maps URL
let mapboxUrl;
if (overlays) {
mapboxUrl = `https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/${overlays}/${centerLng},${centerLat},${zoom}/${options.width}x${options.height}?access_token=${mapboxToken}`;
} else {
mapboxUrl = `https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/${centerLng},${centerLat},${zoom}/${options.width}x${options.height}?access_token=${mapboxToken}`;
}
console.log('Fetching Mapbox static map...');
console.log('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.log('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 {
// Return a simple text-based error - in a real implementation you'd create a proper error image
const errorText = `Map generation failed - Mapbox API error\nSize: ${options.width}x${options.height}`;
return Buffer.from(errorText, 'utf8');
}
}
export default MapImageService;