feat: Add map to make site easier to use
All checks were successful
Build Docker / BuildImage (push) Successful in 41s

This commit is contained in:
2025-05-26 16:56:53 +10:00
parent dce2175c1c
commit b856448247
4 changed files with 451 additions and 48 deletions

8
db.py
View File

@@ -24,11 +24,12 @@ def load_locations():
except (json.JSONDecodeError, FileNotFoundError):
return []
def save_location(name, description="", rating=None):
def save_location(latitude, longitude, description="", rating=None):
"""Save a new kite location to the database
Args:
name (str): Name of the kite flying location
latitude (float): Latitude coordinate
longitude (float): Longitude coordinate
description (str, optional): Description of the location
rating (int, optional): Rating from 1-5 stars
@@ -39,7 +40,8 @@ def save_location(name, description="", rating=None):
# Create new location entry
new_location = {
"name": name,
"latitude": float(latitude),
"longitude": float(longitude),
"description": description,
"rating": rating,
"date_added": datetime.now().isoformat()

View File

@@ -13,7 +13,7 @@ from flask import (
import os
import json
import requests
from datetime import datetime
from datetime import datetime, timedelta
import dotenv
import db # Import our new db module
import re
@@ -147,43 +147,57 @@ def index():
# Kite Watch API Routes
@app.route("/api/locations", methods=["GET"])
def get_locations():
locations = db.load_locations()
return jsonify(locations)
# Get all locations from DB
all_locations = db.load_locations()
# Check if we should filter by time (last 48 hours)
cutoff_time = datetime.now() - timedelta(hours=48)
# Filter locations by time
recent_locations = [
loc for loc in all_locations
if datetime.fromisoformat(loc["date_added"]) > cutoff_time
]
return jsonify(recent_locations)
@app.route("/api/locations", methods=["POST"])
def add_location():
data = request.json
if not data or "name" not in data:
return jsonify({"error": "Name is required"}), 400
if not data:
return jsonify({"error": "Invalid request data"}), 400
name = data.get("name")
latitude = data.get("latitude")
longitude = data.get("longitude")
description = data.get("description", "")
rating = data.get("rating")
# Validate required fields
if len(name.strip()) < 5:
return jsonify({"error": "Location name must be at least 5 characters long"}), 400
if len(name.strip()) > 100:
return jsonify({"error": "Location name is too long (max 100 characters)"}), 400
if latitude is None or longitude is None:
return jsonify({"error": "Latitude and longitude are required"}), 400
try:
latitude = float(latitude)
longitude = float(longitude)
except (ValueError, TypeError):
return jsonify({"error": "Latitude and longitude must be valid numbers"}), 400
# Validate coordinate ranges
if not (-90 <= latitude <= 90) or not (-180 <= longitude <= 180):
return jsonify({"error": "Invalid coordinate values"}), 400
if len(description.strip()) > 2000:
return jsonify({"error": "Notes are too long (max 2000 characters)"}), 400
if not name.strip():
return jsonify({"error": "Location name cannot be empty"}), 400
# Check for profanity
if contains_profanity(name):
return jsonify({"error": "Location name contains inappropriate language"}), 400
if contains_profanity(description):
return jsonify({"error": "Notes contain inappropriate language"}), 400
# Validate for JSON safety
if not is_json_safe(name) or not is_json_safe(description):
if not is_json_safe(description):
return jsonify({"error": "Input contains invalid characters"}), 400
# Sanitize inputs
name = sanitize_text_input(name)
description = sanitize_text_input(description)
# Validate rating
@@ -197,7 +211,8 @@ def add_location():
else:
return jsonify({"error": "Rating is required"}), 400
location = db.save_location(name, description, rating)
# Save to database
location = db.save_location(latitude, longitude, description, rating)
return jsonify(location), 201
@app.route("/<path:path>")

View File

@@ -46,6 +46,7 @@ h2 {
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: visible;
}
.form-group {
@@ -294,3 +295,142 @@ a:hover {
.footer a:hover {
text-decoration: underline;
}
/* Map styling */
#map-container {
height: 500px; /* Increased height for better resolution */
width: 100%;
margin-bottom: 20px;
border-radius: 8px;
border: 1px solid #333;
z-index: 1;
/* Improved rendering for high-DPI displays */
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
backface-visibility: hidden;
}
/* Fix for Leaflet tiles to render at higher resolution */
.leaflet-container {
image-rendering: high-quality;
}
.leaflet-retina .leaflet-tile {
box-shadow: none !important;
}
/* Custom marker styling */
.custom-marker {
text-align: center;
}
.marker-pin {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #ff0000;
border: 2px solid #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
/* Coordinates input styling */
.location-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.coordinates-group {
display: flex;
gap: 10px;
}
.coordinates-group input {
flex: 1;
}
#get-location-btn {
background-color: #333;
color: #fff;
border: 1px solid #444;
padding: 8px;
cursor: pointer;
}
#get-location-btn:hover {
background-color: #444;
}
/* Popup styling */
.leaflet-popup-content-wrapper {
background-color: #222;
color: #fff;
border-radius: 5px;
}
.leaflet-popup-content {
margin: 10px;
}
.leaflet-popup-tip {
background-color: #222;
}
.popup-content {
padding: 5px;
}
.popup-content .rating {
font-weight: bold;
display: flex;
align-items: center;
margin-top: 0;
color: #ffd700;
}
.popup-content .date {
font-size: 0.8em;
color: #aaa;
margin-bottom: 0;
}
/* Modifications to existing styles to accommodate map */
.card {
/* ...existing code... */
overflow: visible;
}
@media (max-width: 600px) {
/* ...existing code... */
.coordinates-group {
flex-direction: column;
}
#map-container {
height: 300px;
}
}
/* Hide the coordinate inputs */
.hidden {
display: none !important;
}
/* Input map styling */
#input-map-container {
height: 300px;
width: 100%;
margin-bottom: 10px;
border-radius: 8px;
border: 1px solid #333;
z-index: 1;
}
.location-help {
margin: 10px 0;
font-size: 0.9rem;
color: #adb5bd;
text-align: center;
}

View File

@@ -7,6 +7,12 @@
<title>Kite Watch</title>
<link rel="icon" href="/assets/img/favicon.png" type="image/png">
<link rel="stylesheet" href="/assets/css/index.css">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossorigin=""/>
<!-- Leaflet JavaScript -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
crossorigin=""></script>
</head>
<body>
@@ -19,10 +25,17 @@
<div class="card">
<h2>Add a Kite Flying Location</h2>
<div id="notification" class="notification hidden"></div>
<form id="location-form">
<form id="location-form" novalidate>
<div class="form-group">
<label for="location-name">Location Name *</label>
<input type="text" id="location-name" required placeholder="Arboretum, Weston Park, etc.">
<label>Select Your Location</label>
<div id="input-map-container"></div>
<div class="location-controls">
<!-- Using hidden input type instead of visually hiding regular inputs -->
<input type="hidden" id="location-lat" name="location-lat">
<input type="hidden" id="location-lng" name="location-lng">
<button type="button" id="get-location-btn">Get My Location</button>
<p class="location-help">Click on the map or use the button above to set your location</p>
</div>
</div>
<div class="form-group">
<label for="location-description">Notes</label>
@@ -40,7 +53,8 @@
</div>
<div class="card">
<h2>Great Places to Fly Kites</h2>
<h2>Great Places to Fly Kites in Canberra</h2>
<div id="map-container"></div>
<div id="locations-list">
<p class="loading">Loading locations...</p>
</div>
@@ -54,28 +68,218 @@
</footer>
<script>
// Global map variable
let map;
let inputMap; // New map for input section
let markers = [];
let locationMarker; // Marker for the currently selected location
// Canberra coordinates - defined at the top level for reuse
const CANBERRA_LAT = -35.2809;
const CANBERRA_LNG = 149.1300;
const MAX_DISTANCE_KM = 50; // Maximum distance from Canberra allowed (in kilometers)
// Calculate distance between two points using the Haversine formula (in kilometers)
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in kilometers
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Check if a location is within the allowed distance from Canberra
function isWithinCanberraRange(lat, lng) {
const distance = calculateDistance(CANBERRA_LAT, CANBERRA_LNG, lat, lng);
return distance <= MAX_DISTANCE_KM;
}
// Load locations when the page loads
document.addEventListener('DOMContentLoaded', () => {
// Make sure Leaflet is fully loaded before initializing the map
if (typeof L !== 'undefined') {
// Initialize map centered on Canberra
initMap();
// Load kite flying locations
loadLocations();
// Automatically get user's location on page load
getUserLocation();
} else {
console.error("Leaflet library not loaded. Attempting to retry in 500ms.");
// Try again after a short delay
setTimeout(() => {
if (typeof L !== 'undefined') {
initMap();
loadLocations();
getUserLocation();
} else {
showNotification("Failed to load map. Please refresh the page.", "error");
}
}, 500);
}
// Handle get location button
document.getElementById('get-location-btn').addEventListener('click', getUserLocation);
// Handle form submission
document.getElementById('location-form').addEventListener('submit', (e) => {
e.preventDefault();
const name = document.getElementById('location-name').value;
const lat = parseFloat(document.getElementById('location-lat').value);
const lng = parseFloat(document.getElementById('location-lng').value);
const description = document.getElementById('location-description').value;
const rating = parseInt(document.getElementById('rating').value);
if (!name) {
showNotification('Please enter a location name', 'error');
if (isNaN(lat) || isNaN(lng)) {
showNotification('Please provide valid coordinates', 'error');
return;
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
showNotification('Please provide valid coordinates within range', 'error');
return;
}
// Check if location is within Canberra range
if (!isWithinCanberraRange(lat, lng)) {
showNotification(`Location must be within ${MAX_DISTANCE_KM}km of Canberra`, 'error');
return;
}
// Submit the location
submitLocation(name, description, rating);
submitLocation(lat, lng, description, rating);
});
});
// Initialize the map
function initMap() {
// Use the global Canberra coordinates
map = L.map('map-container').setView([CANBERRA_LAT, CANBERRA_LNG], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Initialize the input map
inputMap = L.map('input-map-container').setView([CANBERRA_LAT, CANBERRA_LNG], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(inputMap);
// Draw a 50km radius circle around Canberra to visualize the allowed area
const canberraCircle = L.circle([CANBERRA_LAT, CANBERRA_LNG], {
color: 'blue',
fillColor: '#3388ff',
fillOpacity: 0.1,
radius: MAX_DISTANCE_KM * 1000 // Convert to meters
}).addTo(map);
// Add the same circle to the input map
const inputCanberraCircle = L.circle([CANBERRA_LAT, CANBERRA_LNG], {
color: 'blue',
fillColor: '#3388ff',
fillOpacity: 0.1,
radius: MAX_DISTANCE_KM * 1000 // Convert to meters
}).addTo(inputMap);
// Add click handler to the input map to update selected location
inputMap.on('click', function(e) {
updateSelectedLocation(e.latlng.lat, e.latlng.lng);
});
}
// Function to set and update the selected location marker
function updateSelectedLocation(lat, lng) {
// Check if the location is within Canberra range
if (!isWithinCanberraRange(lat, lng)) {
showNotification(`Location must be within ${MAX_DISTANCE_KM}km of Canberra`, 'error');
return;
}
// Update form inputs (hidden from user but needed for form submission)
document.getElementById('location-lat').value = lat.toFixed(6);
document.getElementById('location-lng').value = lng.toFixed(6);
// Update or create the marker on the input map
if (locationMarker) {
locationMarker.setLatLng([lat, lng]);
} else {
// Create a draggable marker with a different icon than the location markers
const markerIcon = L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
locationMarker = L.marker([lat, lng], {
icon: markerIcon,
draggable: true
}).addTo(inputMap);
// Update form when marker is dragged
locationMarker.on('dragend', function(e) {
const position = locationMarker.getLatLng();
updateSelectedLocation(position.lat, position.lng);
});
}
// Center the input map on the selected location
inputMap.setView([lat, lng], inputMap.getZoom());
}
// Get user's current location
function getUserLocation() {
const locationBtn = document.getElementById('get-location-btn');
if (!navigator.geolocation) {
showNotification('Geolocation is not supported by your browser', 'error');
return;
}
locationBtn.disabled = true;
locationBtn.textContent = 'Getting location...';
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
// Check if the user's location is within Canberra range
if (!isWithinCanberraRange(lat, lng)) {
showNotification(`Your current location is outside the ${MAX_DISTANCE_KM}km Canberra radius. Using Canberra center instead.`, 'warning');
// Default to Canberra center if user is too far
updateSelectedLocation(CANBERRA_LAT, CANBERRA_LNG);
} else {
// Update selected location marker and form inputs
updateSelectedLocation(lat, lng);
}
// Center input map on valid location
inputMap.setView([document.getElementById('location-lat').value, document.getElementById('location-lng').value], 15);
locationBtn.disabled = false;
locationBtn.textContent = 'Get My Location';
},
(error) => {
showNotification('Unable to get location: ' + error.message + '. Using Canberra center instead.', 'error');
// Default to Canberra center if geolocation fails
updateSelectedLocation(CANBERRA_LAT, CANBERRA_LNG);
locationBtn.disabled = false;
locationBtn.textContent = 'Get My Location';
}
);
}
// Function to show notifications instead of alerts
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
@@ -95,6 +299,10 @@
fetch('/api/locations')
.then(response => response.json())
.then(locations => {
// Clear existing markers
markers.forEach(marker => map.removeLayer(marker));
markers = [];
locationsList.innerHTML = '';
if (locations.length === 0) {
@@ -105,21 +313,26 @@
// Sort locations with newest first
locations.sort((a, b) => new Date(b.date_added) - new Date(a.date_added));
locations.forEach(location => {
const date = new Date(location.date_added);
const formattedDate = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
// Filter locations to only show last 48 hours
const cutoffTime = new Date();
cutoffTime.setHours(cutoffTime.getHours() - 48);
const locationElement = document.createElement('div');
locationElement.className = 'location-item';
locationElement.innerHTML = `
<h3>${location.name}</h3>
<p>${location.description || 'No notes provided'}</p>
<p class="date">Added on: ${formattedDate}</p>
<p class="rating">Rating: ${location.rating}</p>
`;
const recentLocations = locations.filter(location =>
new Date(location.date_added) > cutoffTime
);
locationsList.appendChild(locationElement);
if (recentLocations.length === 0) {
locationsList.innerHTML = '<p>No locations added in the last 48 hours.</p>';
return;
}
// Add markers for each location
recentLocations.forEach(location => {
addLocationToMap(location);
});
// Update the list with summary
locationsList.innerHTML = `<p>Showing ${recentLocations.length} locations from the last 48 hours. Click on markers to see details.</p>`;
})
.catch(error => {
console.error('Error loading locations:', error);
@@ -127,8 +340,42 @@
});
}
// Add a location marker to the map
function addLocationToMap(location) {
// Rating determines color: 1=red, 2=orange, 3=yellow, 4=light green, 5=bright green
const colors = ['#ff0000', '#ff8800', '#ffff00', '#88ff00', '#00ff00'];
const color = colors[location.rating - 1] || '#ffaa00';
const date = new Date(location.date_added);
const formattedDate = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
// Create marker with custom color
const markerIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="background-color: ${color};" class="marker-pin"></div>`,
iconSize: [30, 30],
iconAnchor: [15, 30]
});
if (location.latitude && location.longitude) {
const marker = L.marker([location.latitude, location.longitude], { icon: markerIcon }).addTo(map);
// Create popup content
const popupContent = `
<div class="popup-content">
<p class="rating">Rating: ${location.rating} stars</p>
<p>${location.description || 'No notes provided'}</p>
<p class="date">Added on: ${formattedDate}</p>
</div>
`;
marker.bindPopup(popupContent);
markers.push(marker);
}
}
// Function to submit a new location
function submitLocation(name, description, rating) {
function submitLocation(latitude, longitude, description, rating) {
const submitButton = document.getElementById('submit-button');
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
@@ -138,7 +385,7 @@
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, description, rating })
body: JSON.stringify({ latitude, longitude, description, rating })
})
.then(response => {
if (!response.ok) {
@@ -151,7 +398,6 @@
})
.then(data => {
// Clear form
document.getElementById('location-name').value = '';
document.getElementById('location-description').value = '';
document.getElementById('rating').value = 3;
document.getElementById('rating-value').textContent = 3;