@@ -831,6 +831,254 @@ def api_elevation_profile(route_id):
831831 'total_descent' : round (total_descent , 1 ),
832832 'total_distance_m' : round (total_dist ),
833833 })
834+
835+
836+ # ─── Route Plan with Milestones (v7.4.0) ───────────────────────────
837+ #
838+ # Takes an existing saved route (ordered waypoint chain) + a pace + a
839+ # departure time, and returns:
840+ # - Timed milestones at every waypoint ("you'll be at X by 14:30")
841+ # - Sunrise / sunset along the way so the planner can see if they're
842+ # moving in darkness on any leg
843+ # - Resupply / shelter candidates from other waypoints within a
844+ # configurable corridor around each leg (doesn't have to be
845+ # on the route — just nearby)
846+ # - Per-person water + calorie estimate for the trip
847+ #
848+ # This is deterministic math on the existing elevation_profile pipeline.
849+ # No external services, no routing engine — we just walk the waypoint
850+ # chain the user already built.
851+
852+ def _haversine_km (lat1 , lng1 , lat2 , lng2 ):
853+ """Great-circle distance in km."""
854+ R = 6371.0
855+ p1 , p2 = math .radians (lat1 ), math .radians (lat2 )
856+ dlat = math .radians (lat2 - lat1 )
857+ dlng = math .radians (lng2 - lng1 )
858+ a = math .sin (dlat / 2 ) ** 2 + math .cos (p1 ) * math .cos (p2 ) * math .sin (dlng / 2 ) ** 2
859+ return 2 * R * math .asin (math .sqrt (a ))
860+
861+
862+ def _sun_times_for (lat , lng , when ):
863+ """Compute sunrise/sunset in UTC ISO-8601 for a given date + coord.
864+
865+ Simplified from tasks.py:_sun_times — uses the same NOAA algorithm
866+ but scoped to one day so the import graph stays trivial. Returns
867+ ``{sunrise_iso, sunset_iso}``, or ``None`` for polar day/night.
868+ """
869+ from datetime import datetime , timezone , timedelta
870+ # Convert date to Julian day (NOAA formula)
871+ y , m , d = when .year , when .month , when .day
872+ if m <= 2 :
873+ y , m = y - 1 , m + 12
874+ a = y // 100
875+ b = 2 - a + a // 4
876+ jd = int (365.25 * (y + 4716 )) + int (30.6001 * (m + 1 )) + d + b - 1524.5
877+
878+ def _zenith (rising ):
879+ # Official zenith for sunrise/sunset = 90° 50'
880+ zen = 90.833
881+ lng_hr = lng / 15.0
882+ t = jd + ((6 if rising else 18 ) - lng_hr ) / 24
883+ M = (0.9856 * t ) - 3.289
884+ L = M + (1.916 * math .sin (math .radians (M ))) + (0.020 * math .sin (math .radians (2 * M ))) + 282.634
885+ L = L % 360
886+ RA = math .degrees (math .atan (0.91764 * math .tan (math .radians (L )))) % 360
887+ Lq = math .floor (L / 90 ) * 90
888+ RAq = math .floor (RA / 90 ) * 90
889+ RA = (RA + (Lq - RAq )) / 15
890+ sinDec = 0.39782 * math .sin (math .radians (L ))
891+ cosDec = math .cos (math .asin (sinDec ))
892+ try :
893+ cosH = (math .cos (math .radians (zen )) - (sinDec * math .sin (math .radians (lat )))) / (cosDec * math .cos (math .radians (lat )))
894+ except ZeroDivisionError :
895+ return None
896+ if cosH > 1 or cosH < - 1 :
897+ return None # Polar day/night
898+ if rising :
899+ H = 360 - math .degrees (math .acos (cosH ))
900+ else :
901+ H = math .degrees (math .acos (cosH ))
902+ H = H / 15
903+ T = H + RA - (0.06571 * t ) - 6.622
904+ UT = (T - lng_hr ) % 24
905+ return UT
906+
907+ def _ut_to_iso (ut , day ):
908+ if ut is None :
909+ return None
910+ hours = int (ut )
911+ minutes = int ((ut - hours ) * 60 )
912+ minutes = max (0 , min (59 , minutes ))
913+ base = datetime (day .year , day .month , day .day , 0 , 0 , 0 , tzinfo = timezone .utc )
914+ return (base + timedelta (hours = hours , minutes = minutes )).isoformat ()
915+
916+ return {
917+ 'sunrise_iso' : _ut_to_iso (_zenith (True ), when ),
918+ 'sunset_iso' : _ut_to_iso (_zenith (False ), when ),
919+ }
920+
921+
922+ @maps_bp .route ('/api/maps/route-plan' , methods = ['POST' ])
923+ def api_maps_route_plan ():
924+ """Return a planned schedule for an existing route.
925+
926+ Body:
927+ {route_id, pace_kmh, depart_iso, corridor_km, people}
928+
929+ All fields optional except ``route_id``. Defaults:
930+ pace_kmh=5 (walking), depart_iso=now UTC, corridor_km=5, people=1
931+ """
932+ from datetime import datetime , timezone , timedelta
933+
934+ data = request .get_json () or {}
935+ route_id = data .get ('route_id' )
936+ if not isinstance (route_id , int ):
937+ try : route_id = int (route_id )
938+ except (TypeError , ValueError ):
939+ return jsonify ({'error' : 'route_id required' }), 400
940+
941+ try :
942+ pace_kmh = float (data .get ('pace_kmh' ) or 5.0 )
943+ except (TypeError , ValueError ):
944+ pace_kmh = 5.0
945+ pace_kmh = max (0.5 , min (pace_kmh , 120.0 )) # walking pace floor, vehicle ceiling
946+
947+ try :
948+ corridor_km = float (data .get ('corridor_km' ) or 5.0 )
949+ except (TypeError , ValueError ):
950+ corridor_km = 5.0
951+ corridor_km = max (0.5 , min (corridor_km , 50.0 ))
952+
953+ try :
954+ people = max (1 , min (int (data .get ('people' ) or 1 ), 50 ))
955+ except (TypeError , ValueError ):
956+ people = 1
957+
958+ depart_iso = data .get ('depart_iso' )
959+ try :
960+ depart = datetime .fromisoformat ((depart_iso or '' ).replace ('Z' , '+00:00' ))
961+ if depart .tzinfo is None :
962+ depart = depart .replace (tzinfo = timezone .utc )
963+ except (TypeError , ValueError ):
964+ depart = datetime .now (timezone .utc )
965+
966+ with db_session () as db :
967+ route = db .execute ('SELECT * FROM map_routes WHERE id = ?' , (route_id ,)).fetchone ()
968+ if not route :
969+ return jsonify ({'error' : 'Route not found' }), 404
970+ wp_ids = _safe_id_list (route ['waypoint_ids' ])
971+ if len (wp_ids ) < 2 :
972+ return jsonify ({'error' : 'Route needs at least 2 waypoints' }), 400
973+
974+ placeholders = ',' .join ('?' for _ in wp_ids )
975+ order_case = ' ' .join (f'WHEN id = ? THEN { i } ' for i , _ in enumerate (wp_ids ))
976+ waypoints = db .execute (
977+ f'SELECT id, name, lat, lng, elevation_m, category FROM waypoints '
978+ f'WHERE id IN ({ placeholders } ) ORDER BY CASE { order_case } END' ,
979+ wp_ids + wp_ids ,
980+ ).fetchall ()
981+ waypoints = [dict (w ) for w in waypoints ]
982+
983+ # All other waypoints (candidates for resupply/shelter corridor)
984+ all_wps = db .execute (
985+ 'SELECT id, name, lat, lng, category FROM waypoints LIMIT 2000'
986+ ).fetchall ()
987+ route_id_set = set (wp_ids )
988+ candidates = [dict (w ) for w in all_wps if w ['id' ] not in route_id_set ]
989+
990+ # Walk the chain — compute cumulative distance, ETA, leg-level info
991+ milestones = []
992+ cumulative_km = 0.0
993+ cumulative_ascent_m = 0.0
994+ prev = None
995+ for wp in waypoints :
996+ if prev is not None :
997+ seg_km = _haversine_km (prev ['lat' ], prev ['lng' ], wp ['lat' ], wp ['lng' ])
998+ cumulative_km += seg_km
999+ delta = (wp .get ('elevation_m' ) or 0 ) - (prev .get ('elevation_m' ) or 0 )
1000+ if delta > 0 :
1001+ cumulative_ascent_m += delta
1002+ eta = depart + timedelta (hours = (cumulative_km / pace_kmh ))
1003+ milestones .append ({
1004+ 'waypoint_id' : wp ['id' ],
1005+ 'name' : wp ['name' ],
1006+ 'lat' : wp ['lat' ],
1007+ 'lng' : wp ['lng' ],
1008+ 'category' : wp .get ('category' ) or '' ,
1009+ 'cumulative_km' : round (cumulative_km , 2 ),
1010+ 'eta_iso' : eta .isoformat (),
1011+ 'eta_hours_in' : round ((eta - depart ).total_seconds () / 3600 , 2 ),
1012+ })
1013+ prev = wp
1014+
1015+ total_km = round (cumulative_km , 2 )
1016+ total_hours = round (cumulative_km / pace_kmh , 2 )
1017+
1018+ # Find nearby candidates — within corridor_km of ANY waypoint on the route
1019+ nearby = []
1020+ for cand in candidates :
1021+ best_km = None
1022+ best_wp = None
1023+ for wp in waypoints :
1024+ d = _haversine_km (wp ['lat' ], wp ['lng' ], cand ['lat' ], cand ['lng' ])
1025+ if best_km is None or d < best_km :
1026+ best_km = d
1027+ best_wp = wp
1028+ if best_km is not None and best_km <= corridor_km :
1029+ nearby .append ({
1030+ 'waypoint_id' : cand ['id' ],
1031+ 'name' : cand ['name' ],
1032+ 'category' : cand .get ('category' ) or '' ,
1033+ 'lat' : cand ['lat' ],
1034+ 'lng' : cand ['lng' ],
1035+ 'nearest_route_wp' : best_wp ['name' ] if best_wp else '' ,
1036+ 'distance_from_route_km' : round (best_km , 2 ),
1037+ })
1038+ nearby .sort (key = lambda c : c ['distance_from_route_km' ])
1039+ nearby = nearby [:30 ]
1040+
1041+ # Sunrise / sunset overlay — use the start coord on the depart date,
1042+ # and the end coord on the arrival date. For most routes these are
1043+ # the same day; two days of sun data is enough signal.
1044+ sun = []
1045+ days_spanned = set ([depart .date ()])
1046+ arrive_at = depart + timedelta (hours = total_hours )
1047+ days_spanned .add (arrive_at .date ())
1048+ start_lat , start_lng = waypoints [0 ]['lat' ], waypoints [0 ]['lng' ]
1049+ for day in sorted (days_spanned ):
1050+ st = _sun_times_for (start_lat , start_lng , datetime .combine (day , datetime .min .time ()))
1051+ sun .append ({'date' : day .isoformat (), 'lat' : start_lat , 'lng' : start_lng , ** st })
1052+
1053+ # Resource burn estimate — reuse the Kit Builder water model without
1054+ # importing the blueprint (keeps maps → kit_builder coupling clean).
1055+ # Assume temperate + bug-out for pace-based movement.
1056+ water_l_per_person = 3.0 * max (1.0 , total_hours / 24 )
1057+ kcal_per_person = 3500 * max (1.0 , total_hours / 24 )
1058+
1059+ return jsonify ({
1060+ 'route_id' : route_id ,
1061+ 'route_name' : route ['name' ],
1062+ 'params' : {
1063+ 'pace_kmh' : pace_kmh ,
1064+ 'corridor_km' : corridor_km ,
1065+ 'people' : people ,
1066+ 'depart_iso' : depart .isoformat (),
1067+ },
1068+ 'milestones' : milestones ,
1069+ 'totals' : {
1070+ 'distance_km' : total_km ,
1071+ 'duration_hours' : total_hours ,
1072+ 'ascent_m' : round (cumulative_ascent_m , 0 ),
1073+ 'water_l_total' : round (water_l_per_person * people , 1 ),
1074+ 'kcal_total' : round (kcal_per_person * people , 0 ),
1075+ 'arrive_iso' : arrive_at .isoformat (),
1076+ },
1077+ 'nearby_waypoints' : nearby ,
1078+ 'sun' : sun ,
1079+ })
1080+
1081+
8341082@maps_bp .route ('/api/maps/annotations' )
8351083def api_map_annotations_list ():
8361084 try :
0 commit comments