Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75bb82d03f | ||
|
|
2ce5bac9e3 | ||
|
|
a4f2ac928d | ||
|
|
80c286740d | ||
|
|
4753d88983 | ||
|
|
bef30eda25 | ||
|
|
a5e895c66a | ||
|
|
612f622c76 | ||
|
|
58e94e3875 | ||
|
|
47c3b65cc4 | ||
|
|
7683b74524 | ||
|
|
084df5137c | ||
|
|
0b8abe4f09 |
@@ -1,6 +1,6 @@
|
||||
# 평면 지도 읽기
|
||||
|
||||
위성 사진 위에 지점 · 노선 · 측점을 겹쳐 보여 주는 가운데 지도. 지점을 끌어 옮길 수도 있음.
|
||||
위성 사진 위에 지점 · 노선 · 측점을 겹쳐 보여 주는 가운데 지도. 지점을 끌어 옮기고, 고른 노선의 꺾임점도 고칠 수 있음.
|
||||
|
||||
## 여는 길
|
||||
|
||||
@@ -18,13 +18,15 @@
|
||||
| 2 | 선 위 눈금 글(0+0 · 5+0 · 10+0 …)로 측점(20 m 간격)을 읽음. |
|
||||
| 3 | 지점이 마음에 안 들면 지점(원)을 마우스 왼쪽 단추로 누른 채 끌어 옮김. |
|
||||
| 4 | 옮긴 뒤 [분석]을 다시 눌러 새 지점으로 노선을 찾음. |
|
||||
| 5 | 지도 바탕을 끌어 옮기고 휠로 확대 · 축소함. |
|
||||
| 5 | 노선 모양만 바꾸려면 흰 네모(꺾임점)를 끌어 옮김. [꺾임점 고치기](D03/07_꺾임점_고치기) 참고. |
|
||||
| 6 | 지도 바탕을 끌어 옮기고 휠로 확대 · 축소함. |
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- **지점(원)의 색** — 초록 = 첫 지점(시점) · 빨강 = 마지막 지점(종점) · 파랑 = 연도가 바뀌는 중간 지점.
|
||||
- **선** — 굵은 선 = 고른 후보. 흰 점선(옅음) = 고르지 않은 다른 후보. 분석 전에는 지점끼리 잇는 점선만 보임.
|
||||
- **등고선** = 도엽(수치지형도) 등고선. 노란 선과 노란 숫자(높이). 확대하면 더 촘촘히 보임.
|
||||
- **흰 네모 · 주황 점 · R 숫자** = 고른 노선의 꺾임점 · 곡선 손잡이 · 곡선 반지름(산림청 계정만 보임).
|
||||
- 누른 측점은 보라색 고리로 표시됨.
|
||||
- **누가** = 측점 번호. 앞 연도 끝 = 다음 연도 시작으로 이어서 셈. 시작 값은 등록 때 정한 노선 시작 누가거리.
|
||||
- 끌기를 마치면 그 지점 앞 · 뒤 연도의 분석 결과가 비워지고 횡단 판에 「지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요.」가 나옴.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 꺾임점 고치기
|
||||
|
||||
추천된 노선의 꺾임점(IP)을 평면 지도에서 직접 옮기고, 넣고, 지우는 방법. 곡선 반지름(R)도 손잡이로 바꿈.
|
||||
|
||||
## 여는 길
|
||||
|
||||
- 노선 분석 화면의 가운데 평면 지도. 고른 후보 노선 위에 흰 네모(꺾임점) · 주황 점(곡선 손잡이) · 「R 숫자」 글이 보임.
|
||||
|
||||

|
||||
|
||||
그림 번호 — 1 평면 지도(꺾임점 하나를 옮긴 모습) · 2 「꺾임점 고침 — [저장] 전」 줄과 [원래 노선] · 3 고친 후보 카드(연장이 바로 바뀜) · 4 [되돌리기]
|
||||
|
||||
## 단계
|
||||
|
||||
| 단계 | 할 일 |
|
||||
| --- | --- |
|
||||
| 1 | 「연도별 노선」 카드에서 고칠 후보를 먼저 고름. 꺾임점은 고른 후보에만 보임. |
|
||||
| 2 | 흰 네모(꺾임점)를 마우스 왼쪽 단추로 누른 채 끌어 옮김. 노선 · 연장 · 측점이 바로 다시 그려짐. |
|
||||
| 3 | 꺾임점을 더 넣으려면 꺾임점 사이 선 위를 두 번 누름. |
|
||||
| 4 | 꺾임점을 빼려면 흰 네모를 마우스 오른쪽 단추로 누름. |
|
||||
| 5 | 곡선을 넓히거나 좁히려면 주황 점(곡선 시작 · 끝)을 끌어 옮김. 그쪽 직선 각도와 R 이 함께 바뀜. |
|
||||
| 6 | 잘못 고쳤으면 [되돌리기](또는 Ctrl+Z)로 한 걸음씩 되돌림. 처음 노선으로 돌리려면 [원래 노선]. |
|
||||
| 7 | 측점을 눌러 둔 상태면 아래 횡단도 고친 노선으로 바로 바뀜. |
|
||||
| 8 | 다 고쳤으면 [저장]을 누름. 서버가 꺾임점으로 노선을 다시 그어 기울기 · 위반 수를 셈. |
|
||||
|
||||
## 보이는 것
|
||||
|
||||
- **흰 네모** = 꺾임점. 테두리가 빨가면 그 자리 곡선이 기준(최소 곡선 반지름 · 배향곡선)에 못 미침.
|
||||
- **주황 점** = 곡선 시작 · 끝(손잡이). **R 숫자** = 그 곡선의 반지름(m). 기준 미달이면 붉은 글.
|
||||
- 고친 연도에는 「꺾임점 고침 — [저장] 전」 줄이 보이고, 저장 뒤에는 「꺾임점 고침(저장됨)」으로 바뀜.
|
||||
- 고친 후보 카드는 「연장 ○○ m · 기울기 · 위반 = 저장 때 셈」으로 연장만 바로 보임. [저장] 뒤에 최대 기울기 · 위반 수가 채워짐.
|
||||
- 연장이 바뀌면 그 뒤 연도의 시작 · 끝 측점도 함께 밀림.
|
||||
|
||||
> 주의: 처음 · 끝 꺾임점은 연도가 바뀌는 지점이라 여기서 못 옮김. 지점(원)을 끌어 옮기고 다시 [분석]함. 지점을 옮기면 그 앞 · 뒤 연도의 꺾임점 고침은 버려짐.
|
||||
|
||||
> 주의: 곡선 반지름은 법정 최소 곡선 반지름 밑으로 내려가지 않음. 옮기다가 지키던 곡선이 기준 밑으로 떨어지면 그 걸음은 막히고 까닭이 알림으로 뜸.
|
||||
|
||||
> 참고: 고친 값은 [저장] 전까지 이 브라우저 탭에만 임시로 남음(새로 고침해도 유지). 다른 후보를 골랐다가 다시 고르면 고친 노선이 그대로 돌아옴. [분석]을 다시 해 분석 결과가 바뀐 연도는 고침이 버려짐.
|
||||
|
||||
> 참고: 산림청이 아닌 계정에는 꺾임점 · 손잡이가 보이지 않음. 보기만 가능.
|
||||
|
Before Width: | Height: | Size: 1000 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 994 KiB After Width: | Height: | Size: 999 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 970 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
@@ -7,5 +7,6 @@
|
||||
{"name":"D03_05_station_cross","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"eval":"window.__d03Plan.pick(100)"},{"wait":".d03-plan__cross-body svg"},{"wait_ms":600}],"settle_ms":2000,"clip":".d03-plan__cross","callouts":[{"n":1,"sel":".d03-plan__cross-head"},{"n":2,"sel":".d03-plan__cross-body"}]},
|
||||
{"name":"D03_06_cross_fold","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,900],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"eval":"window.__d03Plan.pick(400)"},{"wait":".d03-plan__cross-body svg"},{"wait_ms":600},{"click":".d03-plan__cross .ui-workflow-overlay__handle"},{"wait_ms":800}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__cross .ui-workflow-overlay__handle","pos":"tr"}]},
|
||||
{"name":"D03_07_buttons","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000}],"settle_ms":2000,"clip":".ui-sidebar-actions","callouts":[{"n":1,"sel":".ui-sidebar-actions > button:nth-child(1)"},{"n":2,"sel":".ui-sidebar-actions > button:nth-child(2)"},{"n":3,"sel":".ui-sidebar-actions > button:nth-child(3)"},{"n":4,"sel":".ui-sidebar-actions > button:nth-child(4)"},{"n":5,"sel":".ui-sidebar-actions > button:nth-child(5)"},{"n":6,"sel":".ui-sidebar-actions > button:nth-child(6)"}]},
|
||||
{"name":"D03_08_3d_modal","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"click":".ui-sidebar-actions > button:nth-child(6)"},{"wait":".b05-route__3d-modal"},{"wait_ms":9000},{"wait":".d03-plan__3d-corridor input:checked"}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__3d-year"},{"n":2,"sel":".d03-plan__3d-corridor"},{"n":3,"sel":".b05-route__3d-body .b05-route__viewport","pos":"br"}]}
|
||||
{"name":"D03_08_3d_modal","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"click":".ui-sidebar-actions > button:nth-child(6)"},{"wait":".b05-route__3d-modal"},{"wait_ms":9000},{"wait":".d03-plan__3d-corridor input:checked"}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__3d-year"},{"n":2,"sel":".d03-plan__3d-corridor"},{"n":3,"sel":".b05-route__3d-body .b05-route__viewport","pos":"br"}]},
|
||||
{"name":"D03_09_ip_edit","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"eval":"window.__d03Plan.nudge(3, 18, 14)"},{"eval":"window.__d03Plan.pick(200)"},{"wait":".d03-plan__cross-body svg"},{"wait_ms":600}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__map","pos":"tr"},{"n":2,"sel":".d03-plan__year[data-year=\"2027\"] .d03-plan__year-head:nth-child(2)","pos":"tr"},{"n":3,"sel":".d03-plan__year[data-year=\"2027\"] .d03-plan__mode.is-picked","pos":"tr"},{"n":4,"sel":".ui-sidebar-actions > button:nth-child(7)","pos":"tr"}]}
|
||||
]
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
- 추천 기준 = 프로젝트 임도 종류 · 설계속도 → B 법정 함수 · 지형 구분은 B 폴백(`TERRAIN_TYPE`)
|
||||
- 계류 이격 = 도엽 하천선(B 로더 그대로) · 복도 반폭 = `PLAN_CORRIDOR_M`(D 만)
|
||||
- 연도 경계는 꺾인 채(경계점 = 측점) · 누가거리 = 등록 시작 누가부터 고른 갈래 길이로 이어 셈
|
||||
- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>.json`(입력 지문 같으면 다시 안 셈)
|
||||
- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>_<지점 열쇠>.json`(입력 지문 같으면 다시
|
||||
안 셈) · 끈 지점 분석이 저장 지점 분석본을 덮지 않음 · 연도마다 최근 `KEEP_PER_YEAR` 개 + 저장
|
||||
지점 것만 남김 · 옛 꼴 `route_<연도>.json` 은 읽기만(공유 storage — 옛 판 서버가 아직 읽음)
|
||||
|
||||
계산 자리 = ③ 서버 단독(B 추천과 같음).
|
||||
|
||||
⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` ↔ `logic/D03_PlanAnalysis_Plan.ts`
|
||||
`pickMode` · `chainRows` · `joinedLine`(화면이 고르는 즉시) · 거울 시험 `test_530_3_plan_logic.py`.
|
||||
⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` · `with_edits` ↔
|
||||
`logic/D03_PlanAnalysis_Plan.ts` `pickMode` · `chainRows` · `joinedLine` · `withEdits`(화면이 고르는
|
||||
즉시) · 거울 시험 `test_530_3_plan_logic.py` · `test_543_plan_edit.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -56,6 +59,8 @@ ALGO = 1
|
||||
# 계류 거르기 칸 · 지형 상자 여유(m) — `near_streams`
|
||||
STREAM_TILE_M = 200.0
|
||||
STREAM_PAD_M = 100.0
|
||||
# 연도마다 남길 분석본 수 — 저장 지점 분석본은 따로 늘 남김
|
||||
KEEP_PER_YEAR = 3
|
||||
|
||||
Progress = Callable[[int, str], None]
|
||||
Loader = Callable[..., tuple[Any, list]]
|
||||
@@ -85,17 +90,53 @@ def _same_point(a: Any, b: Any) -> bool:
|
||||
return abs(float(a[0]) - float(b[0])) < 1e-3 and abs(float(a[1]) - float(b[1])) < 1e-3
|
||||
|
||||
|
||||
def segment_path(project_root: Path, seg: dict[str, Any]) -> Path:
|
||||
"""연도 · 지점 열쇠 파일 — 지점이 다르면 다른 파일(덮지 않음)."""
|
||||
key = hashlib.blake2b(
|
||||
repr((_point(seg["start"]), _point(seg["end"]))).encode(), digest_size=4
|
||||
).hexdigest()
|
||||
return derived_dir(project_root) / f"route_{int(seg['year'])}_{key}.json"
|
||||
|
||||
|
||||
def _find(project_root: Path, seg: dict[str, Any]) -> tuple[Path, dict[str, Any]] | None:
|
||||
"""지점이 지금과 같은 저장본(1 mm) — 열쇠 파일 · 같은 연도 다른 열쇠(새것부터) · 옛 꼴 차례."""
|
||||
year = int(seg["year"])
|
||||
folder = derived_dir(project_root)
|
||||
exact = segment_path(project_root, seg)
|
||||
others = sorted(
|
||||
(p for p in folder.glob(f"route_{year}_*.json") if p != exact),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in (exact, *others, folder / f"route_{year}.json"):
|
||||
try:
|
||||
stored = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]):
|
||||
return path, stored
|
||||
return None
|
||||
|
||||
|
||||
def read_segment(project_root: Path, seg: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""저장본 — 지점이 지금과 같을 때만(입력 지문은 분석 때 다시 봄)."""
|
||||
try:
|
||||
stored = json.loads(
|
||||
(derived_dir(project_root) / f"route_{int(seg['year'])}.json").read_text("utf-8")
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]):
|
||||
return stored
|
||||
return None
|
||||
found = _find(project_root, seg)
|
||||
return found[1] if found else None
|
||||
|
||||
|
||||
def _prune(project_root: Path, seg: dict[str, Any]) -> None:
|
||||
"""연도 분석본 — 최근 `KEEP_PER_YEAR` 개 + 저장 지점(`saved` · 없으면 seg) 것만 남김 · 옛 꼴은
|
||||
안 지움."""
|
||||
start, end = seg.get("saved") or (seg["start"], seg["end"])
|
||||
canon = _find(project_root, {"year": seg["year"], "start": start, "end": end})
|
||||
files = sorted(
|
||||
derived_dir(project_root).glob(f"route_{int(seg['year'])}_*.json"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in files[KEEP_PER_YEAR:]:
|
||||
if canon is None or path != canon[0]:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _route_view(result: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -174,7 +215,8 @@ def analyze_segment(
|
||||
},
|
||||
"routes": routes,
|
||||
}
|
||||
atomic_write_json(derived_dir(project_root) / f"route_{int(seg['year'])}.json", result)
|
||||
atomic_write_json(segment_path(project_root, seg), result)
|
||||
_prune(project_root, seg)
|
||||
return {**result, "stored": False}
|
||||
|
||||
|
||||
@@ -318,6 +360,35 @@ def chain(
|
||||
return rows
|
||||
|
||||
|
||||
def line_length(line: list) -> float:
|
||||
"""선 길이(m) — 짝: `lineLength`(같은 차례로 더함)."""
|
||||
total = 0.0
|
||||
for a, b in zip(line, line[1:]):
|
||||
total += math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
|
||||
return total
|
||||
|
||||
|
||||
def with_edits(
|
||||
results: list[dict[str, Any]], edits: dict[int, dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""고친 노선(`{mode, planned, metrics?}`)을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선
|
||||
길이. 그 갈래가 없거나 빈 갈래면 그대로."""
|
||||
out = []
|
||||
for result in results:
|
||||
edit = edits.get(int(result["year"]))
|
||||
route = (result.get("routes") or {}).get(edit["mode"]) if edit else None
|
||||
if not edit or route is None or "error" in route:
|
||||
out.append(result)
|
||||
continue
|
||||
metrics = {**(edit.get("metrics") or {}), "length_m": line_length(edit["planned"])}
|
||||
routes = {
|
||||
**result["routes"],
|
||||
edit["mode"]: {**route, "planned": edit["planned"], "metrics": metrics, "edited": True},
|
||||
}
|
||||
out.append({**result, "routes": routes})
|
||||
return out
|
||||
|
||||
|
||||
def joined_line(results: list[dict[str, Any]], rows: list[dict[str, Any]]) -> list[list[float]]:
|
||||
"""고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채)."""
|
||||
line: list[list[float]] = []
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Api.ts
|
||||
* D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] ·
|
||||
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄.
|
||||
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은
|
||||
* `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||||
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
|
||||
import type { ChainRow, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import type { PlanEdit } from "./logic/D03_PlanAnalysis_Edit";
|
||||
import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */
|
||||
export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null };
|
||||
|
||||
export interface PlanPointsView {
|
||||
year_start: number;
|
||||
@@ -29,7 +34,14 @@ export interface PlanJob {
|
||||
}
|
||||
|
||||
export interface PlanAnalysisView {
|
||||
years: Array<{ year: number; start: Vertex; end: Vertex; analysis: YearAnalysis | null }>;
|
||||
years: Array<{
|
||||
year: number;
|
||||
start: Vertex;
|
||||
end: Vertex;
|
||||
analysis: YearAnalysis | null;
|
||||
/** 저장된 꺾임점 고침(분석 지문이 같을 때만). */
|
||||
edit?: SavedEdit | null;
|
||||
}>;
|
||||
chain: ChainRow[] | null;
|
||||
/** 등록 노선 시작 누가(m). */
|
||||
start_chainage_m: number;
|
||||
@@ -83,10 +95,11 @@ export const savePlanAnalysis = (
|
||||
projectId: string,
|
||||
choices: Record<number, string>,
|
||||
points: PlanPoints | null,
|
||||
edits: Record<number, PlanEdit> = {},
|
||||
): Promise<{ chain: ChainRow[] }> =>
|
||||
request(`${base(projectId)}/analysis`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(points ? { choices, points } : { choices }),
|
||||
body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }),
|
||||
});
|
||||
|
||||
export interface YearSurface {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""D 예정노선 꺾임점 고침 [저장] 정본(요구 363-1 · PLAN 543-3).
|
||||
|
||||
- 받는 것 = 연도별 마디 · 곡선 켬끔 · R(화면이 그은 선은 안 받음)
|
||||
- 선 = B05 편집 갈래 `node_polyline`(Node `route.polyline` = 화면 `buildEditedPolyline` 같은 TS) ·
|
||||
못 돌림 = 파이썬 짝 `build_planned_polyline(simplify=False)`
|
||||
- 지표 = 엔진과 같은 `route_metrics` → `hard_limits` → `with_station_misses`(연도 지형 · 계류)
|
||||
- 처음 · 끝 마디 = 연도 경계 지점(1 mm) · 고친 갈래 · 분석 지문이 지금과 같아야 함
|
||||
|
||||
계산 자리 = ① 파이썬 · TS 짝 — 화면 `logic/D03_PlanAnalysis_Edit.ts`(조작 중 즉시) ↔
|
||||
여기([저장] 정본) · 거울 시험 `resources/tester/test_543_plan_edit.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
ROAD_WIDTH_M,
|
||||
STREAM_OFFSET_M,
|
||||
load_terrain_and_streams,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import route_metrics
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses
|
||||
from B05_Profile.B05_Profile_Route_Polyline_Write import node_polyline
|
||||
from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
|
||||
|
||||
# 끝 마디 = 지점 판정(m) — 짝: `D02_PlanBase_Router.SAME_M`
|
||||
SAME_M = 1e-3
|
||||
INPUT_KEYS = ("base_mode", "base_fingerprint", "nodes", "curve_flags", "radii")
|
||||
|
||||
|
||||
class EditError(ValueError):
|
||||
"""고침을 받을 수 없음 — 까닭 그대로 화면에."""
|
||||
|
||||
|
||||
def edited_line(edit: dict[str, Any], min_radius_m: float) -> PlannedPolyline:
|
||||
"""마디 · 켬끔 · R → 선(화면과 같은 편집 갈래)."""
|
||||
points = [(float(x), float(y)) for x, y in edit["nodes"]]
|
||||
flags = [bool(on) for on in edit["curve_flags"]]
|
||||
radii = [None if r is None else float(r) for r in edit["radii"]]
|
||||
built = node_polyline(points, min_radius_m, flags, radii, None, None)
|
||||
if built is not None:
|
||||
return built[0]
|
||||
return build_planned_polyline(
|
||||
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
|
||||
)
|
||||
|
||||
|
||||
def check_edit(edit: dict[str, Any], result: dict[str, Any], mode: str) -> None:
|
||||
"""고친 갈래 · 분석 지문 · 칸 수 · 끝 마디 = 지점."""
|
||||
year = int(result["year"])
|
||||
if edit["base_mode"] != mode:
|
||||
raise EditError(f"{year}년 고침이 고른 갈래와 다릅니다.")
|
||||
if edit["base_fingerprint"] != result.get("fingerprint"):
|
||||
raise EditError(f"{year}년 분석이 바뀌어 고침이 낡았습니다 — 다시 고치세요.")
|
||||
nodes = edit["nodes"]
|
||||
if len(nodes) < 2 or not (len(nodes) == len(edit["curve_flags"]) == len(edit["radii"])):
|
||||
raise EditError(f"{year}년 고침 마디 꼴이 다릅니다.")
|
||||
if any(r is not None and not float(r) > 0 for r in edit["radii"]):
|
||||
raise EditError(f"{year}년 R 은 0 보다 커야 합니다.")
|
||||
for got, want in ((nodes[0], result["start"]), (nodes[-1], result["end"])):
|
||||
if max(abs(float(a) - float(b)) for a, b in zip(got, want)) > SAME_M:
|
||||
raise EditError(f"{year}년 처음 · 끝 마디가 연도 경계 지점과 다릅니다.")
|
||||
|
||||
|
||||
def edit_metrics(
|
||||
line: PlannedPolyline, result: dict[str, Any], terrain: Any, streams: list
|
||||
) -> dict[str, Any]:
|
||||
"""엔진과 같은 잼 — 원지반 기울기는 참고(못 지킨 수에서 뺌) · 측점 밖 곡선 더함."""
|
||||
criteria = result["criteria"]
|
||||
metrics = route_metrics(
|
||||
line.vertices,
|
||||
[tuple(result["start"]), tuple(result["end"])],
|
||||
terrain,
|
||||
streams,
|
||||
line.curves,
|
||||
max_grade=float(criteria["max_grade_pct"]) / 100.0,
|
||||
min_radius_m=float(criteria["min_radius_m"]),
|
||||
stream_offset_m=STREAM_OFFSET_M,
|
||||
road_width_m=ROAD_WIDTH_M,
|
||||
bound_pct=None,
|
||||
)
|
||||
return with_station_misses(hard_limits(metrics), line)
|
||||
|
||||
|
||||
def saved_edit(route_json: Any) -> dict[str, Any] | None:
|
||||
"""저장된 고침(route_json `edit` + 그때 선 · 지표) — 없으면 None."""
|
||||
try:
|
||||
value = json.loads(route_json) if isinstance(route_json, str) else route_json
|
||||
except ValueError:
|
||||
return None
|
||||
edit = value.get("edit") if isinstance(value, dict) else None
|
||||
if not isinstance(edit, dict) or not all(key in edit for key in INPUT_KEYS):
|
||||
return None
|
||||
return {
|
||||
**{key: edit[key] for key in INPUT_KEYS},
|
||||
"planned": value.get("planned"),
|
||||
"nodes_info": value.get("nodes"),
|
||||
"curves": value.get("curves"),
|
||||
"metrics": value.get("metrics"),
|
||||
}
|
||||
|
||||
|
||||
def same_inputs(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
|
||||
"""같은 고침 입력인가(1 mm) — 같으면 저장된 선 · 지표를 다시 씀."""
|
||||
if not a or not b:
|
||||
return False
|
||||
|
||||
def near(x: Any, y: Any) -> bool:
|
||||
return (
|
||||
x is None
|
||||
and y is None
|
||||
or (x is not None and y is not None and abs(float(x) - float(y)) < 1e-3)
|
||||
)
|
||||
|
||||
return (
|
||||
a["base_mode"] == b["base_mode"]
|
||||
and a["base_fingerprint"] == b["base_fingerprint"]
|
||||
and len(a["nodes"]) == len(b["nodes"])
|
||||
and all(near(p[0], q[0]) and near(p[1], q[1]) for p, q in zip(a["nodes"], b["nodes"]))
|
||||
and [bool(v) for v in a["curve_flags"]] == [bool(v) for v in b["curve_flags"]]
|
||||
and all(near(x, y) for x, y in zip(a["radii"], b["radii"]))
|
||||
)
|
||||
|
||||
|
||||
def build_edits(
|
||||
project_root: Path,
|
||||
results: list[dict[str, Any]],
|
||||
rows: list[dict[str, Any]],
|
||||
edits: dict[int, dict[str, Any]],
|
||||
saved: dict[int, dict[str, Any] | None],
|
||||
loader: analyze.Loader = load_terrain_and_streams,
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
"""연도 → 고친 노선 `{mode, planned, nodes, curves, metrics, edit}` — 저장된 고침과 입력이
|
||||
같으면 그 선 · 지표 그대로(지형 안 읽음)."""
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
for result, row in zip(results, rows):
|
||||
year = int(result["year"])
|
||||
edit = edits.get(year)
|
||||
if edit is None:
|
||||
continue
|
||||
check_edit(edit, result, row["mode"])
|
||||
inputs = {key: edit[key] for key in INPUT_KEYS}
|
||||
old = saved.get(year)
|
||||
if old is not None and old.get("metrics") and same_inputs(inputs, old):
|
||||
out[year] = {
|
||||
"mode": row["mode"],
|
||||
"planned": old["planned"],
|
||||
"nodes": old["nodes_info"],
|
||||
"curves": old["curves"],
|
||||
"metrics": old["metrics"],
|
||||
"edit": inputs,
|
||||
}
|
||||
continue
|
||||
line = edited_line(inputs, float(result["criteria"]["min_radius_m"]))
|
||||
terrain, streams = analyze._load(project_root, year, loader)
|
||||
out[year] = {
|
||||
"mode": row["mode"],
|
||||
"planned": [[round(x, 4), round(y, 4)] for x, y in line.vertices],
|
||||
"nodes": [node.as_dict() for node in line.nodes],
|
||||
"curves": [curve.as_dict() for curve in line.curves],
|
||||
"metrics": edit_metrics(line, result, terrain, streams),
|
||||
"edit": inputs,
|
||||
}
|
||||
return out
|
||||
@@ -4,11 +4,14 @@
|
||||
- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본)
|
||||
- `GET …/plan/progress` — 두 일의 진행률
|
||||
- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선
|
||||
- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래 → `plan_route_years.route_json` · `fingerprint`
|
||||
- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래(+ 꺾임점 고침) → `plan_route_years.route_json` ·
|
||||
`fingerprint`
|
||||
|
||||
지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은
|
||||
`points`(작업 좌표계 m)로 받아 그 지점으로 받기 · 분석 · 보기 · [저장] 때만 D02 규칙으로 정본에 씀.
|
||||
계산 자리 = ③ 서버 단독 — [저장]은 노선을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈.
|
||||
꺾임점 고침(543)은 마디 · 켬끔 · R 만 받아 서버가 선 · 지표를 다시 셈(`D03_PlanAnalysis_Edit`) ·
|
||||
GET 은 분석 지문이 같은 저장 고침만 돌려줌.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,6 +34,7 @@ from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from D02_PlanBase import D02_PlanBase_Router as base
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as plan_edit
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,8 +58,19 @@ class PointsBody(BaseModel):
|
||||
points: list[PlanPoint] | None = None
|
||||
|
||||
|
||||
class PlanEditBody(BaseModel):
|
||||
"""연도 하나의 꺾임점 고침 — 고친 갈래 · 그 분석 지문 · 마디(작업 좌표계 m) · 곡선 켬끔 · R."""
|
||||
|
||||
base_mode: str
|
||||
base_fingerprint: str
|
||||
nodes: list[tuple[float, float]]
|
||||
curve_flags: list[bool]
|
||||
radii: list[float | None]
|
||||
|
||||
|
||||
class ChoiceRequest(PointsBody):
|
||||
choices: dict[int, str] # 연도 → 갈래
|
||||
edits: dict[int, PlanEditBody] | None = None # 연도 → 꺾임점 고침(없는 연도 = 엔진 노선)
|
||||
|
||||
|
||||
def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[dict[str, Any]]:
|
||||
@@ -64,7 +79,12 @@ def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[d
|
||||
if set(by_year) != {seg["year"] for seg in segments}:
|
||||
raise HTTPException(status_code=400, detail="끈 지점의 연도가 사업연도와 다릅니다.")
|
||||
out = [
|
||||
{**seg, "start": tuple(by_year[seg["year"]].start), "end": tuple(by_year[seg["year"]].end)}
|
||||
{
|
||||
**seg,
|
||||
"start": tuple(by_year[seg["year"]].start),
|
||||
"end": tuple(by_year[seg["year"]].end),
|
||||
"saved": (seg["start"], seg["end"]), # 저장 지점 — 그 분석본은 쌓임 거르기에서 남김
|
||||
}
|
||||
for seg in segments
|
||||
]
|
||||
for prev, seg in zip(out, out[1:]):
|
||||
@@ -220,14 +240,46 @@ def _saved_mode(route_json: Any) -> str | None:
|
||||
|
||||
|
||||
def _assemble(
|
||||
root: Path, project: dict[str, Any], segments: list[dict[str, Any]], choices: dict[int, Any]
|
||||
root: Path,
|
||||
project: dict[str, Any],
|
||||
segments: list[dict[str, Any]],
|
||||
choices: dict[int, Any],
|
||||
edits: dict[int, dict[str, Any]] | None = None,
|
||||
) -> tuple[list[dict[str, Any] | None], list[dict[str, Any]] | None, list[list[float]]]:
|
||||
"""(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선."""
|
||||
"""(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선.
|
||||
|
||||
`edits` = 연도 → 고친 노선(`{mode, planned, metrics}`) — 누가 · 선만 고친 길이로(저장본은 엔진
|
||||
그대로).
|
||||
"""
|
||||
results = [analyze.read_segment(root, seg) for seg in segments]
|
||||
if any(result is None for result in results):
|
||||
return results, None, []
|
||||
rows = analyze.chain(results, choices, float(project["route_start_m"] or 0.0))
|
||||
return results, rows, analyze.joined_line(results, rows)
|
||||
view = analyze.with_edits(results, edits or {}) # type: ignore[arg-type]
|
||||
rows = analyze.chain(view, choices, float(project["route_start_m"] or 0.0))
|
||||
return results, rows, analyze.joined_line(view, rows)
|
||||
|
||||
|
||||
def _saved_edits(
|
||||
root: Path, segments: list[dict[str, Any]], choices: dict[int, Any]
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
"""저장된 꺾임점 고침 — 저장 갈래 그대로 · 분석 지문이 지금과 같은 것만."""
|
||||
out = {}
|
||||
for seg in segments:
|
||||
saved = plan_edit.saved_edit(seg["route_json"])
|
||||
result = analyze.read_segment(root, seg)
|
||||
if (
|
||||
saved
|
||||
and result
|
||||
and saved["base_mode"] == choices.get(seg["year"])
|
||||
and saved["base_fingerprint"] == result.get("fingerprint")
|
||||
and saved.get("planned")
|
||||
):
|
||||
out[seg["year"]] = saved
|
||||
return out
|
||||
|
||||
|
||||
def _edit_view(saved: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: saved[key] for key in (*plan_edit.INPUT_KEYS, "metrics")}
|
||||
|
||||
|
||||
def _query_points(points: str | None) -> list[PlanPoint] | None:
|
||||
@@ -247,11 +299,22 @@ async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str
|
||||
"""
|
||||
root, project, segments = await _plan_inputs(project_id, _query_points(points))
|
||||
choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments}
|
||||
results, rows, line = await asyncio.to_thread(_assemble, root, project, segments, choices)
|
||||
saved = await asyncio.to_thread(_saved_edits, root, segments, choices)
|
||||
edits = {year: {"mode": e["base_mode"], **e} for year, e in saved.items()}
|
||||
results, rows, line = await asyncio.to_thread(
|
||||
_assemble, root, project, segments, choices, edits
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"years": [
|
||||
{"year": seg["year"], "start": seg["start"], "end": seg["end"], "analysis": result}
|
||||
{
|
||||
"year": seg["year"],
|
||||
"start": seg["start"],
|
||||
"end": seg["end"],
|
||||
"analysis": result,
|
||||
# 저장된 꺾임점 고침(입력 + 그때 지표) — 분석 지문이 같을 때만
|
||||
"edit": _edit_view(saved[seg["year"]]) if seg["year"] in saved else None,
|
||||
}
|
||||
for seg, result in zip(segments, results)
|
||||
],
|
||||
"chain": rows,
|
||||
@@ -297,7 +360,7 @@ async def save_plan_analysis(
|
||||
session: dict[str, Any] = Depends(require_company),
|
||||
) -> dict[str, Any]:
|
||||
"""[저장] — 끈 지점(있으면 정본으로) · 연도별 고른 갈래를 서버 저장본으로 다시 이어 씀."""
|
||||
if request.points and not is_forest_service(session):
|
||||
if (request.points or request.edits) and not is_forest_service(session):
|
||||
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
|
||||
root, project, segments = await _plan_inputs(project_id, request.points)
|
||||
bad = [m for m in request.choices.values() if m not in analyze.PLAN_MODES]
|
||||
@@ -313,6 +376,17 @@ async def save_plan_analysis(
|
||||
lost = [r["year"] for r in rows if choices.get(r["year"]) not in (None, r["mode"])]
|
||||
if lost:
|
||||
raise HTTPException(status_code=409, detail=f"고른 갈래에 노선이 없습니다: {lost}년")
|
||||
edited: dict[int, dict[str, Any]] = {}
|
||||
if request.edits:
|
||||
saved = {seg["year"]: plan_edit.saved_edit(seg["route_json"]) for seg in segments}
|
||||
asked = {year: body.model_dump() for year, body in request.edits.items()}
|
||||
try:
|
||||
edited = await asyncio.to_thread(
|
||||
plan_edit.build_edits, root, results, rows, asked, saved
|
||||
)
|
||||
except plan_edit.EditError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||
_, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices, edited)
|
||||
if request.points:
|
||||
await _store_points(project_id, segments)
|
||||
async with get_db_pool().acquire() as connection:
|
||||
@@ -320,8 +394,10 @@ async def save_plan_analysis(
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
for result, row in zip(results, rows):
|
||||
route = result["routes"][row["mode"]]
|
||||
route = edited.get(row["year"]) or result["routes"][row["mode"]]
|
||||
body = {**row, **{k: route[k] for k in ("planned", "nodes", "curves")}}
|
||||
if row["year"] in edited: # 꺾임점 고침 — 입력 · 지표 함께(GET 이 돌려줌)
|
||||
body.update(metrics=route["metrics"], edit=route["edit"])
|
||||
await cursor.execute(
|
||||
"UPDATE plan_route_years SET route_json = %s, fingerprint = %s "
|
||||
"WHERE project_id = %s AND year = %s",
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Edit.ts
|
||||
* D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) ·
|
||||
* R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`)
|
||||
* 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음.
|
||||
*
|
||||
* 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만.
|
||||
* 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
|
||||
import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import {
|
||||
insertNode,
|
||||
moveHandle,
|
||||
moveNode,
|
||||
removeNode,
|
||||
type EditStep,
|
||||
type PlanEdit,
|
||||
} from "./logic/D03_PlanAnalysis_Edit";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */
|
||||
const NODE_HIT_PX = 10;
|
||||
const HANDLE_HIT_PX = 8;
|
||||
const SEGMENT_HIT_PX = 10;
|
||||
|
||||
/** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */
|
||||
export interface EditTarget {
|
||||
year: number;
|
||||
edit: PlanEdit;
|
||||
line: EditedPolyline;
|
||||
minRadiusM: number;
|
||||
}
|
||||
|
||||
export interface PlanEditEvents {
|
||||
/** 고치기 직전 한 번(되돌리기 쌓기). */
|
||||
begin: () => void;
|
||||
/** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */
|
||||
change: (year: number, edit: PlanEdit, final: boolean) => void;
|
||||
/** 막힌 까닭(하한 · 끝 잠금). */
|
||||
blocked: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface PlanEditLayer extends PlanMapOverlay {
|
||||
setTargets: (targets: EditTarget[]) => void;
|
||||
targets: () => EditTarget[];
|
||||
}
|
||||
|
||||
type Grab = { year: number; index: number; which: "node" | "start" | "end" };
|
||||
|
||||
export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer {
|
||||
let targets: EditTarget[] = [];
|
||||
let grab: Grab | null = null;
|
||||
let moved = false;
|
||||
let stop = "";
|
||||
|
||||
const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1);
|
||||
|
||||
function apply(target: EditTarget, step: EditStep, final: boolean): void {
|
||||
if ("blocked" in step) {
|
||||
stop = step.blocked;
|
||||
if (final) events.blocked(stop);
|
||||
return;
|
||||
}
|
||||
target.edit = step.edit;
|
||||
target.line = step.line;
|
||||
events.change(target.year, step.edit, final);
|
||||
}
|
||||
|
||||
return {
|
||||
setTargets(next) {
|
||||
targets = next;
|
||||
},
|
||||
targets: () => targets,
|
||||
|
||||
draw(context, map) {
|
||||
context.save();
|
||||
context.font = "11px system-ui, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "bottom";
|
||||
for (const target of targets) {
|
||||
for (const curve of target.line.curves) {
|
||||
for (const end of [curve.start, curve.end]) {
|
||||
const [x, y] = map.toScreen(end);
|
||||
context.beginPath();
|
||||
context.arc(x, y, 4, 0, Math.PI * 2);
|
||||
context.fillStyle = "#f59e0b";
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
const [ax, ay] = map.toScreen(curve.apex);
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
|
||||
context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
|
||||
context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff";
|
||||
context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
|
||||
}
|
||||
interior(target.edit).forEach((node, i) => {
|
||||
const [x, y] = map.toScreen(node);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(x - 4.5, y - 4.5, 9, 9);
|
||||
context.lineWidth = 2;
|
||||
const bad = target.line.nodes[i + 1]?.violations.length;
|
||||
context.strokeStyle = bad ? "#dc2626" : "#2563eb";
|
||||
context.strokeRect(x - 4.5, y - 4.5, 9, 9);
|
||||
});
|
||||
}
|
||||
context.restore();
|
||||
},
|
||||
|
||||
grab(px, py, map) {
|
||||
grab = null;
|
||||
moved = false;
|
||||
stop = "";
|
||||
for (const target of targets) {
|
||||
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
|
||||
if (node >= 0) {
|
||||
grab = { year: target.year, index: node + 1, which: "node" };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const target of targets) {
|
||||
const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX);
|
||||
if (hit) {
|
||||
grab = { year: target.year, index: hit.node, which: hit.end };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
move(point) {
|
||||
const target = grab && targets.find((entry) => entry.year === grab!.year);
|
||||
if (!grab || !target) return;
|
||||
if (!moved) events.begin();
|
||||
moved = true;
|
||||
const step =
|
||||
grab.which === "node"
|
||||
? moveNode(target.edit, grab.index, point, target.minRadiusM)
|
||||
: moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM);
|
||||
if (!("blocked" in step)) stop = "";
|
||||
apply(target, step, false);
|
||||
},
|
||||
|
||||
release() {
|
||||
const target = grab && targets.find((entry) => entry.year === grab!.year);
|
||||
grab = null;
|
||||
if (!moved || !target) return;
|
||||
moved = false;
|
||||
events.change(target.year, target.edit, true);
|
||||
if (stop) events.blocked(stop);
|
||||
},
|
||||
|
||||
insert(px: number, py: number, map: ScreenMap) {
|
||||
for (const target of targets) {
|
||||
const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX);
|
||||
if (segment < 0) continue;
|
||||
events.begin();
|
||||
apply(
|
||||
target,
|
||||
insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
remove(px: number, py: number, map: ScreenMap) {
|
||||
for (const target of targets) {
|
||||
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
|
||||
if (node < 0) continue;
|
||||
events.begin();
|
||||
apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` ·
|
||||
* 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선
|
||||
* `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시).
|
||||
* 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
@@ -60,10 +61,27 @@ export interface PlanMapScene {
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
/** 화면 px ↔ 작업 좌표계 m. */
|
||||
export interface ScreenMap {
|
||||
toScreen: (point: Vertex) => [number, number];
|
||||
toMetric: (px: number, py: number) => Vertex;
|
||||
}
|
||||
|
||||
/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */
|
||||
export interface PlanMapOverlay {
|
||||
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
|
||||
grab: (px: number, py: number, map: ScreenMap) => boolean;
|
||||
move: (point: Vertex) => void;
|
||||
release: () => void;
|
||||
insert: (px: number, py: number, map: ScreenMap) => void;
|
||||
remove: (px: number, py: number, map: ScreenMap) => void;
|
||||
}
|
||||
|
||||
export interface PlanMapEvents {
|
||||
onDrag: (index: number, point: Vertex) => void;
|
||||
onDragEnd: (index: number) => void;
|
||||
onStation: (chainage: number) => void;
|
||||
overlay?: PlanMapOverlay;
|
||||
}
|
||||
|
||||
export interface PlanMap {
|
||||
@@ -73,6 +91,8 @@ export interface PlanMap {
|
||||
fit: () => void;
|
||||
/** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */
|
||||
stationScreen: (chainage: number) => [number, number] | null;
|
||||
/** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */
|
||||
screenOf: (point: Vertex) => [number, number] | null;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -133,6 +153,10 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
|
||||
const toScreen = (point: Vertex): [number, number] =>
|
||||
createMetricProjector(meta!, view).toScreen(point[0], point[1]);
|
||||
const screenMap: ScreenMap = {
|
||||
toScreen,
|
||||
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
|
||||
};
|
||||
|
||||
function refit(): void {
|
||||
const all = [...scene.nodes, ...scene.line];
|
||||
@@ -228,6 +252,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (scene.editable) events.overlay?.draw(context, screenMap);
|
||||
scene.nodes.forEach((node, i) => {
|
||||
const [sx, sy] = toScreen(node);
|
||||
const last = i === scene.nodes.length - 1;
|
||||
@@ -295,11 +320,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
press = [event.clientX, event.clientY];
|
||||
if (!meta || !scene.editable || event.button !== 0) return;
|
||||
const index = nodeAt(...at(event));
|
||||
if (index < 0) return;
|
||||
event.stopImmediatePropagation();
|
||||
const [px, py] = at(event);
|
||||
const [sx, sy] = toScreen(scene.nodes[index]);
|
||||
// 지점 먼저 · 다음 고침 겹(index -1)
|
||||
const index = nodeAt(px, py);
|
||||
if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return;
|
||||
event.stopImmediatePropagation();
|
||||
const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]);
|
||||
dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false };
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
canvas.style.cursor = "grabbing";
|
||||
@@ -315,14 +341,17 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (!dragging.moved && !far) return;
|
||||
dragging.moved = true;
|
||||
const [px, py] = at(event);
|
||||
events.onDrag(dragging.index, screenToMetric(meta, view, px + dragging.dx, py + dragging.dy));
|
||||
const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy);
|
||||
if (dragging.index < 0) events.overlay?.move(point);
|
||||
else events.onDrag(dragging.index, point);
|
||||
});
|
||||
const endDrag = (event: PointerEvent): void => {
|
||||
if (!dragging || event.pointerId !== dragging.id) return;
|
||||
const { index, moved } = dragging;
|
||||
dragging = null;
|
||||
canvas.style.cursor = "";
|
||||
if (moved) events.onDragEnd(index);
|
||||
if (index < 0) events.overlay?.release();
|
||||
else if (moved) events.onDragEnd(index);
|
||||
};
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
@@ -353,6 +382,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (hit) events.onStation(hit.chainage);
|
||||
});
|
||||
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap);
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
if (!meta || !scene.editable) return;
|
||||
event.preventDefault();
|
||||
events.overlay?.remove(...at(event), screenMap);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => resize());
|
||||
observer.observe(root);
|
||||
|
||||
@@ -369,6 +407,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
draw();
|
||||
},
|
||||
stationScreen,
|
||||
screenOf: (point) => (meta ? toScreen(point) : null),
|
||||
dispose: () => observer.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
* 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D].
|
||||
* 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」.
|
||||
*
|
||||
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis
|
||||
* (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만.
|
||||
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] =
|
||||
* PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은
|
||||
* 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 ·
|
||||
* 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만.
|
||||
* 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -45,11 +47,13 @@ import {
|
||||
savePlanAnalysis,
|
||||
startPlanJob,
|
||||
type PlanAnalysisView,
|
||||
type SavedEdit,
|
||||
type PlanJob,
|
||||
type YearSurface,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross";
|
||||
import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
import {
|
||||
chainRows,
|
||||
@@ -61,10 +65,19 @@ import {
|
||||
rowAt,
|
||||
sameNodes,
|
||||
stationChainages,
|
||||
withEdits,
|
||||
type ChainRow,
|
||||
type RouteEdit,
|
||||
type Vertex,
|
||||
type YearAnalysis,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
buildEdit,
|
||||
editFromRoute,
|
||||
moveNode,
|
||||
sameEdit,
|
||||
type PlanEdit,
|
||||
} from "./logic/D03_PlanAnalysis_Edit";
|
||||
|
||||
const MODE_LABEL: Record<string, string> = {
|
||||
gradeline: "영선",
|
||||
@@ -76,18 +89,30 @@ const POLL_MS = 1000;
|
||||
/** 등고선을 받아 올 지점 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 50;
|
||||
|
||||
interface Draft {
|
||||
nodes: Vertex[] | null;
|
||||
choices: Record<number, string>;
|
||||
/** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */
|
||||
edits?: Record<number, PlanEdit>;
|
||||
}
|
||||
|
||||
type Snapshot = { edits: Record<number, PlanEdit>; choices: Record<number, string> };
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
|
||||
const errorText = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
const map = createPlanMap({ onDrag, onDragEnd, onStation });
|
||||
const editLayer = createPlanEditLayer({
|
||||
begin: remember,
|
||||
change: onEdit,
|
||||
blocked: (message) => showToast(message, "info"),
|
||||
});
|
||||
const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer });
|
||||
const cross = createPlanCross();
|
||||
const main = document.createElement("div");
|
||||
main.className = "d03-plan";
|
||||
@@ -114,9 +139,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
|
||||
const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() });
|
||||
const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() });
|
||||
undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(analyzeButton, saveButton, viewButton);
|
||||
actions.append(analyzeButton, saveButton, viewButton, undoButton);
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
terrainCard.append(terrainText, bar, more);
|
||||
const routeCard = createPanelCard("연도별 노선", false);
|
||||
@@ -142,6 +169,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let editable = false;
|
||||
let busy: "terrain" | "analyze" | null = null;
|
||||
let lastCross: StationGround | null = null;
|
||||
// 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기
|
||||
let edits: Record<number, PlanEdit> = {};
|
||||
let serverEdits: Record<number, SavedEdit> = {};
|
||||
let draftEdits: Record<number, PlanEdit> | undefined;
|
||||
let history: Snapshot[] = [];
|
||||
let lastSaveMs: number | null = null;
|
||||
|
||||
const moved = (): boolean => !sameNodes(nodes, savedNodes);
|
||||
const sentPoints = () => (moved() ? pointsOf(years, nodes) : null);
|
||||
@@ -161,11 +194,54 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}),
|
||||
);
|
||||
const keepDraft = (): void =>
|
||||
writeState("d03-plan", { nodes: moved() ? nodes : null, choices } satisfies Draft, projectId);
|
||||
writeState(
|
||||
"d03-plan",
|
||||
{ nodes: moved() ? nodes : null, choices, edits } satisfies Draft,
|
||||
projectId,
|
||||
);
|
||||
|
||||
/** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */
|
||||
function remember(): void {
|
||||
history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })];
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
const last = history.pop();
|
||||
if (!last) return;
|
||||
({ edits, choices } = last);
|
||||
keepDraft();
|
||||
refresh();
|
||||
repick();
|
||||
}
|
||||
|
||||
/** 누른 측점 횡단을 지금 노선으로 다시. */
|
||||
function repick(): void {
|
||||
if (selected !== null && rowAt(rows, selected)) onStation(selected);
|
||||
}
|
||||
|
||||
function onEdit(year: number, edit: PlanEdit, final: boolean): void {
|
||||
edits = { ...edits, [year]: edit };
|
||||
refresh();
|
||||
if (!final) return;
|
||||
keepDraft();
|
||||
repick();
|
||||
}
|
||||
|
||||
/** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */
|
||||
const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => {
|
||||
const own = edits[result.year];
|
||||
return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint
|
||||
? own
|
||||
: null;
|
||||
};
|
||||
|
||||
/** 연도 목록에서 뺌. */
|
||||
const without = (from: Record<number, PlanEdit>, drop: number[]): Record<number, PlanEdit> =>
|
||||
Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year))));
|
||||
|
||||
/** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */
|
||||
function refresh(): void {
|
||||
results = years.map(
|
||||
const raw: YearAnalysis[] = years.map(
|
||||
(year, i) =>
|
||||
analysis?.years.find((entry) => entry.year === year)?.analysis ?? {
|
||||
year,
|
||||
@@ -176,7 +252,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
// 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름
|
||||
const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode]));
|
||||
rows = chainRows(results, { ...saved, ...choices }, startM);
|
||||
const picks = { ...saved, ...choices };
|
||||
// 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움
|
||||
const targets: EditTarget[] = [];
|
||||
const routed: Record<number, RouteEdit> = {};
|
||||
chainRows(raw, picks, startM).forEach((row, i) => {
|
||||
const result = raw[i];
|
||||
const route = row.mode ? result.routes[row.mode] : undefined;
|
||||
if (!row.mode || !route?.nodes || !result.fingerprint) return;
|
||||
const minRadiusM = result.criteria?.min_radius_m ?? 0;
|
||||
const own = editOf(result, row.mode);
|
||||
const built = buildEdit(
|
||||
own ?? editFromRoute(route, row.mode, result.fingerprint),
|
||||
minRadiusM,
|
||||
);
|
||||
targets.push({ year: result.year, ...built, minRadiusM });
|
||||
if (own)
|
||||
routed[result.year] = {
|
||||
mode: row.mode,
|
||||
planned: built.line.vertices,
|
||||
metrics: sameEdit(own, serverEdits[result.year])
|
||||
? serverEdits[result.year].metrics
|
||||
: null,
|
||||
};
|
||||
});
|
||||
editLayer.setTargets(editable ? targets : []);
|
||||
results = withEdits(raw, routed);
|
||||
rows = chainRows(results, picks, startM);
|
||||
const line = joinedLine(results, rows);
|
||||
const others: Vertex[][] = results.flatMap((result, i) => {
|
||||
const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode)
|
||||
@@ -210,6 +312,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요."
|
||||
: "";
|
||||
viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year));
|
||||
undoButton.hidden = !editable;
|
||||
undoButton.disabled = !history.length;
|
||||
}
|
||||
|
||||
function renderYears(): void {
|
||||
@@ -226,6 +330,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
`${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)`
|
||||
: `${result.year}년 · 분석 전 — [분석]을 누르세요`;
|
||||
box.append(head);
|
||||
if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year));
|
||||
for (const mode of PLAN_MODES) {
|
||||
const route = result.routes[mode];
|
||||
if (!route) continue;
|
||||
@@ -239,11 +344,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = MODE_LABEL[mode];
|
||||
const values = document.createElement("span");
|
||||
values.textContent = metrics
|
||||
? `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${(metrics.max_grade_pct ?? 0).toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`
|
||||
: "노선 없음";
|
||||
values.textContent = !metrics
|
||||
? "노선 없음"
|
||||
: metrics.max_grade_pct === undefined
|
||||
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
|
||||
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`;
|
||||
card.append(name, values);
|
||||
card.addEventListener("click", () => {
|
||||
if (row.mode === mode) return;
|
||||
remember();
|
||||
choices = { ...choices, [result.year]: mode };
|
||||
keepDraft();
|
||||
refresh();
|
||||
@@ -256,6 +365,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */
|
||||
function editLine(year: number): HTMLElement {
|
||||
const line = document.createElement("div");
|
||||
line.className = "d03-plan__year-head";
|
||||
line.textContent = sameEdit(edits[year], serverEdits[year])
|
||||
? "꺾임점 고침(저장됨) "
|
||||
: "꺾임점 고침 — [저장] 전 ";
|
||||
const reset = createButton({
|
||||
label: "원래 노선",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
remember();
|
||||
edits = without(edits, [year]);
|
||||
keepDraft();
|
||||
refresh();
|
||||
repick();
|
||||
},
|
||||
});
|
||||
reset.hidden = !editable;
|
||||
line.append(reset);
|
||||
return line;
|
||||
}
|
||||
|
||||
/** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */
|
||||
function targetLine(): HTMLElement[] {
|
||||
const last = rows[rows.length - 1];
|
||||
@@ -276,7 +409,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
function onDragEnd(index: number): void {
|
||||
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석]
|
||||
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림
|
||||
const lost = [years[index - 1], years[index]].filter(
|
||||
(year) => year !== undefined && edits[year],
|
||||
);
|
||||
if (lost.length) {
|
||||
edits = without(edits, lost);
|
||||
history = [];
|
||||
showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
|
||||
}
|
||||
if (analysis)
|
||||
analysis = {
|
||||
...analysis,
|
||||
@@ -357,6 +498,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
analysis = await fetchPlanAnalysis(projectId, sentPoints());
|
||||
startM = analysis.start_chainage_m;
|
||||
targetM = analysis.end_chainage_target_m ?? null;
|
||||
serverEdits = Object.fromEntries(
|
||||
analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])),
|
||||
);
|
||||
edits =
|
||||
draftEdits ??
|
||||
Object.fromEntries(
|
||||
Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]),
|
||||
);
|
||||
// 분석이 바뀐 연도(지문 다름)의 고침은 버림
|
||||
const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint]));
|
||||
const stale = Object.keys(edits)
|
||||
.map(Number)
|
||||
.filter((year) => prints.get(year) !== edits[year].base_fingerprint);
|
||||
if (stale.length) {
|
||||
edits = without(edits, stale);
|
||||
if (draftEdits)
|
||||
showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
|
||||
}
|
||||
draftEdits = edits;
|
||||
} catch (error) {
|
||||
analysis = null;
|
||||
showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
@@ -422,10 +582,20 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const picked = Object.fromEntries(
|
||||
rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]),
|
||||
);
|
||||
const sent = Object.fromEntries(
|
||||
results.flatMap((result, i) => {
|
||||
const edit = editOf(result, rows[i].mode);
|
||||
return edit ? [[result.year, edit]] : [];
|
||||
}),
|
||||
);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await savePlanAnalysis(projectId, picked, sentPoints());
|
||||
const began = performance.now();
|
||||
await savePlanAnalysis(projectId, picked, sentPoints(), sent);
|
||||
lastSaveMs = Math.round(performance.now() - began);
|
||||
clearState("d03-plan", projectId);
|
||||
draftEdits = undefined;
|
||||
history = [];
|
||||
await loadPoints();
|
||||
await loadAnalysis();
|
||||
refresh();
|
||||
@@ -469,17 +639,42 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const draft = readState<Draft>("d03-plan", projectId);
|
||||
nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes;
|
||||
choices = draft?.choices ?? {};
|
||||
draftEdits = draft?.edits;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정)
|
||||
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정)
|
||||
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
|
||||
stationScreen: map.stationScreen,
|
||||
screenOf: map.screenOf,
|
||||
rows: () => rows,
|
||||
nodes: () => nodes,
|
||||
pick: onStation,
|
||||
cross: () => lastCross,
|
||||
edits: () => edits,
|
||||
targets: () => editLayer.targets(),
|
||||
results: () => results,
|
||||
saveMs: () => lastSaveMs,
|
||||
// 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길)
|
||||
nudge: (index: number, dx: number, dy: number) => {
|
||||
const target = editLayer.targets()[0];
|
||||
const node = target?.edit.nodes[index];
|
||||
if (!node) return;
|
||||
const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM);
|
||||
if ("blocked" in step) return showToast(step.blocked, "info");
|
||||
remember();
|
||||
onEdit(target.year, step.edit, true);
|
||||
},
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
|
||||
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
|
||||
if (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Edit.ts
|
||||
* D 예정노선 꺾임점 고침 계산(PLAN 543-1) — 고른 갈래 노선의 마디(IP) 끌기 · 넣기 · 지우기 · 곡선 손잡이
|
||||
* = R. DOM · fetch · sessionStorage 없음.
|
||||
*
|
||||
* 셈은 B05 편집기 순수 부품 그대로(import 만) — 서버 노드 펴기 `flattenServerPlan` · 다시 그음
|
||||
* `rebuildLine`(R 하한 끌어올림 + `buildEditedPolyline`) · 마디 넣기 · 빼기 `spliceNode` · 손잡이 →
|
||||
* 교각점 · R `dragHandleTo` · 하한 새로 깸 막기 `floorStop`.
|
||||
* 처음 · 끝 마디 = 연도 경계 지점 → 잠금(옮기면 연도 사이가 끊김 · 지점은 지점 끌기로).
|
||||
*
|
||||
* ⚠ 서버 짝 — [저장] 때 `D03_PlanAnalysis_Edit.py` `edited_line` 이 같은 마디 · 켬끔 · R 로 선을 다시
|
||||
* 그음(Node `route.polyline` = 같은 `buildEditedPolyline` · 폴백 `build_planned_polyline`) · 거울 시험
|
||||
* `resources/tester/test_543_plan_edit.py`.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
flattenServerPlan,
|
||||
floorStop,
|
||||
rebuildLine,
|
||||
spliceNode,
|
||||
type CurveLimits,
|
||||
type NodeColumns,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
dragHandleTo,
|
||||
type EditedCurve,
|
||||
type EditedPolyline,
|
||||
type Vertex,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import type { PlanRoute } from "./D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 연도 하나의 꺾임점 고침(캐시 · [저장] 입력) — 고친 갈래 · 그 분석 지문 · 마디 · 곡선 켬끔 · R. */
|
||||
export interface PlanEdit {
|
||||
base_mode: string;
|
||||
base_fingerprint: string;
|
||||
nodes: Vertex[];
|
||||
curve_flags: boolean[];
|
||||
/** 넣은 R(하한까지 끌어올린 값) · null = 법정 최소 R. */
|
||||
radii: Array<number | null>;
|
||||
}
|
||||
|
||||
export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string };
|
||||
|
||||
const limitsOf = (minRadiusM: number): CurveLimits => ({
|
||||
minRadiusM,
|
||||
limitRadiusM: minRadiusM,
|
||||
limitArcM: 0,
|
||||
});
|
||||
|
||||
const columnsOf = (edit: PlanEdit): NodeColumns => ({
|
||||
planned: edit.nodes.map(([x, y]) => [x, y] as Vertex),
|
||||
curveOn: [...edit.curve_flags],
|
||||
curveRadius: [...edit.radii],
|
||||
curveLock: edit.nodes.map(() => null),
|
||||
curveArc: edit.nodes.map(() => null),
|
||||
apexLock: edit.nodes.map(() => false),
|
||||
});
|
||||
|
||||
/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */
|
||||
export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit {
|
||||
const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]);
|
||||
return {
|
||||
base_mode: mode,
|
||||
base_fingerprint: fingerprint,
|
||||
nodes: flat.planned,
|
||||
curve_flags: flat.curveOn,
|
||||
radii: flat.curveRadius,
|
||||
};
|
||||
}
|
||||
|
||||
/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */
|
||||
export function buildEdit(
|
||||
edit: PlanEdit,
|
||||
minRadiusM: number,
|
||||
): { edit: PlanEdit; line: EditedPolyline } {
|
||||
const columns = columnsOf(edit);
|
||||
const line = rebuildLine(columns, limitsOf(minRadiusM));
|
||||
return { edit: { ...edit, radii: columns.curveRadius }, line };
|
||||
}
|
||||
|
||||
/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */
|
||||
function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep {
|
||||
const built = buildEdit(after, minRadiusM);
|
||||
const stop = floorStop(
|
||||
columnsOf(before),
|
||||
built.edit.nodes,
|
||||
built.line.nodes,
|
||||
limitsOf(minRadiusM),
|
||||
);
|
||||
return stop ? { blocked: stop } : built;
|
||||
}
|
||||
|
||||
const isEnd = (edit: PlanEdit, index: number): boolean =>
|
||||
index <= 0 || index >= edit.nodes.length - 1;
|
||||
const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다.";
|
||||
|
||||
/** 마디 끌기 — 처음 · 끝 잠금. */
|
||||
export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep {
|
||||
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
|
||||
const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node));
|
||||
return settle(edit, { ...edit, nodes }, minRadiusM);
|
||||
}
|
||||
|
||||
/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */
|
||||
export function moveHandle(
|
||||
edit: PlanEdit,
|
||||
index: number,
|
||||
which: "start" | "end",
|
||||
to: Vertex,
|
||||
minRadiusM: number,
|
||||
): EditStep {
|
||||
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
|
||||
const got = dragHandleTo(
|
||||
edit.nodes[index - 1],
|
||||
edit.nodes[index],
|
||||
edit.nodes[index + 1],
|
||||
which,
|
||||
to,
|
||||
);
|
||||
if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." };
|
||||
const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node));
|
||||
const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r));
|
||||
const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on));
|
||||
return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM);
|
||||
}
|
||||
|
||||
/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */
|
||||
export function insertNode(
|
||||
edit: PlanEdit,
|
||||
segment: number,
|
||||
at: Vertex,
|
||||
minRadiusM: number,
|
||||
): EditStep {
|
||||
if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." };
|
||||
const columns = columnsOf(edit);
|
||||
spliceNode(columns, segment + 1, [at[0], at[1]]);
|
||||
const after = {
|
||||
...edit,
|
||||
nodes: columns.planned,
|
||||
curve_flags: columns.curveOn,
|
||||
radii: columns.curveRadius,
|
||||
};
|
||||
return settle(edit, after, minRadiusM);
|
||||
}
|
||||
|
||||
/** 마디 지우기 — 처음 · 끝 잠금. */
|
||||
export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep {
|
||||
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
|
||||
const columns = columnsOf(edit);
|
||||
spliceNode(columns, index);
|
||||
const after = {
|
||||
...edit,
|
||||
nodes: columns.planned,
|
||||
curve_flags: columns.curveOn,
|
||||
radii: columns.curveRadius,
|
||||
};
|
||||
return settle(edit, after, minRadiusM);
|
||||
}
|
||||
|
||||
/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */
|
||||
export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
const near = (x: number | null, y: number | null): boolean =>
|
||||
x === null || y === null ? x === y : Math.abs(x - y) < 1e-3;
|
||||
return (
|
||||
a.base_mode === b.base_mode &&
|
||||
a.base_fingerprint === b.base_fingerprint &&
|
||||
a.nodes.length === b.nodes.length &&
|
||||
a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) &&
|
||||
a.curve_flags.every((on, i) => on === b.curve_flags[i]) &&
|
||||
a.radii.every((r, i) => near(r, b.radii[i] ?? null))
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
* D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 ·
|
||||
* 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음.
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` ↔
|
||||
* `pickMode` · `chainRows` · `joinedLine`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험
|
||||
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔
|
||||
* `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험
|
||||
* `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -18,13 +18,26 @@ export type PlanMode = (typeof PLAN_MODES)[number];
|
||||
* 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */
|
||||
export const PLAN_REACH_M = 300;
|
||||
|
||||
export interface RouteMetrics {
|
||||
length_m: number;
|
||||
max_grade_pct?: number;
|
||||
over_limit?: { total: number };
|
||||
}
|
||||
|
||||
export interface PlanRoute {
|
||||
planned?: Vertex[];
|
||||
metrics?: {
|
||||
length_m: number;
|
||||
max_grade_pct?: number;
|
||||
over_limit?: { total: number };
|
||||
};
|
||||
/** 엔진 노드 · 곡선(B05 `RouteNode` · `RouteCurve` 꼴) — 꺾임점 고침의 시작 마디. */
|
||||
nodes?: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
radius_m: number | null;
|
||||
inner_angle_deg: number | null;
|
||||
violations?: string[];
|
||||
}>;
|
||||
curves?: unknown[];
|
||||
metrics?: RouteMetrics;
|
||||
/** 꺾임점을 고친 노선(`withEdits`). */
|
||||
edited?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -32,9 +45,19 @@ export interface YearAnalysis {
|
||||
year: number;
|
||||
start: Vertex;
|
||||
end: Vertex;
|
||||
/** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */
|
||||
fingerprint?: string;
|
||||
criteria?: { min_radius_m: number; max_grade_pct?: number };
|
||||
routes: Record<string, PlanRoute>;
|
||||
}
|
||||
|
||||
/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */
|
||||
export interface RouteEdit {
|
||||
mode: string;
|
||||
planned: Vertex[];
|
||||
metrics?: RouteMetrics | null;
|
||||
}
|
||||
|
||||
export interface ChainRow {
|
||||
year: number;
|
||||
mode: string | null;
|
||||
@@ -75,6 +98,35 @@ export function chainRows(
|
||||
});
|
||||
}
|
||||
|
||||
/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */
|
||||
export function lineLength(line: ReadonlyArray<Vertex>): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < line.length; i += 1)
|
||||
total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감).
|
||||
* 그 갈래가 없거나 빈 갈래면 그대로. */
|
||||
export function withEdits(
|
||||
results: ReadonlyArray<YearAnalysis>,
|
||||
edits: Readonly<Record<number, RouteEdit | undefined>>,
|
||||
): YearAnalysis[] {
|
||||
return results.map((result) => {
|
||||
const edit = edits[result.year];
|
||||
const route = edit ? result.routes?.[edit.mode] : undefined;
|
||||
if (!edit || !route || "error" in route) return result;
|
||||
const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) };
|
||||
return {
|
||||
...result,
|
||||
routes: {
|
||||
...result.routes,
|
||||
[edit.mode]: { ...route, planned: edit.planned, metrics, edited: true },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const samePoint = (a: Vertex, b: Vertex): boolean =>
|
||||
Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# PLAN 543~545 — 요구 363 · 364 예정노선 꺾임점 고침 · 예상 횡단 · 3D · 측점 표기 (2026-10-26 완료 · 이관)
|
||||
|
||||
## 543. 예정노선 노선 꺾임점 고침 (요구 363-1 · 사용자 진행 · sub3 · Opus high)
|
||||
|
||||
- 정함(브레인): 근거 `docs/raw/요구_1025/_분석_363_예정노선_지점_횡단.md` ③ · 고칠 자리 = D03 평면(측점 · 횡단 · 3D 가 한 화면) · D02 다음맵 = 지금대로 연도 경계 지점만(이미 끌기 됨) · 고침 = 고른 갈래 노선 꺾임점 끌기 · 넣기 · 지우기 · R 손잡이 · 처음 · 끝 잠금 · 법정 최소 R 밑 막음 · B05 순수 함수 가져다 씀(B 고침 0) · 브라우저 즉시 다시 셈 · [저장] = 서버 정본 · 권한 = 산림청 그대로.
|
||||
- [x] 543-1 꺾임점 고침 순수 함수 + 이은 누가 짝(`logic/D03_PlanAnalysis_Edit.ts` 새 · `Plan.ts` · `Analyze.py`) · 시험(끝점 그대로 · 길이 · 거울).
|
||||
- 검증완료 — test_543 6 · test_530_3 2 통과 · 끌기 · 손잡이 · 넣기 · 지우기 뒤 끝점 그대로 · 길이 = lineLength · 브라우저 선 = 서버 선 1 mm(Node · 파이썬 폴백)
|
||||
- [x] 543-2 평면 마디 겹 · 캐시 · 되돌리기 · 카드(`UI_Map` · 새 `UI_Edit` · `UI_Page` · `Api`) · ORCA 끌기 뒤 연장 · 측점 · 횡단 바뀜.
|
||||
- 검증완료 — 가이드 D 2027 끌기 연장 365.26→359.772 m · 끝 측점 18+5.3→17+19.8 · 10+0 횡단 지반고 847.22→854.67 m · 넣기 6→7 · 지우기 7→6 · 끝 마디 잠금 · 손잡이 R 55.97→61.02 · 새로고침 뒤 고침 유지 · 되돌리기 · 원래 노선 365.26(ORCA · Playwright · 저장 안 함 · 캐시 비움)
|
||||
- [x] 543-3 [저장] 정본 + GET 돌려줌(`Router.py` · 새 `D03_PlanAnalysis_Edit.py`) · 시험(브라우저 선 = 서버 선 1 mm · 저장 시간).
|
||||
- 검증완료 — 검증 D 「검증_sub3 예정노선」 끌기 뒤 [저장] 브라우저 선 = 서버 선 최대 0.06 mm · 점 60 = 60 · 연장 382.265 = 382.265 m · 저장 6.7 s(연도 지형 읽기 포함) · 새로고침 「저장됨」 + 서버 지표(최대 34.3 % · 위반 3) · test_530_2 8 통과
|
||||
|
||||
## 544. 예정노선 예상 횡단도 · 3D (요구 363-1 · 사용자 진행 · sub2 · Opus high)
|
||||
|
||||
- 정함(브레인): 예상 횡단 = B 공용 `computeCrossDesign`(파이썬 짝 있음) + 표준 횡단 기본값 + B 초기 설계 기본(리핑암 · 산쪽 절토 · 일반 측구) · 계획고 = 중심 지반고(B 첫 종단과 같음) · 3D = 같은 단면 → B05 `buildCorridor` → D03 3D 창 「예상 코리도」 켬끔 · 543 과 파일 안 겹침.
|
||||
- [x] 544-1 예상 단면 순수 함수(`logic/D03_PlanAnalysis_Expect.ts` 새) · 시험(절 · 성토 ㎡ = 파이썬 값).
|
||||
- 검증완료 — 시험 test_544_1 2 통과(비탈 표본 셋 · 미교차 넓힘 — TS 설계선 · 절 · 성토 ㎡ = 파이썬 compute_cross_design 1e-9) · typecheck(f3269f7f2)
|
||||
- [x] 544-2 횡단 패널에 원지반선 + 예상 단면 · 절 · 성토 ㎡(`UI_Cross`) · ORCA 가이드 D 프로젝트 측점 누름 수치.
|
||||
- 검증완료 — ORCA 가이드 D 2027 5+0 머리 「절토 3.30㎡ · 성토 3.92㎡」 = 파이썬 compute_cross_design 3.30 · 3.92 · 설계선 27 점(주황 rgb 249,115,22) · 15+0 미교차 넓힘 45 m 성토 174.19㎡ · 밝음 · 어두움(지반 rgb 179,174,198) · 도움말 04 · 그림 00 · 05(3e44791fe · 156afa57f)
|
||||
- [x] 544-3 3D 예상 코리도(`UI_3D`) · ORCA 사진 · 노면 높이 = 지형.
|
||||
- 검증완료 — ORCA 3D 측점 20 · 코리도 짓기 92 ms · 열기 0.85 s · 노면 중심 − 측점 지반고 0.000 m(19/19) · 끔 = 원지형 −0.10~+0.07 m · 켬끔 됨 · 지형 잘림 401400→419846 삼각형 · 도움말 06 · 그림 08(c96d559dc · 156afa57f)
|
||||
|
||||
## 545. 측점 표기 = No.+거리 (요구 363-2 · Sonnet high)
|
||||
|
||||
- 정함(브레인): 표기 = B 화면과 같은 공용 `stationLabel`(`61+14.5`) · D03 은 이미 같음 · 남은 날 누가 = D01 시작 · 종료 칸(m 입력 옆에 즉시 측점 표기 · 저장값 m 그대로) · D04 표 · D04 xlsx(측점 칸 덧 · m 칸 유지) · shp 숫자 칸 그대로 · 도움말 맞춤.
|
||||
- [x] 545-1 D01 · D04 화면 · xlsx · locale · 도움말(D01 · D04) · 시험 + ORCA.
|
||||
- 검증완료 — 시험 14(test_531 + 새 거울 test_545_1) · typecheck · ORCA D01 1234.5 → 「= 61+14.5」 · D04 표 「0+0 ~ 18+5.3 ; 18+5.3 ~ 38+6 ; 38+6 ~ 57+1.1」 · 도움말 그림 16 다시(852af2020 · 브레인 대리 체크)
|
||||
- [x] 543-4 저장 안 한 지점 분석이 저장 지점 분석본을 덮는 흠 — `POST /plan/analyze` 가 끈 지점으로 연도별 파생 파일을 덮어 GET(DB 지점)이 「분석 없음」 · 파생 저장본을 입력 지문 열쇠로 나눔(CLAUDE.md 5장) · 시험(끈 지점 분석 뒤 DB 지점 GET 그대로) · sub3.
|
||||
- 검증완료 — test_530_2 9 · test_543 6 · test_530_3 2 통과(끈 지점 분석 뒤 DB 지점 GET 그대로 · 끈 지점 GET = 그 분석 · [저장] 뒤 정본 바뀜 · 쌓임 연도마다 3 + 정본) · typecheck · 가이드 D GET 세 연도 365.26 · 400.73 · 375.07 m 그대로 · 파일 손대지 않음(4753d8898)
|
||||
@@ -203,3 +203,12 @@ on 이 맞음. 그런데 shell command가 아님?
|
||||
1. 예정노선
|
||||
- 다음맵 페이지나 노선추천에서 각지점들의 수정이 가능해야함. 이때 측점을 누르면 보이는 횡단도는 원지반선 뿐만아니라 표준 횡단도의 프로그챔 기본값으로 반영된 예상횡단도 그림이 있어야 하면 이걸 3d에도 볼 수 있어야 함.
|
||||
2. 측점의 표기는 프로그램 로직에서 초기테 누가거리 형태로 표기하는데 측점no. +거리 표기가 되어야함. 이 표현 방식은 b그룹 임도설계 로직에 많이 사용됨.
|
||||
|
||||
## 요구 364 (10-26 · 사용자 · 363 계획 답)
|
||||
|
||||
서브3 폴더계획서 원본 연결
|
||||
꺽인점 고치기와 예상 횡단도, 3d 계획은 진행해
|
||||
|
||||
## 요구 365 (10-26 · 사용자)
|
||||
|
||||
전역 깃 싱크
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""D 분석 API(PLAN 530-2 · 530-3) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래
|
||||
거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만).
|
||||
거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만) ·
|
||||
꺾임점 고침(543-3) = 서버가 마디로 선 · 지표를 다시 셈 · 누가가 고친 길이로 · GET 은 지문 같은 고침만.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
@@ -13,6 +15,7 @@ from fastapi.testclient import TestClient
|
||||
from common_util.common_util_auth import require_company
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as plan_edit
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
|
||||
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
@@ -83,6 +86,7 @@ def client(tmp_path, monkeypatch):
|
||||
"start": list(seg["start"]),
|
||||
"end": list(seg["end"]),
|
||||
"fingerprint": "f",
|
||||
"criteria": {"min_radius_m": 12.0, "max_grade_pct": 14.0},
|
||||
"routes": routes,
|
||||
},
|
||||
)
|
||||
@@ -109,7 +113,23 @@ def client(tmp_path, monkeypatch):
|
||||
stored.append([(s["year"], s["start"], s["end"]) for s in segs])
|
||||
|
||||
monkeypatch.setattr(router_mod, "_store_points", store_points)
|
||||
# 꺾임점 고침 지표 — 지형 없이(선 길이만)
|
||||
loads: list = []
|
||||
monkeypatch.setattr(
|
||||
analyze, "_load", lambda root, year, loader: loads.append(year) or (None, [])
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plan_edit,
|
||||
"edit_metrics",
|
||||
lambda line, result, terrain, streams: {
|
||||
"length_m": round(analyze.line_length(line.vertices), 2),
|
||||
"max_grade_pct": 9.5,
|
||||
"over_limit": {"total": 1},
|
||||
},
|
||||
)
|
||||
test = TestClient(app)
|
||||
test.segments = segments
|
||||
test.loads = loads
|
||||
test.log = log
|
||||
test.session = session
|
||||
test.stored = stored
|
||||
@@ -179,3 +199,105 @@ def test_put_with_points_stores_points_forest_only(client) -> None:
|
||||
moved = {"choices": {}, "points": _points((310.0, 400.0))}
|
||||
client.session["email"] = "a@forest.go.kr"
|
||||
assert client.put(url, json=moved).status_code == 409 and len(client.stored) == 1
|
||||
|
||||
|
||||
def _edit(nodes, fingerprint="f") -> dict:
|
||||
return {
|
||||
"base_mode": "gentle",
|
||||
"base_fingerprint": fingerprint,
|
||||
"nodes": nodes,
|
||||
"curve_flags": [True] * len(nodes),
|
||||
"radii": [None] * len(nodes),
|
||||
}
|
||||
|
||||
|
||||
def test_put_edit_server_redraws_and_get_returns(client) -> None:
|
||||
url = f"/api/projects/{PID}/plan/analysis"
|
||||
edit = _edit([[300, 400], [600, 560], [900, 400]])
|
||||
got = client.put(url, json={"choices": {}, "edits": {"2027": edit}})
|
||||
assert got.status_code == 200, got.text
|
||||
updates = {args[3]: json.loads(args[0]) for sql, args in client.log if sql.startswith("UPDATE")}
|
||||
body = updates[2027]
|
||||
line = plan_edit.edited_line(edit, 12.0)
|
||||
assert len(body["planned"]) == len(line.vertices) and body["edit"]["nodes"] == edit["nodes"]
|
||||
assert body["planned"][0] == [300, 400] and body["planned"][-1] == [900, 400]
|
||||
assert body["metrics"]["max_grade_pct"] == 9.5 and len(body["curves"]) == 1
|
||||
length = analyze.line_length(body["planned"])
|
||||
assert got.json()["chain"][1]["length_m"] == round(length, 3) != 640.0
|
||||
assert "edit" not in updates[2026] and client.loads == [2027]
|
||||
|
||||
# GET — 저장된 고침 + 고친 길이로 이은 누가 · 지문이 바뀌면 안 돌려줌
|
||||
client.segments[1]["route_json"] = json.dumps(body)
|
||||
view = client.get(url).json()
|
||||
assert view["years"][1]["edit"]["nodes"] == edit["nodes"]
|
||||
assert view["years"][1]["edit"]["metrics"]["over_limit"]["total"] == 1
|
||||
assert view["chain"][1]["length_m"] == round(length, 3)
|
||||
assert view["years"][1]["analysis"]["routes"]["gentle"]["metrics"]["length_m"] == 640.0
|
||||
# 같은 입력 다시 [저장] = 저장된 선 · 지표 그대로(지형 안 읽음)
|
||||
assert client.put(url, json={"choices": {}, "edits": {"2027": edit}}).status_code == 200
|
||||
assert client.loads == [2027]
|
||||
client.segments[1]["route_json"] = json.dumps(
|
||||
{**body, "edit": {**body["edit"], "base_fingerprint": "old"}}
|
||||
)
|
||||
stale = client.get(url).json()
|
||||
assert stale["years"][1]["edit"] is None and stale["chain"][1]["length_m"] == 640.0
|
||||
|
||||
|
||||
def test_put_edit_rejects(client) -> None:
|
||||
url = f"/api/projects/{PID}/plan/analysis"
|
||||
off_end = _edit([[300, 400], [600, 560], [901, 400]])
|
||||
stale = _edit([[300, 400], [600, 560], [900, 400]], "old")
|
||||
other = {**stale, "base_fingerprint": "f", "base_mode": "gradeline"}
|
||||
for bad in (off_end, stale, other):
|
||||
assert client.put(url, json={"choices": {}, "edits": {"2027": bad}}).status_code == 409
|
||||
client.session["email"] = "a@gmail.com"
|
||||
good = _edit([[300, 400], [600, 560], [900, 400]])
|
||||
assert client.put(url, json={"choices": {}, "edits": {"2027": good}}).status_code == 403
|
||||
assert not [sql for sql, _ in client.log if sql.startswith("UPDATE")]
|
||||
|
||||
|
||||
def _analyze_moved(client, tmp_path, monkeypatch, p1, length=700.0) -> None:
|
||||
"""끈 지점 분석(`POST …/plan/analyze` 와 같은 길) — 엔진 · 지문은 가짜."""
|
||||
monkeypatch.setattr(analyze, "segment_fingerprint", lambda seg, *a, **k: "g")
|
||||
monkeypatch.setattr(
|
||||
analyze,
|
||||
"generate_initial_route",
|
||||
lambda expected, *a, mode, **k: _route([list(expected[0]), list(expected[1])], length),
|
||||
)
|
||||
points = [router_mod.PlanPoint(**p) for p in _points(p1)]
|
||||
segs = router_mod._override(client.segments, points)
|
||||
analyze.analyze_plan(tmp_path, segs, analyze.plan_criteria("work", None), workers=1)
|
||||
|
||||
|
||||
def test_moved_analysis_keeps_saved_store(client, tmp_path, monkeypatch) -> None:
|
||||
"""543-4 — 끈 지점 분석은 지점 열쇠 파일로 · 저장 지점 GET 그대로 · 끈 지점 GET 은 그 분석 ·
|
||||
[저장] 뒤 정본 = 끈 지점 분석본 · 옛 꼴 파일은 읽기만 · 쌓임 거르기는 저장 지점 것을 남김."""
|
||||
url = f"/api/projects/{PID}/plan/analysis"
|
||||
before = client.get(url).json()
|
||||
moved = (310.0, 400.0)
|
||||
_analyze_moved(client, tmp_path, monkeypatch, moved)
|
||||
folder = analyze.derived_dir(tmp_path)
|
||||
assert (folder / "route_2026.json").is_file() and (folder / "route_2027.json").is_file()
|
||||
|
||||
assert client.get(url).json() == before # 저장 지점 = 옛 분석 그대로
|
||||
view = client.get(url, params={"points": json.dumps(_points(moved))}).json()
|
||||
assert [y["analysis"]["fingerprint"] for y in view["years"]] == ["g", "g"]
|
||||
assert view["chain"][1]["end_chainage_m"] == 2400.0
|
||||
|
||||
# [저장] = 끈 지점 정본 → 저장 지점 GET 이 그 분석
|
||||
body = {"choices": {"2027": "gentle"}, "points": _points(moved)}
|
||||
assert client.put(url, json=body).status_code == 200
|
||||
for seg, (start, end) in zip(client.segments, ((P0, moved), (moved, P2))):
|
||||
seg.update(start=start, end=end)
|
||||
after = client.get(url).json()
|
||||
assert [y["analysis"]["fingerprint"] for y in after["years"]] == ["g", "g"]
|
||||
|
||||
# 쌓임 — 저장 지점 것은 가장 옛것이어도 남음 · 연도마다 최근 KEEP_PER_YEAR 개
|
||||
for path in folder.glob("route_*_*.json"):
|
||||
os.utime(path, (1000.0, 1000.0))
|
||||
for n in range(analyze.KEEP_PER_YEAR + 1):
|
||||
_analyze_moved(client, tmp_path, monkeypatch, (320.0 + n, 400.0))
|
||||
keyed = sorted(p.name for p in folder.glob("route_2026_*.json"))
|
||||
assert len(keyed) == analyze.KEEP_PER_YEAR + 1
|
||||
assert analyze.segment_path(tmp_path, client.segments[0]).name in keyed
|
||||
assert client.get(url).json() == after
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""D03 꺾임점 고침 거울(PLAN 543-1 · 543-3) — 화면 `logic/D03_PlanAnalysis_Edit.ts` ↔ 서버
|
||||
`D03_PlanAnalysis_Edit.py` · 이은 누가 짝 `withEdits` ↔ `with_edits`.
|
||||
|
||||
- 끌기 · 넣기 · 지우기 · 손잡이 뒤 처음 · 끝 마디 그대로 · 끝 마디 잠금 · R ≥ 법정 최소
|
||||
- 고친 선 길이 = `lineLength` · 이은 누가가 고친 길이로 이어짐(파이썬 = TS)
|
||||
- 브라우저 선 = 서버 선(1 mm) — Node 길 · 파이썬 폴백 둘 다
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_543_plan_edit.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze # noqa: E402
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as edit_mod # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Edit.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
MIN_R = 12.0
|
||||
NODES = [(0.0, 0.0), (120.0, 10.0), (200.0, 120.0), (330.0, 140.0), (420.0, 60.0)]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import * as ed from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.js";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const R = job.minR;
|
||||
const base = ed.editFromRoute(job.route, "gentle", "f1");
|
||||
const step = (s) =>
|
||||
"blocked" in s ? { blocked: s.blocked } : { edit: s.edit, line: s.line.vertices };
|
||||
const moved = ed.moveNode(base, 2, [215, 150], R);
|
||||
const handle = ed.moveHandle(base, 1, "end", job.handleTo, R);
|
||||
const inserted = ed.insertNode(base, 0, [60, 5], R);
|
||||
const removed = ed.removeNode(base, 1, R);
|
||||
const out = {
|
||||
base,
|
||||
baseLine: ed.buildEdit(base, R).line.vertices,
|
||||
moved: step(moved),
|
||||
handle: step(handle),
|
||||
inserted: step(inserted),
|
||||
removed: step(removed),
|
||||
endLocked: step(ed.moveNode(base, 0, [5, 5], R)),
|
||||
endRemove: step(ed.removeNode(base, base.nodes.length - 1, R)),
|
||||
movedLength: "edit" in moved ? plan.lineLength(moved.line.vertices) : null,
|
||||
};
|
||||
const edits = { 2026: { mode: "gentle", planned: out.moved.line } };
|
||||
const results = plan.withEdits(job.results, edits);
|
||||
out.rows = plan.chainRows(results, {}, 1000);
|
||||
out.line = plan.joinedLine(results, out.rows);
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _route() -> dict:
|
||||
line = build_planned_polyline(list(NODES), min_radius_m=MIN_R, simplify=False)
|
||||
return {
|
||||
"planned": [list(v) for v in line.vertices],
|
||||
"nodes": [n.as_dict() for n in line.nodes],
|
||||
"curves": [c.as_dict() for c in line.curves],
|
||||
"metrics": {"length_m": 999.0},
|
||||
}
|
||||
|
||||
|
||||
def _results(route: dict) -> list:
|
||||
end = list(NODES[-1])
|
||||
return [
|
||||
{
|
||||
"year": 2026,
|
||||
"start": list(NODES[0]),
|
||||
"end": end,
|
||||
"fingerprint": "f1",
|
||||
"criteria": {"min_radius_m": MIN_R, "max_grade_pct": 14.0},
|
||||
"routes": {"gentle": route, "earthwork": {"error": "길 없음"}},
|
||||
},
|
||||
{
|
||||
"year": 2027,
|
||||
"start": end,
|
||||
"end": [600.0, 60.0],
|
||||
"routes": {"gentle": {"planned": [end, [600.0, 60.0]], "metrics": {"length_m": 180.0}}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts(tmp_path_factory) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("543")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
str(ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--rootDir",
|
||||
str(ROOT),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
route = _route()
|
||||
job = {"minR": MIN_R, "route": route, "results": _results(route), "handleTo": None}
|
||||
# 손잡이 = 첫 곡선 끝 접선점을 나가는 직선 따라 교각점에서 1.8 배 자리로(접선 길이 ↑ = R ↑)
|
||||
curve = next(c for c in route["curves"] if c["node_first"] == 1)
|
||||
apex, end = curve["apex"], curve["end"]
|
||||
job["handleTo"] = [apex[0] + 1.8 * (end[0] - apex[0]), apex[1] + 1.8 * (end[1] - apex[1])]
|
||||
(tmp / "job.json").write_text(json.dumps(job), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(tmp / "job.json"), str(tmp / "out.json")],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return {**json.loads((tmp / "out.json").read_text(encoding="utf-8")), "route": route}
|
||||
|
||||
|
||||
def _close(a: list, b: list, tol: float = 1e-3) -> bool:
|
||||
return len(a) == len(b) and all(
|
||||
abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol for p, q in zip(a, b)
|
||||
)
|
||||
|
||||
|
||||
def test_ends_kept_and_locked(ts) -> None:
|
||||
for name in ("moved", "handle", "inserted", "removed"):
|
||||
step = ts[name]
|
||||
assert "edit" in step, (name, step)
|
||||
assert step["edit"]["nodes"][0] == list(NODES[0])
|
||||
assert step["edit"]["nodes"][-1] == list(NODES[-1])
|
||||
assert _close([step["line"][0], step["line"][-1]], [list(NODES[0]), list(NODES[-1])])
|
||||
assert "연도 경계" in ts["endLocked"]["blocked"]
|
||||
assert "연도 경계" in ts["endRemove"]["blocked"]
|
||||
count = len(ts["base"]["nodes"])
|
||||
assert len(ts["inserted"]["edit"]["nodes"]) == count + 1
|
||||
assert len(ts["removed"]["edit"]["nodes"]) == count - 1
|
||||
# 손잡이 = 그 마디 R 이 바뀜 · 법정 최소 밑 아님
|
||||
assert ts["handle"]["edit"]["radii"][1] >= MIN_R
|
||||
assert ts["handle"]["edit"]["radii"][1] > ts["base"]["radii"][1] * 1.5
|
||||
|
||||
|
||||
def test_unedited_base_redraws_engine_line(ts) -> None:
|
||||
assert _close(ts["baseLine"], ts["route"]["planned"])
|
||||
|
||||
|
||||
def test_length_and_chain_mirror(ts) -> None:
|
||||
line = ts["moved"]["line"]
|
||||
assert abs(ts["movedLength"] - analyze.line_length(line)) < 1e-6
|
||||
results = analyze.with_edits(_results(ts["route"]), {2026: {"mode": "gentle", "planned": line}})
|
||||
rows = analyze.chain(results, {}, 1000.0)
|
||||
assert ts["rows"] == rows
|
||||
assert rows[0]["length_m"] == round(analyze.line_length(line), 3)
|
||||
assert rows[1]["start_chainage_m"] == rows[0]["end_chainage_m"]
|
||||
assert ts["line"] == analyze.joined_line(results, rows)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_path", [True, False])
|
||||
def test_browser_line_equals_server_line(ts, monkeypatch, node_path) -> None:
|
||||
if not node_path:
|
||||
monkeypatch.setattr(edit_mod, "node_polyline", lambda *a, **k: None)
|
||||
for name in ("moved", "handle", "inserted", "removed"):
|
||||
step = ts[name]
|
||||
server = edit_mod.edited_line(step["edit"], MIN_R)
|
||||
assert _close([list(v) for v in server.vertices], step["line"]), name
|
||||
|
||||
|
||||
def test_check_edit_rejects() -> None:
|
||||
result = _results(_route())[0]
|
||||
good = {
|
||||
"base_mode": "gentle",
|
||||
"base_fingerprint": "f1",
|
||||
"nodes": [list(p) for p in NODES],
|
||||
"curve_flags": [True] * len(NODES),
|
||||
"radii": [None] * len(NODES),
|
||||
}
|
||||
edit_mod.check_edit(good, result, "gentle")
|
||||
for bad, mode in (
|
||||
({**good, "base_fingerprint": "old"}, "gentle"),
|
||||
(good, "earthwork"),
|
||||
({**good, "nodes": [[1.0, 0.0], *good["nodes"][1:]]}, "gentle"),
|
||||
({**good, "radii": [None, -3.0, None, None, None]}, "gentle"),
|
||||
({**good, "curve_flags": [True]}, "gentle"),
|
||||
):
|
||||
with pytest.raises(edit_mod.EditError):
|
||||
edit_mod.check_edit(bad, result, mode)
|
||||