diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 2c6be09c5..cd50cd0a0 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -198,7 +198,7 @@ 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 예정노선 노선분석 `{version: 2, network: 그은 선 · 갈래 · 조각}` — [저장] 때 plan/network 로(563-1). */ "d03-plan": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 6284663e6..ea2362e4e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -1,29 +1,27 @@ /* ============================================================================= * D03_PlanAnalysis_Api.ts - * D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] · - * 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은 - * `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음). + * D03 분석 화면 서버 요청(PLAN 563-1) — 전체 시점 · 종점(D02 정본) · 지형 받기 범위 · 받기 · 진행 · + * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network). * ========================================================================== */ 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 { PlanEdit } from "./logic/D03_PlanAnalysis_Edit"; -import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan"; +import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan"; -/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */ -export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null }; +export interface BasePoint { + lon: number; + lat: number; + x: number; + y: number; +} -export interface PlanPointsView { - year_start: number; - year_end: number; +/** D02 정본 — 전체 시점 · 종점(작업 좌표계 m + 경위도). */ +export interface PlanBaseView { epsg: number | null; - years: Array<{ - year: number; - start: { x: number; y: number } | null; - end: { x: number; y: number } | null; - }>; + start: BasePoint | null; + end: BasePoint | null; } export interface PlanJob { @@ -33,23 +31,43 @@ export interface PlanJob { error: string | null; } -export interface PlanAnalysisView { - years: Array<{ - year: number; - start: Vertex; - end: Vertex; - analysis: YearAnalysis | null; - /** 저장된 꺾임점 고침(분석 지문이 같을 때만). */ - edit?: SavedEdit | null; - }>; - chain: ChainRow[] | null; - /** 등록 노선 시작 누가(m). */ - start_chainage_m: number; - /** 등록 노선 종료 누가(계획 목표 · m) — 없으면 null. */ - end_chainage_target_m?: number | null; +/** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */ +export interface TerrainExtent { + epsg: number; + margin_m: number; + box_m: [number, number, number, number]; + box: Array<[number, number]>; + have_m: [number, number, number, number] | null; + have: Array<[number, number]> | null; + covered: boolean; + sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; + cells: number; } -export type PlanPoints = Array<{ year: number; start: Vertex; end: Vertex }>; +/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ +export interface ServerNetwork { + epsg: number; + lines: PlanLine[]; + marks: Array<{ line: string; at_m: number }>; + pieces: PlanPiece[]; +} + +export interface PlanNetworkView { + network: ServerNetwork | null; + derived: { + lines: Array<{ id: string; length_m: number; stations: number[] }>; + pieces: Array; + } | null; + /** 옛 연도 노선을 선으로 바꾼 것인가. */ + legacy: boolean; + /** 사업연도 시작(있으면). */ + year_start?: number | null; +} + +export interface PlanSurface { + modelId: number; + grid: DtmGrid; +} async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -66,62 +84,52 @@ async function request(path: string, init: RequestInit = {}): Promise { } const base = (projectId: string): string => `/projects/${projectId}/plan`; -const pointsBody = (points: PlanPoints | null): string => JSON.stringify(points ? { points } : {}); -export const fetchPlanPoints = (projectId: string): Promise => +export const fetchPlanBase = (projectId: string): Promise => request(`${base(projectId)}/points`); -export const startPlanJob = ( +/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */ +export const fetchTerrainExtent = ( projectId: string, - kind: "terrain" | "analyze", - points: PlanPoints | null, + marginM: number | null, + extra: Vertex[], +): Promise => { + const query = new URLSearchParams(); + if (marginM !== null) query.set("margin_m", String(marginM)); + if (extra.length) query.set("extra", JSON.stringify(extra)); + return request(`${base(projectId)}/terrain/extent?${query}`); +}; + +export const startTerrain = ( + projectId: string, + marginM: number | null, + extra: Vertex[], ): Promise => - request(`${base(projectId)}/${kind}`, { method: "POST", body: pointsBody(points) }); - -export const fetchPlanProgress = ( - projectId: string, -): Promise<{ terrain: PlanJob; analyze: PlanJob }> => request(`${base(projectId)}/progress`); - -export const fetchPlanAnalysis = ( - projectId: string, - points: PlanPoints | null, -): Promise => - request( - `${base(projectId)}/analysis` + - (points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""), - ); - -export const savePlanAnalysis = ( - projectId: string, - choices: Record, - points: PlanPoints | null, - edits: Record = {}, -): Promise<{ chain: ChainRow[] }> => - request(`${base(projectId)}/analysis`, { - method: "PUT", - body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }), + request(`${base(projectId)}/terrain`, { + method: "POST", + body: JSON.stringify({ margin_m: marginM, extra }), }); -export interface YearSurface { - modelId: number; - grid: DtmGrid; -} +export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => + request(`${base(projectId)}/progress`); -/** 연도별 지형면 — 모델 행 경로 `…/models_<연도>/…` · 격자 = 분석과 같은 평활본. */ -export async function fetchYearSurfaces(projectId: string): Promise> { +export const fetchPlanNetwork = (projectId: string): Promise => + request(`${base(projectId)}/network`); + +export const savePlanNetwork = ( + projectId: string, + network: ServerNetwork, +): Promise => + request(`${base(projectId)}/network`, { method: "PUT", body: JSON.stringify(network) }); + +/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */ +export async function fetchPlanSurface(projectId: string): Promise { const { models } = await listSurfaceModels(projectId); - const out = new Map(); - for (const model of models) { - const year = Number(/models_(\d+)\//.exec(model.model_file_path ?? "")?.[1]); - if (!year || out.has(year)) continue; - const stamp = String(model.generation_params?.file_stamp ?? ""); - const url = - `${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/grid` + - `?smooth=true&sig=${encodeURIComponent(stamp)}`; - out.set(year, { - modelId: model.id, - grid: parseDtmGrid(await fetchCachedBytes(projectId, url)), - }); - } - return out; + const model = models.find((entry) => /models_plan\//.test(entry.model_file_path ?? "")); + if (!model) return null; + 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)}`; + return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts index b1082a6fa..a8d934226 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -1,13 +1,11 @@ /* ============================================================================= * D03_PlanAnalysis_UI_3D.ts - * D03 [3D] 큰 모달(PLAN 530-3 · 544-3) — 한 연도 DEM + 그 연도 고른 노선 + 측점 막대 + 예상 코리도. - * 예상 코리도 = 측점마다 횡단 판과 같은 예상 단면(`logic/D03_PlanAnalysis_Expect`) → B05 `buildCorridor` - * → `setCorridor`(노면 · 측구 · 절성토면 · 지형 잘림) · 구조물 없음 · 「예상 코리도」 켬끔. - * 연도 고름 = 모달 위 칸 · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지). + * D03 [3D] 큰 모달(PLAN 530-3 · 563-1) — 사업 전체 DEM + 그은 선 하나(지반에 붙임) + 측점 막대. + * 선 고름 = 모달 위 칸(본선 · 갈래) · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지). + * 예상 코리도 없음(꺾은선 · 계획고 = 지반고라 절성토 0 — 요구서 389 3장 11). * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createSelectField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; @@ -15,22 +13,16 @@ 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 { buildCorridor } from "../B05_Profile/logic/B05_Profile_Corridor_Build"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Types"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; -import { expectSections } from "./logic/D03_PlanAnalysis_Expect"; -import { loadExpectStandard } from "./D03_PlanAnalysis_UI_Cross"; -export interface Plan3dYear { - year: number; - modelId: number; - grid: DtmGrid; +export interface Plan3dLine { + name: string; line: Vertex[]; - /** 그 연도 노선 위 측점 누가(연도 시작 = 0) · 등록 누가. */ - stations: Array<{ localM: number; chainage: number }>; + /** 선 위 측점 누가(선마다 No.0 부터). */ + stations: number[]; } -/** 격자 → 뷰어 상자(지형에 붙는 노선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */ +/** 격자 → 뷰어 상자(지형에 붙는 선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */ function gridBounds(grid: DtmGrid): { x: [number, number]; y: [number, number]; @@ -50,77 +42,62 @@ function gridBounds(grid: DtmGrid): { }; } -export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void { - if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning"); +export function openPlan3d( + projectId: string, + surface: { modelId: number; grid: DtmGrid }, + lines: Plan3dLine[], +): void { + if (!lines.length) return void showToast("3D 로 볼 선이 없습니다.", "warning"); let viewer: RouteViewer | null = createRouteViewer(); + let loaded = false; const pick = createSelectField({ - options: years.map((item) => ({ value: String(item.year), text: `${item.year}년` })), - value: String(firstYear), + options: lines.map((item, i) => ({ value: String(i), text: item.name })), + value: "0", size: 28, - ariaLabel: "연도", + ariaLabel: "선", }).select; pick.classList.add("d03-plan__3d-year"); - const corridor = createCheckbox(" 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)", { - checked: true, - }); - const corridorBox = corridor.root; - corridorBox.classList.add("d03-plan__3d-corridor"); - const corridorOn = corridor.input; - corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked)); const top = document.createElement("div"); top.className = "d03-plan__3d-top"; - top.append(pick, corridorBox); - const standard = loadExpectStandard(projectId); + top.append(pick); - const load = async (year: number): Promise => { - const item = years.find((entry) => entry.year === year) ?? years[0]; + const load = async (index: number): Promise => { + const item = lines[index] ?? lines[0]; if (!viewer) return; const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); progress.set(null); viewer.root.append(progress.root); const started = performance.now(); - const stat: Record = { year: item.year, done: false }; + const stat: Record = { line: item.name, done: false }; (window as unknown as { __d03Plan3d?: unknown }).__d03Plan3d = stat; try { - await viewer.loadSurface(projectId, item.modelId, "dtm", true, 5, gridBounds(item.grid)); + if (!loaded) { + await viewer.loadSurface( + projectId, + surface.modelId, + "dtm", + true, + 5, + gridBounds(surface.grid), + ); + loaded = true; + } 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, - ); + const ground = sectionsGround(item.line, item.stations, surface.grid); viewer.renderStationLines( ground.stations.map((station, i) => ({ - station_id: String(item.stations[i]?.chainage ?? station.chainage_m), + station_id: String(item.stations[i] ?? 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, + chainage_m: item.stations[i] ?? station.chainage_m, })), 2.5, ); - const std = await standard; - if (!viewer) return; - const built = performance.now(); - const sections = std - ? (expectSections(ground.stations, std, item.grid) as unknown as CrossSection[]) - : []; - const build = buildCorridor( - sections, - item.line.map(([x, y]) => ({ x, y })), - ); - if (build) build.sections = sections; // 지형 잘림이 읽음 - viewer.setCorridor(build); - viewer.setCorridorVisible(corridorOn.checked); - Object.assign(stat, { - sections: sections.length, - corridor: !!build, - buildMs: Math.round(performance.now() - built), - centers: sections.map((c) => [c.center_x, c.center_y, c.center_z]), - }); + stat.stations = ground.stations.length; viewer.setView("iso"); } catch (error) { showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); @@ -133,7 +110,7 @@ export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: nu pick.addEventListener("change", () => void load(Number(pick.value))); openModal({ - title: "3D 보기 — 지형 + 노선", + title: "3D 보기 — 지형 + 그은 선", closeLabel: "닫기", wide: true, dialogClass: "d03-plan__3d-modal", @@ -148,5 +125,5 @@ export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: nu return true; }, }); - void load(Number(pick.value)); + void load(0); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts deleted file mode 100644 index 1232ffa41..000000000 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* ============================================================================= - * D03_PlanAnalysis_UI_Cross.ts - * D03 하 패널 — 누른 측점의 지반선 + 예상 횡단(PLAN 530-3 · 544-2). 계산 = 화면 TS 즉시(① 짝 `sectionsGround` ↔ - * `generate_sections` · 격자 = 그 연도 DEM 평활본) · 예상 = `logic/D03_PlanAnalysis_Expect`(표준 횡단 기본값 · - * 계획고 = 중심 지반고 · 표준값 = `sections/context` 세션 캐시) · 그림 = B05 `buildCrossSvg`. - * ========================================================================== */ - -import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createText } from "@ui/ui_template_text"; -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, - type StationGround, -} from "../B05_Profile/logic/B05_Profile_Sections_Ground"; -import { fetchSectionContext } from "../B06_Section/B06_Section_Api_Context"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; -import { expectCross, type ExpectStandard } from "./logic/D03_PlanAnalysis_Expect"; -import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; - -export interface PlanCross { - root: HTMLElement; - /** `chainage` = 등록 누가 · `localM` = 그 연도 노선 위 누가 · 돌려줌 = 그린 측점 지반(못 읽으면 null). */ - show: ( - chainage: number, - year: number, - line: Vertex[], - localM: number, - grid: DtmGrid, - ) => StationGround | null; - clear: (text: string) => void; -} - -/** 표준 횡단 기본값(config · 서버 한 번 · 세션 캐시) — 못 받으면 null(지반선만). */ -export function loadExpectStandard(projectId: string): Promise { - return fetchSectionContext(projectId).then( - (context) => ({ - standard: context.standard_cross_section as ExpectStandard["standard"], - rockBoundaryM: context.rock_boundary_default_offset_m, - }), - () => null, - ); -} - -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(createText(text, "muted", 14)); - delete root.dataset.chainage; - }; - clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다."); - let standard: ExpectStandard | null = null; - let asked: Promise | null = null; - - return { - root, - clear, - show(chainage, year, line, localM, grid) { - const station = sectionsGround(line, [localM], grid).stations[0]; - if (!station) { - clear("이 측점의 지반을 읽지 못했습니다."); - return null; - } - const name = stationLabel(chainage, PLAN_STATION_INTERVAL_M, 1); - const z = station.center_z === null ? "—" : `${station.center_z.toFixed(2)} m`; - root.dataset.chainage = String(chainage); - const draw = (): void => { - const expect = standard ? expectCross(station, standard, grid) : null; - const area = expect - ? ` · 예상(표준 횡단 기본값 · 계획고 = 지반고) 절토 ${expect.design.cut_area_m2.toFixed(2)}㎡ · 성토 ${expect.design.fill_area_m2.toFixed(2)}㎡` - : ""; - head.textContent = `${year}년 · ${name} · 중심 지반고 ${z}${area}`; - 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: expect?.samples ?? station.samples, - design: expect ? { ...expect.design } : null, - }, - Math.max(rect.width, 320), - Math.max(rect.height, 160), - !expect, - ); - body.replaceChildren(plot.svg); - }; - draw(); - if (!standard) { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - asked ??= loadExpectStandard(projectId); - void asked.then((value) => { - standard = value; - // 그사이 다른 측점을 눌렀으면 그 측점이 다시 그림 - if (value && root.dataset.chainage === String(chainage)) draw(); - }); - } - return station; - }, - }; -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts new file mode 100644 index 000000000..484d8809b --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -0,0 +1,607 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Draw.ts + * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음. + * + * 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝. + * 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷: + * 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것). + * 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움). + * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). + * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · + * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { + compassCandidates, + compassPoint, + distanceAlong, + fixedLengthPoint, + fixedSlopePoint, + groundSlopePct, + lineLength, + pointAtDistance, + segmentLength, + stationChainages, +} from "./logic/D03_PlanAnalysis_Draw"; +import { + addBranch, + appendVertex, + dropLastLine, + dropLastVertex, + insertVertex, + reachedEnd, + removeMark, + splitPiece, + type PlanNetwork, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +import { + planColors, + PLAN_STATION_INTERVAL_M, + type PlanMapLayer, + type ScreenMap, +} from "./D03_PlanAnalysis_UI_Map"; + +/** 종점 · 꺾임점에 붙는 반경(px). */ +const SNAP_PX = 12; +/** 선 위 누름으로 볼 반경(px). */ +const LINE_HIT_PX = 8; +/** 구간 길이 라벨을 보일 화면 길이(px). */ +const LABEL_MIN_PX = 70; +/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ +export const DRAW_DEFAULT_LENGTH_M = 20; +export const DRAW_DEFAULT_SLOPE_PCT = 9; + +export type DrawConstraint = "free" | "length" | "slope" | "both"; +export type DrawMode = "idle" | "draw" | "branch" | "mark"; + +export interface DrawPreview { + point: Vertex; + lengthM: number; + slopePct: number | null; + /** 붙음 · 자리 없음 안내(있으면 점을 못 찍음 = `blocked`). */ + note: string | null; + blocked: boolean; + snapped: boolean; +} + +export interface PlanDrawParams { + base: () => { start: Vertex; end: Vertex }; + network: () => PlanNetwork; + heightAt: () => (x: number, y: number) => number; + commit: (next: PlanNetwork) => void; + /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ + onMode: () => void; +} + +export interface PlanDraw { + layer: PlanMapLayer; + /** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */ + panel: HTMLElement; + mode: () => DrawMode; + setMode: (mode: DrawMode) => void; + setEditable: (editable: boolean) => void; + /** 키 — 받았으면 참(Enter · Esc · Backspace). */ + key: (event: KeyboardEvent) => boolean; + /** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */ + sync: () => void; + /** 검증 창구 — 지금 미리보기 · 제약. */ + preview: () => DrawPreview | null; + constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; + setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; +} + +const fmt = (value: number): string => value.toFixed(1); +const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; +const near = (a: [number, number], b: [number, number], px: number): boolean => + Math.hypot(a[0] - b[0], a[1] - b[1]) <= px; + +export function createPlanDraw(params: PlanDrawParams): PlanDraw { + let mode: DrawMode = "idle"; + let editable = false; + let active: number | null = null; // 그리는 선 차례 + let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 + let preview: DrawPreview | null = null; + let candidates: Vertex[] = []; + let kind: DrawConstraint = "free"; + let lengthM = DRAW_DEFAULT_LENGTH_M; + let slopePct = DRAW_DEFAULT_SLOPE_PCT; + + // ── 좌측 칸 ───────────────────────────────────────────── + const mainButton = createButton({ + label: "본선 긋기", + variant: "filled", + size: 28, + onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"), + }); + const branchButton = createButton({ + label: "갈래", + variant: "ghost", + size: 28, + onClick: () => setMode(mode === "branch" ? "idle" : "branch"), + }); + const markButton = createButton({ + label: "구역 마크", + variant: "ghost", + size: 28, + onClick: () => setMode(mode === "mark" ? "idle" : "mark"), + }); + const modeRow = document.createElement("div"); + modeRow.className = "d03-plan__row"; + modeRow.append(mainButton, branchButton, markButton); + const kindSegment = createSegment({ + options: [ + { value: "free", label: "자유" }, + { value: "length", label: "길이 고정" }, + { value: "slope", label: "기울기 고정" }, + { value: "both", label: "둘 다" }, + ], + value: kind, + size: 24, + ariaLabel: "긋기 제약", + onChange: (value) => { + kind = value as DrawConstraint; + fieldsState(); + }, + }); + kindSegment.root.dataset.role = "draw-constraint"; + const lengthField = createInputField({ + label: "길이", + type: "number", + value: String(lengthM), + min: 1, + unit: "m", + size: 24, + onInput: (value) => { + const next = Number(value); + if (next > 0) lengthM = next; + lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); + }, + }); + lengthField.input.dataset.role = "draw-length"; + const slopeField = createInputField({ + label: "기울기(+ 오르막)", + type: "number", + value: String(slopePct), + unit: "%", + size: 24, + onInput: (value) => { + const next = Number(value); + if (value.trim() !== "" && Number.isFinite(next)) slopePct = next; + slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자"); + }, + }); + slopeField.input.dataset.role = "draw-slope"; + const fieldRow = document.createElement("div"); + fieldRow.className = "d03-plan__row"; + fieldRow.append(lengthField.root, slopeField.root); + const hint = createText("", "muted"); + hint.classList.add("d03-plan__hint"); + const panel = document.createElement("div"); + panel.className = "d03-plan__draw"; + panel.append(modeRow, kindSegment.root, fieldRow, hint); + + function fieldsState(): void { + lengthField.input.disabled = kind === "free" || kind === "slope"; + slopeField.input.disabled = kind === "free" || kind === "length"; + } + + function panelState(): void { + const network = params.network(); + const done = reachedEnd(network, params.base().end); + mainButton.querySelector(".ui-btn__label")!.textContent = + mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기"; + mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0)); + branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0; + markButton.disabled = branchButton.disabled; + branchButton.setAttribute("aria-pressed", String(mode === "branch")); + markButton.setAttribute("aria-pressed", String(mode === "mark")); + panel.dataset.mode = mode; + hint.textContent = !editable + ? "보기만 — 산림청 계정에서 긋습니다." + : mode === "draw" + ? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" + : mode === "branch" + ? "갈래를 시작할 선 위 점을 누르세요." + : mode === "mark" + ? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움" + : done + ? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요." + : "[본선 긋기]로 시점에서 점을 찍어 갑니다."; + } + fieldsState(); + + // ── 셈 ────────────────────────────────────────────────── + const lastOf = (line: number): Vertex | null => { + const vertices = params.network().lines[line]?.vertices ?? []; + return vertices[vertices.length - 1] ?? null; + }; + + /** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */ + function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { + if (active === null) return null; + const from = lastOf(active); + if (!from) return null; + const z = params.heightAt(); + const cursor = map.toMetric(px, py); + const end = params.base().end; + candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; + let point: Vertex | null = cursor; + let note: string | null = null; + let snapped = false; + if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) { + [point, note, snapped] = [end, "종점에 붙음", true]; + } else if (kind === "length") { + point = fixedLengthPoint(from, cursor, lengthM); + } else if (kind === "slope") { + point = fixedSlopePoint(from, cursor, slopePct, z); + if (!point) note = `이 방향엔 ${signed(slopePct)} % 자리 없음`; + } else if (kind === "both") { + point = compassPoint(from, cursor, lengthM, slopePct, z); + if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ${signed(slopePct)} % 자리 없음`; + } + const at = point ?? cursor; + const length = segmentLength(from, at); + return { + point: at, + lengthM: length, + slopePct: groundSlopePct(from, at, z), + note, + blocked: !point || length <= 0, + snapped, + }; + } + + /** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */ + function lineAt( + px: number, + py: number, + map: ScreenMap, + ): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null { + const cursor = map.toMetric(px, py); + let best: ReturnType = null; + let bestPx = LINE_HIT_PX; + params.network().lines.forEach((line, index) => { + if (line.vertices.length < 2) return; + const vertex = line.vertices.findIndex((v) => near(map.toScreen(v), [px, py], SNAP_PX)); + const atM = + vertex >= 0 + ? lineLength(line.vertices.slice(0, vertex + 1)) + : distanceAlong(line.vertices, cursor); + const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM); + const [sx, sy] = map.toScreen(point); + const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py); + if (gap > bestPx || (best && gap === bestPx)) return; + // 누가가 든 구간(꺾임점 끼울 자리) + let segment = 0; + for (let walked = 0; segment < line.vertices.length - 2; segment += 1) { + walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]); + if (walked >= atM) break; + } + best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment }; + bestPx = gap; + }); + return best; + } + + // ── 모드 ──────────────────────────────────────────────── + function stop(): void { + // 점 하나뿐인 갈래는 버림(본선 시점은 남김) + const network = params.network(); + if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2) + params.commit(dropLastLine(network)); + active = null; + strokeStart = null; + preview = null; + candidates = []; + } + + function setMode(next: DrawMode): void { + if (mode === "draw") stop(); + mode = editable ? next : "idle"; + if (mode === "draw") { + const network = params.network(); + if (reachedEnd(network, params.base().end)) { + showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info"); + mode = "idle"; + } else { + active = 0; + strokeStart = network; + } + } + panelState(); + params.onMode(); + } + + function addPoint(): boolean { + if (active === null || !preview) return false; + if (preview.blocked) { + showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); + return false; + } + const line = active; + const ended = preview.snapped; + params.commit(appendVertex(params.network(), line, preview.point)); + if (ended) { + showToast("본선이 종점에 닿았습니다.", "success"); + setMode("idle"); + } + return true; + } + + const layer: PlanMapLayer = { + draw(context, map) { + const colors = planColors(); + const { start, end } = params.base(); + const network = params.network(); + context.lineCap = "round"; + context.lineJoin = "round"; + network.lines.forEach((line, index) => { + if (line.vertices.length < 2) return; + context.strokeStyle = index === active ? colors.active : colors.line; + context.lineWidth = index === 0 ? 3 : 2.5; + context.beginPath(); + line.vertices.forEach((v, i) => { + const [sx, sy] = map.toScreen(v); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + drawStationTicks( + context, + line.vertices.map(([x, y]) => ({ x, y })), + { + intervalM: PLAN_STATION_INTERVAL_M, + pxPerMeter: map.pxPerMeter, + toScreen: (x, y) => map.toScreen([x, y]), + uprightRad: map.uprightRad, + chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), + }, + ); + segmentLabels(context, map, line.vertices); + }); + // 꺾임점 + context.fillStyle = "#ffffff"; + for (const line of network.lines) + for (const v of line.vertices.slice(1)) { + const [sx, sy] = map.toScreen(v); + context.beginPath(); + context.arc(sx, sy, 3, 0, Math.PI * 2); + context.fill(); + } + drawPieces(context, map, network); + drawPreview(context, map, colors); + // 시점(초록) · 종점(빨강) + for (const [point, fill] of [ + [start, "#16a34a"], + [end, "#dc2626"], + ] as const) { + const [sx, sy] = map.toScreen(point); + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.fillStyle = fill; + context.fill(); + context.lineWidth = 2; + context.strokeStyle = "#ffffff"; + context.stroke(); + } + }, + press(px, py, map, button) { + if (!editable) return false; + if (mode === "draw" && button === 0) { + preview = previewAt(px, py, map); + return addPoint(); + } + if (mode === "branch" && button === 0) { + const hit = lineAt(px, py, map); + if (!hit) return false; + let network = params.network(); + let vertex = hit.vertex; + if (vertex === null) { + network = insertVertex(network, hit.line, hit.segment, hit.point); + vertex = hit.segment + 1; + } + strokeStart = params.network(); + params.commit(addBranch(network, hit.line, vertex)); + mode = "draw"; + active = params.network().lines.length - 1; + panelState(); + params.onMode(); + return true; + } + if (mode === "mark") { + const hit = lineAt(px, py, map); + if (!hit) return false; + const network = params.network(); + const id = network.lines[hit.line].id; + const next = + button === 2 + ? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter) + : splitPiece(network, id, hit.atM); + if (next !== network) params.commit(next); + return true; + } + return false; + }, + hover(px, py, map) { + preview = mode === "draw" ? previewAt(px, py, map) : null; + }, + leave() { + preview = null; + }, + }; + + function segmentLabels( + context: CanvasRenderingContext2D, + map: ScreenMap, + vertices: Vertex[], + ): void { + const z = params.heightAt(); + for (let i = 1; i < vertices.length; i += 1) { + const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])]; + if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue; + const slope = groundSlopePct(vertices[i - 1], vertices[i], z); + const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`; + label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px"); + } + } + + function drawPieces( + context: CanvasRenderingContext2D, + map: ScreenMap, + network: PlanNetwork, + ): void { + const colors = planColors(); + network.pieces.forEach((piece) => { + const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; + if (!vertices || vertices.length < 2) return; + if (piece.from_m > 0) { + const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m)); + context.beginPath(); + context.moveTo(sx, sy - 8); + context.lineTo(sx + 6, sy); + context.lineTo(sx, sy + 8); + context.lineTo(sx - 6, sy); + context.closePath(); + context.fillStyle = colors.active; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = "#ffffff"; + context.stroke(); + } + const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); + label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); + }); + } + + function drawPreview( + context: CanvasRenderingContext2D, + map: ScreenMap, + colors: ReturnType, + ): void { + if (active === null || !preview) return; + const from = lastOf(active); + if (!from) return; + const [ax, ay] = map.toScreen(from); + const [bx, by] = map.toScreen(preview.point); + if (kind === "both") { + context.strokeStyle = colors.active; + context.lineWidth = 1; + context.setLineDash([3, 4]); + context.beginPath(); + context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2); + context.stroke(); + context.fillStyle = colors.active; + for (const c of candidates) { + const [cx, cy] = map.toScreen(c); + context.beginPath(); + context.arc(cx, cy, 3, 0, Math.PI * 2); + context.fill(); + } + } + context.strokeStyle = preview.blocked ? colors.warn : colors.active; + context.lineWidth = 2; + context.setLineDash([6, 4]); + context.beginPath(); + context.moveTo(ax, ay); + context.lineTo(bx, by); + context.stroke(); + context.setLineDash([]); + context.beginPath(); + context.arc(bx, by, 5, 0, Math.PI * 2); + context.stroke(); + const value = + `${fmt(preview.lengthM)} m · ` + + (preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`); + label( + context, + map, + bx + 14, + by - 14, + preview.note ? `${value} · ${preview.note}` : value, + "12px", + "left", + ); + } + + function label( + context: CanvasRenderingContext2D, + map: ScreenMap, + x: number, + y: number, + text: string, + font: string, + align: CanvasTextAlign = "center", + ): void { + map.upright(context, x, y, () => { + context.font = `${font} system-ui, sans-serif`; + context.textAlign = align; + context.textBaseline = "middle"; + context.lineWidth = 3; + context.strokeStyle = planColors().halo; + context.strokeText(text, x, y); + context.fillStyle = "#ffffff"; + context.fillText(text, x, y); + }); + } + + panelState(); + + return { + layer, + panel, + mode: () => mode, + setMode, + setEditable(next) { + editable = next; + if (!editable) setMode("idle"); + panelState(); + }, + key(event) { + if (mode === "draw" && event.key === "Enter") { + setMode("idle"); + return true; + } + if (mode !== "idle" && event.key === "Escape") { + if (mode === "draw" && strokeStart && strokeStart !== params.network()) + params.commit(strokeStart); + active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함 + setMode("idle"); + return true; + } + if (mode === "draw" && active !== null && event.key === "Backspace") { + const network = params.network(); + const step = + active > 0 && network.lines[active].vertices.length < 2 + ? dropLastLine(network) + : dropLastVertex(network, active); + if (typeof step === "string") showToast(step, "info"); + else if (step !== network) params.commit(step); + if (active >= params.network().lines.length) setMode("idle"); + return true; + } + return false; + }, + sync() { + if (active !== null && active >= params.network().lines.length) { + active = null; + mode = "idle"; + } + panelState(); + }, + preview: () => preview, + constraint: () => ({ kind, lengthM, slopePct }), + setConstraint(next, length, slope) { + kind = next; + kindSegment.set(next); + if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)]; + if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)]; + fieldsState(); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts deleted file mode 100644 index 4200793a9..000000000 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* ============================================================================= - * D03_PlanAnalysis_UI_Edit.ts - * D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) · - * R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`) - * 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음. - * - * 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만. - * 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`). - * ========================================================================== */ - -import { - handleAtScreen, - nodeAtScreen, - segmentAtScreen, -} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; -import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; -import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -import { - insertNode, - moveHandle, - moveNode, - removeNode, - type EditStep, - type PlanEdit, -} from "./logic/D03_PlanAnalysis_Edit"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; - -/** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */ -const NODE_HIT_PX = 10; -const HANDLE_HIT_PX = 8; -const SEGMENT_HIT_PX = 10; - -/** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */ -export interface EditTarget { - year: number; - edit: PlanEdit; - line: EditedPolyline; - minRadiusM: number; -} - -export interface PlanEditEvents { - /** 고치기 직전 한 번(되돌리기 쌓기). */ - begin: () => void; - /** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */ - change: (year: number, edit: PlanEdit, final: boolean) => void; - /** 막힌 까닭(하한 · 끝 잠금). */ - blocked: (message: string) => void; -} - -export interface PlanEditLayer extends PlanMapOverlay { - setTargets: (targets: EditTarget[]) => void; - targets: () => EditTarget[]; -} - -type Grab = { year: number; index: number; which: "node" | "start" | "end" }; - -export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer { - let targets: EditTarget[] = []; - let grab: Grab | null = null; - let moved = false; - let stop = ""; - - const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1); - - function apply(target: EditTarget, step: EditStep, final: boolean): void { - if ("blocked" in step) { - stop = step.blocked; - if (final) events.blocked(stop); - return; - } - target.edit = step.edit; - target.line = step.line; - events.change(target.year, step.edit, final); - } - - return { - setTargets(next) { - targets = next; - }, - targets: () => targets, - - draw(context, map) { - context.save(); - context.font = "11px system-ui, sans-serif"; - context.textAlign = "left"; - context.textBaseline = "bottom"; - for (const target of targets) { - for (const curve of target.line.curves) { - for (const end of [curve.start, curve.end]) { - const [x, y] = map.toScreen(end); - context.beginPath(); - context.arc(x, y, 4, 0, Math.PI * 2); - context.fillStyle = "#f59e0b"; - context.fill(); - context.lineWidth = 1.5; - context.strokeStyle = "#ffffff"; - context.stroke(); - } - const [ax, ay] = map.toScreen(curve.apex); - context.lineWidth = 3; - context.strokeStyle = "rgba(0, 0, 0, 0.7)"; - context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5); - context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff"; - context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5); - } - interior(target.edit).forEach((node, i) => { - const [x, y] = map.toScreen(node); - context.fillStyle = "#ffffff"; - context.fillRect(x - 4.5, y - 4.5, 9, 9); - context.lineWidth = 2; - const bad = target.line.nodes[i + 1]?.violations.length; - context.strokeStyle = bad ? "#dc2626" : "#2563eb"; - context.strokeRect(x - 4.5, y - 4.5, 9, 9); - }); - } - context.restore(); - }, - - grab(px, py, map) { - grab = null; - moved = false; - stop = ""; - for (const target of targets) { - const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX); - if (node >= 0) { - grab = { year: target.year, index: node + 1, which: "node" }; - return true; - } - } - for (const target of targets) { - const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX); - if (hit) { - grab = { year: target.year, index: hit.node, which: hit.end }; - return true; - } - } - return false; - }, - - move(point) { - const target = grab && targets.find((entry) => entry.year === grab!.year); - if (!grab || !target) return; - if (!moved) events.begin(); - moved = true; - const step = - grab.which === "node" - ? moveNode(target.edit, grab.index, point, target.minRadiusM) - : moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM); - if (!("blocked" in step)) stop = ""; - apply(target, step, false); - }, - - release() { - const target = grab && targets.find((entry) => entry.year === grab!.year); - grab = null; - if (!moved || !target) return; - moved = false; - events.change(target.year, target.edit, true); - if (stop) events.blocked(stop); - }, - - insert(px: number, py: number, map: ScreenMap) { - for (const target of targets) { - const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX); - if (segment < 0) continue; - events.begin(); - apply( - target, - insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM), - true, - ); - return; - } - }, - - remove(px: number, py: number, map: ScreenMap) { - for (const target of targets) { - const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX); - if (node < 0) continue; - events.begin(); - apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true); - return; - } - }, - }; -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index e3fe536e3..0a84e4bcc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -1,21 +1,19 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Map.ts - * D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 + - * 지점(연도 경계) + 지점 끌기 겹. + * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯 + * (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기). * - * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤 - * `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` · - * 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선 - * `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시). - * 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기. + * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤 + * `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · + * 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠). + * 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향). + * 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김. * ========================================================================== */ +import { createIconButton } from "@ui/ui_template_button"; import { themeColor } from "@ui/ui_template_palette"; +import { createToolbar } from "@ui/ui_template_toolbar"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - drawStationTicks, - type StationLabelBox, -} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { computeMapRect, computeRouteView, @@ -28,71 +26,81 @@ import { type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import { - bindRouteEditNavigation, - stationAtScreen, -} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate"; import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; -import { leadInLine, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import 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). */ +/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */ +export function planColors(): { line: string; active: string; warn: string; halo: string } { + return { + line: routeLineColor(), + active: themeColor("--draw-primary", "#6d28d9"), + warn: themeColor("--draw-danger", "#dc2626"), + halo: "rgba(0, 0, 0, 0.65)", + }; +} + +/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(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; + /** 처음 맞출 점(시점 · 종점 · 그은 선). */ + fit: Vertex[]; } -/** 화면 px ↔ 작업 좌표계 m. */ +/** 겹이 쓰는 좌표 — 그림 px(돌리기 전) ↔ 작업 좌표계 m · 글자 세우기. */ export interface ScreenMap { toScreen: (point: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; + pxPerMeter: number; + /** 글자를 세울 각(라디안 · 안 돌렸으면 0). */ + uprightRad: number; + /** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */ + upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void; } -/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */ -export interface PlanMapOverlay { +/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */ +export interface PlanMapLayer { draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; - grab: (px: number, py: number, map: ScreenMap) => boolean; - move: (point: Vertex) => void; - release: () => void; - insert: (px: number, py: number, map: ScreenMap) => void; - remove: (px: number, py: number, map: ScreenMap) => void; + press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean; + hover?: (px: number, py: number, map: ScreenMap) => void; + leave?: () => void; } export interface PlanMapEvents { - onDrag: (index: number, point: Vertex) => void; - onDragEnd: (index: number) => void; - onStation: (chainage: number) => void; - overlay?: PlanMapOverlay; + onUndo: () => void; + onRedo: () => void; + on3d: () => void; + onMeasure: () => void; +} + +export interface PlanTools { + undo: boolean; + redo: boolean; + view3d: boolean; + /** 거리재기 켜짐(눌림). */ + measure: boolean; } export interface PlanMap { root: HTMLElement; update: (patch: Partial) => void; - /** 지점 · 노선이 들어오게 다시 맞춤. */ + setLayers: (layers: PlanMapLayer[]) => void; + setTools: (tools: Partial) => void; + /** 다시 그림(겹 값이 바뀜). */ + redraw: () => void; + /** 맞출 점이 들어오게 다시 맞춤. */ fit: () => void; - /** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */ - stationScreen: (chainage: number) => [number, number] | null; - /** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */ + /** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */ screenOf: (point: Vertex) => [number, number] | null; + /** 검증 창구 — 지금 돌린 각(도). */ + rotationDeg: () => number; dispose: () => void; } @@ -124,24 +132,43 @@ 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, + const tool = ( + act: string, + icon: Parameters[0]["icon"], + title: string, + onClick?: () => void, + ): HTMLButtonElement => { + const button = createIconButton({ icon, title, onClick }); + button.dataset.act = act; + return button; }; + const undoButton = tool("undo", "undo", "되돌리기(Ctrl+Z)", events.onUndo); + const redoButton = tool("redo", "redo", "다시하기(Ctrl+Y)", events.onRedo); + const viewButton = tool("view3d", "cube", "3D 보기", events.on3d); + const measureButton = tool("measure", "ruler", "거리재기", events.onMeasure); + measureButton.setAttribute("aria-pressed", "false"); + const toolbar = createToolbar({ + glass: true, + size: 32, + label: "평면 도구", + items: [ + undoButton, + redoButton, + "divider", + viewButton, + tool("rotate-ccw", "rotateCcw", "왼쪽으로 돌림(15°)"), + tool("rotate-cw", "rotateCw", "오른쪽으로 돌림(15°)"), + "divider", + measureButton, + ], + }); + toolbar.classList.add("d03-plan__tools"); + root.append(canvas, toolbar); + const context = canvas.getContext("2d")!; + let scene: PlanMapScene = { meta: null, image: null, contours: null, fit: [] }; + let layers: PlanMapLayer[] = []; let meta: VWorldMeta | null = null; let fitted = false; - let labels: StationLabelBox[] = []; let view: ViewState = { width: 0, height: 0, @@ -150,20 +177,37 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { offsetY: 0, mapRect: computeMapRect(null, 0, 0), }; + const rotation = createMapRotation({ + overlay: toolbar, + size: () => view, + onChange: () => draw(), + }); - const toScreen = (point: Vertex): [number, number] => - createMetricProjector(meta!, view).toScreen(point[0], point[1]); - const screenMap: ScreenMap = { - toScreen, - toMetric: (px, py) => screenToMetric(meta!, view, px, py), + const screenMap = (): ScreenMap => { + const projector = createMetricProjector(meta!, view); + return { + toScreen: (point) => projector.toScreen(point[0], point[1]), + toMetric: (px, py) => screenToMetric(meta!, view, px, py), + pxPerMeter: projector.pxPerMeter, + uprightRad: rotation.uprightRad(), + upright: (ctx, x, y, paint) => { + const angle = rotation.uprightRad(); + if (!angle) return paint(); + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + ctx.translate(-x, -y); + paint(); + ctx.restore(); + }, + }; }; 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); + meta = scene.meta ?? fakeMeta(scene.fit); + if (!meta || !view.width || !scene.fit.length) return; + const xs = scene.fit.map(([x]) => x); + const ys = scene.fit.map(([, y]) => y); const box = { x_min: Math.min(...xs), x_max: Math.max(...xs), @@ -178,20 +222,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { 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); + context.save(); + context.translate(view.width / 2, view.height / 2); + context.rotate(rotation.radians()); + context.translate(-view.width / 2, -view.height / 2); if (scene.image?.complete && scene.image.naturalWidth && scene.meta) { const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); @@ -211,83 +248,24 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { 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)"); + drawPreparedLabels( + context, + scene.contours, + view, + "#fde68a", + 25, + rotation.uprightRad(), + "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); + const map = screenMap(); + for (const layer of layers) { + context.save(); + layer.draw(context, map); + context.restore(); } 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("--draw-primary", "#6d28d9"); - context.stroke(); - } - } - if (scene.editable) events.overlay?.draw(context, screenMap); - scene.nodes.forEach((node, i) => { - const [sx, sy] = toScreen(node); - const last = i === scene.nodes.length - 1; - 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 { @@ -298,98 +276,45 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { draw(); } + /** 누른 화면 자리 → 그림 px(돌리기 전). */ 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; + return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); }; - // 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김 - // 집은 자리와 지점 가운데 차(px)를 지킴 · 손 떨림(CLICK_SLOP_PX) 안이면 끌기 아님(누름) - let dragging: { index: number; id: number; dx: number; dy: number; moved: boolean } | 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; + if (!meta || event.button !== 0) return; const [px, py] = at(event); - // 지점 먼저 · 다음 고침 겹(index -1) - const index = nodeAt(px, py); - if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return; - event.stopImmediatePropagation(); - const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]); - dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false }; - canvas.setPointerCapture(event.pointerId); - canvas.style.cursor = "grabbing"; + const map = screenMap(); + if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw(); }); 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; - } - if (!press) return; - const far = Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX; - if (!dragging.moved && !far) return; - dragging.moved = true; + if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 const [px, py] = at(event); - const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy); - if (dragging.index < 0) events.overlay?.move(point); - else events.onDrag(dragging.index, point); + const map = screenMap(); + for (const layer of layers) layer.hover?.(px, py, map); + draw(); + }); + canvas.addEventListener("pointerleave", () => { + for (const layer of layers) layer.leave?.(); + draw(); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + if (!meta) return; + const [px, py] = at(event); + const map = screenMap(); + if (layers.some((layer) => layer.press?.(px, py, map, 2))) draw(); }); - const endDrag = (event: PointerEvent): void => { - if (!dragging || event.pointerId !== dragging.id) return; - const { index, moved } = dragging; - dragging = null; - canvas.style.cursor = ""; - if (index < 0) events.overlay?.release(); - else if (moved) events.onDragEnd(index); - }; - canvas.addEventListener("pointerup", endDrag); - canvas.addEventListener("pointercancel", endDrag); bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => (view = next), getMeta: () => meta, - leftPan: true, + unrotateDelta: rotation.unrotateDelta, + rotateWheel: rotation.wheel, 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); - }); - - canvas.addEventListener("dblclick", (event) => { - if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap); - }); - canvas.addEventListener("contextmenu", (event) => { - if (!meta || !scene.editable) return; - event.preventDefault(); - events.overlay?.remove(...at(event), screenMap); - }); const observer = new ResizeObserver(() => resize()); observer.observe(root); @@ -402,12 +327,28 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (metaChanged || !fitted) refit(); draw(); }, + setLayers(next) { + layers = next; + draw(); + }, + setTools(tools) { + if (tools.undo !== undefined) undoButton.disabled = !tools.undo; + if (tools.redo !== undefined) redoButton.disabled = !tools.redo; + if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d; + if (tools.measure !== undefined) + measureButton.setAttribute("aria-pressed", String(tools.measure)); + }, + redraw: draw, fit() { refit(); draw(); }, - stationScreen, - screenOf: (point) => (meta ? toScreen(point) : null), + screenOf: (point) => { + if (!meta) return null; + const [x, y] = screenMap().toScreen(point); + return rotation.rerotate(x, y); + }, + rotationDeg: () => (rotation.radians() * 180) / Math.PI, dispose: () => observer.disconnect(), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts new file mode 100644 index 000000000..fb4dbe80a --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Measure.ts + * D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m). + * 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`). + * ========================================================================== */ + +import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; + +export interface PlanMeasure { + layer: PlanMapLayer; + /** 지움(Esc · 끔). */ + clear: () => void; + /** 검증 창구 — 잰 값(두 점이 다 있으면). */ + result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null; +} + +const fmt = (value: number, digits = 1): string => value.toFixed(digits); +const signed = (value: number, digits = 1): string => + `${value >= 0 ? "+" : ""}${fmt(value, digits)}`; + +export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure { + let from: Vertex | null = null; + let to: Vertex | null = null; + let fixed = false; + + const measure = (a: Vertex, b: Vertex) => { + const z = heightAt(); + const rise = z(b[0], b[1]) - z(a[0], a[1]); + return { + lengthM: segmentLength(a, b), + slopePct: groundSlopePct(a, b, z), + riseM: Number.isFinite(rise) ? rise : null, + }; + }; + + const layer: PlanMapLayer = { + draw(context, map: ScreenMap) { + if (!from) return; + const colors = planColors(); + const [ax, ay] = map.toScreen(from); + context.lineWidth = 2; + context.strokeStyle = colors.active; + context.fillStyle = colors.active; + context.setLineDash([6, 4]); + if (to) { + const [bx, by] = map.toScreen(to); + context.beginPath(); + context.moveTo(ax, ay); + context.lineTo(bx, by); + context.stroke(); + context.setLineDash([]); + for (const [x, y] of [ + [ax, ay], + [bx, by], + ]) { + context.beginPath(); + context.arc(x, y, 4, 0, Math.PI * 2); + context.fill(); + } + const { lengthM, slopePct, riseM } = measure(from, to); + const text = + `${fmt(lengthM)} m · ` + + (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`); + const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14]; + map.upright(context, mx, my, () => { + context.font = "12px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.lineWidth = 3; + context.strokeStyle = colors.halo; + context.strokeText(text, mx, my); + context.fillStyle = "#ffffff"; + context.fillText(text, mx, my); + }); + } else { + context.beginPath(); + context.arc(ax, ay, 4, 0, Math.PI * 2); + context.fill(); + } + }, + press(px, py, map, button) { + if (button !== 0) return false; + const point = map.toMetric(px, py); + if (!from || fixed) { + [from, to, fixed] = [point, null, false]; + } else { + [to, fixed] = [point, true]; + } + return true; + }, + hover(px, py, map) { + if (from && !fixed) to = map.toMetric(px, py); + }, + }; + + return { + layer, + clear: () => { + [from, to, fixed] = [null, null, false]; + }, + result: () => (from && to && fixed ? measure(from, to) : null), + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 185ed67b9..e30c8fc41 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -1,22 +1,23 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Page.ts - * D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`). + * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`). * - * 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 · - * 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D]. - * 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」. + * 메인 = 평면(위성 + 도엽 등고선 + 그은 선 · 측점 · 구역 마크) + 도구 여섯. + * 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) · + * 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화]. * - * 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] = - * PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은 - * 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 · - * 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만. - * 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음. + * 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] = + * PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT. + * 되돌리기 · 다시하기 = 이 화면 스택(걸음 = `commit` 한 번). 화면 전체 잠금 = [저장] · [초기화]만. + * 공유 회사(산림청 아님) = 보기만 — 긋기 · 받기 · [저장] 없음. * ========================================================================== */ import "./D03_PlanAnalysis_UI_Style.css"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; -import { createCard } from "@ui/ui_template_card"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { editMap } from "@ui/ui_template_kakao_map"; +import { askConfirm, openModal } from "@ui/ui_template_modal"; import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { @@ -29,516 +30,363 @@ import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_as 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 { fetchVWorldMeta, getVWorldMapUrl } 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 { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import { stationLabel } from "../common_util/common_util_station"; -import type { StationGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; import { - fetchPlanAnalysis, - fetchPlanPoints, + fetchPlanBase, + fetchPlanNetwork, fetchPlanProgress, - fetchYearSurfaces, - savePlanAnalysis, - startPlanJob, - type PlanAnalysisView, - type SavedEdit, + fetchPlanSurface, + fetchTerrainExtent, + savePlanNetwork, + startTerrain, type PlanJob, - type YearSurface, + type PlanNetworkView, + type PlanSurface, + type TerrainExtent, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; -import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross"; -import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit"; +import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; +import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; +import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw"; import { - chainRows, - joinedLine, - nodesInReach, - nodesOf, - PLAN_MODES, - pointsOf, - rowAt, - sameNodes, - stationChainages, - withEdits, - type ChainRow, - type RouteEdit, + emptyNetwork, + fromServer, + lineName, + marksOf, + normalizePieces, + setPieceYear, + type PlanNetwork, type Vertex, - type YearAnalysis, } from "./logic/D03_PlanAnalysis_Plan"; -import { - buildEdit, - editFromRoute, - moveNode, - sameEdit, - type PlanEdit, -} from "./logic/D03_PlanAnalysis_Edit"; -const MODE_LABEL: Record = { - gradeline: "영선", - earthwork: "토공 최소", - gentle: "기울기 최소", - weighted: "비중 계산", -}; const POLL_MS = 1000; -/** 등고선을 받아 올 지점 둘레(m). */ +/** 등고선을 받아 올 그은 선 둘레(m). */ const CONTOUR_PAD_M = 600; - /** 되돌리기 쌓는 수. */ -const UNDO_MAX = 50; +const UNDO_MAX = 100; +/** 조각 연도 고를 수 — 사업연도 시작부터. */ +const YEAR_SPAN = 10; +/** 캐시 꼴 판 — 옛 꼴(지점 · 갈래 고름)은 안 읽음. */ +const DRAFT_VERSION = 2; interface Draft { - nodes: Vertex[] | null; - choices: Record; - /** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */ - edits?: Record; + version: number; + network: PlanNetwork; } -type Snapshot = { edits: Record; choices: Record }; - const sleep = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); const errorText = (error: unknown): string => error instanceof Error ? error.message : String(error); +const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - const editLayer = createPlanEditLayer({ - begin: remember, - change: onEdit, - blocked: (message) => showToast(message, "info"), + let editable = false; + let start: Vertex = [0, 0]; + let end: Vertex = [0, 0]; + let epsg = 0; + let yearStart = new Date().getFullYear(); + let network: PlanNetwork = emptyNetwork(start); + let savedJson = ""; + let undoStack: PlanNetwork[] = []; + let redoStack: PlanNetwork[] = []; + let surface: PlanSurface | null = null; + let have: [number, number, number, number] | null = null; + let busy = false; + let failed: string | null = null; + let measuring = false; + let lastSaveMs: number | null = null; + + const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN); + const draw = createPlanDraw({ + base: () => ({ start, end }), + network: () => network, + heightAt: () => z, + commit, + onMode: () => { + if (draw.mode() !== "idle") setMeasure(false); + refresh(); + }, + }); + const measure = createPlanMeasure(() => z); + const map = createPlanMap({ + onUndo: undo, + onRedo: redo, + on3d: open3d, + onMeasure: () => setMeasure(!measuring), }); - const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer }); - const cross = createPlanCross(); const main = document.createElement("div"); main.className = "d03-plan"; - main.append(map.root, cross.root); + main.append(map.root); const terrainText = createText("", "muted"); terrainText.classList.add("d03-plan__hint"); const bar = createProgressBar(); bar.root.hidden = true; - const more = createButton({ - label: "범위 밖 — 더 받기", + const extentButton = createButton({ + label: "지형 범위 보기", variant: "filled", - onClick: () => void download(), + size: 28, + onClick: () => void openExtent([]), }); - more.hidden = true; - const yearList = document.createElement("div"); - yearList.className = "d03-plan__years"; - const analyzeButton = createButton({ - label: "분석", - variant: "ghost", - onClick: () => void analyze(), + const moreButton = createButton({ + label: "범위 밖 더 받기", + variant: "filled", + size: 28, + onClick: () => void openExtent(allVertices()), }); + const terrainCard = createPanelCard("지형", false); + terrainCard.append(terrainText, bar.root, extentButton, moreButton); + const drawCard = createPanelCard("그리기", false); + drawCard.append(draw.panel); + const pieceList = document.createElement("div"); + pieceList.className = "d03-plan__pieces"; + const pieceCard = createPanelCard("구역 · 연도", false); + pieceCard.append(pieceList); const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); - const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() }); - const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() }); - undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)"; + const resetButton = createButton({ + label: "초기화", + variant: "ghost", + onClick: () => void reset(), + }); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; - actions.append(analyzeButton, saveButton, viewButton, undoButton); - const terrainCard = createPanelCard("지형", false); - terrainCard.append(terrainText, bar.root, more); - const routeCard = createPanelCard("연도별 노선", false); - routeCard.append(yearList); + actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(terrainCard, routeCard, actions); + panel.append(terrainCard, drawCard, pieceCard, actions); attachCollapsible(panel); mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel }); root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); - let years: number[] = []; - let savedNodes: Vertex[] = []; - let nodes: Vertex[] = []; - let choices: Record = {}; - let startM = 0; - let targetM: number | null = null; // 등록 목표 끝 누가 - let analysis: PlanAnalysisView | null = null; - let surfaces = new Map(); - let rows: ChainRow[] = []; - let results: YearAnalysis[] = []; - let selected: number | null = null; - let editable = false; - let busy: "terrain" | "analyze" | null = null; - let lastCross: StationGround | null = null; - // 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기 - let edits: Record = {}; - let serverEdits: Record = {}; - let draftEdits: Record | undefined; - let history: Snapshot[] = []; - let lastSaveMs: number | 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 allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); + const dirty = (): boolean => JSON.stringify(network) !== savedJson; const keepDraft = (): void => - writeState( - "d03-plan", - { nodes: moved() ? nodes : null, choices, edits } satisfies Draft, - projectId, - ); + dirty() + ? writeState("d03-plan", { version: DRAFT_VERSION, network } satisfies Draft, projectId) + : clearState("d03-plan", projectId); - /** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */ - function remember(): void { - history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })]; + /** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시. */ + function commit(next: PlanNetwork): void { + undoStack = [...undoStack.slice(1 - UNDO_MAX), network]; + redoStack = []; + network = normalizePieces(next, yearStart); + keepDraft(); + refresh(); } function undo(): void { - const last = history.pop(); + const last = undoStack.pop(); if (!last) return; - ({ edits, choices } = last); + redoStack.push(network); + network = last; keepDraft(); refresh(); - repick(); } - /** 누른 측점 횡단을 지금 노선으로 다시. */ - function repick(): void { - if (selected !== null && rowAt(rows, selected)) onStation(selected); - } - - function onEdit(year: number, edit: PlanEdit, final: boolean): void { - edits = { ...edits, [year]: edit }; - refresh(); - if (!final) return; + function redo(): void { + const next = redoStack.pop(); + if (!next) return; + undoStack.push(network); + network = next; keepDraft(); - repick(); + refresh(); } - /** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */ - const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => { - const own = edits[result.year]; - return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint - ? own - : null; - }; + function setMeasure(on: boolean): void { + if (on === measuring) return; + measuring = on; + measure.clear(); + if (on) draw.setMode("idle"); + map.setLayers(measuring ? [draw.layer, measure.layer] : [draw.layer]); + refresh(); + } - /** 연도 목록에서 뺌. */ - const without = (from: Record, drop: number[]): Record => - Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year)))); + /** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */ + const outside = (): boolean => + !!have && + allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]); - /** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */ function refresh(): void { - const raw: YearAnalysis[] = years.map( - (year, i) => - analysis?.years.find((entry) => entry.year === year)?.analysis ?? { - year, - start: nodes[i], - end: nodes[i + 1], - routes: {}, - }, - ); - // 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름 - const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode])); - const picks = { ...saved, ...choices }; - // 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움 - const targets: EditTarget[] = []; - const routed: Record = {}; - chainRows(raw, picks, startM).forEach((row, i) => { - const result = raw[i]; - const route = row.mode ? result.routes[row.mode] : undefined; - if (!row.mode || !route?.nodes || !result.fingerprint) return; - const minRadiusM = result.criteria?.min_radius_m ?? 0; - const own = editOf(result, row.mode); - const built = buildEdit( - own ?? editFromRoute(route, row.mode, result.fingerprint), - minRadiusM, + draw.sync(); + map.setTools({ + undo: editable && undoStack.length > 0, + redo: editable && redoStack.length > 0, + view3d: !!surface && network.lines.some((line) => line.vertices.length > 1), + measure: measuring, + }); + map.redraw(); + renderPieces(); + extentButton.hidden = !editable || busy || !!surface; + moreButton.hidden = !editable || busy || !surface || !outside(); + if (!busy) + terrainText.textContent = failed + ? `지형 받기 실패 — ${failed}` + : !surface + ? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)." + : outside() + ? "그은 선이 받은 지형 밖으로 나갔습니다 — 더 받아야 기울기를 셉니다." + : "받은 지형 안"; + if (failed && editable && !busy) extentButton.hidden = false; + extentButton.querySelector(".ui-btn__label")!.textContent = failed + ? "다시 받기" + : "지형 범위 보기"; + saveButton.hidden = resetButton.hidden = !editable; + saveButton.disabled = busy || !dirty(); + } + + function renderPieces(): void { + const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i)); + network.pieces.forEach((piece) => years.add(piece.year)); + const options = [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); + const rows: HTMLElement[] = []; + network.lines.forEach((line, index) => { + const pieces = network.pieces.filter((piece) => piece.line === line.id); + if (!pieces.length) return; + const head = createText( + `${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`, + "muted", ); - targets.push({ year: result.year, ...built, minRadiusM }); - if (own) - routed[result.year] = { - mode: row.mode, - planned: built.line.vertices, - metrics: sameEdit(own, serverEdits[result.year]) - ? serverEdits[result.year].metrics - : null, - }; + head.classList.add("d03-plan__piece-head"); + rows.push(head); + for (const piece of pieces) { + const row = document.createElement("div"); + row.className = "d03-plan__piece"; + row.dataset.piece = piece.id; + const span = createText( + `${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`, + "muted", + ); + const pick = createSelectField({ + options, + value: String(piece.year), + size: 24, + ariaLabel: "연도", + disabled: !editable, + onChange: (value) => + commit(setPieceYear(network, network.pieces.indexOf(piece), Number(value))), + }); + row.append(span, pick.root); + rows.push(row); + } }); - editLayer.setTargets(editable ? targets : []); - results = withEdits(raw, routed); - rows = chainRows(results, picks, startM); - const line = joinedLine(results, rows); - const others: Vertex[][] = results.flatMap((result, i) => { - const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode) - .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)); - undoButton.hidden = !editable; - undoButton.disabled = !history.length; + if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); + pieceList.replaceChildren(...rows); } - 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); - if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year)); - for (const mode of PLAN_MODES) { - const route = result.routes[mode]; - if (!route) continue; - const metrics = route.metrics; - const values = createText( - !metrics - ? "노선 없음" - : metrics.max_grade_pct === undefined - ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` - : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`, - "muted", - ); - const card = createCard({ - title: MODE_LABEL[mode], - body: [values], - pad: 12, - selected: row.mode === mode, - onSelect: () => { - if (row.mode === mode) return; - remember(); - choices = { ...choices, [result.year]: mode }; - keepDraft(); - refresh(); - }, - }); - card.classList.add("d03-plan__mode"); - card.classList.toggle("is-picked", row.mode === mode); - card.dataset.mode = mode; - card.disabled = !!route.error || !metrics; - card.title = route.error ?? ""; - box.append(card); - } - return box; - }), - ...targetLine(), - ); - } - - /** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */ - function editLine(year: number): HTMLElement { - const line = document.createElement("div"); - line.className = "d03-plan__year-head"; - line.textContent = sameEdit(edits[year], serverEdits[year]) - ? "꺾임점 고침(저장됨) " - : "꺾임점 고침 — [저장] 전 "; - const reset = createButton({ - label: "원래 노선", + /** 받기 전 범위를 카카오 지도에 보임 → [이 범위 받기]. */ + async function openExtent(extra: Vertex[]): Promise { + let extent: TerrainExtent; + try { + extent = await fetchTerrainExtent(projectId, null, extra); + } catch (error) { + return void showToast(`받을 범위를 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + const info = createText("", "muted"); + const kakao = editMap({ + onMove: () => undefined, + hint: "받을 범위(빨강) · 받은 범위(초록) · 도엽(옅게)", + }); + const show = (view: TerrainExtent): void => { + const ring = (points: Array<[number, number]>): Array<[number, number]> => + [...points, points[0]].map(([lon, lat]) => [lat, lon]); + const lines = [ + ring(view.box), + ...(view.have ? [ring(view.have)] : []), + ...view.sheets.map((s) => ring(s.ring)), + ]; + const colors = [ + "#E60046", + ...(view.have ? ["#16a34a"] : []), + ...view.sheets.map(() => "#94a3b8"), + ]; + kakao.set([], lines, colors); + const newSheets = view.sheets.filter((sheet) => !sheet.stored).length; + info.textContent = + `여유 ${view.margin_m.toFixed(0)} m · 도엽 ${view.sheets.length}매(새로 ${newSheets}) · ` + + `지형 칸 약 ${(view.cells / 1e6).toFixed(1)}백만` + + (view.covered ? " · 이미 받은 범위 안" : ""); + }; + const margin = createInputField({ + label: "여유", + type: "number", + value: extent.margin_m.toFixed(0), + unit: "m", + size: 24, + }); + const applyMargin = createButton({ + label: "다시 보기", variant: "ghost", - size: "sm", + size: 24, + onClick: () => + void fetchTerrainExtent(projectId, Number(margin.input.value) || null, extra) + .then((view) => show((extent = view))) + .catch((error) => showToast(errorText(error), "error")), + }); + const row = document.createElement("div"); + row.className = "d03-plan__row"; + row.append(margin.root, applyMargin); + let close = (): void => undefined; + const go = createButton({ + label: "이 범위 받기", + variant: "filled", onClick: () => { - remember(); - edits = without(edits, [year]); - keepDraft(); - refresh(); - repick(); + close(); + void download(extent.margin_m, extra); }, }); - reset.hidden = !editable; - line.append(reset); - return line; + ({ close } = openModal({ + title: "지형 받기 — 범위 미리보기", + closeLabel: "닫기", + width: "960px", + dialogClass: "d03-plan__extent-modal", + actions: [go], + mount: (body) => { + body.classList.add("d03-plan__extent"); + body.append(row, info, kakao.root); + show(extent); + }, + })); } - /** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */ - function targetLine(): HTMLElement[] { - const last = rows[rows.length - 1]; - if (targetM === null || !last || rows.some((row) => !row.mode)) return []; - const diff = last.end_chainage_m - targetM; - const line = document.createElement("div"); - line.className = "d03-plan__year-head d03-plan__target"; - line.textContent = - `끝 ${stationLabel(last.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} · ` + - `목표 ${stationLabel(targetM, PLAN_STATION_INTERVAL_M, 1)} · ` + - `차이 ${diff >= 0 ? "+" : ""}${diff.toFixed(1)} m`; - return [line]; - } - - 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 { - // 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림 - const lost = [years[index - 1], years[index]].filter( - (year) => year !== undefined && edits[year], - ); - if (lost.length) { - edits = without(edits, lost); - history = []; - showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info"); + async function download(marginM: number, extra: Vertex[]): Promise { + try { + await startTerrain(projectId, marginM, extra); + } catch (error) { + // 이미 도는 일(다른 창)이면 그것을 지켜봄 + if (!/이미 돌고/.test(errorText(error))) return void showToast(errorText(error), "error"); } - if (analysis) - analysis = { - ...analysis, - years: analysis.years.map((entry, i) => - i === index - 1 || i === index ? { ...entry, analysis: null } : entry, - ), - }; - selected = null; - lastCross = null; - cross.clear("지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요."); - keepDraft(); + await follow(); + } + + /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ + async function follow(): Promise { + const job = await watch(); + if (!job || !root.isConnected) return; + failed = job.error; + await Promise.all([loadSurface(), loadBackground()]); 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 }); - lastCross = cross.show( - chainage, - row.year, - result.routes[row.mode].planned ?? [], - Math.max(0, chainage - row.start_chainage_m), - surface.grid, - ); - } - - async function loadBackground(): Promise { - let meta: VWorldMeta | null = null; - try { - meta = await fetchVWorldMeta(projectId, "satellite"); - } catch { - meta = null; // 위성 못 받음 — 가짜 도엽 위에 그림 - } - let image: HTMLImageElement | null = null; - let contours: PreparedLayer | null = null; - if (meta) { - image = new Image(); - image.onload = () => map.update({}); - // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 - image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; - const xs = nodes.map(([x]) => x); - const ys = nodes.map(([, y]) => y); - const bbox = [ - Math.min(...xs) - CONTOUR_PAD_M, - Math.min(...ys) - CONTOUR_PAD_M, - Math.max(...xs) + CONTOUR_PAD_M, - Math.max(...ys) + CONTOUR_PAD_M, - ].map((v) => v.toFixed(0)); - try { - const collection = await fetchCachedSheetLayer( - projectId, - DRAINAGE_SHEET_LAYERS[0], - "route", - bbox.join(","), - ); - contours = prepareLayer(collection, createNormalizer(meta), ["등고수치"]); - } catch { - contours = null; - } - } - map.update({ meta, image, contours }); - } - - async function loadSurfaces(): Promise { - try { - surfaces = await fetchYearSurfaces(projectId); - } catch (error) { - showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); - } - } - - async function loadAnalysis(): Promise { - try { - analysis = await fetchPlanAnalysis(projectId, sentPoints()); - startM = analysis.start_chainage_m; - targetM = analysis.end_chainage_target_m ?? null; - serverEdits = Object.fromEntries( - analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])), - ); - edits = - draftEdits ?? - Object.fromEntries( - Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]), - ); - // 분석이 바뀐 연도(지문 다름)의 고침은 버림 - const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint])); - const stale = Object.keys(edits) - .map(Number) - .filter((year) => prints.get(year) !== edits[year].base_fingerprint); - if (stale.length) { - edits = without(edits, stale); - if (draftEdits) - showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info"); - } - draftEdits = edits; - } catch (error) { - analysis = null; - showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error"); - } - } - - /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ - async function watch(kind: "terrain" | "analyze"): Promise { - busy = kind; + /** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ + async function watch(): Promise { + busy = true; bar.root.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; - const job = (await fetchPlanProgress(projectId))[kind]; + const job = (await fetchPlanProgress(projectId)).terrain; bar.set(job.percent / 100); - terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`; + terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`; if (!job.running) { if (job.error) showToast(job.error, "error"); return job; @@ -546,152 +394,187 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await sleep(POLL_MS); } } catch (error) { - // 진행을 못 읽음(세션 끊김 등) — 서버 일은 그대로 돎 · 다시 열면 이어 지켜봄 showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); return null; } finally { - busy = null; + busy = false; bar.root.hidden = true; refresh(); } } - async function run(kind: "terrain" | "analyze"): Promise { + async function loadSurface(): Promise { try { - await startPlanJob(projectId, kind, sentPoints()); + surface = await fetchPlanSurface(projectId); + const extent = surface ? await fetchTerrainExtent(projectId, null, []) : null; + have = extent?.have_m ?? null; } catch (error) { - // 이미 도는 일(다른 창 · D02 [>])이면 그것을 지켜봄 - if (!/이미 돌고/.test(errorText(error))) { - showToast(errorText(error), "error"); - return null; - } + showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); } - return watch(kind); } - async function download(): Promise { - const job = await run("terrain"); - if (!job || !root.isConnected) return; - await Promise.all([loadSurfaces(), loadBackground()]); - refresh(); + async function loadBackground(): Promise { + const meta = await fetchVWorldMeta(projectId, "satellite").catch(() => null); + if (!meta) return map.update({ meta: null, image: null, contours: null }); + const image = new Image(); + image.onload = () => map.redraw(); + // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 + image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; + const points = [start, end, ...allVertices()]; + const xs = points.map(([x]) => x); + const ys = points.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)); + const contours = await fetchCachedSheetLayer( + projectId, + DRAINAGE_SHEET_LAYERS[0], + "route", + bbox.join(","), + ) + .then((collection) => prepareLayer(collection, createNormalizer(meta), ["등고수치"])) + .catch(() => null); + map.update({ meta, image, contours }); } - async function analyze(): Promise { - const job = await run("analyze"); - if (!job || !root.isConnected) return; - await loadAnalysis(); - refresh(); + /** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */ + function takeServer(view: PlanNetworkView): void { + if (view.year_start) yearStart = view.year_start; + const server = view.network; + network = + server && server.lines.length + ? fromServer(server.lines, server.marks, server.pieces, yearStart) + : emptyNetwork(start); + if (server?.epsg) epsg = server.epsg; + savedJson = JSON.stringify(network); } - async function save(): Promise { - const picked = Object.fromEntries( - rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]), - ); - const sent = Object.fromEntries( - results.flatMap((result, i) => { - const edit = editOf(result, rows[i].mode); - return edit ? [[result.year, edit]] : []; - }), - ); + function serverBody(): Parameters[1] { + return { epsg, lines: network.lines, marks: marksOf(network), pieces: network.pieces }; + } + + /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ + async function save(): Promise { + draw.setMode("idle"); showLoadingOverlay(); try { const began = performance.now(); - await savePlanAnalysis(projectId, picked, sentPoints(), sent); + takeServer(await savePlanNetwork(projectId, serverBody())); lastSaveMs = Math.round(performance.now() - began); clearState("d03-plan", projectId); - draftEdits = undefined; - history = []; - await loadPoints(); - await loadAnalysis(); + undoStack = []; + redoStack = []; refresh(); showToast("예정노선을 저장했습니다.", "success"); + return true; } catch (error) { showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); + return false; + } finally { + hideLoadingOverlay(); + } + } + + /** [초기화] = 시점 · 종점만 남은 빈 선을 정본에. */ + async function reset(): Promise { + const ok = await askConfirm({ + title: "초기화", + message: + "그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.", + okLabel: "초기화", + danger: true, + }); + if (!ok) return; + draw.setMode("idle"); + showLoadingOverlay(); + try { + takeServer(await savePlanNetwork(projectId, { epsg, lines: [], marks: [], pieces: [] })); + clearState("d03-plan", projectId); + undoStack = []; + redoStack = []; + 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); + if (!surface) return; + const lines = network.lines + .map((line, index) => ({ + name: lineName(index), + line: line.vertices, + stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), + })) + .filter((item) => item.line.length > 1); + openPlan3d(projectId, surface, lines); } - async function loadPoints(): Promise { - const view = await fetchPlanPoints(projectId); - const empty = view.years.find((entry) => !entry.start || !entry.end); - if (empty) { - terrainText.textContent = `${empty.year}년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.`; - return false; - } - years = view.years.map((entry) => entry.year); - savedNodes = nodesOf( - view.years.map((entry) => ({ - start: [entry.start!.x, entry.start!.y] as Vertex, - end: [entry.end!.x, entry.end!.y] as Vertex, - })), - ); - const draft = readState("d03-plan", projectId); - nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes; - choices = draft?.choices ?? {}; - draftEdits = draft?.edits; - return true; - } - - // 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정) + // 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정) (window as unknown as { __d03Plan?: unknown }).__d03Plan = { - stationScreen: map.stationScreen, + network: () => network, + base: () => ({ start, end }), screenOf: map.screenOf, - rows: () => rows, - nodes: () => nodes, - pick: onStation, - cross: () => lastCross, - edits: () => edits, - targets: () => editLayer.targets(), - results: () => results, + preview: draw.preview, + constraint: draw.constraint, + setConstraint: draw.setConstraint, + mode: draw.mode, + setMode: draw.setMode, + measure: measure.result, + rotationDeg: map.rotationDeg, + history: () => ({ undo: undoStack.length, redo: redoStack.length }), + dirty, saveMs: () => lastSaveMs, - // 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길) - nudge: (index: number, dx: number, dy: number) => { - const target = editLayer.targets()[0]; - const node = target?.edit.nodes[index]; - if (!node) return; - const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM); - if ("blocked" in step) return showToast(step.blocked, "info"); - remember(); - onEdit(target.year, step.edit, true); - }, + heightAt: z, }; const onKey = (event: KeyboardEvent): void => { if (!root.isConnected) return window.removeEventListener("keydown", onKey); const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select"); - if (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") { + if (typing || !editable) return; + const key = event.key.toLowerCase(); + const ctrl = event.ctrlKey || event.metaKey; + const handled = + ctrl && key === "z" && !event.shiftKey + ? (undo(), true) + : ctrl && (key === "y" || (key === "z" && event.shiftKey)) + ? (redo(), true) + : measuring && event.key === "Escape" + ? (setMeasure(false), true) + : !ctrl && draw.key(event); + if (handled) { event.preventDefault(); - undo(); + map.redraw(); } }; window.addEventListener("keydown", onKey); + map.setLayers([draw.layer]); try { editable = !!(await fetchSessionUser())?.is_forest_service; - if (!(await loadPoints())) return; - await Promise.all([loadSurfaces(), loadAnalysis()]); + draw.setEditable(editable); + const base = await fetchPlanBase(projectId); + if (!base.start || !base.end || !base.epsg) { + terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요."; + return; + } + start = [base.start.x, base.start.y]; + end = [base.end.x, base.end.y]; + epsg = base.epsg; + takeServer(await fetchPlanNetwork(projectId)); + const draft = readState("d03-plan", projectId); + if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network; + map.update({ fit: [start, end, ...allVertices()] }); + refresh(); + await loadSurface(); 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(); + if ((await fetchPlanProgress(projectId)).terrain.running) await follow(); } catch (error) { terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index faf721e4d..c493fe022 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -1,4 +1,4 @@ -/* D03 예정노선 2단계 분석(PLAN 530-3) — 평면이 본문을 채우고 아래 횡단 판 · 좌측 연도 카드. */ +/* D03 예정노선 2단계 노선분석(PLAN 530-3 · 563-1) — 평면이 본문을 채움 · 위에 도구줄 · 좌측 지형 · 그리기 · 구역 · 연도. */ .d03-plan-layout .ui-workflow-layout__main { height: calc(100dvh - var(--app-header-h)); @@ -25,65 +25,66 @@ 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 { +.d03-plan__tools { 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; + top: var(--spacing-8); + right: var(--spacing-8); } .d03-plan__hint { margin: 0 0 6px; } -.d03-plan__years { +.d03-plan__row { display: flex; - flex-direction: column; - gap: 10px; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: flex-end; + margin-bottom: 6px; } -.d03-plan__year { +.d03-plan__row > .ui-field { + flex: 1 1 6rem; + margin: 0; +} + +.d03-plan__draw { + display: flex; + flex-direction: column; + gap: 6px; +} + +.d03-plan__pieces { display: flex; flex-direction: column; gap: 4px; } -.d03-plan__year-head { +.d03-plan__piece-head { + margin: 6px 0 0; font-weight: 600; - font-size: 0.85rem; + color: var(--color-text); +} + +.d03-plan__piece { + display: flex; + gap: var(--spacing-8); + align-items: center; + justify-content: space-between; +} + +.d03-plan__piece > .ui-text { + margin: 0; +} + +.d03-plan__piece > .ui-field { + flex: 0 0 6.5rem; + margin: 0; +} + +/* 지형 받기 범위 미리보기 — 카카오 지도 */ +.d03-plan__extent > .ui-text { + margin: 0 0 6px; } /* 3D 큰 모달 — 3D 화면이 모달 몸을 꽉 채움 · 몸 스크롤 없음 */ diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts deleted file mode 100644 index afa56b53e..000000000 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* ============================================================================= - * D03_PlanAnalysis_Edit.ts - * D 예정노선 꺾임점 고침 계산(PLAN 543-1) — 고른 갈래 노선의 마디(IP) 끌기 · 넣기 · 지우기 · 곡선 손잡이 - * = R. DOM · fetch · sessionStorage 없음. - * - * 셈은 B05 편집기 순수 부품 그대로(import 만) — 서버 노드 펴기 `flattenServerPlan` · 다시 그음 - * `rebuildLine`(R 하한 끌어올림 + `buildEditedPolyline`) · 마디 넣기 · 빼기 `spliceNode` · 손잡이 → - * 교각점 · R `dragHandleTo` · 하한 새로 깸 막기 `floorStop`. - * 처음 · 끝 마디 = 연도 경계 지점 → 잠금(옮기면 연도 사이가 끊김 · 지점은 지점 끌기로). - * - * ⚠ 서버 짝 — [저장] 때 `D03_PlanAnalysis_Edit.py` `edited_line` 이 같은 마디 · 켬끔 · R 로 선을 다시 - * 그음(Node `route.polyline` = 같은 `buildEditedPolyline` · 폴백 `build_planned_polyline`) · 거울 시험 - * `resources/tester/test_543_plan_edit.py`. - * ========================================================================== */ - -import { - flattenServerPlan, - floorStop, - rebuildLine, - spliceNode, - type CurveLimits, - type NodeColumns, -} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits"; -import { - dragHandleTo, - type EditedCurve, - type EditedPolyline, - type Vertex, -} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; -import type { PlanRoute } from "./D03_PlanAnalysis_Plan"; - -/** 연도 하나의 꺾임점 고침(캐시 · [저장] 입력) — 고친 갈래 · 그 분석 지문 · 마디 · 곡선 켬끔 · R. */ -export interface PlanEdit { - base_mode: string; - base_fingerprint: string; - nodes: Vertex[]; - curve_flags: boolean[]; - /** 넣은 R(하한까지 끌어올린 값) · null = 법정 최소 R. */ - radii: Array; -} - -export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string }; - -const limitsOf = (minRadiusM: number): CurveLimits => ({ - minRadiusM, - limitRadiusM: minRadiusM, - limitArcM: 0, -}); - -const columnsOf = (edit: PlanEdit): NodeColumns => ({ - planned: edit.nodes.map(([x, y]) => [x, y] as Vertex), - curveOn: [...edit.curve_flags], - curveRadius: [...edit.radii], - curveLock: edit.nodes.map(() => null), - curveArc: edit.nodes.map(() => null), - apexLock: edit.nodes.map(() => false), -}); - -/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */ -export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit { - const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]); - return { - base_mode: mode, - base_fingerprint: fingerprint, - nodes: flat.planned, - curve_flags: flat.curveOn, - radii: flat.curveRadius, - }; -} - -/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */ -export function buildEdit( - edit: PlanEdit, - minRadiusM: number, -): { edit: PlanEdit; line: EditedPolyline } { - const columns = columnsOf(edit); - const line = rebuildLine(columns, limitsOf(minRadiusM)); - return { edit: { ...edit, radii: columns.curveRadius }, line }; -} - -/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */ -function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep { - const built = buildEdit(after, minRadiusM); - const stop = floorStop( - columnsOf(before), - built.edit.nodes, - built.line.nodes, - limitsOf(minRadiusM), - ); - return stop ? { blocked: stop } : built; -} - -const isEnd = (edit: PlanEdit, index: number): boolean => - index <= 0 || index >= edit.nodes.length - 1; -const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다."; - -/** 마디 끌기 — 처음 · 끝 잠금. */ -export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep { - if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; - const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node)); - return settle(edit, { ...edit, nodes }, minRadiusM); -} - -/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */ -export function moveHandle( - edit: PlanEdit, - index: number, - which: "start" | "end", - to: Vertex, - minRadiusM: number, -): EditStep { - if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; - const got = dragHandleTo( - edit.nodes[index - 1], - edit.nodes[index], - edit.nodes[index + 1], - which, - to, - ); - if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." }; - const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node)); - const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r)); - const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on)); - return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM); -} - -/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */ -export function insertNode( - edit: PlanEdit, - segment: number, - at: Vertex, - minRadiusM: number, -): EditStep { - if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." }; - const columns = columnsOf(edit); - spliceNode(columns, segment + 1, [at[0], at[1]]); - const after = { - ...edit, - nodes: columns.planned, - curve_flags: columns.curveOn, - radii: columns.curveRadius, - }; - return settle(edit, after, minRadiusM); -} - -/** 마디 지우기 — 처음 · 끝 잠금. */ -export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep { - if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; - const columns = columnsOf(edit); - spliceNode(columns, index); - const after = { - ...edit, - nodes: columns.planned, - curve_flags: columns.curveOn, - radii: columns.curveRadius, - }; - return settle(edit, after, minRadiusM); -} - -/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */ -export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean { - if (!a || !b) return false; - const near = (x: number | null, y: number | null): boolean => - x === null || y === null ? x === y : Math.abs(x - y) < 1e-3; - return ( - a.base_mode === b.base_mode && - a.base_fingerprint === b.base_fingerprint && - a.nodes.length === b.nodes.length && - a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) && - a.curve_flags.every((on, i) => on === b.curve_flags[i]) && - a.radii.every((r, i) => near(r, b.radii[i] ?? null)) - ); -} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts deleted file mode 100644 index 3a6c10e0c..000000000 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* ============================================================================= - * D03_PlanAnalysis_Expect.ts - * D03 예상 횡단(PLAN 544-1 · 요구 363-1) — 측점 지반 → 표준 횡단 기본값 설계선 · 절 · 성토 ㎡. - * 셈 = 공용 `computeCrossDesign`(① 짝 `compute_cross_design` · 거울 `test_b06_cross_design_mirror.py`) - * · 기본 = B 초기 설계와 같음(리핑암 · 산쪽 절토 · 일반 측구 · 암 경계 = 서버 기본) · 계획고 = 측점 중심 지반고 - * · 미교차 = `extendUnclosed`(격자 있을 때) · 곡선 확폭 없음 · 보기만(저장 없음). - * 3D = 같은 단면을 B05 `buildCorridor` 가 읽는 측점 꼴로(`expectSections`). - * 시험 `resources/tester/test_544_1_plan_expect.py`. - * ========================================================================== */ - -import { - computeCrossDesign, - type CrossDesignOptions, - type CrossDesignResult, - type StandardCrossSectionSpec, -} from "../../common_util/common_util_cross_design"; -import type { DtmGrid } from "../../common_util/common_util_surface_sampler"; -import { - extendUnclosed, - type CrossGroundPoint, - type StationGround, -} from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; - -/** 서버 `sections/context` 의 표준 횡단(config) · 암 경계 기본 오프셋. */ -export interface ExpectStandard { - standard: StandardCrossSectionSpec; - rockBoundaryM: number | null; -} - -export interface ExpectCross { - design: CrossDesignResult; - /** 설계에 쓴 지반 표본(미교차로 넓혔으면 넓힌 것). */ - samples: CrossGroundPoint[]; -} - -/** B 초기 설계 기본 — 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구. */ -export function expectOptions( - uphill: "left" | "right" | null, - std: ExpectStandard, -): CrossDesignOptions { - return { - groundType: "ripping_rock", - sectionMode: `${uphill ?? "left"}_cut`, - ditchType: "standard", - standard: std.standard, - rockBoundaryOffsetM: std.rockBoundaryM, - }; -} - -/** 측점 하나 예상 단면 — 지반고 · 표본이 없으면 null. */ -export function expectCross( - station: StationGround, - std: ExpectStandard, - grid?: DtmGrid | null, -): ExpectCross | null { - const elevation = station.center_z; - if (elevation === null) return null; - const options = expectOptions(station.uphill_side, std); - try { - let samples = station.samples; - let design = computeCrossDesign(samples, elevation, options); - const wide = - design.slope_unclosed && grid - ? extendUnclosed( - samples, - [station.center_x, station.center_y], - station.left_xy, - (next) => computeCrossDesign(next, elevation, options), - grid, - ) - : null; - if (wide) { - samples = wide.samples as CrossGroundPoint[]; - design = computeCrossDesign(samples, elevation, options); - } - return { design, samples }; - } catch { - return null; // 표본 부족 — B 와 같은 태도(그 측점 건너뜀) - } -} - -/** B06 `CrossSection` 중 `buildCorridor` 가 읽는 칸(B06 형 파일은 별칭 import 라 여기 안 끌어옴). */ -export interface ExpectSection { - station_id: string; - chainage_m: number; - label: string; - kind: "regular"; - center_z: number | null; - azimuth_deg: null; - center_x: number; - center_y: number; - uphill_side: "left" | "right" | null; - frame: { left_xy: [number, number] }; - samples: CrossGroundPoint[]; - design: CrossDesignResult; -} - -/** 3D 코리도 입력 — 측점마다 예상 단면(못 세운 측점 뺌) · 누가 = 연도 노선 위 누가. */ -export function expectSections( - stations: StationGround[], - std: ExpectStandard, - grid?: DtmGrid | null, -): ExpectSection[] { - return stations.flatMap((station) => { - const cross = expectCross(station, std, grid); - if (!cross) return []; - return [ - { - station_id: String(station.chainage_m), - chainage_m: station.chainage_m, - label: "", - kind: "regular", - center_z: station.center_z, - azimuth_deg: null, - center_x: station.center_x, - center_y: station.center_y, - uphill_side: station.uphill_side, - frame: { left_xy: station.left_xy }, - samples: cross.samples, - design: cross.design, - }, - ]; - }); -} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index 445992c7e..7f48c26bc 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -1,232 +1,236 @@ /* ============================================================================= * D03_PlanAnalysis_Plan.ts - * D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 · - * 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음. - * - * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔ - * `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 - * `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기. + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ +import { lineLength } from "./D03_PlanAnalysis_Draw"; + 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 RouteMetrics { - length_m: number; - max_grade_pct?: number; - over_limit?: { total: number }; +/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */ +export interface PlanLine { + id: string; + vertices: Vertex[]; + branch: { line: string; vertex: number } | null; } -export interface PlanRoute { - planned?: Vertex[]; - /** 엔진 노드 · 곡선(B05 `RouteNode` · `RouteCurve` 꼴) — 꺾임점 고침의 시작 마디. */ - nodes?: Array<{ - x: number; - y: number; - radius_m: number | null; - inner_angle_deg: number | null; - violations?: string[]; - }>; - curves?: unknown[]; - metrics?: RouteMetrics; - /** 꺾임점을 고친 노선(`withEdits`). */ - edited?: boolean; - error?: string; -} - -export interface YearAnalysis { +/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */ +export interface PlanPiece { + id: string; + line: string; + from_m: number; + to_m: number; year: number; - start: Vertex; - end: Vertex; - /** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */ - fingerprint?: string; - criteria?: { min_radius_m: number; max_grade_pct?: number }; - routes: Record; } -/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */ -export interface RouteEdit { - mode: string; - planned: Vertex[]; - metrics?: RouteMetrics | null; +export interface PlanNetwork { + lines: PlanLine[]; + pieces: PlanPiece[]; } -export interface ChainRow { - year: number; - mode: string | null; - length_m: number; - start_chainage_m: number; - end_chainage_m: number; +/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */ +export const MARK_GAP_M = 1; + +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ +function nextPieceId(pieces: ReadonlyArray): string { + const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0)); + return `p${Math.max(0, ...used) + 1}`; } -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 emptyNetwork(start: Vertex): PlanNetwork { + return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] }; } -/** 연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가. */ -export function chainRows( - results: ReadonlyArray, - choices: Readonly>, - startChainageM: number, -): ChainRow[] { - let at = startChainageM || 0; - return results.map((result) => { - const mode = pickMode(result, choices[result.year]); - const length = mode ? Number(result.routes[mode].metrics?.length_m ?? 0) : 0; - const row = { - year: result.year, - mode, - length_m: round3(length), - start_chainage_m: round3(at), - end_chainage_m: round3(at + length), - }; - at += length; - return row; - }); -} - -/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */ -export function lineLength(line: ReadonlyArray): number { - let total = 0; - for (let i = 1; i < line.length; i += 1) - total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]); - return total; -} - -/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감). - * 그 갈래가 없거나 빈 갈래면 그대로. */ -export function withEdits( - results: ReadonlyArray, - edits: Readonly>, -): YearAnalysis[] { - return results.map((result) => { - const edit = edits[result.year]; - const route = edit ? result.routes?.[edit.mode] : undefined; - if (!edit || !route || "error" in route) return result; - const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) }; - return { - ...result, - routes: { - ...result.routes, - [edit.mode]: { ...route, planned: edit.planned, metrics, edited: true }, - }, - }; - }); -} - -const samePoint = (a: Vertex, b: Vertex): boolean => - Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3; - -/** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */ -export function joinedLine( - results: ReadonlyArray, - rows: ReadonlyArray, -): Vertex[] { - const line: Vertex[] = []; - results.forEach((result, i) => { - const mode = rows[i]?.mode; - if (!mode) return; - const planned = result.routes[mode].planned ?? []; - const head = line.length && planned.length && samePoint(line[line.length - 1], planned[0]); - line.push(...(head ? planned.slice(1) : planned)); - }); - return line; -} - -/** 측점 누가(오름차순) — 연도 경계마다(경계점 = 측점) + 간격 배수 + 끝. 노선 없는 연도는 건너뜀. */ -export function stationChainages(rows: ReadonlyArray, intervalM: number): number[] { - const out: number[] = []; - const push = (value: number): void => { - if (!out.length || value - out[out.length - 1] > 1e-6) out.push(round3(value)); - }; - for (const row of rows) { - if (!row.mode) continue; - push(row.start_chainage_m); - let next = Math.floor(row.start_chainage_m / intervalM + 1e-9) * intervalM + intervalM; - for (; next < row.end_chainage_m - 1e-6; next += intervalM) push(next); - push(row.end_chainage_m); +/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 · + * 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */ +export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork { + const pieces: PlanPiece[] = []; + for (const line of network.lines) { + const length = round3(lineLength(line.vertices)); + if (!(length > 0)) continue; + const own = network.pieces + .filter((piece) => piece.line === line.id) + .sort((a, b) => a.from_m - b.from_m); + const starts = own + .filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2) + .map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year })); + const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { id: "", year }; + const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts]; + cuts.forEach((cut, i) => + pieces.push({ + // id 빈 칸 · 연도 없음(서버 마크만 있던 구역) = 새 id · 처음 연도 + id: cut.id || nextPieceId([...network.pieces, ...pieces]), + line: line.id, + from_m: cut.from, + to_m: i + 1 < cuts.length ? cuts[i + 1].from : length, + year: Number.isFinite(cut.year) ? cut.year : year, + }), + ); } - return out; + return { ...network, pieces }; } -/** 측점 그리기 · 집기용 선 — 첫 점 앞에 첫 구간 방향으로 `startM` 만큼 뒤로 뺀 점을 붙여 선 위 누가 = - * 등록 시작 누가부터(`drawStationTicks` · `stationAtScreen` 은 선 첫 점을 0 으로 셈). */ -export function leadInLine(line: ReadonlyArray, startM: number): Vertex[] { - if (line.length < 2 || !(startM > 0)) return [...line]; - const [a, b] = line; - const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; - const back: Vertex = [ - a[0] - ((b[0] - a[0]) / length) * startM, - a[1] - ((b[1] - a[1]) / length) * startM, - ]; - return [back, ...line]; -} - -/** 누가가 든 연도 행(경계는 앞 연도) — 노선 있는 연도만 · 밖이면 null. */ -export function rowAt(rows: ReadonlyArray, chainageM: number): ChainRow | null { - return ( - rows.find( - (row) => - row.mode && - chainageM >= row.start_chainage_m - 1e-6 && - chainageM <= row.end_chainage_m + 1e-6, - ) ?? null +/** 선 위 `atM` 에 마크 — 그 자리 조각을 둘로(같은 연도) · 끝 · 경계와 가까우면 그대로. */ +export function splitPiece(network: PlanNetwork, line: string, atM: number): PlanNetwork { + const index = network.pieces.findIndex( + (piece) => piece.line === line && atM > piece.from_m && atM < piece.to_m, ); + const piece = network.pieces[index]; + if (!piece || atM - piece.from_m < MARK_GAP_M || piece.to_m - atM < MARK_GAP_M) return network; + const at = round3(atM); + const pieces = [...network.pieces]; + pieces.splice( + index, + 1, + { ...piece, to_m: at }, + { ...piece, id: nextPieceId(pieces), from_m: at }, + ); + return { ...network, pieces }; } -/** 연도별 지점 → 마디 P0…Pn(직전 종점 = 다음 시점). */ -export function nodesOf(years: ReadonlyArray<{ start: Vertex; end: Vertex }>): Vertex[] { - return years.length ? [years[0].start, ...years.map((year) => year.end)] : []; +/** `atM` 둘레 `toleranceM` 안 마크를 지움 — 두 조각을 합침(앞 조각 연도). 없으면 그대로. */ +export function removeMark( + network: PlanNetwork, + line: string, + atM: number, + toleranceM: number, +): PlanNetwork { + const index = network.pieces.findIndex( + (piece) => + piece.line === line && piece.from_m > 0 && Math.abs(piece.from_m - atM) <= toleranceM, + ); + if (index < 1 || network.pieces[index - 1].line !== line) return network; + const pieces = [...network.pieces]; + const before = pieces[index - 1]; + pieces.splice(index - 1, 2, { ...before, to_m: pieces[index].to_m }); + return { ...network, pieces }; } -/** 마디 → 연도별 지점(서버 `points` 꼴). */ -export function pointsOf( - years: ReadonlyArray, - nodes: ReadonlyArray, -): Array<{ year: number; start: Vertex; end: Vertex }> { - return years.map((year, i) => ({ year, start: nodes[i], end: nodes[i + 1] })); +/** 조각 연도 바꿈. */ +export function setPieceYear(network: PlanNetwork, index: number, year: number): PlanNetwork { + return { + ...network, + pieces: network.pieces.map((piece, i) => (i === index ? { ...piece, year } : piece)), + }; } -export interface Box { - x0: number; - y0: number; - x1: number; - y1: number; +/** `line` 째 선의 `segment` 번째 구간 안에 꺾임점을 끼움 — 그 선에 붙은 갈래 차례를 한 칸 밈. + * 선 위 점이라 누가 · 조각은 그대로. */ +export function insertVertex( + network: PlanNetwork, + line: number, + segment: number, + point: Vertex, +): PlanNetwork { + const id = network.lines[line].id; + return { + ...network, + lines: network.lines.map((entry, i) => { + if (i === line) { + const vertices = [...entry.vertices]; + vertices.splice(segment + 1, 0, point); + return { ...entry, vertices }; + } + const branch = entry.branch; + return branch && branch.line === id && branch.vertex > segment + ? { ...entry, branch: { ...branch, vertex: branch.vertex + 1 } } + : entry; + }), + }; } -/** 마디마다 받은 지형 안인가 — 그 마디를 끝점으로 쓰는 연도(앞 · 뒤) DEM 상자를 `reachM` 만큼 줄인 안. - * 연도 DEM 이 없으면 밖. */ -export function nodesInReach( - nodes: ReadonlyArray, - boxes: ReadonlyArray, - reachM: number = PLAN_REACH_M, -): boolean[] { - const inside = (point: Vertex, box: Box | null): boolean => - !!box && - point[0] >= box.x0 + reachM && - point[0] <= box.x1 - reachM && - point[1] >= box.y0 + reachM && - point[1] <= box.y1 - reachM; - return nodes.map((point, i) => { - const years = [i - 1, i].filter((year) => year >= 0 && year < boxes.length); - return years.every((year) => inside(point, boxes[year])); - }); +/** 선 끝에 점을 더함. */ +export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork { + return { + ...network, + lines: network.lines.map((entry, i) => + i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry, + ), + }; } -/** 마디가 정본과 같은가(1 mm — 짝: `D02_PlanBase_Router.SAME_M`). */ -export function sameNodes(a: ReadonlyArray, b: ReadonlyArray): boolean { - return a.length === b.length && a.every((point, i) => samePoint(point, b[i])); +/** 선 끝 점을 뺌 — 첫 점(본선 시점 · 갈래 붙은 점)은 못 뺌 · 갈래가 붙은 점도 못 뺌(까닭 글). */ +export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork | string { + const entry = network.lines[line]; + if (!entry || entry.vertices.length < 2) return "더 뺄 점이 없습니다."; + const last = entry.vertices.length - 1; + if ( + network.lines.some((other) => other.branch?.line === entry.id && other.branch.vertex === last) + ) + return "갈래가 붙은 점은 뺄 수 없습니다 — 갈래를 먼저 지우세요."; + return { + ...network, + lines: network.lines.map((other, i) => + i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other, + ), + }; +} + +/** 마지막 선(그리던 갈래)을 지움 — 그 선 조각도. 갈래가 붙어 있으면 그대로. */ +export function dropLastLine(network: PlanNetwork): PlanNetwork { + const index = network.lines.length - 1; + const id = network.lines[index]?.id; + if (index < 1 || network.lines.some((line) => line.branch?.line === id)) return network; + return { + lines: network.lines.slice(0, index), + pieces: network.pieces.filter((piece) => piece.line !== id), + }; +} + +/** 새 갈래 — `line` 째 선의 `vertex` 꺾임점에서 시작. */ +export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork { + const parent = network.lines[line]; + const from = parent.vertices[vertex]; + const used = new Set(network.lines.map((entry) => entry.id)); + let n = network.lines.length; + while (used.has(`branch-${n}`)) n += 1; + return { + ...network, + lines: [ + ...network.lines, + { id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } }, + ], + }; +} + +/** 서버 꼴(선 · 마크 · 조각 · 조각 없는 구역 있음) → 화면 꼴(조각이 선을 빈틈없이 덮음). */ +export function fromServer( + lines: PlanLine[], + marks: ReadonlyArray<{ line: string; at_m: number }>, + pieces: ReadonlyArray, + year: number, +): PlanNetwork { + const cuts: PlanPiece[] = marks + .filter( + (mark) => + !pieces.some( + (piece) => piece.line === mark.line && Math.abs(piece.from_m - mark.at_m) < 0.01, + ), + ) + .map((mark) => ({ id: "", line: mark.line, from_m: mark.at_m, to_m: mark.at_m, year: NaN })); + return normalizePieces({ lines, pieces: [...pieces, ...cuts] }, year); +} + +/** 서버 꼴 마크 — 조각 경계(선마다 0 빼고 시작 자리). */ +export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: number }> { + return network.pieces + .filter((piece) => piece.from_m > 0) + .map((piece) => ({ line: piece.line, at_m: piece.from_m })); +} + +/** 본선이 종점에 닿았나(1 mm). */ +export function reachedEnd(network: PlanNetwork, end: Vertex): boolean { + const main = network.lines[0]?.vertices ?? []; + const last = main[main.length - 1]; + return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3; +} + +/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */ +export function lineName(index: number): string { + return index === 0 ? "본선" : `갈래 ${index}`; } diff --git a/resources/tester/test_530_3_plan_logic.py b/resources/tester/test_530_3_plan_logic.py index 34b4578f2..727ea3077 100644 --- a/resources/tester/test_530_3_plan_logic.py +++ b/resources/tester/test_530_3_plan_logic.py @@ -1,7 +1,7 @@ -"""D03 화면 계산 거울(PLAN 530-3) — 파이썬 `D03_PlanAnalysis_Analyze` ↔ TS `logic/D03_PlanAnalysis_Plan.ts`. +"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`. -짝: `pick_mode` · `chain` · `joined_line` ↔ `pickMode` · `chainRows` · `joinedLine`. -TS 만: 측점 누가(경계 = 측점 · 간격 배수) · 앞붙임 선(선 위 누가 = 등록 시작 누가) · 받은 지형 판정. +조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 · +화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q """ @@ -18,61 +18,47 @@ 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 +from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # 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 { 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], +const Y = 2026; +let n = plan.emptyNetwork([0, 0]); +n = plan.appendVertex(n, 0, [100, 0]); +n = plan.appendVertex(n, 0, [100, 100]); +n = plan.normalizePieces(n, Y); +const one = n; +n = plan.normalizePieces(plan.splitPiece(n, "main", 50), Y); +const split = n; +const tooClose = plan.splitPiece(n, "main", 50.5); +n = plan.normalizePieces(plan.setPieceYear(n, 1, 2027), Y); +n = plan.addBranch(n, 0, 1); +n = plan.appendVertex(n, 1, [160, 0]); +n = plan.normalizePieces(n, Y); +const branched = n; +const inserted = plan.insertVertex(n, 0, 0, [40, 0]); +const blocked = plan.dropLastVertex(inserted, 0); +const dropped = plan.dropLastVertex(n, 1); +const guard = plan.dropLastVertex(plan.dropLastVertex(n, 0), 0); +const removed = plan.normalizePieces(plan.removeMark(n, "main", 50.4, 1), Y); +const grown = plan.normalizePieces(plan.appendVertex(n, 0, [100, 150]), Y); +const read = plan.fromServer( + n.lines, + [{ line: "main", at_m: 50 }, { line: "main", at_m: 150 }], + [{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }], + Y, ); -writeFileSync(process.argv[3], JSON.stringify({ out, lead, reach })); +writeFileSync(process.argv[2], JSON.stringify({ + one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read, + marks: plan.marksOf(branched), + reached: plan.reachedEnd(n, [100, 100]), +})); """ -_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" @@ -103,11 +89,9 @@ def _run_ts(tmp_path: Path) -> dict: 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)], + ["node", str(out / "runner.mjs"), str(result)], cwd=str(ROOT), check=True, capture_output=True, @@ -115,28 +99,48 @@ def _run_ts(tmp_path: Path) -> dict: return json.loads(result.read_text(encoding="utf-8")) -def test_ts_matches_python_chain(tmp_path: Path) -> None: +def _spans(network: dict, line: str = "main") -> list[tuple[float, float, int]]: + return [(p["from_m"], p["to_m"], p["year"]) for p in network["pieces"] if p["line"] == line] + + +def test_pieces_cover_and_split(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] + assert _spans(got["one"]) == [(0, 200, 2026)] + assert _spans(got["split"]) == [(0, 50, 2026), (50, 200, 2026)] + assert [p["id"] for p in got["split"]["pieces"]] == ["p1", "p2"] # 앞 조각이 id 를 지님 + assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음 + assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도 + assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚 + assert got["reached"] is True -def test_lead_in_and_reach(tmp_path: Path) -> None: +def test_branch_order_and_guard(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] + branch = got["branched"]["lines"][1] + assert branch["branch"] == {"line": "main", "vertex": 1} and branch["vertices"][0] == [100, 0] + assert _spans(got["branched"], branch["id"]) == [(0, 60, 2026)] + # 본선 앞 구간에 꺾임점을 끼우면 갈래가 붙은 차례가 한 칸 밀림 + assert got["inserted"]["lines"][0]["vertices"][1] == [40, 0] + assert got["inserted"]["lines"][1]["branch"]["vertex"] == 2 + assert got["blocked"]["lines"][0]["vertices"][-1] == [100, 0] # 끝 점은 갈래 없음 → 빠짐 + assert "갈래가 붙은 점" in got["guard"] # 갈래가 붙은 점은 못 뺌 + assert got["dropped"]["lines"][1]["vertices"] == [[100, 0]] + + +def test_from_server_fills_marks(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + # 조각 없는 구역(마크만) = 처음 연도 · 새 id + assert _spans(got["read"]) == [(0, 50, 2028), (50, 150, 2026), (150, 200, 2026)] + assert got["read"]["pieces"][0]["id"] == "p9" + assert len({p["id"] for p in got["read"]["pieces"]}) == 4 # 갈래 조각 포함 겹침 없음 + + +def test_server_accepts_screen_body(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + body = {**got["branched"], "marks": got["marks"], "epsg": 5186} + derived = server.derive(body, 2026) + assert [(p["line"], p["from_m"], p["to_m"], p["year"]) for p in derived["pieces"]] == [ + ("main", 0.0, 50.0, 2026), + ("main", 50.0, 200.0, 2027), + ("branch-1", 0.0, 60.0, 2026), + ] diff --git a/resources/tester/test_544_1_plan_expect.py b/resources/tester/test_544_1_plan_expect.py deleted file mode 100644 index eadfcde49..000000000 --- a/resources/tester/test_544_1_plan_expect.py +++ /dev/null @@ -1,187 +0,0 @@ -"""D03 예상 횡단(PLAN 544-1) — TS `logic/D03_PlanAnalysis_Expect.ts` 값 = 파이썬 `compute_cross_design`. - -기본 = 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구 · 암 경계 = config 기본 · 계획고 = 측점 중심 지반고. -미교차 = 격자로 넓힌 표본 · 넓힌 표본으로 파이썬이 다시 셈한 값과 같음 · 코리도 측점 꼴. - -./venv/Scripts/python.exe -m pytest resources/tester/test_544_1_plan_expect.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 B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 -from config.config_system_design import ( # noqa: E402 - STANDARD_CROSS_SECTION, - STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, -) - -TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Expect.ts" -TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" - -_RUNNER = """ -import { readFileSync, writeFileSync } from "node:fs"; -import * as expect from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.js"; -const job = JSON.parse(readFileSync(process.argv[2], "utf8")); -const std = { standard: job.standard, rockBoundaryM: job.rock }; -// 평면 격자 — z = 100 + slope * y(좌 축 = +y) · 1 m · ±100 m -const n = 201; -const axis = Float64Array.from({ length: n }, (_, i) => i - 100); -const z = new Float64Array(n * n); -for (let iy = 0; iy < n; iy += 1) - for (let ix = 0; ix < n; ix += 1) z[iy * n + ix] = 100 + job.wideSlope * axis[iy]; -const grid = { x: axis, y: axis, z, validBits: new Uint8Array(Math.ceil((n * n) / 8)).fill(255) }; -const cross = job.stations.map((s) => expect.expectCross(s, std)); -const wide = expect.expectCross(job.wide, std, grid); -const sections = expect.expectSections([...job.stations, job.wide], std, grid); -writeFileSync(process.argv[3], JSON.stringify({ cross, wide, sections })); -""" - - -def _station(slope: float, uphill: str | None, *, half: float = 20.0) -> dict: - samples = [] - count = int(half * 2 / 0.5) + 1 - for i in range(count): - offset = -half + i * 0.5 - z = round(100.0 + slope * offset, 6) - samples.append( - {"offset_m": offset, "x": 0.0, "y": offset, "z": z, "elevation_m": z, "valid": True} - ) - return { - "chainage_m": 40.0, - "center_x": 0.0, - "center_y": 0.0, - "center_z": 100.0, - "tangent_xy": [1.0, 0.0], - "left_xy": [0.0, 1.0], - "uphill_side": uphill, - "samples": samples, - } - - -_STATIONS = [ - _station(0.30, "left"), - _station(-0.45, "right"), - _station(0.05, None), - {**_station(0.2, "left"), "center_z": None}, -] -_WIDE_SLOPE = 0.8 - - -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( - { - "standard": STANDARD_CROSS_SECTION, - "rock": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, - "stations": _STATIONS, - "wide": _station(_WIDE_SLOPE, "left"), - "wideSlope": _WIDE_SLOPE, - } - ), - 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 _python(samples: list[dict], elevation: float, uphill: str | None) -> dict: - return compute_cross_design( - samples, - elevation, - ground_type="ripping_rock", - section_mode=f"{uphill or 'left'}_cut", - ditch_type="standard", - standard=STANDARD_CROSS_SECTION, - rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, - ) - - -def _same(ts: dict, py: dict) -> None: - assert ts["section_mode"] == py["section_mode"] - assert ts["ground_type"] == "ripping_rock" and ts["ditch_type"] == "standard" - assert abs(ts["cut_area_m2"] - py["cut_area_m2"]) < 1e-9 - assert abs(ts["fill_area_m2"] - py["fill_area_m2"]) < 1e-9 - assert len(ts["design_line"]) == len(py["design_line"]) - for a, b in zip(ts["design_line"], py["design_line"]): - assert abs(a["offset_m"] - b["offset_m"]) < 1e-9 - assert abs(a["elevation_m"] - b["elevation_m"]) < 1e-9 - - -def test_expect_matches_python(tmp_path: Path) -> None: - got = _run_ts(tmp_path) - for station, ts in zip(_STATIONS, got["cross"]): - if station["center_z"] is None: - assert ts is None # 지반고 없음 = 못 세움 - continue - py = _python(station["samples"], station["center_z"], station["uphill_side"]) - _same(ts["design"], py) - assert ts["design"]["design_elevation_m"] == station["center_z"] - # 산쪽 절토 · 모르면 좌 - assert [c and c["design"]["section_mode"] for c in got["cross"]] == [ - "left_cut", - "right_cut", - "left_cut", - None, - ] - # 비탈 표본 — 절토 · 성토 둘 다 섬 - assert got["cross"][0]["design"]["cut_area_m2"] > 0 - assert got["cross"][0]["design"]["fill_area_m2"] > 0 - - -def test_unclosed_extends_and_sections(tmp_path: Path) -> None: - got = _run_ts(tmp_path) - wide = got["wide"] - narrow = _python(_station(_WIDE_SLOPE, "left")["samples"], 100.0, "left") - assert narrow["slope_unclosed"] is True # ±20 m 안에선 안 닫힘 - assert wide["design"]["slope_unclosed"] is False - assert min(s["offset_m"] for s in wide["samples"]) < -20.0 - _same(wide["design"], _python(wide["samples"], 100.0, "left")) - # 코리도 측점 = 세운 측점만(지반고 없는 측점 뺌) · 좌 축 · 설계 실음 - sections = got["sections"] - assert len(sections) == 4 - assert all(s["frame"]["left_xy"] == [0.0, 1.0] and s["design"]["design_line"] for s in sections) - assert sections[-1]["samples"] == wide["samples"] diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 2a79b490f..bf150db97 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -38,12 +38,19 @@ def test_d03_parts(): ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts") three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts") css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css") + draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts") + tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") assert 'createElement("progress")' not in ts and "createProgressBar()" in ts - assert "createCard({" in ts and "onSelect:" in ts - assert 'classList.toggle("is-picked"' in ts # 촬영 장면이 `.d03-plan__mode.is-picked` 로 잡음 - assert "card.dataset.mode = mode" in ts + # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 + for src in (ts, draw, tools): + for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): + assert raw not in src, raw + assert "createToolbar({" in tools and "createIconButton(" in tools + assert '"rotateCcw"' in tools and '"rotateCw"' in tools and "leftPan" not in tools + assert "createSegment({" in draw and draw.count("createInputField(") == 2 + assert "createSelectField(" in ts # 조각 연도 assert 'createElement("select")' not in three and "createSelectField" in three - assert 'createElement("input")' not in three and "createCheckbox" in three + assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음 for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): assert dead not in css, dead assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음