diff --git a/backend/src/controllers/pointController.js b/backend/src/controllers/pointController.js
index 2d04445..5ad23ca 100644
--- a/backend/src/controllers/pointController.js
+++ b/backend/src/controllers/pointController.js
@@ -5,8 +5,11 @@ exports.addPoint = async (req, res) => {
try {
const { lat, lon, temp } = req.body;
- if (!lat || !lon || !temp) {
- return res.status(400).json({ message: 'Missing required fields' });
+ // only reject if any value is null or undefined
+ if (lat == null || lon == null || temp == null) {
+ return res
+ .status(400)
+ .json({ success: false, message: 'Latitude, longitude and temperature are required.' });
}
// Get logged-in user
diff --git a/frontend/src/components/AddPoint.jsx b/frontend/src/components/AddPoint.jsx
index c315b24..68d4987 100644
--- a/frontend/src/components/AddPoint.jsx
+++ b/frontend/src/components/AddPoint.jsx
@@ -167,9 +167,16 @@ export default function AddPoint() {
const handleSubmit = async (e) => {
e.preventDefault();
+ setSubmitting(true);
setError('');
setSuccess(false);
- setSubmitting(true);
+
+ // only reject when fields are actually empty strings
+ if (lat === '' || lon === '' || temp === '') {
+ setError('Missing required fields');
+ setSubmitting(false);
+ return;
+ }
try {
const token = localStorage.getItem('authToken');
@@ -223,6 +230,30 @@ export default function AddPoint() {
setSuccess(true);
setTemp('');
setShowFullscreenConfirmation(true);
+
+ // Add new point to existing cache instead of clearing it
+ const newUserPoint = {
+ lat: parseFloat(lat),
+ lng: parseFloat(lon), // Note: using 'lng' to match existing format
+ temp: parseFloat(temp),
+ timestamp: mostRecentPoint.timestamp || new Date().toISOString(),
+ createdAt: mostRecentPoint.createdAt || new Date().toISOString(),
+ updatedAt: mostRecentPoint.updatedAt || new Date().toISOString(),
+ isUserPoint: true
+ };
+
+ // Update cache with new point
+ const existingCache = localStorage.getItem('waterData');
+ if (existingCache) {
+ const cachedData = JSON.parse(existingCache);
+ cachedData.push(newUserPoint);
+ localStorage.setItem('waterData', JSON.stringify(cachedData));
+ }
+
+ // Dispatch custom event to add single point to map and HUD
+ window.dispatchEvent(new CustomEvent('pointAdded', {
+ detail: newUserPoint
+ }));
} else {
setError('Point was submitted but could not be verified in database');
}
@@ -231,15 +262,41 @@ export default function AddPoint() {
}
} catch (verifyErr) {
// Point was added but verification failed - still show success
- setAddedPoint({
+ const newPoint = {
lat: parseFloat(lat),
lon: parseFloat(lon),
temp: parseFloat(temp),
timestamp: new Date().toISOString()
- });
+ };
+
+ setAddedPoint(newPoint);
setSuccess(true);
setTemp('');
setShowFullscreenConfirmation(true);
+
+ // Add new point to existing cache instead of clearing it
+ const newUserPoint = {
+ lat: newPoint.lat,
+ lng: newPoint.lon, // Note: using 'lng' to match existing format
+ temp: newPoint.temp,
+ timestamp: newPoint.timestamp,
+ createdAt: newPoint.timestamp,
+ updatedAt: newPoint.timestamp,
+ isUserPoint: true
+ };
+
+ // Update cache with new point
+ const existingCache = localStorage.getItem('waterData');
+ if (existingCache) {
+ const cachedData = JSON.parse(existingCache);
+ cachedData.push(newUserPoint);
+ localStorage.setItem('waterData', JSON.stringify(cachedData));
+ }
+
+ // Dispatch custom event to add single point to map and HUD
+ window.dispatchEvent(new CustomEvent('pointAdded', {
+ detail: newUserPoint
+ }));
}
} else {
const errorData = await res.json();
diff --git a/frontend/src/components/HUDleftPoints.jsx b/frontend/src/components/HUDleftPoints.jsx
index 56597cf..58a701f 100644
--- a/frontend/src/components/HUDleftPoints.jsx
+++ b/frontend/src/components/HUDleftPoints.jsx
@@ -75,9 +75,23 @@ function LogoBlock() {
checkForData();
};
+ // Listen for point added event to add single point instantly
+ const handlePointAdded = (event) => {
+ console.log('📡 HUD received pointAdded event, adding new point...');
+ const newPoint = event.detail;
+
+ // Add the new point to existing lists
+ setLocaList(prevList => [...prevList, newPoint]);
+ setFilteredList(prevList => [...prevList, newPoint]);
+ };
+
window.addEventListener('dataloaded', handleDataLoaded);
+ window.addEventListener('pointAdded', handlePointAdded);
- return () => window.removeEventListener('dataloaded', handleDataLoaded);
+ return () => {
+ window.removeEventListener('dataloaded', handleDataLoaded);
+ window.removeEventListener('pointAdded', handlePointAdded);
+ };
}, []);
// Sort functionality
diff --git a/frontend/src/components/MapComponent.jsx b/frontend/src/components/MapComponent.jsx
index dbee23a..acd8497 100644
--- a/frontend/src/components/MapComponent.jsx
+++ b/frontend/src/components/MapComponent.jsx
@@ -184,6 +184,458 @@ export default function MapComponent() {
// Store the cleanup function for later use
map._themeCleanup = removeThemeListener;
+ // Function to add a marker for a single item - extracted for reuse
+ function addMarkerForItem(item, i, isGrouped = false) {
+ const lon = item.lng ?? item.lon ?? item.Longitude;
+ const lat = item.lat ?? item.Latitude;
+ const t = parseFloat(item.temp ?? item.Result); // Convert to number here
+ const name = item.siteName || item.Label || `User Point ${i + 1}`;
+
+ // determine age (fallback to createdAt for user‐points)
+ const rawTime = item.timestamp || item.createdAt || item.created_at;
+ const ts = new Date(rawTime).getTime();
+ const isStale = !isNaN(ts) && (Date.now() - ts) > 2 * 24 * 60 * 60 * 1000; // older than 2 days
+
+ const currentUnit = UnitManager.getUnit();
+ const formattedTemp = formatTemperature(t, currentUnit);
+ const tempColor = getAccessibleTemperatureColor(t, 'C'); // Now t is a number
+ const tempCategory = getTemperatureCategory(t);
+
+ // outline for stale, grey text for stale
+ const outlineStyle = isStale ? 'border: 2px solid grey;' : '';
+ const valueColor = isStale ? 'color: grey;' : '';
+
+ // determine stale styling
+ const bgColor = isStale ? '#ffffff20' : tempColor;
+ const staleFilter = isStale
+ ? 'backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);'
+ : '';
+
+ // If this is a grouped user point, modify the marker appearance
+ const groupLabel = isGrouped && item.pointCount > 1
+ ? `${item.pointCount}`
+ : '';
+
+ const groupStyle = isGrouped && item.pointCount > 1
+ ? 'border: 2px solid #fff; transform: scale(1.1);'
+ : '';
+
+ const icon = L.divIcon({
+ className: 'custom-temp-marker',
+ html: `
+
+ ${formattedTemp}
+ ${groupLabel}
+
+ `,
+ iconSize: [50, 40],
+ iconAnchor: [25, 20],
+ });
+
+ const marker = L.marker([lat, lon], { icon }).addTo(mapInstanceRef.current);
+
+ // Set group information for popup display
+ if (isGrouped && item.pointCount > 1) {
+ marker.isGrouped = true;
+ marker.pointCount = item.pointCount;
+ }
+
+ // bump this marker to the top on hover
+ marker.on('mouseover', () => {
+ marker.setZIndexOffset(1000);
+ });
+ // reset when mouse leaves
+ marker.on('mouseout', () => {
+ marker.setZIndexOffset(0);
+ });
+
+ // Add function to announce to screen readers (this was missing)
+ function announceToScreenReader(message) {
+ const announcement = document.createElement('div');
+ announcement.setAttribute('aria-live', 'polite');
+ announcement.setAttribute('aria-atomic', 'true');
+ announcement.className = 'sr-only';
+ announcement.textContent = message;
+
+ document.body.appendChild(announcement);
+
+ // Remove after announcement
+ setTimeout(() => {
+ if (document.body.contains(announcement)) {
+ document.body.removeChild(announcement);
+ }
+ }, 1000);
+ }
+
+ // Add click handler FIRST (this is the main functionality)
+ marker.on('click', async () => {
+ // add prefix for grey (stale) points
+ const labelPrefix = isStale ? 'OLD: ' : '';
+
+ // add prefix for grouped points
+ const groupPrefix = isGrouped && item.pointCount > 1
+ ? `GROUP: ${item.pointCount} points within 1km • `
+ : '';
+
+ let historicalData = [];
+
+ // For grouped user points, use the collected historical points
+ if (isGrouped && item.historicalPoints && item.historicalPoints.length > 0) {
+ historicalData = item.historicalPoints;
+ } else {
+ // For non-grouped points, fetch data from API as usual
+ historicalData = await fetchHistoricalData(name);
+ }
+
+ const popupOffset = [17, -32];
+
+ // no historical data
+ if (historicalData.length === 0) {
+ // Ensure popup is rebinding so it can reopen
+ marker.closePopup();
+ marker.unbindPopup();
+ marker.bindPopup(`
+
+
+
No historical data available
+ ${
+ isStale
+ ? `
Last updated at: ${new Date(rawTime).toLocaleString()}
`
+ : `
Current temperature: ${formattedTemp} (${tempCategory})
`
+ }
+ ${isGrouped && item.pointCount > 1 ? '
This is a group of multiple user points within 1km radius. The most recent temperature is shown.
' : ''}
+
+ `, {
+ offset: popupOffset,
+ className: 'custom-popup'
+ });
+ marker.openPopup();
+ return;
+ }
+
+ if (historicalData.length < 2) {
+ // Rebind popup to guarantee it opens on every click
+ marker.closePopup();
+ marker.unbindPopup();
+ marker.bindPopup(`
+
+
+
Not enough data to generate a graph.
+
Last updated at: ${new Date(rawTime).toLocaleString()}
+
+ `, {
+ offset: popupOffset,
+ className: 'custom-popup'
+ });
+ marker.openPopup();
+ return;
+ }
+
+ // Sort the historical data by timestamp
+ const sortedData = historicalData.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
+
+ // Declare currentUnit BEFORE using it
+ const currentUnit = UnitManager.getUnit();
+
+ // Create time-based data for proper spacing
+ const timeBasedData = sortedData.map((d) => ({
+ x: new Date(d.timestamp),
+ y: currentUnit === 'F' ? (d.temp * 9) / 5 + 32 : d.temp
+ }));
+
+ const timeDifferences = [];
+ for (let i = 1; i < sortedData.length; i++) {
+ const prevTime = new Date(sortedData[i - 1].timestamp);
+ const currTime = new Date(sortedData[i].timestamp);
+ const diffHours = Math.round((currTime - prevTime) / (1000 * 60 * 60));
+ timeDifferences.push(`${diffHours}h gap`);
+ }
+
+ // Create graph container with responsive dimensions
+ const graphContainer = document.createElement('div');
+ graphContainer.className = 'historical-data-container';
+
+ // Detect mobile viewport
+ const isMobile = window.innerWidth <= 768;
+ const isSmallMobile = window.innerWidth <= 480;
+
+ // Set responsive dimensions
+ if (isSmallMobile) {
+ graphContainer.style.width = '340px';
+ graphContainer.style.height = '300px';
+ graphContainer.style.padding = '8px';
+ } else if (isMobile) {
+ graphContainer.style.width = '450px';
+ graphContainer.style.height = '350px';
+ graphContainer.style.padding = '10px';
+ } else {
+ graphContainer.style.width = '600px';
+ graphContainer.style.height = '420px';
+ graphContainer.style.padding = '15px';
+ }
+
+ graphContainer.style.backgroundColor = window.globalTheme === 'dark' ? '#1a1a1a' : '#ffffff';
+ graphContainer.style.borderRadius = isMobile ? '8px' : '12px';
+ graphContainer.style.boxShadow = window.globalTheme === 'dark'
+ ? '0 8px 32px rgba(0,0,0,0.5)'
+ : '0 8px 32px rgba(0,0,0,0.15)';
+ graphContainer.style.border = window.globalTheme === 'dark' ? '1px solid #333' : '1px solid #ddd';
+
+ const canvas = document.createElement('canvas');
+ canvas.style.width = '100%';
+ canvas.style.height = 'calc(100% - 40px)';
+
+ // Set canvas dimensions based on screen size
+ if (isSmallMobile) {
+ canvas.width = 320;
+ canvas.height = 240;
+ } else if (isMobile) {
+ canvas.width = 420;
+ canvas.height = 290;
+ } else {
+ canvas.width = 570;
+ canvas.height = 380;
+ }
+ graphContainer.appendChild(canvas);
+
+ console.log('Creating chart with time-based data:', timeBasedData);
+
+ // Create the chart with time scale - this gives proper spacing
+ const chart = new Chart(canvas, {
+ type: 'line',
+ data: {
+ datasets: [
+ {
+ data: timeBasedData, // Use time-based data {x: timestamp, y: value}
+ borderColor: window.globalTheme === 'dark' ? 'rgba(0, 217, 255, 1)' : 'rgba(75, 192, 192, 1)',
+ backgroundColor: window.globalTheme === 'dark' ? 'rgba(0, 217, 255, 0.1)' : 'rgba(75, 192, 192, 0.2)',
+ fill: true,
+ tension: 0.3,
+ pointRadius: 6,
+ pointHoverRadius: 10,
+ pointBackgroundColor: window.globalTheme === 'dark' ? 'rgba(0, 217, 255, 1)' : 'rgba(75, 192, 192, 1)',
+ pointBorderColor: window.globalTheme === 'dark' ? '#000' : '#fff',
+ pointBorderWidth: 2,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ title: {
+ display: true,
+ text: isGrouped && item.pointCount > 1
+ ? `Historical Data for Group (${item.pointCount} points within 1km)`
+ : `Historical Data for ${name}`,
+ font: {
+ size: isSmallMobile ? 14 : (isMobile ? 16 : 18),
+ weight: 'bold'
+ },
+ color: window.globalTheme === 'dark' ? '#fff' : '#333',
+ padding: isSmallMobile ? 10 : (isMobile ? 15 : 20)
+ },
+ legend: {
+ display: false
+ },
+ tooltip: {
+ backgroundColor: window.globalTheme === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.95)',
+ titleColor: window.globalTheme === 'dark' ? '#fff' : '#000',
+ bodyColor: window.globalTheme === 'dark' ? '#fff' : '#000',
+ borderColor: window.globalTheme === 'dark' ? '#444' : '#ddd',
+ borderWidth: 1,
+ displayColors: false,
+ callbacks: {
+ title: function(context) {
+ const date = new Date(context[0].parsed.x);
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
+ },
+ label: function(context) {
+ return `${context.parsed.y.toFixed(1)}°${currentUnit}`;
+ },
+ afterBody: function(context) {
+ const dataIndex = context[0].dataIndex;
+ let info = [];
+
+ // Add gap information
+ if (dataIndex > 0 && timeDifferences[dataIndex - 1]) {
+ info.push(`Gap from previous: ${timeDifferences[dataIndex - 1]}`);
+ }
+
+ // For grouped points, add additional information
+ if (isGrouped && item.pointCount > 1) {
+ info.push(`From group of ${item.pointCount} user points within 1km`);
+ }
+
+ return info;
+ }
+ }
+ }
+ },
+ scales: {
+ x: {
+ type: 'linear', // Use linear instead of time to avoid adapter issues
+ position: 'bottom',
+ title: {
+ display: !isSmallMobile, // Hide title on very small screens
+ text: 'Date & Time',
+ color: window.globalTheme === 'dark' ? '#fff' : '#333',
+ font: {
+ size: isSmallMobile ? 10 : (isMobile ? 12 : 14),
+ weight: 'bold'
+ }
+ },
+ ticks: {
+ color: window.globalTheme === 'dark' ? '#ccc' : '#666',
+ maxRotation: isMobile ? 45 : 45,
+ font: {
+ size: isSmallMobile ? 8 : (isMobile ? 9 : 10)
+ },
+ maxTicksLimit: isSmallMobile ? 3 : (isMobile ? 4 : 5), // Fewer ticks on mobile
+ callback: function(value) {
+ // The value here is the actual timestamp (milliseconds)
+ const date = new Date(value);
+ if (isNaN(date.getTime())) return ''; // Invalid date
+
+ // Mobile-friendly date formatting
+ if (isSmallMobile) {
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ } else if (isMobile) {
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + '\n' +
+ date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
+ } else {
+ return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
+ }
+ }
+ },
+ grid: {
+ color: window.globalTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
+ }
+ },
+ y: {
+ title: {
+ display: !isSmallMobile, // Hide title on very small screens
+ text: `Temperature (°${currentUnit})`,
+ color: window.globalTheme === 'dark' ? '#fff' : '#333',
+ font: {
+ size: isSmallMobile ? 10 : (isMobile ? 12 : 14),
+ weight: 'bold'
+ }
+ },
+ ticks: {
+ color: window.globalTheme === 'dark' ? '#ccc' : '#666',
+ font: {
+ size: isSmallMobile ? 8 : (isMobile ? 9 : 11)
+ },
+ callback: function(value) {
+ return `${value}°${currentUnit}`;
+ }
+ },
+ grid: {
+ color: window.globalTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
+ }
+ },
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index'
+ }
+ },
+ });
+
+ console.log('Chart created:', chart);
+
+ marker.chartInstance = chart;
+ marker.chartData = sortedData;
+ marker.chartContainer = graphContainer; // Store reference to the container
+
+ // Open popup with responsive sizing
+ const popup = L.popup({
+ offset: popupOffset,
+ maxWidth: isSmallMobile ? 360 : (isMobile ? 480 : 650),
+ maxHeight: isSmallMobile ? 320 : (isMobile ? 370 : 470),
+ className: 'custom-popup mobile-optimized-popup',
+ autoPan: true,
+ autoPanPadding: [10, 10]
+ })
+ .setLatLng([lat, lon])
+ .setContent(graphContainer)
+ .openOn(mapInstanceRef.current);
+
+ // Force chart resize after popup is opened
+ setTimeout(() => {
+ if (chart && chart.resize) {
+ chart.resize();
+ console.log('Chart resized');
+ }
+ }, 100);
+
+ // Add resize handler for mobile orientation changes
+ const handleResize = () => {
+ if (chart && chart.resize) {
+ setTimeout(() => {
+ chart.resize();
+ console.log('Chart resized after orientation change');
+ }, 200);
+ }
+ };
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('orientationchange', handleResize);
+
+ // Store cleanup function for the resize handlers
+ marker._resizeCleanup = () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('orientationchange', handleResize);
+ };
+ });
+
+ // Add keyboard accessibility AFTER the click handler
+ marker.on('add', () => {
+ // Small delay to ensure DOM is ready
+ setTimeout(() => {
+ const markerElement = marker.getElement();
+ if (markerElement) {
+ const tempLabel = markerElement.querySelector('.temp-label');
+ if (tempLabel) {
+ // Add keyboard event listeners
+ tempLabel.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ console.log('Keyboard trigger for:', name); // Debug log
+ marker.fire('click'); // This should trigger the click handler
+ }
+ });
+
+ // Add focus styling
+ tempLabel.addEventListener('focus', () => {
+ tempLabel.style.outline = '3px solid #0066cc';
+ tempLabel.style.outlineOffset = '2px';
+ });
+
+ tempLabel.addEventListener('blur', () => {
+ tempLabel.style.outline = 'none';
+ });
+ }
+ }
+ }, 100);
+ });
+
+ markersRef.current.push({ marker, tempC: t, name, lat, lon, timestamp: ts });
+ }
+
// Update the addMarkers function
function addMarkers(items) {
// First, separate user points from API points
@@ -275,458 +727,15 @@ export default function MapComponent() {
groupedUserPoints.forEach((item, i) => {
addMarkerForItem(item, i, true);
});
-
- // Function to add a marker for a single item
- function addMarkerForItem(item, i, isGrouped = false) {
- const lon = item.lng || item.lon || item.Longitude;
- const lat = item.lat || item.Latitude;
- const t = item.temp || item.Result;
- const name = item.siteName || item.Label || `User Point ${i + 1}`;
-
- // determine age (fallback to createdAt for user‐points)
- const rawTime = item.timestamp || item.createdAt || item.created_at;
- const ts = new Date(rawTime).getTime();
- const isStale = !isNaN(ts) && (Date.now() - ts) > 2 * 24 * 60 * 60 * 1000; // older than 2 days
-
- const currentUnit = UnitManager.getUnit();
- const formattedTemp = formatTemperature(t, currentUnit);
- const tempColor = getAccessibleTemperatureColor(t, 'C');
- const tempCategory = getTemperatureCategory(t);
-
- // outline for stale, grey text for stale
- const outlineStyle = isStale ? 'border: 2px solid grey;' : '';
- const valueColor = isStale ? 'color: grey;' : '';
-
- // determine stale styling
- const bgColor = isStale ? '#ffffff20' : tempColor;
- const staleFilter = isStale
- ? 'backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);'
- : '';
-
- // If this is a grouped user point, modify the marker appearance
- const groupLabel = isGrouped && item.pointCount > 1
- ? `${item.pointCount}`
- : '';
-
- const groupStyle = isGrouped && item.pointCount > 1
- ? 'border: 2px solid #fff; transform: scale(1.1);'
- : '';
-
- const icon = L.divIcon({
- className: 'custom-temp-marker',
- html: `
-
- ${formattedTemp}
- ${groupLabel}
-
- `,
- iconSize: [50, 40],
- iconAnchor: [25, 20],
- });
-
- const marker = L.marker([lat, lon], { icon }).addTo(mapInstanceRef.current);
-
- // Set group information for popup display
- if (isGrouped && item.pointCount > 1) {
- marker.isGrouped = true;
- marker.pointCount = item.pointCount;
- }
-
- // bump this marker to the top on hover
- marker.on('mouseover', () => {
- marker.setZIndexOffset(1000);
- });
- // reset when mouse leaves
- marker.on('mouseout', () => {
- marker.setZIndexOffset(0);
- });
-
- // Add function to announce to screen readers (this was missing)
- function announceToScreenReader(message) {
- const announcement = document.createElement('div');
- announcement.setAttribute('aria-live', 'polite');
- announcement.setAttribute('aria-atomic', 'true');
- announcement.className = 'sr-only';
- announcement.textContent = message;
-
- document.body.appendChild(announcement);
-
- // Remove after announcement
- setTimeout(() => {
- if (document.body.contains(announcement)) {
- document.body.removeChild(announcement);
- }
- }, 1000);
- }
-
- // Add click handler FIRST (this is the main functionality)
- marker.on('click', async () => {
- // add prefix for grey (stale) points
- const labelPrefix = isStale ? 'OLD: ' : '';
-
- // add prefix for grouped points
- const groupPrefix = isGrouped && item.pointCount > 1
- ? `GROUP: ${item.pointCount} points within 1km • `
- : '';
-
- let historicalData = [];
-
- // For grouped user points, use the collected historical points
- if (isGrouped && item.historicalPoints && item.historicalPoints.length > 0) {
- historicalData = item.historicalPoints;
- } else {
- // For non-grouped points, fetch data from API as usual
- historicalData = await fetchHistoricalData(name);
- }
-
- const popupOffset = [17, -32];
-
- // no historical data
- if (historicalData.length === 0) {
- // Ensure popup is rebinding so it can reopen
- marker.closePopup();
- marker.unbindPopup();
- marker.bindPopup(`
-
-
-
No historical data available
- ${
- isStale
- ? `
Last updated at: ${new Date(rawTime).toLocaleString()}
`
- : `
Current temperature: ${formattedTemp} (${tempCategory})
`
- }
- ${isGrouped && item.pointCount > 1 ? '
This is a group of multiple user points within 1km radius. The most recent temperature is shown.
' : ''}
-
- `, {
- offset: popupOffset,
- className: 'custom-popup'
- });
- marker.openPopup();
- return;
- }
-
- if (historicalData.length < 2) {
- // Rebind popup to guarantee it opens on every click
- marker.closePopup();
- marker.unbindPopup();
- marker.bindPopup(`
-
-
-
Not enough data to generate a graph.
-
Last updated at: ${new Date(rawTime).toLocaleString()}
-
- `, {
- offset: popupOffset,
- className: 'custom-popup'
- });
- marker.openPopup();
- return;
- }
-
- // Sort the historical data by timestamp
- const sortedData = historicalData.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
-
- // Declare currentUnit BEFORE using it
- const currentUnit = UnitManager.getUnit();
-
- // Create time-based data for proper spacing
- const timeBasedData = sortedData.map((d) => ({
- x: new Date(d.timestamp),
- y: currentUnit === 'F' ? (d.temp * 9) / 5 + 32 : d.temp
- }));
-
- const timeDifferences = [];
- for (let i = 1; i < sortedData.length; i++) {
- const prevTime = new Date(sortedData[i - 1].timestamp);
- const currTime = new Date(sortedData[i].timestamp);
- const diffHours = Math.round((currTime - prevTime) / (1000 * 60 * 60));
- timeDifferences.push(`${diffHours}h gap`);
- }
-
- // Create graph container with responsive dimensions
- const graphContainer = document.createElement('div');
- graphContainer.className = 'historical-data-container';
-
- // Detect mobile viewport
- const isMobile = window.innerWidth <= 768;
- const isSmallMobile = window.innerWidth <= 480;
-
- // Set responsive dimensions
- if (isSmallMobile) {
- graphContainer.style.width = '340px';
- graphContainer.style.height = '300px';
- graphContainer.style.padding = '8px';
- } else if (isMobile) {
- graphContainer.style.width = '450px';
- graphContainer.style.height = '350px';
- graphContainer.style.padding = '10px';
- } else {
- graphContainer.style.width = '600px';
- graphContainer.style.height = '420px';
- graphContainer.style.padding = '15px';
- }
-
- graphContainer.style.backgroundColor = window.globalTheme === 'dark' ? '#1a1a1a' : '#ffffff';
- graphContainer.style.borderRadius = isMobile ? '8px' : '12px';
- graphContainer.style.boxShadow = window.globalTheme === 'dark'
- ? '0 8px 32px rgba(0,0,0,0.5)'
- : '0 8px 32px rgba(0,0,0,0.15)';
- graphContainer.style.border = window.globalTheme === 'dark' ? '1px solid #333' : '1px solid #ddd';
-
- const canvas = document.createElement('canvas');
- canvas.style.width = '100%';
- canvas.style.height = 'calc(100% - 40px)';
-
- // Set canvas dimensions based on screen size
- if (isSmallMobile) {
- canvas.width = 320;
- canvas.height = 240;
- } else if (isMobile) {
- canvas.width = 420;
- canvas.height = 290;
- } else {
- canvas.width = 570;
- canvas.height = 380;
- }
- graphContainer.appendChild(canvas);
-
- console.log('Creating chart with time-based data:', timeBasedData);
-
- // Create the chart with time scale - this gives proper spacing
- const chart = new Chart(canvas, {
- type: 'line',
- data: {
- datasets: [
- {
- data: timeBasedData, // Use time-based data {x: timestamp, y: value}
- borderColor: window.globalTheme === 'dark' ? 'rgba(0, 217, 255, 1)' : 'rgba(75, 192, 192, 1)',
- backgroundColor: window.globalTheme === 'dark' ? 'rgba(0, 217, 255, 0.1)' : 'rgba(75, 192, 192, 0.2)',
- fill: true,
- tension: 0.3,
- pointRadius: 6,
- pointHoverRadius: 10,
- pointBackgroundColor: window.globalTheme === 'dark' ? 'rgba(0, 217, 255, 1)' : 'rgba(75, 192, 192, 1)',
- pointBorderColor: window.globalTheme === 'dark' ? '#000' : '#fff',
- pointBorderWidth: 2,
- },
- ],
- },
- options: {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- title: {
- display: true,
- text: isGrouped && item.pointCount > 1
- ? `Historical Data for Group (${item.pointCount} points within 1km)`
- : `Historical Data for ${name}`,
- font: {
- size: isSmallMobile ? 14 : (isMobile ? 16 : 18),
- weight: 'bold'
- },
- color: window.globalTheme === 'dark' ? '#fff' : '#333',
- padding: isSmallMobile ? 10 : (isMobile ? 15 : 20)
- },
- legend: {
- display: false
- },
- tooltip: {
- backgroundColor: window.globalTheme === 'dark' ? 'rgba(0, 0, 0, 0.9)' : 'rgba(255, 255, 255, 0.95)',
- titleColor: window.globalTheme === 'dark' ? '#fff' : '#000',
- bodyColor: window.globalTheme === 'dark' ? '#fff' : '#000',
- borderColor: window.globalTheme === 'dark' ? '#444' : '#ddd',
- borderWidth: 1,
- displayColors: false,
- callbacks: {
- title: function(context) {
- const date = new Date(context[0].parsed.x);
- return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
- },
- label: function(context) {
- return `${context.parsed.y.toFixed(1)}°${currentUnit}`;
- },
- afterBody: function(context) {
- const dataIndex = context[0].dataIndex;
- let info = [];
-
- // Add gap information
- if (dataIndex > 0 && timeDifferences[dataIndex - 1]) {
- info.push(`Gap from previous: ${timeDifferences[dataIndex - 1]}`);
- }
-
- // For grouped points, add additional information
- if (isGrouped && item.pointCount > 1) {
- info.push(`From group of ${item.pointCount} user points within 1km`);
- }
-
- return info;
- }
- }
- }
- },
- scales: {
- x: {
- type: 'linear', // Use linear instead of time to avoid adapter issues
- position: 'bottom',
- title: {
- display: !isSmallMobile, // Hide title on very small screens
- text: 'Date & Time',
- color: window.globalTheme === 'dark' ? '#fff' : '#333',
- font: {
- size: isSmallMobile ? 10 : (isMobile ? 12 : 14),
- weight: 'bold'
- }
- },
- ticks: {
- color: window.globalTheme === 'dark' ? '#ccc' : '#666',
- maxRotation: isMobile ? 45 : 45,
- font: {
- size: isSmallMobile ? 8 : (isMobile ? 9 : 10)
- },
- maxTicksLimit: isSmallMobile ? 3 : (isMobile ? 4 : 5), // Fewer ticks on mobile
- callback: function(value) {
- // The value here is the actual timestamp (milliseconds)
- const date = new Date(value);
- if (isNaN(date.getTime())) return ''; // Invalid date
-
- // Mobile-friendly date formatting
- if (isSmallMobile) {
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
- } else if (isMobile) {
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + '\n' +
- date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
- } else {
- return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
- }
- }
- },
- grid: {
- color: window.globalTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
- }
- },
- y: {
- title: {
- display: !isSmallMobile, // Hide title on very small screens
- text: `Temperature (°${currentUnit})`,
- color: window.globalTheme === 'dark' ? '#fff' : '#333',
- font: {
- size: isSmallMobile ? 10 : (isMobile ? 12 : 14),
- weight: 'bold'
- }
- },
- ticks: {
- color: window.globalTheme === 'dark' ? '#ccc' : '#666',
- font: {
- size: isSmallMobile ? 8 : (isMobile ? 9 : 11)
- },
- callback: function(value) {
- return `${value}°${currentUnit}`;
- }
- },
- grid: {
- color: window.globalTheme === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
- }
- },
- },
- interaction: {
- intersect: false,
- mode: 'index'
- }
- },
- });
-
- console.log('Chart created:', chart);
-
- marker.chartInstance = chart;
- marker.chartData = sortedData;
- marker.chartContainer = graphContainer; // Store reference to the container
-
- // Open popup with responsive sizing
- const popup = L.popup({
- offset: popupOffset,
- maxWidth: isSmallMobile ? 360 : (isMobile ? 480 : 650),
- maxHeight: isSmallMobile ? 320 : (isMobile ? 370 : 470),
- className: 'custom-popup mobile-optimized-popup',
- autoPan: true,
- autoPanPadding: [10, 10]
- })
- .setLatLng([lat, lon])
- .setContent(graphContainer)
- .openOn(mapInstanceRef.current);
-
- // Force chart resize after popup is opened
- setTimeout(() => {
- if (chart && chart.resize) {
- chart.resize();
- console.log('Chart resized');
- }
- }, 100);
-
- // Add resize handler for mobile orientation changes
- const handleResize = () => {
- if (chart && chart.resize) {
- setTimeout(() => {
- chart.resize();
- console.log('Chart resized after orientation change');
- }, 200);
- }
- };
-
- window.addEventListener('resize', handleResize);
- window.addEventListener('orientationchange', handleResize);
-
- // Store cleanup function for the resize handlers
- marker._resizeCleanup = () => {
- window.removeEventListener('resize', handleResize);
- window.removeEventListener('orientationchange', handleResize);
- };
- });
-
- // Add keyboard accessibility AFTER the click handler
- marker.on('add', () => {
- // Small delay to ensure DOM is ready
- setTimeout(() => {
- const markerElement = marker.getElement();
- if (markerElement) {
- const tempLabel = markerElement.querySelector('.temp-label');
- if (tempLabel) {
- // Add keyboard event listeners
- tempLabel.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- console.log('Keyboard trigger for:', name); // Debug log
- marker.fire('click'); // This should trigger the click handler
- }
- });
-
- // Add focus styling
- tempLabel.addEventListener('focus', () => {
- tempLabel.style.outline = '3px solid #0066cc';
- tempLabel.style.outlineOffset = '2px';
- });
-
- tempLabel.addEventListener('blur', () => {
- tempLabel.style.outline = 'none';
- });
- }
- }
- }, 100);
- });
+ }
- markersRef.current.push({ marker, tempC: t, name, lat, lon, timestamp: ts });
- }
+ // Function to check if we have API points (official water data)
+ function hasAPIPoints(items) {
+ if (!items || items.length === 0) return false;
+ // API points have siteName or Label, and are NOT user points
+ return items.some(item =>
+ (item.siteName || item.Label) && !item.isUserPoint
+ );
}
// Function to add water temperature markers
@@ -748,11 +757,19 @@ export default function MapComponent() {
// Set global variable immediately for HUDleftPoints
globalBeach = { items: cachedItems };
- // Notify HUDleftPoints that cached data is available
- window.dispatchEvent(new Event('dataloaded'));
-
- addMarkers(cachedItems);
- setLoading(false); // Hide loading immediately
+ // Check if we have API points in cache
+ if (hasAPIPoints(cachedItems)) {
+ // We have API points, load immediately
+ window.dispatchEvent(new Event('dataloaded'));
+ addMarkers(cachedItems);
+ setLoading(false); // Hide loading immediately
+ } else {
+ // No API points in cache, show loading and fetch fresh data
+ console.log('⚠️ Cache has no API points, fetching fresh data...');
+ setLoading(true);
+ window.loadedAPI = true;
+ // Continue to fetch fresh data below
+ }
} else if (cache) {
console.log('📦 Cache expired, loading old data while fetching fresh...');
const cachedItems = JSON.parse(cache);
@@ -761,8 +778,8 @@ export default function MapComponent() {
setLoading(false);
}
- // Step 2: Fetch fresh data in background (always fetch if cache is expired or missing)
- if (!isCacheValid) {
+ // Step 2: Fetch fresh data if no valid cache OR no API points in cache
+ if (!isCacheValid || (cache && !hasAPIPoints(JSON.parse(cache)))) {
console.log('🔄 Fetching fresh data...');
let officialData = { items: [] }, userData = { items: [] };
@@ -824,10 +841,12 @@ export default function MapComponent() {
}
setLoading(false);
+ window.loadedAPI = false; // Ensure loading state is cleared
} catch (err) {
console.error('fetch error →', err);
// If no cache was loaded and fetch failed, still hide loading
setLoading(false);
+ window.loadedAPI = false;
}
}
@@ -918,6 +937,25 @@ export default function MapComponent() {
// Add the theme event listener - THIS WAS MISSING!
window.addEventListener('themechange', onThemeChange);
+ // Add listener for when new points are added
+ const handlePointAdded = (event) => {
+ console.log('🔄 Point added, adding single marker to map...');
+ const newPoint = event.detail;
+
+ // Add the new point to globalBeach for HUDleftPoints
+ if (globalBeach && globalBeach.items) {
+ globalBeach.items.push(newPoint);
+ }
+
+ // Add single marker to the existing map
+ addMarkerForItem(newPoint, markersRef.current.length, true);
+
+ // Dispatch dataloaded event for HUDleftPoints to update
+ window.dispatchEvent(new Event('dataloaded'));
+ };
+
+ window.addEventListener('pointAdded', handlePointAdded);
+
// Function to update popup CSS dynamically
function updatePopupCSS() {
// Remove existing dynamic popup styles
@@ -964,6 +1002,7 @@ export default function MapComponent() {
return () => {
removeUnitListener(); // Clean up UnitManager listener
window.removeEventListener('themechange', onThemeChange);
+ window.removeEventListener('pointAdded', handlePointAdded);
if (mapInstanceRef.current) {
// Clean up ThemeManager listener
if (mapInstanceRef.current._themeCleanup) {