LIVE
grassrootspulse.co.uk · The Heartbeat of English Grassroots Rugby Midlands · Levels 5–10
📐 Travel Analysis
League Distance Matrix
Crow-flies distances between every club in a league · All 24 Midlands leagues
← Club Map
Level
L5
L6
L7
L8
L9
L10
Loading road distances…
📐
Select a League
Choose a level and league above to see the distance matrix
const LEVEL_NAMES = { 5: 'Regional 1', 6: 'Regional 2', 7: 'Counties 1', 8: 'Counties 2', 9: 'Counties 3', 10: 'Counties 4' }; // All 24 men's leagues grouped by level — exact RFU names matching Supabase const LEAGUES_BY_LEVEL = { 5: ['Regional 1 Midlands'], 6: ['Regional 2 Midlands East', 'Regional 2 Midlands North', 'Regional 2 Midlands West'], 7: ['Counties 1 Midlands East (North)', 'Counties 1 Midlands East (South)', 'Counties 1 Midlands West (North)', 'Counties 1 Midlands West (South)'], 8: ['Counties 2 Midlands East (North)', 'Counties 2 Midlands East (South)', 'Counties 2 Midlands West (East)', 'Counties 2 Midlands West (West)'], 9: ['Counties 3 Midlands East (North East)', 'Counties 3 Midlands East (North West)', 'Counties 3 Midlands East (South North)', 'Counties 3 Midlands East (South South)', 'Counties 3 Midlands West (East)', 'Counties 3 Midlands West (North)', 'Counties 3 Midlands West (South)'], 10: ['Counties 4 Midlands East (North East)', 'Counties 4 Midlands East (North West)', 'Counties 4 Midlands West (East)', 'Counties 4 Midlands West (North)', 'Counties 4 Midlands West (South)'] }; let activeLevel = 5; let allClubsCache = {}; // cache clubs by league_name to avoid re-fetching // ── MOBILE NAV ────────────────────────────────────────────────────────── function toggleMobileNav() { document.getElementById('navHamburger').classList.toggle('open'); document.getElementById('mobileNav').classList.toggle('open'); } // ── HAVERSINE ─────────────────────────────────────────────────────────── function haversineMiles(lat1, lng1, lat2, lng2) { const R = 3958.8; const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLng/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } // ── LEVEL SELECTION ────────────────────────────────────────────────────── function selectLevel(level, btn) { activeLevel = level; document.querySelectorAll('#levelChips .chip').forEach(c => c.classList.remove('active')); btn.classList.add('active'); populateLeagueSelect(level); // Clear matrix document.getElementById('matrixContainer').innerHTML = `
📐
Select a League
Choose a league from the dropdown above
`; document.getElementById('statsStrip').style.display = 'none'; document.getElementById('heatKey').style.display = 'none'; document.getElementById('tripsSection').style.display = 'none'; } function populateLeagueSelect(level) { const sel = document.getElementById('leagueSelect'); const leagues = LEAGUES_BY_LEVEL[level] || []; sel.innerHTML = `` + leagues.map(l => ``).join(''); } // ── LOAD LEAGUE ────────────────────────────────────────────────────────── async function loadLeague(leagueName) { if (!leagueName) return; // Show loading state document.getElementById('matrixContainer').innerHTML = `
Loading clubs…
`; document.getElementById('statsStrip').style.display = 'none'; document.getElementById('heatKey').style.display = 'none'; document.getElementById('tripsSection').style.display = 'none'; try { // Check cache first let clubs = allClubsCache[leagueName]; if (!clubs) { const res = await fetch( `${SUPABASE_URL}/rest/v1/clubs?select=id,club_name,latitude,longitude&league_name=eq.${encodeURIComponent(leagueName)}&latitude=not.is.null&order=club_name.asc`, { headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` } } ); clubs = await res.json(); allClubsCache[leagueName] = clubs; } if (!clubs || clubs.length < 2) { document.getElementById('matrixContainer').innerHTML = `
⚠️
Not Enough Data
Fewer than 2 clubs with coordinates found in this league
`; return; } buildMatrix(clubs, leagueName); } catch(err) { document.getElementById('matrixContainer').innerHTML = `
⚠️
Failed to Load
Could not fetch clubs. Please refresh and try again.
`; } } // ── BUILD MATRIX ────────────────────────────────────────────────────────── function buildMatrix(clubs, leagueName) { const n = clubs.length; // Calculate all pairs const distances = {}; let allDists = []; for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { if (i === j) continue; const d = haversineMiles( clubs[i].latitude, clubs[i].longitude, clubs[j].latitude, clubs[j].longitude ); distances[`${i}-${j}`] = d; if (i < j) allDists.push({ d, i, j }); } } allDists.sort((a, b) => b.d - a.d); const maxDist = allDists[0]; const minDist = allDists[allDists.length - 1]; const avgDist = allDists.reduce((s, x) => s + x.d, 0) / allDists.length; // Thresholds for heat colouring const close = 15, medium = 30; function distClass(d, i, j) { const isMax = (i === maxDist.i && j === maxDist.j) || (i === maxDist.j && j === maxDist.i); const isMin = (i === minDist.i && j === minDist.j) || (i === minDist.j && j === minDist.i); if (isMax) return 'dist-max'; if (isMin) return 'dist-min'; if (d < close) return 'dist-close'; if (d < medium) return 'dist-medium'; return 'dist-far'; } // Build table HTML let html = `
`; // Header row html += ``; clubs.forEach(c => { // Truncate long names for column headers const short = c.club_name.length > 12 ? c.club_name.substring(0, 11) + '…' : c.club_name; html += ``; }); html += ``; // Data rows clubs.forEach((rowClub, i) => { html += ``; clubs.forEach((colClub, j) => { if (i === j) { html += ``; } else { const d = distances[`${i}-${j}`]; const cls = distClass(d, i, j); html += ``; } }); html += ``; }); html += `
Club${short}
${rowClub.club_name}${d.toFixed(1)}
`; document.getElementById('matrixContainer').innerHTML = html; // Stats strip document.getElementById('statClubs').textContent = n; document.getElementById('statLeagueName').textContent = leagueName; document.getElementById('statAvg').textContent = avgDist.toFixed(1) + ' mi'; document.getElementById('statMax').textContent = maxDist.d.toFixed(1) + ' mi'; document.getElementById('statMaxClubs').textContent = `${clubs[maxDist.i].club_name} ↔ ${clubs[maxDist.j].club_name}`; document.getElementById('statMin').textContent = minDist.d.toFixed(1) + ' mi'; document.getElementById('statMinClubs').textContent = `${clubs[minDist.i].club_name} ↔ ${clubs[minDist.j].club_name}`; document.getElementById('statsStrip').style.display = 'grid'; document.getElementById('heatKey').style.display = 'flex'; // Longest / shortest trips cards const top5longest = allDists.slice(0, 5); const top5shortest = allDists.slice(-5).reverse(); function tripHTML(trips, isFar) { return trips.map((t, idx) => `
${idx + 1}.
${clubs[t.i].club_name} ↔ ${clubs[t.j].club_name}
${t.d.toFixed(1)} mi
`).join(''); } document.getElementById('longestTrips').innerHTML = tripHTML(top5longest, true); document.getElementById('shortestTrips').innerHTML = tripHTML(top5shortest, false); document.getElementById('tripsSection').style.display = 'block'; } // ── BOOT ────────────────────────────────────────────────────────────────── // Check URL param for direct league link: ?league=Regional+1+Midlands const urlParams = new URLSearchParams(window.location.search); const paramLeague = urlParams.get('league'); const paramLevel = urlParams.get('level'); if (paramLevel) { const lvl = parseInt(paramLevel); const chip = document.querySelector(`[data-level="${lvl}"]`); if (chip) selectLevel(lvl, chip); } else { populateLeagueSelect(5); } if (paramLeague) { // Find the level for this league for (const [lvl, leagues] of Object.entries(LEAGUES_BY_LEVEL)) { if (leagues.includes(paramLeague)) { const chip = document.querySelector(`[data-level="${lvl}"]`); if (chip) selectLevel(parseInt(lvl), chip); document.getElementById('leagueSelect').value = paramLeague; loadLeague(paramLeague); break; } } } else { populateLeagueSelect(5); }