<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Magic Mountain Incident Tracker</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Leaflet MarkerCluster CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css" />
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
<style>
body, html {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden; /* Prevent body scroll */
}
#map {
/* Use absolute positioning instead of vh to fit exact available space */
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
z-index: 0;
}
/* Define custom map controls */
.leaflet-control-layers {
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
border: none !important;
padding: 10px !important;
font-family: system-ui, -apple-system, sans-serif;
background: white;
max-width: 250px; /* Prevent it getting too wide on mobile */
}
/* Style title in standard control */
.layer-control-title {
font-weight: 700;
margin-bottom: 8px;
color: #374151; /* gray-700 */
border-bottom: 1px solid #e5e7eb; /* gray-200 */
padding-bottom: 4px;
font-size: 0.875rem; /* text-sm */
display: block;
}
/* Add spinner */
.loader {
border: 3px solid #f3f3f3;
border-radius: 50%;
border-top: 3px solid #3b82f6;
width: 20px;
height: 20px;
-webkit-animation: spin 1s linear infinite; /* Safari */
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Mobile Leaflet controls */
@media (max-width: 640px) {
.leaflet-top { top: 120px; } /* This pushes the controls down so they don't hide behind title */
.leaflet-control-layers { font-size: 14px; }
}
</style>
</head>
<body class="bg-gray-100">
<!-- Map Container -->
<div id="map"></div>
<!-- UI Overlay: Title (Responsive) -->
<!--
Mobile: Top, full width minus margins, smaller text
Desktop: Top-left, specific width, larger text
-->
<div class="absolute top-2 left-2 right-2 md:top-4 md:left-14 md:right-auto md:w-auto z-[1000] bg-white p-3 md:p-6 rounded-lg shadow-lg md:max-w-lg border-l-4 md:border-l-8 border-red-600">
<div class="flex flex-col">
<h1 class="text-xl md:text-3xl font-bold text-gray-800 mb-1 whitespace-nowrap overflow-hidden text-ellipsis">
<i class="fas fa-mountain text-red-600 mr-2"></i>Magic Mtn Patrol
</h1>
<h2 class="text-sm md:text-xl font-semibold text-gray-500 mb-1 md:mb-2">Incident Reporting Tool</h2>
<p class="text-xs md:text-sm text-gray-400 hidden md:block mt-1">Click the map to log a new incident.</p>
<div id="connection-status" class="mt-1 md:mt-2 text-xs text-amber-600 font-semibold cursor-pointer hover:underline" onclick="init()">
<i class="fas fa-spinner fa-spin mr-1"></i> Connecting...
</div>
</div>
</div>
<!-- Incident Form Modal -->
<div id="incident-modal" class="fixed inset-0 z-[2000] bg-black bg-opacity-50 hidden flex items-center justify-center p-4">
<!-- Added max-h- and flex-col to handle mobile keyboards -->
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh] transform transition-all">
<!-- Header -->
<div class="bg-gray-800 p-4 flex justify-between items-center flex-shrink-0 rounded-t-xl">
<h2 class="text-white font-bold text-lg"><i class="fas fa-clipboard-list mr-2"></i>Log Incident</h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-white transition p-2">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Form -->
<form id="incident-form" class="p-6 space-y-4 overflow-y-auto">
<input type="hidden" id="lat" name="Latitude">
<input type="hidden" id="lng" name="Longitude">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Date</label>
<!-- This prevents iOS zoom -->
<input type="date" id="date" name="Date" class="w-full bg-gray-50 border border-gray-300 rounded p-2 text-base focus:outline-none focus:border-red-500" required>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Incident #</label>
<input type="text" id="incident_num" name="Incident_Num" placeholder="e.g. 25/26-###" class="w-full bg-gray-50 border border-gray-300 rounded p-2 text-base focus:outline-none focus:border-red-500" required>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Transport Type</label>
<div class="relative">
<select id="transport" name="Transport_Type" class="w-full bg-gray-50 border border-gray-300 rounded p-2 text-base appearance-none focus:outline-none focus:border-red-500" required>
<option value="">Select type...</option>
<option value="Non-Injury Transport">Non-Injury Transport</option>
<option value="Injury Transport">Injury Transport</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Location Coords</label>
<div class="text-sm bg-gray-100 p-2 rounded text-gray-600 font-mono" id="coord-display">
-
</div>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Comments</label>
<textarea id="comment" name="Comment" rows="3" class="w-full bg-gray-50 border border-gray-300 rounded p-2 text-base focus:outline-none focus:border-red-500" placeholder="Describe trail conditions, specific injury, etc."></textarea>
</div>
<div class="pt-2 pb-2">
<button type="submit" id="submit-btn" class="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-3 rounded shadow transition flex justify-center items-center text-lg">
<span id="btn-text">Submit Report</span>
<div id="btn-loader" class="loader ml-2 hidden"></div>
</button>
</div>
</form>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster.js"></script>
<script>
// CONFIG
let SCRIPT_URL = 'https://script.google.com/macros/s/AKfycbzYjs0OeOcV1sGnB8cl4gMNA7DzPJID2hsKjCN7Mdkq5XLaT2dE-WSvrQ-4oMG-QXHB/exec';
SCRIPT_URL = SCRIPT_URL.trim();
// --- MAP SETUP ---
// Center on Magic Mtn
const INITIAL_CENTER = [43.197123, -72.765931];
const INITIAL_ZOOM = 16;
const map = L.map('map', {
zoomControl: false // Disable default zoom control to position it better for mobile
}).setView(INITIAL_CENTER, INITIAL_ZOOM);
// Re-add Zoom control in a better position (Top Left, below title roughly)
L.control.zoom({
position: 'topleft'
}).addTo(map);
// Base Layer: Standard OSM (Default)
const osmMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Base Layer: Satellite Imagery (Esri World Imagery)
const satelliteMap = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
maxZoom: 19,
attribution: 'Tiles © Esri'
});
// Overlay: Ski Trails (OpenSnowMap)
const skiTrails = L.tileLayer('https://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '© OpenSnowMap.org'
}).addTo(map);
// Custom Icons
const thickCrossSvg = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>`;
// Injury: Red Circle, White Cross
const injuryIcon = L.divIcon({
html: `<div class="w-8 h-8 bg-red-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white">${thickCrossSvg}</div>`,
className: '',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
// Non-Injury: Green Circle, White Cross
const nonInjuryIcon = L.divIcon({
html: `<div class="w-8 h-8 bg-green-600 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white">${thickCrossSvg}</div>`,
className: '',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
// Layer Groups
const injuryLayer = L.layerGroup();
const nonInjuryLayer = L.layerGroup();
const allIncidentsLayer = L.layerGroup([injuryLayer, nonInjuryLayer]).addTo(map);
const clusterLayer = L.markerClusterGroup();
// Layer Control - Standard (Base Maps & Trails)
const baseMaps = {
"Standard Sheet": osmMap,
"Satellite Imagery": satelliteMap
};
const overlayMaps = {
"Ski Trails": skiTrails,
"Incident Locations": allIncidentsLayer,
"Incident Clustering": clusterLayer
};
const layerControl = L.control.layers(baseMaps, overlayMaps, { collapsed: true, position: 'topright' }).addTo(map);
// Add "Data Layers" into the standard control
const layerContainer = layerControl.getContainer();
const titleDiv = document.createElement('div');
titleDiv.className = 'layer-control-title';
titleDiv.innerText = 'Data Layers';
// Insert at the top of the container
layerContainer.insertBefore(titleDiv, layerContainer.firstChild);
// --- Apply Filter ---
function applyIncidentFilter() {
const filterValue = document.getElementById('incident-filter')?.value || 'all';
allIncidentsLayer.clearLayers();
if (filterValue === 'all') {
allIncidentsLayer.addLayer(injuryLayer);
allIncidentsLayer.addLayer(nonInjuryLayer);
} else if (filterValue === 'injury') {
allIncidentsLayer.addLayer(injuryLayer);
} else if (filterValue === 'noninjury') {
allIncidentsLayer.addLayer(nonInjuryLayer);
}
}
// --- Exclusion Logic (Clusters vs Locations) ---
map.on('overlayadd', function(e) {
setTimeout(() => {
if (e.name === 'Incident Clustering') {
if (map.hasLayer(allIncidentsLayer)) {
map.removeLayer(allIncidentsLayer);
}
}
else if (e.name === 'Incident Locations') {
if (map.hasLayer(clusterLayer)) {
map.removeLayer(clusterLayer);
}
applyIncidentFilter();
}
}, 50);
});
// --- Home Button ---
const homeControl = L.control({ position: 'topleft' });
homeControl.onAdd = function(map) {
const div = L.DomUtil.create('div', 'leaflet-bar leaflet-control');
div.innerHTML = `
<a href="#" title="Home / Reset View" role="button" style="width: 30px; height: 30px; line-height: 30px; display: flex; align-items: center; justify-content: center; background-color: white; color: #333;">
<i class="fas fa-home"></i>
</a>
`;
div.onclick = function(e) {
e.preventDefault();
e.stopPropagation();
map.setView(INITIAL_CENTER, INITIAL_ZOOM);
};
return div;
};
homeControl.addTo(map);
// --- Dropdown Filter ---
const filterControl = L.control({position: 'topright'});
filterControl.onAdd = function (map) {
const div = L.DomUtil.create('div', 'bg-white p-3 rounded-lg shadow-xl text-sm border border-gray-200');
div.innerHTML = `
<div class="font-bold mb-2 text-gray-700 border-b pb-1 whitespace-nowrap">Incident Type Filter</div>
<div class="relative">
<select id="incident-filter" class="w-full bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2">
<option value="all" selected>Show All Incidents</option>
<option value="injury">Injury Only</option>
<option value="noninjury">Non-Injury Only</option>
</select>
</div>
`;
L.DomEvent.disableClickPropagation(div);
return div;
};
filterControl.addTo(map);
// Bind Dropdown Events
setTimeout(() => {
const filterSelect = document.getElementById('incident-filter');
filterSelect.addEventListener('change', (e) => {
applyIncidentFilter();
});
}, 500);
// --- App Logic ---
function init() {
const statusEl = document.getElementById('connection-status');
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Connecting to Sheet...';
statusEl.className = "mt-1 md:mt-2 text-xs text-amber-600 font-semibold cursor-pointer hover:underline";
statusEl.onclick = init;
if (SCRIPT_URL) {
fetchData();
} else {
console.log("No SCRIPT_URL provided. Using mock data.");
statusEl.innerHTML = '<i class="fas fa-wifi mr-1"></i> Demo Mode (No URL)';
processData(mockData);
}
}
function fetchData() {
const statusEl = document.getElementById('connection-status');
const fetchUrl = `${SCRIPT_URL}?action=read`;
console.log("Fetching:", fetchUrl);
fetch(fetchUrl, {
method: 'GET',
credentials: 'omit',
mode: 'cors',
redirect: 'follow'
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json();
})
.then(data => {
processData(data);
statusEl.innerHTML = '<i class="fas fa-link text-green-500"></i> Connected to Live Database';
statusEl.className = "mt-1 md:mt-2 text-xs text-green-600 font-semibold";
statusEl.title = "Connection Successful";
statusEl.onclick = null;
})
.catch(error => {
console.error('Fetch Failed:', error);
processData(mockData);
statusEl.innerHTML = `<i class="fas fa-info-circle"></i> Demo Data (Live View Blocked)`;
statusEl.className = "mt-1 md:mt-2 text-xs text-orange-600 font-semibold cursor-pointer hover:underline";
statusEl.title = "The preview environment blocked the database connection.";
statusEl.onclick = function() {
window.open(SCRIPT_URL, '_blank');
};
});
}
function processData(data) {
injuryLayer.clearLayers();
nonInjuryLayer.clearLayers();
clusterLayer.clearLayers();
allIncidentsLayer.clearLayers();
if (!Array.isArray(data)) {
console.warn("Data is not an array:", data);
return;
}
const sortedData = [...data].reverse();
sortedData.forEach(point => {
if (!point.Latitude || !point.Longitude) return;
const lat = parseFloat(point.Latitude);
const lng = parseFloat(point.Longitude);
if (isNaN(lat) || isNaN(lng)) return;
const isInjury = point.Transport_Type === 'Injury Transport';
const marker = L.marker([lat, lng], {
icon: isInjury ? injuryIcon : nonInjuryIcon
});
const popupContent = `
<div class="font-sans">
<div class="font-bold text-lg border-b pb-1 mb-2 ${isInjury ? 'text-red-600' : 'text-blue-600'}">
${point.Incident_Num || 'No ID'}
</div>
<p><strong>Date:</strong> ${point.Date ? new Date(point.Date).toLocaleDateString() : 'N/A'}</p>
<p><strong>Type:</strong> ${point.Transport_Type}</p>
<p class="mt-2"><strong>Comment:</strong> <span class="text-gray-600 italic">"${point.Comment || ''}"</span></p>
</div>
`;
marker.bindPopup(popupContent);
if (isInjury) {
injuryLayer.addLayer(marker);
} else {
nonInjuryLayer.addLayer(marker);
}
clusterLayer.addLayer(marker);
});
applyIncidentFilter();
}
// --- Map Interactions ---
map.on('click', function(e) {
const lat = e.latlng.lat.toFixed(6);
const lng = e.latlng.lng.toFixed(6);
document.getElementById('lat').value = lat;
document.getElementById('lng').value = lng;
document.getElementById('coord-display').innerText = `${lat}, ${lng}`;
const today = new Date().toISOString().split('T')[0];
document.getElementById('date').value = today;
document.getElementById('incident-modal').classList.remove('hidden');
});
function closeModal() {
document.getElementById('incident-modal').classList.add('hidden');
document.getElementById('incident-form').reset();
}
document.getElementById('incident-form').addEventListener('submit', function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
const loader = document.getElementById('btn-loader');
submitBtn.disabled = true;
submitBtn.classList.add('opacity-75');
btnText.innerText = 'Submitting...';
loader.classList.remove('hidden');
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
if (SCRIPT_URL) {
fetch(SCRIPT_URL, {
method: 'POST',
mode: 'no-cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(() => {
handleSuccess(data);
})
.catch(err => {
console.error('Error:', err);
alert("Failed to save data. Check console.");
resetButton();
});
} else {
setTimeout(() => {
handleSuccess(data);
}, 1000);
}
});
function handleSuccess(data) {
const isInjury = data.Transport_Type === 'Injury Transport';
const marker = L.marker([data.Latitude, data.Longitude], {
icon: isInjury ? injuryIcon : nonInjuryIcon
});
const popupContent = `
<div class="font-sans">
<div class="font-bold text-lg border-b pb-1 mb-2 ${isInjury ? 'text-red-600' : 'text-blue-600'}">
${data.Incident_Num}
</div>
<p><strong>Date:</strong> ${data.Date}</p>
<p><strong>Type:</strong> ${data.Transport_Type}</p>
<p class="mt-2"><strong>Comment:</strong> <span class="text-gray-600 italic">"${data.Comment}"</span></p>
</div>
`;
marker.bindPopup(popupContent);
if (isInjury) {
injuryLayer.addLayer(marker);
} else {
nonInjuryLayer.addLayer(marker);
}
clusterLayer.addLayer(marker);
// Add to master layer if filter permits
const filterValue = document.getElementById('incident-filter')?.value || 'all';
if (filterValue === 'all') {
allIncidentsLayer.addLayer(isInjury ? injuryLayer : nonInjuryLayer);
} else if (filterValue === 'injury' && isInjury) {
// Already in injuryLayer
} else if (filterValue === 'noninjury' && !isInjury) {
// Already in nonInjuryLayer
}
resetButton();
closeModal();
}
function resetButton() {
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
const loader = document.getElementById('btn-loader');
submitBtn.disabled = false;
submitBtn.classList.remove('opacity-75');
btnText.innerText = 'Submit Report';
loader.classList.add('hidden');
}
init();
</script>
</body>
</html>