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:
@@ -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" },
|
||||
|
||||
@@ -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)) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 # 빌림 끊음
|
||||
|
||||
Reference in New Issue
Block a user