Initial commit from upstream

This commit is contained in:
Will Bradley
2025-12-05 14:29:17 -08:00
commit 3a85c3e281
21 changed files with 5339 additions and 0 deletions

683
web/app.js Normal file
View File

@@ -0,0 +1,683 @@
// Global state
let map;
let osmLayer;
let diffLayer;
let countyLayer;
let osmData = null;
let diffData = null;
let countyData = null;
let selectedFeature = null;
let selectedLayer = null;
let acceptedFeatures = new Set();
let rejectedFeatures = new Set();
let featurePopup = null;
let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom)
// Initialize map
function initMap() {
map = L.map('map').setView([28.7, -81.7], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
// Create custom panes for layer ordering
map.createPane('osmPane');
map.createPane('diffPane');
map.createPane('countyPane');
// Set initial z-indices for panes
map.getPane('osmPane').style.zIndex = 400;
map.getPane('diffPane').style.zIndex = 401;
map.getPane('countyPane').style.zIndex = 402;
}
// Calculate bounds for all loaded layers
function calculateBounds() {
const bounds = L.latLngBounds([]);
let hasData = false;
if (osmData && osmData.features.length > 0) {
L.geoJSON(osmData).eachLayer(layer => {
if (layer.getBounds) {
bounds.extend(layer.getBounds());
} else if (layer.getLatLng) {
bounds.extend(layer.getLatLng());
}
});
hasData = true;
}
if (diffData && diffData.features.length > 0) {
L.geoJSON(diffData).eachLayer(layer => {
if (layer.getBounds) {
bounds.extend(layer.getBounds());
} else if (layer.getLatLng) {
bounds.extend(layer.getLatLng());
}
});
hasData = true;
}
if (countyData && countyData.features.length > 0) {
L.geoJSON(countyData).eachLayer(layer => {
if (layer.getBounds) {
bounds.extend(layer.getBounds());
} else if (layer.getLatLng) {
bounds.extend(layer.getLatLng());
}
});
hasData = true;
}
if (hasData && bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
}
}
// Style functions
function osmStyle(feature) {
return {
color: '#4a4a4a',
weight: 3,
opacity: 0.7
};
}
function diffStyle(feature) {
// Check if feature is accepted or rejected
if (acceptedFeatures.has(feature)) {
return {
color: '#007bff',
weight: 3,
opacity: 0.8
};
}
if (rejectedFeatures.has(feature)) {
return {
color: '#ff8c00',
weight: 3,
opacity: 0.8
};
}
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
return {
color: isRemoved ? '#ff0000' : '#00ff00',
weight: 3,
opacity: 0.8
};
}
function countyStyle(feature) {
return {
color: '#ff00ff',
weight: 3,
opacity: 0.8
};
}
// Filter function for OSM features
function shouldShowOsmFeature(feature) {
const props = feature.properties || {};
const isService = props.highway === 'service';
const hideService = document.getElementById('hideService').checked;
if (isService && hideService) return false;
return true;
}
// Create layer for OSM data
function createOsmLayer() {
if (osmLayer) {
map.removeLayer(osmLayer);
}
if (!osmData) return;
osmLayer = L.geoJSON(osmData, {
style: osmStyle,
filter: shouldShowOsmFeature,
pane: 'osmPane',
onEachFeature: function(feature, layer) {
layer.on('click', function(e) {
L.DomEvent.stopPropagation(e);
selectFeature(feature, layer, e, 'osm');
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
layer.setStyle({
weight: 5,
opacity: 1
});
}
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
layer.setStyle(osmStyle(feature));
}
});
}
}).addTo(map);
updateLayerZIndex();
}
// Filter function for diff features
function shouldShowFeature(feature) {
const props = feature.properties || {};
const isRemoved = props.removed === true || props.removed === 'True';
const isService = props.highway === 'service';
const showAdded = document.getElementById('showAdded').checked;
const showRemoved = document.getElementById('showRemoved').checked;
const hideService = document.getElementById('hideService').checked;
// Check removed/added filter
if (isRemoved && !showRemoved) return false;
if (!isRemoved && !showAdded) return false;
// Check service filter
if (isService && hideService) return false;
return true;
}
// Create layer for diff data with click handlers
function createDiffLayer() {
if (diffLayer) {
map.removeLayer(diffLayer);
}
if (!diffData) return;
diffLayer = L.geoJSON(diffData, {
style: diffStyle,
filter: shouldShowFeature,
pane: 'diffPane',
onEachFeature: function(feature, layer) {
layer.on('click', function(e) {
L.DomEvent.stopPropagation(e);
selectFeature(feature, layer, e, 'diff');
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
layer.setStyle({
weight: 5,
opacity: 1
});
}
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
layer.setStyle(diffStyle(feature));
}
});
}
}).addTo(map);
updateLayerZIndex();
}
// Filter function for county features
function shouldShowCountyFeature(feature) {
const props = feature.properties || {};
const isService = props.highway === 'service';
const hideService = document.getElementById('hideService').checked;
if (isService && hideService) return false;
return true;
}
// Create layer for county data
function createCountyLayer() {
if (countyLayer) {
map.removeLayer(countyLayer);
}
if (!countyData) return;
countyLayer = L.geoJSON(countyData, {
style: countyStyle,
filter: shouldShowCountyFeature,
pane: 'countyPane',
onEachFeature: function(feature, layer) {
layer.on('click', function(e) {
L.DomEvent.stopPropagation(e);
selectFeature(feature, layer, e, 'county');
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
layer.setStyle({
weight: 5,
opacity: 1
});
}
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
layer.setStyle(countyStyle(feature));
}
});
}
});
// County layer is hidden by default
if (document.getElementById('countyToggle').checked) {
countyLayer.addTo(map);
}
updateLayerZIndex();
}
// Select a feature from any layer
function selectFeature(feature, layer, e, layerType = 'diff') {
// Deselect previous feature
if (selectedLayer) {
// Get the appropriate style function based on previous layer type
const styleFunc = selectedLayer._layerType === 'diff' ? diffStyle :
selectedLayer._layerType === 'osm' ? osmStyle : countyStyle;
selectedLayer.setStyle(styleFunc(selectedLayer.feature));
}
selectedFeature = feature;
selectedLayer = layer;
selectedLayer._layerType = layerType; // Store layer type for later
layer.setStyle({
weight: 6,
opacity: 1,
color: '#ffc107'
});
// Create popup near the clicked location
const props = feature.properties || {};
const isRemoved = props.removed === true || props.removed === 'True';
const isAccepted = acceptedFeatures.has(feature);
const isRejected = rejectedFeatures.has(feature);
let html = '<div style="font-size: 12px; max-height: 400px; overflow-y: auto;">';
// Show layer type
html += `<div style="margin-bottom: 8px;"><strong>Layer:</strong> ${layerType.toUpperCase()}</div>`;
// Only show status for diff layer
if (layerType === 'diff') {
html += `<div style="margin-bottom: 8px;"><strong>Status:</strong> ${isRemoved ? 'Removed' : 'Added/Modified'}</div>`;
}
// Display all non-null properties with custom ordering
const displayProps = Object.entries(props)
.filter(([key, value]) => value !== null && value !== undefined && key !== 'removed')
.sort(([a], [b]) => {
// Priority order: name, highway, then alphabetical
const priorityOrder = { 'name': 0, 'highway': 1 };
const aPriority = priorityOrder[a] ?? 999;
const bPriority = priorityOrder[b] ?? 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
return a.localeCompare(b);
});
if (displayProps.length > 0) {
html += '<div style="font-size: 11px;">';
for (const [key, value] of displayProps) {
html += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${value}</div>`;
}
html += '</div>';
}
// Only show accept/reject for diff layer
if (layerType === 'diff') {
if (isAccepted) {
html += '<div style="margin-top: 8px; color: #007bff; font-weight: bold;">✓ Accepted</div>';
} else if (isRejected) {
html += '<div style="margin-top: 8px; color: #4a4a4a; font-weight: bold;">✗ Rejected</div>';
}
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
html += '<button onclick="acceptFeature()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept</button>';
html += '<button onclick="rejectFeature()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject</button>';
html += '</div>';
}
html += '</div>';
// Remove old popup if exists
if (featurePopup) {
map.closePopup(featurePopup);
}
// Create popup at click location
featurePopup = L.popup({
maxWidth: 300,
closeButton: true,
autoClose: false,
closeOnClick: false
})
.setLatLng(e.latlng)
.setContent(html)
.openOn(map);
// Handle popup close
featurePopup.on('remove', function() {
if (selectedLayer) {
selectedLayer.setStyle(diffStyle(selectedLayer.feature));
selectedLayer = null;
selectedFeature = null;
}
});
}
// Accept a feature
function acceptFeature() {
if (!selectedFeature) return;
// Remove from rejected if present
rejectedFeatures.delete(selectedFeature);
// Add to accepted
acceptedFeatures.add(selectedFeature);
// Update layer style
if (selectedLayer) {
selectedLayer.setStyle(diffStyle(selectedFeature));
}
// Close popup
if (featurePopup) {
map.closePopup(featurePopup);
}
// Enable save button
updateSaveButton();
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
}
// Reject a feature
function rejectFeature() {
if (!selectedFeature) return;
// Remove from accepted if present
acceptedFeatures.delete(selectedFeature);
// Add to rejected
rejectedFeatures.add(selectedFeature);
// Update layer style
if (selectedLayer) {
selectedLayer.setStyle(diffStyle(selectedFeature));
}
// Close popup
if (featurePopup) {
map.closePopup(featurePopup);
}
// Enable save button
updateSaveButton();
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
}
// Expose functions globally for onclick handlers
window.acceptFeature = acceptFeature;
window.rejectFeature = rejectFeature;
// Update save button state
function updateSaveButton() {
document.getElementById('saveButton').disabled =
acceptedFeatures.size === 0 && rejectedFeatures.size === 0;
}
// Load file from input
function loadFile(input) {
return new Promise((resolve, reject) => {
const file = input.files[0];
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
resolve(data);
} catch (error) {
reject(new Error(`Failed to parse ${file.name}: ${error.message}`));
}
};
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
reader.readAsText(file);
});
}
// Load all files
async function loadFiles() {
try {
showStatus('Loading files...', 'success');
const osmInput = document.getElementById('osmFile');
const diffInput = document.getElementById('diffFile');
const countyInput = document.getElementById('countyFile');
// Load files
osmData = await loadFile(osmInput);
diffData = await loadFile(diffInput);
countyData = await loadFile(countyInput);
if (!osmData && !diffData && !countyData) {
showStatus('Please select at least one file', 'error');
return;
}
// Create layers
createOsmLayer();
createDiffLayer();
createCountyLayer();
// Fit bounds to smallest layer
calculateBounds();
showStatus('Files loaded successfully!', 'success');
// Enable save button if we have diff data
document.getElementById('saveButton').disabled = !diffData;
} catch (error) {
showStatus(error.message, 'error');
console.error(error);
}
}
// Save accepted and rejected items to original diff file
async function saveAcceptedItems() {
if (!diffData || (acceptedFeatures.size === 0 && rejectedFeatures.size === 0)) {
showStatus('No features to save', 'error');
return;
}
try {
// Add accepted=true or accepted=false property to features
diffData.features.forEach(feature => {
if (acceptedFeatures.has(feature)) {
feature.properties.accepted = true;
} else if (rejectedFeatures.has(feature)) {
feature.properties.accepted = false;
}
});
// Create download
const dataStr = JSON.stringify(diffData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'diff-updated.geojson';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus(`Saved ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
} catch (error) {
showStatus(`Save failed: ${error.message}`, 'error');
console.error(error);
}
}
// Show status message
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = `status ${type}`;
setTimeout(() => {
status.classList.add('hidden');
}, 3000);
}
// Update pane z-index based on order
function updateLayerZIndex() {
const panes = {
'osm': 'osmPane',
'diff': 'diffPane',
'county': 'countyPane'
};
// Reverse index so first item in list is on top
layerOrder.forEach((layerName, index) => {
const paneName = panes[layerName];
const pane = map.getPane(paneName);
if (pane) {
pane.style.zIndex = 400 + (layerOrder.length - 1 - index);
}
});
}
// Toggle layer visibility
function toggleLayer(layerId, layer) {
const checkbox = document.getElementById(layerId);
if (checkbox.checked && layer) {
if (!map.hasLayer(layer)) {
map.addLayer(layer);
updateLayerZIndex();
}
} else if (layer) {
if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
}
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
initMap();
// Layer toggles
document.getElementById('osmToggle').addEventListener('change', function() {
toggleLayer('osmToggle', osmLayer);
});
document.getElementById('diffToggle').addEventListener('change', function() {
toggleLayer('diffToggle', diffLayer);
});
document.getElementById('countyToggle').addEventListener('change', function() {
toggleLayer('countyToggle', countyLayer);
});
// Diff filter toggles
document.getElementById('showAdded').addEventListener('change', function() {
createDiffLayer();
});
document.getElementById('showRemoved').addEventListener('change', function() {
createDiffLayer();
});
document.getElementById('hideService').addEventListener('change', function() {
createDiffLayer();
createOsmLayer();
createCountyLayer();
});
// Load button
document.getElementById('loadButton').addEventListener('click', loadFiles);
// Save button
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
// Drag and drop for layer reordering
const layerList = document.getElementById('layerList');
const layerItems = layerList.querySelectorAll('.layer-item');
let draggedElement = null;
layerItems.forEach(item => {
item.addEventListener('dragstart', function(e) {
draggedElement = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function(e) {
this.classList.remove('dragging');
draggedElement = null;
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this === draggedElement) return;
const afterElement = getDragAfterElement(layerList, e.clientY);
if (afterElement == null) {
layerList.appendChild(draggedElement);
} else {
layerList.insertBefore(draggedElement, afterElement);
}
});
item.addEventListener('drop', function(e) {
e.preventDefault();
// Update layer order based on new DOM order
layerOrder = Array.from(layerList.querySelectorAll('.layer-item'))
.map(item => item.dataset.layer);
updateLayerZIndex();
});
});
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.layer-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
});

207
web/index.html Normal file
View File

@@ -0,0 +1,207 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoJSON Map Viewer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
#map {
flex: 1;
width: 100%;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 200px;
}
.controls h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
}
.controls label {
display: flex;
align-items: center;
margin: 8px 0;
cursor: pointer;
font-size: 13px;
}
.layer-item {
display: flex;
align-items: center;
margin: 8px 0;
padding: 5px;
background: #f8f9fa;
border-radius: 4px;
cursor: move;
font-size: 13px;
}
.layer-item.dragging {
opacity: 0.5;
}
.layer-item input[type="checkbox"] {
margin-right: 8px;
}
.controls input[type="checkbox"] {
margin-right: 8px;
}
.controls button {
width: 100%;
padding: 10px;
margin-top: 10px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.controls button:hover {
background: #0056b3;
}
.controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
font-size: 12px;
text-align: center;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.status.hidden {
display: none;
}
.file-input-group {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.file-input-group:last-of-type {
border-bottom: none;
}
.file-input-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.file-input-group input[type="file"] {
width: 100%;
font-size: 11px;
}
.load-button {
background: #28a745 !important;
}
.load-button:hover {
background: #218838 !important;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="controls">
<h3>Layer Controls (top to bottom)</h3>
<div id="layerList">
<div class="layer-item" draggable="true" data-layer="diff">
<input type="checkbox" id="diffToggle" checked>
<span>Diff Layer</span>
</div>
<div class="layer-item" draggable="true" data-layer="osm">
<input type="checkbox" id="osmToggle" checked>
<span>OSM Roads (Gray)</span>
</div>
<div class="layer-item" draggable="true" data-layer="county">
<input type="checkbox" id="countyToggle">
<span>County Layer (Purple)</span>
</div>
</div>
<h3 style="margin-top: 15px;">Diff Filters</h3>
<label>
<input type="checkbox" id="showAdded" checked>
Show Added (Green)
</label>
<label>
<input type="checkbox" id="showRemoved" checked>
Show Removed (Red)
</label>
<label>
<input type="checkbox" id="hideService">
Hide highway=service
</label>
<h3 style="margin-top: 15px;">Load Files</h3>
<div class="file-input-group">
<label for="diffFile">Diff File:</label>
<input type="file" id="diffFile" accept=".geojson,.json">
</div>
<div class="file-input-group">
<label for="osmFile">OSM File:</label>
<input type="file" id="osmFile" accept=".geojson,.json">
</div>
<div class="file-input-group">
<label for="countyFile">County File:</label>
<input type="file" id="countyFile" accept=".geojson,.json">
</div>
<button id="loadButton" class="load-button">Load Files</button>
<button id="saveButton" disabled>Save Accepted Items</button>
<div id="status" class="status hidden"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="app.js"></script>
</body>
</html>

215
web/server.py Normal file
View File

@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
Flask web server for The Villages Import Tools
"""
from flask import Flask, render_template, jsonify, request, send_from_directory
import subprocess
import os
import threading
import json
from datetime import datetime
app = Flask(__name__, static_folder='static', template_folder='templates')
# Store running processes
running_processes = {}
process_logs = {}
@app.route('/')
def index():
"""Main index page with script execution buttons"""
# Get available scripts and organize them
script_map = get_script_map()
# Organize scripts by category
scripts_by_category = {
'Download County Data': ['download-county-addresses', 'download-county-roads', 'download-county-paths'],
'Download OSM Data': ['download-osm-roads', 'download-osm-paths'],
'Convert Data': ['convert-roads', 'convert-paths'],
'Diff Data': ['diff-roads', 'diff-paths', 'diff-addresses'],
'Utilities': ['ls', 'make-new-latest']
}
return render_template('index.html',
script_map=script_map,
scripts_by_category=scripts_by_category)
@app.route('/map')
def map_viewer():
"""Map viewer page"""
return render_template('map.html')
def get_script_map():
"""Get the map of available scripts and their commands"""
return {
'ls': 'ls -alr /data',
'make-new-latest': 'cd /data && NEWDIR=$(date +%y%m%d) && mkdir -p $NEWDIR/lake $NEWDIR/sumter && ln -sfn $NEWDIR latest',
# todo: make a clean-old-data script
'download-county-addresses': {
# deliver files with standardized names
'sumter': 'mkdir -p /data/latest/sumter && wget https://www.arcgis.com/sharing/rest/content/items/c75c5aac13a648968c5596b0665be28b/data -O /data/latest/sumter/addresses.shp.zip',
'lake': 'mkdir -p /data/latest/lake && wget [LAKE_URL_HERE] -O /data/latest/lake/addresses.shp.zip'
},
'download-county-roads': {
# deliver files with standardized names
'sumter': 'mkdir -p /data/latest/sumter && wget https://www.arcgis.com/sharing/rest/content/items/9177e17c72d3433aa79630c7eda84add/data -O /data/latest/sumter/roads.shp.zip',
'lake': 'mkdir -p /data/latest/lake && wget [LAKE_URL_HERE] -O /data/latest/lake/roads.shp.zip'
},
'download-county-paths': {
# deliver files with standardized names
#'sumter': ['/data/latest/sumter/paths.shp.zip'],
#'lake': ['/data/latest/lake/paths.shp.zip']
},
# todo: integrate osm downloading and shapefile converting into diff-roads like addresses
'download-osm-roads': {
'lake': ['python', 'download-overpass.py', '--type', 'highways', 'Lake County', 'Florida', '/data/latest/lake/osm-roads.geojson'],
'sumter': ['python', 'download-overpass.py', '--type', 'highways', 'Sumter County', 'Florida', '/data/latest/sumter/osm-roads.geojson']
},
'download-osm-paths': {
# todo: no lake county paths
#'lake': ['python', 'download-overpass.py', '--type', 'highways', 'Lake County', 'Florida', '/data/latest/lake/osm-roads.geojson'],
'sumter': ['python', 'download-overpass.py', '--type', 'paths', 'Sumter County', 'Florida', '/data/latest/sumter/osm-paths.geojson']
},
# todo
'convert-roads': {
'sumter': ['python', 'shp-to-geojson.py', '/data/latest/sumter/roads.shp.zip', '/data/latest/sumter/county-roads.geojson'],
'lake': ['python', 'shp-to-geojson.py', '/data/latest/lake/roads.shp.zip', '/data/latest/lake/county-roads.geojson']
},
'convert-paths': {
#todo: delete sumter-multi-modal-convert.py ?
'sumter': ['python', 'shp-to-geojson.py', '/data/latest/sumter/paths.shp.zip', '/data/latest/sumter/county-paths.geojson'],
},
'diff-roads': {
'lake': ['python', 'diff-highways.py', '/data/latest/lake/osm-roads.geojson', '/data/latest/lake/county-roads.geojson', '--output', '/data/latest/lake/diff-roads.geojson'],
'sumter': ['python', 'diff-highways.py', '/data/latest/sumter/osm-roads.geojson', '/data/latest/sumter/county-roads.geojson', '--output', '/data/latest/sumter/diff-roads.geojson']
},
'diff-paths': {
#todo: no lake county data for paths
#'lake': ['python', 'diff-highways.py', '/data/latest/lake/osm-paths.geojson', '/data/latest/lake/county-paths.geojson', '--output', '/data/latest/lake/diff-paths.geojson'],
'sumter': ['python', 'diff-highways.py', '/data/latest/sumter/osm-paths.geojson', '/data/latest/sumter/county-paths.geojson', '--output', '/data/latest/sumter/diff-paths.geojson'],
},
# addresses need no osm download or shapefile convert, just county download
'diff-addresses': {
#todo: delete sumter-address-convert.py ?
'lake': ['python', 'compare-addresses.py', 'Lake', 'Florida', '--local-zip', '/data/latest/lake/addresses.shp.zip', '--output-dir', '/data/latest/lake', '--cache-dir', '/data/osm_cache'],
'sumter': ['python', 'compare-addresses.py', 'Sumter', 'Florida', '--local-zip', '/data/latest/sumter/addresses.shp.zip', '--output-dir', '/data/latest/sumter', '--cache-dir', '/data/osm_cache']
},
}
@app.route('/api/run-script', methods=['POST'])
def run_script():
"""Execute a script in the background"""
data = request.json
script_name = data.get('script')
county = data.get('county', '')
if not script_name:
return jsonify({'error': 'No script specified'}), 400
script_map = get_script_map()
if script_name not in script_map:
return jsonify({'error': 'Unknown script'}), 400
script_config = script_map[script_name]
# Handle both string commands and dict of county-specific commands
if isinstance(script_config, str):
# Simple string command (like 'ls')
cmd = ['bash', '-c', script_config]
elif isinstance(script_config, dict):
# County-specific commands
if not county:
return jsonify({'error': 'County required for this script'}), 400
if county not in script_config:
return jsonify({'error': f'County {county} not supported for {script_name}'}), 400
cmd_config = script_config[county]
if isinstance(cmd_config, str):
cmd = ['bash', '-c', cmd_config]
else:
cmd = cmd_config
else:
return jsonify({'error': 'Invalid script configuration'}), 400
# Generate a unique job ID
job_id = f"{script_name}_{county}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# Start process in background
def run_command():
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=os.path.dirname(os.path.dirname(__file__))
)
running_processes[job_id] = process
process_logs[job_id] = []
# Stream output
for line in process.stdout:
process_logs[job_id].append(line)
process.wait()
process_logs[job_id].append(f"\n[Process completed with exit code {process.returncode}]")
except Exception as e:
process_logs[job_id].append(f"\n[ERROR: {str(e)}]")
finally:
if job_id in running_processes:
del running_processes[job_id]
thread = threading.Thread(target=run_command)
thread.daemon = True
thread.start()
return jsonify({'job_id': job_id, 'status': 'started'})
@app.route('/api/job-status/<job_id>')
def job_status(job_id):
"""Get status and logs for a job"""
is_running = job_id in running_processes
logs = process_logs.get(job_id, [])
return jsonify({
'job_id': job_id,
'running': is_running,
'logs': logs
})
@app.route('/api/list-files')
def list_files():
"""List available GeoJSON files"""
data_dir = '/data'
files = {
'diff': [],
'osm': [],
'county': []
}
# Scan directories for geojson files
if os.path.exists(data_dir):
for root, dirs, filenames in os.walk(data_dir):
for filename in filenames:
if filename.endswith('.geojson'):
rel_path = os.path.relpath(os.path.join(root, filename), data_dir)
if 'diff' in filename.lower():
files['diff'].append(rel_path)
elif 'osm' in filename.lower():
files['osm'].append(rel_path)
elif any(county in filename.lower() for county in ['lake', 'sumter']):
files['county'].append(rel_path)
return jsonify(files)
@app.route('/data/<path:filename>')
def serve_data(filename):
"""Serve GeoJSON files"""
return send_from_directory('/data', filename)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)

693
web/static/map.js Normal file
View File

@@ -0,0 +1,693 @@
// Global state
let map;
let osmLayer;
let diffLayer;
let countyLayer;
let osmData = null;
let diffData = null;
let countyData = null;
let selectedFeature = null;
let selectedLayer = null;
let acceptedFeatures = new Set();
let rejectedFeatures = new Set();
let featurePopup = null;
let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom)
// Initialize map
function initMap() {
map = L.map('map').setView([28.7, -81.7], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
// Create custom panes for layer ordering
map.createPane('osmPane');
map.createPane('diffPane');
map.createPane('countyPane');
// Set initial z-indices for panes
map.getPane('osmPane').style.zIndex = 400;
map.getPane('diffPane').style.zIndex = 401;
map.getPane('countyPane').style.zIndex = 402;
}
// Calculate bounds for all loaded layers
function calculateBounds() {
const bounds = L.latLngBounds([]);
let hasData = false;
if (osmData && osmData.features.length > 0) {
L.geoJSON(osmData).eachLayer(layer => {
if (layer.getBounds) {
bounds.extend(layer.getBounds());
} else if (layer.getLatLng) {
bounds.extend(layer.getLatLng());
}
});
hasData = true;
}
if (diffData && diffData.features.length > 0) {
L.geoJSON(diffData).eachLayer(layer => {
if (layer.getBounds) {
bounds.extend(layer.getBounds());
} else if (layer.getLatLng) {
bounds.extend(layer.getLatLng());
}
});
hasData = true;
}
if (countyData && countyData.features.length > 0) {
L.geoJSON(countyData).eachLayer(layer => {
if (layer.getBounds) {
bounds.extend(layer.getBounds());
} else if (layer.getLatLng) {
bounds.extend(layer.getLatLng());
}
});
hasData = true;
}
if (hasData && bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
}
}
// Style functions
function osmStyle(feature) {
return {
color: '#4a4a4a',
weight: 3,
opacity: 0.7
};
}
function diffStyle(feature) {
// Check if feature is accepted or rejected
if (acceptedFeatures.has(feature)) {
return {
color: '#007bff',
weight: 3,
opacity: 0.8
};
}
if (rejectedFeatures.has(feature)) {
return {
color: '#ff8c00',
weight: 3,
opacity: 0.8
};
}
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
return {
color: isRemoved ? '#ff0000' : '#00ff00',
weight: 3,
opacity: 0.8
};
}
function countyStyle(feature) {
return {
color: '#ff00ff',
weight: 3,
opacity: 0.8
};
}
// Filter function for OSM features
function shouldShowOsmFeature(feature) {
const props = feature.properties || {};
const isService = props.highway === 'service';
const hideService = document.getElementById('hideService').checked;
if (isService && hideService) return false;
return true;
}
// Create layer for OSM data
function createOsmLayer() {
if (osmLayer) {
map.removeLayer(osmLayer);
}
if (!osmData) return;
osmLayer = L.geoJSON(osmData, {
style: osmStyle,
filter: shouldShowOsmFeature,
pane: 'osmPane',
onEachFeature: function(feature, layer) {
layer.on('click', function(e) {
L.DomEvent.stopPropagation(e);
selectFeature(feature, layer, e, 'osm');
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
layer.setStyle({
weight: 5,
opacity: 1
});
}
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
layer.setStyle(osmStyle(feature));
}
});
}
}).addTo(map);
updateLayerZIndex();
}
// Filter function for diff features
function shouldShowFeature(feature) {
const props = feature.properties || {};
const isRemoved = props.removed === true || props.removed === 'True';
const isService = props.highway === 'service';
const showAdded = document.getElementById('showAdded').checked;
const showRemoved = document.getElementById('showRemoved').checked;
const hideService = document.getElementById('hideService').checked;
// Check removed/added filter
if (isRemoved && !showRemoved) return false;
if (!isRemoved && !showAdded) return false;
// Check service filter
if (isService && hideService) return false;
return true;
}
// Create layer for diff data with click handlers
function createDiffLayer() {
if (diffLayer) {
map.removeLayer(diffLayer);
}
if (!diffData) return;
diffLayer = L.geoJSON(diffData, {
style: diffStyle,
filter: shouldShowFeature,
pane: 'diffPane',
onEachFeature: function(feature, layer) {
layer.on('click', function(e) {
L.DomEvent.stopPropagation(e);
selectFeature(feature, layer, e, 'diff');
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
layer.setStyle({
weight: 5,
opacity: 1
});
}
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
layer.setStyle(diffStyle(feature));
}
});
}
}).addTo(map);
updateLayerZIndex();
}
// Filter function for county features
function shouldShowCountyFeature(feature) {
const props = feature.properties || {};
const isService = props.highway === 'service';
const hideService = document.getElementById('hideService').checked;
if (isService && hideService) return false;
return true;
}
// Create layer for county data
function createCountyLayer() {
if (countyLayer) {
map.removeLayer(countyLayer);
}
if (!countyData) return;
countyLayer = L.geoJSON(countyData, {
style: countyStyle,
filter: shouldShowCountyFeature,
pane: 'countyPane',
onEachFeature: function(feature, layer) {
layer.on('click', function(e) {
L.DomEvent.stopPropagation(e);
selectFeature(feature, layer, e, 'county');
});
layer.on('mouseover', function(e) {
if (selectedLayer !== layer) {
layer.setStyle({
weight: 5,
opacity: 1
});
}
});
layer.on('mouseout', function(e) {
if (selectedLayer !== layer) {
layer.setStyle(countyStyle(feature));
}
});
}
});
// County layer is hidden by default
if (document.getElementById('countyToggle').checked) {
countyLayer.addTo(map);
}
updateLayerZIndex();
}
// Select a feature from any layer
function selectFeature(feature, layer, e, layerType = 'diff') {
// Deselect previous feature
if (selectedLayer) {
// Get the appropriate style function based on previous layer type
const styleFunc = selectedLayer._layerType === 'diff' ? diffStyle :
selectedLayer._layerType === 'osm' ? osmStyle : countyStyle;
selectedLayer.setStyle(styleFunc(selectedLayer.feature));
}
selectedFeature = feature;
selectedLayer = layer;
selectedLayer._layerType = layerType; // Store layer type for later
layer.setStyle({
weight: 6,
opacity: 1,
color: '#ffc107'
});
// Create popup near the clicked location
const props = feature.properties || {};
const isRemoved = props.removed === true || props.removed === 'True';
const isAccepted = acceptedFeatures.has(feature);
const isRejected = rejectedFeatures.has(feature);
let html = '<div style="font-size: 12px; max-height: 400px; overflow-y: auto;">';
// Show layer type
html += `<div style="margin-bottom: 8px;"><strong>Layer:</strong> ${layerType.toUpperCase()}</div>`;
// Only show status for diff layer
if (layerType === 'diff') {
html += `<div style="margin-bottom: 8px;"><strong>Status:</strong> ${isRemoved ? 'Removed' : 'Added/Modified'}</div>`;
}
// Display all non-null properties with custom ordering
const displayProps = Object.entries(props)
.filter(([key, value]) => value !== null && value !== undefined && key !== 'removed')
.sort(([a], [b]) => {
// Priority order: name, highway, then alphabetical
const priorityOrder = { 'name': 0, 'highway': 1 };
const aPriority = priorityOrder[a] ?? 999;
const bPriority = priorityOrder[b] ?? 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
return a.localeCompare(b);
});
if (displayProps.length > 0) {
html += '<div style="font-size: 11px;">';
for (const [key, value] of displayProps) {
html += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${value}</div>`;
}
html += '</div>';
}
// Only show accept/reject for diff layer
if (layerType === 'diff') {
if (isAccepted) {
html += '<div style="margin-top: 8px; color: #007bff; font-weight: bold;">✓ Accepted</div>';
} else if (isRejected) {
html += '<div style="margin-top: 8px; color: #4a4a4a; font-weight: bold;">✗ Rejected</div>';
}
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
html += '<button onclick="acceptFeature()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept</button>';
html += '<button onclick="rejectFeature()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject</button>';
html += '</div>';
}
html += '</div>';
// Remove old popup if exists
if (featurePopup) {
map.closePopup(featurePopup);
}
// Create popup at click location
featurePopup = L.popup({
maxWidth: 300,
closeButton: true,
autoClose: false,
closeOnClick: false
})
.setLatLng(e.latlng)
.setContent(html)
.openOn(map);
// Handle popup close
featurePopup.on('remove', function() {
if (selectedLayer) {
selectedLayer.setStyle(diffStyle(selectedLayer.feature));
selectedLayer = null;
selectedFeature = null;
}
});
}
// Accept a feature
function acceptFeature() {
if (!selectedFeature) return;
// Remove from rejected if present
rejectedFeatures.delete(selectedFeature);
// Add to accepted
acceptedFeatures.add(selectedFeature);
// Update layer style
if (selectedLayer) {
selectedLayer.setStyle(diffStyle(selectedFeature));
}
// Close popup
if (featurePopup) {
map.closePopup(featurePopup);
}
// Enable save button
updateSaveButton();
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
}
// Reject a feature
function rejectFeature() {
if (!selectedFeature) return;
// Remove from accepted if present
acceptedFeatures.delete(selectedFeature);
// Add to rejected
rejectedFeatures.add(selectedFeature);
// Update layer style
if (selectedLayer) {
selectedLayer.setStyle(diffStyle(selectedFeature));
}
// Close popup
if (featurePopup) {
map.closePopup(featurePopup);
}
// Enable save button
updateSaveButton();
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
}
// Expose functions globally for onclick handlers
window.acceptFeature = acceptFeature;
window.rejectFeature = rejectFeature;
// Update save button state
function updateSaveButton() {
document.getElementById('saveButton').disabled =
acceptedFeatures.size === 0 && rejectedFeatures.size === 0;
}
// Load GeoJSON from server
async function loadFromServer(url) {
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return null; // File doesn't exist
}
throw new Error(`Failed to load ${url}: ${response.statusText}`);
}
return await response.json();
}
// Load all files from server
async function loadFiles() {
try {
showStatus('Loading data from server...', 'success');
const county = document.getElementById('countySelect').value;
const dataType = document.getElementById('dataTypeSelect').value;
// Build file paths based on county and data type
let osmFile, diffFile, countyFile;
if (dataType === 'roads') {
osmFile = `latest/${county}/osm-roads.geojson`;
diffFile = `latest/${county}/diff-roads.geojson`;
countyFile = `latest/${county}/county-roads.geojson`;
} else if (dataType === 'paths') {
osmFile = `latest/${county}/osm-paths.geojson`;
diffFile = `latest/${county}/diff-paths.geojson`;
countyFile = `latest/${county}/county-paths.geojson`;
} else if (dataType === 'addresses') {
osmFile = null; // No OSM addresses file
diffFile = `latest/${county}/addresses-to-add.geojson`;
countyFile = `latest/${county}/addresses-existing.geojson`;
}
// Load files from server
osmData = osmFile ? await loadFromServer(`/data/${osmFile}`) : null;
diffData = diffFile ? await loadFromServer(`/data/${diffFile}`) : null;
countyData = countyFile ? await loadFromServer(`/data/${countyFile}`) : null;
if (!osmData && !diffData && !countyData) {
showStatus(`No data files found for ${county} ${dataType}. Run the processing scripts first.`, 'error');
return;
}
// Create layers
createOsmLayer();
createDiffLayer();
createCountyLayer();
// Fit bounds to smallest layer
calculateBounds();
let loadedFiles = [];
if (osmData) loadedFiles.push('OSM');
if (diffData) loadedFiles.push('Diff');
if (countyData) loadedFiles.push('County');
showStatus(`Loaded ${loadedFiles.join(', ')} data for ${county} ${dataType}`, 'success');
// Enable save button if we have diff data
document.getElementById('saveButton').disabled = !diffData;
} catch (error) {
showStatus(error.message, 'error');
console.error(error);
}
}
// Save accepted and rejected items to original diff file
async function saveAcceptedItems() {
if (!diffData || (acceptedFeatures.size === 0 && rejectedFeatures.size === 0)) {
showStatus('No features to save', 'error');
return;
}
try {
// Add accepted=true or accepted=false property to features
diffData.features.forEach(feature => {
if (acceptedFeatures.has(feature)) {
feature.properties.accepted = true;
} else if (rejectedFeatures.has(feature)) {
feature.properties.accepted = false;
}
});
// Create download
const dataStr = JSON.stringify(diffData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'diff-updated.geojson';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
showStatus(`Saved ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
} catch (error) {
showStatus(`Save failed: ${error.message}`, 'error');
console.error(error);
}
}
// Show status message
function showStatus(message, type) {
const status = document.getElementById('status');
status.textContent = message;
status.className = `status ${type}`;
setTimeout(() => {
status.classList.add('hidden');
}, 3000);
}
// Update pane z-index based on order
function updateLayerZIndex() {
const panes = {
'osm': 'osmPane',
'diff': 'diffPane',
'county': 'countyPane'
};
// Reverse index so first item in list is on top
layerOrder.forEach((layerName, index) => {
const paneName = panes[layerName];
const pane = map.getPane(paneName);
if (pane) {
pane.style.zIndex = 400 + (layerOrder.length - 1 - index);
}
});
}
// Toggle layer visibility
function toggleLayer(layerId, layer) {
const checkbox = document.getElementById(layerId);
if (checkbox.checked && layer) {
if (!map.hasLayer(layer)) {
map.addLayer(layer);
updateLayerZIndex();
}
} else if (layer) {
if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
}
}
// Event listeners
document.addEventListener('DOMContentLoaded', function() {
initMap();
// Layer toggles
document.getElementById('osmToggle').addEventListener('change', function() {
toggleLayer('osmToggle', osmLayer);
});
document.getElementById('diffToggle').addEventListener('change', function() {
toggleLayer('diffToggle', diffLayer);
});
document.getElementById('countyToggle').addEventListener('change', function() {
toggleLayer('countyToggle', countyLayer);
});
// Diff filter toggles
document.getElementById('showAdded').addEventListener('change', function() {
createDiffLayer();
});
document.getElementById('showRemoved').addEventListener('change', function() {
createDiffLayer();
});
document.getElementById('hideService').addEventListener('change', function() {
createDiffLayer();
createOsmLayer();
createCountyLayer();
});
// Load button
document.getElementById('loadButton').addEventListener('click', loadFiles);
// Save button
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
// Drag and drop for layer reordering
const layerList = document.getElementById('layerList');
const layerItems = layerList.querySelectorAll('.layer-item');
let draggedElement = null;
layerItems.forEach(item => {
item.addEventListener('dragstart', function(e) {
draggedElement = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
item.addEventListener('dragend', function(e) {
this.classList.remove('dragging');
draggedElement = null;
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this === draggedElement) return;
const afterElement = getDragAfterElement(layerList, e.clientY);
if (afterElement == null) {
layerList.appendChild(draggedElement);
} else {
layerList.insertBefore(draggedElement, afterElement);
}
});
item.addEventListener('drop', function(e) {
e.preventDefault();
// Update layer order based on new DOM order
layerOrder = Array.from(layerList.querySelectorAll('.layer-item'))
.map(item => item.dataset.layer);
updateLayerZIndex();
});
});
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.layer-item:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
});

311
web/templates/index.html Normal file
View File

@@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Villages Import Tools</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 10px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
}
.section {
margin-bottom: 30px;
}
.section h2 {
color: #444;
margin-bottom: 15px;
font-size: 20px;
}
.button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.script-button {
padding: 15px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.script-button:hover {
background: #0056b3;
}
.script-button:disabled {
background: #ccc;
cursor: not-allowed;
}
.script-button.lake {
background: #28a745;
}
.script-button.lake:hover {
background: #218838;
}
.script-button.sumter {
background: #17a2b8;
}
.script-button.sumter:hover {
background: #138496;
}
.map-link {
display: inline-block;
padding: 15px 30px;
background: #6f42c1;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background 0.2s;
}
.map-link:hover {
background: #5a32a3;
}
.log-viewer {
margin-top: 30px;
border-top: 2px solid #eee;
padding-top: 20px;
}
.log-box {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
display: none;
}
.log-box.active {
display: block;
}
.status-message {
padding: 10px 15px;
border-radius: 6px;
margin-bottom: 15px;
display: none;
}
.status-message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status-message.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.county-group {
margin-bottom: 25px;
}
.county-group h3 {
color: #555;
margin-bottom: 10px;
font-size: 16px;
}
</style>
</head>
<body>
<div class="container">
<h1>The Villages Import Tools</h1>
<p class="subtitle">Run data processing scripts and view results</p>
<div class="section">
<h2>Map Viewer</h2>
<a href="/map" class="map-link">Open GeoJSON Map Viewer</a>
</div>
<div class="section">
<h2>Data Processing Scripts</h2>
{% for category, script_names in scripts_by_category.items() %}
<div class="county-group">
<h3>{{ category }}</h3>
{% for script_name in script_names %}
{% if script_name in script_map %}
{% set script_config = script_map[script_name] %}
{% if script_config is string %}
{# Simple command with no county selection #}
<div class="button-grid">
<button class="script-button" onclick="runScript('{{ script_name }}', '')">
{{ script_name|replace('-', ' ')|title }}
</button>
</div>
{% elif script_config is mapping %}
{# County-specific commands #}
<div class="button-grid">
{% if 'lake' in script_config %}
<button class="script-button lake" onclick="runScript('{{ script_name }}', 'lake')">
{{ script_name|replace('-', ' ')|title }} (Lake)
</button>
{% endif %}
{% if 'sumter' in script_config %}
<button class="script-button sumter" onclick="runScript('{{ script_name }}', 'sumter')">
{{ script_name|replace('-', ' ')|title }} (Sumter)
</button>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
<div class="log-viewer">
<h2>Script Output</h2>
<div id="status" class="status-message"></div>
<div id="logs" class="log-box"></div>
</div>
</div>
<script>
let currentJobId = null;
let logCheckInterval = null;
function showStatus(message, type) {
const statusEl = document.getElementById('status');
statusEl.textContent = message;
statusEl.className = `status-message ${type}`;
statusEl.style.display = 'block';
}
function runScript(scriptName, county) {
const logsEl = document.getElementById('logs');
logsEl.textContent = 'Starting script...\n';
logsEl.classList.add('active');
showStatus(`Running ${scriptName} for ${county}...`, 'info');
// Disable all buttons
document.querySelectorAll('.script-button').forEach(btn => {
btn.disabled = true;
});
fetch('/api/run-script', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
script: scriptName,
county: county
})
})
.then(response => response.json())
.then(data => {
if (data.error) {
showStatus(`Error: ${data.error}`, 'error');
enableButtons();
return;
}
currentJobId = data.job_id;
showStatus(`Script started (Job ID: ${data.job_id})`, 'success');
// Start polling for logs
if (logCheckInterval) {
clearInterval(logCheckInterval);
}
logCheckInterval = setInterval(checkJobStatus, 1000);
})
.catch(error => {
showStatus(`Error: ${error.message}`, 'error');
enableButtons();
});
}
function checkJobStatus() {
if (!currentJobId) return;
fetch(`/api/job-status/${currentJobId}`)
.then(response => response.json())
.then(data => {
const logsEl = document.getElementById('logs');
logsEl.textContent = data.logs.join('');
// Auto-scroll to bottom
logsEl.scrollTop = logsEl.scrollHeight;
if (!data.running) {
clearInterval(logCheckInterval);
showStatus('Script completed', 'success');
enableButtons();
currentJobId = null;
}
})
.catch(error => {
console.error('Error checking job status:', error);
});
}
function enableButtons() {
document.querySelectorAll('.script-button').forEach(btn => {
btn.disabled = false;
});
}
</script>
</body>
</html>

214
web/templates/map.html Normal file
View File

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GeoJSON Map Viewer</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
#map {
flex: 1;
width: 100%;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 200px;
}
.controls h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #333;
}
.controls label {
display: flex;
align-items: center;
margin: 8px 0;
cursor: pointer;
font-size: 13px;
}
.layer-item {
display: flex;
align-items: center;
margin: 8px 0;
padding: 5px;
background: #f8f9fa;
border-radius: 4px;
cursor: move;
font-size: 13px;
}
.layer-item.dragging {
opacity: 0.5;
}
.layer-item input[type="checkbox"] {
margin-right: 8px;
}
.controls input[type="checkbox"] {
margin-right: 8px;
}
.controls button {
width: 100%;
padding: 10px;
margin-top: 10px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}
.controls button:hover {
background: #0056b3;
}
.controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
font-size: 12px;
text-align: center;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.status.hidden {
display: none;
}
.file-input-group {
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.file-input-group:last-of-type {
border-bottom: none;
}
.file-input-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.file-input-group input[type="file"],
.file-input-group select {
width: 100%;
font-size: 11px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 4px;
}
.load-button {
background: #28a745 !important;
}
.load-button:hover {
background: #218838 !important;
}
</style>
</head>
<body>
<div id="map"></div>
<div class="controls">
<h3>Layer Controls (top to bottom)</h3>
<div id="layerList">
<div class="layer-item" draggable="true" data-layer="diff">
<input type="checkbox" id="diffToggle" checked>
<span>Diff Layer</span>
</div>
<div class="layer-item" draggable="true" data-layer="osm">
<input type="checkbox" id="osmToggle" checked>
<span>OSM Roads (Gray)</span>
</div>
<div class="layer-item" draggable="true" data-layer="county">
<input type="checkbox" id="countyToggle">
<span>County Layer (Purple)</span>
</div>
</div>
<h3 style="margin-top: 15px;">Diff Filters</h3>
<label>
<input type="checkbox" id="showAdded" checked>
Show Added (Green)
</label>
<label>
<input type="checkbox" id="showRemoved" checked>
Show Removed (Red)
</label>
<label>
<input type="checkbox" id="hideService">
Hide highway=service
</label>
<h3 style="margin-top: 15px;">Load Data</h3>
<div class="file-input-group">
<label for="countySelect">County:</label>
<select id="countySelect">
<option value="lake">Lake</option>
<option value="sumter" selected>Sumter</option>
</select>
</div>
<div class="file-input-group">
<label for="dataTypeSelect">Data Type:</label>
<select id="dataTypeSelect">
<option value="roads" selected>Roads</option>
<option value="paths">Multi-Use Paths</option>
<option value="addresses">Addresses</option>
</select>
</div>
<button id="loadButton" class="load-button">Load from Server</button>
<button id="saveButton" disabled>Save Accepted Items</button>
<div id="status" class="status hidden"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="{{ url_for('static', filename='map.js') }}"></script>
</body>
</html>