Skip to content

Commit dad3e8f

Browse files
authored
Merge pull request #9 from jameson-dev/feature/gps
Feature/gps
2 parents 87df536 + f2c979c commit dad3e8f

8 files changed

Lines changed: 305 additions & 29 deletions

File tree

1.4 MB
Binary file not shown.

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ <h2>Welcome to SA Crash Data Map</h2>
151151
</div>
152152
<div class="button-row">
153153
<button onclick="searchByLocation()" class="apply-filters-btn button-row-item" aria-label="Search for crashes within radius">Search</button>
154+
<button id="gpsBtn" onclick="useMyLocation()" class="apply-filters-btn button-row-item gps-btn" aria-label="Use device GPS to find crashes near me" title="Use my GPS location">📍 GPS</button>
154155
<button onclick="clearLocationSearch()" class="clear-filters-btn button-row-item" aria-label="Clear location search">Clear</button>
155156
</div>
156157
<div id="searchResults" class="search-results-box hidden"></div>

src/js/filters.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import {
1616
dataState,
1717
filterState,
1818
drawState,
19+
searchState,
20+
mapState,
1921
updateFilterState
2022
} from './state.js';
2123
import { updateStatistics } from './analytics.js';
22-
import { showLoading, hideLoading, updateLoadingMessage } from './utils.js';
24+
import { showLoading, hideLoading, updateLoadingMessage, getSearchRadiusKm } from './utils.js';
2325
import { showNotification } from './ui.js';
2426
import { filterCache, perfMonitor, debounce } from './performance.js';
2527
import { updateMapLayers } from './map-renderer.js';
@@ -975,6 +977,45 @@ export async function applyFilters() {
975977
);
976978
}
977979

980+
// Apply GPS radius as an additional spatial constraint if active
981+
if (searchState.gpsLocation && mapState.map) {
982+
const { lat, lng } = searchState.gpsLocation;
983+
const radiusKm = getSearchRadiusKm();
984+
const radiusMeters = radiusKm * 1000;
985+
986+
// Bounding-box prefilter: cheap lat/lng comparison rejects the far-field
987+
// before the expensive per-point Leaflet distance call.
988+
const latDelta = radiusMeters / 111320;
989+
const lngDelta = radiusMeters / (111320 * Math.cos(lat * Math.PI / 180));
990+
const latMin = lat - latDelta;
991+
const latMax = lat + latDelta;
992+
const lngMin = lng - lngDelta;
993+
const lngMax = lng + lngDelta;
994+
995+
filteredData = filteredData.filter(crash => {
996+
const coords = crash._coords;
997+
if (!coords) return false;
998+
const [cLat, cLng] = coords;
999+
if (cLat < latMin || cLat > latMax || cLng < lngMin || cLng > lngMax) return false;
1000+
return mapState.map.distance([lat, lng], coords) <= radiusMeters;
1001+
});
1002+
const resultsEl = document.getElementById('searchResults');
1003+
if (resultsEl) {
1004+
resultsEl.textContent = `Found ${filteredData.length} crashes within ${radiusKm}km of your location`;
1005+
resultsEl.dataset.source = 'gps';
1006+
resultsEl.classList.remove('hidden');
1007+
}
1008+
} else {
1009+
// GPS inactive: clear only if the currently shown text belongs to a past
1010+
// GPS session, so text-search results stay visible.
1011+
const resultsEl = document.getElementById('searchResults');
1012+
if (resultsEl && resultsEl.dataset.source === 'gps') {
1013+
resultsEl.textContent = '';
1014+
delete resultsEl.dataset.source;
1015+
resultsEl.classList.add('hidden');
1016+
}
1017+
}
1018+
9781019
// Update filtered data in state
9791020
dataState.filteredData = filteredData;
9801021

src/js/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ async function setupGlobalHandlers() {
139139
window.cancelDrawMode = map.cancelDrawMode;
140140
window.clearDrawArea = map.clearDrawArea;
141141
window.selectSuggestion = map.selectSuggestion;
142+
window.useMyLocation = map.useMyLocation;
142143
}
143144

144145
// Wait for DOM to be ready

src/js/map-renderer.js

Lines changed: 175 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,22 @@ import {
1010
drawState,
1111
searchState,
1212
cacheState,
13-
updateMapState,
14-
updateDrawState,
15-
updateFilterState,
16-
updateSearchState,
17-
updateCacheState,
18-
clearSearchState,
19-
clearDrawState
13+
updateFilterState
2014
} from './state.js';
2115

2216
import {
2317
SEVERITY_COLORS,
24-
CRASH_TYPE_PALETTE,
25-
MARKER_CONFIG
18+
CRASH_TYPE_PALETTE
2619
} from './config.js';
2720

2821
import {
29-
convertCoordinates,
3022
escapeHtml,
3123
normalizeLGAName,
3224
getLGAName,
3325
showLoading,
3426
hideLoading,
35-
updateLoadingMessage
27+
updateLoadingMessage,
28+
getSearchRadiusKm
3629
} from './utils.js';
3730

3831
import { updateStatistics } from './analytics.js';
@@ -264,6 +257,22 @@ export function initMap() {
264257
}
265258
`;
266259
document.head.appendChild(style);
260+
261+
// Re-run GPS filter when the radius selector changes (only when GPS mode is active).
262+
// Guard against double-binding if init ever runs twice (dataset flag).
263+
const radiusSelect = document.getElementById('searchRadius');
264+
if (radiusSelect && radiusSelect.dataset.gpsBound !== '1') {
265+
radiusSelect.dataset.gpsBound = '1';
266+
radiusSelect.addEventListener('change', () => {
267+
if (searchState.gpsLocation) {
268+
const { lat, lng } = searchState.gpsLocation;
269+
updateGpsCircle(lat, lng, false);
270+
if (typeof window.applyFilters === 'function') {
271+
window.applyFilters();
272+
}
273+
}
274+
});
275+
}
267276
}
268277

269278
// ============================================================================
@@ -842,15 +851,15 @@ export function addChoropleth() {
842851
`);
843852

844853
// Hover effects
845-
layer.on('mouseover', function(e) {
854+
layer.on('mouseover', function() {
846855
this.setStyle({
847856
weight: 3,
848857
opacity: 1,
849858
fillOpacity: 0.8
850859
});
851860
});
852861

853-
layer.on('mouseout', function(e) {
862+
layer.on('mouseout', function() {
854863
mapState.choroplethLayer.resetStyle(this);
855864
});
856865
}
@@ -964,15 +973,15 @@ export function addChoroplethBySuburb() {
964973
`);
965974

966975
// Hover effects
967-
layer.on('mouseover', function(e) {
976+
layer.on('mouseover', function() {
968977
this.setStyle({
969978
weight: 2,
970979
opacity: 1,
971980
fillOpacity: 0.8
972981
});
973982
});
974983

975-
layer.on('mouseout', function(e) {
984+
layer.on('mouseout', function() {
976985
mapState.choroplethLayer.resetStyle(this);
977986
});
978987
}
@@ -1409,15 +1418,23 @@ export function clearLocationSearch() {
14091418
searchState.searchCircle = null;
14101419
}
14111420

1421+
// Clear GPS state so the radius listener stops re-filtering
1422+
searchState.gpsLocation = null;
1423+
document.getElementById('gpsBtn')?.classList.remove('gps-btn-active');
1424+
14121425
// Clear input and results
14131426
const input = document.getElementById('locationSearch');
14141427
const results = document.getElementById('searchResults');
14151428
if (input) input.value = '';
1416-
if (results) results.style.display = 'none';
1429+
if (results) {
1430+
results.textContent = '';
1431+
delete results.dataset.source;
1432+
results.classList.add('hidden');
1433+
}
14171434

1418-
// Mark filters as changed to trigger proper state tracking
1419-
if (typeof window.markFiltersChanged === 'function') {
1420-
window.markFiltersChanged();
1435+
// Re-run filters so markers reflect the remaining active filters
1436+
if (typeof window.applyFilters === 'function') {
1437+
window.applyFilters();
14211438
}
14221439
}
14231440

@@ -1460,9 +1477,13 @@ export async function searchByLocation() {
14601477
const lng = parseFloat(location.lon);
14611478

14621479
// Get search radius
1463-
const radiusKm = parseFloat(document.getElementById('searchRadius')?.value || 5);
1480+
const radiusKm = getSearchRadiusKm();
14641481
const radiusMeters = radiusKm * 1000;
14651482

1483+
// Entering text search clears GPS mode
1484+
searchState.gpsLocation = null;
1485+
document.getElementById('gpsBtn')?.classList.remove('gps-btn-active');
1486+
14661487
// Clear previous search marker/circle
14671488
if (searchState.searchMarker) {
14681489
mapState.map.removeLayer(searchState.searchMarker);
@@ -1493,14 +1514,20 @@ export async function searchByLocation() {
14931514
// Zoom to search area
14941515
mapState.map.fitBounds(searchState.searchCircle.getBounds(), { padding: [50, 50] });
14951516

1496-
// Filter crashes within radius
1517+
// Filter crashes within radius using pre-cached coords.
1518+
// Bounding-box prefilter avoids an expensive distance call for far-field points.
1519+
const latDelta = radiusMeters / 111320;
1520+
const lngDelta = radiusMeters / (111320 * Math.cos(lat * Math.PI / 180));
1521+
const latMin = lat - latDelta;
1522+
const latMax = lat + latDelta;
1523+
const lngMin = lng - lngDelta;
1524+
const lngMax = lng + lngDelta;
14971525
const nearbyCrashes = dataState.crashData.filter(crash => {
1498-
const coords = convertCoordinates(crash.ACCLOC_X, crash.ACCLOC_Y);
1526+
const coords = crash._coords;
14991527
if (!coords) return false;
1500-
1501-
const [crashLat, crashLng] = coords;
1502-
const distance = mapState.map.distance([lat, lng], [crashLat, crashLng]);
1503-
return distance <= radiusMeters;
1528+
const [cLat, cLng] = coords;
1529+
if (cLat < latMin || cLat > latMax || cLng < lngMin || cLng > lngMax) return false;
1530+
return mapState.map.distance([lat, lng], coords) <= radiusMeters;
15041531
});
15051532

15061533
hideLoading();
@@ -1510,7 +1537,8 @@ export async function searchByLocation() {
15101537
const resultsEl = document.getElementById('searchResults');
15111538
if (resultsEl) {
15121539
resultsEl.textContent = resultMsg;
1513-
resultsEl.style.display = 'block';
1540+
resultsEl.dataset.source = 'search';
1541+
resultsEl.classList.remove('hidden');
15141542
}
15151543

15161544
// Apply filter to show only nearby crashes
@@ -1525,6 +1553,126 @@ export async function searchByLocation() {
15251553
}
15261554
}
15271555

1556+
/**
1557+
* Draw/update the GPS radius circle. Visual only — filtering is handled by applyFilters().
1558+
*/
1559+
function updateGpsCircle(lat, lng, fitBounds) {
1560+
const radiusMeters = getSearchRadiusKm() * 1000;
1561+
1562+
if (searchState.searchCircle) {
1563+
mapState.map.removeLayer(searchState.searchCircle);
1564+
}
1565+
searchState.searchCircle = L.circle([lat, lng], {
1566+
radius: radiusMeters,
1567+
color: '#22c55e',
1568+
fillColor: '#22c55e',
1569+
fillOpacity: 0.08,
1570+
weight: 2
1571+
}).addTo(mapState.map);
1572+
1573+
if (fitBounds) {
1574+
mapState.map.fitBounds(searchState.searchCircle.getBounds(), { padding: [50, 50] });
1575+
}
1576+
}
1577+
1578+
/**
1579+
* Use the device GPS to centre the map on the user and filter nearby crashes
1580+
*/
1581+
export function useMyLocation() {
1582+
// Modern browsers require a secure context for geolocation. Without this
1583+
// check the call can fail silently or surface a generic permission error.
1584+
if (!window.isSecureContext) {
1585+
showNotification('GPS requires a secure (HTTPS) connection.', 'warning');
1586+
return;
1587+
}
1588+
if (!navigator.geolocation) {
1589+
showNotification('Geolocation is not supported by your browser.', 'warning');
1590+
return;
1591+
}
1592+
1593+
const btn = document.getElementById('gpsBtn');
1594+
// Re-entrancy guard: a second click while a request is in flight would
1595+
// race two callbacks that each overwrite the marker/circle and re-trigger
1596+
// applyFilters with divergent positions.
1597+
if (btn?.dataset.pending === '1') return;
1598+
if (btn) {
1599+
btn.dataset.pending = '1';
1600+
btn.setAttribute('disabled', '');
1601+
}
1602+
1603+
showLoading('Getting your location...');
1604+
1605+
const finish = () => {
1606+
hideLoading();
1607+
if (btn) {
1608+
btn.dataset.pending = '0';
1609+
btn.removeAttribute('disabled');
1610+
}
1611+
};
1612+
1613+
navigator.geolocation.getCurrentPosition(
1614+
(position) => {
1615+
const lat = position.coords.latitude;
1616+
const lng = position.coords.longitude;
1617+
const accuracy = position.coords.accuracy;
1618+
1619+
searchState.gpsLocation = { lat, lng };
1620+
1621+
if (searchState.searchMarker) {
1622+
mapState.map.removeLayer(searchState.searchMarker);
1623+
}
1624+
if (searchState.searchCircle) {
1625+
mapState.map.removeLayer(searchState.searchCircle);
1626+
}
1627+
1628+
const radiusMeters = getSearchRadiusKm() * 1000;
1629+
const accuracyText = Number.isFinite(accuracy)
1630+
? `<br><small>Accuracy: ±${Math.round(accuracy)} m</small>`
1631+
: '';
1632+
const popupHtml = `<strong>📍 Your Location</strong>${accuracyText}`;
1633+
1634+
searchState.searchMarker = L.marker([lat, lng], {
1635+
icon: L.divIcon({
1636+
className: '',
1637+
html: '<div class="gps-marker-dot"><div class="gps-marker-pulse"></div></div>',
1638+
iconSize: [20, 20],
1639+
iconAnchor: [10, 10]
1640+
})
1641+
}).addTo(mapState.map).bindPopup(popupHtml).openPopup();
1642+
1643+
updateGpsCircle(lat, lng, true);
1644+
btn?.classList.add('gps-btn-active');
1645+
1646+
// Warn when the reported accuracy is larger than the active radius —
1647+
// the "nearby crashes" result is unlikely to be meaningful in that case.
1648+
if (Number.isFinite(accuracy) && accuracy > radiusMeters) {
1649+
showNotification(
1650+
`Your location accuracy (±${Math.round(accuracy)} m) is larger than the ${radiusMeters / 1000} km search radius. Results may be inaccurate.`,
1651+
'warning'
1652+
);
1653+
}
1654+
1655+
finish();
1656+
1657+
// Delegate filtering to applyFilters so GPS stacks on top of active panel filters
1658+
if (typeof window.applyFilters === 'function') {
1659+
window.applyFilters();
1660+
}
1661+
},
1662+
(error) => {
1663+
finish();
1664+
console.error('Geolocation error:', error);
1665+
const messages = {
1666+
1: 'Location access denied. Please allow location access in your browser settings.',
1667+
2: 'Unable to determine your location. Please try again.',
1668+
3: 'Location request timed out. Please try again.'
1669+
};
1670+
showNotification(messages[error.code] || 'Could not get your location.', 'warning');
1671+
},
1672+
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
1673+
);
1674+
}
1675+
15281676
/**
15291677
* Toggle location search collapse/expand
15301678
*/

src/js/state.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export const searchState = {
5252
searchMarker: null,
5353
searchCircle: null,
5454
selectedSuggestionIndex: -1,
55-
currentSuggestions: []
55+
currentSuggestions: [],
56+
gpsLocation: null // { lat, lng } when GPS mode is active
5657
};
5758

5859
// UI state
@@ -128,6 +129,7 @@ export function clearSearchState() {
128129
searchState.searchCircle = null;
129130
searchState.selectedSuggestionIndex = -1;
130131
searchState.currentSuggestions = [];
132+
searchState.gpsLocation = null;
131133
}
132134

133135
export function clearDrawState() {

0 commit comments

Comments
 (0)