diff --git a/src/c3nav/editor/static/editor/css/editor.scss b/src/c3nav/editor/static/editor/css/editor.scss index b782dc1c..14c8cc3d 100644 --- a/src/c3nav/editor/static/editor/css/editor.scss +++ b/src/c3nav/editor/static/editor/css/editor.scss @@ -607,3 +607,71 @@ label.theme-color-label { z-index: 1000; pointer-events: none; } + +@keyframes snap-pulse { + 0% { + opacity: 0.8; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.2); + } + 100% { + opacity: 0.8; + transform: scale(1); + } +} + +/* Edge highlight styles for snap-to-edges */ +.edge-highlight { + z-index: 999; + pointer-events: none; + animation: edge-fade-in 0.2s ease-in; +} + +.original-edge-highlight { + z-index: 1000; + pointer-events: none; + animation: edge-fade-in 0.2s ease-in; +} + +@keyframes edge-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 0.6; + } +} + +/* Right-angle snap indicators */ +.right-angle-reference { + z-index: 998; + pointer-events: none; + animation: edge-fade-in 0.2s ease-in; +} + +.right-angle-line { + z-index: 1001; + pointer-events: none; + animation: right-angle-pulse 2s infinite; +} + +.right-angle-square { + z-index: 1002; + pointer-events: none; + animation: right-angle-pulse 2s infinite; +} + +@keyframes right-angle-pulse { + 0% { + opacity: 0.7; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.7; + } +} diff --git a/src/c3nav/editor/static/editor/js/editor.js b/src/c3nav/editor/static/editor/js/editor.js index cd69262e..e5be1b98 100644 --- a/src/c3nav/editor/static/editor/js/editor.js +++ b/src/c3nav/editor/static/editor/js/editor.js @@ -182,7 +182,7 @@ editor = { $('#sidebar').addClass('loading').find('.content').html(''); editor._cancel_editing(); - + // Clear snap indicators when unloading editor._clear_snap_indicators(); }, @@ -1448,7 +1448,7 @@ editor = { editor._editing_layer.disableEdit(); editor._editing_layer = null; } - + // Clear snap indicators when canceling editing editor._clear_snap_indicators(); }, @@ -1665,36 +1665,33 @@ editor = { // Snap-to-edges functionality _snap_enabled: true, - _snap_distance: 15, // pixels - _snap_to_base_map: false, + _snap_distance: 30, // pixels + _extension_area_multiplier: 4, // Extension area = snap_distance * this multiplier + _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; }; @@ -1707,61 +1704,79 @@ editor = { 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 + + // Apply 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 + + // check for right-angle snap to current shape vertices + var rightAngleSnap = editor._find_right_angle_snap(latlng, mapPoint); + if (rightAngleSnap) { + candidates.push(rightAngleSnap); + } + + // find snap candidates from existing geometries with area-limited infinite extension 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 (layer === editor._editing_layer) return; // don't snap to self + + // check if layer is within the area limit for infinite extension + var allowInfiniteExtension = editor._is_layer_in_extension_area(layer, latlng, mapPoint); + + var snapPoint = editor._find_closest_point_on_geometry(layer, latlng, mapPoint, allowInfiniteExtension); if (snapPoint && snapPoint.distance < editor._snap_distance) { candidates.push(snapPoint); } }); + // check current editing shape with infinite extension enabled + if (editor._current_editing_shape) { + var currentShapeSnap = editor._find_closest_point_on_geometry( + editor._current_editing_shape, latlng, mapPoint, true // Always enable infinite extension for current shape + ); + if (currentShapeSnap && currentShapeSnap.distance < editor._snap_distance) { + candidates.push(currentShapeSnap); + } + } + // 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); - + + // show snap indicator with edge highlighting + editor._show_snap_indicator(best.latlng, best); + return best.latlng; } else { editor._clear_snap_indicators(); @@ -1769,99 +1784,296 @@ editor = { } }, - _find_closest_point_on_geometry: function(layer, targetLatLng, targetMapPoint) { - if (!layer.getLatLngs) return null; - - var closestPoint = null; - var closestDistance = Infinity; - + _is_layer_in_extension_area: function(layer, targetLatLng, targetMapPoint) { + if (!layer.getLatLngs) return false; + + // skip circles entirely for infinite extension + if (layer instanceof L.Circle || layer instanceof L.CircleMarker) { + return false; + } + 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]; + } + } + + if (coordinates.length === 0) return false; + + // extension area radius (in pixels) + var extensionAreaRadius = editor._snap_distance * editor._extension_area_multiplier; + + // check if any vertex of the layer is within the extension area + for (var i = 0; i < coordinates.length; i++) { + var vertexMapPoint = editor.map.latLngToContainerPoint(coordinates[i]); + var distanceToVertex = vertexMapPoint.distanceTo(targetMapPoint); + + if (distanceToVertex <= extensionAreaRadius) { + return true; + } + } + + for (var i = 0; i < coordinates.length; i++) { + var p1 = coordinates[i]; + var p2 = coordinates[(i + 1) % coordinates.length]; + + if (editor._edge_intersects_circle(p1, p2, targetLatLng, targetMapPoint, extensionAreaRadius)) { + return true; + } + } + + return false; + + } catch (error) { + return false; + } + }, + + _edge_intersects_circle: function(edgeStart, edgeEnd, circleCenter, circleCenterMap, radius) { + var p1Map = editor.map.latLngToContainerPoint(edgeStart); + var p2Map = editor.map.latLngToContainerPoint(edgeEnd); + + // find closest point on edge to circle center + var dx = p2Map.x - p1Map.x; + var dy = p2Map.y - p1Map.y; + var length = Math.sqrt(dx * dx + dy * dy); + + if (length === 0) { + return p1Map.distanceTo(circleCenterMap) <= radius; + } + + var t = Math.max(0, Math.min(1, + ((circleCenterMap.x - p1Map.x) * dx + (circleCenterMap.y - p1Map.y) * dy) / (length * length) + )); + + var closestPoint = { + x: p1Map.x + t * dx, + y: p1Map.y + t * dy + }; + + var distance = Math.sqrt( + Math.pow(closestPoint.x - circleCenterMap.x, 2) + + Math.pow(closestPoint.y - circleCenterMap.y, 2) + ); + + return distance <= radius; + }, + + _find_closest_point_on_geometry: function(layer, targetLatLng, targetMapPoint, allowInfiniteExtension) { + 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 + distance: distance, + edgeStart: center, + edgeEnd: center, + isInfiniteExtension: false, + isRightAngle: false }; } return null; } - - // Check each edge of the geometry + + // 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); + + var snapPoint = editor._find_closest_point_on_edge(p1, p2, targetLatLng, targetMapPoint, allowInfiniteExtension); 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) { + _find_closest_point_on_edge: function(p1, p2, targetLatLng, targetMapPoint, allowInfiniteExtension) { var p1Map = editor.map.latLngToContainerPoint(p1); var p2Map = editor.map.latLngToContainerPoint(p2); - - // Find closest point on line segment + + // find closest point on line (infinite or segment based on allowInfiniteExtension) 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 + // points are the same, snap to the point var distance = p1Map.distanceTo(targetMapPoint); return { latlng: p1, - distance: distance + distance: distance, + edgeStart: p1, + edgeEnd: p2, + isInfiniteExtension: false, + isRightAngle: false }; } - - // Calculate parameter t for closest point on line + + // 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 + + // clamp t based on allowInfiniteExtension + var originalT = t; + if (!allowInfiniteExtension) { + 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.x - targetMapPoint.x, 2) + Math.pow(closestMapPoint.y - targetMapPoint.y, 2) ); - - // Convert back to lat/lng + var closestLatLng = editor.map.containerPointToLatLng(closestMapPoint); - + + // determine if this is an infinite extension + var isInfiniteExtension = allowInfiniteExtension && (originalT < 0 || originalT > 1); + return { latlng: closestLatLng, - distance: distance + distance: distance, + edgeStart: p1, + edgeEnd: p2, + isInfiniteExtension: isInfiniteExtension, + isRightAngle: false, + t: originalT }; }, - _show_snap_indicator: function(latlng) { + _find_right_angle_snap: function(targetLatLng, targetMapPoint) { + if (!editor._current_editing_shape) return null; + + try { + var coordinates = []; + + if (editor._current_editing_shape.getLatLngs) { + coordinates = editor._current_editing_shape.getLatLngs(); + if (coordinates[0] && Array.isArray(coordinates[0])) { + coordinates = coordinates[0]; // Handle polygon with holes + } + } else { + return null; + } + + if (coordinates.length < 2) return null; + + var bestRightAngleSnap = null; + var closestDistance = Infinity; + + // Check each vertex for potential right-angle formation + for (var i = 0; i < coordinates.length; i++) { + var vertex = coordinates[i]; + var vertexMap = editor.map.latLngToContainerPoint(vertex); + + var distanceToVertex = vertexMap.distanceTo(targetMapPoint); + if (distanceToVertex > editor._snap_distance * 2) continue; // Larger radius for right-angle detection + + var prevVertex = coordinates[(i - 1 + coordinates.length) % coordinates.length]; + var nextVertex = coordinates[(i + 1) % coordinates.length]; + + var rightAngleSnap1 = editor._calculate_right_angle_snap(vertex, prevVertex, targetLatLng, targetMapPoint); + if (rightAngleSnap1 && rightAngleSnap1.distance < closestDistance) { + closestDistance = rightAngleSnap1.distance; + bestRightAngleSnap = rightAngleSnap1; + } + + var rightAngleSnap2 = editor._calculate_right_angle_snap(vertex, nextVertex, targetLatLng, targetMapPoint); + if (rightAngleSnap2 && rightAngleSnap2.distance < closestDistance) { + closestDistance = rightAngleSnap2.distance; + bestRightAngleSnap = rightAngleSnap2; + } + } + + return bestRightAngleSnap; + + } catch (error) { + return null; + } + }, + + _calculate_right_angle_snap: function(vertex, adjacentVertex, targetLatLng, targetMapPoint) { + var vertexMap = editor.map.latLngToContainerPoint(vertex); + var adjacentMap = editor.map.latLngToContainerPoint(adjacentVertex); + + var edgeDx = adjacentMap.x - vertexMap.x; + var edgeDy = adjacentMap.y - vertexMap.y; + var edgeLength = Math.sqrt(edgeDx * edgeDx + edgeDy * edgeDy); + + if (edgeLength === 0) return null; + + var perpDx = -edgeDy / edgeLength; + var perpDy = edgeDx / edgeLength; + + var targetDx = targetMapPoint.x - vertexMap.x; + var targetDy = targetMapPoint.y - vertexMap.y; + var targetLength = Math.sqrt(targetDx * targetDx + targetDy * targetDy); + + if (targetLength === 0) return null; + + var projectionLength = targetDx * perpDx + targetDy * perpDy; + + var rightAngleMapPoint = { + x: vertexMap.x + projectionLength * perpDx, + y: vertexMap.y + projectionLength * perpDy + }; + + var distance = Math.sqrt( + Math.pow(rightAngleMapPoint.x - targetMapPoint.x, 2) + + Math.pow(rightAngleMapPoint.y - targetMapPoint.y, 2) + ); + + if (distance < editor._snap_distance && Math.abs(projectionLength) > 10) { // minimum 10 pixels away from vertex + var rightAngleLatLng = editor.map.containerPointToLatLng(rightAngleMapPoint); + + return { + latlng: rightAngleLatLng, + distance: distance, + edgeStart: vertex, + edgeEnd: rightAngleLatLng, + isInfiniteExtension: false, + isRightAngle: true, + rightAngleVertex: vertex, + adjacentVertex: adjacentVertex + }; + } + + return null; + }, + + _show_snap_indicator: function(latlng, snapInfo) { editor._clear_snap_indicators(); var size = 0.001; // adjust this to control square size @@ -1880,6 +2092,136 @@ editor = { }); editor._snap_indicator.addLayer(indicator); + + if (snapInfo && snapInfo.edgeStart && snapInfo.edgeEnd) { + editor._show_edge_highlight(snapInfo); + } + }, + + _show_edge_highlight: function(snapInfo) { + if (!snapInfo.edgeStart || !snapInfo.edgeEnd) return; + + // handle right-angle visualization + if (snapInfo.isRightAngle) { + editor._show_right_angle_highlight(snapInfo); + return; + } + + var startPoint = snapInfo.edgeStart; + var endPoint = snapInfo.edgeEnd; + var extendedStart, extendedEnd; + + if (snapInfo.isInfiniteExtension && snapInfo.t !== undefined) { + // Extend the line significantly beyond the original edge + var startMap = editor.map.latLngToContainerPoint(startPoint); + var endMap = editor.map.latLngToContainerPoint(endPoint); + + var dx = endMap.x - startMap.x; + var dy = endMap.y - startMap.y; + var length = Math.sqrt(dx * dx + dy * dy); + + if (length > 0) { + dx /= length; + dy /= length; + + var extensionDistance = 1000; + var extStartMap = { + x: startMap.x - dx * extensionDistance, + y: startMap.y - dy * extensionDistance + }; + var extEndMap = { + x: endMap.x + dx * extensionDistance, + y: endMap.y + dy * extensionDistance + }; + + extendedStart = editor.map.containerPointToLatLng(extStartMap); + extendedEnd = editor.map.containerPointToLatLng(extEndMap); + } else { + extendedStart = startPoint; + extendedEnd = endPoint; + } + } else { + extendedStart = startPoint; + extendedEnd = endPoint; + } + + // create edge highlight line + var edgeHighlight = L.polyline([extendedStart, extendedEnd], { + color: snapInfo.isInfiniteExtension ? '#ffaa00' : '#66dd66', // Orange for infinite, green for original edge + weight: snapInfo.isInfiniteExtension ? 2 : 3, + opacity: snapInfo.isInfiniteExtension ? 0.4 : 0.6, + dashArray: snapInfo.isInfiniteExtension ? '8, 4' : null, // Dashed for infinite extension + className: 'edge-highlight' + }); + + editor._snap_indicator.addLayer(edgeHighlight); + + // if it's an infinite extension, also show the original edge segment more prominently + if (snapInfo.isInfiniteExtension) { + var originalEdge = L.polyline([startPoint, endPoint], { + color: '#66dd66', + weight: 3, + opacity: 0.8, + className: 'original-edge-highlight' + }); + editor._snap_indicator.addLayer(originalEdge); + } + }, + + _show_right_angle_highlight: function(snapInfo) { + var vertex = snapInfo.rightAngleVertex; + var adjacentVertex = snapInfo.adjacentVertex; + var rightAnglePoint = snapInfo.latlng; + + var referenceEdge = L.polyline([vertex, adjacentVertex], { + color: '#4488ff', + weight: 2, + opacity: 0.6, + className: 'right-angle-reference' + }); + editor._snap_indicator.addLayer(referenceEdge); + + var rightAngleLine = L.polyline([vertex, rightAnglePoint], { + color: '#ff4488', + weight: 3, + opacity: 0.8, + dashArray: '6, 3', + className: 'right-angle-line' + }); + editor._snap_indicator.addLayer(rightAngleLine); + + var vertexMap = editor.map.latLngToContainerPoint(vertex); + var adjacentMap = editor.map.latLngToContainerPoint(adjacentVertex); + var rightAngleMap = editor.map.latLngToContainerPoint(rightAnglePoint); + + var size = 15; // Square size in pixels + var dx1 = adjacentMap.x - vertexMap.x; + var dy1 = adjacentMap.y - vertexMap.y; + var len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); + + var dx2 = rightAngleMap.x - vertexMap.x; + var dy2 = rightAngleMap.y - vertexMap.y; + var len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + + if (len1 > 0 && len2 > 0) { + dx1 = (dx1 / len1) * size; + dy1 = (dy1 / len1) * size; + dx2 = (dx2 / len2) * size; + dy2 = (dy2 / len2) * size; + + var corner1 = editor.map.containerPointToLatLng({x: vertexMap.x + dx1, y: vertexMap.y + dy1}); + var corner2 = editor.map.containerPointToLatLng({x: vertexMap.x + dx1 + dx2, y: vertexMap.y + dy1 + dy2}); + var corner3 = editor.map.containerPointToLatLng({x: vertexMap.x + dx2, y: vertexMap.y + dy2}); + + var rightAngleSquare = L.polyline([vertex, corner1, corner2, corner3, vertex], { + color: '#ff4488', + weight: 2, + opacity: 0.7, + fill: false, + className: 'right-angle-square' + }); + editor._snap_indicator.addLayer(rightAngleSquare); + } }, _clear_snap_indicators: function() { diff --git a/src/c3nav/mapdata/api/updates.py b/src/c3nav/mapdata/api/updates.py index b7801e8c..541677fb 100644 --- a/src/c3nav/mapdata/api/updates.py +++ b/src/c3nav/mapdata/api/updates.py @@ -125,7 +125,7 @@ def fetch_updates(request, response: HttpResponse): } if cross_origin is None: result.update({ - 'user_data': request.user_data, + 'user_data': dict(request.user_data), }) if cross_origin is not None: diff --git a/src/c3nav/mapdata/utils/cache/local.py b/src/c3nav/mapdata/utils/cache/local.py index 32df7fb6..a8f4a910 100644 --- a/src/c3nav/mapdata/utils/cache/local.py +++ b/src/c3nav/mapdata/utils/cache/local.py @@ -32,7 +32,6 @@ class LocalCacheProxy: # not in our cache result = cache.get(key, default=NoneFromCache) if result is not NoneFromCache: - print("result", result, result is NoneFromCache) if self._items.get(None) is None: self._items.set(OrderedDict()) self._items.get()[key] = result diff --git a/start_db.sh b/start_db.sh index 3397af90..5a480395 100755 --- a/start_db.sh +++ b/start_db.sh @@ -29,10 +29,16 @@ elif [[ $# == 1 ]] && [[ $1 == "run" ]]; then python manage.py processupdates python manage.py runserver popd +elif [[ $# == 1 ]] && [[ $1 == "run_without_output" ]]; then + echo "Processing updates and running server without output" + pushd src 2>&1 > /dev/null + python manage.py processupdates 2>&1 | (grep -e "^ERROR" -e "^WARNING" -e "^HTTP" || true) + python manage.py runserver 2>&1 | (grep -e "^ERROR" -e "^WARNING" -e "^HTTP" || true) + popd 2>&1 > /dev/null elif [[ $# > 0 ]] && [[ $1 == "manage" ]]; then pushd src python manage.py "${@:2}" popd else - echo "Usage: $0 [stop|db|run|manage]" + echo "Usage: $0 [stop|db|run|run_without_output|manage]" fi