From 50e9c1b015889f508305cdcb124ee7b4bbf8bb47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 25 Oct 2026 20:11:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(530-3):=20D03=20=EC=98=88=EC=A0=95?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EB=B6=84=EC=84=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=E2=80=94=20=ED=8F=89=EB=A9=B4=20=C2=B7=20=EC=A7=80=EC=A0=90=20?= =?UTF-8?q?=EB=81=8C=EA=B8=B0=20=C2=B7=20=EB=8D=94=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EA=B0=88=EB=9E=98=20=EC=B9=B4=EB=93=9C=20=C2=B7=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=A7=80=EB=B0=98=20=ED=9A=A1?= =?UTF-8?q?=EB=8B=A8=20=C2=B7=203D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 평면 = 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점(누가 = 등록 시작부터) + 지점 끌기 겹 - 끈 지점 = 캐시 · 받기 · 분석 · 보기는 points 로 실어 보냄 · [저장] 때만 D02 규칙으로 정본 - 연도별 갈래 넷 카드(연장 · 최대 기울기 · 위반 수) · 고름 = 캐시 · [저장] = 서버 저장본으로 다시 셈 - 측점 누름 = 하 패널 지반선 횡단(sectionsGround · buildCrossSvg 지반만 인자 · 기본 그대로) - 3D 모달 = 연도 DEM + 노선 + 측점 막대(코리도 · 구조물 안 부름) - 화면 계산 logic 짝(pickMode · chainRows · joinedLine ↔ 파이썬) 거울 시험 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 2 + .../B05_Profile_UI_RouteEdit_Cross_Draw.ts | 28 +- D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py | 3 + D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 112 +++++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 116 ++++- D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts | 110 ++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts | 76 +++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 366 ++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 468 ++++++++++++++++++ .../D03_PlanAnalysis_UI_Style.css | 129 +++++ .../draft/D03_PlanAnalysis_Route.ts | 7 +- .../logic/D03_PlanAnalysis_Plan.ts | 180 +++++++ resources/tester/test_530_2_plan_router.py | 54 +- resources/tester/test_530_3_plan_logic.py | 142 ++++++ 14 files changed, 1760 insertions(+), 33 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Api.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts create mode 100644 resources/tester/test_530_3_plan_logic.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 6f95b8585..78e42e604 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -198,6 +198,8 @@ export const STATE_REGISTRY = { "b08-options": { bucket: "draft", scope: "project" }, /** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */ "b08-milestone": { bucket: "draft", scope: "project" }, + /** D03 예정노선 분석 `{nodes: 끈 지점 P0…Pn | null, choices: {연도: 갈래}}` — [저장] 때 plan/analysis 로(530-3). */ + "d03-plan": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index daacecf8d..a8aa5440b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -96,11 +96,14 @@ function clipToToes(line: Point[], low: number, high: number): Point[] { * * **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 * 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). + * + * `groundOnly` = 지반선만(D 예정노선 · PLAN 530-3) — 「기본 계획 횡단」 범례 · 계산 자리 검사를 건너뜀. */ export function buildCrossSvg( preview: CrossPreviewResponse, width: number, height: number, + groundOnly = false, ): CrossPlot { const svg = svgElement("svg", { viewBox: `0 0 ${width} ${height}`, @@ -115,12 +118,13 @@ export function buildCrossSvg( .filter((sample) => sample.valid && sample.elevation_m !== null) .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point); // 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A) - const toes = preview.design - ? calcChecked("fixed_browser", "cross.fit", toeOffsets, [asSection(preview)]) - : { left: null, right: null }; + const toes = + preview.design && !groundOnly + ? calcChecked("fixed_browser", "cross.fit", toeOffsets, [asSection(preview)]) + : { left: null, right: null }; // 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다. const design = clipToToes( - (preview.design?.design_line ?? []).map( + (groundOnly ? [] : (preview.design?.design_line ?? [])).map( (point) => [point.offset_m, point.elevation_m] as Point, ), toes.right ?? -Infinity, // −offset = 우 @@ -184,12 +188,16 @@ export function buildCrossSvg( y: 13, "text-anchor": "start", }), - svgText("기본 계획 횡단", { - class: "b05-routeedit__cross-key is-design", - x: width - CROSS_PLOT_PAD.right, - y: 13, - "text-anchor": "end", - }), + ...(groundOnly + ? [] + : [ + svgText("기본 계획 횡단", { + class: "b05-routeedit__cross-key is-design", + x: width - CROSS_PLOT_PAD.right, + y: 13, + "text-anchor": "end", + }), + ]), svgText( `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py index 9cb67fa73..ad1dce02b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py @@ -8,6 +8,9 @@ - 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>.json`(입력 지문 같으면 다시 안 셈) 계산 자리 = ③ 서버 단독(B 추천과 같음). + +⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` ↔ `logic/D03_PlanAnalysis_Plan.ts` +`pickMode` · `chainRows` · `joinedLine`(화면이 고르는 즉시) · 거울 시험 `test_530_3_plan_logic.py`. """ from __future__ import annotations diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts new file mode 100644 index 000000000..f3255c199 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -0,0 +1,112 @@ +/* ============================================================================= + * D03_PlanAnalysis_Api.ts + * D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] · + * 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄. + * ========================================================================== */ + +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"; + +export interface PlanPointsView { + year_start: number; + year_end: number; + epsg: number | null; + years: Array<{ + year: number; + start: { x: number; y: number } | null; + end: { x: number; y: number } | null; + }>; +} + +export interface PlanJob { + running: boolean; + percent: number; + message: string | null; + error: string | null; +} + +export interface PlanAnalysisView { + years: Array<{ year: number; start: Vertex; end: Vertex; analysis: YearAnalysis | null }>; + chain: ChainRow[] | null; + /** 등록 노선 시작 누가(m). */ + start_chainage_m: number; +} + +export type PlanPoints = Array<{ year: number; start: Vertex; end: Vertex }>; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`; + throw new Error(detail); + } + return body; +} + +const base = (projectId: string): string => `/projects/${projectId}/plan`; +const pointsBody = (points: PlanPoints | null): string => JSON.stringify(points ? { points } : {}); + +export const fetchPlanPoints = (projectId: string): Promise => + request(`${base(projectId)}/points`); + +export const startPlanJob = ( + projectId: string, + kind: "terrain" | "analyze", + points: PlanPoints | null, +): Promise => + request(`${base(projectId)}/${kind}`, { method: "POST", body: pointsBody(points) }); + +export const fetchPlanProgress = ( + projectId: string, +): Promise<{ terrain: PlanJob; analyze: PlanJob }> => request(`${base(projectId)}/progress`); + +export const fetchPlanAnalysis = ( + projectId: string, + points: PlanPoints | null, +): Promise => + request( + `${base(projectId)}/analysis` + + (points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""), + ); + +export const savePlanAnalysis = ( + projectId: string, + choices: Record, + points: PlanPoints | null, +): Promise<{ chain: ChainRow[] }> => + request(`${base(projectId)}/analysis`, { + method: "PUT", + body: JSON.stringify(points ? { choices, points } : { choices }), + }); + +export interface YearSurface { + modelId: number; + grid: DtmGrid; +} + +/** 연도별 지형면 — 모델 행 경로 `…/models_<연도>/…` · 격자 = 분석과 같은 평활본. */ +export async function fetchYearSurfaces(projectId: string): Promise> { + const { models } = await listSurfaceModels(projectId); + const out = new Map(); + for (const model of models) { + const year = Number(/models_(\d+)\//.exec(model.model_file_path ?? "")?.[1]); + if (!year || out.has(year)) continue; + const stamp = String(model.generation_params?.file_stamp ?? ""); + const url = + `${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/grid` + + `?smooth=true&sig=${encodeURIComponent(stamp)}`; + out.set(year, { + modelId: model.id, + grid: parseDtmGrid(await fetchCachedBytes(projectId, url)), + }); + } + return out; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 0a620fc6e..f4a424e08 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -6,8 +6,9 @@ - `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선 - `PUT …/plan/analysis` — [저장] 연도별 고른 갈래 → `plan_route_years.route_json` · `fingerprint` -지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫)을 읽기만 함. 계산 자리 = ③ 서버 단독 — -[저장]은 브라우저 값을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈. +지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은 +`points`(작업 좌표계 m)로 받아 그 지점으로 받기 · 분석 · 보기 · [저장] 때만 D02 규칙으로 정본에 씀. +계산 자리 = ③ 서버 단독 — [저장]은 노선을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈. """ from __future__ import annotations @@ -20,12 +21,15 @@ from typing import Any from uuid import UUID import aiomysql -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_auth import is_forest_service, require_company +from common_util.common_util_crs import tm_to_lonlat 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_Terrain as terrain @@ -38,12 +42,43 @@ _TASKS: set[asyncio.Task] = set() KINDS = ("terrain", "analyze") -class ChoiceRequest(BaseModel): +class PlanPoint(BaseModel): + """끈 지점(작업 좌표계 m) — 연도 하나의 시점 · 종점.""" + + year: int + start: tuple[float, float] + end: tuple[float, float] + + +class PointsBody(BaseModel): + points: list[PlanPoint] | None = None + + +class ChoiceRequest(PointsBody): choices: dict[int, str] # 연도 → 갈래 -async def _plan_inputs(project_id: UUID) -> tuple[Path, dict[str, Any], list[dict[str, Any]]]: - """(프로젝트 실경로, 프로젝트 칸, 연도 구간) — 지점이 빈 연도 · 좌표계 섞임은 거절.""" +def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[dict[str, Any]]: + """연도 구간 지점을 끈 지점으로 — 연도 같음 · 직전 종점 = 다음 시점(1 mm) 아니면 거절.""" + by_year = {p.year: p for p in points} + 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)} + for seg in segments + ] + for prev, seg in zip(out, out[1:]): + if max(abs(a - b) for a, b in zip(prev["end"], seg["start"])) > base.SAME_M: + raise HTTPException( + status_code=400, detail=f"{seg['year']}년 시점이 직전 종점과 다릅니다." + ) + return out + + +async def _plan_inputs( + project_id: UUID, points: list[PlanPoint] | None = None +) -> tuple[Path, dict[str, Any], list[dict[str, Any]]]: + """(실경로, 프로젝트 칸, 연도 구간) — 빈 연도 · 좌표계 섞임 거절 · `points` = 끈 지점.""" async with get_db_pool().acquire() as connection: stored = await get_project_storage_relative_path(connection, project_id) async with connection.cursor(aiomysql.DictCursor) as cursor: @@ -79,6 +114,8 @@ async def _plan_inputs(project_id: UUID) -> tuple[Path, dict[str, Any], list[dic } for row in rows ] + if points: + segments = _override(segments, points) return Path(resolve_stored_project_path(stored)), project, segments @@ -134,9 +171,9 @@ async def _save_models(project_id: UUID, years: dict[int, list[dict[str, Any]]], @router.post("/{project_id}/plan/terrain", response_model=None) -async def plan_terrain(project_id: UUID) -> dict[str, Any]: +async def plan_terrain(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]: """지점 기준 수치지형도 · 위성 · 연도별 DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" - root, _, segments = await _plan_inputs(project_id) + root, _, segments = await _plan_inputs(project_id, body.points if body else None) epsg = segments[0]["epsg"] async def work(report: Any) -> None: @@ -148,9 +185,9 @@ async def plan_terrain(project_id: UUID) -> dict[str, Any]: @router.post("/{project_id}/plan/analyze", response_model=None) -async def plan_analyze(project_id: UUID) -> dict[str, Any]: +async def plan_analyze(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]: """연도 구간마다 노선 추천 갈래 넷(뒤에서 · 같은 입력이면 저장본).""" - root, project, segments = await _plan_inputs(project_id) + root, project, segments = await _plan_inputs(project_id, body.points if body else None) criteria = analyze.plan_criteria(project["road_type"], project["design_speed_kph"]) missing = [s["year"] for s in segments if not terrain.models_dir_of(root, s["year"]).is_dir()] if missing: @@ -192,10 +229,22 @@ def _assemble( return results, rows, analyze.joined_line(results, rows) +def _query_points(points: str | None) -> list[PlanPoint] | None: + if not points: + return None + try: + return [PlanPoint(**item) for item in json.loads(points)] + except (ValueError, TypeError) as error: + raise HTTPException(status_code=400, detail="points 꼴이 다릅니다.") from error + + @router.get("/{project_id}/plan/analysis", response_model=None) -async def plan_analysis(project_id: UUID) -> dict[str, Any]: - """분석 저장본(지점이 지금과 같은 것만) + 고른 갈래(저장값 · 없으면 첫 성공 갈래).""" - root, project, segments = await _plan_inputs(project_id) +async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str, Any]: + """분석 저장본(지점이 지금과 같은 것만) + 고른 갈래(저장값 · 없으면 첫 성공 갈래). + + `points` = 끈 지점 JSON(`[{year, start, end}]` · 작업 좌표계 m) — 그 지점의 저장본을 봄. + """ + 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) return { @@ -206,13 +255,46 @@ async def plan_analysis(project_id: UUID) -> dict[str, Any]: ], "chain": rows, "line": line, + "start_chainage_m": float(project["route_start_m"] or 0.0), } +async def _store_points(project_id: UUID, segments: list[dict[str, Any]]) -> None: + """끈 지점 → D02 정본 규칙(경위도 · 잇기 · 바뀐 연도만 · 그 연도 노선 비움).""" + epsg = segments[0]["epsg"] + + def lonlat(xy: tuple[float, float]) -> base.LonLat: + lon, lat = tm_to_lonlat(xy[0], xy[1], epsg) + return base.LonLat(lon=lon, lat=lat) + + project, old = await base._load(project_id) + start = int(project["project_year"]) + end = int(project["project_year_end"] or start) + items = [ + base.YearPoints(year=s["year"], start=lonlat(s["start"]), end=lonlat(s["end"])) + for s in segments + ] + new_epsg, rows = base.with_tm(base.chain_years(start, end, items)) + if new_epsg != epsg: + raise HTTPException( + status_code=409, + detail="끈 지점이 다른 좌표계 띠로 넘어갔습니다 — 1단계에서 지정하세요.", + ) + writes = [row for row in rows if base.changed(row, old.get(row["year"]), epsg)] + if writes: + await base._store(project_id, epsg, writes, []) + + @router.put("/{project_id}/plan/analysis", response_model=None) -async def save_plan_analysis(project_id: UUID, request: ChoiceRequest) -> dict[str, Any]: - """[저장] — 연도별 고른 갈래를 서버 저장본으로 다시 이어 `plan_route_years` 에 씀.""" - root, project, segments = await _plan_inputs(project_id) +async def save_plan_analysis( + project_id: UUID, + request: ChoiceRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """[저장] — 끈 지점(있으면 정본으로) · 연도별 고른 갈래를 서버 저장본으로 다시 이어 씀.""" + if request.points 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] if bad: raise HTTPException(status_code=400, detail=f"모르는 갈래: {bad}") @@ -226,6 +308,8 @@ async def save_plan_analysis(project_id: UUID, request: ChoiceRequest) -> dict[s 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}년") + if request.points: + await _store_points(project_id, segments) async with get_db_pool().acquire() as connection: await connection.begin() try: diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts new file mode 100644 index 000000000..ebb06a218 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -0,0 +1,110 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_3D.ts + * D03 [3D] 큰 모달(PLAN 530-3) — 한 연도 DEM + 그 연도 고른 노선 + 측점 막대만. 코리도 · 구조물은 + * 안 부름(`setCorridor` 안 씀 → 노견 · 절성토면 · 구조물 없음). 연도 고름 = 모달 위 칸. + * 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지). + * ========================================================================== */ + +import "../B05_Profile/B05_Profile_UI_Style.css"; +import { openModal } from "@ui/ui_template_modal"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { showToast } from "@ui/ui_template_elements"; +import type { DtmGrid } from "../common_util/common_util_surface_sampler"; +import { createRouteViewer, type RouteViewer } from "../B05_Profile/B05_Profile_UI_Viewer"; +import { sectionsGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; + +export interface Plan3dYear { + year: number; + modelId: number; + grid: DtmGrid; + line: Vertex[]; + /** 그 연도 노선 위 측점 누가(연도 시작 = 0) · 등록 누가. */ + stations: Array<{ localM: number; chainage: number }>; +} + +/** 격자 → 뷰어 상자(지형에 붙는 노선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */ +function gridBounds(grid: DtmGrid): { + x: [number, number]; + y: [number, number]; + z: [number, number]; +} { + let low = Infinity; + let high = -Infinity; + for (const z of grid.z) { + if (!Number.isFinite(z)) continue; + if (z < low) low = z; + if (z > high) high = z; + } + return { + x: [grid.x[0], grid.x[grid.x.length - 1]], + y: [grid.y[0], grid.y[grid.y.length - 1]], + z: [low, high], + }; +} + +export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void { + if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning"); + let viewer: RouteViewer | null = createRouteViewer(); + const pick = document.createElement("select"); + pick.className = "d03-plan__3d-year"; + for (const item of years) + pick.append(new Option(`${item.year}년`, String(item.year), false, item.year === firstYear)); + const top = document.createElement("div"); + top.className = "d03-plan__3d-top"; + top.append(pick); + + const load = async (year: number): Promise => { + const item = years.find((entry) => entry.year === year) ?? years[0]; + if (!viewer) return; + const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); + progress.set(null); + viewer.root.append(progress.root); + try { + await viewer.loadSurface(projectId, item.modelId, "dtm", true, 5, gridBounds(item.grid)); + if (!viewer) return; + viewer.markers.renderRoute(item.line.map(([x, y]) => ({ x, y }))); + const ground = sectionsGround( + item.line, + item.stations.map((s) => s.localM), + item.grid, + ); + viewer.renderStationLines( + ground.stations.map((station, i) => ({ + station_id: String(item.stations[i]?.chainage ?? station.chainage_m), + center_x: station.center_x, + center_y: station.center_y, + center_z: station.center_z, + frame: { left_xy: station.left_xy }, + kind: "regular", + chainage_m: item.stations[i]?.chainage ?? station.chainage_m, + })), + 2.5, + ); + viewer.setView("iso"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } finally { + progress.remove(); + } + }; + pick.addEventListener("change", () => void load(Number(pick.value))); + + openModal({ + title: "3D 보기 — 지형 + 노선", + closeLabel: "닫기", + wide: true, + dialogClass: "b05-route__3d-modal", + mount: (body) => { + body.classList.add("b05-route__3d-body"); + body.append(top, viewer!.root); + viewer!.setActive(true); + }, + beforeClose: () => { + viewer?.dispose(); + viewer = null; + return true; + }, + }); + void load(Number(pick.value)); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts new file mode 100644 index 000000000..d8e7db7e4 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Cross.ts + * D03 하 패널 — 누른 측점의 지반선 횡단(PLAN 530-3). 계산 = 화면 TS 즉시(① 짝 `sectionsGround` ↔ + * `generate_sections` · 격자 = 그 연도 DEM 평활본) · 그림 = B05 `buildCrossSvg` 지반만. + * ========================================================================== */ + +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { stationLabel } from "../common_util/common_util_station"; +import type { DtmGrid } from "../common_util/common_util_surface_sampler"; +import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw"; +import { sectionsGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; + +export interface PlanCross { + root: HTMLElement; + /** `chainage` = 등록 누가 · `localM` = 그 연도 노선 위 누가. */ + show: (chainage: number, year: number, line: Vertex[], localM: number, grid: DtmGrid) => void; + clear: (text: string) => void; +} + +export function createPlanCross(): PlanCross { + const root = document.createElement("section"); + root.className = "d03-plan__cross"; + const handle = createWorkflowPanelHandle("bottom", "down", "횡단"); + const head = document.createElement("div"); + head.className = "d03-plan__cross-head"; + const body = document.createElement("div"); + body.className = "d03-plan__cross-body"; + root.append(handle.root, head, body); + let open = true; + handle.setOpen(open); + handle.root.addEventListener("click", () => { + open = !open; + root.classList.toggle("is-collapsed", !open); + handle.setOpen(open); + }); + + const clear = (text: string): void => { + head.textContent = ""; + body.replaceChildren(Object.assign(document.createElement("p"), { textContent: text })); + delete root.dataset.chainage; + }; + clear("평면에서 측점을 누르면 그 자리 지반선 횡단이 보입니다."); + + return { + root, + clear, + show(chainage, year, line, localM, grid) { + const station = sectionsGround(line, [localM], grid).stations[0]; + if (!station) return clear("이 측점의 지반을 읽지 못했습니다."); + const name = stationLabel(chainage, PLAN_STATION_INTERVAL_M, 1); + const z = station.center_z === null ? "—" : `${station.center_z.toFixed(2)} m`; + head.textContent = `${year}년 · ${name} · 중심 지반고 ${z}`; + root.dataset.chainage = String(chainage); + const rect = body.getBoundingClientRect(); + const plot = buildCrossSvg( + { + status: "success", + chainage_m: chainage, + label: name, + uphill_side: station.uphill_side, + plan_radius_m: null, + curve_widening_m: null, + cross_half_width_m: 20, + samples: station.samples, + design: null, + }, + Math.max(rect.width, 320), + Math.max(rect.height, 160), + true, + ); + body.replaceChildren(plot.svg); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts new file mode 100644 index 000000000..219589be2 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -0,0 +1,366 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Map.ts + * D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 + + * 지점(연도 경계) + 지점 끌기 겹. + * + * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤 + * `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` · + * 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선 + * `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시). + * ========================================================================== */ + +import { themeColor } from "@ui/ui_template_palette"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + drawStationTicks, + type StationLabelBox, +} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { + computeMapRect, + computeRouteView, + createMetricProjector, + drawPreparedLabels, + drawPreparedLayer, + normalizedToScreen, + pickLevelStep, + routeLineColor, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + bindRouteEditNavigation, + stationAtScreen, +} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; +import { leadInLine, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; + +/** 측점 간격(m) — B 규칙 측점과 같음. */ +export const PLAN_STATION_INTERVAL_M = 20; +/** 지점 집는 반경(px). */ +const NODE_HIT_PX = 12; +/** 누름으로 볼 손 떨림(px) — 넘으면 지도 옮기기. */ +const CLICK_SLOP_PX = 4; +/** 위성이 없을 때 지점 둘레로 둘 가짜 도엽 여유(m). */ +const FAKE_MARGIN_M = 600; + +export interface PlanMapScene { + meta: VWorldMeta | null; + image: HTMLImageElement | null; + contours: PreparedLayer | null; + nodes: Vertex[]; + /** 마디마다 받은 지형 안인가 — 밖이면 빨간 고리. */ + reach: boolean[]; + /** 고르지 않은 갈래(옅게). */ + others: Vertex[][]; + /** 고른 갈래를 이은 중심선. */ + line: Vertex[]; + startM: number; + stations: number[]; + selected: number | null; + editable: boolean; +} + +export interface PlanMapEvents { + onDrag: (index: number, point: Vertex) => void; + onDragEnd: (index: number) => void; + onStation: (chainage: number) => void; +} + +export interface PlanMap { + root: HTMLElement; + update: (patch: Partial) => void; + /** 지점 · 노선이 들어오게 다시 맞춤. */ + fit: () => void; + /** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */ + stationScreen: (chainage: number) => [number, number] | null; + dispose: () => void; +} + +function fakeMeta(points: Vertex[]): VWorldMeta | null { + if (!points.length) return null; + const xs = points.map(([x]) => x); + const ys = points.map(([, y]) => y); + const x_min = Math.min(...xs) - FAKE_MARGIN_M; + const y_min = Math.min(...ys) - FAKE_MARGIN_M; + const x_max = Math.max(...xs) + FAKE_MARGIN_M; + const y_max = Math.max(...ys) + FAKE_MARGIN_M; + return { + x_min, + x_max, + y_min, + y_max, + width_meters: x_max - x_min, + height_meters: y_max - y_min, + center_x: (x_min + x_max) / 2, + center_y: (y_min + y_max) / 2, + lon_min: 0, + lon_max: 1, + lat_min: 0, + lat_max: 1, + }; +} + +export function createPlanMap(events: PlanMapEvents): PlanMap { + const root = document.createElement("div"); + root.className = "d03-plan__map"; + const canvas = document.createElement("canvas"); + root.append(canvas); + const context = canvas.getContext("2d")!; + let scene: PlanMapScene = { + meta: null, + image: null, + contours: null, + nodes: [], + reach: [], + others: [], + line: [], + startM: 0, + stations: [], + selected: null, + editable: false, + }; + let meta: VWorldMeta | null = null; + let fitted = false; + let labels: StationLabelBox[] = []; + let view: ViewState = { + width: 0, + height: 0, + scale: 1, + offsetX: 0, + offsetY: 0, + mapRect: computeMapRect(null, 0, 0), + }; + + const toScreen = (point: Vertex): [number, number] => + createMetricProjector(meta!, view).toScreen(point[0], point[1]); + + function refit(): void { + const all = [...scene.nodes, ...scene.line]; + meta = scene.meta ?? fakeMeta(all); + if (!meta || !view.width || !all.length) return; + const xs = all.map(([x]) => x); + const ys = all.map(([, y]) => y); + const box = { + x_min: Math.min(...xs), + x_max: Math.max(...xs), + y_min: Math.min(...ys), + y_max: Math.max(...ys), + }; + view = { + ...view, + mapRect: computeMapRect(meta, view.width, view.height), + ...computeRouteView(meta, box, view.width, view.height, 150), + }; + fitted = true; + } + + function polyline(points: Vertex[]): void { + context.beginPath(); + points.forEach((point, i) => { + const [sx, sy] = toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + } + + function draw(): void { + context.clearRect(0, 0, view.width, view.height); + if (!meta) return; + const projector = createMetricProjector(meta, view); + if (scene.image?.complete && scene.image.naturalWidth && scene.meta) { + const [x0, y0] = normalizedToScreen(view, 0, 0); + const [x1, y1] = normalizedToScreen(view, 1, 1); + context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); + } + if (scene.contours) { + context.save(); + context.strokeStyle = "rgba(255, 214, 140, 0.7)"; + context.lineWidth = 0.8; + drawPreparedLayer( + context, + scene.contours, + view, + "dot", + pickLevelStep(scene.contours, view, 5), + ); + context.font = "10px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + drawPreparedLabels(context, scene.contours, view, "#fde68a", 25, 0, "rgba(0, 0, 0, 0.6)"); + context.restore(); + } + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = "rgba(255, 255, 255, 0.55)"; + context.lineWidth = 1.5; + context.setLineDash([6, 4]); + for (const other of scene.others) if (other.length > 1) polyline(other); + context.setLineDash([]); + if (scene.line.length > 1) { + context.strokeStyle = routeLineColor(); + context.lineWidth = 3; + polyline(scene.line); + } + context.restore(); + labels = []; + if (scene.line.length > 1) { + drawStationTicks( + context, + leadInLine(scene.line, scene.startM).map(([x, y]) => ({ x, y })), + { + intervalM: PLAN_STATION_INTERVAL_M, + pxPerMeter: projector.pxPerMeter, + toScreen: projector.toScreen, + chainages: scene.stations, + onLabel: (box) => labels.push(box), + }, + ); + } + if (scene.selected !== null) { + const at = stationScreen(scene.selected); + if (at) { + context.beginPath(); + context.arc(at[0], at[1], 7, 0, Math.PI * 2); + context.lineWidth = 2.5; + context.strokeStyle = themeColor("--color-primary", "#6d28d9"); + context.stroke(); + } + } + scene.nodes.forEach((node, i) => { + const [sx, sy] = toScreen(node); + const last = i === scene.nodes.length - 1; + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.fillStyle = i === 0 ? "#16a34a" : last ? "#dc2626" : "#2563eb"; + context.fill(); + context.lineWidth = 2; + context.strokeStyle = scene.reach[i] === false ? "#ef4444" : "#ffffff"; + context.stroke(); + if (scene.reach[i] === false) { + context.beginPath(); + context.arc(sx, sy, 12, 0, Math.PI * 2); + context.setLineDash([3, 3]); + context.stroke(); + context.setLineDash([]); + } + }); + } + + /** 측점 누가 → 화면 px — 앞붙임 선 위 보간(눈금과 같은 자리). */ + function stationScreen(chainage: number): [number, number] | null { + if (!meta || scene.line.length < 2) return null; + const line = leadInLine(scene.line, scene.startM); + let travelled = 0; + for (let i = 1; i < line.length; i += 1) { + const [a, b] = [line[i - 1], line[i]]; + const span = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (travelled + span >= chainage - 1e-6) { + const t = span ? (chainage - travelled) / span : 0; + return toScreen([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + travelled += span; + } + return toScreen(line[line.length - 1]); + } + + function resize(): void { + const { width, height } = fitCanvas(canvas, context); + if (!width || !height) return; + view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; + if (!fitted) refit(); + draw(); + } + + const at = (event: PointerEvent | MouseEvent): [number, number] => { + const rect = canvas.getBoundingClientRect(); + return [event.clientX - rect.left, event.clientY - rect.top]; + }; + const nodeAt = (px: number, py: number): number => { + let best = -1; + let bestDistance = NODE_HIT_PX; + scene.nodes.forEach((node, i) => { + const [sx, sy] = toScreen(node); + const distance = Math.hypot(sx - px, sy - py); + if (distance < bestDistance) [best, bestDistance] = [i, distance]; + }); + return best; + }; + + // 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김 + let dragging: { index: number; id: number } | null = null; + let press: [number, number] | null = null; + 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(); + dragging = { index, id: event.pointerId }; + canvas.setPointerCapture(event.pointerId); + canvas.style.cursor = "grabbing"; + }); + canvas.addEventListener("pointermove", (event) => { + if (!dragging || event.pointerId !== dragging.id || !meta) { + if (meta && scene.editable && !dragging) + canvas.style.cursor = nodeAt(...at(event)) >= 0 ? "grab" : ""; + return; + } + events.onDrag(dragging.index, screenToMetric(meta, view, ...at(event))); + }); + const endDrag = (event: PointerEvent): void => { + if (!dragging || event.pointerId !== dragging.id) return; + const index = dragging.index; + dragging = null; + canvas.style.cursor = ""; + events.onDragEnd(index); + }; + canvas.addEventListener("pointerup", endDrag); + canvas.addEventListener("pointercancel", endDrag); + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => (view = next), + getMeta: () => meta, + leftPan: true, + draw, + }); + // 측점 누름 — 손을 거의 안 움직인 누름만(옮기기 끝은 안 셈) + canvas.addEventListener("click", (event) => { + if (!press || Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX) + return; + if (!meta || scene.line.length < 2) return; + const [px, py] = at(event); + const hit = stationAtScreen( + leadInLine(scene.line, scene.startM), + toScreen, + PLAN_STATION_INTERVAL_M, + px, + py, + 14, + labels, + scene.stations, + ); + if (hit) events.onStation(hit.chainage); + }); + + const observer = new ResizeObserver(() => resize()); + observer.observe(root); + + return { + root, + update(patch) { + const metaChanged = "meta" in patch && patch.meta !== scene.meta; + scene = { ...scene, ...patch }; + if (metaChanged || !fitted) refit(); + draw(); + }, + fit() { + refit(); + draw(); + }, + stationScreen, + dispose: () => observer.disconnect(), + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts new file mode 100644 index 000000000..d1eb5f677 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -0,0 +1,468 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Page.ts + * D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`). + * + * 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 · + * 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D]. + * 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」. + * + * 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis + * (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만. + * 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음. + * ========================================================================== */ + +import "./D03_PlanAnalysis_UI_Style.css"; +import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + createNormalizer, + type GeoJsonCollection, + type PreparedLayer, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; +import { stationLabel } from "../common_util/common_util_station"; +import { + fetchPlanAnalysis, + fetchPlanPoints, + fetchPlanProgress, + fetchYearSurfaces, + savePlanAnalysis, + startPlanJob, + type PlanAnalysisView, + type PlanJob, + type YearSurface, +} from "./D03_PlanAnalysis_Api"; +import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; +import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross"; +import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; +import { + chainRows, + joinedLine, + nodesInReach, + nodesOf, + PLAN_MODES, + pointsOf, + rowAt, + sameNodes, + stationChainages, + type ChainRow, + type Vertex, + type YearAnalysis, +} from "./logic/D03_PlanAnalysis_Plan"; + +const MODE_LABEL: Record = { + gradeline: "영선", + earthwork: "토공 최소", + gentle: "기울기 최소", + weighted: "비중 계산", +}; +const POLL_MS = 1000; +/** 등고선을 받아 올 지점 둘레(m). */ +const CONTOUR_PAD_M = 600; + +interface Draft { + nodes: Vertex[] | null; + choices: Record; +} + +const sleep = (ms: number): Promise => 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 { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const map = createPlanMap({ onDrag, onDragEnd, onStation }); + const cross = createPlanCross(); + const main = document.createElement("div"); + main.className = "d03-plan"; + main.append(map.root, cross.root); + + const terrainText = document.createElement("p"); + terrainText.className = "d03-plan__hint"; + const bar = document.createElement("progress"); + bar.className = "d03-plan__bar"; + bar.max = 100; + bar.hidden = true; + const more = createButton({ + label: "범위 밖 — 더 받기", + variant: "filled", + onClick: () => void download(), + }); + more.hidden = true; + const yearList = document.createElement("div"); + yearList.className = "d03-plan__years"; + const analyzeButton = createButton({ + label: "분석", + variant: "ghost", + onClick: () => void analyze(), + }); + const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); + const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() }); + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(analyzeButton, saveButton, viewButton); + const terrainCard = createPanelCard("지형", false); + terrainCard.append(terrainText, bar, more); + const routeCard = createPanelCard("연도별 노선", false); + routeCard.append(yearList); + const panel = document.createElement("div"); + panel.className = "ui-sidebar-panel d03-plan__panel"; + panel.append(terrainCard, routeCard, actions); + attachCollapsible(panel); + mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel }); + root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); + + let years: number[] = []; + let savedNodes: Vertex[] = []; + let nodes: Vertex[] = []; + let choices: Record = {}; + let startM = 0; + let analysis: PlanAnalysisView | null = null; + let surfaces = new Map(); + let rows: ChainRow[] = []; + let results: YearAnalysis[] = []; + let selected: number | null = null; + let editable = false; + let busy: "terrain" | "analyze" | null = null; + + const moved = (): boolean => !sameNodes(nodes, savedNodes); + const sentPoints = () => (moved() ? pointsOf(years, nodes) : null); + const reach = (): boolean[] => + nodesInReach( + nodes, + years.map((year) => { + const grid = surfaces.get(year)?.grid; + return grid + ? { + x0: grid.x[0], + x1: grid.x[grid.x.length - 1], + y0: grid.y[0], + y1: grid.y[grid.y.length - 1], + } + : null; + }), + ); + const keepDraft = (): void => + writeState("d03-plan", { nodes: moved() ? nodes : null, choices } satisfies Draft, projectId); + + /** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */ + function refresh(): void { + results = years.map( + (year, i) => + analysis?.years.find((entry) => entry.year === year)?.analysis ?? { + year, + start: nodes[i], + end: nodes[i + 1], + routes: {}, + }, + ); + rows = chainRows(results, choices, startM); + const line = joinedLine(results, rows); + const others: Vertex[][] = results.flatMap((result, i) => { + const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode) + .map((mode) => result.routes[mode]?.planned) + .filter((planned): planned is Vertex[] => !!planned && planned.length > 1); + return rows[i].mode ? routes : [[nodes[i], nodes[i + 1]]]; + }); + const inReach = reach(); + map.update({ + nodes, + reach: inReach, + others, + line, + startM, + stations: stationChainages(rows, PLAN_STATION_INTERVAL_M), + selected, + editable, + }); + renderYears(); + const outside = inReach.some((ok) => !ok); + const missing = rows.some((row) => !row.mode); + more.hidden = !editable || !outside || busy !== null; + if (busy === null) + terrainText.textContent = outside + ? "빨간 고리 지점이 받은 지형 밖입니다 — 더 받아야 분석합니다." + : `받은 지형 안 · 연도 지형면 ${surfaces.size}/${years.length}`; + analyzeButton.hidden = saveButton.hidden = !editable; + analyzeButton.disabled = busy !== null || outside; + saveButton.disabled = busy !== null || missing; + saveButton.title = missing + ? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요." + : ""; + viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year)); + } + + function renderYears(): void { + yearList.replaceChildren( + ...results.map((result, i) => { + const row = rows[i]; + const box = document.createElement("div"); + box.className = "d03-plan__year"; + box.dataset.year = String(result.year); + const head = document.createElement("div"); + head.className = "d03-plan__year-head"; + head.textContent = row.mode + ? `${result.year}년 · ${stationLabel(row.start_chainage_m, PLAN_STATION_INTERVAL_M, 1)} ~ ` + + `${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)` + : `${result.year}년 · 분석 전 — [분석]을 누르세요`; + box.append(head); + for (const mode of PLAN_MODES) { + const route = result.routes[mode]; + if (!route) continue; + const card = document.createElement("button"); + card.type = "button"; + card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : ""); + card.dataset.mode = mode; + const metrics = route.metrics; + card.disabled = !!route.error || !metrics; + card.title = route.error ?? ""; + 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}` + : "노선 없음"; + card.append(name, values); + card.addEventListener("click", () => { + choices = { ...choices, [result.year]: mode }; + keepDraft(); + refresh(); + }); + box.append(card); + } + return box; + }), + ); + } + + function onDrag(index: number, point: Vertex): void { + nodes = nodes.map((node, i) => (i === index ? point : node)); + map.update({ nodes, reach: reach() }); + } + + function onDragEnd(index: number): void { + // 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] + if (analysis) + analysis = { + ...analysis, + years: analysis.years.map((entry, i) => + i === index - 1 || i === index ? { ...entry, analysis: null } : entry, + ), + }; + keepDraft(); + refresh(); + } + + function onStation(chainage: number): void { + const row = rowAt(rows, chainage); + const surface = row ? surfaces.get(row.year) : undefined; + if (!row?.mode || !surface) return; + const result = results.find((entry) => entry.year === row.year)!; + selected = chainage; + map.update({ selected }); + cross.show( + chainage, + row.year, + result.routes[row.mode].planned ?? [], + Math.max(0, chainage - row.start_chainage_m), + surface.grid, + ); + } + + async function loadBackground(): Promise { + let meta: VWorldMeta | null = null; + try { + meta = await fetchVWorldMeta(projectId, "satellite"); + } catch { + meta = null; // 위성 못 받음 — 가짜 도엽 위에 그림 + } + let image: HTMLImageElement | null = null; + let contours: PreparedLayer | null = null; + if (meta) { + image = new Image(); + image.onload = () => map.update({}); + // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 + image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; + const xs = nodes.map(([x]) => x); + const ys = nodes.map(([, y]) => y); + const bbox = [ + Math.min(...xs) - CONTOUR_PAD_M, + Math.min(...ys) - CONTOUR_PAD_M, + Math.max(...xs) + CONTOUR_PAD_M, + Math.max(...ys) + CONTOUR_PAD_M, + ].map((v) => v.toFixed(0)); + try { + const collection = await fetchCachedSheetLayer( + projectId, + DRAINAGE_SHEET_LAYERS[0], + "route", + bbox.join(","), + ); + contours = prepareLayer(collection, createNormalizer(meta), ["등고수치"]); + } catch { + contours = null; + } + } + map.update({ meta, image, contours }); + } + + async function loadSurfaces(): Promise { + try { + surfaces = await fetchYearSurfaces(projectId); + } catch (error) { + showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + } + + async function loadAnalysis(): Promise { + try { + analysis = await fetchPlanAnalysis(projectId, sentPoints()); + } catch (error) { + analysis = null; + showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + } + + /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ + async function watch(kind: "terrain" | "analyze"): Promise { + busy = kind; + bar.hidden = false; + refresh(); + try { + for (;;) { + if (!root.isConnected) return null; + const job = (await fetchPlanProgress(projectId))[kind]; + bar.value = job.percent; + terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`; + if (!job.running) { + if (job.error) showToast(job.error, "error"); + return job; + } + await sleep(POLL_MS); + } + } finally { + busy = null; + bar.hidden = true; + } + } + + async function run(kind: "terrain" | "analyze"): Promise { + try { + await startPlanJob(projectId, kind, sentPoints()); + } catch (error) { + // 이미 도는 일(다른 창 · D02 [>])이면 그것을 지켜봄 + if (!/이미 돌고/.test(errorText(error))) { + showToast(errorText(error), "error"); + return null; + } + } + return watch(kind); + } + + async function download(): Promise { + const job = await run("terrain"); + if (!job || !root.isConnected) return; + await Promise.all([loadSurfaces(), loadBackground()]); + refresh(); + } + + async function analyze(): Promise { + const job = await run("analyze"); + if (!job || !root.isConnected) return; + await loadAnalysis(); + refresh(); + } + + async function save(): Promise { + const picked = Object.fromEntries( + rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]), + ); + showLoadingOverlay(); + try { + await savePlanAnalysis(projectId, picked, sentPoints()); + clearState("d03-plan", projectId); + await loadPoints(); + await loadAnalysis(); + refresh(); + showToast("예정노선을 저장했습니다.", "success"); + } catch (error) { + showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + } + + function open3d(): void { + const items = rows.flatMap((row, i) => { + const surface = surfaces.get(row.year); + const line = row.mode ? results[i].routes[row.mode].planned : undefined; + if (!surface || !line) return []; + const stations = stationChainages([row], PLAN_STATION_INTERVAL_M).map((chainage) => ({ + chainage, + localM: chainage - row.start_chainage_m, + })); + return [{ year: row.year, modelId: surface.modelId, grid: surface.grid, line, stations }]; + }); + const first = selected !== null ? rowAt(rows, selected)?.year : undefined; + openPlan3d(projectId, items, first ?? items[0]?.year ?? 0); + } + + async function loadPoints(): Promise { + const view = await fetchPlanPoints(projectId); + const empty = view.years.find((entry) => !entry.start || !entry.end); + if (empty) { + terrainText.textContent = `${empty.year}년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.`; + return false; + } + years = view.years.map((entry) => entry.year); + savedNodes = nodesOf( + view.years.map((entry) => ({ + start: [entry.start!.x, entry.start!.y] as Vertex, + end: [entry.end!.x, entry.end!.y] as Vertex, + })), + ); + const draft = readState("d03-plan", projectId); + nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes; + choices = draft?.choices ?? {}; + return true; + } + + // 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정) + (window as unknown as { __d03Plan?: unknown }).__d03Plan = { + stationScreen: map.stationScreen, + rows: () => rows, + nodes: () => nodes, + pick: onStation, + }; + + try { + editable = !!(await fetchSessionUser())?.is_forest_service; + if (!(await loadPoints())) return; + await Promise.all([loadSurfaces(), loadAnalysis()]); + startM = analysis?.start_chainage_m ?? 0; + refresh(); + map.fit(); + await loadBackground(); + const progress = await fetchPlanProgress(projectId); + if (progress.analyze.running) await analyze(); + if (progress.terrain.running) await download(); + else if (editable && reach().some((ok) => !ok)) await download(); + } catch (error) { + terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; + } +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css new file mode 100644 index 000000000..fc5773e7a --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -0,0 +1,129 @@ +/* D03 예정노선 2단계 분석(PLAN 530-3) — 평면이 본문을 채우고 아래 횡단 판 · 좌측 연도 카드. */ + +.d03-plan-layout .ui-workflow-layout__main { + height: calc(100dvh - var(--app-header-h)); + overflow: hidden; +} + +.d03-plan { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.d03-plan__map { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: #1f2937; +} + +.d03-plan__map canvas { + display: block; + touch-action: none; +} + +.d03-plan__cross { + position: relative; + flex: 0 0 240px; + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.d03-plan__cross.is-collapsed { + flex-basis: 0; +} + +.d03-plan__cross.is-collapsed > :not(.ui-workflow-overlay__handle) { + display: none; +} + +.d03-plan__cross > .ui-workflow-overlay__handle { + position: absolute; + top: -28px; + left: 50%; + transform: translateX(-50%); +} + +.d03-plan__cross-head { + padding: 6px 12px 0; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.d03-plan__cross-body { + flex: 1 1 auto; + min-height: 0; + padding: 4px 12px 8px; +} + +.d03-plan__cross-body p { + margin: 12px 0; + color: var(--color-text-secondary); +} + +.d03-plan__hint { + margin: 0 0 6px; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.d03-plan__bar { + width: 100%; +} + +.d03-plan__years { + display: flex; + flex-direction: column; + gap: 10px; +} + +.d03-plan__year { + display: flex; + flex-direction: column; + gap: 4px; +} + +.d03-plan__year-head { + font-weight: 600; + font-size: 0.85rem; +} + +.d03-plan__mode { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 6px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + font-size: 0.8rem; + text-align: left; + cursor: pointer; +} + +.d03-plan__mode.is-picked { + border-color: var(--color-primary); + box-shadow: inset 0 0 0 1px var(--color-primary); +} + +.d03-plan__mode:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.d03-plan__mode span { + color: var(--color-text-secondary); +} + +.d03-plan__3d-top { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} diff --git a/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts index 00fe6d874..c7983eacf 100644 --- a/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts +++ b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts @@ -1,7 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_Route.ts - * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. - * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대) + * 길 선언(PLAN 529-1 · 530-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 분석 화면 * ========================================================================== */ import { ROUTES } from "@config/config_frontend"; @@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = { workflow: true, project: true, footerless: true, - load: async () => - (await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D03_PLAN_ANALYSIS), + load: async () => (await import("../D03_PlanAnalysis_UI_Page")).renderD03PlanAnalysis, }; diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts new file mode 100644 index 000000000..9e1fbebbf --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -0,0 +1,180 @@ +/* ============================================================================= + * D03_PlanAnalysis_Plan.ts + * D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 · + * 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음. + * + * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` ↔ + * `pickMode` · `chainRows` · `joinedLine`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 + * `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 짝: `D03_PlanAnalysis_Analyze.PLAN_MODES`(follow 뺌). */ +export const PLAN_MODES = ["gradeline", "earthwork", "gentle", "weighted"] as const; +export type PlanMode = (typeof PLAN_MODES)[number]; + +/** 짝: `D03_PlanAnalysis_Terrain.PLAN_CORRIDOR_M` — 지점이 연도 DEM 가장자리에서 이만큼 안이어야 + * 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */ +export const PLAN_REACH_M = 300; + +export interface PlanRoute { + planned?: Vertex[]; + metrics?: { + length_m: number; + max_grade_pct?: number; + over_limit?: { total: number }; + }; + error?: string; +} + +export interface YearAnalysis { + year: number; + start: Vertex; + end: Vertex; + routes: Record; +} + +export interface ChainRow { + year: number; + mode: string | null; + length_m: number; + start_chainage_m: number; + end_chainage_m: number; +} + +const round3 = (value: number): number => Number(value.toFixed(3)); + +/** 고른 갈래 — 없거나 빈 갈래면 차례대로 첫 성공 갈래. */ +export function pickMode(result: YearAnalysis, wanted: string | null | undefined): string | null { + const routes = result.routes ?? {}; + const ok = (mode: string): boolean => mode in routes && !("error" in routes[mode]); + if (wanted && ok(wanted)) return wanted; + return PLAN_MODES.find(ok) ?? null; +} + +/** 연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가. */ +export function chainRows( + results: ReadonlyArray, + choices: Readonly>, + startChainageM: number, +): ChainRow[] { + let at = startChainageM || 0; + return results.map((result) => { + const mode = pickMode(result, choices[result.year]); + const length = mode ? Number(result.routes[mode].metrics?.length_m ?? 0) : 0; + const row = { + year: result.year, + mode, + length_m: round3(length), + start_chainage_m: round3(at), + end_chainage_m: round3(at + length), + }; + at += length; + return row; + }); +} + +const samePoint = (a: Vertex, b: Vertex): boolean => + Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3; + +/** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */ +export function joinedLine( + results: ReadonlyArray, + rows: ReadonlyArray, +): Vertex[] { + const line: Vertex[] = []; + results.forEach((result, i) => { + const mode = rows[i]?.mode; + if (!mode) return; + const planned = result.routes[mode].planned ?? []; + const head = line.length && planned.length && samePoint(line[line.length - 1], planned[0]); + line.push(...(head ? planned.slice(1) : planned)); + }); + return line; +} + +/** 측점 누가(오름차순) — 연도 경계마다(경계점 = 측점) + 간격 배수 + 끝. 노선 없는 연도는 건너뜀. */ +export function stationChainages(rows: ReadonlyArray, intervalM: number): number[] { + const out: number[] = []; + const push = (value: number): void => { + if (!out.length || value - out[out.length - 1] > 1e-6) out.push(round3(value)); + }; + for (const row of rows) { + if (!row.mode) continue; + push(row.start_chainage_m); + let next = Math.floor(row.start_chainage_m / intervalM + 1e-9) * intervalM + intervalM; + for (; next < row.end_chainage_m - 1e-6; next += intervalM) push(next); + push(row.end_chainage_m); + } + return out; +} + +/** 측점 그리기 · 집기용 선 — 첫 점 앞에 첫 구간 방향으로 `startM` 만큼 뒤로 뺀 점을 붙여 선 위 누가 = + * 등록 시작 누가부터(`drawStationTicks` · `stationAtScreen` 은 선 첫 점을 0 으로 셈). */ +export function leadInLine(line: ReadonlyArray, startM: number): Vertex[] { + if (line.length < 2 || !(startM > 0)) return [...line]; + const [a, b] = line; + const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const back: Vertex = [ + a[0] - ((b[0] - a[0]) / length) * startM, + a[1] - ((b[1] - a[1]) / length) * startM, + ]; + return [back, ...line]; +} + +/** 누가가 든 연도 행(경계는 앞 연도) — 노선 있는 연도만 · 밖이면 null. */ +export function rowAt(rows: ReadonlyArray, chainageM: number): ChainRow | null { + return ( + rows.find( + (row) => + row.mode && + chainageM >= row.start_chainage_m - 1e-6 && + chainageM <= row.end_chainage_m + 1e-6, + ) ?? null + ); +} + +/** 연도별 지점 → 마디 P0…Pn(직전 종점 = 다음 시점). */ +export function nodesOf(years: ReadonlyArray<{ start: Vertex; end: Vertex }>): Vertex[] { + return years.length ? [years[0].start, ...years.map((year) => year.end)] : []; +} + +/** 마디 → 연도별 지점(서버 `points` 꼴). */ +export function pointsOf( + years: ReadonlyArray, + nodes: ReadonlyArray, +): Array<{ year: number; start: Vertex; end: Vertex }> { + return years.map((year, i) => ({ year, start: nodes[i], end: nodes[i + 1] })); +} + +export interface Box { + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** 마디마다 받은 지형 안인가 — 그 마디를 끝점으로 쓰는 연도(앞 · 뒤) DEM 상자를 `reachM` 만큼 줄인 안. + * 연도 DEM 이 없으면 밖. */ +export function nodesInReach( + nodes: ReadonlyArray, + boxes: ReadonlyArray, + reachM: number = PLAN_REACH_M, +): boolean[] { + const inside = (point: Vertex, box: Box | null): boolean => + !!box && + point[0] >= box.x0 + reachM && + point[0] <= box.x1 - reachM && + point[1] >= box.y0 + reachM && + point[1] <= box.y1 - reachM; + return nodes.map((point, i) => { + const years = [i - 1, i].filter((year) => year >= 0 && year < boxes.length); + return years.every((year) => inside(point, boxes[year])); + }); +} + +/** 마디가 정본과 같은가(1 mm — 짝: `D02_PlanBase_Router.SAME_M`). */ +export function sameNodes(a: ReadonlyArray, b: ReadonlyArray): boolean { + return a.length === b.length && a.every((point, i) => samePoint(point, b[i])); +} diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index f8755420c..2e82813b0 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -1,4 +1,5 @@ -"""D 분석 API(PLAN 530-2) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래 거절. +"""D 분석 API(PLAN 530-2 · 530-3) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래 +거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만). ./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q """ @@ -9,6 +10,7 @@ import pytest from fastapi import FastAPI 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_Router as router_mod @@ -86,16 +88,26 @@ def client(tmp_path, monkeypatch): ) project = {"road_type": "work", "design_speed_kph": None, "route_start_m": 1000.0} - async def inputs(project_id): - return tmp_path, project, segments + async def inputs(project_id, points=None): + return tmp_path, project, router_mod._override(segments, points) if points else segments log: list = [] monkeypatch.setattr(router_mod, "_plan_inputs", inputs) monkeypatch.setattr(router_mod, "get_db_pool", lambda: _Pool(log)) app = FastAPI() app.include_router(router_mod.router) + session = {"role": "USER", "email": "a@forest.go.kr"} + app.dependency_overrides[require_company] = lambda: session + stored: list = [] + + async def store_points(project_id, segs): + stored.append([(s["year"], s["start"], s["end"]) for s in segs]) + + monkeypatch.setattr(router_mod, "_store_points", store_points) test = TestClient(app) test.log = log + test.session = session + test.stored = stored return test @@ -125,3 +137,39 @@ def test_put_rejects_unknown_or_empty_mode(client) -> None: assert client.put(url, json={"choices": {"2027": "follow"}}).status_code == 400 assert client.put(url, json={"choices": {"2027": "earthwork"}}).status_code == 409 assert not [sql for sql, _ in client.log if sql.startswith("UPDATE")] + + +def _points(p1) -> list: + return [ + {"year": 2026, "start": list(P0), "end": list(p1)}, + {"year": 2027, "start": list(p1), "end": list(P2)}, + ] + + +def test_points_override_sees_matching_store_only(client) -> None: + url = f"/api/projects/{PID}/plan/analysis" + same = client.get(url, params={"points": json.dumps(_points(P1))}).json() + assert same["chain"] is not None and same["line"][-1] == [900, 400] + moved = client.get(url, params={"points": json.dumps(_points((310.0, 400.0)))}).json() + assert moved["chain"] is None and moved["years"][0]["analysis"] is None # 끈 지점 = 저장본 없음 + + +def test_points_must_chain_and_cover_years(client) -> None: + url = f"/api/projects/{PID}/plan/analysis" + broken = _points(P1) + broken[1]["start"] = [301.0, 400.0] + assert client.get(url, params={"points": json.dumps(broken)}).status_code == 400 + assert client.get(url, params={"points": json.dumps(_points(P1)[:1])}).status_code == 400 + + +def test_put_with_points_stores_points_forest_only(client) -> None: + url = f"/api/projects/{PID}/plan/analysis" + body = {"choices": {"2027": "gradeline"}, "points": _points(P1)} + assert client.put(url, json=body).status_code == 200 + assert client.stored == [[(2026, P0, P1), (2027, P1, P2)]] + assert client.put(url, json={"choices": {}}).status_code == 200 and len(client.stored) == 1 + client.session["email"] = "a@gmail.com" + assert client.put(url, json=body).status_code == 403 + 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 diff --git a/resources/tester/test_530_3_plan_logic.py b/resources/tester/test_530_3_plan_logic.py new file mode 100644 index 000000000..34b4578f2 --- /dev/null +++ b/resources/tester/test_530_3_plan_logic.py @@ -0,0 +1,142 @@ +"""D03 화면 계산 거울(PLAN 530-3) — 파이썬 `D03_PlanAnalysis_Analyze` ↔ TS `logic/D03_PlanAnalysis_Plan.ts`. + +짝: `pick_mode` · `chain` · `joined_line` ↔ `pickMode` · `chainRows` · `joinedLine`. +TS 만: 측점 누가(경계 = 측점 · 간격 배수) · 앞붙임 선(선 위 누가 = 등록 시작 누가) · 받은 지형 판정. + +./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze # noqa: E402 + +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +const job = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = job.cases.map((c) => { + const rows = plan.chainRows(job.results, c.choices, c.start); + return { + picks: job.results.map((r) => plan.pickMode(r, c.choices[r.year])), + rows, + line: plan.joinedLine(job.results, rows), + stations: plan.stationChainages(rows, 20), + }; +}); +const lead = plan.leadInLine([[0, 0], [3, 4], [10, 4]], 1234.5); +const reach = plan.nodesInReach( + [[500, 500], [900, 500], [1700, 500]], + [{ x0: 0, y0: 0, x1: 1300, y1: 1000 }, null], +); +writeFileSync(process.argv[3], JSON.stringify({ out, lead, reach })); +""" + +_P0, _P1, _P2 = [0.0, 0.0], [300.0, 400.0], [900.0, 400.0] +_RESULTS = [ + { + "year": 2026, + "start": _P0, + "end": _P1, + "routes": { + "gradeline": {"planned": [_P0, [100.0, 200.0], _P1], "metrics": {"length_m": 530.0}}, + "earthwork": {"error": "길 없음"}, + "gentle": {"planned": [_P0, _P1], "metrics": {"length_m": 500.004}}, + }, + }, + { + "year": 2027, + "start": _P1, + "end": _P2, + "routes": { + "gradeline": {"error": "길 없음"}, + "gentle": {"planned": [_P1, [600.0, 500.0], _P2], "metrics": {"length_m": 640.333}}, + "weighted": {"planned": [_P1, _P2], "metrics": {"length_m": 600.0}}, + }, + }, +] +_CASES = [ + {"choices": {}, "start": 1000.0}, + {"choices": {"2026": "gentle", "2027": "weighted"}, "start": 17.25}, + {"choices": {"2026": "earthwork", "2027": "follow"}, "start": 0.0}, +] + + +def _run_ts(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--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") + job = tmp_path / "job.json" + result = tmp_path / "out.json" + job.write_text(json.dumps({"results": _RESULTS, "cases": _CASES}), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_ts_matches_python_chain(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + for case, ts in zip(_CASES, got["out"]): + choices = {int(k): v for k, v in case["choices"].items()} + rows = analyze.chain(_RESULTS, choices, case["start"]) + assert ts["picks"] == [analyze.pick_mode(r, choices.get(r["year"])) for r in _RESULTS] + assert ts["rows"] == rows + assert ts["line"] == analyze.joined_line(_RESULTS, rows) + + # 측점 = 경계(1530) · 20 배수 · 끝 — 첫 연도 시작 1000 · 끝 2170.333 + stations = got["out"][0]["stations"] + assert stations[:3] == [1000.0, 1020.0, 1040.0] + assert 1530.0 in stations and 1520.0 in stations and 1540.0 in stations + assert stations[-2:] == [2160.0, 2170.333] + # 시작 17.25 → 첫 규칙 측점 20 + assert got["out"][1]["stations"][:2] == [17.25, 20.0] + + +def test_lead_in_and_reach(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + back = got["lead"][0] + assert abs(back[0] + 1234.5 * 0.6) < 1e-9 and abs(back[1] + 1234.5 * 0.8) < 1e-9 + assert got["lead"][1:] == [[0, 0], [3, 4], [10, 4]] + # 마디 0 = 첫 연도 안 · 마디 1 = 첫 연도 안 + 둘째 연도 DEM 없음 = 밖 · 마디 2 = 밖 + assert got["reach"] == [True, False, False]