2025-09-05 15:39:06 -06:00
|
|
|
|
{{ define "content" }}
|
|
|
|
|
|
<link
|
|
|
|
|
|
rel="stylesheet"
|
|
|
|
|
|
href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
#single-map {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: calc(100vh - 80px); /* Account for header height */
|
|
|
|
|
|
border: 1px solid #e5e7eb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.map-controls {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 10px;
|
|
|
|
|
|
right: 10px;
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Hide map controls when sidebar is active on mobile */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
#single-map {
|
|
|
|
|
|
height: 50vh; /* Smaller height on mobile */
|
|
|
|
|
|
}
|
|
|
|
|
|
body.sidebar-open .map-controls {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-button {
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
border: 1px solid #ccc;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
padding: 6px 10px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.control-button:hover {
|
|
|
|
|
|
background: #f9fafb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ol-popup {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
background-color: white;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
border: 1px solid #e5e7eb;
|
|
|
|
|
|
bottom: 12px;
|
|
|
|
|
|
left: -50px;
|
|
|
|
|
|
min-width: 180px;
|
|
|
|
|
|
max-width: 280px;
|
|
|
|
|
|
z-index: 200;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ol-popup:after {
|
|
|
|
|
|
top: 100%;
|
|
|
|
|
|
border: solid transparent;
|
|
|
|
|
|
content: " ";
|
|
|
|
|
|
height: 0;
|
|
|
|
|
|
width: 0;
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
border-top-color: #ffffff;
|
|
|
|
|
|
border-width: 10px;
|
|
|
|
|
|
left: 48px;
|
|
|
|
|
|
margin-left: -10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Ensure OpenLayers controls stay below sidebar */
|
|
|
|
|
|
.ol-control {
|
|
|
|
|
|
z-index: 150 !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Hide popup when sidebar is open on mobile */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
body.sidebar-open .ol-popup {
|
|
|
|
|
|
display: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Dashboard layout */
|
|
|
|
|
|
.dashboard-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
height: calc(100vh - 80px); /* Account for header */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (min-width: 1024px) {
|
|
|
|
|
|
.dashboard-container {
|
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
|
}
|
|
|
|
|
|
#single-map {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.map-section {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
min-height: 50vh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats-section {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-height: 50vh;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
background: #f9fafb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (min-width: 1024px) {
|
|
|
|
|
|
.stats-section {
|
|
|
|
|
|
width: 20rem;
|
|
|
|
|
|
max-height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Dashboard Layout -->
|
|
|
|
|
|
<div class="dashboard-container">
|
|
|
|
|
|
<!-- Left: Map -->
|
|
|
|
|
|
<div class="map-section bg-white">
|
|
|
|
|
|
<div class="map-controls">
|
|
|
|
|
|
<button class="control-button" onclick="refreshMap()" title="Refresh Map">
|
|
|
|
|
|
<i class="fas fa-sync-alt"></i>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="single-map"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<div id="popup" class="ol-popup">
|
|
|
|
|
|
<a
|
|
|
|
|
|
href="#"
|
|
|
|
|
|
id="popup-closer"
|
|
|
|
|
|
class="absolute top-1 right-2 text-gray-500 hover:text-gray-800"
|
|
|
|
|
|
>×</a
|
|
|
|
|
|
>
|
|
|
|
|
|
<div id="popup-content"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Right: Stats -->
|
|
|
|
|
|
<div class="stats-section">
|
|
|
|
|
|
<div class="flex flex-col gap-4">
|
|
|
|
|
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
|
|
|
|
|
<div class="w-10 h-10 bg-blue-50 flex items-center justify-center rounded">
|
|
|
|
|
|
<i class="fas fa-users text-blue-600 text-lg"></i>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ml-3">
|
|
|
|
|
|
<p class="text-sm text-gray-600">Active Volunteers</p>
|
|
|
|
|
|
<p class="text-xl font-bold text-gray-900">{{.VolunteerCount}}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
|
|
|
|
|
<div class="w-10 h-10 bg-green-50 flex items-center justify-center rounded">
|
|
|
|
|
|
<i class="fas fa-map-marker-alt text-green-600 text-lg"></i>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ml-3">
|
|
|
|
|
|
<p class="text-sm text-gray-600">Addresses Visited</p>
|
|
|
|
|
|
<p class="text-xl font-bold text-gray-900">{{.ValidatedCount}}</p>
|
|
|
|
|
|
<p id="marker-count" class="text-xs text-gray-500">Loading...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
|
|
|
|
|
<div class="w-10 h-10 bg-yellow-50 flex items-center justify-center rounded">
|
|
|
|
|
|
<i class="fas fa-dollar-sign text-yellow-600 text-lg"></i>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ml-3">
|
|
|
|
|
|
<p class="text-sm text-gray-600">Donation</p>
|
|
|
|
|
|
<p class="text-xl font-bold text-gray-900">${{.TotalDonations}}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
|
|
|
|
|
<div class="w-10 h-10 bg-red-50 flex items-center justify-center rounded">
|
|
|
|
|
|
<i class="fas fa-percentage text-red-600 text-lg"></i>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="ml-3">
|
|
|
|
|
|
<p class="text-sm text-gray-600">Houses Left</p>
|
|
|
|
|
|
<p class="text-xl font-bold text-gray-900">{{.HousesLeftPercent}}%</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
let theMap = null;
|
|
|
|
|
|
let markerLayer = null;
|
|
|
|
|
|
let popup = null;
|
|
|
|
|
|
let initialized = false;
|
|
|
|
|
|
|
|
|
|
|
|
function initializeMap() {
|
2025-09-09 10:42:24 -06:00
|
|
|
|
if (initialized || !window.ol) return;
|
2025-09-05 15:39:06 -06:00
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
|
|
|
|
|
|
|
|
|
|
|
theMap = new ol.Map({
|
|
|
|
|
|
target: "single-map",
|
|
|
|
|
|
layers: [
|
|
|
|
|
|
new ol.layer.Tile({
|
|
|
|
|
|
source: new ol.source.OSM(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
],
|
|
|
|
|
|
view: new ol.View({
|
|
|
|
|
|
center: center,
|
|
|
|
|
|
zoom: 11,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
popup = new ol.Overlay({
|
|
|
|
|
|
element: document.getElementById("popup"),
|
|
|
|
|
|
positioning: "bottom-center",
|
|
|
|
|
|
stopEvent: false,
|
|
|
|
|
|
offset: [0, -50],
|
|
|
|
|
|
});
|
|
|
|
|
|
theMap.addOverlay(popup);
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("popup-closer").onclick = function () {
|
|
|
|
|
|
popup.setPosition(undefined);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
markerLayer = new ol.layer.Vector({
|
|
|
|
|
|
source: new ol.source.Vector(),
|
|
|
|
|
|
style: new ol.style.Style({
|
|
|
|
|
|
text: new ol.style.Text({
|
|
|
|
|
|
text: "📍",
|
|
|
|
|
|
font: "24px sans-serif",
|
|
|
|
|
|
fill: new ol.style.Fill({ color: "#EF4444" }),
|
|
|
|
|
|
offsetY: -12,
|
|
|
|
|
|
}),
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
|
|
|
|
|
theMap.addLayer(markerLayer);
|
|
|
|
|
|
|
|
|
|
|
|
theMap.on("click", function (event) {
|
2025-09-09 10:42:24 -06:00
|
|
|
|
const feature = theMap.forEachFeatureAtPixel(event.pixel, f => f);
|
2025-09-05 15:39:06 -06:00
|
|
|
|
if (feature && feature.get("address_data")) {
|
|
|
|
|
|
const data = feature.get("address_data");
|
|
|
|
|
|
document.getElementById("popup-content").innerHTML = `
|
|
|
|
|
|
<div class="text-sm">
|
2025-09-09 10:42:24 -06:00
|
|
|
|
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
|
|
|
|
|
<p><strong>Address:</strong> ${data.address}</p>
|
|
|
|
|
|
<p><strong>House #:</strong> ${data.house_number}</p>
|
|
|
|
|
|
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
|
|
|
|
|
<p><strong>ID:</strong> ${data.address_id}</p>
|
|
|
|
|
|
</div>`;
|
2025-09-05 15:39:06 -06:00
|
|
|
|
popup.setPosition(event.coordinate);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
popup.setPosition(undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
initialized = true;
|
|
|
|
|
|
setTimeout(loadMarkers, 500);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Map initialization error:", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadMarkers() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch("/api/validated-addresses");
|
|
|
|
|
|
const addresses = await response.json();
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("marker-count").textContent = `${addresses.length} on map`;
|
|
|
|
|
|
|
|
|
|
|
|
markerLayer.getSource().clear();
|
2025-09-09 10:42:24 -06:00
|
|
|
|
const features = addresses
|
|
|
|
|
|
.filter(addr => addr.longitude && addr.latitude)
|
|
|
|
|
|
.map(addr => new ol.Feature({
|
|
|
|
|
|
geometry: new ol.geom.Point(ol.proj.fromLonLat([addr.longitude, addr.latitude])),
|
|
|
|
|
|
address_data: addr,
|
|
|
|
|
|
}));
|
2025-09-05 15:39:06 -06:00
|
|
|
|
|
|
|
|
|
|
markerLayer.getSource().addFeatures(features);
|
|
|
|
|
|
|
|
|
|
|
|
if (features.length > 0) {
|
|
|
|
|
|
const extent = markerLayer.getSource().getExtent();
|
2025-09-09 10:42:24 -06:00
|
|
|
|
theMap.getView(extent, { padding: [20, 20, 20, 20] });
|
2025-09-05 15:39:06 -06:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error loading markers:", error);
|
|
|
|
|
|
document.getElementById("marker-count").textContent = "Error loading";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refreshMap() {
|
2025-09-09 10:42:24 -06:00
|
|
|
|
const view = theMap.getView();
|
|
|
|
|
|
const calgaryCenter = ol.proj.fromLonLat([-114.0719, 51.0447]); // Downtown Calgary
|
|
|
|
|
|
view.setCenter(calgaryCenter);
|
|
|
|
|
|
view.setZoom(11); // your default zoom leve
|
2025-09-05 15:39:06 -06:00
|
|
|
|
loadMarkers();
|
|
|
|
|
|
}
|
2025-09-09 10:42:24 -06:00
|
|
|
|
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
2025-09-05 15:39:06 -06:00
|
|
|
|
setTimeout(initializeMap, 1000);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function handleSidebarToggle() {
|
|
|
|
|
|
const sidebar = document.getElementById('sidebar');
|
2025-09-09 10:42:24 -06:00
|
|
|
|
document.body.classList.toggle('sidebar-open', sidebar && sidebar.classList.contains('active'));
|
2025-09-05 15:39:06 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (typeof window.toggleSidebar === 'function') {
|
|
|
|
|
|
const originalToggleSidebar = window.toggleSidebar;
|
|
|
|
|
|
window.toggleSidebar = function() {
|
|
|
|
|
|
originalToggleSidebar();
|
|
|
|
|
|
setTimeout(handleSidebarToggle, 50);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
2025-09-09 10:42:24 -06:00
|
|
|
|
{{ end }}
|