Spaces:
Running
Running
Sync DemoService aa0ec3b35f9dd33dbf0d56e23f5af0ad1cac70f4
Browse files- api/ai-service/app/api/v1/endpoints/route_generation.py +34 -8
- api/ai-service/app/services/walking_route_fallback.py +11 -6
- api/ai-service/tests/test_route_generation_recovery.py +23 -0
- api/ai-service/tests/test_walking_route_fallback.py +18 -0
- main/app/route-results/[id]/page.tsx +1 -1
- main/app/route-results/page.tsx +8 -21
- main/app/route-search/page.tsx +5 -70
- router/ai/scripts/clip_osm_region_test.py +20 -8
api/ai-service/app/api/v1/endpoints/route_generation.py
CHANGED
|
@@ -51,6 +51,7 @@ class RouteGenerateRequest(BaseModel):
|
|
| 51 |
selectedLabel: str | None = None
|
| 52 |
selectedTopK: int = Field(default=0, ge=0, le=20)
|
| 53 |
fastRoads: bool = True
|
|
|
|
| 54 |
generationId: str | None = Field(default=None, max_length=200)
|
| 55 |
# The route-search page knows the centre of every selectable region. Keep
|
| 56 |
# it as a recovery coordinate so a temporary geocoder failure never turns
|
|
@@ -402,6 +403,7 @@ def _run_fast_road_pipeline(
|
|
| 402 |
route_count: int,
|
| 403 |
target_distance_km: float,
|
| 404 |
selected_label: str | None,
|
|
|
|
| 405 |
) -> dict[str, Any]:
|
| 406 |
"""Create real walkable-road routes without per-route CLIP inference.
|
| 407 |
|
|
@@ -425,12 +427,17 @@ def _run_fast_road_pipeline(
|
|
| 425 |
# failed request before it can return any usable road loop.
|
| 426 |
examples = getattr(module, "GPX_SHAPE_TEMPLATES", {}).get(selected_label, [])
|
| 427 |
candidate_pool = min(16 if examples else 8, max(route_count, route_count + (11 if examples else 1)))
|
| 428 |
-
|
| 429 |
graph=graph,
|
| 430 |
route_count=candidate_pool,
|
| 431 |
target_distance_m=target_distance_km * 1000.0,
|
| 432 |
selected_label=selected_label,
|
| 433 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
# A selected shape is a preference, not a reason to leave the runner with
|
| 435 |
# no route. Sparse neighbourhoods and long distances do not always have
|
| 436 |
# enough roads to satisfy the strict shape-quality gate. In that case
|
|
@@ -438,7 +445,7 @@ def _run_fast_road_pipeline(
|
|
| 438 |
# the response marks them as non-art-ready so the UI can state this
|
| 439 |
# honestly instead of showing a red generation failure.
|
| 440 |
generation_mode = "fast_walk_roads"
|
| 441 |
-
if not candidates and selected_label:
|
| 442 |
candidates = build_candidates(
|
| 443 |
graph=graph,
|
| 444 |
route_count=candidate_pool,
|
|
@@ -447,7 +454,7 @@ def _run_fast_road_pipeline(
|
|
| 447 |
tolerance_ratio=0.60,
|
| 448 |
)
|
| 449 |
generation_mode = "fast_walk_roads_nearby_fallback"
|
| 450 |
-
if not candidates:
|
| 451 |
# Rural/island road graphs may have no acceptable drawing or loop.
|
| 452 |
# A return trip along existing walking edges is still a real route.
|
| 453 |
candidates = _nearby_road_return_routes(
|
|
@@ -502,6 +509,9 @@ def _run_fast_road_pipeline(
|
|
| 502 |
abs(route["length_km"] - target_distance_km),
|
| 503 |
))
|
| 504 |
routes = routes[:route_count]
|
|
|
|
|
|
|
|
|
|
| 505 |
if not routes:
|
| 506 |
raise RuntimeError("Walkable road routes did not contain usable map coordinates.")
|
| 507 |
|
|
@@ -511,7 +521,8 @@ def _run_fast_road_pipeline(
|
|
| 511 |
"candidate_count": len(candidates),
|
| 512 |
"requested_count": route_count,
|
| 513 |
"generated_count": len(routes),
|
| 514 |
-
"target_distance_km": target_distance_km,
|
|
|
|
| 515 |
"radius_km": radius_km,
|
| 516 |
"labels": [selected_label] if selected_label else [],
|
| 517 |
"selected_label": selected_label,
|
|
@@ -673,12 +684,12 @@ def generate_routes(payload: RouteGenerateRequest) -> dict[str, Any]:
|
|
| 673 |
|
| 674 |
route_count_cap = 20 if use_current else 100
|
| 675 |
route_count = min(route_count_cap, max(1, payload.routeCount))
|
| 676 |
-
target_distance_km = max(0.5, payload.targetDistanceKm)
|
| 677 |
# A 1.5 km graph is enough for ordinary short courses, but it cannot make
|
| 678 |
# a truthful 16~30 km route. Grow the road graph only as much as the
|
| 679 |
# requested distance needs, up to the request model's safe 10 km limit.
|
| 680 |
required_radius_km = target_distance_km / 5.5
|
| 681 |
-
radius_km = min(10.0, max(1.5, min(10.0, payload.radiusKm), required_radius_km))
|
| 682 |
|
| 683 |
if payload.fastRoads:
|
| 684 |
module = None
|
|
@@ -693,6 +704,7 @@ def generate_routes(payload: RouteGenerateRequest) -> dict[str, Any]:
|
|
| 693 |
route_count=route_count,
|
| 694 |
target_distance_km=target_distance_km,
|
| 695 |
selected_label=selected_label,
|
|
|
|
| 696 |
)
|
| 697 |
except Exception as primary_exc:
|
| 698 |
logger.exception("fast walk-road route generation failed")
|
|
@@ -722,22 +734,36 @@ def generate_routes(payload: RouteGenerateRequest) -> dict[str, Any]:
|
|
| 722 |
route_count=route_count,
|
| 723 |
target_distance_km=target_distance_km,
|
| 724 |
selected_label=selected_label,
|
|
|
|
| 725 |
)
|
| 726 |
except Exception:
|
| 727 |
logger.exception("fallback-centre walk-road generation failed")
|
| 728 |
# Provider outages must reach this branch too. Previously the
|
| 729 |
# early skip above also skipped recovery and returned HTTP 503.
|
| 730 |
template_fn = getattr(module, "guided_waypoints_for_label", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
real_roads = generate_walking_fallback(
|
| 732 |
center_lat=fallback_lat, center_lon=fallback_lon,
|
| 733 |
route_count=route_count, target_distance_km=target_distance_km,
|
| 734 |
selected_label=selected_label, place=payload.place,
|
| 735 |
-
template=
|
| 736 |
-
templates=
|
| 737 |
generation_id=payload.generationId,
|
|
|
|
| 738 |
)
|
| 739 |
if real_roads:
|
| 740 |
return real_roads
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
return _coordinate_fallback_routes(
|
| 742 |
center_lat=fallback_lat,
|
| 743 |
center_lon=fallback_lon,
|
|
|
|
| 51 |
selectedLabel: str | None = None
|
| 52 |
selectedTopK: int = Field(default=0, ge=0, le=20)
|
| 53 |
fastRoads: bool = True
|
| 54 |
+
autoDistance: bool = False
|
| 55 |
generationId: str | None = Field(default=None, max_length=200)
|
| 56 |
# The route-search page knows the centre of every selectable region. Keep
|
| 57 |
# it as a recovery coordinate so a temporary geocoder failure never turns
|
|
|
|
| 403 |
route_count: int,
|
| 404 |
target_distance_km: float,
|
| 405 |
selected_label: str | None,
|
| 406 |
+
auto_distance: bool = False,
|
| 407 |
) -> dict[str, Any]:
|
| 408 |
"""Create real walkable-road routes without per-route CLIP inference.
|
| 409 |
|
|
|
|
| 427 |
# failed request before it can return any usable road loop.
|
| 428 |
examples = getattr(module, "GPX_SHAPE_TEMPLATES", {}).get(selected_label, [])
|
| 429 |
candidate_pool = min(16 if examples else 8, max(route_count, route_count + (11 if examples else 1)))
|
| 430 |
+
candidate_options = dict(
|
| 431 |
graph=graph,
|
| 432 |
route_count=candidate_pool,
|
| 433 |
target_distance_m=target_distance_km * 1000.0,
|
| 434 |
selected_label=selected_label,
|
| 435 |
)
|
| 436 |
+
if auto_distance:
|
| 437 |
+
candidate_options["auto_distance"] = True
|
| 438 |
+
candidates = build_candidates(**candidate_options)
|
| 439 |
+
if auto_distance and selected_label:
|
| 440 |
+
candidates = [route for route in candidates if route.get("generation_mode") == "label_guided"]
|
| 441 |
# A selected shape is a preference, not a reason to leave the runner with
|
| 442 |
# no route. Sparse neighbourhoods and long distances do not always have
|
| 443 |
# enough roads to satisfy the strict shape-quality gate. In that case
|
|
|
|
| 445 |
# the response marks them as non-art-ready so the UI can state this
|
| 446 |
# honestly instead of showing a red generation failure.
|
| 447 |
generation_mode = "fast_walk_roads"
|
| 448 |
+
if not candidates and selected_label and not auto_distance:
|
| 449 |
candidates = build_candidates(
|
| 450 |
graph=graph,
|
| 451 |
route_count=candidate_pool,
|
|
|
|
| 454 |
tolerance_ratio=0.60,
|
| 455 |
)
|
| 456 |
generation_mode = "fast_walk_roads_nearby_fallback"
|
| 457 |
+
if not candidates and not auto_distance:
|
| 458 |
# Rural/island road graphs may have no acceptable drawing or loop.
|
| 459 |
# A return trip along existing walking edges is still a real route.
|
| 460 |
candidates = _nearby_road_return_routes(
|
|
|
|
| 509 |
abs(route["length_km"] - target_distance_km),
|
| 510 |
))
|
| 511 |
routes = routes[:route_count]
|
| 512 |
+
elif auto_distance:
|
| 513 |
+
routes.sort(key=lambda route: -route["top1_score"])
|
| 514 |
+
routes = routes[:route_count]
|
| 515 |
if not routes:
|
| 516 |
raise RuntimeError("Walkable road routes did not contain usable map coordinates.")
|
| 517 |
|
|
|
|
| 521 |
"candidate_count": len(candidates),
|
| 522 |
"requested_count": route_count,
|
| 523 |
"generated_count": len(routes),
|
| 524 |
+
"target_distance_km": routes[0]["length_km"] if auto_distance else target_distance_km,
|
| 525 |
+
"auto_distance": auto_distance,
|
| 526 |
"radius_km": radius_km,
|
| 527 |
"labels": [selected_label] if selected_label else [],
|
| 528 |
"selected_label": selected_label,
|
|
|
|
| 684 |
|
| 685 |
route_count_cap = 20 if use_current else 100
|
| 686 |
route_count = min(route_count_cap, max(1, payload.routeCount))
|
| 687 |
+
target_distance_km = 6.0 if payload.autoDistance else max(0.5, payload.targetDistanceKm)
|
| 688 |
# A 1.5 km graph is enough for ordinary short courses, but it cannot make
|
| 689 |
# a truthful 16~30 km route. Grow the road graph only as much as the
|
| 690 |
# requested distance needs, up to the request model's safe 10 km limit.
|
| 691 |
required_radius_km = target_distance_km / 5.5
|
| 692 |
+
radius_km = min(10.0, max(5.0 if payload.autoDistance else 1.5, min(10.0, payload.radiusKm), required_radius_km))
|
| 693 |
|
| 694 |
if payload.fastRoads:
|
| 695 |
module = None
|
|
|
|
| 704 |
route_count=route_count,
|
| 705 |
target_distance_km=target_distance_km,
|
| 706 |
selected_label=selected_label,
|
| 707 |
+
auto_distance=payload.autoDistance,
|
| 708 |
)
|
| 709 |
except Exception as primary_exc:
|
| 710 |
logger.exception("fast walk-road route generation failed")
|
|
|
|
| 734 |
route_count=route_count,
|
| 735 |
target_distance_km=target_distance_km,
|
| 736 |
selected_label=selected_label,
|
| 737 |
+
auto_distance=payload.autoDistance,
|
| 738 |
)
|
| 739 |
except Exception:
|
| 740 |
logger.exception("fallback-centre walk-road generation failed")
|
| 741 |
# Provider outages must reach this branch too. Previously the
|
| 742 |
# early skip above also skipped recovery and returned HTTP 503.
|
| 743 |
template_fn = getattr(module, "guided_waypoints_for_label", None)
|
| 744 |
+
guided_template = template_fn(selected_label) if callable(template_fn) else None
|
| 745 |
+
gpx_templates = getattr(module, "GPX_SHAPE_TEMPLATES", {}).get(selected_label, [])
|
| 746 |
+
if payload.autoDistance and selected_label and not guided_template and not gpx_templates:
|
| 747 |
+
raise HTTPException(
|
| 748 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 749 |
+
detail="선택한 모양의 경로 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.",
|
| 750 |
+
)
|
| 751 |
real_roads = generate_walking_fallback(
|
| 752 |
center_lat=fallback_lat, center_lon=fallback_lon,
|
| 753 |
route_count=route_count, target_distance_km=target_distance_km,
|
| 754 |
selected_label=selected_label, place=payload.place,
|
| 755 |
+
template=guided_template,
|
| 756 |
+
templates=gpx_templates,
|
| 757 |
generation_id=payload.generationId,
|
| 758 |
+
auto_distance=payload.autoDistance,
|
| 759 |
)
|
| 760 |
if real_roads:
|
| 761 |
return real_roads
|
| 762 |
+
if payload.autoDistance:
|
| 763 |
+
raise HTTPException(
|
| 764 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 765 |
+
detail="선택한 지역에서 해당 모양에 가까운 실제 보행 경로를 찾지 못했습니다. 다시 찾기를 눌러주세요.",
|
| 766 |
+
)
|
| 767 |
return _coordinate_fallback_routes(
|
| 768 |
center_lat=fallback_lat,
|
| 769 |
center_lon=fallback_lon,
|
api/ai-service/app/services/walking_route_fallback.py
CHANGED
|
@@ -80,8 +80,9 @@ def generate_walking_fallback(
|
|
| 80 |
template: list[tuple[float, float]] | None = None,
|
| 81 |
templates: list[list[tuple[float, float]]] | None = None,
|
| 82 |
generation_id: str | None = None,
|
|
|
|
| 83 |
) -> dict[str, Any] | None:
|
| 84 |
-
key = (round(center_lat, 5), round(center_lon, 5), route_count, target_distance_km, selected_label, generation_id)
|
| 85 |
with _LOCK:
|
| 86 |
cached = _CACHE.get(key)
|
| 87 |
if cached and time.monotonic() - cached[0] < 6 * 3600:
|
|
@@ -110,10 +111,13 @@ def generate_walking_fallback(
|
|
| 110 |
perimeter = sum(math.hypot(b[0]-a[0], b[1]-a[1]) for a, b in zip(outline, outline[1:]))
|
| 111 |
if perimeter <= 0:
|
| 112 |
continue
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
| 114 |
angle = generation_angle(generation_id) + attempt * math.pi * (3 - math.sqrt(5))
|
| 115 |
# Shorter retries also work near a coast or on a small island.
|
| 116 |
-
size = scale * (1 if attempt < 3 else 0.5 if attempt < 5 else 0.2)
|
| 117 |
locations = []
|
| 118 |
for x, y in outline:
|
| 119 |
dx, dy = (x-outline[0][0]) * size, (y-outline[0][1]) * size
|
|
@@ -131,7 +135,7 @@ def generate_walking_fallback(
|
|
| 131 |
trip = response.json().get("trip", {})
|
| 132 |
summary = trip.get("summary", {})
|
| 133 |
length = float(summary.get("length", 0))
|
| 134 |
-
if summary.get("has_ferry") or not math.isfinite(length) or length <= 0:
|
| 135 |
continue
|
| 136 |
coords = []
|
| 137 |
for leg in trip.get("legs", []):
|
|
@@ -165,11 +169,12 @@ def generate_walking_fallback(
|
|
| 165 |
if not routes:
|
| 166 |
return None
|
| 167 |
routes.sort(key=lambda route: (-route["route_quality"].get("gpx_similarity", 0.0),
|
| 168 |
-
abs(route["length_km"]-target_distance_km)))
|
| 169 |
routes = routes[:route_count]
|
| 170 |
result = {"place":place or f"{center_lat:.6f},{center_lon:.6f}","source":"point",
|
| 171 |
"candidate_count":len(routes),"requested_count":route_count,"generated_count":len(routes),
|
| 172 |
-
"target_distance_km":
|
|
|
|
| 173 |
"selected_label":selected_label,"selected_top_k":0,"routes":routes,"best_route":routes[0],
|
| 174 |
"generation_mode":"independent_walk_roads",
|
| 175 |
"fallback_notice":"주변 실제 보행 도로를 이용해 경로를 생성했습니다. 도로 구조에 따라 선택한 모양과 목표 거리는 달라질 수 있습니다."}
|
|
|
|
| 80 |
template: list[tuple[float, float]] | None = None,
|
| 81 |
templates: list[list[tuple[float, float]]] | None = None,
|
| 82 |
generation_id: str | None = None,
|
| 83 |
+
auto_distance: bool = False,
|
| 84 |
) -> dict[str, Any] | None:
|
| 85 |
+
key = (round(center_lat, 5), round(center_lon, 5), route_count, target_distance_km, selected_label, generation_id, auto_distance)
|
| 86 |
with _LOCK:
|
| 87 |
cached = _CACHE.get(key)
|
| 88 |
if cached and time.monotonic() - cached[0] < 6 * 3600:
|
|
|
|
| 111 |
perimeter = sum(math.hypot(b[0]-a[0], b[1]-a[1]) for a, b in zip(outline, outline[1:]))
|
| 112 |
if perimeter <= 0:
|
| 113 |
continue
|
| 114 |
+
# Auto mode searches several sizes of the same GPX drawing. The
|
| 115 |
+
# result's measured length, rather than a hidden default, is shown.
|
| 116 |
+
requested_size_km = (3, 5, 8, 12, 16, 20, 6, 10)[attempt] if auto_distance else target_distance_km
|
| 117 |
+
scale = requested_size_km * 1000 / perimeter / 1.2
|
| 118 |
angle = generation_angle(generation_id) + attempt * math.pi * (3 - math.sqrt(5))
|
| 119 |
# Shorter retries also work near a coast or on a small island.
|
| 120 |
+
size = scale if auto_distance else scale * (1 if attempt < 3 else 0.5 if attempt < 5 else 0.2)
|
| 121 |
locations = []
|
| 122 |
for x, y in outline:
|
| 123 |
dx, dy = (x-outline[0][0]) * size, (y-outline[0][1]) * size
|
|
|
|
| 135 |
trip = response.json().get("trip", {})
|
| 136 |
summary = trip.get("summary", {})
|
| 137 |
length = float(summary.get("length", 0))
|
| 138 |
+
if summary.get("has_ferry") or not math.isfinite(length) or length <= 0 or (auto_distance and not 1.5 <= length <= 20):
|
| 139 |
continue
|
| 140 |
coords = []
|
| 141 |
for leg in trip.get("legs", []):
|
|
|
|
| 169 |
if not routes:
|
| 170 |
return None
|
| 171 |
routes.sort(key=lambda route: (-route["route_quality"].get("gpx_similarity", 0.0),
|
| 172 |
+
0 if auto_distance else abs(route["length_km"]-target_distance_km)))
|
| 173 |
routes = routes[:route_count]
|
| 174 |
result = {"place":place or f"{center_lat:.6f},{center_lon:.6f}","source":"point",
|
| 175 |
"candidate_count":len(routes),"requested_count":route_count,"generated_count":len(routes),
|
| 176 |
+
"target_distance_km":routes[0]["length_km"] if auto_distance else target_distance_km,
|
| 177 |
+
"auto_distance":auto_distance,"labels":[selected_label] if selected_label else [],
|
| 178 |
"selected_label":selected_label,"selected_top_k":0,"routes":routes,"best_route":routes[0],
|
| 179 |
"generation_mode":"independent_walk_roads",
|
| 180 |
"fallback_notice":"주변 실제 보행 도로를 이용해 경로를 생성했습니다. 도로 구조에 따라 선택한 모양과 목표 거리는 달라질 수 있습니다."}
|
api/ai-service/tests/test_route_generation_recovery.py
CHANGED
|
@@ -113,6 +113,29 @@ def test_real_independent_roads_are_preferred_to_estimated_preview(pipeline):
|
|
| 113 |
assert result == expected
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def test_gpx_guided_fast_routes_are_ranked_by_actual_road_similarity(monkeypatch):
|
| 117 |
candidates = [
|
| 118 |
{"nodes": [1, 2], "length_m": 5900, "shape_quality": 0.8, "is_loop": True},
|
|
|
|
| 113 |
assert result == expected
|
| 114 |
|
| 115 |
|
| 116 |
+
def test_auto_distance_passes_through_to_real_road_fallback(pipeline):
|
| 117 |
+
pipeline.setattr(routes, "_load_pipeline_module", lambda _: SimpleNamespace(
|
| 118 |
+
guided_waypoints_for_label=lambda _label: [(0, 0), (1, 0), (1, 1), (0, 0)]))
|
| 119 |
+
pipeline.setattr(routes, "_run_fast_road_pipeline", Mock(side_effect=RuntimeError("All Overpass endpoints failed")))
|
| 120 |
+
expected = {"generation_mode": "independent_walk_roads", "routes": [{"estimated_route": False}]}
|
| 121 |
+
walking = Mock(return_value=expected)
|
| 122 |
+
pipeline.setattr(routes, "generate_walking_fallback", walking)
|
| 123 |
+
result = routes.generate_routes(routes.RouteGenerateRequest(
|
| 124 |
+
source="current", latitude=37.5, longitude=127, selectedLabel="dog", labels=["dog"], autoDistance=True))
|
| 125 |
+
assert result == expected
|
| 126 |
+
assert walking.call_args.kwargs["auto_distance"] is True
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_auto_distance_never_returns_estimated_coordinates(pipeline):
|
| 130 |
+
pipeline.setattr(routes, "_load_pipeline_module", lambda _: SimpleNamespace(
|
| 131 |
+
guided_waypoints_for_label=lambda _label: [(0, 0), (1, 0), (1, 1), (0, 0)]))
|
| 132 |
+
pipeline.setattr(routes, "_run_fast_road_pipeline", Mock(side_effect=RuntimeError("All Overpass endpoints failed")))
|
| 133 |
+
with pytest.raises(routes.HTTPException) as error:
|
| 134 |
+
routes.generate_routes(routes.RouteGenerateRequest(
|
| 135 |
+
source="current", latitude=37.5, longitude=127, selectedLabel="dog", labels=["dog"], autoDistance=True))
|
| 136 |
+
assert error.value.status_code == 503
|
| 137 |
+
|
| 138 |
+
|
| 139 |
def test_gpx_guided_fast_routes_are_ranked_by_actual_road_similarity(monkeypatch):
|
| 140 |
candidates = [
|
| 141 |
{"nodes": [1, 2], "length_m": 5900, "shape_quality": 0.8, "is_loop": True},
|
api/ai-service/tests/test_walking_route_fallback.py
CHANGED
|
@@ -139,3 +139,21 @@ def test_gpx_fallback_checks_multiple_examples_and_returns_best_match(monkeypatc
|
|
| 139 |
assert all(location['radius'] == 400 for locations_in_request in locations for location in locations_in_request)
|
| 140 |
assert result['best_route']['route_quality']['gpx_similarity'] == 0.9
|
| 141 |
assert result['generated_count'] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
assert all(location['radius'] == 400 for locations_in_request in locations for location in locations_in_request)
|
| 140 |
assert result['best_route']['route_quality']['gpx_similarity'] == 0.9
|
| 141 |
assert result['generated_count'] == 1
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_auto_distance_tries_multiple_sizes_and_reports_measured_length(monkeypatch):
|
| 145 |
+
import json
|
| 146 |
+
response_obj = SimpleNamespace(raise_for_status=lambda: None,
|
| 147 |
+
json=lambda: {'trip': {'summary': {'length': 4.2, 'has_ferry': False},
|
| 148 |
+
'legs': [{'shape': encode([[37.5, 127.0], [37.501, 127.002], [37.5, 127.0]])}]}})
|
| 149 |
+
get = Mock(return_value=response_obj)
|
| 150 |
+
monkeypatch.setattr(requests, 'get', get)
|
| 151 |
+
examples = [[(0, 0), (1, 0), (1, 1), (0, 0)]]
|
| 152 |
+
result = walking.generate_walking_fallback(**options(), templates=examples,
|
| 153 |
+
generation_id='auto-sizes', auto_distance=True)
|
| 154 |
+
assert result['target_distance_km'] == 4.2
|
| 155 |
+
assert result['auto_distance'] is True
|
| 156 |
+
assert get.call_count == 4
|
| 157 |
+
first = json.loads(get.call_args_list[0].kwargs['params']['json'])['locations']
|
| 158 |
+
second = json.loads(get.call_args_list[1].kwargs['params']['json'])['locations']
|
| 159 |
+
assert first[1] != second[1]
|
main/app/route-results/[id]/page.tsx
CHANGED
|
@@ -148,7 +148,7 @@ function GeneratedRouteDetailPageContent() {
|
|
| 148 |
source: "place",
|
| 149 |
label: record.routeLabel,
|
| 150 |
routeCount: "8",
|
| 151 |
-
|
| 152 |
selectedTopK: "8",
|
| 153 |
radiusKm: "2.5",
|
| 154 |
});
|
|
|
|
| 148 |
source: "place",
|
| 149 |
label: record.routeLabel,
|
| 150 |
routeCount: "8",
|
| 151 |
+
autoDistance: "1",
|
| 152 |
selectedTopK: "8",
|
| 153 |
radiusKm: "2.5",
|
| 154 |
});
|
main/app/route-results/page.tsx
CHANGED
|
@@ -140,7 +140,6 @@ function RouteResultsPageContent() {
|
|
| 140 |
const initialLongitude = parseNumberParam(searchParams.get("longitude"));
|
| 141 |
const initialRadiusKm = parseNumberParam(searchParams.get("radiusKm"));
|
| 142 |
const initialCount = parseNumberParam(searchParams.get("routeCount"));
|
| 143 |
-
const initialDistance = parseNumberParam(searchParams.get("targetDistanceKm"));
|
| 144 |
const initialSelectedTopK = parseNumberParam(searchParams.get("selectedTopK"));
|
| 145 |
const fallbackLatitude = parseNumberParam(searchParams.get("fallbackLatitude"));
|
| 146 |
const fallbackLongitude = parseNumberParam(searchParams.get("fallbackLongitude"));
|
|
@@ -148,7 +147,6 @@ function RouteResultsPageContent() {
|
|
| 148 |
|
| 149 |
const [place, setPlace] = useState(initialPlace);
|
| 150 |
const [routeCountInput, setRouteCountInput] = useState(Number.isFinite(initialCount) ? String(initialCount) : "8");
|
| 151 |
-
const [targetDistanceInput, setTargetDistanceInput] = useState(Number.isFinite(initialDistance) ? String(initialDistance) : "5");
|
| 152 |
const [loading, setLoading] = useState(false);
|
| 153 |
const [error, setError] = useState<string | null>(null);
|
| 154 |
const [routeQualityNotice, setRouteQualityNotice] = useState<string | null>(null);
|
|
@@ -168,16 +166,15 @@ function RouteResultsPageContent() {
|
|
| 168 |
const regenerateRoutes = () => {
|
| 169 |
const requestPlace = place.trim();
|
| 170 |
const count = Number(routeCountInput);
|
| 171 |
-
|
| 172 |
-
if (!requestPlace || !Number.isFinite(count) || count < 5 || count > 100 ||
|
| 173 |
-
!Number.isFinite(distance) || distance < 1 || distance > 30) {
|
| 174 |
void fetchRoutes(); // Keep the same field validation messages.
|
| 175 |
return;
|
| 176 |
}
|
| 177 |
const params = new URLSearchParams(searchParams.toString());
|
| 178 |
params.set("place", requestPlace);
|
| 179 |
params.set("routeCount", String(count));
|
| 180 |
-
params.
|
|
|
|
| 181 |
if (requestPlace !== initialPlace.trim()) {
|
| 182 |
params.set("source", "place");
|
| 183 |
for (const key of ["latitude", "longitude", "fallbackLatitude", "fallbackLongitude"]) params.delete(key);
|
|
@@ -192,7 +189,6 @@ function RouteResultsPageContent() {
|
|
| 192 |
|
| 193 |
const fetchRoutes = async () => {
|
| 194 |
const routeCount = Number(routeCountInput);
|
| 195 |
-
const targetDistanceKm = Number(targetDistanceInput);
|
| 196 |
const requestPlace = place.trim();
|
| 197 |
const placeWasEdited = requestPlace !== initialPlace.trim();
|
| 198 |
|
|
@@ -205,10 +201,6 @@ function RouteResultsPageContent() {
|
|
| 205 |
setError("경로 수는 5~100 사이로 입력해주세요.");
|
| 206 |
return;
|
| 207 |
}
|
| 208 |
-
if (!Number.isFinite(targetDistanceKm) || targetDistanceKm < 1 || targetDistanceKm > 30) {
|
| 209 |
-
setError("목표 거리는 1~30km 사이로 입력해주세요.");
|
| 210 |
-
return;
|
| 211 |
-
}
|
| 212 |
|
| 213 |
activeRequest.current?.abort();
|
| 214 |
const controller = new AbortController();
|
|
@@ -250,7 +242,7 @@ function RouteResultsPageContent() {
|
|
| 250 |
longitude: placeWasEdited ? null : longitude,
|
| 251 |
radiusKm,
|
| 252 |
routeCount,
|
| 253 |
-
|
| 254 |
labels: AVAILABLE_LABELS,
|
| 255 |
selectedLabel,
|
| 256 |
selectedTopK,
|
|
@@ -331,8 +323,7 @@ function RouteResultsPageContent() {
|
|
| 331 |
// Store under the request's own key. A separate effect used to write
|
| 332 |
// the previous result into the newly selected location's cache.
|
| 333 |
try {
|
| 334 |
-
if (!placeWasEdited && routeCount === (Number.isFinite(initialCount) ? initialCount : 8)
|
| 335 |
-
&& targetDistanceKm === (Number.isFinite(initialDistance) ? initialDistance : 5)) {
|
| 336 |
window.sessionStorage.setItem(cacheKey, JSON.stringify(parsed));
|
| 337 |
}
|
| 338 |
} catch {
|
|
@@ -480,7 +471,7 @@ function RouteResultsPageContent() {
|
|
| 480 |
/>
|
| 481 |
|
| 482 |
<SectionCard>
|
| 483 |
-
<div className="grid gap-3 sm:grid-cols-[
|
| 484 |
<label className="grid gap-2">
|
| 485 |
<span className="rts-label">지역</span>
|
| 486 |
<input value={place} onChange={(event) => setPlace(event.target.value)} className="rts-input" />
|
|
@@ -489,10 +480,6 @@ function RouteResultsPageContent() {
|
|
| 489 |
<span className="rts-label">경로 수</span>
|
| 490 |
<input type="number" min={5} max={100} value={routeCountInput} onChange={(event) => setRouteCountInput(event.target.value)} className="rts-input" />
|
| 491 |
</label>
|
| 492 |
-
<label className="grid gap-2">
|
| 493 |
-
<span className="rts-label">목표 거리</span>
|
| 494 |
-
<input type="number" min={1} max={30} step={0.5} value={targetDistanceInput} onChange={(event) => setTargetDistanceInput(event.target.value)} className="rts-input" />
|
| 495 |
-
</label>
|
| 496 |
</div>
|
| 497 |
|
| 498 |
{error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null}
|
|
@@ -517,8 +504,8 @@ function RouteResultsPageContent() {
|
|
| 517 |
<span>추천 경로</span>
|
| 518 |
</div>
|
| 519 |
<div>
|
| 520 |
-
<strong>{formatDistance(result.
|
| 521 |
-
<span>
|
| 522 |
</div>
|
| 523 |
<div>
|
| 524 |
<strong>{formatGeneratedRouteLabel(selectedLabel)}</strong>
|
|
|
|
| 140 |
const initialLongitude = parseNumberParam(searchParams.get("longitude"));
|
| 141 |
const initialRadiusKm = parseNumberParam(searchParams.get("radiusKm"));
|
| 142 |
const initialCount = parseNumberParam(searchParams.get("routeCount"));
|
|
|
|
| 143 |
const initialSelectedTopK = parseNumberParam(searchParams.get("selectedTopK"));
|
| 144 |
const fallbackLatitude = parseNumberParam(searchParams.get("fallbackLatitude"));
|
| 145 |
const fallbackLongitude = parseNumberParam(searchParams.get("fallbackLongitude"));
|
|
|
|
| 147 |
|
| 148 |
const [place, setPlace] = useState(initialPlace);
|
| 149 |
const [routeCountInput, setRouteCountInput] = useState(Number.isFinite(initialCount) ? String(initialCount) : "8");
|
|
|
|
| 150 |
const [loading, setLoading] = useState(false);
|
| 151 |
const [error, setError] = useState<string | null>(null);
|
| 152 |
const [routeQualityNotice, setRouteQualityNotice] = useState<string | null>(null);
|
|
|
|
| 166 |
const regenerateRoutes = () => {
|
| 167 |
const requestPlace = place.trim();
|
| 168 |
const count = Number(routeCountInput);
|
| 169 |
+
if (!requestPlace || !Number.isFinite(count) || count < 5 || count > 100) {
|
|
|
|
|
|
|
| 170 |
void fetchRoutes(); // Keep the same field validation messages.
|
| 171 |
return;
|
| 172 |
}
|
| 173 |
const params = new URLSearchParams(searchParams.toString());
|
| 174 |
params.set("place", requestPlace);
|
| 175 |
params.set("routeCount", String(count));
|
| 176 |
+
params.delete("targetDistanceKm");
|
| 177 |
+
params.set("autoDistance", "1");
|
| 178 |
if (requestPlace !== initialPlace.trim()) {
|
| 179 |
params.set("source", "place");
|
| 180 |
for (const key of ["latitude", "longitude", "fallbackLatitude", "fallbackLongitude"]) params.delete(key);
|
|
|
|
| 189 |
|
| 190 |
const fetchRoutes = async () => {
|
| 191 |
const routeCount = Number(routeCountInput);
|
|
|
|
| 192 |
const requestPlace = place.trim();
|
| 193 |
const placeWasEdited = requestPlace !== initialPlace.trim();
|
| 194 |
|
|
|
|
| 201 |
setError("경로 수는 5~100 사이로 입력해주세요.");
|
| 202 |
return;
|
| 203 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
activeRequest.current?.abort();
|
| 206 |
const controller = new AbortController();
|
|
|
|
| 242 |
longitude: placeWasEdited ? null : longitude,
|
| 243 |
radiusKm,
|
| 244 |
routeCount,
|
| 245 |
+
autoDistance: true,
|
| 246 |
labels: AVAILABLE_LABELS,
|
| 247 |
selectedLabel,
|
| 248 |
selectedTopK,
|
|
|
|
| 323 |
// Store under the request's own key. A separate effect used to write
|
| 324 |
// the previous result into the newly selected location's cache.
|
| 325 |
try {
|
| 326 |
+
if (!placeWasEdited && routeCount === (Number.isFinite(initialCount) ? initialCount : 8)) {
|
|
|
|
| 327 |
window.sessionStorage.setItem(cacheKey, JSON.stringify(parsed));
|
| 328 |
}
|
| 329 |
} catch {
|
|
|
|
| 471 |
/>
|
| 472 |
|
| 473 |
<SectionCard>
|
| 474 |
+
<div className="grid gap-3 sm:grid-cols-[1fr_120px] sm:items-end">
|
| 475 |
<label className="grid gap-2">
|
| 476 |
<span className="rts-label">지역</span>
|
| 477 |
<input value={place} onChange={(event) => setPlace(event.target.value)} className="rts-input" />
|
|
|
|
| 480 |
<span className="rts-label">경로 수</span>
|
| 481 |
<input type="number" min={5} max={100} value={routeCountInput} onChange={(event) => setRouteCountInput(event.target.value)} className="rts-input" />
|
| 482 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
</div>
|
| 484 |
|
| 485 |
{error ? <p className="mt-3 text-sm text-red-600">{error}</p> : null}
|
|
|
|
| 504 |
<span>추천 경로</span>
|
| 505 |
</div>
|
| 506 |
<div>
|
| 507 |
+
<strong>{result.best_route ? formatDistance(result.best_route.length_km) : "-"}</strong>
|
| 508 |
+
<span>첫 번째 경로 거리</span>
|
| 509 |
</div>
|
| 510 |
<div>
|
| 511 |
<strong>{formatGeneratedRouteLabel(selectedLabel)}</strong>
|
main/app/route-search/page.tsx
CHANGED
|
@@ -499,13 +499,11 @@ function RouteSearchPageContent() {
|
|
| 499 |
const [isRegionPickerOpen, setIsRegionPickerOpen] = useState(false);
|
| 500 |
const [isDistrictPickerOpen, setIsDistrictPickerOpen] = useState(false);
|
| 501 |
const [isShapePickerOpen, setIsShapePickerOpen] = useState(false);
|
| 502 |
-
const [isDistancePickerOpen, setIsDistancePickerOpen] = useState(false);
|
| 503 |
const [latitude, setLatitude] = useState<number | null>(null);
|
| 504 |
const [longitude, setLongitude] = useState<number | null>(null);
|
| 505 |
const [locationAccuracyM, setLocationAccuracyM] = useState<number | null>(null);
|
| 506 |
const [radiusKm, setRadiusKm] = useState(2.5);
|
| 507 |
const [routeCountInput, setRouteCountInput] = useState("5");
|
| 508 |
-
const [targetDistanceInput, setTargetDistanceInput] = useState("6");
|
| 509 |
const [selectedLabel, setSelectedLabel] = useState(requestedLabel || "cloud");
|
| 510 |
const [geoError, setGeoError] = useState<string | null>(null);
|
| 511 |
const [locationPermissionState, setLocationPermissionState] =
|
|
@@ -528,7 +526,6 @@ function RouteSearchPageContent() {
|
|
| 528 |
);
|
| 529 |
const districtOptions = activeRegion.districts;
|
| 530 |
const routeCountPresets = [5, 8, 16];
|
| 531 |
-
const distanceOptions = [3, 5, 7, 8, 10, 12, 16];
|
| 532 |
const radiusPresets = [2.5, 5, 10];
|
| 533 |
const labelOptions = useMemo(() => SHAPE_OPTIONS.map((shape) => shape.key), []);
|
| 534 |
const selectedShape =
|
|
@@ -604,8 +601,8 @@ function RouteSearchPageContent() {
|
|
| 604 |
: [];
|
| 605 |
const isWaitingForCurrentLocation = isLocating;
|
| 606 |
const summaryPlace = source === "current" ? "현재 위치" : selectedDistrictLabel;
|
| 607 |
-
const selectedSummary = `${summaryPlace} · ${selectedShape.label} ·
|
| 608 |
-
const routePlanText = `${summaryPlace}에서 ${selectedShape.label} 모양에 가까운
|
| 609 |
|
| 610 |
useEffect(() => {
|
| 611 |
const isValid = isUiFlowPermitValid(FLOW_KEYS.routeSearch, flowToken);
|
|
@@ -771,26 +768,17 @@ function RouteSearchPageContent() {
|
|
| 771 |
}
|
| 772 |
|
| 773 |
const routeCount = Number(routeCountInput);
|
| 774 |
-
const targetDistanceKm = Number(targetDistanceInput);
|
| 775 |
if (!Number.isFinite(routeCount) || routeCount < 5 || routeCount > 100) {
|
| 776 |
setFormError("추천 경로 수는 5~100 사이 숫자로 입력해 주세요.");
|
| 777 |
return;
|
| 778 |
}
|
| 779 |
-
if (
|
| 780 |
-
!Number.isFinite(targetDistanceKm) ||
|
| 781 |
-
targetDistanceKm < 1 ||
|
| 782 |
-
targetDistanceKm > 30
|
| 783 |
-
) {
|
| 784 |
-
setFormError("목표 거리는 1~30km 사이 숫자로 입력해 주세요.");
|
| 785 |
-
return;
|
| 786 |
-
}
|
| 787 |
setIsNavigating(true);
|
| 788 |
const params = new URLSearchParams({
|
| 789 |
source: effectiveSource,
|
| 790 |
place: effectivePlace,
|
| 791 |
radiusKm: String(radiusKm),
|
| 792 |
routeCount: String(routeCount),
|
| 793 |
-
|
| 794 |
label: selectedLabel,
|
| 795 |
selectedTopK: "3",
|
| 796 |
// Supply the visible map centre for a server-side recovery attempt when
|
|
@@ -932,7 +920,6 @@ function RouteSearchPageContent() {
|
|
| 932 |
setIsRegionPickerOpen((open) => !open);
|
| 933 |
setIsDistrictPickerOpen(false);
|
| 934 |
setIsShapePickerOpen(false);
|
| 935 |
-
setIsDistancePickerOpen(false);
|
| 936 |
}}
|
| 937 |
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 938 |
>
|
|
@@ -988,7 +975,6 @@ function RouteSearchPageContent() {
|
|
| 988 |
setIsDistrictPickerOpen((open) => !open);
|
| 989 |
setIsRegionPickerOpen(false);
|
| 990 |
setIsShapePickerOpen(false);
|
| 991 |
-
setIsDistancePickerOpen(false);
|
| 992 |
}}
|
| 993 |
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 994 |
>
|
|
@@ -1060,8 +1046,8 @@ function RouteSearchPageContent() {
|
|
| 1060 |
</div>
|
| 1061 |
</section>
|
| 1062 |
|
| 1063 |
-
<section className={`relative rounded-[28px] border border-black/10 bg-white p-5 shadow-[0_18px_45px_rgba(0,0,0,0.06)] ${isShapePickerOpen
|
| 1064 |
-
<h2 className="text-xl font-bold">모양
|
| 1065 |
|
| 1066 |
<div className="mt-4 grid gap-4">
|
| 1067 |
<label className="grid gap-2">
|
|
@@ -1073,7 +1059,6 @@ function RouteSearchPageContent() {
|
|
| 1073 |
setIsShapePickerOpen((open) => !open);
|
| 1074 |
setIsRegionPickerOpen(false);
|
| 1075 |
setIsDistrictPickerOpen(false);
|
| 1076 |
-
setIsDistancePickerOpen(false);
|
| 1077 |
}}
|
| 1078 |
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 1079 |
>
|
|
@@ -1113,56 +1098,6 @@ function RouteSearchPageContent() {
|
|
| 1113 |
</div>
|
| 1114 |
</label>
|
| 1115 |
|
| 1116 |
-
<label className="grid gap-2">
|
| 1117 |
-
<span className="text-sm font-bold">목표 거리</span>
|
| 1118 |
-
<div className="relative">
|
| 1119 |
-
<button
|
| 1120 |
-
type="button"
|
| 1121 |
-
onClick={() => {
|
| 1122 |
-
setIsDistancePickerOpen((open) => !open);
|
| 1123 |
-
setIsRegionPickerOpen(false);
|
| 1124 |
-
setIsDistrictPickerOpen(false);
|
| 1125 |
-
setIsShapePickerOpen(false);
|
| 1126 |
-
}}
|
| 1127 |
-
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 1128 |
-
>
|
| 1129 |
-
<span>{targetDistanceInput || "-"}km</span>
|
| 1130 |
-
<span className="ml-3 text-xs text-[#666]">
|
| 1131 |
-
{isDistancePickerOpen ? "▲" : "▼"}
|
| 1132 |
-
</span>
|
| 1133 |
-
</button>
|
| 1134 |
-
|
| 1135 |
-
<MotionPopover
|
| 1136 |
-
open={isDistancePickerOpen}
|
| 1137 |
-
className="absolute left-0 right-0 top-[54px] z-[2300] max-h-[308px] overflow-y-auto rounded-2xl border border-black/10 bg-white py-2 shadow-[0_14px_34px_rgba(0,0,0,0.16)]"
|
| 1138 |
-
>
|
| 1139 |
-
{distanceOptions.map((distance) => {
|
| 1140 |
-
const value = String(distance);
|
| 1141 |
-
const isSelected = targetDistanceInput === value;
|
| 1142 |
-
|
| 1143 |
-
return (
|
| 1144 |
-
<button
|
| 1145 |
-
key={distance}
|
| 1146 |
-
type="button"
|
| 1147 |
-
onMouseDown={(event) => event.preventDefault()}
|
| 1148 |
-
onClick={() => {
|
| 1149 |
-
setTargetDistanceInput(value);
|
| 1150 |
-
setIsDistancePickerOpen(false);
|
| 1151 |
-
}}
|
| 1152 |
-
className={`flex h-11 w-full items-center px-4 text-left text-sm font-semibold ${
|
| 1153 |
-
isSelected
|
| 1154 |
-
? "bg-[#1E40AF] text-white"
|
| 1155 |
-
: "text-black hover:bg-[#f3f7ff]"
|
| 1156 |
-
}`}
|
| 1157 |
-
>
|
| 1158 |
-
{distance}km
|
| 1159 |
-
</button>
|
| 1160 |
-
);
|
| 1161 |
-
})}
|
| 1162 |
-
</MotionPopover>
|
| 1163 |
-
</div>
|
| 1164 |
-
</label>
|
| 1165 |
-
|
| 1166 |
<details className="rounded-2xl bg-[#f7f7f7] p-4">
|
| 1167 |
<summary className="cursor-pointer text-sm font-bold">세부 설정</summary>
|
| 1168 |
<div className="mt-4 grid gap-4">
|
|
|
|
| 499 |
const [isRegionPickerOpen, setIsRegionPickerOpen] = useState(false);
|
| 500 |
const [isDistrictPickerOpen, setIsDistrictPickerOpen] = useState(false);
|
| 501 |
const [isShapePickerOpen, setIsShapePickerOpen] = useState(false);
|
|
|
|
| 502 |
const [latitude, setLatitude] = useState<number | null>(null);
|
| 503 |
const [longitude, setLongitude] = useState<number | null>(null);
|
| 504 |
const [locationAccuracyM, setLocationAccuracyM] = useState<number | null>(null);
|
| 505 |
const [radiusKm, setRadiusKm] = useState(2.5);
|
| 506 |
const [routeCountInput, setRouteCountInput] = useState("5");
|
|
|
|
| 507 |
const [selectedLabel, setSelectedLabel] = useState(requestedLabel || "cloud");
|
| 508 |
const [geoError, setGeoError] = useState<string | null>(null);
|
| 509 |
const [locationPermissionState, setLocationPermissionState] =
|
|
|
|
| 526 |
);
|
| 527 |
const districtOptions = activeRegion.districts;
|
| 528 |
const routeCountPresets = [5, 8, 16];
|
|
|
|
| 529 |
const radiusPresets = [2.5, 5, 10];
|
| 530 |
const labelOptions = useMemo(() => SHAPE_OPTIONS.map((shape) => shape.key), []);
|
| 531 |
const selectedShape =
|
|
|
|
| 601 |
: [];
|
| 602 |
const isWaitingForCurrentLocation = isLocating;
|
| 603 |
const summaryPlace = source === "current" ? "현재 위치" : selectedDistrictLabel;
|
| 604 |
+
const selectedSummary = `${summaryPlace} · ${selectedShape.label} · 거리 자동`;
|
| 605 |
+
const routePlanText = `${summaryPlace}에서 목표 거리를 정하지 않고 ${selectedShape.label} 모양에 가까운 경로를 찾아 ${routeCountInput || "-"}개 추천합니다.`;
|
| 606 |
|
| 607 |
useEffect(() => {
|
| 608 |
const isValid = isUiFlowPermitValid(FLOW_KEYS.routeSearch, flowToken);
|
|
|
|
| 768 |
}
|
| 769 |
|
| 770 |
const routeCount = Number(routeCountInput);
|
|
|
|
| 771 |
if (!Number.isFinite(routeCount) || routeCount < 5 || routeCount > 100) {
|
| 772 |
setFormError("추천 경로 수는 5~100 사이 숫자로 입력해 주세요.");
|
| 773 |
return;
|
| 774 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
setIsNavigating(true);
|
| 776 |
const params = new URLSearchParams({
|
| 777 |
source: effectiveSource,
|
| 778 |
place: effectivePlace,
|
| 779 |
radiusKm: String(radiusKm),
|
| 780 |
routeCount: String(routeCount),
|
| 781 |
+
autoDistance: "1",
|
| 782 |
label: selectedLabel,
|
| 783 |
selectedTopK: "3",
|
| 784 |
// Supply the visible map centre for a server-side recovery attempt when
|
|
|
|
| 920 |
setIsRegionPickerOpen((open) => !open);
|
| 921 |
setIsDistrictPickerOpen(false);
|
| 922 |
setIsShapePickerOpen(false);
|
|
|
|
| 923 |
}}
|
| 924 |
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 925 |
>
|
|
|
|
| 975 |
setIsDistrictPickerOpen((open) => !open);
|
| 976 |
setIsRegionPickerOpen(false);
|
| 977 |
setIsShapePickerOpen(false);
|
|
|
|
| 978 |
}}
|
| 979 |
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 980 |
>
|
|
|
|
| 1046 |
</div>
|
| 1047 |
</section>
|
| 1048 |
|
| 1049 |
+
<section className={`relative rounded-[28px] border border-black/10 bg-white p-5 shadow-[0_18px_45px_rgba(0,0,0,0.06)] ${isShapePickerOpen ? "z-[2400]" : ""}`}>
|
| 1050 |
+
<h2 className="text-xl font-bold">모양</h2>
|
| 1051 |
|
| 1052 |
<div className="mt-4 grid gap-4">
|
| 1053 |
<label className="grid gap-2">
|
|
|
|
| 1059 |
setIsShapePickerOpen((open) => !open);
|
| 1060 |
setIsRegionPickerOpen(false);
|
| 1061 |
setIsDistrictPickerOpen(false);
|
|
|
|
| 1062 |
}}
|
| 1063 |
className="flex h-12 w-full items-center justify-between rounded-2xl border border-black/10 bg-[#f7f7f7] px-4 text-left text-sm font-semibold"
|
| 1064 |
>
|
|
|
|
| 1098 |
</div>
|
| 1099 |
</label>
|
| 1100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1101 |
<details className="rounded-2xl bg-[#f7f7f7] p-4">
|
| 1102 |
<summary className="cursor-pointer text-sm font-bold">세부 설정</summary>
|
| 1103 |
<div className="mt-4 grid gap-4">
|
router/ai/scripts/clip_osm_region_test.py
CHANGED
|
@@ -2387,6 +2387,7 @@ def generate_candidate_routes(
|
|
| 2387 |
target_distance_m: float,
|
| 2388 |
tolerance_ratio: float = 0.35,
|
| 2389 |
selected_label: str | None = None,
|
|
|
|
| 2390 |
) -> list[dict[str, Any]]:
|
| 2391 |
nodes = list(graph.nodes)
|
| 2392 |
if len(nodes) < 8:
|
|
@@ -2415,10 +2416,11 @@ def generate_candidate_routes(
|
|
| 2415 |
shuffled_examples = random.sample(examples, len(examples)) if examples else []
|
| 2416 |
for attempt in range(guided_attempts):
|
| 2417 |
try:
|
|
|
|
| 2418 |
geo_points = guided_geo_waypoints(
|
| 2419 |
graph=graph,
|
| 2420 |
label=str(selected_label),
|
| 2421 |
-
target_distance_m=
|
| 2422 |
template=shuffled_examples[attempt % len(shuffled_examples)] if shuffled_examples else None,
|
| 2423 |
)
|
| 2424 |
waypoint_nodes = nearest_nodes_for_geo_points(graph, geo_points)
|
|
@@ -2429,7 +2431,7 @@ def generate_candidate_routes(
|
|
| 2429 |
if len(route_nodes) < 18:
|
| 2430 |
continue
|
| 2431 |
length_m = route_length_m(graph, route_nodes)
|
| 2432 |
-
if length_m <= 0:
|
| 2433 |
continue
|
| 2434 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2435 |
if unique_ratio < 0.50:
|
|
@@ -2481,10 +2483,20 @@ def generate_candidate_routes(
|
|
| 2481 |
selected_label, shape_metrics, is_loop=True
|
| 2482 |
),
|
| 2483 |
}
|
| 2484 |
-
if delta <= tolerance:
|
| 2485 |
accepted.append(row)
|
| 2486 |
fallback.append(row)
|
| 2487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2488 |
# 2) closed-loop random candidates
|
| 2489 |
for _ in range(max_attempts):
|
| 2490 |
try:
|
|
@@ -2512,7 +2524,7 @@ def generate_candidate_routes(
|
|
| 2512 |
continue
|
| 2513 |
|
| 2514 |
length_m = route_length_m(graph, route_nodes)
|
| 2515 |
-
if length_m <= 0:
|
| 2516 |
continue
|
| 2517 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2518 |
if unique_ratio < 0.55:
|
|
@@ -2570,7 +2582,7 @@ def generate_candidate_routes(
|
|
| 2570 |
selected_label, shape_metrics, is_loop=True
|
| 2571 |
),
|
| 2572 |
}
|
| 2573 |
-
if delta <= tolerance:
|
| 2574 |
accepted.append(row)
|
| 2575 |
fallback.append(row)
|
| 2576 |
|
|
@@ -2591,7 +2603,7 @@ def generate_candidate_routes(
|
|
| 2591 |
if len(route_nodes) < 10:
|
| 2592 |
continue
|
| 2593 |
length_m = route_length_m(graph, route_nodes)
|
| 2594 |
-
if length_m <= 0:
|
| 2595 |
continue
|
| 2596 |
pts = route_points(graph, route_nodes)
|
| 2597 |
shape_quality, shape_metrics = route_shape_quality_with_metrics(pts, close_loop=False)
|
|
@@ -2639,7 +2651,7 @@ def generate_candidate_routes(
|
|
| 2639 |
selected_label, shape_metrics, is_loop=False
|
| 2640 |
),
|
| 2641 |
}
|
| 2642 |
-
if delta <= tolerance:
|
| 2643 |
accepted.append(row)
|
| 2644 |
fallback.append(row)
|
| 2645 |
|
|
@@ -2647,7 +2659,7 @@ def generate_candidate_routes(
|
|
| 2647 |
key=lambda x: (
|
| 2648 |
-generation_mode_priority(x),
|
| 2649 |
-float(x.get("label_generation_prior", 0.0)),
|
| 2650 |
-
x["delta"],
|
| 2651 |
-float(x.get("shape_quality", 0.0)),
|
| 2652 |
-len(x["nodes"]),
|
| 2653 |
)
|
|
|
|
| 2387 |
target_distance_m: float,
|
| 2388 |
tolerance_ratio: float = 0.35,
|
| 2389 |
selected_label: str | None = None,
|
| 2390 |
+
auto_distance: bool = False,
|
| 2391 |
) -> list[dict[str, Any]]:
|
| 2392 |
nodes = list(graph.nodes)
|
| 2393 |
if len(nodes) < 8:
|
|
|
|
| 2416 |
shuffled_examples = random.sample(examples, len(examples)) if examples else []
|
| 2417 |
for attempt in range(guided_attempts):
|
| 2418 |
try:
|
| 2419 |
+
guided_target_m = (3000.0, 5000.0, 8000.0, 12000.0, 16000.0)[attempt % 5] if auto_distance else target_distance_m
|
| 2420 |
geo_points = guided_geo_waypoints(
|
| 2421 |
graph=graph,
|
| 2422 |
label=str(selected_label),
|
| 2423 |
+
target_distance_m=guided_target_m,
|
| 2424 |
template=shuffled_examples[attempt % len(shuffled_examples)] if shuffled_examples else None,
|
| 2425 |
)
|
| 2426 |
waypoint_nodes = nearest_nodes_for_geo_points(graph, geo_points)
|
|
|
|
| 2431 |
if len(route_nodes) < 18:
|
| 2432 |
continue
|
| 2433 |
length_m = route_length_m(graph, route_nodes)
|
| 2434 |
+
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <= 20000.0):
|
| 2435 |
continue
|
| 2436 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2437 |
if unique_ratio < 0.50:
|
|
|
|
| 2483 |
selected_label, shape_metrics, is_loop=True
|
| 2484 |
),
|
| 2485 |
}
|
| 2486 |
+
if delta <= tolerance or auto_distance:
|
| 2487 |
accepted.append(row)
|
| 2488 |
fallback.append(row)
|
| 2489 |
|
| 2490 |
+
if auto_distance and selected_label:
|
| 2491 |
+
# Automatic sizing is a drawing search. Random loops cannot satisfy
|
| 2492 |
+
# the requested label, so spend the remaining time ranking guided
|
| 2493 |
+
# road routes instead of generating hundreds of unrelated loops.
|
| 2494 |
+
accepted.sort(key=lambda route: (
|
| 2495 |
+
-float(route.get("shape_quality", 0.0)),
|
| 2496 |
+
-float(route.get("label_generation_prior", 0.0)),
|
| 2497 |
+
))
|
| 2498 |
+
return accepted[:max(route_count, 24)]
|
| 2499 |
+
|
| 2500 |
# 2) closed-loop random candidates
|
| 2501 |
for _ in range(max_attempts):
|
| 2502 |
try:
|
|
|
|
| 2524 |
continue
|
| 2525 |
|
| 2526 |
length_m = route_length_m(graph, route_nodes)
|
| 2527 |
+
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <= 20000.0):
|
| 2528 |
continue
|
| 2529 |
unique_ratio = len(set(route_nodes)) / len(route_nodes)
|
| 2530 |
if unique_ratio < 0.55:
|
|
|
|
| 2582 |
selected_label, shape_metrics, is_loop=True
|
| 2583 |
),
|
| 2584 |
}
|
| 2585 |
+
if delta <= tolerance or auto_distance:
|
| 2586 |
accepted.append(row)
|
| 2587 |
fallback.append(row)
|
| 2588 |
|
|
|
|
| 2603 |
if len(route_nodes) < 10:
|
| 2604 |
continue
|
| 2605 |
length_m = route_length_m(graph, route_nodes)
|
| 2606 |
+
if length_m <= 0 or (auto_distance and not 1500.0 <= length_m <= 20000.0):
|
| 2607 |
continue
|
| 2608 |
pts = route_points(graph, route_nodes)
|
| 2609 |
shape_quality, shape_metrics = route_shape_quality_with_metrics(pts, close_loop=False)
|
|
|
|
| 2651 |
selected_label, shape_metrics, is_loop=False
|
| 2652 |
),
|
| 2653 |
}
|
| 2654 |
+
if delta <= tolerance or auto_distance:
|
| 2655 |
accepted.append(row)
|
| 2656 |
fallback.append(row)
|
| 2657 |
|
|
|
|
| 2659 |
key=lambda x: (
|
| 2660 |
-generation_mode_priority(x),
|
| 2661 |
-float(x.get("label_generation_prior", 0.0)),
|
| 2662 |
+
*((x["delta"],) if not auto_distance else ()),
|
| 2663 |
-float(x.get("shape_quality", 0.0)),
|
| 2664 |
-len(x["nodes"]),
|
| 2665 |
)
|