feat(530-3): D03 예정노선 분석 화면 — 평면 · 지점 끌기 · 더 받기 · 갈래 카드 · 측점 · 지반 횡단 · 3D
- 평면 = 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점(누가 = 등록 시작부터) + 지점 끌기 겹 - 끈 지점 = 캐시 · 받기 · 분석 · 보기는 points 로 실어 보냄 · [저장] 때만 D02 규칙으로 정본 - 연도별 갈래 넷 카드(연장 · 최대 기울기 · 위반 수) · 고름 = 캐시 · [저장] = 서버 저장본으로 다시 셈 - 측점 누름 = 하 패널 지반선 횡단(sectionsGround · buildCrossSvg 지반만 인자 · 기본 그대로) - 3D 모달 = 연도 DEM + 노선 + 측점 막대(코리도 · 구조물 안 부름) - 화면 계산 logic 짝(pickMode · chainRows · joinedLine ↔ 파이썬) 거울 시험 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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<PlanPointsView> =>
|
||||
request(`${base(projectId)}/points`);
|
||||
|
||||
export const startPlanJob = (
|
||||
projectId: string,
|
||||
kind: "terrain" | "analyze",
|
||||
points: PlanPoints | null,
|
||||
): Promise<PlanJob> =>
|
||||
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<PlanAnalysisView> =>
|
||||
request(
|
||||
`${base(projectId)}/analysis` +
|
||||
(points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""),
|
||||
);
|
||||
|
||||
export const savePlanAnalysis = (
|
||||
projectId: string,
|
||||
choices: Record<number, string>,
|
||||
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<Map<number, YearSurface>> {
|
||||
const { models } = await listSurfaceModels(projectId);
|
||||
const out = new Map<number, YearSurface>();
|
||||
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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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<void> => {
|
||||
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));
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<PlanMapScene>) => 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(),
|
||||
};
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
gradeline: "영선",
|
||||
earthwork: "토공 최소",
|
||||
gentle: "기울기 최소",
|
||||
weighted: "비중 계산",
|
||||
};
|
||||
const POLL_MS = 1000;
|
||||
/** 등고선을 받아 올 지점 둘레(m). */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
|
||||
interface Draft {
|
||||
nodes: Vertex[] | null;
|
||||
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 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<number, string> = {};
|
||||
let startM = 0;
|
||||
let analysis: PlanAnalysisView | null = null;
|
||||
let surfaces = new Map<number, YearSurface>();
|
||||
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<void> {
|
||||
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<GeoJsonCollection>(
|
||||
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<void> {
|
||||
try {
|
||||
surfaces = await fetchYearSurfaces(projectId);
|
||||
} catch (error) {
|
||||
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnalysis(): Promise<void> {
|
||||
try {
|
||||
analysis = await fetchPlanAnalysis(projectId, sentPoints());
|
||||
} catch (error) {
|
||||
analysis = null;
|
||||
showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
|
||||
async function watch(kind: "terrain" | "analyze"): Promise<PlanJob | null> {
|
||||
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<PlanJob | null> {
|
||||
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<void> {
|
||||
const job = await run("terrain");
|
||||
if (!job || !root.isConnected) return;
|
||||
await Promise.all([loadSurfaces(), loadBackground()]);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function analyze(): Promise<void> {
|
||||
const job = await run("analyze");
|
||||
if (!job || !root.isConnected) return;
|
||||
await loadAnalysis();
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
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<boolean> {
|
||||
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<Draft>("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)}`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string, PlanRoute>;
|
||||
}
|
||||
|
||||
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<YearAnalysis>,
|
||||
choices: Readonly<Record<number, string | null | undefined>>,
|
||||
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<YearAnalysis>,
|
||||
rows: ReadonlyArray<ChainRow>,
|
||||
): 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<ChainRow>, 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<Vertex>, 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<ChainRow>, 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<number>,
|
||||
nodes: ReadonlyArray<Vertex>,
|
||||
): 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<Vertex>,
|
||||
boxes: ReadonlyArray<Box | null>,
|
||||
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<Vertex>, b: ReadonlyArray<Vertex>): boolean {
|
||||
return a.length === b.length && a.every((point, i) => samePoint(point, b[i]));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user