alex 12407b74e4 Initial commit - Stage 1 working version
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>
2025-12-04 13:43:57 -07:00

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);
});
}