diff --git a/NOI Hackathon 2025 - Open Data Hub.pdf b/NOI Hackathon 2025 - Open Data Hub.pdf new file mode 100644 index 00000000..4ddb6ead Binary files /dev/null and b/NOI Hackathon 2025 - Open Data Hub.pdf differ diff --git a/Open Data Hub challenge_more info.pdf b/Open Data Hub challenge_more info.pdf new file mode 100644 index 00000000..8089342b Binary files /dev/null and b/Open Data Hub challenge_more info.pdf differ diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index ce9cd7b7..14602875 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -561,4 +561,62 @@ label.theme-color-label { &.leaflet-control-overlays-expanded > .content { display: flex; } +} + +/* Snap-to-edges control styles */ +.leaflet-control-snap { + background-color: white; + border-radius: 4px; + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + + .snap-toggle { + display: block; + width: 30px; + height: 30px; + line-height: 26px; + text-align: center; + text-decoration: none; + font-size: 18px; + background-color: white; + color: #666; + border-radius: 2px; + transition: all 0.2s ease; + + &:hover { + background-color: #f4f4f4; + color: #333; + } + + &.active { + background-color: #4CAF50; + color: white; + + &:hover { + background-color: #45a049; + } + } + } +} + +/* Snap indicator styles */ +.snap-indicator { + z-index: 1000; + pointer-events: none; + animation: snap-pulse 1s infinite; +} + +@keyframes snap-pulse { + 0% { + opacity: 0.8; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.2); + } + 100% { + opacity: 0.8; + transform: scale(1); + } } \ No newline at end of file diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index 36db2983..bf87f13d 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -182,6 +182,9 @@ editor = { $('#sidebar').addClass('loading').find('.content').html(''); editor._cancel_editing(); + + // Clear snap indicators when unloading + editor._clear_snap_indicators(); }, _fill_level_control: function (level_control, level_list, geometryURLs) { var levels = level_list.find('a'); @@ -815,6 +818,9 @@ editor = { editor.map.on('zoomend', editor._adjust_line_zoom); + // Initialize snap-to-edges functionality + editor.init_snap_to_edges(); + c3nav_api.get('editor/geometrystyles') .then(geometrystyles => { editor.geometrystyles = geometrystyles; @@ -1442,6 +1448,9 @@ editor = { editor._editing_layer.disableEdit(); editor._editing_layer = null; } + + // Clear snap indicators when canceling editing + editor._clear_snap_indicators(); }, _canceled_creating: function (e) { // called after we canceled creating so we can remove the temporary layer. @@ -1652,6 +1661,224 @@ editor = { editor._wifi_scan_waits = true; mobileclient.scanNow(); } + }, + + // Snap-to-edges functionality + _snap_enabled: true, + _snap_distance: 15, // pixels + _snap_to_base_map: false, + _snap_indicator: null, + _snap_candidates: [], + + init_snap_to_edges: function() { + // Initialize snap indicator layer + editor._snap_indicator = L.layerGroup().addTo(editor.map); + + // Override existing drawing event handlers to include snapping + editor.map.on('editable:drawing:move', editor._handle_snap_during_draw); + editor.map.on('editable:vertex:drag', editor._handle_snap_during_vertex_drag); + + // Add snap toggle to UI + editor._add_snap_controls(); + }, + + _add_snap_controls: function() { + // Add snap toggle control to the map + var snapControl = L.control({position: 'topleft'}); + snapControl.onAdd = function() { + var container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-snap'); + container.innerHTML = ''; + + L.DomEvent.on(container.querySelector('.snap-toggle'), 'click', function(e) { + e.preventDefault(); + editor._toggle_snap(); + }); + + L.DomEvent.disableClickPropagation(container); + return container; + }; + snapControl.addTo(editor.map); + }, + + _toggle_snap: function() { + editor._snap_enabled = !editor._snap_enabled; + var toggle = document.querySelector('.snap-toggle'); + if (toggle) { + toggle.classList.toggle('active', editor._snap_enabled); + } + // Clear any existing snap indicators + editor._clear_snap_indicators(); + }, + + _handle_snap_during_draw: function(e) { + if (!editor._snap_enabled || !editor._creating) return; + + var snapped = editor._find_and_apply_snap(e.latlng); + if (snapped) { + e.latlng.lat = snapped.lat; + e.latlng.lng = snapped.lng; + } + + // Apply existing rounding + e.latlng.lat = Math.round(e.latlng.lat * 100) / 100; + e.latlng.lng = Math.round(e.latlng.lng * 100) / 100; + }, + + _handle_snap_during_vertex_drag: function(e) { + if (!editor._snap_enabled) return; + + var snapped = editor._find_and_apply_snap(e.latlng); + if (snapped) { + e.latlng.lat = snapped.lat; + e.latlng.lng = snapped.lng; + } + + // Apply existing rounding and other constraints + e.vertex.setLatLng([Math.round(e.latlng.lat * 100) / 100, Math.round(e.latlng.lng * 100) / 100]); + }, + + _find_and_apply_snap: function(latlng) { + if (!editor._geometries_layer) return null; + + var mapPoint = editor.map.latLngToContainerPoint(latlng); + var candidates = []; + + // Find snap candidates from existing geometries + editor._geometries_layer.eachLayer(function(layer) { + if (layer === editor._editing_layer) return; // Don't snap to self + + var snapPoint = editor._find_closest_point_on_geometry(layer, latlng, mapPoint); + if (snapPoint && snapPoint.distance < editor._snap_distance) { + candidates.push(snapPoint); + } + }); + + // Find the closest candidate + if (candidates.length > 0) { + candidates.sort(function(a, b) { return a.distance - b.distance; }); + var best = candidates[0]; + + // Show snap indicator + editor._show_snap_indicator(best.latlng); + + return best.latlng; + } else { + editor._clear_snap_indicators(); + return null; + } + }, + + _find_closest_point_on_geometry: function(layer, targetLatLng, targetMapPoint) { + if (!layer.getLatLngs) return null; + + var closestPoint = null; + var closestDistance = Infinity; + + try { + var coordinates = []; + + // Handle different geometry types + if (layer instanceof L.Polygon || layer instanceof L.Polyline) { + coordinates = layer.getLatLngs(); + if (coordinates[0] && Array.isArray(coordinates[0])) { + coordinates = coordinates[0]; // Handle polygon with holes + } + } else if (layer instanceof L.Circle || layer instanceof L.CircleMarker) { + // For circles, snap to center + var center = layer.getLatLng(); + var centerMapPoint = editor.map.latLngToContainerPoint(center); + var distance = centerMapPoint.distanceTo(targetMapPoint); + if (distance < editor._snap_distance) { + return { + latlng: center, + distance: distance + }; + } + return null; + } + + // Check each edge of the geometry + for (var i = 0; i < coordinates.length; i++) { + var p1 = coordinates[i]; + var p2 = coordinates[(i + 1) % coordinates.length]; + + var snapPoint = editor._find_closest_point_on_edge(p1, p2, targetLatLng, targetMapPoint); + if (snapPoint && snapPoint.distance < closestDistance) { + closestDistance = snapPoint.distance; + closestPoint = snapPoint; + } + } + + } catch (error) { + // Silently handle geometry access errors + return null; + } + + return closestPoint; + }, + + _find_closest_point_on_edge: function(p1, p2, targetLatLng, targetMapPoint) { + var p1Map = editor.map.latLngToContainerPoint(p1); + var p2Map = editor.map.latLngToContainerPoint(p2); + + // Find closest point on line segment + var dx = p2Map.x - p1Map.x; + var dy = p2Map.y - p1Map.y; + var length = Math.sqrt(dx * dx + dy * dy); + + if (length === 0) { + // Points are the same, snap to the point + var distance = p1Map.distanceTo(targetMapPoint); + return { + latlng: p1, + distance: distance + }; + } + + // Calculate parameter t for closest point on line + var t = ((targetMapPoint.x - p1Map.x) * dx + (targetMapPoint.y - p1Map.y) * dy) / (length * length); + t = Math.max(0, Math.min(1, t)); // Clamp to line segment + + // Calculate closest point + var closestMapPoint = { + x: p1Map.x + t * dx, + y: p1Map.y + t * dy + }; + + var distance = Math.sqrt( + Math.pow(closestMapPoint.x - targetMapPoint.x, 2) + + Math.pow(closestMapPoint.y - targetMapPoint.y, 2) + ); + + // Convert back to lat/lng + var closestLatLng = editor.map.containerPointToLatLng(closestMapPoint); + + return { + latlng: closestLatLng, + distance: distance + }; + }, + + _show_snap_indicator: function(latlng) { + editor._clear_snap_indicators(); + + var indicator = L.circleMarker(latlng, { + radius: 4, + color: '#ff6b6b', + fillColor: '#ff6b6b', + fillOpacity: 0.8, + weight: 2, + className: 'snap-indicator' + }); + + editor._snap_indicator.addLayer(indicator); + }, + + _clear_snap_indicators: function() { + if (editor._snap_indicator) { + editor._snap_indicator.clearLayers(); + } } };