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:
2026-10-25 20:11:23 +09:00
co-authored by Claude Opus 5.5
parent 94c47acd1e
commit 50e9c1b015
14 changed files with 1760 additions and 33 deletions
+2
View File
@@ -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
+112
View File
@@ -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;
}
+100 -16
View File
@@ -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:
+110
View File
@@ -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);
},
};
}
+366
View File
@@ -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]));
}
+51 -3
View File
@@ -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
+142
View File
@@ -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]