Saving current working state before proceeding to Stage 2. Includes: - Backend: Python-based QC validator with shapefile processing - Frontend: Drag-and-drop file upload interface - Sample files for testing - Documentation and revision history 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2014 lines
72 KiB
JavaScript
2014 lines
72 KiB
JavaScript
const statusDiv = document.getElementById('status');
|
|
const statusPanel = document.getElementById('statusPanel');
|
|
const qcResultDiv = document.getElementById('qcResult');
|
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
|
|
// Global endpoint markers layer group for toggling visibility
|
|
let endpointMarkersLayer = null;
|
|
|
|
// ============================================
|
|
// STYLE CONFIGURATION
|
|
// ============================================
|
|
// Centralized styling configuration for easy customization
|
|
|
|
const LAYER_STYLES = {
|
|
// Zone polygon styles
|
|
zonePolygons: {
|
|
color: '#6366f1', // Border color (indigo)
|
|
fillColor: '#818cf8', // Fill color (lighter indigo)
|
|
fillOpacity: 0.15, // Transparency (15%)
|
|
weight: 2, // Border width
|
|
opacity: 0.7 // Border opacity
|
|
},
|
|
|
|
// Segment styles
|
|
segments: {
|
|
aerial: {
|
|
color: '#0066cc', // Blue for aerial
|
|
weight: 4,
|
|
opacity: 0.85
|
|
},
|
|
underground: {
|
|
color: '#cc0000', // Red for underground
|
|
weight: 4,
|
|
opacity: 0.85
|
|
},
|
|
default: {
|
|
color: '#808080', // Gray for other types
|
|
weight: 4,
|
|
opacity: 0.7
|
|
}
|
|
},
|
|
|
|
// Point marker styles
|
|
markers: {
|
|
sites: {
|
|
radius: 7,
|
|
fillColor: '#3b82f6',
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
fillOpacity: 0.9
|
|
},
|
|
poles: {
|
|
radius: 5,
|
|
fillColor: '#1a1a1a',
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
fillOpacity: 0.9
|
|
},
|
|
handholes: {
|
|
// Square marker created via divIcon
|
|
size: [18, 18],
|
|
backgroundColor: '#10b981',
|
|
borderColor: '#ffffff',
|
|
borderWidth: 2
|
|
}
|
|
}
|
|
};
|
|
|
|
function showStatus(msg, isError = false) {
|
|
statusDiv.textContent = msg;
|
|
statusDiv.style.color = isError ? '#ef4444' : '#6b7280';
|
|
statusPanel.classList.add('visible');
|
|
|
|
// Auto-hide after 5 seconds if not an error
|
|
if (!isError && !msg.includes('QC')) {
|
|
setTimeout(() => {
|
|
if (!qcResultDiv.innerHTML) {
|
|
statusPanel.classList.remove('visible');
|
|
}
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
function hideStatus() {
|
|
statusPanel.classList.remove('visible');
|
|
qcResultDiv.innerHTML = '';
|
|
}
|
|
|
|
function showLoading(show = true) {
|
|
loadingOverlay.style.display = show ? 'flex' : 'none';
|
|
}
|
|
|
|
function setButtonLoading(button, loading = true) {
|
|
if (loading) {
|
|
button.classList.add('loading');
|
|
button.disabled = true;
|
|
} else {
|
|
button.classList.remove('loading');
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
const map = L.map('map').setView([29.2, -99.7], 12);
|
|
|
|
// Create a custom pane for endpoint markers with high z-index
|
|
map.createPane('endpointPane');
|
|
map.getPane('endpointPane').style.zIndex = 650; // Higher than markerPane (600)
|
|
|
|
// Initialize endpoint markers layer group for toggling visibility
|
|
endpointMarkersLayer = L.layerGroup().addTo(map);
|
|
|
|
// Define multiple basemap options
|
|
const baseMaps = {
|
|
'Clean (Light)': L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© OpenStreetMap contributors © CARTO',
|
|
subdomains: 'abcd',
|
|
maxZoom: 20
|
|
}),
|
|
'Clean (Dark)': L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© OpenStreetMap contributors © CARTO',
|
|
subdomains: 'abcd',
|
|
maxZoom: 20
|
|
}),
|
|
'OpenStreetMap': L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19
|
|
}),
|
|
'Satellite': L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
|
maxZoom: 19
|
|
}),
|
|
'Satellite + Labels': L.layerGroup([
|
|
L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
attribution: 'Tiles © Esri',
|
|
maxZoom: 19
|
|
}),
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© CARTO',
|
|
subdomains: 'abcd',
|
|
maxZoom: 20
|
|
})
|
|
])
|
|
};
|
|
|
|
// Add default basemap (Clean Light)
|
|
baseMaps['Clean (Light)'].addTo(map);
|
|
|
|
const layers = {
|
|
segments: L.layerGroup().addTo(map),
|
|
sites: L.layerGroup().addTo(map),
|
|
info: L.layerGroup().addTo(map),
|
|
handholes: L.layerGroup().addTo(map),
|
|
poles: L.layerGroup().addTo(map),
|
|
permits: L.layerGroup().addTo(map),
|
|
};
|
|
|
|
// Add layer control with basemaps and overlay layers
|
|
L.control.layers(baseMaps, {
|
|
'Segments': layers.segments,
|
|
'Sites': layers.sites,
|
|
'Info Objects': layers.info,
|
|
'Handholes': layers.handholes,
|
|
'Poles': layers.poles,
|
|
'Permits': layers.permits
|
|
}).addTo(map);
|
|
|
|
// Create custom legend control
|
|
const legend = L.control({ position: 'bottomright' });
|
|
|
|
legend.onAdd = function(map) {
|
|
const div = L.DomUtil.create('div', 'legend');
|
|
div.innerHTML = `
|
|
<div class="legend-title">Layer Legend</div>
|
|
<div class="legend-section-title">Segments</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-aerial" checked onchange="toggleLayer('segments')">
|
|
<svg width="30" height="6"><line x1="0" y1="3" x2="30" y2="3" stroke="#0066cc" stroke-width="4" stroke-linecap="round" opacity="0.85"/></svg>
|
|
<label for="legend-aerial">Aerial</label>
|
|
</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-underground" checked onchange="toggleLayer('segments')">
|
|
<svg width="30" height="6"><line x1="0" y1="3" x2="30" y2="3" stroke="#cc0000" stroke-width="4" stroke-linecap="round" opacity="0.85"/></svg>
|
|
<label for="legend-underground">Underground</label>
|
|
</div>
|
|
<div class="legend-item-small">
|
|
<svg width="20" height="10"><circle cx="10" cy="5" r="4" fill="#fbbf24" stroke="#1a1a1a" stroke-width="1.5"/></svg>
|
|
<label>Start Point</label>
|
|
</div>
|
|
<div class="legend-item-small">
|
|
<svg width="20" height="10"><circle cx="10" cy="5" r="4" fill="#ef4444" stroke="#1a1a1a" stroke-width="1.5"/></svg>
|
|
<label>End Point</label>
|
|
</div>
|
|
<div class="legend-section-title">Features</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-poles" checked onchange="toggleLayer('poles')">
|
|
<svg width="30" height="14"><circle cx="15" cy="7" r="5" fill="#1a1a1a" stroke="white" stroke-width="2"/></svg>
|
|
<label for="legend-poles">Poles</label>
|
|
</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-handholes" checked onchange="toggleLayer('handholes')">
|
|
<svg width="30" height="14"><rect x="8" y="0" width="14" height="14" fill="#10b981" stroke="white" stroke-width="2" rx="2"/></svg>
|
|
<label for="legend-handholes">Handholes</label>
|
|
</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-sites" checked onchange="toggleLayer('sites')">
|
|
<svg width="30" height="14"><circle cx="15" cy="7" r="7" fill="#3b82f6" stroke="white" stroke-width="2"/></svg>
|
|
<label for="legend-sites">Sites</label>
|
|
</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-info" checked onchange="toggleLayer('info')">
|
|
<svg width="30" height="6"><line x1="0" y1="3" x2="30" y2="3" stroke="#9ca3af" stroke-width="2" stroke-dasharray="5,5" opacity="0.6"/></svg>
|
|
<label for="legend-info">Info Objects</label>
|
|
</div>
|
|
<div class="legend-item">
|
|
<input type="checkbox" id="legend-permits" checked onchange="toggleLayer('permits')">
|
|
<svg width="30" height="14"><rect x="5" y="2" width="20" height="10" fill="#fbbf24" opacity="0.2" stroke="#f59e0b" stroke-width="2"/></svg>
|
|
<label for="legend-permits">Permits</label>
|
|
</div>
|
|
`;
|
|
return div;
|
|
};
|
|
|
|
legend.addTo(map);
|
|
|
|
// Function to toggle layers
|
|
function toggleLayer(layerName) {
|
|
if (map.hasLayer(layers[layerName])) {
|
|
map.removeLayer(layers[layerName]);
|
|
} else {
|
|
map.addLayer(layers[layerName]);
|
|
}
|
|
}
|
|
|
|
function clearLayers() {
|
|
Object.values(layers).forEach(layer => layer.clearLayers());
|
|
}
|
|
|
|
function loadMapData(market, zone) {
|
|
clearLayers();
|
|
hideStatus();
|
|
showLoading(true);
|
|
showStatus("Loading map data...");
|
|
|
|
const endpoints = {
|
|
segments: '/api/segments',
|
|
sites: '/api/sites',
|
|
info: '/api/info',
|
|
handholes: '/api/access_points',
|
|
poles: '/api/poles',
|
|
permits: '/api/permits'
|
|
};
|
|
|
|
let loadedCount = 0;
|
|
const totalLayers = Object.keys(endpoints).length;
|
|
|
|
for (const [key, url] of Object.entries(endpoints)) {
|
|
fetch(`${url}?map_id=${market}&zone=${zone}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log(`Loaded ${key} data:`, data);
|
|
|
|
const layerOptions = {
|
|
pointToLayer: (f, latlng) => {
|
|
switch (key) {
|
|
case 'sites':
|
|
return L.circleMarker(latlng, {
|
|
radius: 7,
|
|
fillColor: '#3b82f6',
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.9
|
|
});
|
|
case 'handholes':
|
|
// Create a square marker for handholes (18x18 including 2px border on each side)
|
|
const squareIcon = L.divIcon({
|
|
className: 'handhole-marker',
|
|
html: '<div style="background-color: #10b981; width: 14px; height: 14px; border: 2px solid white; border-radius: 2px; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
|
|
iconSize: [18, 18],
|
|
iconAnchor: [9, 9]
|
|
});
|
|
return L.marker(latlng, { icon: squareIcon });
|
|
case 'poles':
|
|
return L.circleMarker(latlng, {
|
|
radius: 5,
|
|
fillColor: '#1a1a1a',
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.9
|
|
});
|
|
default: return L.marker(latlng);
|
|
}
|
|
},
|
|
style: (f) => {
|
|
switch (key) {
|
|
case 'segments':
|
|
// Color based on segment type
|
|
const segmentType = f.properties.segment_type?.toLowerCase();
|
|
if (segmentType === 'aerial') {
|
|
return {
|
|
color: '#0066cc',
|
|
weight: 4,
|
|
opacity: 0.85,
|
|
lineCap: 'round',
|
|
lineJoin: 'round'
|
|
}; // Blue for Aerial
|
|
} else if (segmentType === 'underground') {
|
|
return {
|
|
color: '#cc0000',
|
|
weight: 4,
|
|
opacity: 0.85,
|
|
lineCap: 'round',
|
|
lineJoin: 'round'
|
|
}; // Red for Underground
|
|
} else {
|
|
return {
|
|
color: '#808080',
|
|
weight: 4,
|
|
opacity: 0.7,
|
|
lineCap: 'round',
|
|
lineJoin: 'round'
|
|
}; // Gray for other types
|
|
}
|
|
case 'info': return LAYER_STYLES.zonePolygons;
|
|
case 'permits': return {
|
|
color: '#f59e0b',
|
|
fillColor: '#fbbf24',
|
|
fillOpacity: 0.2,
|
|
weight: 2,
|
|
opacity: 0.7
|
|
};
|
|
default: return {};
|
|
}
|
|
},
|
|
filter: (f) => {
|
|
if (key === 'segments') {
|
|
const selectedType = document.getElementById('segmentTypeFilter').value;
|
|
if (!selectedType) return true;
|
|
const segmentType = f.properties.segment_type?.toLowerCase();
|
|
return segmentType === selectedType.toLowerCase();
|
|
}
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const geoLayer = L.geoJSON(data, layerOptions).addTo(layers[key]);
|
|
console.log(`${key} layer added`, geoLayer);
|
|
|
|
// Add popups to all features
|
|
if (key === 'sites') {
|
|
geoLayer.eachLayer(layer => {
|
|
if (layer.feature && layer.feature.properties) {
|
|
const props = layer.feature.properties;
|
|
layer.bindPopup(`
|
|
<strong>🏠 Site Details</strong><br>
|
|
<strong>ID:</strong> ${props.id || 'N/A'}<br>
|
|
<strong>Name:</strong> ${props.name || 'N/A'}<br>
|
|
<strong>Zone Assignment:</strong> ${props.group1 || props['Group 1'] || '<span style="color: red;">NULL/Unassigned</span>'}<br>
|
|
<strong>Address:</strong> ${props.address || props.address1 || 'N/A'}<br>
|
|
<strong>City:</strong> ${props.city || 'N/A'}<br>
|
|
<strong>State:</strong> ${props.state || 'N/A'}<br>
|
|
<strong>Zip:</strong> ${props.zip || 'N/A'}
|
|
`);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (key === 'poles') {
|
|
geoLayer.eachLayer(layer => {
|
|
if (layer.feature && layer.feature.properties) {
|
|
const props = layer.feature.properties;
|
|
layer.bindPopup(`
|
|
<strong>⚡ Pole Details</strong><br>
|
|
<strong>ID:</strong> ${props.id || 'N/A'}<br>
|
|
<strong>Name:</strong> ${props.name || 'N/A'}<br>
|
|
<strong>Zone Assignment:</strong> ${props.group1 || '<span style="color: red;">NULL/Unassigned</span>'}<br>
|
|
<strong>Owner:</strong> ${props.owner || 'N/A'}<br>
|
|
<strong>Height:</strong> ${props.poleheight || 'N/A'} ft<br>
|
|
<strong>Attachment Height:</strong> ${props.attachmentheight || 'N/A'} ft
|
|
`);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (key === 'handholes') {
|
|
geoLayer.eachLayer(layer => {
|
|
if (layer.feature && layer.feature.properties) {
|
|
const props = layer.feature.properties;
|
|
layer.bindPopup(`
|
|
<strong>🔧 Handhole/Access Point</strong><br>
|
|
<strong>ID:</strong> ${props.id || 'N/A'}<br>
|
|
<strong>Name:</strong> ${props.name || 'N/A'}<br>
|
|
<strong>Zone Assignment:</strong> ${props.group1 || '<span style="color: red;">NULL/Unassigned</span>'}<br>
|
|
<strong>Manufacturer:</strong> ${props.manufacturer || 'N/A'}<br>
|
|
<strong>Size:</strong> ${props.size || 'N/A'}<br>
|
|
<strong>Description:</strong> ${props.description || 'N/A'}
|
|
`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add labels and popups to zones
|
|
if (key === 'info') {
|
|
geoLayer.eachLayer(layer => {
|
|
if (layer.feature && layer.feature.properties) {
|
|
const props = layer.feature.properties;
|
|
const zoneName = props.name || props.group_1 || 'Unknown Zone';
|
|
|
|
// Add popup
|
|
layer.bindPopup(`
|
|
<strong>Zone: ${zoneName}</strong><br>
|
|
${props.description ? `<em>${props.description}</em><br>` : ''}
|
|
${props.group_1 ? `<strong>Group:</strong> ${props.group_1}` : ''}
|
|
`);
|
|
|
|
// Add permanent label at center of zone
|
|
const center = layer.getBounds().getCenter();
|
|
L.marker(center, {
|
|
icon: L.divIcon({
|
|
className: 'zone-label',
|
|
html: `<div style="background: rgba(99, 102, 241, 0.9); color: white; padding: 4px 8px; border-radius: 4px; font-weight: 600; font-size: 12px; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.3);">${zoneName}</div>`,
|
|
iconSize: [100, 20],
|
|
iconAnchor: [50, 10]
|
|
})
|
|
}).addTo(layers[key]);
|
|
}
|
|
});
|
|
|
|
// Zoom to zones
|
|
if (geoLayer.getBounds().isValid()) {
|
|
console.log("Zooming to zones bounds:", geoLayer.getBounds());
|
|
map.fitBounds(geoLayer.getBounds(), { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
// Add interactivity for segments
|
|
if (key === 'segments') {
|
|
geoLayer.eachLayer(layer => {
|
|
// Add hover effect
|
|
layer.on('mouseover', function(e) {
|
|
this.setStyle({ weight: 6, opacity: 1 });
|
|
});
|
|
layer.on('mouseout', function(e) {
|
|
geoLayer.resetStyle(this);
|
|
});
|
|
|
|
// Add popup with segment info
|
|
if (layer.feature && layer.feature.properties) {
|
|
const props = layer.feature.properties;
|
|
const zoneDisplay = props.group_1 || props['Group 1'] || '<span style="color: red;">NULL/Unassigned</span>';
|
|
layer.bindPopup(`
|
|
<strong>📏 Segment Details</strong><br>
|
|
<strong>ID:</strong> ${props.id || 'N/A'}<br>
|
|
<strong>Type:</strong> ${props.segment_type || 'N/A'}<br>
|
|
<strong>Zone Assignment:</strong> ${zoneDisplay}<br>
|
|
<strong>Status:</strong> ${props.segment_status || 'N/A'}<br>
|
|
<strong>Protection:</strong> ${props.protection_status || 'N/A'}<br>
|
|
<strong>QC Flag:</strong> ${props.qc_flag || 'None'}
|
|
`);
|
|
}
|
|
});
|
|
|
|
// Zoom to segments immediately after they load
|
|
if (geoLayer.getBounds().isValid()) {
|
|
console.log("Zooming to segments bounds:", geoLayer.getBounds());
|
|
map.fitBounds(geoLayer.getBounds(), { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
loadedCount++;
|
|
if (loadedCount === totalLayers) {
|
|
showLoading(false);
|
|
showStatus("All layers loaded successfully.");
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error(`Failed to load ${key}:`, err);
|
|
showLoading(false);
|
|
showStatus(`Error loading ${key}.`, true);
|
|
});
|
|
}
|
|
}
|
|
|
|
// Populate dropdowns
|
|
const marketSelect = document.getElementById('marketSelect');
|
|
const zoneSelect = document.getElementById('zoneSelect');
|
|
|
|
function loadMarkets() {
|
|
fetch('/api/markets')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log('Markets data:', data);
|
|
marketSelect.innerHTML = '<option value="">Select Market</option>';
|
|
data.forEach(m => {
|
|
const opt = document.createElement('option');
|
|
opt.value = m.mapid;
|
|
opt.textContent = m.project;
|
|
marketSelect.appendChild(opt);
|
|
});
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load markets:', err);
|
|
marketSelect.innerHTML = '<option value="">Error loading markets</option>';
|
|
});
|
|
}
|
|
|
|
function loadZones() {
|
|
const market = marketSelect.value;
|
|
if (!market) {
|
|
zoneSelect.innerHTML = '<option value="">Select market first</option>';
|
|
zoneSelect.disabled = true;
|
|
return;
|
|
}
|
|
|
|
console.log(`/api/zones?map_id=${market}`);
|
|
fetch(`/api/zones?map_id=${market}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
zoneSelect.innerHTML = '<option value="">Select Zone</option>';
|
|
data.forEach(z => {
|
|
const opt = document.createElement('option');
|
|
opt.value = z;
|
|
opt.textContent = z;
|
|
zoneSelect.appendChild(opt);
|
|
});
|
|
zoneSelect.disabled = false;
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load zones:', err);
|
|
zoneSelect.innerHTML = '<option value="">Error loading zones</option>';
|
|
zoneSelect.disabled = true;
|
|
});
|
|
}
|
|
|
|
// Show the clear QC button
|
|
function showClearQCButton() {
|
|
document.getElementById('clearQCButton').style.display = 'inline-block';
|
|
}
|
|
|
|
function showEndpointToggle() {
|
|
document.getElementById('endpointToggleContainer').style.display = 'flex';
|
|
}
|
|
|
|
// Clear all QC result layers
|
|
function clearQCResults() {
|
|
// Clear connectivity layers
|
|
if (connectivityLayers && connectivityLayers.length > 0) {
|
|
connectivityLayers.forEach(layer => map.removeLayer(layer));
|
|
connectivityLayers = [];
|
|
}
|
|
|
|
// Clear invalid span layer
|
|
if (invalidSpanLayer) {
|
|
map.removeLayer(invalidSpanLayer);
|
|
invalidSpanLayer = null;
|
|
}
|
|
|
|
// Clear disconnected handhole layer
|
|
if (disconnectedHandholeLayer) {
|
|
map.removeLayer(disconnectedHandholeLayer);
|
|
disconnectedHandholeLayer = null;
|
|
}
|
|
|
|
// Clear disconnected sites layer
|
|
if (disconnectedSitesLayer) {
|
|
map.removeLayer(disconnectedSitesLayer);
|
|
disconnectedSitesLayer = null;
|
|
}
|
|
|
|
// Clear invalid aerial endpoint layer
|
|
if (invalidAerialEndpointLayer) {
|
|
map.removeLayer(invalidAerialEndpointLayer);
|
|
invalidAerialEndpointLayer = null;
|
|
}
|
|
|
|
// Clear invalid underground layer
|
|
if (invalidUndergroundLayer) {
|
|
map.removeLayer(invalidUndergroundLayer);
|
|
invalidUndergroundLayer = null;
|
|
}
|
|
|
|
// Clear invalid zone containment layer
|
|
if (invalidZoneContainmentLayer) {
|
|
map.removeLayer(invalidZoneContainmentLayer);
|
|
invalidZoneContainmentLayer = null;
|
|
}
|
|
|
|
// Clear endpoint markers
|
|
if (endpointMarkersLayer) {
|
|
endpointMarkersLayer.clearLayers();
|
|
}
|
|
|
|
// Restore base segments layer if it was hidden
|
|
if (layers.segments && !map.hasLayer(layers.segments)) {
|
|
map.addLayer(layers.segments);
|
|
}
|
|
|
|
// Hide clear button
|
|
document.getElementById('clearQCButton').style.display = 'none';
|
|
|
|
// Hide endpoint toggle
|
|
document.getElementById('endpointToggleContainer').style.display = 'none';
|
|
|
|
// Hide status panel
|
|
hideStatus();
|
|
|
|
console.log('All QC results cleared');
|
|
}
|
|
|
|
// Clear QC button handler
|
|
document.getElementById('clearQCButton').addEventListener('click', clearQCResults);
|
|
|
|
// Endpoint toggle handler
|
|
document.getElementById('endpointToggle').addEventListener('change', function() {
|
|
if (this.checked) {
|
|
// Show endpoints
|
|
if (endpointMarkersLayer && !map.hasLayer(endpointMarkersLayer)) {
|
|
map.addLayer(endpointMarkersLayer);
|
|
}
|
|
} else {
|
|
// Hide endpoints
|
|
if (endpointMarkersLayer && map.hasLayer(endpointMarkersLayer)) {
|
|
map.removeLayer(endpointMarkersLayer);
|
|
}
|
|
}
|
|
});
|
|
|
|
marketSelect.addEventListener('change', () => {
|
|
const market = marketSelect.value;
|
|
console.log("Selected Market ID:", market);
|
|
loadZones();
|
|
|
|
// Clear QC results when changing market
|
|
clearQCResults();
|
|
|
|
const zone = zoneSelect.value;
|
|
if (market && zone) {
|
|
loadMapData(market, zone);
|
|
}
|
|
});
|
|
|
|
zoneSelect.addEventListener('change', () => {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
console.log("Selected Market ID & Zone:", market, zone);
|
|
|
|
// Clear QC results when changing zone
|
|
clearQCResults();
|
|
|
|
if (market && zone) {
|
|
loadMapData(market, zone);
|
|
}
|
|
});
|
|
|
|
loadMarkets();
|
|
loadZones();
|
|
|
|
const segmentTypeFilter = document.getElementById('segmentTypeFilter');
|
|
segmentTypeFilter.addEventListener('change', () => {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
if (market && zone) {
|
|
loadMapData(market, zone);
|
|
}
|
|
});
|
|
|
|
// Enable/disable Run QC button based on dropdown selection
|
|
const qcOperationSelect = document.getElementById('qcOperationSelect');
|
|
const runQCButton = document.getElementById('runQCButton');
|
|
|
|
qcOperationSelect.addEventListener('change', () => {
|
|
runQCButton.disabled = !qcOperationSelect.value;
|
|
});
|
|
|
|
// Consolidated QC button handler
|
|
runQCButton.addEventListener('click', () => {
|
|
const operation = qcOperationSelect.value;
|
|
if (!operation) return;
|
|
|
|
switch(operation) {
|
|
case 'connectivity':
|
|
runConnectivityQC();
|
|
break;
|
|
case 'single-span':
|
|
runSingleSpanQC();
|
|
break;
|
|
case 'aerial-endpoints':
|
|
runAerialEndpointQC();
|
|
break;
|
|
case 'underground-endpoints':
|
|
runUndergroundEndpointQC();
|
|
break;
|
|
case 'zone-containment':
|
|
runZoneContainmentQC();
|
|
break;
|
|
case 'handhole-connectivity':
|
|
runAccessPointQC();
|
|
break;
|
|
case 'site-connectivity':
|
|
runSiteConnectivityQC();
|
|
break;
|
|
}
|
|
});
|
|
|
|
/// Connectivity QC
|
|
let connectivityLayers = [];
|
|
|
|
function runConnectivityQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market || !zone) {
|
|
alert("Select a market and zone first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous connectivity layers
|
|
connectivityLayers.forEach(layer => map.removeLayer(layer));
|
|
connectivityLayers = [];
|
|
|
|
// Hide the base segments layer temporarily so QC colors are visible
|
|
if (map.hasLayer(layers.segments)) {
|
|
map.removeLayer(layers.segments);
|
|
}
|
|
|
|
fetch(`/api/qc/connectivity?map_id=${market}&zone=${zone}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log("Connectivity QC data received:", data);
|
|
console.log("Number of features:", data.features ? data.features.length : 0);
|
|
if (data.features && data.features.length > 0) {
|
|
console.log("First feature:", data.features[0]);
|
|
}
|
|
|
|
const { components, totalSegments } = getConnectedComponents(data);
|
|
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (components.length === 1) {
|
|
qcResultDiv.textContent = "✅ Segment network is fully connected.";
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
|
|
// Show the single connected network in bright blue with white outline
|
|
// Draw white outline first
|
|
const outlineLayer = L.geoJSON(components[0].segments, {
|
|
style: {
|
|
color: 'white',
|
|
weight: 10,
|
|
opacity: 1.0
|
|
}
|
|
}).addTo(map);
|
|
connectivityLayers.push(outlineLayer);
|
|
|
|
// Draw bright blue line on top
|
|
const layer = L.geoJSON(components[0].segments, {
|
|
style: {
|
|
color: '#00bfff',
|
|
weight: 6,
|
|
opacity: 1.0
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties;
|
|
|
|
// Add hover effect
|
|
layer.on('mouseover', function() {
|
|
this.setStyle({ weight: 8, opacity: 1 });
|
|
});
|
|
layer.on('mouseout', function() {
|
|
this.setStyle({ weight: 6, opacity: 1.0 });
|
|
});
|
|
|
|
layer.bindPopup(`
|
|
<strong>✅ Main Connected Network</strong><br>
|
|
<strong>Segment ID:</strong> ${props.id || 'N/A'}<br>
|
|
<strong>ID_0:</strong> ${props.id_0 || 'N/A'}<br>
|
|
<strong>Type:</strong> ${props.segment_type || 'N/A'}<br>
|
|
<strong>Status:</strong> ${props.segment_status || 'N/A'}<br>
|
|
<strong>Protection:</strong> ${props.protection_status || 'N/A'}<br>
|
|
<strong>Map ID:</strong> ${props.mapid || 'N/A'}
|
|
`);
|
|
|
|
// Add endpoint markers
|
|
if (feature.geometry && feature.geometry.coordinates) {
|
|
let coords = feature.geometry.coordinates;
|
|
|
|
// Handle MultiLineString - extract first linestring
|
|
if (feature.geometry.type === 'MultiLineString' && coords.length > 0) {
|
|
coords = coords[0];
|
|
}
|
|
|
|
console.log('Adding endpoint markers for segment:', props.id, 'coords length:', coords.length);
|
|
|
|
if (coords && coords.length >= 2) {
|
|
const startCoord = [coords[0][1], coords[0][0]];
|
|
const endCoord = [coords[coords.length - 1][1], coords[coords.length - 1][0]];
|
|
|
|
console.log('Start coord:', startCoord, 'End coord:', endCoord);
|
|
|
|
// Start point (yellow)
|
|
L.circleMarker(startCoord, {
|
|
radius: 6,
|
|
fillColor: '#fbbf24',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane',
|
|
zIndexOffset: 1000
|
|
}).bindPopup(`<strong>🟡 Segment Start</strong><br>ID: ${props.id || 'N/A'}`).addTo(endpointMarkersLayer);
|
|
|
|
// End point (red)
|
|
L.circleMarker(endCoord, {
|
|
radius: 6,
|
|
fillColor: '#ef4444',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane',
|
|
zIndexOffset: 1000
|
|
}).bindPopup(`<strong>🔴 Segment End</strong><br>ID: ${props.id || 'N/A'}`).addTo(endpointMarkersLayer);
|
|
}
|
|
}
|
|
}
|
|
}).addTo(map);
|
|
connectivityLayers.push(layer);
|
|
|
|
// Show endpoint toggle since we've added endpoint markers
|
|
showEndpointToggle();
|
|
|
|
} else {
|
|
// Multiple components - color code them
|
|
const largestComponent = components[0];
|
|
const disconnectedComponents = components.slice(1);
|
|
const totalDisconnected = disconnectedComponents.reduce((sum, c) => sum + c.segments.length, 0);
|
|
|
|
qcResultDiv.innerHTML = `
|
|
📊 Network Connectivity Analysis:<br>
|
|
• Main Network: ${largestComponent.segments.length} segments (bright blue)<br>
|
|
• Disconnected Groups: ${disconnectedComponents.length} (${totalDisconnected} segments)<br>
|
|
• Total Segments: ${totalSegments}
|
|
`;
|
|
qcResultDiv.style.color = "#f59e0b";
|
|
showClearQCButton();
|
|
|
|
// Color palette for different components
|
|
// First color is for main network (bright blue with special styling)
|
|
// Remaining colors are for disconnected groups (avoiding blues/cyans)
|
|
const colors = [
|
|
'#00bfff', // Bright blue for main network (solid, thick)
|
|
'#ff0000', // Red
|
|
'#ff8c00', // Orange
|
|
'#00ff00', // Lime/Green
|
|
'#ff00ff', // Magenta
|
|
'#ffff00', // Yellow
|
|
'#ff1493', // Deep pink
|
|
'#9400d3', // Violet/Purple
|
|
'#ff6347', // Tomato/Coral
|
|
'#ffa500', // Gold/Amber
|
|
'#32cd32', // Bright lime
|
|
'#ff69b4', // Hot pink
|
|
'#8b4513', // Saddle brown
|
|
'#ff4500', // Orange red
|
|
'#9370db' // Medium purple
|
|
];
|
|
|
|
// Visualize each component with its color
|
|
components.forEach((component, index) => {
|
|
const color = colors[index % colors.length];
|
|
const isMainNetwork = index === 0;
|
|
|
|
// For main network, add a white outline for extra distinction
|
|
if (isMainNetwork) {
|
|
// Draw white outline first (underneath)
|
|
const outlineLayer = L.geoJSON(component.segments, {
|
|
style: {
|
|
color: 'white',
|
|
weight: 10,
|
|
opacity: 1.0
|
|
}
|
|
}).addTo(map);
|
|
connectivityLayers.push(outlineLayer);
|
|
}
|
|
|
|
// Draw the main colored line
|
|
const layer = L.geoJSON(component.segments, {
|
|
style: {
|
|
color: color,
|
|
weight: isMainNetwork ? 6 : 7,
|
|
opacity: 1.0,
|
|
dashArray: isMainNetwork ? '' : '10,6'
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties;
|
|
const componentLabel = isMainNetwork
|
|
? `<strong>✅ Main Connected Network</strong>`
|
|
: `<strong>🚨 Disconnected Group ${index}</strong>`;
|
|
const componentInfo = isMainNetwork
|
|
? ``
|
|
: `<strong>Group Size:</strong> ${component.segments.length} segment(s)<br>`;
|
|
|
|
// Add hover effect
|
|
layer.on('mouseover', function() {
|
|
this.setStyle({ weight: isMainNetwork ? 8 : 9, opacity: 1 });
|
|
});
|
|
layer.on('mouseout', function() {
|
|
this.setStyle({ weight: isMainNetwork ? 6 : 7, opacity: 1.0 });
|
|
});
|
|
|
|
layer.bindPopup(`
|
|
${componentLabel}<br>
|
|
${componentInfo}
|
|
<strong>Segment ID:</strong> ${props.id || 'N/A'}<br>
|
|
<strong>ID_0:</strong> ${props.id_0 || 'N/A'}<br>
|
|
<strong>Type:</strong> ${props.segment_type || 'N/A'}<br>
|
|
<strong>Status:</strong> ${props.segment_status || 'N/A'}<br>
|
|
<strong>Protection:</strong> ${props.protection_status || 'N/A'}<br>
|
|
<strong>Map ID:</strong> ${props.mapid || 'N/A'}
|
|
`);
|
|
|
|
// Add endpoint markers
|
|
if (feature.geometry && feature.geometry.coordinates) {
|
|
let coords = feature.geometry.coordinates;
|
|
|
|
// Handle MultiLineString - extract first linestring
|
|
if (feature.geometry.type === 'MultiLineString' && coords.length > 0) {
|
|
coords = coords[0];
|
|
}
|
|
|
|
console.log('Multi-component: Adding endpoint markers for segment:', props.id, 'coords length:', coords.length, 'Group:', index);
|
|
|
|
if (coords && coords.length >= 2) {
|
|
const startCoord = [coords[0][1], coords[0][0]];
|
|
const endCoord = [coords[coords.length - 1][1], coords[coords.length - 1][0]];
|
|
|
|
console.log('Multi-component Start coord:', startCoord, 'End coord:', endCoord);
|
|
|
|
// Start point (yellow)
|
|
L.circleMarker(startCoord, {
|
|
radius: 6,
|
|
fillColor: '#fbbf24',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane',
|
|
zIndexOffset: 1000
|
|
}).bindPopup(`<strong>🟡 Segment Start</strong><br>ID: ${props.id || 'N/A'}<br>Group: ${isMainNetwork ? 'Main Network' : 'Disconnected ' + index}`).addTo(endpointMarkersLayer);
|
|
|
|
// End point (red)
|
|
L.circleMarker(endCoord, {
|
|
radius: 6,
|
|
fillColor: '#ef4444',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane',
|
|
zIndexOffset: 1000
|
|
}).bindPopup(`<strong>🔴 Segment End</strong><br>ID: ${props.id || 'N/A'}<br>Group: ${isMainNetwork ? 'Main Network' : 'Disconnected ' + index}`).addTo(endpointMarkersLayer);
|
|
}
|
|
}
|
|
}
|
|
}).addTo(map);
|
|
|
|
connectivityLayers.push(layer);
|
|
});
|
|
|
|
// Show endpoint toggle since we've added endpoint markers
|
|
showEndpointToggle();
|
|
|
|
// Zoom to show all segments
|
|
const allBounds = L.latLngBounds();
|
|
connectivityLayers.forEach(layer => {
|
|
// Only GeoJSON layers have getBounds, skip markers
|
|
if (layer.getBounds && typeof layer.getBounds === 'function') {
|
|
const bounds = layer.getBounds();
|
|
if (bounds.isValid()) {
|
|
allBounds.extend(bounds);
|
|
}
|
|
} else if (layer.getLatLng && typeof layer.getLatLng === 'function') {
|
|
// For individual markers, extend by their position
|
|
allBounds.extend(layer.getLatLng());
|
|
}
|
|
});
|
|
if (allBounds.isValid()) {
|
|
map.fitBounds(allBounds, { padding: [30, 30] });
|
|
}
|
|
|
|
// Tag disconnected segments in DB
|
|
const disconnectedIds = disconnectedComponents.flatMap(c =>
|
|
c.segments.map(s => s.properties.id)
|
|
);
|
|
|
|
fetch('/api/qc/tag-disconnected', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ids: disconnectedIds })
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log("Tagging complete:", data.message);
|
|
})
|
|
.catch(err => console.error("Error tagging disconnected segments:", err));
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Connectivity QC error:", err);
|
|
setButtonLoading(runQCButton, false);
|
|
qcResultDiv.textContent = "Error running QC";
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
statusPanel.classList.add('visible');
|
|
});
|
|
}
|
|
|
|
/// Single Span QC - NEW FUNCTIONALITY
|
|
let invalidSpanLayer;
|
|
|
|
function runSingleSpanQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market || !zone) {
|
|
alert("Select a market and zone first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous invalid span layer
|
|
if (invalidSpanLayer) {
|
|
map.removeLayer(invalidSpanLayer);
|
|
invalidSpanLayer = null;
|
|
}
|
|
|
|
showStatus("Running Single Span QC...");
|
|
|
|
fetch(`/api/qc/single-span?map_id=${market}&zone=${zone}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (data.invalid_segments === 0) {
|
|
qcResultDiv.innerHTML = `✅ All ${data.total_aerial_segments} aerial segments have valid single spans.`;
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
showStatus("Single Span QC completed - All segments valid.");
|
|
} else {
|
|
qcResultDiv.innerHTML = `
|
|
❌ Single Span QC Results:<br>
|
|
• Total Aerial Segments: ${data.total_aerial_segments}<br>
|
|
• Valid: ${data.valid_segments}<br>
|
|
• Invalid: ${data.invalid_segments}<br>
|
|
• Pass Rate: ${data.pass_rate.toFixed(1)}%
|
|
`;
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
showStatus(`Single Span QC completed - ${data.invalid_segments} segments need attention.`, true);
|
|
|
|
// Get invalid segments and highlight them on map
|
|
const invalidSegments = data.results.filter(r => !r.is_valid);
|
|
console.log("Invalid segments:", invalidSegments);
|
|
|
|
// Create GeoJSON features for invalid segments
|
|
const invalidFeatures = [];
|
|
invalidSegments.forEach(segment => {
|
|
if (segment.geometry) {
|
|
invalidFeatures.push({
|
|
type: "Feature",
|
|
geometry: segment.geometry,
|
|
properties: {
|
|
segment_id: segment.segment_id,
|
|
error_message: segment.error_message,
|
|
vertex_count: segment.vertex_count
|
|
}
|
|
});
|
|
}
|
|
console.log(`Invalid segment ${segment.segment_id}: ${segment.error_message}`);
|
|
});
|
|
|
|
// Add invalid segments to map if we have geometry data
|
|
if (invalidFeatures.length > 0) {
|
|
invalidSpanLayer = L.geoJSON(invalidFeatures, {
|
|
style: {
|
|
color: 'orange',
|
|
weight: 5,
|
|
dashArray: '10,5'
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
// Add hover effect
|
|
layer.on('mouseover', function() {
|
|
this.setStyle({ weight: 7 });
|
|
});
|
|
layer.on('mouseout', function() {
|
|
this.setStyle({ weight: 5 });
|
|
});
|
|
|
|
layer.bindPopup(`
|
|
🚨 Invalid Single Span<br>
|
|
<strong>Segment ID:</strong> ${feature.properties.segment_id}<br>
|
|
<strong>Vertices:</strong> ${feature.properties.vertex_count}<br>
|
|
<strong>Error:</strong> ${feature.properties.error_message}
|
|
`);
|
|
|
|
// Add endpoint markers
|
|
if (feature.geometry && feature.geometry.coordinates) {
|
|
let coords = feature.geometry.coordinates;
|
|
|
|
// Handle MultiLineString - extract first linestring
|
|
if (feature.geometry.type === 'MultiLineString' && coords.length > 0) {
|
|
coords = coords[0];
|
|
}
|
|
|
|
if (coords && coords.length >= 2) {
|
|
// Start point (yellow)
|
|
L.circleMarker([coords[0][1], coords[0][0]], {
|
|
radius: 6,
|
|
fillColor: '#fbbf24',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane'
|
|
}).bindPopup(`<strong>Segment Start</strong><br>ID: ${feature.properties.segment_id || 'N/A'}`).addTo(map);
|
|
|
|
// End point (red)
|
|
L.circleMarker([coords[coords.length - 1][1], coords[coords.length - 1][0]], {
|
|
radius: 6,
|
|
fillColor: '#ef4444',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane'
|
|
}).bindPopup(`<strong>Segment End</strong><br>ID: ${feature.properties.segment_id || 'N/A'}`).addTo(map);
|
|
}
|
|
}
|
|
}
|
|
}).addTo(map);
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Single Span QC error:", err);
|
|
setButtonLoading(runQCButton, false);
|
|
qcResultDiv.textContent = "Error running Single Span QC";
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
statusPanel.classList.add('visible');
|
|
showStatus("Error running Single Span QC", true);
|
|
});
|
|
}
|
|
|
|
function getConnectedComponents(geojson) {
|
|
const nodes = new Map();
|
|
const edges = [];
|
|
|
|
console.log("Processing geojson with", geojson.features.length, "features");
|
|
|
|
// Build graph from segments
|
|
for (const feat of geojson.features) {
|
|
if (!feat.geometry || !feat.geometry.coordinates) {
|
|
console.warn("Feature missing geometry:", feat);
|
|
continue;
|
|
}
|
|
|
|
let coords = feat.geometry.coordinates;
|
|
|
|
// Handle MultiLineString - extract first LineString
|
|
if (feat.geometry.type === 'MultiLineString') {
|
|
console.log("MultiLineString detected, coords:", coords);
|
|
if (!coords || coords.length === 0 || !coords[0] || coords[0].length < 2) {
|
|
console.warn("Invalid MultiLineString:", coords);
|
|
continue;
|
|
}
|
|
coords = coords[0]; // Use first linestring
|
|
}
|
|
|
|
if (!coords || coords.length < 2) {
|
|
console.warn("Invalid coords:", coords);
|
|
continue;
|
|
}
|
|
|
|
// Verify coordinate structure
|
|
if (!coords[0] || !Array.isArray(coords[0]) || coords[0].length < 2) {
|
|
console.warn("Invalid start coordinate:", coords[0]);
|
|
continue;
|
|
}
|
|
if (!coords[coords.length - 1] || !Array.isArray(coords[coords.length - 1])) {
|
|
console.warn("Invalid end coordinate:", coords[coords.length - 1]);
|
|
continue;
|
|
}
|
|
|
|
const start = coords[0].join(',');
|
|
const end = coords[coords.length - 1].join(',');
|
|
|
|
edges.push([start, end, feat]);
|
|
|
|
if (!nodes.has(start)) nodes.set(start, []);
|
|
if (!nodes.has(end)) nodes.set(end, []);
|
|
nodes.get(start).push(end);
|
|
nodes.get(end).push(start);
|
|
}
|
|
|
|
// Check if we have any edges to process
|
|
if (edges.length === 0) {
|
|
return { components: [], totalSegments: 0 };
|
|
}
|
|
|
|
// Find all connected components using DFS
|
|
const visitedNodes = new Set();
|
|
const components = [];
|
|
|
|
// Helper function to perform DFS and collect all nodes in a component
|
|
function dfs(startNode) {
|
|
const componentNodes = new Set();
|
|
const stack = [startNode];
|
|
|
|
while (stack.length) {
|
|
const node = stack.pop();
|
|
if (componentNodes.has(node)) continue;
|
|
componentNodes.add(node);
|
|
visitedNodes.add(node);
|
|
|
|
for (const neighbor of nodes.get(node) || []) {
|
|
if (!componentNodes.has(neighbor)) {
|
|
stack.push(neighbor);
|
|
}
|
|
}
|
|
}
|
|
|
|
return componentNodes;
|
|
}
|
|
|
|
// Find all connected components
|
|
for (const node of nodes.keys()) {
|
|
if (!visitedNodes.has(node)) {
|
|
const componentNodes = dfs(node);
|
|
|
|
// Collect all edges (segments) that belong to this component
|
|
const componentSegments = [];
|
|
for (const [start, end, feature] of edges) {
|
|
if (componentNodes.has(start) && componentNodes.has(end)) {
|
|
componentSegments.push(feature);
|
|
}
|
|
}
|
|
|
|
if (componentSegments.length > 0) {
|
|
components.push({
|
|
nodes: Array.from(componentNodes),
|
|
segments: componentSegments,
|
|
size: componentSegments.length
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort components by size (largest first)
|
|
components.sort((a, b) => b.size - a.size);
|
|
|
|
console.log(`Found ${components.length} connected component(s):`,
|
|
components.map(c => `${c.size} segments`));
|
|
|
|
return {
|
|
components: components,
|
|
totalSegments: edges.length
|
|
};
|
|
}
|
|
|
|
let disconnectedHandholeLayer;
|
|
|
|
function runAccessPointQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market || !zone) {
|
|
alert("Select a market and zone first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous layer
|
|
if (disconnectedHandholeLayer) {
|
|
map.removeLayer(disconnectedHandholeLayer);
|
|
disconnectedHandholeLayer = null;
|
|
}
|
|
|
|
showStatus("Running Handhole Connectivity QC...");
|
|
|
|
const segmentFeatures = [];
|
|
layers.segments.eachLayer(layer => {
|
|
if (layer.feature) segmentFeatures.push(layer.feature);
|
|
});
|
|
|
|
const handholeFeatures = [];
|
|
layers.handholes.eachLayer(layer => {
|
|
if (layer.feature) handholeFeatures.push(layer.feature);
|
|
});
|
|
|
|
if (!segmentFeatures.length || !handholeFeatures.length) {
|
|
setButtonLoading(runQCButton, false);
|
|
showStatus("Segments or handholes not loaded.", true);
|
|
return;
|
|
}
|
|
|
|
const endpoints = [];
|
|
segmentFeatures.forEach(feature => {
|
|
const coords = feature.geometry.coordinates;
|
|
endpoints.push(turf.point(coords[0]));
|
|
endpoints.push(turf.point(coords[coords.length - 1]));
|
|
});
|
|
|
|
const endpointCollection = turf.featureCollection(endpoints);
|
|
const disconnectedHHs = [];
|
|
|
|
handholeFeatures.forEach(hh => {
|
|
const hhPoint = turf.point(hh.geometry.coordinates);
|
|
const nearest = turf.nearestPoint(hhPoint, endpointCollection);
|
|
const distance = turf.distance(hhPoint, nearest, { units: 'meters' });
|
|
|
|
if (distance > 1.0) {
|
|
disconnectedHHs.push(hh);
|
|
}
|
|
});
|
|
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (disconnectedHHs.length === 0) {
|
|
qcResultDiv.textContent = "✅ All handholes are connected.";
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
showStatus("Handhole Connectivity QC passed.");
|
|
} else {
|
|
qcResultDiv.textContent = `❌ Found ${disconnectedHHs.length} disconnected handhole(s).`;
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
showStatus(`Found ${disconnectedHHs.length} disconnected handhole(s).`, true);
|
|
|
|
disconnectedHandholeLayer = L.geoJSON(disconnectedHHs, {
|
|
pointToLayer: (f, latlng) => L.circleMarker(latlng, {
|
|
radius: 8,
|
|
color: 'red',
|
|
fillOpacity: 0.8
|
|
}),
|
|
onEachFeature: (feature, layer) => {
|
|
const id = feature.properties.id || 'Unknown';
|
|
const name = feature.properties.name || 'Unnamed';
|
|
layer.bindPopup(`🚨 Disconnected Handhole<br>ID: ${id}<br>Name: ${name}`);
|
|
}
|
|
}).addTo(map);
|
|
}
|
|
}
|
|
|
|
/// Site Connectivity QC - NEW FUNCTIONALITY
|
|
let disconnectedSitesLayer;
|
|
|
|
function runSiteConnectivityQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market) {
|
|
alert("Select a market first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous disconnected sites layer
|
|
if (disconnectedSitesLayer) {
|
|
map.removeLayer(disconnectedSitesLayer);
|
|
disconnectedSitesLayer = null;
|
|
}
|
|
|
|
showStatus("Checking site connectivity...");
|
|
|
|
// Build query parameters
|
|
let queryParams = `map_id=${market}`;
|
|
if (zone) {
|
|
queryParams += `&zone=${zone}`;
|
|
}
|
|
|
|
// Add max distance parameter (default 50m, user could modify this)
|
|
queryParams += `&max_distance=50`;
|
|
|
|
fetch(`/api/qc/site-connectivity?${queryParams}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (data.disconnected_sites === 0) {
|
|
qcResultDiv.innerHTML = `✅ All ${data.total_sites} sites are connected to the network (within 50m).`;
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
showStatus("Site Connectivity QC completed - All sites connected.");
|
|
} else {
|
|
qcResultDiv.innerHTML = `
|
|
📡 Site Connectivity Results:<br>
|
|
• Total Sites: ${data.total_sites}<br>
|
|
• Connected: ${data.connected_sites}<br>
|
|
• Disconnected: ${data.disconnected_sites}<br>
|
|
• Connectivity Rate: ${data.connectivity_rate.toFixed(1)}%<br>
|
|
• Max Distance: ${data.max_distance_meters}m
|
|
`;
|
|
qcResultDiv.style.color = data.disconnected_sites > 0 ? "#ef4444" : "#10b981";
|
|
showClearQCButton();
|
|
showStatus(`Site Connectivity QC completed - ${data.disconnected_sites} sites need attention.`, data.disconnected_sites > 0);
|
|
|
|
// Get disconnected sites and highlight them on map
|
|
const disconnectedSites = data.results.filter(r => !r.is_connected);
|
|
console.log("Disconnected sites:", disconnectedSites);
|
|
|
|
// Create GeoJSON features for disconnected sites
|
|
const disconnectedFeatures = [];
|
|
disconnectedSites.forEach(site => {
|
|
if (site.geometry) {
|
|
disconnectedFeatures.push({
|
|
type: "Feature",
|
|
geometry: JSON.parse(site.geometry),
|
|
properties: {
|
|
site_id: site.site_id,
|
|
site_name: site.site_name,
|
|
address: site.address,
|
|
city: site.city,
|
|
state: site.state,
|
|
nearest_distance: site.nearest_distance,
|
|
connectivity_status: site.connectivity_status
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Add disconnected sites to map if we have geometry data
|
|
if (disconnectedFeatures.length > 0) {
|
|
disconnectedSitesLayer = L.geoJSON(disconnectedFeatures, {
|
|
pointToLayer: (f, latlng) => L.circleMarker(latlng, {
|
|
radius: 8,
|
|
color: 'red',
|
|
fillColor: 'red',
|
|
fillOpacity: 0.8,
|
|
weight: 3
|
|
}),
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties;
|
|
layer.bindPopup(`
|
|
🚨 Disconnected Site<br>
|
|
<strong>ID:</strong> ${props.site_id}<br>
|
|
<strong>Name:</strong> ${props.site_name || 'Unnamed'}<br>
|
|
<strong>Address:</strong> ${props.address || 'N/A'}<br>
|
|
<strong>Distance to Network:</strong> ${props.nearest_distance.toFixed(1)}m<br>
|
|
<strong>Status:</strong> ${props.connectivity_status}
|
|
`);
|
|
}
|
|
}).addTo(map);
|
|
|
|
// Fit map to disconnected sites if any exist
|
|
if (disconnectedSitesLayer.getBounds().isValid()) {
|
|
map.fitBounds(disconnectedSitesLayer.getBounds(), { padding: [20, 20] });
|
|
}
|
|
}
|
|
|
|
// Log database update confirmation
|
|
console.log("Site connectivity status updated in database for QGIS analysis");
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Site Connectivity QC error:", err);
|
|
setButtonLoading(runQCButton, false);
|
|
qcResultDiv.textContent = "Error running Site Connectivity QC";
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
statusPanel.classList.add('visible');
|
|
showStatus("Error running Site Connectivity QC", true);
|
|
});
|
|
}
|
|
|
|
/// Aerial Endpoint QC - NEW FUNCTIONALITY
|
|
let invalidAerialEndpointLayer;
|
|
|
|
function runAerialEndpointQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market || !zone) {
|
|
alert("Select a market and zone first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous invalid aerial endpoint layer
|
|
if (invalidAerialEndpointLayer) {
|
|
map.removeLayer(invalidAerialEndpointLayer);
|
|
invalidAerialEndpointLayer = null;
|
|
}
|
|
|
|
showStatus("Running Aerial Endpoint QC...");
|
|
|
|
fetch(`/api/qc/aerial-endpoints?map_id=${market}&zone=${zone}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (data.invalid_segments === 0) {
|
|
qcResultDiv.innerHTML = `✅ All ${data.total_aerial_segments} aerial segments have exactly one pole at each endpoint.`;
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
showStatus("Aerial Endpoint QC completed - All segments valid.");
|
|
} else {
|
|
qcResultDiv.innerHTML = `
|
|
🔌 Aerial Endpoint QC Results:<br>
|
|
• Total Aerial Segments: ${data.total_aerial_segments}<br>
|
|
• Valid: ${data.valid_segments}<br>
|
|
• Invalid: ${data.invalid_segments}<br>
|
|
• Pass Rate: ${data.pass_rate.toFixed(1)}%
|
|
`;
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
showStatus(`Aerial Endpoint QC completed - ${data.invalid_segments} segments need attention.`, true);
|
|
|
|
// Get invalid segments and highlight them on map
|
|
const invalidSegments = data.results.filter(r => !r.is_valid);
|
|
console.log("Invalid aerial endpoint segments:", invalidSegments);
|
|
|
|
// Create GeoJSON features for invalid segments
|
|
const invalidFeatures = [];
|
|
invalidSegments.forEach(segment => {
|
|
if (segment.geometry) {
|
|
invalidFeatures.push({
|
|
type: "Feature",
|
|
geometry: segment.geometry,
|
|
properties: {
|
|
segment_id: segment.segment_id,
|
|
error_message: segment.error_message,
|
|
start_pole_count: segment.start_pole_count,
|
|
end_pole_count: segment.end_pole_count,
|
|
start_pole_ids: segment.start_pole_ids,
|
|
end_pole_ids: segment.end_pole_ids
|
|
}
|
|
});
|
|
}
|
|
console.log(`Invalid aerial segment ${segment.segment_id}: ${segment.error_message}`);
|
|
});
|
|
|
|
// Add invalid segments to map if we have geometry data
|
|
if (invalidFeatures.length > 0) {
|
|
invalidAerialEndpointLayer = L.geoJSON(invalidFeatures, {
|
|
style: {
|
|
color: 'yellow',
|
|
weight: 6,
|
|
dashArray: '10,5'
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties;
|
|
const startPoleIds = props.start_pole_ids && props.start_pole_ids.length > 0
|
|
? props.start_pole_ids.join(', ')
|
|
: 'None';
|
|
const endPoleIds = props.end_pole_ids && props.end_pole_ids.length > 0
|
|
? props.end_pole_ids.join(', ')
|
|
: 'None';
|
|
|
|
// Add hover effect
|
|
layer.on('mouseover', function() {
|
|
this.setStyle({ weight: 8 });
|
|
});
|
|
layer.on('mouseout', function() {
|
|
this.setStyle({ weight: 6 });
|
|
});
|
|
|
|
layer.bindPopup(`
|
|
🔌 Invalid Aerial Endpoint<br>
|
|
<strong>Segment ID:</strong> ${props.segment_id}<br>
|
|
<strong>Start Poles:</strong> ${props.start_pole_count} (IDs: ${startPoleIds})<br>
|
|
<strong>End Poles:</strong> ${props.end_pole_count} (IDs: ${endPoleIds})<br>
|
|
<strong>Issue:</strong> ${props.error_message}
|
|
`);
|
|
|
|
// Add endpoint markers
|
|
if (feature.geometry && feature.geometry.coordinates) {
|
|
let coords = feature.geometry.coordinates;
|
|
|
|
// Handle MultiLineString - extract first linestring
|
|
if (feature.geometry.type === 'MultiLineString' && coords.length > 0) {
|
|
coords = coords[0];
|
|
}
|
|
|
|
if (coords && coords.length >= 2) {
|
|
// Start point (yellow)
|
|
L.circleMarker([coords[0][1], coords[0][0]], {
|
|
radius: 6,
|
|
fillColor: '#fbbf24',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane'
|
|
}).bindPopup(`<strong>Segment Start</strong><br>ID: ${props.segment_id}<br>Poles: ${props.start_pole_count}`).addTo(endpointMarkersLayer);
|
|
|
|
// End point (red)
|
|
L.circleMarker([coords[coords.length - 1][1], coords[coords.length - 1][0]], {
|
|
radius: 6,
|
|
fillColor: '#ef4444',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane'
|
|
}).bindPopup(`<strong>Segment End</strong><br>ID: ${props.segment_id}<br>Poles: ${props.end_pole_count}`).addTo(endpointMarkersLayer);
|
|
}
|
|
}
|
|
}
|
|
}).addTo(map);
|
|
|
|
// Show endpoint toggle since we've added endpoint markers
|
|
showEndpointToggle();
|
|
|
|
// Update QC flags in database for invalid segments
|
|
const invalidSegmentIds = invalidSegments.map(s => s.segment_id);
|
|
fetch('/api/qc/aerial-endpoints/update-flags', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
segment_ids: invalidSegmentIds,
|
|
map_id: market,
|
|
zone: zone
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log("QC flags updated:", data.message);
|
|
})
|
|
.catch(err => console.error("Error updating QC flags:", err));
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Aerial Endpoint QC error:", err);
|
|
setButtonLoading(runQCButton, false);
|
|
qcResultDiv.textContent = "Error running Aerial Endpoint QC";
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
statusPanel.classList.add('visible');
|
|
showStatus("Error running Aerial Endpoint QC", true);
|
|
});
|
|
}
|
|
|
|
/// Underground Endpoint QC - NEW FUNCTIONALITY
|
|
let invalidUndergroundLayer;
|
|
|
|
function runUndergroundEndpointQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market || !zone) {
|
|
alert("Select a market and zone first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous invalid underground layer
|
|
if (invalidUndergroundLayer) {
|
|
map.removeLayer(invalidUndergroundLayer);
|
|
invalidUndergroundLayer = null;
|
|
}
|
|
|
|
showStatus("Running Underground Endpoint QC...");
|
|
|
|
fetch(`/api/qc/underground-endpoints?map_id=${market}&zone=${zone}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (data.invalid_segments === 0) {
|
|
qcResultDiv.innerHTML = `✅ All ${data.total_underground_segments} underground segments have valid endpoints.`;
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
showStatus("Underground Endpoint QC completed - All segments valid.");
|
|
} else {
|
|
qcResultDiv.innerHTML = `
|
|
🔧 Underground Endpoint QC Results:<br>
|
|
• Total Underground Segments: ${data.total_underground_segments}<br>
|
|
• Valid: ${data.valid_segments}<br>
|
|
• Invalid: ${data.invalid_segments}<br>
|
|
• Pass Rate: ${data.pass_rate.toFixed(1)}%
|
|
`;
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
showStatus(`Underground Endpoint QC completed - ${data.invalid_segments} segments need endpoints.`, true);
|
|
|
|
// Get invalid segments and highlight them on map
|
|
const invalidSegments = data.results.filter(r => !r.is_valid);
|
|
console.log("Invalid underground segments:", invalidSegments);
|
|
|
|
// Create GeoJSON features for invalid segments
|
|
const invalidFeatures = [];
|
|
invalidSegments.forEach(segment => {
|
|
if (segment.geometry) {
|
|
invalidFeatures.push({
|
|
type: "Feature",
|
|
geometry: segment.geometry,
|
|
properties: {
|
|
segment_id: segment.segment_id,
|
|
error_message: segment.error_message,
|
|
start_endpoint: segment.start_endpoint,
|
|
end_endpoint: segment.end_endpoint
|
|
}
|
|
});
|
|
}
|
|
console.log(`Invalid underground segment ${segment.segment_id}: ${segment.error_message}`);
|
|
});
|
|
|
|
// Add invalid segments to map if we have geometry data
|
|
if (invalidFeatures.length > 0) {
|
|
invalidUndergroundLayer = L.geoJSON(invalidFeatures, {
|
|
style: {
|
|
color: 'purple',
|
|
weight: 6,
|
|
dashArray: '8,4'
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
// Add hover effect
|
|
layer.on('mouseover', function() {
|
|
this.setStyle({ weight: 8 });
|
|
});
|
|
layer.on('mouseout', function() {
|
|
this.setStyle({ weight: 6 });
|
|
});
|
|
|
|
layer.bindPopup(`
|
|
🔧 Invalid Underground Endpoint<br>
|
|
<strong>Segment ID:</strong> ${feature.properties.segment_id}<br>
|
|
<strong>Start Endpoint:</strong> ${feature.properties.start_endpoint}<br>
|
|
<strong>End Endpoint:</strong> ${feature.properties.end_endpoint}<br>
|
|
<strong>Issue:</strong> ${feature.properties.error_message}
|
|
`);
|
|
|
|
// Add endpoint markers
|
|
if (feature.geometry && feature.geometry.coordinates) {
|
|
let coords = feature.geometry.coordinates;
|
|
|
|
// Handle MultiLineString - extract first linestring
|
|
if (feature.geometry.type === 'MultiLineString' && coords.length > 0) {
|
|
coords = coords[0];
|
|
}
|
|
|
|
if (coords && coords.length >= 2) {
|
|
// Start point (yellow)
|
|
L.circleMarker([coords[0][1], coords[0][0]], {
|
|
radius: 6,
|
|
fillColor: '#fbbf24',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane'
|
|
}).bindPopup(`<strong>Segment Start</strong><br>ID: ${feature.properties.segment_id}<br>Status: ${feature.properties.start_endpoint}`).addTo(endpointMarkersLayer);
|
|
|
|
// End point (red)
|
|
L.circleMarker([coords[coords.length - 1][1], coords[coords.length - 1][0]], {
|
|
radius: 6,
|
|
fillColor: '#ef4444',
|
|
color: '#000000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 1,
|
|
pane: 'endpointPane'
|
|
}).bindPopup(`<strong>Segment End</strong><br>ID: ${feature.properties.segment_id}<br>Status: ${feature.properties.end_endpoint}`).addTo(endpointMarkersLayer);
|
|
}
|
|
}
|
|
}
|
|
}).addTo(map);
|
|
|
|
// Show endpoint toggle since we've added endpoint markers
|
|
showEndpointToggle();
|
|
|
|
// Update QC flags in database for invalid segments
|
|
const invalidSegmentIds = invalidSegments.map(s => s.segment_id);
|
|
fetch('/api/qc/underground-endpoints/update-flags', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
segment_ids: invalidSegmentIds,
|
|
map_id: market,
|
|
zone: zone
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log("QC flags updated:", data.message);
|
|
})
|
|
.catch(err => console.error("Error updating QC flags:", err));
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Underground Endpoint QC error:", err);
|
|
setButtonLoading(runQCButton, false);
|
|
qcResultDiv.textContent = "Error running Underground Endpoint QC";
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
statusPanel.classList.add('visible');
|
|
showStatus("Error running Underground Endpoint QC", true);
|
|
});
|
|
}
|
|
|
|
/// Zone Containment QC
|
|
let invalidZoneContainmentLayer;
|
|
|
|
function runZoneContainmentQC() {
|
|
const market = marketSelect.value;
|
|
const zone = zoneSelect.value;
|
|
|
|
if (!market || !zone) {
|
|
alert("Select a market and zone first.");
|
|
return;
|
|
}
|
|
|
|
setButtonLoading(runQCButton, true);
|
|
hideStatus();
|
|
|
|
// Clear previous invalid zone containment layer
|
|
if (invalidZoneContainmentLayer) {
|
|
map.removeLayer(invalidZoneContainmentLayer);
|
|
invalidZoneContainmentLayer = null;
|
|
}
|
|
|
|
showStatus("Running Zone Containment QC...");
|
|
|
|
fetch(`/api/qc/zone-containment?map_id=${market}&zone=${zone}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setButtonLoading(runQCButton, false);
|
|
statusPanel.classList.add('visible');
|
|
|
|
if (data.invalid_elements === 0) {
|
|
qcResultDiv.innerHTML = `✅ All ${data.total_elements} network elements are within their assigned zones.`;
|
|
qcResultDiv.style.color = "#10b981";
|
|
showClearQCButton();
|
|
showStatus("Zone Containment QC completed - All elements valid.");
|
|
} else {
|
|
// Build detailed breakdown by element type
|
|
let typeBreakdown = '';
|
|
for (const [elementType, summary] of Object.entries(data.by_type)) {
|
|
if (summary.total > 0) {
|
|
const icon = summary.invalid > 0 ? '❌' : '✅';
|
|
typeBreakdown += ` ${icon} ${elementType}: ${summary.valid}/${summary.total} valid<br>`;
|
|
}
|
|
}
|
|
|
|
qcResultDiv.innerHTML = `
|
|
📍 Zone Containment QC Results:<br>
|
|
• Total Elements: ${data.total_elements}<br>
|
|
• Valid: ${data.valid_elements}<br>
|
|
• Invalid: ${data.invalid_elements}<br>
|
|
• Pass Rate: ${data.pass_rate.toFixed(1)}%<br>
|
|
<br>
|
|
<strong>By Type:</strong><br>
|
|
${typeBreakdown}
|
|
`;
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
showStatus(`Zone Containment QC completed - ${data.invalid_elements} elements need attention.`, true);
|
|
|
|
// Get invalid elements and highlight them on map
|
|
const invalidElements = data.results.filter(r => !r.is_valid);
|
|
console.log("Invalid zone containment elements:", invalidElements);
|
|
|
|
// Create GeoJSON features for invalid elements
|
|
const invalidFeatures = [];
|
|
invalidElements.forEach(element => {
|
|
if (element.geometry) {
|
|
invalidFeatures.push({
|
|
type: "Feature",
|
|
geometry: element.geometry,
|
|
properties: {
|
|
element_id: element.element_id,
|
|
element_type: element.element_type,
|
|
element_name: element.element_name,
|
|
assigned_zone: element.assigned_zone,
|
|
actual_zones: element.actual_zones,
|
|
error_message: element.error_message
|
|
}
|
|
});
|
|
}
|
|
console.log(`Invalid ${element.element_type} ${element.element_id}: ${element.error_message}`);
|
|
});
|
|
|
|
// Add invalid elements to map if we have geometry data
|
|
if (invalidFeatures.length > 0) {
|
|
invalidZoneContainmentLayer = L.geoJSON(invalidFeatures, {
|
|
pointToLayer: (feature, latlng) => {
|
|
// Different marker colors by element type
|
|
const colors = {
|
|
'site': '#ef4444', // red
|
|
'pole': '#f59e0b', // amber
|
|
'access_point': '#8b5cf6' // purple
|
|
};
|
|
const color = colors[feature.properties.element_type] || '#ef4444';
|
|
|
|
return L.circleMarker(latlng, {
|
|
radius: 8,
|
|
fillColor: color,
|
|
color: '#ffffff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.9
|
|
});
|
|
},
|
|
style: (feature) => {
|
|
// For segments (LineString)
|
|
if (feature.geometry.type === 'LineString' || feature.geometry.type === 'MultiLineString') {
|
|
return {
|
|
color: '#fbbf24', // yellow
|
|
weight: 6,
|
|
dashArray: '10,5',
|
|
opacity: 1
|
|
};
|
|
}
|
|
},
|
|
onEachFeature: (feature, layer) => {
|
|
const props = feature.properties;
|
|
const assignedZone = props.assigned_zone || 'NULL/Blank';
|
|
const actualZones = props.actual_zones && props.actual_zones.length > 0
|
|
? props.actual_zones.join(', ')
|
|
: 'None';
|
|
|
|
// Add hover effect
|
|
layer.on('mouseover', function() {
|
|
if (feature.geometry.type === 'LineString' || feature.geometry.type === 'MultiLineString') {
|
|
this.setStyle({ weight: 8 });
|
|
} else {
|
|
this.setStyle({ radius: 10 });
|
|
}
|
|
});
|
|
layer.on('mouseout', function() {
|
|
if (feature.geometry.type === 'LineString' || feature.geometry.type === 'MultiLineString') {
|
|
this.setStyle({ weight: 6 });
|
|
} else {
|
|
this.setStyle({ radius: 8 });
|
|
}
|
|
});
|
|
|
|
const elementTypeName = props.element_type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
layer.bindPopup(`
|
|
📍 Invalid Zone Containment<br>
|
|
<strong>Type:</strong> ${elementTypeName}<br>
|
|
<strong>ID:</strong> ${props.element_id}<br>
|
|
${props.element_name ? `<strong>Name:</strong> ${props.element_name}<br>` : ''}
|
|
<strong>Assigned Zone:</strong> ${assignedZone}<br>
|
|
<strong>Actual Zones:</strong> ${actualZones}<br>
|
|
<strong>Issue:</strong> ${props.error_message}
|
|
`);
|
|
}
|
|
}).addTo(map);
|
|
|
|
// Update QC flags in database for invalid segments
|
|
const invalidSegmentIds = invalidElements
|
|
.filter(e => e.element_type === 'segment')
|
|
.map(e => e.element_id);
|
|
|
|
if (invalidSegmentIds.length > 0) {
|
|
fetch('/api/qc/zone-containment/update-flags', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
segment_ids: invalidSegmentIds,
|
|
map_id: market,
|
|
zone: zone
|
|
})
|
|
})
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
console.log("QC flags updated:", data.message);
|
|
})
|
|
.catch(err => console.error("Error updating QC flags:", err));
|
|
}
|
|
|
|
// Zoom to invalid elements
|
|
const bounds = invalidZoneContainmentLayer.getBounds();
|
|
if (bounds.isValid()) {
|
|
map.fitBounds(bounds, { padding: [50, 50] });
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error("Zone Containment QC error:", err);
|
|
setButtonLoading(runQCButton, false);
|
|
qcResultDiv.textContent = "Error running Zone Containment QC";
|
|
qcResultDiv.style.color = "#ef4444";
|
|
showClearQCButton();
|
|
statusPanel.classList.add('visible');
|
|
showStatus("Error running Zone Containment QC", true);
|
|
});
|
|
}
|