improved josm; snapping
This commit is contained in:
@@ -27,6 +27,17 @@ let mobileControlsOpen = false;
|
||||
let loadedCounty = '';
|
||||
let loadedDataType = '';
|
||||
|
||||
// OSM node index for JOSM export snapping
|
||||
let osmVertices = [];
|
||||
let osmNodeById = new Map();
|
||||
let osmCoordToNodeId = new Map();
|
||||
let osmNodeVersionById = new Map();
|
||||
const SNAP_TOL = 0.000135; // ~15m in degrees
|
||||
|
||||
// Snap preview layer and popup state
|
||||
let snapPreviewLayer = null;
|
||||
let popupLatlng = null;
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
map = L.map('map').setView([28.7, -81.7], 12);
|
||||
@@ -37,6 +48,8 @@ function initMap() {
|
||||
maxZoom: 20
|
||||
}).addTo(map);
|
||||
|
||||
snapPreviewLayer = L.layerGroup().addTo(map);
|
||||
|
||||
// Create custom panes for layer ordering
|
||||
map.createPane('osmPane');
|
||||
map.createPane('diffPane');
|
||||
@@ -203,6 +216,7 @@ function clearSelection() {
|
||||
|
||||
selectedFeatures = [];
|
||||
selectedLayers = [];
|
||||
if (snapPreviewLayer) snapPreviewLayer.clearLayers();
|
||||
|
||||
if (featurePopup) {
|
||||
map.closePopup(featurePopup);
|
||||
@@ -685,12 +699,18 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
|
||||
|
||||
// Show popup for single or multiple features
|
||||
showMultiFeaturePopup(e.latlng);
|
||||
|
||||
if (layerType === 'diff' && feature.geometry.type === 'LineString') {
|
||||
const isAdded = !feature.properties || (feature.properties.removed !== true && feature.properties.removed !== 'True');
|
||||
if (isAdded && osmVertices.length > 0) updateSnapPreview(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show popup for multiple features (JOSM-style aggregation)
|
||||
function showMultiFeaturePopup(latlng) {
|
||||
if (selectedFeatures.length === 0) return;
|
||||
popupLatlng = latlng;
|
||||
|
||||
// Remove old popup if exists
|
||||
if (featurePopup) {
|
||||
@@ -787,6 +807,9 @@ function showMultiFeaturePopup(latlng) {
|
||||
// Single selection - show specific status
|
||||
const isRemoved = selectedFeatures[0].properties && (selectedFeatures[0].properties.removed === true || selectedFeatures[0].properties.removed === 'True');
|
||||
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${isRemoved ? 'Removed (Red)' : 'Added (Green)'}</div>`;
|
||||
if (!isRemoved && osmVertices.length > 0) {
|
||||
html += connectivitySection(selectedFeatures[0]);
|
||||
}
|
||||
} else if (addedCount > 0 && removedCount > 0) {
|
||||
// Mixed selection
|
||||
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${addedCount} added, ${removedCount} removed</div>`;
|
||||
@@ -823,6 +846,26 @@ function showMultiFeaturePopup(latlng) {
|
||||
}
|
||||
}
|
||||
|
||||
// County road import section
|
||||
const isCountySingle = selectedFeatures.length === 1
|
||||
&& selectedLayers[0].type === 'county'
|
||||
&& selectedFeatures[0].geometry.type === 'LineString';
|
||||
if (isCountySingle) {
|
||||
const osmTags = countyPropsToOsmTags(selectedFeatures[0].properties);
|
||||
const alreadyImported = [...acceptedFeatures].some(f => f._countySource === selectedFeatures[0]);
|
||||
html += '<div style="margin-top:10px;border-top:1px solid #ddd;padding-top:8px;font-size:11px;">';
|
||||
html += '<strong>Import as:</strong>';
|
||||
Object.entries(osmTags).forEach(([k, v]) => {
|
||||
html += `<div style="margin:2px 0;"><strong>${k}:</strong> ${v}</div>`;
|
||||
});
|
||||
if (alreadyImported) {
|
||||
html += '<div style="margin-top:6px;color:#28a745;">✓ Already imported</div>';
|
||||
} else {
|
||||
html += `<button onclick="importCountyFeature()" style="margin-top:8px;width:100%;padding:5px;background:#28a745;color:white;border:none;border-radius:3px;cursor:pointer;">Import as new road</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Create popup at click location
|
||||
@@ -853,6 +896,10 @@ function acceptAllFeatures() {
|
||||
// Add to accepted
|
||||
acceptedFeatures.add(feature);
|
||||
|
||||
// Cache snap endpoints for JOSM export
|
||||
const isAdded = !feature.properties || (feature.properties.removed !== true && feature.properties.removed !== 'True');
|
||||
if (isAdded && osmVertices.length > 0) cacheSnapInfo(feature);
|
||||
|
||||
// Update layer style
|
||||
const layerInfo = selectedLayers[index];
|
||||
if (layerInfo && layerInfo.layer) {
|
||||
@@ -989,10 +1036,705 @@ window.acceptAllFeatures = acceptAllFeatures;
|
||||
window.rejectAllFeatures = rejectAllFeatures;
|
||||
window.excludeSelectedFeatures = excludeSelectedFeatures;
|
||||
|
||||
// ---- JOSM Export: OSM node index ----
|
||||
|
||||
function buildOsmNodeIndex() {
|
||||
osmVertices = [];
|
||||
osmNodeById.clear();
|
||||
osmCoordToNodeId.clear();
|
||||
osmNodeVersionById.clear();
|
||||
if (!osmData) return;
|
||||
osmData.features.forEach((feat, wi) => {
|
||||
const nodeIds = feat.properties.osm_nodes;
|
||||
const nodeVersions = feat.properties.osm_node_versions;
|
||||
const name = feat.properties.name || feat.properties.highway || '';
|
||||
const coords = feat.geometry && feat.geometry.type === 'LineString' && feat.geometry.coordinates;
|
||||
if (!coords) return;
|
||||
coords.forEach((coord, ci) => {
|
||||
const nid = nodeIds ? nodeIds[ci] : null;
|
||||
const key = coord[0].toFixed(7) + ',' + coord[1].toFixed(7);
|
||||
if (nid) {
|
||||
osmNodeById.set(nid, coord);
|
||||
osmCoordToNodeId.set(key, nid);
|
||||
if (nodeVersions && nodeVersions[ci]) osmNodeVersionById.set(nid, nodeVersions[ci]);
|
||||
}
|
||||
osmVertices.push({ nodeId: nid, coord, name, wayIdx: wi, posInWay: ci, wayLen: coords.length });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findNearestOsmVertex(coord, tol) {
|
||||
tol = tol !== undefined ? tol : SNAP_TOL;
|
||||
const [lon, lat] = coord;
|
||||
let best = null, bestDist = tol;
|
||||
for (const v of osmVertices) {
|
||||
const d = Math.hypot(v.coord[0] - lon, v.coord[1] - lat);
|
||||
if (d < bestDist) { bestDist = d; best = { ...v, dist: d }; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function projectOnSeg(pt, a, b) {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 === 0) return { t: 0, coord: a };
|
||||
const t = Math.max(0, Math.min(1, ((pt[0] - a[0]) * dx + (pt[1] - a[1]) * dy) / len2));
|
||||
return { t, coord: [a[0] + t * dx, a[1] + t * dy] };
|
||||
}
|
||||
|
||||
function closestPointOnWay(point, coords, tol) {
|
||||
let best = null, bestDist = tol !== undefined ? tol : Infinity;
|
||||
for (let i = 0; i < coords.length - 1; i++) {
|
||||
const { t, coord } = projectOnSeg(point, coords[i], coords[i + 1]);
|
||||
const d = Math.hypot(coord[0] - point[0], coord[1] - point[1]);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = { segIdx: i, t, coord, dist: d };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Find closest point on any OSM way segment (not just vertices).
|
||||
// Returns { feat, segIdx, t, coord, dist } or null.
|
||||
function findNearestOsmWayPoint(coord, tol) {
|
||||
if (!osmData) return null;
|
||||
let best = null, bestDist = tol !== undefined ? tol : SNAP_TOL;
|
||||
osmData.features.forEach(feat => {
|
||||
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
|
||||
const hit = closestPointOnWay(coord, feat.geometry.coordinates, bestDist);
|
||||
if (hit && hit.dist < bestDist) {
|
||||
bestDist = hit.dist;
|
||||
best = { feat, ...hit };
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function segIntersect(p1, p2, p3, p4) {
|
||||
const d1x = p2[0] - p1[0], d1y = p2[1] - p1[1];
|
||||
const d2x = p4[0] - p3[0], d2y = p4[1] - p3[1];
|
||||
const cross = d1x * d2y - d1y * d2x;
|
||||
if (Math.abs(cross) < 1e-12) return null;
|
||||
const dx = p3[0] - p1[0], dy = p3[1] - p1[1];
|
||||
const t = (dx * d2y - dy * d2x) / cross;
|
||||
const u = (dx * d1y - dy * d1x) / cross;
|
||||
if (t < 0 || t > 1 || u < 0 || u > 1) return null;
|
||||
return [p1[0] + t * d1x, p1[1] + t * d1y];
|
||||
}
|
||||
|
||||
function findOsmCrossings(newCoords) {
|
||||
if (!osmData) return [];
|
||||
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
||||
newCoords.forEach(([lon, lat]) => {
|
||||
if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon;
|
||||
if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat;
|
||||
});
|
||||
const pad = SNAP_TOL;
|
||||
const results = [];
|
||||
osmData.features.forEach(feat => {
|
||||
const coords = feat.geometry && feat.geometry.type === 'LineString' && feat.geometry.coordinates;
|
||||
if (!coords || coords.length < 2) return;
|
||||
let fMinLon = Infinity, fMaxLon = -Infinity, fMinLat = Infinity, fMaxLat = -Infinity;
|
||||
coords.forEach(([lon, lat]) => {
|
||||
if (lon < fMinLon) fMinLon = lon; if (lon > fMaxLon) fMaxLon = lon;
|
||||
if (lat < fMinLat) fMinLat = lat; if (lat > fMaxLat) fMaxLat = lat;
|
||||
});
|
||||
if (fMaxLon < minLon - pad || fMinLon > maxLon + pad ||
|
||||
fMaxLat < minLat - pad || fMinLat > maxLat + pad) return;
|
||||
for (let i = 0; i < newCoords.length - 1; i++) {
|
||||
for (let j = 0; j < coords.length - 1; j++) {
|
||||
const pt = segIntersect(newCoords[i], newCoords[i + 1], coords[j], coords[j + 1]);
|
||||
if (pt) {
|
||||
results.push({ feature: feat, crossPoint: pt, name: feat.properties.name || feat.properties.highway || '' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
function cacheSnapInfo(feature) {
|
||||
if (!feature.geometry || feature.geometry.type !== 'LineString') return;
|
||||
const coords = feature.geometry.coordinates;
|
||||
feature._snapA = findNearestOsmVertex(coords[0]);
|
||||
feature._snapB = findNearestOsmVertex(coords[coords.length - 1]);
|
||||
feature._snappedCoords = [
|
||||
feature._snapA ? feature._snapA.coord : coords[0],
|
||||
...coords.slice(1, -1),
|
||||
feature._snapB ? feature._snapB.coord : coords[coords.length - 1]
|
||||
];
|
||||
}
|
||||
|
||||
// ---- County road tag conversion (mirrors diff-highways.py logic) ----
|
||||
|
||||
function formatStreetName(s) {
|
||||
if (!s) return s;
|
||||
const abbrevs = {
|
||||
'N':'North','NE':'Northeast','E':'East','SE':'Southeast','S':'South',
|
||||
'SW':'Southwest','W':'West','NW':'Northwest',
|
||||
'ST':'Street','AVE':'Avenue','AV':'Avenue','BLVD':'Boulevard','BV':'Boulevard',
|
||||
'CIR':'Circle','CT':'Court','DR':'Drive','HWY':'Highway','HW':'Highway',
|
||||
'LN':'Lane','LOOP':'Loop','LP':'Loop','PKWY':'Parkway','PL':'Place',
|
||||
'PT':'Point','RD':'Road','TRL':'Trail','TR':'Trail','WAY':'Way','WY':'Way',
|
||||
'XING':'Crossing','CRK':'Creek','RDG':'Ridge','RUN':'Run','GLN':'Glenn',
|
||||
'GRV':'Grove','HL':'Hill','HTS':'Heights','MNR':'Manor','MT':'Mount',
|
||||
'BND':'Bend','CRST':'Crest','CV':'Curve','CURV':'Curve','FLDS':'Fields',
|
||||
'HOLW':'Hollow','LNDG':'Landing','MTN':'Mountain','PARK':'Park','PASS':'Pass',
|
||||
'PATH':'Path','PLZ':'Plaza','SQ':'Square','TER':'Terrace','TRCE':'Terrace',
|
||||
'TPKE':'Turnpike','VW':'View','WALK':'Walk','ALY':'Alley','BLF':'Bluff',
|
||||
'COR':'Corner','SHRS':'Shores','SR':'SR','US':'US',
|
||||
};
|
||||
const parts = s.trim().split(/\s+/);
|
||||
if (parts[0] && parts[0].toUpperCase() === 'ST' && parts.length > 1) parts[0] = 'Saint';
|
||||
if (parts[0] && parts[0].toUpperCase() === 'CR') parts[0] = 'County Road';
|
||||
return parts.map(p => {
|
||||
const up = p.toUpperCase();
|
||||
if (abbrevs[up]) return abbrevs[up];
|
||||
if (/^[0-9]{2,4}[A-Za-z]$/.test(p)) return p;
|
||||
return p.charAt(0).toUpperCase() + p.slice(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function getLakeHighwayType(sc) {
|
||||
return { ALLEY:'alley', LOCAL:'residential', MAJOR:'trunk', 'MEDIAN CUT':'primary_link',
|
||||
OTHER:'unclassified', PRIMARY:'primary', PRIVATE:'service', RAMP:'trunk_link',
|
||||
SECONDARY:'secondary', 'TURN LANE':'primary_link', 'VEHICULAR TRAIL':'track' }[sc] || 'residential';
|
||||
}
|
||||
|
||||
function getSumterHighwayType(rc) {
|
||||
if (!rc) return 'residential';
|
||||
const v = String(rc);
|
||||
if (v.startsWith('PRIMARY')) return 'trunk';
|
||||
if (v.startsWith('MAJOR')) return 'primary';
|
||||
return 'residential';
|
||||
}
|
||||
|
||||
function countyPropsToOsmTags(props) {
|
||||
const tags = { surface: 'asphalt' };
|
||||
const isSumter = 'NAME' in props && 'RoadClass' in props;
|
||||
const isLake = 'FullStreet' in props;
|
||||
if (isSumter) {
|
||||
if (props.NAME) tags.name = formatStreetName(props.NAME);
|
||||
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
|
||||
tags.highway = getSumterHighwayType(props.RoadClass);
|
||||
if (props.OneWayCode === 'F') tags.oneway = 'yes';
|
||||
else if (props.OneWayCode === 'T') tags.oneway = '-1';
|
||||
const laneCount = parseInt(props.LANES);
|
||||
if (laneCount > 0) tags.lanes = String(laneCount);
|
||||
} else if (isLake) {
|
||||
if (props.FullStreet) tags.name = formatStreetName(props.FullStreet);
|
||||
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
|
||||
tags.highway = getLakeHighwayType(props.StreetClas);
|
||||
if (props.Oneway === 'FT') tags.oneway = 'yes';
|
||||
else if (props.Oneway === 'TF') tags.oneway = '-1';
|
||||
const laneCount = parseInt(props.NumberOfLa);
|
||||
if (laneCount > 0) tags.lanes = String(laneCount);
|
||||
} else {
|
||||
const name = props.NAME || props.FullStreet || props.name;
|
||||
if (name) tags.name = formatStreetName(name);
|
||||
if (props.SpeedLimit != null) tags.maxspeed = `${props.SpeedLimit} mph`;
|
||||
tags.highway = 'residential';
|
||||
}
|
||||
if (!tags.highway) tags.highway = 'residential';
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ---- Snap preview markers ----
|
||||
|
||||
function updateSnapPreview(feature) {
|
||||
if (!snapPreviewLayer) return;
|
||||
snapPreviewLayer.clearLayers();
|
||||
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
|
||||
|
||||
cacheSnapInfo(feature);
|
||||
const rawCoords = feature.geometry.coordinates;
|
||||
|
||||
[0, rawCoords.length - 1].forEach(i => {
|
||||
const isStart = i === 0;
|
||||
const snap = isStart ? feature._snapA : feature._snapB;
|
||||
const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled';
|
||||
const isEnabled = feature[enabledFlag] !== false;
|
||||
const segSnap = !snap ? findNearestOsmWayPoint(rawCoords[i], SNAP_TOL * 3) : null;
|
||||
|
||||
let coord, color, canToggle;
|
||||
if (snap) {
|
||||
coord = snap.coord;
|
||||
color = isEnabled ? '#28a745' : '#dc3545';
|
||||
canToggle = true;
|
||||
} else if (segSnap) {
|
||||
coord = segSnap.coord;
|
||||
color = isEnabled ? '#007bff' : '#dc3545';
|
||||
canToggle = true;
|
||||
} else {
|
||||
coord = rawCoords[i];
|
||||
color = '#ffc107';
|
||||
canToggle = false;
|
||||
}
|
||||
|
||||
const marker = L.circleMarker([coord[1], coord[0]], {
|
||||
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9
|
||||
});
|
||||
|
||||
if (canToggle) {
|
||||
marker.bindTooltip(isEnabled ? `Click to disable ${isStart ? 'start' : 'end'} snap` : 'Click to re-enable snap', { sticky: true });
|
||||
marker.on('click', function(e) {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
feature[enabledFlag] = !isEnabled;
|
||||
updateSnapPreview(feature);
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
});
|
||||
} else {
|
||||
marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
|
||||
}
|
||||
|
||||
snapPreviewLayer.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSnapA = function() {
|
||||
if (selectedFeatures.length !== 1) return;
|
||||
const feat = selectedFeatures[0];
|
||||
feat._snapAEnabled = feat._snapAEnabled !== false ? false : true;
|
||||
updateSnapPreview(feat);
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
window.toggleSnapB = function() {
|
||||
if (selectedFeatures.length !== 1) return;
|
||||
const feat = selectedFeatures[0];
|
||||
feat._snapBEnabled = feat._snapBEnabled !== false ? false : true;
|
||||
updateSnapPreview(feat);
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
|
||||
window.importCountyFeature = function() {
|
||||
if (selectedFeatures.length !== 1 || selectedLayers[0].type !== 'county') return;
|
||||
const source = selectedFeatures[0];
|
||||
if (source.geometry.type !== 'LineString') return;
|
||||
const osmTags = countyPropsToOsmTags(source.properties);
|
||||
const synthetic = {
|
||||
type: 'Feature',
|
||||
geometry: JSON.parse(JSON.stringify(source.geometry)),
|
||||
properties: osmTags,
|
||||
_countySource: source
|
||||
};
|
||||
acceptedFeatures.add(synthetic);
|
||||
cacheSnapInfo(synthetic);
|
||||
showStatus(`Imported "${osmTags.name || 'unnamed'}" from county data`, 'success');
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
|
||||
function connectivitySection(feature) {
|
||||
if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
|
||||
const coords = feature.geometry.coordinates;
|
||||
const snapA = findNearestOsmVertex(coords[0]);
|
||||
const snapB = findNearestOsmVertex(coords[coords.length - 1]);
|
||||
const segA = snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3);
|
||||
const segB = snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3);
|
||||
|
||||
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
|
||||
function findDiffConnection(coord) {
|
||||
if (!diffData) return null;
|
||||
let best = null, bestDist = SNAP_TOL;
|
||||
diffData.features.forEach(f => {
|
||||
if (f === feature || !f.geometry || f.geometry.type !== 'LineString') return;
|
||||
const fc = f.geometry.coordinates;
|
||||
[fc[0], fc[fc.length - 1]].forEach(ep => {
|
||||
const d = Math.hypot(ep[0] - coord[0], ep[1] - coord[1]);
|
||||
if (d < bestDist) { bestDist = d; best = f.properties.name || 'new road'; }
|
||||
});
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function endpointHtml(label, snap, segSnap, toggleFn) {
|
||||
const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled';
|
||||
const isEnabled = feature[enabledFlag] !== false;
|
||||
const toggleBtn = (snap || segSnap)
|
||||
? ` <button onclick="${toggleFn}()" style="font-size:10px;padding:1px 5px;cursor:pointer;">${isEnabled ? 'Disable snap' : 'Enable snap'}</button>`
|
||||
: '';
|
||||
|
||||
if (snap) {
|
||||
const isEndpoint = snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1;
|
||||
const m = Math.round(snap.dist * 111320);
|
||||
const roadName = snap.name || 'unnamed road';
|
||||
const typeStr = isEndpoint ? 'endpoint of' : 'midpoint of';
|
||||
const color = isEnabled ? '#155724' : '#6c757d';
|
||||
return `<div style="color:${color};">✓ ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}</div>`;
|
||||
}
|
||||
if (segSnap) {
|
||||
const m = Math.round(segSnap.dist * 111320);
|
||||
const roadName = segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway) || 'unnamed road';
|
||||
const color = isEnabled ? '#155724' : '#6c757d';
|
||||
return `<div style="color:${color};">✓ ${label}: will insert node into "${roadName}" (${m} m) on export${toggleBtn}</div>`;
|
||||
}
|
||||
const diffConn = findDiffConnection(label === 'Start' ? coords[0] : coords[coords.length - 1]);
|
||||
if (diffConn) {
|
||||
return `<div style="color:#155724;">✓ ${label}: connects to new "${diffConn}"</div>`;
|
||||
}
|
||||
return `<div style="color:#856404;">⚠ ${label}: no nearby road — dangling</div>`;
|
||||
}
|
||||
|
||||
const crossings = findOsmCrossings(coords);
|
||||
const newName = feature.properties && feature.properties.name;
|
||||
let html = '<div style="margin-top:8px;font-size:11px;">';
|
||||
html += '<strong>Connectivity:</strong>';
|
||||
html += endpointHtml('Start', snapA, segA, 'toggleSnapA');
|
||||
html += endpointHtml('End', snapB, segB, 'toggleSnapB');
|
||||
crossings.slice(0, 3).forEach(c => {
|
||||
const sameName = newName && c.name && c.name.toLowerCase() === newName.toLowerCase();
|
||||
const hint = sameName ? ' — <em>possible rename/reroute</em>' : '';
|
||||
html += `<div style="color:#004085;">⇄ Crosses "${c.name || 'unnamed'}"${hint}</div>`;
|
||||
});
|
||||
if (crossings.length > 3) html += `<div style="color:#004085;">… and ${crossings.length - 3} more</div>`;
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---- JOSM Export: removal plans ----
|
||||
|
||||
function buildNodeRefCount() {
|
||||
const counts = new Map();
|
||||
if (!osmData) return counts;
|
||||
osmData.features.forEach(feat => {
|
||||
(feat.properties.osm_nodes || []).forEach(nid => {
|
||||
counts.set(nid, (counts.get(nid) || 0) + 1);
|
||||
});
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) {
|
||||
const props = removedFeat.properties || {};
|
||||
const osmId = props.osm_id || props['@id'];
|
||||
const osmVersion = props.osm_version || 1;
|
||||
const osmNodes = props.osm_nodes;
|
||||
const wayCoords = removedFeat.geometry && removedFeat.geometry.coordinates;
|
||||
|
||||
const tags = {};
|
||||
Object.entries(props).forEach(([k, v]) => {
|
||||
if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) &&
|
||||
v !== null && v !== undefined && v !== '') tags[k] = String(v);
|
||||
});
|
||||
|
||||
if (!osmNodes || !wayCoords || osmNodes.length !== wayCoords.length) {
|
||||
return { type: 'delete', osmId, osmVersion, wayCoords, tags, description: `Delete way${osmId ? ' ' + osmId : ''} (no node data)` };
|
||||
}
|
||||
|
||||
// Find endpoints of accepted added features that land on this old way
|
||||
let splitPoints = [];
|
||||
acceptedAddedFeats.forEach(addedFeat => {
|
||||
const snappedCoords = addedFeat._snappedCoords || addedFeat.geometry.coordinates;
|
||||
[snappedCoords[0], snappedCoords[snappedCoords.length - 1]].forEach(ep => {
|
||||
const hit = closestPointOnWay(ep, wayCoords, SNAP_TOL * 3);
|
||||
if (hit) {
|
||||
splitPoints.push({
|
||||
segIdx: hit.segIdx,
|
||||
localT: hit.t,
|
||||
t_overall: (hit.segIdx + hit.t) / (wayCoords.length - 1),
|
||||
coord: hit.coord,
|
||||
newRoadName: (addedFeat.properties && addedFeat.properties.name) || 'new road'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (splitPoints.length === 0) {
|
||||
const deleteNodeIds = osmNodes.slice(1, -1).filter(nid => (nodeRefCount.get(nid) || 0) <= 1);
|
||||
return { type: 'delete', osmId, osmVersion, wayCoords, osmNodes, tags, deleteNodeIds, description: `Delete way${osmId ? ' ' + osmId : ''}` };
|
||||
}
|
||||
|
||||
splitPoints.sort((a, b) => a.t_overall - b.t_overall);
|
||||
// Deduplicate very close split points
|
||||
splitPoints = splitPoints.filter((sp, i) => i === 0 || Math.abs(sp.t_overall - splitPoints[i - 1].t_overall) > 0.01);
|
||||
|
||||
const firstSplit = splitPoints[0];
|
||||
const lastSplit = splitPoints[splitPoints.length - 1];
|
||||
|
||||
function resolveSplitNode(sp) {
|
||||
const i = sp.segIdx;
|
||||
if (Math.hypot(sp.coord[0] - wayCoords[i][0], sp.coord[1] - wayCoords[i][1]) <= SNAP_TOL)
|
||||
return { atIndex: i, nodeId: osmNodes[i], insertAfter: null };
|
||||
if (Math.hypot(sp.coord[0] - wayCoords[i + 1][0], sp.coord[1] - wayCoords[i + 1][1]) <= SNAP_TOL)
|
||||
return { atIndex: i + 1, nodeId: osmNodes[i + 1], insertAfter: null };
|
||||
return { atIndex: i + 1, nodeId: null, coord: sp.coord, insertAfter: i };
|
||||
}
|
||||
|
||||
const firstResolved = resolveSplitNode(firstSplit);
|
||||
const lastResolved = resolveSplitNode(lastSplit);
|
||||
|
||||
const midStart = firstResolved.atIndex + (firstResolved.nodeId !== null ? 1 : 0);
|
||||
const midEnd = lastResolved.atIndex;
|
||||
const middleNodeIds = osmNodes.slice(midStart, midEnd).filter(nid => (nodeRefCount.get(nid) || 0) <= 1);
|
||||
const keptJunctionIds = osmNodes.slice(midStart, midEnd).filter(nid => (nodeRefCount.get(nid) || 0) > 1);
|
||||
|
||||
return {
|
||||
type: 'split', osmId, osmVersion, osmNodes, wayCoords, tags,
|
||||
firstResolved, lastResolved,
|
||||
middleNodeIds, keptJunctionIds, splitPoints,
|
||||
description: `Split way${osmId ? ' ' + osmId : ''} around "${firstSplit.newRoadName}"`
|
||||
};
|
||||
}
|
||||
|
||||
function showAutoFixModal(plans) {
|
||||
return new Promise(resolve => {
|
||||
const modal = document.getElementById('autoFixModal');
|
||||
const body = document.getElementById('autoFixModalBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
plans.forEach((plan, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.style.cssText = 'padding:10px;background:#f8f9fa;border-radius:4px;margin-bottom:8px;';
|
||||
const lines = [`<strong>${plan.description}</strong>`];
|
||||
if (plan.type === 'split') {
|
||||
lines.push(`✓ Keep before-segment: nodes 0→${plan.firstResolved.atIndex}`);
|
||||
if (plan.firstResolved.nodeId === null) lines.push('✓ Insert new node at split point');
|
||||
lines.push(`✓ Keep after-segment: nodes ${plan.lastResolved.atIndex}→end`);
|
||||
lines.push(`✓ Delete ${plan.middleNodeIds.length} single-use middle node(s)`);
|
||||
if (plan.keptJunctionIds.length > 0)
|
||||
lines.push(`⚠ Keep ${plan.keptJunctionIds.length} junction node(s) shared with other ways`);
|
||||
} else {
|
||||
const cnt = plan.deleteNodeIds ? plan.deleteNodeIds.length : 0;
|
||||
lines.push(`✓ Delete way and ${cnt} single-use node(s)`);
|
||||
}
|
||||
div.innerHTML = `<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;">
|
||||
<input type="checkbox" data-plan-idx="${i}" checked style="margin-top:2px;">
|
||||
<div style="font-size:12px;">${lines.join('<br>')}</div>
|
||||
</label>`;
|
||||
body.appendChild(div);
|
||||
});
|
||||
|
||||
modal.style.display = 'flex';
|
||||
|
||||
document.getElementById('autoFixCancel').onclick = () => { modal.style.display = 'none'; resolve(null); };
|
||||
document.getElementById('autoFixExport').onclick = () => {
|
||||
const checked = new Set();
|
||||
body.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
if (cb.checked) checked.add(parseInt(cb.dataset.planIdx));
|
||||
});
|
||||
modal.style.display = 'none';
|
||||
resolve(checked);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---- JOSM Export: XML generation ----
|
||||
|
||||
function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
|
||||
const nodeRegistry = new Map(); // "lon7,lat7" → id
|
||||
const referencedExisting = new Map(); // nodeId (positive) → {coord, version}
|
||||
let nextNewId = -1;
|
||||
|
||||
function allocNodeId(coord, existingNodeId) {
|
||||
const key = coord[0].toFixed(7) + ',' + coord[1].toFixed(7);
|
||||
if (nodeRegistry.has(key)) return nodeRegistry.get(key);
|
||||
const id = existingNodeId ? existingNodeId : nextNewId--;
|
||||
nodeRegistry.set(key, id);
|
||||
if (existingNodeId) {
|
||||
const version = osmNodeVersionById.get(existingNodeId) || 1;
|
||||
referencedExisting.set(existingNodeId, { coord, version });
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// Tracks nodes that need to be inserted into existing ways: feat → [{segIdx, t, nodeId, coord}]
|
||||
const wayInsertions = new Map();
|
||||
|
||||
// Allocate an endpoint node, falling back to segment snap if no vertex snap exists.
|
||||
function allocEndpointId(rawCoord, snap) {
|
||||
if (snap) return allocNodeId(snap.coord, snap.nodeId);
|
||||
// Try snapping to the nearest point on any existing way segment.
|
||||
const segHit = findNearestOsmWayPoint(rawCoord, SNAP_TOL * 3);
|
||||
if (!segHit) return allocNodeId(rawCoord, null);
|
||||
// If the projection landed right on an existing node, use it.
|
||||
const vertexHit = findNearestOsmVertex(segHit.coord, SNAP_TOL);
|
||||
if (vertexHit) return allocNodeId(vertexHit.coord, vertexHit.nodeId);
|
||||
// Midpoint of an existing way — allocate a new node and record the insertion.
|
||||
const key = segHit.coord[0].toFixed(7) + ',' + segHit.coord[1].toFixed(7);
|
||||
if (!nodeRegistry.has(key)) {
|
||||
nodeRegistry.set(key, nextNewId--);
|
||||
const list = wayInsertions.get(segHit.feat) || [];
|
||||
list.push({ segIdx: segHit.segIdx, t: segHit.t, nodeId: nodeRegistry.get(key), coord: segHit.coord });
|
||||
wayInsertions.set(segHit.feat, list);
|
||||
}
|
||||
return nodeRegistry.get(key);
|
||||
}
|
||||
|
||||
const newWays = [];
|
||||
const modifiedWays = [];
|
||||
const splitTailWays = [];
|
||||
const deleteWayIds = []; // [{id, version}]
|
||||
const deleteNodeIds = new Map(); // id -> version
|
||||
|
||||
acceptedAdded.forEach(feat => {
|
||||
const rawCoords = feat.geometry.coordinates;
|
||||
const midCoords = (feat._snappedCoords || rawCoords).slice(1, -1);
|
||||
const startId = allocEndpointId(rawCoords[0], feat._snapAEnabled !== false ? feat._snapA : null);
|
||||
const endId = allocEndpointId(rawCoords[rawCoords.length - 1], feat._snapBEnabled !== false ? feat._snapB : null);
|
||||
const ndRefs = [startId, ...midCoords.map(c => allocNodeId(c, null)), endId];
|
||||
const tags = {};
|
||||
Object.entries(feat.properties || {}).forEach(([k, v]) => {
|
||||
if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) &&
|
||||
v !== null && v !== undefined && v !== '') tags[k] = String(v);
|
||||
});
|
||||
newWays.push({ id: nextNewId--, ndRefs, tags });
|
||||
});
|
||||
|
||||
// Emit modify actions for existing ways that need a node inserted at a new intersection.
|
||||
wayInsertions.forEach((insertions, feat) => {
|
||||
const osmId = feat.properties.osm_id;
|
||||
if (!osmId) return;
|
||||
const osmVersion = feat.properties.osm_version || 1;
|
||||
const osmNodes = feat.properties.osm_nodes || [];
|
||||
const wayCoords = feat.geometry.coordinates;
|
||||
insertions.sort((a, b) => a.segIdx !== b.segIdx ? a.segIdx - b.segIdx : a.t - b.t);
|
||||
|
||||
const ndRefs = [];
|
||||
let insIdx = 0;
|
||||
for (let i = 0; i < wayCoords.length; i++) {
|
||||
ndRefs.push(allocNodeId(wayCoords[i], osmNodes[i] || null));
|
||||
while (insIdx < insertions.length && insertions[insIdx].segIdx === i) {
|
||||
ndRefs.push(insertions[insIdx].nodeId);
|
||||
insIdx++;
|
||||
}
|
||||
}
|
||||
const tags = {};
|
||||
Object.entries(feat.properties || {}).forEach(([k, v]) => {
|
||||
if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) &&
|
||||
v !== null && v !== undefined && v !== '') tags[k] = String(v);
|
||||
});
|
||||
modifiedWays.push({ id: osmId, version: osmVersion, ndRefs, tags });
|
||||
});
|
||||
|
||||
removalPlans.forEach((plan, i) => {
|
||||
if (!checkedPlanIndices.has(i) || !plan.osmId) return;
|
||||
|
||||
if (plan.type === 'delete') {
|
||||
deleteWayIds.push({ id: plan.osmId, version: plan.osmVersion || 1 });
|
||||
if (plan.deleteNodeIds) plan.deleteNodeIds.forEach(nid => deleteNodeIds.set(nid, osmNodeVersionById.get(nid) || 1));
|
||||
} else if (plan.type === 'split') {
|
||||
const beforeEnd = plan.firstResolved;
|
||||
const beforeRefs = [];
|
||||
for (let j = 0; j <= beforeEnd.atIndex; j++) {
|
||||
beforeRefs.push(allocNodeId(plan.wayCoords[j], plan.osmNodes[j]));
|
||||
}
|
||||
if (beforeEnd.nodeId === null && beforeEnd.coord) {
|
||||
beforeRefs.push(allocNodeId(beforeEnd.coord, null));
|
||||
}
|
||||
|
||||
const afterStart = plan.lastResolved;
|
||||
const afterRefs = [];
|
||||
if (afterStart.nodeId === null && afterStart.coord) {
|
||||
afterRefs.push(allocNodeId(afterStart.coord, null));
|
||||
}
|
||||
for (let j = afterStart.atIndex; j < plan.wayCoords.length; j++) {
|
||||
afterRefs.push(allocNodeId(plan.wayCoords[j], plan.osmNodes[j]));
|
||||
}
|
||||
|
||||
modifiedWays.push({ id: plan.osmId, version: plan.osmVersion || 1, ndRefs: beforeRefs, tags: plan.tags });
|
||||
if (afterRefs.length > 1) {
|
||||
splitTailWays.push({ id: nextNewId--, ndRefs: afterRefs, tags: plan.tags });
|
||||
}
|
||||
plan.middleNodeIds.forEach(nid => deleteNodeIds.set(nid, osmNodeVersionById.get(nid) || 1));
|
||||
}
|
||||
});
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function wayXml(w, action) {
|
||||
let x = ` <way id="${w.id}"${action ? ` action="${action}"` : ''}${action === 'modify' ? ` version="${w.version || 1}"` : ''}>\n`;
|
||||
w.ndRefs.forEach(ref => { x += ` <nd ref="${ref}"/>\n`; });
|
||||
Object.entries(w.tags).forEach(([k, v]) => { x += ` <tag k="${esc(k)}" v="${esc(v)}"/>\n`; });
|
||||
return x + ' </way>\n';
|
||||
}
|
||||
|
||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<osm version="0.6" generator="osm-import-tools">\n\n';
|
||||
|
||||
referencedExisting.forEach(({ coord, version }, nid) => {
|
||||
xml += ` <node id="${nid}" version="${version}" lat="${coord[1].toFixed(7)}" lon="${coord[0].toFixed(7)}"/>\n`;
|
||||
});
|
||||
nodeRegistry.forEach((id, key) => {
|
||||
if (id >= 0) return;
|
||||
const [lon, lat] = key.split(',').map(Number);
|
||||
xml += ` <node id="${id}" action="create" lat="${lat.toFixed(7)}" lon="${lon.toFixed(7)}"/>\n`;
|
||||
});
|
||||
if (referencedExisting.size > 0 || [...nodeRegistry.values()].some(id => id < 0)) xml += '\n';
|
||||
|
||||
newWays.forEach(w => { xml += wayXml(w, 'create'); });
|
||||
modifiedWays.forEach(w => { xml += wayXml(w, 'modify'); });
|
||||
splitTailWays.forEach(w => { xml += wayXml(w, 'create'); });
|
||||
deleteWayIds.forEach(w => { xml += ` <way id="${w.id}" action="delete" version="${w.version}"/>\n`; });
|
||||
deleteNodeIds.forEach((version, nid) => { xml += ` <node id="${nid}" action="delete" version="${version}"/>\n`; });
|
||||
|
||||
xml += '\n</osm>\n';
|
||||
return xml;
|
||||
}
|
||||
|
||||
async function exportToOsm() {
|
||||
if (acceptedFeatures.size === 0) {
|
||||
showStatus('No accepted features to export', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const acceptedAdded = [];
|
||||
const acceptedRemoved = [];
|
||||
acceptedFeatures.forEach(feat => {
|
||||
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
||||
if (isRemoved) acceptedRemoved.push(feat);
|
||||
else acceptedAdded.push(feat);
|
||||
});
|
||||
|
||||
// Ensure all added features have snap info
|
||||
acceptedAdded.forEach(feat => cacheSnapInfo(feat));
|
||||
|
||||
const nodeRefCount = buildNodeRefCount();
|
||||
const removalPlans = acceptedRemoved.map(feat => computeRemovalPlan(feat, acceptedAdded, nodeRefCount));
|
||||
|
||||
const complexPlans = removalPlans.filter(p => p.type === 'split');
|
||||
let checkedPlanIndices;
|
||||
|
||||
if (complexPlans.length > 0) {
|
||||
const complexToFull = [];
|
||||
removalPlans.forEach((p, i) => { if (p.type === 'split') complexToFull.push(i); });
|
||||
const result = await showAutoFixModal(complexPlans);
|
||||
if (result === null) return;
|
||||
checkedPlanIndices = new Set(
|
||||
[...removalPlans.keys()].filter(i => {
|
||||
if (removalPlans[i].type !== 'split') return true;
|
||||
return result.has(complexToFull.indexOf(i));
|
||||
})
|
||||
);
|
||||
} else {
|
||||
checkedPlanIndices = new Set(removalPlans.keys());
|
||||
}
|
||||
|
||||
const xml = generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices);
|
||||
const dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const filename = `${loadedCounty || 'osm'}-${loadedDataType || 'export'}-${dataDate}.osm`;
|
||||
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showStatus(`Exported ${acceptedAdded.length} added, ${acceptedRemoved.length} removed as JOSM .osm`, 'success');
|
||||
}
|
||||
|
||||
window.exportToOsm = exportToOsm;
|
||||
|
||||
// Update save button state
|
||||
function updateSaveButton() {
|
||||
document.getElementById('saveButton').disabled =
|
||||
acceptedFeatures.size === 0 && rejectedFeatures.size === 0;
|
||||
document.getElementById('exportOsmButton').disabled = acceptedFeatures.size === 0;
|
||||
}
|
||||
|
||||
// Load GeoJSON from server
|
||||
@@ -1094,6 +1836,7 @@ async function loadFiles() {
|
||||
|
||||
// Create layers
|
||||
createOsmLayer();
|
||||
buildOsmNodeIndex();
|
||||
createDiffLayer();
|
||||
createCountyLayer();
|
||||
|
||||
@@ -1326,6 +2069,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Save button
|
||||
document.getElementById('saveButton').addEventListener('click', saveAcceptedItems);
|
||||
|
||||
// Export to JOSM button
|
||||
document.getElementById('exportOsmButton').addEventListener('click', exportToOsm);
|
||||
|
||||
// Multi-select toggle button
|
||||
document.getElementById('multiSelectToggle').addEventListener('click', function() {
|
||||
multiSelectMode = !multiSelectMode;
|
||||
|
||||
+15
-1
@@ -346,11 +346,25 @@
|
||||
</div>
|
||||
|
||||
<button id="loadButton" class="load-button">Load from Server</button>
|
||||
<button id="saveButton" disabled>Save Accepted Items</button>
|
||||
<button id="saveButton" disabled>Save GeoJSON</button>
|
||||
<button id="exportOsmButton" disabled style="background:#6f42c1;">Export to JOSM (.osm)</button>
|
||||
|
||||
<div id="status" class="status hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-fix preview modal -->
|
||||
<div id="autoFixModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.55);z-index:10000;align-items:center;justify-content:center;">
|
||||
<div style="background:white;border-radius:8px;padding:20px;max-width:520px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 4px 24px rgba(0,0,0,0.35);">
|
||||
<h3 style="margin:0 0 8px;font-size:15px;">Review Auto-Fix Plans</h3>
|
||||
<p style="font-size:12px;color:#555;margin:0 0 12px;">These removed roads need splitting. Uncheck any to skip (old way excluded from export).</p>
|
||||
<div id="autoFixModalBody"></div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button id="autoFixCancel" style="padding:8px 16px;background:#6c757d;color:white;border:none;border-radius:4px;cursor:pointer;font-size:13px;">Cancel</button>
|
||||
<button id="autoFixExport" style="padding:8px 16px;background:#6f42c1;color:white;border:none;border-radius:4px;cursor:pointer;font-size:13px;">Export</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user