feat(D02·D03): 예정노선 지점 하나 · 차례 색 · 노선분석 핀 · 카카오 지도 마우스 설정 따름 (PLAN 585-1 · 요구 445 A1~A3)

- 기준지정 시점 · 종점 목록 → 「지점 목록」 하나 · [+ 지점] · 지점마다 차례 색 여섯 돌림(줄 왼 띠 · 지도 이름표 바탕)
- 저장 꼴 plan_base_json {epsg, points} · 옛 starts + ends · start · end 는 읽을 때 이어 붙임(옮김 없음) · 상한 40
- 노선분석 지점 = 핀 + 「지점 N」 · 기준지정과 같은 색 · 겹 목록 맨 끝(늘 맨 위) · 지점 누름 = 새 본선(No.0 = 그 지점) · 선 놓인 지점 = 갈래
- 공용 카카오 지도 = 휠 설정 방향 한 단(마우스 자리 고정) · 가운데 끌기 옮김 — 기준지정 · 지형 범위 창 · B08 이정표 같이
- 시험: 529-3 · 530-2 새 꼴 · 585-1 새 지킴 시험 · 낡은 B08 가짜 카카오(바탕 종류 · 사건 받기 없음) 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 18:40:49 +09:00
co-authored by Claude Opus 5.5
parent 808e212a16
commit f5645c256d
22 changed files with 450 additions and 243 deletions
+28 -31
View File
@@ -1,13 +1,12 @@
"""D 예정노선 1단계 기준 지정 — 전체 시점 · 종점 목록 정본 API
(요구서 389 3장 1 · 440 1 · PLAN 560-2 · 579-1).
"""D 예정노선 1단계 기준 지정 — 지점 목록 하나 정본 API
(요구서 389 3장 1 · 440 1 · 445 A2 · PLAN 560-2 · 579-1 · 585-1).
- `GET /api/projects/{id}/plan/points` — {epsg, starts: [점], ends: [점]} · 점 = {lon, lat, x, y}
- `PUT …/plan/points` — {starts: [{lon, lat}], ends: [...]} → TM 셈 → `projects.plan_base_json`
(`{"epsg", "starts": [{"x", "y"}], "ends": [...]}` · 좌표계 = 저장된 값 ·
없으면 점 평균 경도의 TM 띠)
- 시점 · 종점은 짝 없는 따로 목록 · 옛 꼴(`start` · `end` 한 점씩)은 읽을 때 목록으로 감쌈
(옮김 없음).
- 칸이 비면(옛 프로젝트) 첫 연도 시점 + 마지막 연도 종점으로 접어 보임 · 연도 행은 D03 [저장] 몫.
- `GET /api/projects/{id}/plan/points` — {epsg, points: [점]} · 점 = {lon, lat, x, y}
- `PUT …/plan/points` — {points: [{lon, lat}]} → TM 셈 → `projects.plan_base_json`
(`{"epsg", "points": [{"x", "y"}]}` · 좌표계 = 저장된 값 · 없으면 점 평균 경도의 TM 띠)
- 시점 · 종점 구분 없음 — 옛 꼴(`starts` + `ends` 목록 · 더 옛 `start` · `end` 한 점)은 읽을 때
이어 붙임(옮김 없음 · 쓰기는 새 꼴).
- 칸이 비면(옛 프로젝트) [첫 연도 시점, 마지막 연도 종점]으로 접어 보임 · 연도 행은 D03 [저장] 몫.
- 고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫).
"""
@@ -27,7 +26,7 @@ from config.config_db import get_db_pool
router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"])
MAX_POINTS = 20 # 시점 · 종점 목록마다 상한
MAX_POINTS = 40 # 지점 상한(옛 시점 20 + 종점 20)
class LonLat(BaseModel):
@@ -36,8 +35,7 @@ class LonLat(BaseModel):
class BaseRequest(BaseModel):
starts: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
ends: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
points: list[LonLat] = Field(default_factory=list, max_length=MAX_POINTS)
async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, Any]]]:
@@ -78,45 +76,44 @@ def _stored(project: dict[str, Any]) -> dict[str, Any] | None:
def base_view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""{epsg, starts, ends} — 정본 칸(옛 한 점 꼴은 감쌈) ·
비면 첫 연도 시점 + 마지막 연도 종점으로 접음."""
"""{epsg, points} — 정본 칸(옛 시점 · 종점 꼴은 이어 붙임) ·
비면 [첫 연도 시점, 마지막 연도 종점]으로 접음."""
stored = _stored(project)
if stored:
epsg = stored.get("epsg")
starts = stored.get("starts", [stored["start"]] if stored.get("start") else [])
ends = stored.get("ends", [stored["end"]] if stored.get("end") else [])
items = stored.get("points")
if items is None:
starts = stored.get("starts", [stored["start"]] if stored.get("start") else [])
ends = stored.get("ends", [stored["end"]] if stored.get("end") else [])
items = [*starts, *ends]
elif rows:
first, last = rows[min(rows)], rows[max(rows)]
epsg = first.get("epsg")
starts = [{"x": first.get("start_x"), "y": first.get("start_y")}]
ends = [{"x": last.get("end_x"), "y": last.get("end_y")}]
items = [
{"x": first.get("start_x"), "y": first.get("start_y")},
{"x": last.get("end_x"), "y": last.get("end_y")},
]
else:
epsg, starts, ends = None, [], []
def points(items: list[dict[str, Any]]) -> list[dict[str, float]]:
return [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in items) if p]
return {"epsg": epsg, "starts": points(starts), "ends": points(ends)}
epsg, items = None, []
points = [p for p in (_point(i.get("x"), i.get("y"), epsg) for i in items) if p]
return {"epsg": epsg, "points": points}
async def load_base(project_id: UUID) -> dict[str, Any]:
"""전체 시점 · 종점 목록 — D03 이 읽는 길."""
"""지점 목록 — D03 이 읽는 길."""
project, rows = await _load(project_id)
return base_view(project, rows)
def stored_base(payload: BaseRequest, epsg: int | None = None) -> dict[str, Any]:
"""PUT 몸 → 정본 칸 꼴(TM m) — 좌표계 = `epsg`(저장된 값) · 없으면 점 평균 경도의 TM 띠."""
given = [*payload.starts, *payload.ends]
given = payload.points
if not given:
epsg = None
elif epsg is None:
epsg = tm_epsg_for_lon(sum(p.lon for p in given) / len(given))
def tm(items: list[LonLat]) -> list[dict[str, float]]:
return [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in items]
return {"epsg": epsg, "starts": tm(payload.starts), "ends": tm(payload.ends)}
points = [dict(zip(("x", "y"), lonlat_to_tm(p.lon, p.lat, epsg))) for p in given]
return {"epsg": epsg, "points": points}
async def _store_base(project_id: UUID, base: dict[str, Any]) -> None: