feat(562-3 · 563-1): D03 손 긋기 화면 — 도구 여섯 · 그리기 · 거리재기 · 구역 연도 · 횡단 판 · 예상 코리도 걷음

- UI_Map: 바탕(위성 · 등고선) + 겹(그리기 · 거리재기) · 도구줄 부품(되돌리기 · 다시하기 · 3D · 좌 · 우 회전 · 거리재기) · B05 createMapRotation · 왼쪽 끌기 이동 끔(가운데 단추 · 휠 방향 = 설정)
- 새 UI_Draw: 시점에서 점 찍어 직선 · 미리보기 길이 · 기울기 · 제약 칸(자유 · 길이 고정 · 기울기 고정 · 둘 다 · 20 m · 9 %) · 종점 붙기 · 갈래 · 구역 마크 · Enter · Esc · Backspace
- 새 UI_Measure: 두 점 길이 · 기울기 · 높이 차(지형 heightAt)
- logic/Plan: 선 · 갈래 · 조각 꼴 다루기로 다시 씀(옛 갈래 넷 · 이은 누가 걷음)
- UI_Page · Api: 지형 범위 카카오 지도 미리보기 → [이 범위 받기] · [범위 밖 더 받기] · GET · PUT plan/network · [초기화] · 조각 연도 고름 · 캐시 판 2
- UI_3D: 지형 + 그은 선(선 고름) · 예상 코리도 · 체크 칸 걷음
- 지움: UI_Cross · UI_Edit · logic/Expect · logic/Edit · test_544_1(예상 횡단 · 예상 코리도 셈이 화면에서 걷혀 지킬 대상 없음)
- 시험: 530_3 = 손 긋기 꼴 다루기 + 서버 derive 대조로 다시 씀 · 553_4 D03 부품 단언 새 화면으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:16:08 +09:00
co-authored by Claude Opus 5.5
parent 02a511e400
commit cdcdd5f597
16 changed files with 1765 additions and 2022 deletions
+1 -1
View File
@@ -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" },
+87 -79
View File
@@ -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<PlanPiece & { length_m: number; planned: Vertex[] }>;
} | null;
/** 옛 연도 노선을 선으로 바꾼 것인가. */
legacy: boolean;
/** 사업연도 시작(있으면). */
year_start?: number | null;
}
export interface PlanSurface {
modelId: number;
grid: DtmGrid;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -66,62 +84,52 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
}
const base = (projectId: string): string => `/projects/${projectId}/plan`;
const pointsBody = (points: PlanPoints | null): string => JSON.stringify(points ? { points } : {});
export const fetchPlanPoints = (projectId: string): Promise<PlanPointsView> =>
export const fetchPlanBase = (projectId: string): Promise<PlanBaseView> =>
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<TerrainExtent> => {
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<PlanJob> =>
request(`${base(projectId)}/${kind}`, { method: "POST", body: pointsBody(points) });
export const fetchPlanProgress = (
projectId: string,
): Promise<{ terrain: PlanJob; analyze: PlanJob }> => request(`${base(projectId)}/progress`);
export const fetchPlanAnalysis = (
projectId: string,
points: PlanPoints | null,
): Promise<PlanAnalysisView> =>
request(
`${base(projectId)}/analysis` +
(points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""),
);
export const savePlanAnalysis = (
projectId: string,
choices: Record<number, string>,
points: PlanPoints | null,
edits: Record<number, PlanEdit> = {},
): 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<Map<number, YearSurface>> {
export const fetchPlanNetwork = (projectId: string): Promise<PlanNetworkView> =>
request(`${base(projectId)}/network`);
export const savePlanNetwork = (
projectId: string,
network: ServerNetwork,
): Promise<PlanNetworkView> =>
request(`${base(projectId)}/network`, { method: "PUT", body: JSON.stringify(network) });
/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */
export async function fetchPlanSurface(projectId: string): Promise<PlanSurface | null> {
const { models } = await listSurfaceModels(projectId);
const out = new Map<number, YearSurface>();
for (const model of models) {
const year = Number(/models_(\d+)\//.exec(model.model_file_path ?? "")?.[1]);
if (!year || out.has(year)) continue;
const stamp = String(model.generation_params?.file_stamp ?? "");
const url =
`${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/grid` +
`?smooth=true&sig=${encodeURIComponent(stamp)}`;
out.set(year, {
modelId: model.id,
grid: parseDtmGrid(await fetchCachedBytes(projectId, url)),
});
}
return out;
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)) };
}
+39 -62
View File
@@ -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<void> => {
const item = years.find((entry) => entry.year === year) ?? years[0];
const load = async (index: number): Promise<void> => {
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<string, unknown> = { year: item.year, done: false };
const stat: Record<string, unknown> = { 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);
}
@@ -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<ExpectStandard | null> {
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<ExpectStandard | null> | 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;
},
};
}
@@ -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<typeof lineAt> = 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<typeof planColors>,
): 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();
},
};
}
@@ -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;
}
},
};
}
+175 -234
View File
@@ -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<PlanMapScene>) => void;
/** 지점 · 노선이 들어오게 다시 맞춤. */
setLayers: (layers: PlanMapLayer[]) => void;
setTools: (tools: Partial<PlanTools>) => 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<typeof createIconButton>[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(),
};
}
@@ -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),
};
}
File diff suppressed because it is too large Load Diff
+45 -44
View File
@@ -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 화면이 모달 몸을 꽉 채움 · 몸 스크롤 없음 */
@@ -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<number | null>;
}
export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string };
const limitsOf = (minRadiusM: number): CurveLimits => ({
minRadiusM,
limitRadiusM: minRadiusM,
limitArcM: 0,
});
const columnsOf = (edit: PlanEdit): NodeColumns => ({
planned: edit.nodes.map(([x, y]) => [x, y] as Vertex),
curveOn: [...edit.curve_flags],
curveRadius: [...edit.radii],
curveLock: edit.nodes.map(() => null),
curveArc: edit.nodes.map(() => null),
apexLock: edit.nodes.map(() => false),
});
/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */
export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit {
const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]);
return {
base_mode: mode,
base_fingerprint: fingerprint,
nodes: flat.planned,
curve_flags: flat.curveOn,
radii: flat.curveRadius,
};
}
/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */
export function buildEdit(
edit: PlanEdit,
minRadiusM: number,
): { edit: PlanEdit; line: EditedPolyline } {
const columns = columnsOf(edit);
const line = rebuildLine(columns, limitsOf(minRadiusM));
return { edit: { ...edit, radii: columns.curveRadius }, line };
}
/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */
function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep {
const built = buildEdit(after, minRadiusM);
const stop = floorStop(
columnsOf(before),
built.edit.nodes,
built.line.nodes,
limitsOf(minRadiusM),
);
return stop ? { blocked: stop } : built;
}
const isEnd = (edit: PlanEdit, index: number): boolean =>
index <= 0 || index >= edit.nodes.length - 1;
const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다.";
/** 마디 끌기 — 처음 · 끝 잠금. */
export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep {
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node));
return settle(edit, { ...edit, nodes }, minRadiusM);
}
/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */
export function moveHandle(
edit: PlanEdit,
index: number,
which: "start" | "end",
to: Vertex,
minRadiusM: number,
): EditStep {
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
const got = dragHandleTo(
edit.nodes[index - 1],
edit.nodes[index],
edit.nodes[index + 1],
which,
to,
);
if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." };
const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node));
const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r));
const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on));
return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM);
}
/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */
export function insertNode(
edit: PlanEdit,
segment: number,
at: Vertex,
minRadiusM: number,
): EditStep {
if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." };
const columns = columnsOf(edit);
spliceNode(columns, segment + 1, [at[0], at[1]]);
const after = {
...edit,
nodes: columns.planned,
curve_flags: columns.curveOn,
radii: columns.curveRadius,
};
return settle(edit, after, minRadiusM);
}
/** 마디 지우기 — 처음 · 끝 잠금. */
export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep {
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
const columns = columnsOf(edit);
spliceNode(columns, index);
const after = {
...edit,
nodes: columns.planned,
curve_flags: columns.curveOn,
radii: columns.curveRadius,
};
return settle(edit, after, minRadiusM);
}
/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */
export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean {
if (!a || !b) return false;
const near = (x: number | null, y: number | null): boolean =>
x === null || y === null ? x === y : Math.abs(x - y) < 1e-3;
return (
a.base_mode === b.base_mode &&
a.base_fingerprint === b.base_fingerprint &&
a.nodes.length === b.nodes.length &&
a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) &&
a.curve_flags.every((on, i) => on === b.curve_flags[i]) &&
a.radii.every((r, i) => near(r, b.radii[i] ?? null))
);
}
@@ -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,
},
];
});
}
+204 -200
View File
@@ -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<string, PlanRoute>;
}
/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */
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<n>`(있는 것 중 가장 큰 수 + 1). */
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): 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<YearAnalysis>,
choices: Readonly<Record<number, string | null | undefined>>,
startChainageM: number,
): ChainRow[] {
let at = startChainageM || 0;
return results.map((result) => {
const mode = pickMode(result, choices[result.year]);
const length = mode ? Number(result.routes[mode].metrics?.length_m ?? 0) : 0;
const row = {
year: result.year,
mode,
length_m: round3(length),
start_chainage_m: round3(at),
end_chainage_m: round3(at + length),
};
at += length;
return row;
});
}
/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */
export function lineLength(line: ReadonlyArray<Vertex>): number {
let total = 0;
for (let i = 1; i < line.length; i += 1)
total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]);
return total;
}
/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감).
* 그 갈래가 없거나 빈 갈래면 그대로. */
export function withEdits(
results: ReadonlyArray<YearAnalysis>,
edits: Readonly<Record<number, RouteEdit | undefined>>,
): YearAnalysis[] {
return results.map((result) => {
const edit = edits[result.year];
const route = edit ? result.routes?.[edit.mode] : undefined;
if (!edit || !route || "error" in route) return result;
const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) };
return {
...result,
routes: {
...result.routes,
[edit.mode]: { ...route, planned: edit.planned, metrics, edited: true },
},
};
});
}
const samePoint = (a: Vertex, b: Vertex): boolean =>
Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3;
/** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */
export function joinedLine(
results: ReadonlyArray<YearAnalysis>,
rows: ReadonlyArray<ChainRow>,
): Vertex[] {
const line: Vertex[] = [];
results.forEach((result, i) => {
const mode = rows[i]?.mode;
if (!mode) return;
const planned = result.routes[mode].planned ?? [];
const head = line.length && planned.length && samePoint(line[line.length - 1], planned[0]);
line.push(...(head ? planned.slice(1) : planned));
});
return line;
}
/** 측점 누가(오름차순) — 연도 경계마다(경계점 = 측점) + 간격 배수 + 끝. 노선 없는 연도는 건너뜀. */
export function stationChainages(rows: ReadonlyArray<ChainRow>, intervalM: number): number[] {
const out: number[] = [];
const push = (value: number): void => {
if (!out.length || value - out[out.length - 1] > 1e-6) out.push(round3(value));
};
for (const row of rows) {
if (!row.mode) continue;
push(row.start_chainage_m);
let next = Math.floor(row.start_chainage_m / intervalM + 1e-9) * intervalM + intervalM;
for (; next < row.end_chainage_m - 1e-6; next += intervalM) push(next);
push(row.end_chainage_m);
/** 선마다 조각이 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<Vertex>, startM: number): Vertex[] {
if (line.length < 2 || !(startM > 0)) return [...line];
const [a, b] = line;
const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
const back: Vertex = [
a[0] - ((b[0] - a[0]) / length) * startM,
a[1] - ((b[1] - a[1]) / length) * startM,
];
return [back, ...line];
}
/** 누가가 든 연도 행(경계는 앞 연도) — 노선 있는 연도만 · 밖이면 null. */
export function rowAt(rows: ReadonlyArray<ChainRow>, chainageM: number): ChainRow | null {
return (
rows.find(
(row) =>
row.mode &&
chainageM >= row.start_chainage_m - 1e-6 &&
chainageM <= row.end_chainage_m + 1e-6,
) ?? null
/** 선 위 `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<number>,
nodes: ReadonlyArray<Vertex>,
): Array<{ year: number; start: Vertex; end: Vertex }> {
return years.map((year, i) => ({ year, start: nodes[i], end: nodes[i + 1] }));
/** 조각 연도 바꿈. */
export 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<Vertex>,
boxes: ReadonlyArray<Box | null>,
reachM: number = PLAN_REACH_M,
): boolean[] {
const inside = (point: Vertex, box: Box | null): boolean =>
!!box &&
point[0] >= box.x0 + reachM &&
point[0] <= box.x1 - reachM &&
point[1] >= box.y0 + reachM &&
point[1] <= box.y1 - reachM;
return nodes.map((point, i) => {
const years = [i - 1, i].filter((year) => year >= 0 && year < boxes.length);
return years.every((year) => inside(point, boxes[year]));
});
/** 선 끝에 점을 더함. */
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<Vertex>, b: ReadonlyArray<Vertex>): 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<PlanPiece>,
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}`;
}
+77 -73
View File
@@ -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),
]
-187
View File
@@ -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"]
+11 -4
View File
@@ -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 # 빌림 끊음