Merge remote-tracking branches 'origin/main_desktop_1', 'origin/sub_laptop_5', 'origin/sub_laptop_6' and 'origin/sub_laptop_8' into sub_laptop_3
This commit is contained in:
@@ -174,6 +174,9 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `b06:rock-boundary-default:${p}`,
|
||||
},
|
||||
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
|
||||
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
|
||||
"routeedit-initial": { bucket: "result", scope: "project" },
|
||||
} as const satisfies Record<string, StateEntry>;
|
||||
|
||||
export type StateName = keyof typeof STATE_REGISTRY;
|
||||
|
||||
@@ -76,7 +76,11 @@ export interface RouteReplanResponse {
|
||||
vertex_count: number;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
|
||||
export async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -121,13 +125,15 @@ export interface RouteReplanVertex {
|
||||
export async function replanRoute(
|
||||
projectId: string,
|
||||
vertices: Array<[number, number]> | RouteReplanVertex[],
|
||||
/** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<RouteReplanResponse> {
|
||||
const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) =>
|
||||
Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex,
|
||||
);
|
||||
return requestJson<RouteReplanResponse>(
|
||||
`/projects/${projectId}/route/replan`,
|
||||
{ method: "POST", body: JSON.stringify({ vertices: payload }) },
|
||||
{ method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) },
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Api_RouteInitial.ts
|
||||
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
|
||||
*
|
||||
* POST /projects/{id}/route/initial { logic, weights? } → 노드 · 비교값 · 공통 제약 기준값
|
||||
*
|
||||
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
|
||||
* 받은 비교값·기준값을 보이기만 한다. 서버 길은 sub3 몫이라 모양이 오면 `normalizeRouteInitial`
|
||||
* (`_RouteEdit_Initial_Model`) 한 곳만 맞춘다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { requestJson } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
|
||||
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */
|
||||
export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted";
|
||||
|
||||
/** 비중 계산의 세 비중(%) — 합 100. */
|
||||
export interface RouteInitialWeights {
|
||||
expected: number;
|
||||
earthwork: number;
|
||||
slope: number;
|
||||
}
|
||||
|
||||
export interface RouteInitialRequest {
|
||||
logic: RouteInitialLogic;
|
||||
weights?: RouteInitialWeights;
|
||||
}
|
||||
|
||||
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
|
||||
export const ROUTE_INITIAL_MISSING =
|
||||
"초기노선 갈래 서버 길이 아직 없습니다(계획서 20 · sub3 대기).";
|
||||
|
||||
/** 로직 하나로 초기노선을 묻는다. 답의 모양은 `normalizeRouteInitial` 이 편다. */
|
||||
export async function fetchRouteInitial(
|
||||
projectId: string,
|
||||
request: RouteInitialRequest,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await requestJson<unknown>(
|
||||
`/projects/${projectId}/route/initial`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
INITIAL_TIMEOUT_MS,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/HTTP 40[45]|Not Found|Method Not Allowed/.test(message)) {
|
||||
throw new Error(ROUTE_INITIAL_MISSING);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
* 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다.
|
||||
* 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이
|
||||
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
|
||||
*
|
||||
* 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py`
|
||||
* `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
@@ -75,8 +78,31 @@ function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||||
}
|
||||
|
||||
/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소.
|
||||
* 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */
|
||||
/** 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
* 서버 `common_util_drainage_pipes.CARRY_TOLERANCE_M` 와 짝. */
|
||||
export const CARRY_TOLERANCE_M = 0.05;
|
||||
|
||||
/** 관 측점마다 속성을 가져올 원본 번호(못 붙이면 null) — 허용 오차 안 가장 가까운 원본 ·
|
||||
* 한 원본은 한 번만. 서버 `carry_indices` 와 짝(거울 시험 test_b05_facility_carry). */
|
||||
export function carryIndices(
|
||||
targets: readonly number[],
|
||||
sources: readonly number[],
|
||||
): Array<number | null> {
|
||||
const free = new Set(sources.keys());
|
||||
return targets.map((target) => {
|
||||
let best: number | null = null;
|
||||
for (const index of free) {
|
||||
const gap = Math.abs(sources[index] - target);
|
||||
if (gap <= CARRY_TOLERANCE_M && (best === null || gap < Math.abs(sources[best] - target)))
|
||||
best = index;
|
||||
}
|
||||
if (best !== null) free.delete(best);
|
||||
return best;
|
||||
});
|
||||
}
|
||||
|
||||
/** 재계산 왕복에서 시설 정보를 잃지 않게 측점으로 되붙이는 보관소 — 붙이기는 `carryIndices`
|
||||
* (서버 `carry_facility_attributes` 와 같은 규칙) · 관을 옮기면 `move` · `follow` 가 속성을 옮김. */
|
||||
export function createFacilityStore() {
|
||||
let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = [];
|
||||
|
||||
@@ -114,22 +140,29 @@ export function createFacilityStore() {
|
||||
const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005);
|
||||
return hit ? hit.attributes : null;
|
||||
},
|
||||
/** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음)
|
||||
* 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */
|
||||
/** 옮긴 관의 속성을 새 측점으로 옮긴다(지도에서 끌기). */
|
||||
move(fromM: number, toM: number): void {
|
||||
const hit = entries.find((entry) => Math.abs(entry.chainage - fromM) <= 0.005);
|
||||
if (hit) hit.chainage = toM;
|
||||
},
|
||||
/** 관 목록이 통째로 바뀔 때(되돌리기 · 구조물 폼) — 사라진 관 하나 · 새 관 하나면 옮긴 것으로
|
||||
* 보고 속성을 따라 옮김. 둘 이상이 한꺼번에 바뀌면 옮기지 않음(제자리 관만 속성 유지). */
|
||||
follow(before: readonly number[], after: readonly number[]): void {
|
||||
const near = (list: readonly number[], value: number): boolean =>
|
||||
list.some((other) => Math.abs(other - value) <= CARRY_TOLERANCE_M);
|
||||
const gone = before.filter((value) => !near(after, value));
|
||||
const added = after.filter((value) => !near(before, value));
|
||||
if (gone.length === 1 && added.length === 1) this.move(gone[0], added[0]);
|
||||
},
|
||||
/** 요청 목록에 시설 정보를 되붙인다 — 허용 오차 안의 원본만 · 못 붙인 관은 속성 없음. */
|
||||
attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] {
|
||||
const pool = [...entries];
|
||||
return points.map((point) => {
|
||||
if (!pool.length) return { ...point };
|
||||
let best = 0;
|
||||
for (let index = 1; index < pool.length; index += 1) {
|
||||
if (
|
||||
Math.abs(pool[index].chainage - point.chainage_m) <
|
||||
Math.abs(pool[best].chainage - point.chainage_m)
|
||||
)
|
||||
best = index;
|
||||
}
|
||||
const [entry] = pool.splice(best, 1);
|
||||
return { ...point, ...entry.attributes };
|
||||
const picks = carryIndices(
|
||||
points.map((point) => point.chainage_m),
|
||||
entries.map((entry) => entry.chainage),
|
||||
);
|
||||
return points.map((point, index) => {
|
||||
const pick = picks[index];
|
||||
return pick === null ? { ...point } : { ...point, ...entries[pick].attributes };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +143,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
rememberPipes();
|
||||
void analyze();
|
||||
},
|
||||
(fromM, toM) => facilityStore.move(fromM, toM),
|
||||
);
|
||||
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
|
||||
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
|
||||
@@ -588,8 +589,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
selectBasin(picked ? picked.index : null, false);
|
||||
},
|
||||
setPipeChainages(chainages) {
|
||||
const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m);
|
||||
const next = reconcilePipes(pipeEditor.pipes(), chainages);
|
||||
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
|
||||
// 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감
|
||||
facilityStore.follow(
|
||||
before,
|
||||
next.map((pipe) => pipe.chainage_m),
|
||||
);
|
||||
pipeEditor.setPipes(next);
|
||||
void analyze();
|
||||
},
|
||||
|
||||
@@ -56,10 +56,12 @@ export interface PipeEditor {
|
||||
}
|
||||
|
||||
/** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료).
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). */
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
|
||||
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
|
||||
export function createPipeEditor(
|
||||
onChange: () => void,
|
||||
onCommit: () => void = () => {},
|
||||
onMove: (fromM: number, toM: number) => void = () => {},
|
||||
): PipeEditor {
|
||||
let meta: VWorldMeta | null = null;
|
||||
let route: Array<{ x: number; y: number; chainage: number }> = [];
|
||||
@@ -68,6 +70,7 @@ export function createPipeEditor(
|
||||
let selectedIndex: number | null = null;
|
||||
let draggingIndex: number | null = null;
|
||||
let dragMoved = false;
|
||||
let dragFrom = 0;
|
||||
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
|
||||
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
|
||||
let dragDownAt: { x: number; y: number } | null = null;
|
||||
@@ -250,6 +253,7 @@ export function createPipeEditor(
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
|
||||
selectedIndex = i;
|
||||
draggingIndex = i;
|
||||
dragFrom = pipeList[i].chainage_m;
|
||||
dragDownAt = { x: screenX, y: screenY };
|
||||
onChange();
|
||||
return true;
|
||||
@@ -276,9 +280,11 @@ export function createPipeEditor(
|
||||
},
|
||||
handleUp() {
|
||||
if (draggingIndex === null) return false;
|
||||
const dragged = pipeList[draggingIndex];
|
||||
draggingIndex = null;
|
||||
dragDownAt = null;
|
||||
if (dragMoved) {
|
||||
onMove(dragFrom, dragged.chainage_m);
|
||||
sortPipes();
|
||||
onChange();
|
||||
// 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다.
|
||||
|
||||
@@ -171,15 +171,11 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
// ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야
|
||||
// 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는
|
||||
// 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다.
|
||||
// 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄.
|
||||
if (latest?.route?.id != null) {
|
||||
const routeId = latest.route.id;
|
||||
await flushCulvertOptions(
|
||||
projectId,
|
||||
stateKey("culvertopt", projectId, routeId),
|
||||
stateKey("culvertmove", projectId, routeId),
|
||||
stateKey("culvertopt", projectId, latest.route.id),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
|
||||
@@ -104,10 +104,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
|
||||
function applyIrregularStations(stations: IrregularStation[]): void {
|
||||
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 지운다.
|
||||
// 서버가 관 측점을 0.01m 로 반올림해 돌려주므로 0.005m 안의 새 측점은 같은 측점 — 편집을 옮김.
|
||||
const nextKeys = new Set(stations.map((station) => station.chainage_m.toFixed(3)));
|
||||
const oldKeys = new Set(irregularStations.map((station) => station.chainage_m.toFixed(3)));
|
||||
irregularStations
|
||||
.filter((station) => !nextKeys.has(station.chainage_m.toFixed(3)))
|
||||
.forEach((station) => deps.profilePanel().resetStationEdit(station.chainage_m));
|
||||
.forEach((station) => {
|
||||
const same = stations.find(
|
||||
(next) =>
|
||||
Math.abs(next.chainage_m - station.chainage_m) < 0.006 &&
|
||||
!oldKeys.has(next.chainage_m.toFixed(3)),
|
||||
);
|
||||
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
|
||||
});
|
||||
irregularStations = stations;
|
||||
deps.renderStationLines();
|
||||
deps.profilePanel().setIrregularStations(withoutFordPavement(stations));
|
||||
|
||||
@@ -70,7 +70,8 @@ export interface ProfileEditStore {
|
||||
restoreSaved(): void;
|
||||
/** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */
|
||||
canRestoreSaved(): boolean;
|
||||
resetStation(chainageM: number): void;
|
||||
/** `moveTo` 를 주면 지우지 않고 그 측점으로 옮김(서버 반올림으로 측점이 조금 바뀐 때). */
|
||||
resetStation(chainageM: number, moveTo?: number): void;
|
||||
resetAll(): void;
|
||||
/** 아직 서버에 반영되지 않은 변경이 있는가. */
|
||||
dirty(): boolean;
|
||||
@@ -184,10 +185,18 @@ export function createProfileEditStore(
|
||||
onChange();
|
||||
},
|
||||
canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline),
|
||||
resetStation(chainageM) {
|
||||
resetStation(chainageM, moveTo) {
|
||||
const key = chainageKey(chainageM);
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
const curveRadii = { ...current.curve_radii };
|
||||
if (moveTo !== undefined) {
|
||||
const to = chainageKey(moveTo);
|
||||
if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key];
|
||||
if (curveRadii[key] !== undefined) curveRadii[to] = curveRadii[key];
|
||||
delete stationOffsets[key];
|
||||
delete curveRadii[key];
|
||||
return commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
|
||||
}
|
||||
delete stationOffsets[key];
|
||||
// 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면
|
||||
// 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23).
|
||||
|
||||
@@ -780,11 +780,11 @@ export function createRouteProfilePanel(
|
||||
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
|
||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||
*/
|
||||
resetStationEdit(chainageM: number) {
|
||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||
const key = chainageKey(chainageM);
|
||||
const edits = store.edits();
|
||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||||
store.resetStation(chainageM);
|
||||
store.resetStation(chainageM, moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
|
||||
*
|
||||
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
|
||||
* [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산).
|
||||
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
|
||||
*
|
||||
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
|
||||
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import {
|
||||
bindNodeSplice,
|
||||
bindRouteEditNavigation,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
viewPxPerMeter,
|
||||
} from "./B05_Profile_UI_RouteEdit_View";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
@@ -93,7 +95,7 @@ const CONTOUR_LEVEL_BUDGET = 1200;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
||||
/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
||||
export interface RouteEditOptions {
|
||||
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
|
||||
stationIntervalM?: number;
|
||||
@@ -227,8 +229,16 @@ export async function openRouteEditModal(
|
||||
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close);
|
||||
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
|
||||
const initial = createInitialPanel({
|
||||
overlay,
|
||||
projectId,
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
});
|
||||
const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다.
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel);
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel);
|
||||
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
|
||||
|
||||
function resize(): void {
|
||||
@@ -412,19 +422,16 @@ export async function openRouteEditModal(
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
||||
}
|
||||
|
||||
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
|
||||
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
|
||||
applyEdit(message, record);
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
overlay,
|
||||
getHistory: () => history,
|
||||
restore: (snapshot, message) => {
|
||||
planned = snapshot.planned;
|
||||
curveOn = snapshot.curveOn;
|
||||
curveRadius = snapshot.curveRadius;
|
||||
curveLock = snapshot.curveLock;
|
||||
curveArc = snapshot.curveArc;
|
||||
apexLock = snapshot.apexLock;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
restore: (snapshot, message) => restoreSnapshot(snapshot, message, false),
|
||||
});
|
||||
|
||||
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
@@ -592,28 +599,22 @@ export async function openRouteEditModal(
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const segment = segmentAt(px, py);
|
||||
if (segment < 0) return;
|
||||
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
|
||||
if (index < 0) return;
|
||||
if (planned.length <= 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
spliceNode(snapshotNow(), index);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
bindNodeSplice({
|
||||
canvas,
|
||||
unrotate: rotation.unrotate,
|
||||
segmentAt,
|
||||
nodeAt,
|
||||
insert: (segment, px, py) => {
|
||||
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
},
|
||||
remove: (index) => {
|
||||
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
spliceNode(snapshotNow(), index);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
},
|
||||
});
|
||||
|
||||
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
|
||||
@@ -634,6 +635,8 @@ export async function openRouteEditModal(
|
||||
busy,
|
||||
projectId,
|
||||
nodes: () => ({ planned, curveOn, curveRadius }),
|
||||
changed: () => history?.isDirty() ?? false,
|
||||
initial,
|
||||
close,
|
||||
onApplied,
|
||||
});
|
||||
@@ -655,18 +658,13 @@ export async function openRouteEditModal(
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
planned = flat.planned;
|
||||
nodeInfo = flat.nodes;
|
||||
curveInfo = flat.curves;
|
||||
curveOn = flat.curveOn;
|
||||
curveRadius = flat.curveRadius;
|
||||
curveLock = flat.curveLock;
|
||||
curveArc = flat.curveArc;
|
||||
apexLock = flat.apexLock;
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat);
|
||||
({ nodes: nodeInfo, curves: curveInfo } = flat);
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
historyControls.sync();
|
||||
initial.ready(snapshotNow(), plan.edited);
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Apply.ts
|
||||
* 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시.
|
||||
* 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시.
|
||||
*
|
||||
* 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면
|
||||
* 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`).
|
||||
*
|
||||
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
|
||||
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
|
||||
@@ -9,7 +12,7 @@
|
||||
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import { replanRoute } from "./B05_Profile_Api_Replan";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -20,12 +23,16 @@ export interface RouteApplyParams {
|
||||
projectId: string;
|
||||
/** 지금 편집값 — 누른 순간에 읽는다. */
|
||||
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
|
||||
changed: () => boolean;
|
||||
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: { current: () => unknown; keep: () => void };
|
||||
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
|
||||
close: () => void;
|
||||
onApplied: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
|
||||
/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */
|
||||
export function bindRouteApply(params: RouteApplyParams): void {
|
||||
const { overlay, busy, projectId } = params;
|
||||
|
||||
@@ -46,6 +53,7 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다.
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
params.close();
|
||||
await params.onApplied();
|
||||
@@ -63,6 +71,11 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
if (!params.changed()) {
|
||||
showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info");
|
||||
params.close();
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
@@ -72,10 +85,8 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
{ initial: params.initial.current() },
|
||||
),
|
||||
);
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,8 +52,9 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>연 상태로</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
|
||||
title="마지막에 적용한 초기값으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial.ts
|
||||
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화]
|
||||
* · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
|
||||
*
|
||||
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
|
||||
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
|
||||
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
|
||||
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
|
||||
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchRouteInitial,
|
||||
ROUTE_INITIAL_MISSING,
|
||||
type RouteInitialLogic,
|
||||
type RouteInitialWeights,
|
||||
} from "./B05_Profile_Api_RouteInitial";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import {
|
||||
choiceLabel,
|
||||
criteriaLine,
|
||||
DEFAULT_WEIGHTS,
|
||||
metricsLine,
|
||||
normalizeRouteInitial,
|
||||
readInitialChoice,
|
||||
rebalanceWeights,
|
||||
WEIGHT_KEYS,
|
||||
writeInitialChoice,
|
||||
type InitialChoice,
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
|
||||
const WEIGHT_NAME: Record<WeightKey, string> = {
|
||||
expected: "예상",
|
||||
earthwork: "토공",
|
||||
slope: "기울기",
|
||||
};
|
||||
|
||||
export interface InitialPanelParams {
|
||||
overlay: HTMLElement;
|
||||
projectId: string;
|
||||
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
|
||||
planMinRadiusM: () => number;
|
||||
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
|
||||
apply: (snapshot: RouteEditSnapshot, message: string) => void;
|
||||
}
|
||||
|
||||
export interface InitialPanel {
|
||||
/** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */
|
||||
ready: (opening: RouteEditSnapshot, edited: boolean) => void;
|
||||
/** [확인]에 실어 보낼 지금 초기값. */
|
||||
current: () => { logic: RouteInitialLogic; weights: RouteInitialWeights | null };
|
||||
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
|
||||
cancel: () => void;
|
||||
/** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */
|
||||
keep: () => void;
|
||||
}
|
||||
|
||||
const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
|
||||
JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot;
|
||||
|
||||
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const { overlay, projectId } = params;
|
||||
const box = document.createElement("div");
|
||||
box.className = "b05-routeedit__initial";
|
||||
box.innerHTML = `
|
||||
<div class="b05-routeedit__initial-row">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-logic="earthwork" disabled
|
||||
title="절토 · 성토가 가장 적은 노선을 서버가 셈해 초기값으로 싣습니다">토공 최소</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-logic="gentle" disabled
|
||||
title="종단기울기가 가장 순한 노선을 서버가 셈해 초기값으로 싣습니다">기울기 순한 노선</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="weights-open" disabled
|
||||
aria-expanded="false" title="예상 · 토공 · 기울기 비중(합 100)으로 셈합니다">비중 계산</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__weights" hidden>
|
||||
${WEIGHT_KEYS.map(
|
||||
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
|
||||
<output data-weight-out="${key}"></output></label>`,
|
||||
).join("")}
|
||||
<div class="b05-routeedit__weights-foot">
|
||||
<span data-weight-sum></span>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-logic="weighted">계산</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__initial-now"></div>
|
||||
<div class="b05-routeedit__initial-line" data-line="metrics"></div>
|
||||
<div class="b05-routeedit__initial-line" data-line="criteria"></div>`;
|
||||
overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box);
|
||||
|
||||
const nowLine = box.querySelector<HTMLElement>(".b05-routeedit__initial-now")!;
|
||||
const metricsEl = box.querySelector<HTMLElement>('[data-line="metrics"]')!;
|
||||
const criteriaEl = box.querySelector<HTMLElement>('[data-line="criteria"]')!;
|
||||
const weightsBox = box.querySelector<HTMLElement>(".b05-routeedit__weights")!;
|
||||
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
|
||||
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
|
||||
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
|
||||
|
||||
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
|
||||
const openingChoice = readInitialChoice(projectId);
|
||||
let choice: InitialChoice | null = openingChoice;
|
||||
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
|
||||
let opening: RouteEditSnapshot | null = null;
|
||||
let edited = false;
|
||||
let busy = false;
|
||||
|
||||
function render(): void {
|
||||
const logic = choice?.logic ?? "follow";
|
||||
nowLine.textContent = `현재 초기값: ${choice ? choiceLabel(logic, choice.weights) : choiceLabel("follow", null)}`;
|
||||
metricsEl.textContent =
|
||||
choice && !choice.metrics
|
||||
? "비교값 — 서버 답에 없습니다."
|
||||
: metricsLine(choice?.metrics ?? null);
|
||||
criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM());
|
||||
box.dataset.logic = logic;
|
||||
for (const key of WEIGHT_KEYS) {
|
||||
box.querySelector<HTMLInputElement>(`[data-weight="${key}"]`)!.value = String(weights[key]);
|
||||
box.querySelector<HTMLOutputElement>(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`;
|
||||
}
|
||||
box.querySelector<HTMLElement>("[data-weight-sum]")!.textContent =
|
||||
`합 ${weights.expected + weights.earthwork + weights.slope}`;
|
||||
buttons.forEach((button) => {
|
||||
button.disabled = busy || !opening;
|
||||
});
|
||||
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
|
||||
}
|
||||
|
||||
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
|
||||
async function run(logic: RouteInitialLogic, allowFallback = false): Promise<void> {
|
||||
if (busy || !opening) return;
|
||||
busy = true;
|
||||
const sent = logic === "weighted" ? { ...weights } : null;
|
||||
render();
|
||||
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
|
||||
try {
|
||||
const response = await fetchRouteInitial(projectId, {
|
||||
logic,
|
||||
...(sent ? { weights: sent } : {}),
|
||||
});
|
||||
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
|
||||
choice = normalizeRouteInitial(response, logic, sent);
|
||||
writeInitialChoice(projectId, choice);
|
||||
params.apply(
|
||||
copy(choice.snapshot),
|
||||
`초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
|
||||
// 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다.
|
||||
if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited) {
|
||||
params.apply(
|
||||
copy(opening),
|
||||
"초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
|
||||
);
|
||||
} else {
|
||||
showToast(message, "error");
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
if (box.isConnected) render();
|
||||
}
|
||||
}
|
||||
|
||||
box.querySelectorAll<HTMLButtonElement>("[data-logic]").forEach((button) => {
|
||||
button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic));
|
||||
});
|
||||
openWeights.addEventListener("click", () => {
|
||||
weightsBox.hidden = !weightsBox.hidden;
|
||||
openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden));
|
||||
});
|
||||
box.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value));
|
||||
render();
|
||||
});
|
||||
});
|
||||
// [초기화] — 마지막에 적용한 초기값으로. 아직 없으면 예상노선 추종을 서버에 묻는다.
|
||||
resetButton.addEventListener("click", () => {
|
||||
if (!choice) {
|
||||
void run("follow", true);
|
||||
return;
|
||||
}
|
||||
params.apply(
|
||||
copy(choice.snapshot),
|
||||
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
|
||||
);
|
||||
});
|
||||
render();
|
||||
|
||||
return {
|
||||
ready(snapshot, wasEdited) {
|
||||
opening = copy(snapshot);
|
||||
edited = wasEdited;
|
||||
render();
|
||||
},
|
||||
current: () => ({ logic: choice?.logic ?? "follow", weights: choice?.weights ?? null }),
|
||||
cancel: () => writeInitialChoice(projectId, openingChoice),
|
||||
keep: () => writeInitialChoice(projectId, choice),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial_Model.ts
|
||||
* 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시.
|
||||
* 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial";
|
||||
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
|
||||
follow: "예상노선 추종",
|
||||
earthwork: "토공 최소",
|
||||
gentle: "기울기 순한 노선",
|
||||
weighted: "비중 계산",
|
||||
};
|
||||
|
||||
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
|
||||
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 };
|
||||
export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] as const;
|
||||
export type WeightKey = (typeof WEIGHT_KEYS)[number];
|
||||
|
||||
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
|
||||
export function rebalanceWeights(
|
||||
weights: RouteInitialWeights,
|
||||
key: WeightKey,
|
||||
value: number,
|
||||
): RouteInitialWeights {
|
||||
const fixed = Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
|
||||
const [first, second] = WEIGHT_KEYS.filter((name) => name !== key);
|
||||
const rest = 100 - fixed;
|
||||
const pool = weights[first] + weights[second];
|
||||
const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2);
|
||||
const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare };
|
||||
return { expected: out.expected, earthwork: out.earthwork, slope: out.slope };
|
||||
}
|
||||
|
||||
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
|
||||
export interface InitialMetrics {
|
||||
lengthM: number | null;
|
||||
maxGradePct: number | null;
|
||||
meanGradePct: number | null;
|
||||
cutM3: number | null;
|
||||
fillM3: number | null;
|
||||
overLimitCount: number | null;
|
||||
offsetM: number | null;
|
||||
}
|
||||
|
||||
/** 공통 제약 기준값 — 서버가 준 값. `confirmed` 가 거짓이면 지식DB 후보(사용자 확정 대기). */
|
||||
export interface InitialCriteria {
|
||||
maxGradePct: number | null;
|
||||
minRadiusM: number | null;
|
||||
streamOffsetM: number | null;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
|
||||
export interface InitialChoice {
|
||||
logic: RouteInitialLogic;
|
||||
weights: RouteInitialWeights | null;
|
||||
snapshot: RouteEditSnapshot;
|
||||
metrics: InitialMetrics | null;
|
||||
criteria: InitialCriteria | null;
|
||||
}
|
||||
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */
|
||||
function pick(source: Record<string, unknown>, ...names: string[]): number | null {
|
||||
for (const name of names) {
|
||||
const value = num(source[name]);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */
|
||||
function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
|
||||
if (Array.isArray(curves) && curves.length) {
|
||||
const flat = flattenServerPlan(nodes as never, curves as never);
|
||||
return { ...flat, picked: -1 };
|
||||
}
|
||||
const rows = nodes as Array<Record<string, unknown> | Vertex>;
|
||||
const planned = rows.map((row): Vertex =>
|
||||
Array.isArray(row) ? [row[0], row[1]] : [Number(row.x), Number(row.y)],
|
||||
);
|
||||
return {
|
||||
planned,
|
||||
curveOn: rows.map((row) => (Array.isArray(row) ? true : row.curve !== false)),
|
||||
curveRadius: rows.map((row) => (Array.isArray(row) ? null : num(row.radius_m))),
|
||||
curveLock: planned.map(() => null),
|
||||
curveArc: planned.map(() => null),
|
||||
apexLock: planned.map(() => false),
|
||||
picked: -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */
|
||||
export function normalizeRouteInitial(
|
||||
response: unknown,
|
||||
logic: RouteInitialLogic,
|
||||
weights: RouteInitialWeights | null,
|
||||
): InitialChoice {
|
||||
const body = (response ?? {}) as Record<string, unknown>;
|
||||
const nodes = Array.isArray(body.nodes)
|
||||
? body.nodes
|
||||
: Array.isArray(body.vertices)
|
||||
? body.vertices
|
||||
: [];
|
||||
const snapshot = snapshotOf(nodes, body.curves);
|
||||
if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) {
|
||||
throw new Error("서버가 낸 초기노선에 노드가 모자랍니다.");
|
||||
}
|
||||
const m = (body.metrics ?? {}) as Record<string, unknown>;
|
||||
const c = (body.criteria ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
logic,
|
||||
weights,
|
||||
snapshot,
|
||||
metrics: body.metrics
|
||||
? {
|
||||
lengthM: pick(m, "length_m", "total_length_m"),
|
||||
maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"),
|
||||
meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"),
|
||||
cutM3: pick(m, "cut_m3", "cut_volume_m3"),
|
||||
fillM3: pick(m, "fill_m3", "fill_volume_m3"),
|
||||
overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"),
|
||||
offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"),
|
||||
}
|
||||
: null,
|
||||
criteria: body.criteria
|
||||
? {
|
||||
maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"),
|
||||
minRadiusM: pick(c, "min_radius_m"),
|
||||
streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"),
|
||||
confirmed: c.confirmed === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
|
||||
export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string {
|
||||
if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic];
|
||||
return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.slope}`;
|
||||
}
|
||||
|
||||
const fixed = (value: number | null, digits: number, unit: string): string =>
|
||||
value === null
|
||||
? "—"
|
||||
: `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`;
|
||||
|
||||
/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */
|
||||
export function metricsLine(metrics: InitialMetrics | null): string {
|
||||
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
|
||||
return [
|
||||
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
|
||||
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
|
||||
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
|
||||
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
|
||||
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */
|
||||
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
|
||||
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
|
||||
const parts = [
|
||||
`종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`,
|
||||
`최소 R ${fixed(radius, 0, "m")}`,
|
||||
`계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`,
|
||||
];
|
||||
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
|
||||
return `공통 제약 ${parts.join(" · ")}${tail}`;
|
||||
}
|
||||
|
||||
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */
|
||||
export function readInitialChoice(projectId: string): InitialChoice | null {
|
||||
const saved = readState<InitialChoice>("routeedit-initial", projectId);
|
||||
if (!saved || !saved.snapshot || !Array.isArray(saved.snapshot.planned)) return null;
|
||||
if (!(saved.logic in LOGIC_LABEL) || saved.snapshot.planned.length < 2) return null;
|
||||
return saved;
|
||||
}
|
||||
|
||||
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
|
||||
writeState("routeedit-initial", choice, projectId);
|
||||
}
|
||||
@@ -104,6 +104,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
canvas.addEventListener("pointercancel", stop);
|
||||
}
|
||||
|
||||
export interface NodeSpliceParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** 돌린 지도 보정 — 화면 px 를 돌리기 전 그림 px 로. */
|
||||
unrotate: (px: number, py: number) => [number, number];
|
||||
segmentAt: (px: number, py: number) => number;
|
||||
nodeAt: (px: number, py: number) => number;
|
||||
/** 선(`segment` 번째 구간) 위 그림 px 자리에 노드를 끼운다. */
|
||||
insert: (segment: number, px: number, py: number) => void;
|
||||
/** `index` 번째 노드를 뺀다. */
|
||||
remove: (index: number) => void;
|
||||
}
|
||||
|
||||
/** 선 두 번 클릭 = 노드 추가 · 노드 오른쪽 클릭 = 삭제. 리스너는 캔버스와 수명이 같다. */
|
||||
export function bindNodeSplice(params: NodeSpliceParams): void {
|
||||
const { canvas } = params;
|
||||
const at = (event: MouseEvent): [number, number] => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
};
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
const [px, py] = at(event);
|
||||
const segment = params.segmentAt(px, py);
|
||||
if (segment >= 0) params.insert(segment, px, py);
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const index = params.nodeAt(...at(event));
|
||||
if (index >= 0) params.remove(index);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */
|
||||
|
||||
export type ScreenOf = (point: [number, number]) => [number, number];
|
||||
|
||||
@@ -178,11 +178,18 @@ export function stationFields(
|
||||
// 다른 change 리스너(옵션 잠금·임시 배치)보다 먼저 등록돼 정규화된 값이 읽힌다.
|
||||
[station, remainder].forEach((input) => input.addEventListener("change", normalize));
|
||||
}
|
||||
// 칸은 잔여를 소수 1자리로 보임 — 사용자가 칸을 안 고쳤으면 실어 둔 누가거리를 그대로 돌려줌
|
||||
// (표시값을 다시 읽으면 0.1m 로 뭉개져 옵션 · 메모만 고쳐도 측점이 옮겨 저장됨)
|
||||
let written: { chainageM: number; station: string; remainder: string } | null = null;
|
||||
return {
|
||||
wrap,
|
||||
station,
|
||||
remainder,
|
||||
read(intervalM, required) {
|
||||
if (written && station.value === written.station && remainder.value === written.remainder) {
|
||||
markInvalid(false);
|
||||
return written.chainageM;
|
||||
}
|
||||
const stationText = station.value.trim();
|
||||
const remainderText = remainder.value.trim();
|
||||
if (!stationText && !remainderText) {
|
||||
@@ -197,6 +204,7 @@ export function stationFields(
|
||||
return valid ? stationNo * intervalM + rest : null;
|
||||
},
|
||||
write(chainageM, intervalM) {
|
||||
written = null;
|
||||
if (chainageM === null) {
|
||||
station.value = "";
|
||||
remainder.value = "";
|
||||
@@ -205,6 +213,7 @@ export function stationFields(
|
||||
const parts = chainageToStation(chainageM, intervalM);
|
||||
station.value = String(parts.station);
|
||||
remainder.value = parts.remainder.toFixed(1);
|
||||
written = { chainageM, station: station.value, remainder: remainder.value };
|
||||
},
|
||||
clearInvalid: () => markInvalid(false),
|
||||
};
|
||||
|
||||
@@ -125,6 +125,78 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 초기노선 갈래(계획서 20) — 회전 단추 아래 오른쪽. 로직 단추 · 비중 · 현재 초기값 · 비교값. */
|
||||
.b05-routeedit__initial {
|
||||
position: absolute;
|
||||
top: calc(var(--spacing-12) * 2 + 34px);
|
||||
right: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: min(420px, 44%);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-row .ui-btn {
|
||||
padding: 4px var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-row [aria-expanded="true"] {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routeedit__weights {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b05-routeedit__weights label {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5em 1fr 2.5em;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__weights output {
|
||||
text-align: right;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routeedit__weights-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.b05-routeedit__weights-foot .ui-btn {
|
||||
padding: 4px var(--spacing-12);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-now {
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-line {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.b05-routeedit__legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -58,50 +58,27 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
|
||||
* 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도
|
||||
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
||||
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
||||
* 옛 「관 이동 예약」(culvertmove)은 읽지 않음 — 관 위치는 B05·B06 이 같은 관 목록 스냅샷으로만
|
||||
* 옮김. 옛 세션 잔값을 저장 때 적용하면 관이 이웃 측점으로 튀어 시설 속성이 넘어감.
|
||||
*/
|
||||
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m).
|
||||
* ⚠ **이제 쓰는 곳은 없다**(2026-09-12 일원화: 관 목록은 B05·B06 이 같은 스냅샷을 쓴다).
|
||||
* 옛 세션에 남아 있던 예약을 저장 때 한 번 흘려보내려고 읽기만 남겨 둔다. */
|
||||
type PendingMoves = Record<string, number>;
|
||||
|
||||
function readMoves(sessionKey: string | null): PendingMoves {
|
||||
if (!sessionKey) return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(sessionKey);
|
||||
return raw ? (JSON.parse(raw) as PendingMoves) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushCulvertOptions(
|
||||
projectId: string | null,
|
||||
sessionKey: string | null,
|
||||
moveKey: string | null = null,
|
||||
): Promise<void> {
|
||||
const pending = readPending(sessionKey);
|
||||
const moves = readMoves(moveKey);
|
||||
if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return;
|
||||
if (!projectId || Object.keys(pending).length === 0) return;
|
||||
const current = await fetchDetailPipePoints(projectId);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const key = keyOf(point.chainage_m);
|
||||
const patch = pending[key];
|
||||
const movedTo = moves[key];
|
||||
if (!patch && movedTo === undefined) return input;
|
||||
const patch = pending[keyOf(point.chainage_m)];
|
||||
if (!patch) return input;
|
||||
touched = true;
|
||||
const next: DetailPipeInput = patch
|
||||
? { ...input, options: { ...(input.options ?? {}), ...patch } }
|
||||
: { ...input };
|
||||
// 기준 측점 이동 — 관 위치가 바뀌면 서버가 세부 배수유역을 다시 나눠 응답한다.
|
||||
if (movedTo !== undefined) next.chainage_m = movedTo;
|
||||
return next;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
});
|
||||
if (touched) await saveDetailPipePoints(projectId, points);
|
||||
writePending(sessionKey, {});
|
||||
writePending(moveKey, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +89,6 @@ export function createCulvertOptionWriter(
|
||||
projectId: () => string | null,
|
||||
sessionKey: () => string | null,
|
||||
onError?: (message: string) => void,
|
||||
/** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */
|
||||
moveKey: () => string | null = () => null,
|
||||
): CulvertOptionWriter {
|
||||
return {
|
||||
queue(chainageM, patch) {
|
||||
@@ -124,7 +99,7 @@ export function createCulvertOptionWriter(
|
||||
},
|
||||
async flush() {
|
||||
try {
|
||||
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
|
||||
await flushCulvertOptions(projectId(), sessionKey());
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
throw error;
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StationControlDeps {
|
||||
| "extrawall"
|
||||
| "extraspan"
|
||||
| "culvertopt"
|
||||
| "culvertmove"
|
||||
| "revetlink"
|
||||
| "fordadjust"
|
||||
| "boxadjust",
|
||||
@@ -429,7 +428,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
deps.projectId,
|
||||
() => deps.sessionKey("culvertopt"),
|
||||
deps.onSaveError,
|
||||
() => deps.sessionKey("culvertmove"),
|
||||
);
|
||||
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
|
||||
const linkDetached = linkSession.detached;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고,
|
||||
좌표가 없는 구 저장분만 버린다.
|
||||
|
||||
짝: 시설 속성 붙이기 `carry_indices` ↔ 화면 `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`
|
||||
`carryIndices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -352,21 +355,46 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
return points
|
||||
|
||||
|
||||
# 시설 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
# 화면 `CARRY_TOLERANCE_M` 과 짝.
|
||||
CARRY_TOLERANCE_M = 0.05
|
||||
|
||||
|
||||
def carry_indices(targets: list[float], sources: list[float]) -> list[int | None]:
|
||||
"""관 측점마다 속성을 가져올 원본 번호(못 붙이면 None).
|
||||
|
||||
허용 오차 안 가장 가까운 원본만 · 한 원본은 한 번만 — 화면 `carryIndices` 와 같은 규칙.
|
||||
"""
|
||||
free = list(range(len(sources)))
|
||||
picked: list[int | None] = []
|
||||
for target in targets:
|
||||
best: int | None = None
|
||||
for index in free:
|
||||
gap = abs(sources[index] - target)
|
||||
if gap <= CARRY_TOLERANCE_M and (best is None or gap < abs(sources[best] - target)):
|
||||
best = index
|
||||
if best is not None:
|
||||
free.remove(best)
|
||||
picked.append(best)
|
||||
return picked
|
||||
|
||||
|
||||
def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]:
|
||||
"""계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다.
|
||||
|
||||
세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다.
|
||||
그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세
|
||||
조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를
|
||||
되붙이는 `_retag`(B04 라우터)과 같은 기준이다.
|
||||
세부유역 계산기는 chainage만 다루므로(반올림 0.01m) 계산에서 돌아온 목록은 전부 기본
|
||||
배관이 된다. 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다.
|
||||
붙이기는 `carry_indices` — 허용 오차 밖의 관은 속성 없이 둔다(옆 관 속성을 가져가지 않음).
|
||||
"""
|
||||
if not reference:
|
||||
return base
|
||||
by_key = {round(ref.chainage_m, 2): ref for ref in reference}
|
||||
for point in base:
|
||||
ref = by_key.get(round(point.chainage_m, 2))
|
||||
if ref is None:
|
||||
ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m))
|
||||
picks = carry_indices(
|
||||
[point.chainage_m for point in base], [ref.chainage_m for ref in reference]
|
||||
)
|
||||
for point, pick in zip(base, picks):
|
||||
if pick is None:
|
||||
continue # 못 붙인 관 = 계산기가 돌려준 기본 배관 그대로(추천은 뒤에서 빈칸에만)
|
||||
ref = reference[pick]
|
||||
point.facility = ref.facility
|
||||
point.start_m = ref.start_m
|
||||
point.end_m = ref.end_m
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* 계획노선 편집 창 **초기노선 갈래**(계획서 20) 셈 단위검증 헬퍼 —
|
||||
* `B05_Profile_UI_RouteEdit_Initial_Model.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다.
|
||||
* 브라우저 전용인 저장소는 가짜로 채운다 — 셈만 본다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
|
||||
globalThis.fetch = () => Promise.resolve({ ok: false });
|
||||
|
||||
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
|
||||
const { DEFAULT_WEIGHTS } = model;
|
||||
|
||||
// ① 비중 맞추기 — 하나를 옮기면 나머지 둘이 비율대로 나눠 합 100.
|
||||
const moves = [
|
||||
model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70),
|
||||
model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40),
|
||||
model.rebalanceWeights({ expected: 100, earthwork: 0, slope: 0 }, "expected", 60),
|
||||
model.rebalanceWeights({ expected: 20, earthwork: 30, slope: 50 }, "slope", 100),
|
||||
model.rebalanceWeights({ expected: 33, earthwork: 33, slope: 34 }, "earthwork", 33.4),
|
||||
model.rebalanceWeights(DEFAULT_WEIGHTS, "slope", -5),
|
||||
];
|
||||
|
||||
// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다.
|
||||
const plain = model.normalizeRouteInitial(
|
||||
{
|
||||
nodes: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 10, curve: true, radius_m: 30 },
|
||||
{ x: 100, y: 0, curve: false },
|
||||
],
|
||||
metrics: {
|
||||
length_m: 1234.56,
|
||||
max_grade_pct: 12.34,
|
||||
mean_grade_pct: 6.1,
|
||||
cut_m3: 1500.4,
|
||||
fill_m3: 900,
|
||||
over_limit_count: 2,
|
||||
offset_mean_m: 3.25,
|
||||
},
|
||||
criteria: { max_grade_pct: 14, min_radius_m: 12, stream_offset_m: 5 },
|
||||
},
|
||||
"earthwork",
|
||||
null,
|
||||
);
|
||||
const planShape = model.normalizeRouteInitial(
|
||||
{
|
||||
nodes: [
|
||||
{ x: 0, y: 0, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
|
||||
{ x: 40, y: 30, radius_m: 25, inner_angle_deg: 140, tangent_m: 9, violations: [] },
|
||||
{ x: 90, y: 30, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
|
||||
],
|
||||
curves: [
|
||||
{
|
||||
apex: [40, 30],
|
||||
radius_m: 25,
|
||||
tangent_m: 9,
|
||||
inner_angle_deg: 140,
|
||||
start: [33, 25],
|
||||
end: [49, 30],
|
||||
node_first: 1,
|
||||
node_last: 1,
|
||||
violations: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"weighted",
|
||||
{ expected: 60, earthwork: 20, slope: 20 },
|
||||
);
|
||||
let tooShort = "";
|
||||
try {
|
||||
model.normalizeRouteInitial({ nodes: [{ x: 1, y: 1 }] }, "gentle", null);
|
||||
} catch (error) {
|
||||
tooShort = error.message;
|
||||
}
|
||||
|
||||
// ③ 글 — 현재 초기값 · 비교값 · 기준값.
|
||||
const labels = {
|
||||
follow: model.choiceLabel("follow", null),
|
||||
weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, slope: 20 }),
|
||||
metrics: model.metricsLine(plain.metrics),
|
||||
metricsNone: model.metricsLine(null),
|
||||
criteria: model.criteriaLine(plain.criteria, 0),
|
||||
criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0),
|
||||
criteriaFallback: model.criteriaLine(null, 15),
|
||||
};
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
model.writeInitialChoice("p-1", plain);
|
||||
const readBack = model.readInitialChoice("p-1");
|
||||
const other = model.readInitialChoice("p-2");
|
||||
const keys = [...session._map.keys()];
|
||||
session._map.set(
|
||||
keys[0],
|
||||
JSON.stringify({
|
||||
logic: "nope",
|
||||
snapshot: {
|
||||
planned: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const badLogic = model.readInitialChoice("p-1");
|
||||
model.writeInitialChoice("p-1", null);
|
||||
const cleared = model.readInitialChoice("p-1");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
moves,
|
||||
plain,
|
||||
planShape,
|
||||
tooShort,
|
||||
labels,
|
||||
readBack,
|
||||
other,
|
||||
keys,
|
||||
badLogic,
|
||||
cleared,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다.
|
||||
// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록
|
||||
// {op:"attach", entries:[관 응답], points:[{chainage_m, source}]} → attach 결과(DetailPipeInput[])
|
||||
// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값
|
||||
// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? ".");
|
||||
const { build } = await import(
|
||||
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
|
||||
);
|
||||
|
||||
// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다
|
||||
class Node {
|
||||
constructor() {
|
||||
this.value = "";
|
||||
this.children = [];
|
||||
this.classList = { toggle() {}, add() {}, remove() {} };
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.children.push(kid);
|
||||
return kid;
|
||||
}
|
||||
addEventListener() {}
|
||||
setAttribute() {}
|
||||
}
|
||||
const session = new Map();
|
||||
globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() };
|
||||
globalThis.window = {
|
||||
sessionStorage: {
|
||||
getItem: (k) => (session.has(k) ? session.get(k) : null),
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
},
|
||||
location: { origin: "http://localhost" },
|
||||
setTimeout: (...a) => setTimeout(...a),
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
};
|
||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
||||
globalThis.localStorage = globalThis.window.sessionStorage;
|
||||
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-"));
|
||||
try {
|
||||
await build({
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
root,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ui": path.join(root, "ui_template"),
|
||||
"@config": path.join(root, "config"),
|
||||
"@util": path.join(root, "common_util"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
lib: {
|
||||
entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "entry",
|
||||
},
|
||||
},
|
||||
});
|
||||
const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf-8"));
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
if (job.op === "attach") {
|
||||
const store = mod.createFacilityStore();
|
||||
store.replaceFromResponse(job.entries);
|
||||
results.push(store.attach(job.points));
|
||||
} else if (job.op === "station") {
|
||||
const fields = mod.stationFields("측점", () => job.interval);
|
||||
fields.write(job.chainage, job.interval);
|
||||
results.push(fields.read(job.interval, true));
|
||||
} else if (job.op === "culvert") {
|
||||
session.clear();
|
||||
session.set("opt", JSON.stringify(job.pending ?? {}));
|
||||
session.set("move", JSON.stringify(job.moves ?? {}));
|
||||
let sent = null;
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
const body = init.method === "PUT" ? JSON.parse(init.body) : null;
|
||||
if (body) sent = body.points;
|
||||
const payload = body ? { pipe_points: body.points } : { pipe_points: job.server };
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
await mod.flushCulvertOptions("p", "opt", "move");
|
||||
results.push(sent);
|
||||
} else {
|
||||
throw new Error(`모르는 작업 ${job.op}`);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* =============================================================================
|
||||
* helper_b05_save_payload_entry.ts
|
||||
* 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때
|
||||
* 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기.
|
||||
* ========================================================================== */
|
||||
|
||||
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
|
||||
export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
@@ -0,0 +1,100 @@
|
||||
/* 구조물 측점 왕복 단위검증 헬퍼 — `B05_Profile_UI_Structures_Fields.ts`(측점 칸)와
|
||||
* `B05_Profile_UI_Profile_Edit.ts`(계획고 편집 저장소)를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
* 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
// 측점 칸이 쓰는 만큼만 흉내 낸 DOM
|
||||
function fakeElement() {
|
||||
const listeners = {};
|
||||
const classes = new Set();
|
||||
return {
|
||||
value: "",
|
||||
children: [],
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: {
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
|
||||
contains: (c) => classes.has(c),
|
||||
},
|
||||
setAttribute() {},
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
},
|
||||
appendChild(kid) {
|
||||
this.children.push(kid);
|
||||
return kid;
|
||||
},
|
||||
addEventListener: (type, fn) => (listeners[type] = listeners[type] || []).push(fn),
|
||||
fire: (type) => (listeners[type] || []).forEach((fn) => fn({ type })),
|
||||
};
|
||||
}
|
||||
global.document = { createElement: () => fakeElement(), createTextNode: () => fakeElement() };
|
||||
|
||||
const { stationFields } = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Fields.ts"),
|
||||
);
|
||||
const { createProfileEditStore } = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"),
|
||||
);
|
||||
|
||||
const INTERVAL = 20;
|
||||
const out = {};
|
||||
const fields = stationFields("기준", () => INTERVAL);
|
||||
|
||||
// ① 실어 둔 값은 칸을 안 고치면 그대로(표시는 6 + 3.4)
|
||||
fields.write(123.37, INTERVAL);
|
||||
out.shown = [fields.station.value, fields.remainder.value];
|
||||
out.untouched = fields.read(INTERVAL, true);
|
||||
fields.write(123.3721, INTERVAL);
|
||||
out.untouchedRaw = fields.read(INTERVAL, true);
|
||||
// 사용자가 잔여를 고치면 친 값으로
|
||||
fields.remainder.value = "5.2";
|
||||
fields.remainder.fire("change");
|
||||
out.edited = fields.read(INTERVAL, true);
|
||||
// 같은 글로 되돌려 치면 — 여전히 실어 둔 값(칸 글이 쓴 그대로)
|
||||
fields.write(123.37, INTERVAL);
|
||||
fields.remainder.value = "3.4";
|
||||
out.retypedSame = fields.read(INTERVAL, true);
|
||||
// 넘침 정규화(1 + 30 → 2 + 10)는 친 값으로
|
||||
fields.station.value = "1";
|
||||
fields.remainder.value = "30";
|
||||
fields.remainder.fire("change");
|
||||
out.normalized = [fields.station.value, fields.remainder.value, fields.read(INTERVAL, true)];
|
||||
// 비운 폼은 읽기 null
|
||||
fields.write(null, INTERVAL);
|
||||
out.cleared = fields.read(INTERVAL, false);
|
||||
|
||||
// ② 계획고 편집 — 측점이 0.005m 안으로 옮겨 오면 편집을 옮기고, 아니면 지움
|
||||
const saved = { station_offsets: { 123.372: 0.5, "200.000": 0.3 }, curve_radii: { 123.372: 40 } };
|
||||
const store = createProfileEditStore(null, saved, () => {});
|
||||
store.resetStation(123.372, 123.37);
|
||||
out.moved = store.edits();
|
||||
store.resetStation(200);
|
||||
out.deleted = store.edits();
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -170,14 +170,28 @@ def test_carry_attributes_restores_facility_after_rebuild():
|
||||
assert carried[1].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_carry_attributes_uses_nearest_when_chainage_shifted():
|
||||
"""계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준."""
|
||||
def test_carry_attributes_only_within_tolerance():
|
||||
"""계산기 반올림(0.01m) 차이는 승계 · 허용 오차(0.05m) 밖은 옆 관 속성을 안 가져감."""
|
||||
requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}])
|
||||
rebuilt = [PipePoint(chainage_m=150.37)]
|
||||
near = carry_facility_attributes([PipePoint(chainage_m=150.004)], requested)
|
||||
far = carry_facility_attributes([PipePoint(chainage_m=150.37)], requested)
|
||||
|
||||
assert near[0].facility == PIPE_FACILITY_BOX
|
||||
assert far[0].facility == PIPE_FACILITY_PIPE and far[0].options is None
|
||||
|
||||
|
||||
def test_carry_attributes_new_pipe_before_box_keeps_box_in_place():
|
||||
"""옵션 없는 관을 BOX 앞에 두어도 BOX 속성은 제 측점에 남음 — 관 수 > 속성 원본 수."""
|
||||
requested = parse_pipe_points(
|
||||
[{"chainage_m": 100.0, "facility": "box_culvert", "options": {"body_width_m": 2.0}}]
|
||||
)
|
||||
rebuilt = [PipePoint(chainage_m=97.0), PipePoint(chainage_m=100.0)]
|
||||
|
||||
carried = carry_facility_attributes(rebuilt, requested)
|
||||
|
||||
assert carried[0].facility == PIPE_FACILITY_BOX
|
||||
assert carried[0].facility == PIPE_FACILITY_PIPE and carried[0].options is None
|
||||
assert carried[1].facility == PIPE_FACILITY_BOX
|
||||
assert carried[1].options == {"body_width_m": 2.0}
|
||||
|
||||
|
||||
def test_carry_attributes_without_reference_is_noop():
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장).
|
||||
|
||||
환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를
|
||||
부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과
|
||||
0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 ·
|
||||
구조물 측점 칸 · 구간값 예약).
|
||||
|
||||
- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림.
|
||||
- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 ·
|
||||
쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(DB 에 남기는 값도 보낸 값과 대조).
|
||||
- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
|
||||
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
|
||||
# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀
|
||||
_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section")
|
||||
_COPY_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음")
|
||||
|
||||
|
||||
def _copy_project(dest: Path) -> None:
|
||||
for rel in _COPY_DIRS:
|
||||
shutil.copytree(SOURCE / rel, dest / rel)
|
||||
for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"):
|
||||
(dest / rel).mkdir(parents=True, exist_ok=True)
|
||||
for item in (SOURCE / rel).iterdir():
|
||||
if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES:
|
||||
shutil.copy2(item, dest / rel / item.name)
|
||||
for item in SOURCE.iterdir():
|
||||
if item.is_file():
|
||||
shutil.copy2(item, dest / item.name)
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""DB 에 쓰는 함수 대신 — 받은 값을 적어 둠."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def fake(self, name: str, result: Any = None):
|
||||
async def _fake(*args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append((name, {"args": args[1:], **kwargs}))
|
||||
return result
|
||||
|
||||
return _fake
|
||||
|
||||
def of(self, name: str) -> list[dict[str, Any]]:
|
||||
return [call for key, call in self.calls if key == name]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api(tmp_path, monkeypatch):
|
||||
aiomysql = pytest.importorskip("aiomysql")
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Router_Basins as basins
|
||||
import B05_Profile.B05_Profile_Router as route
|
||||
import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures
|
||||
import B06_Section.B06_Section_Repository_Bulk as bulk
|
||||
import B06_Section.B06_Section_Router_Confirm as sections
|
||||
import common_util.common_util_storage as storage
|
||||
import config.config_db as config_db
|
||||
from config import config_system as cfg
|
||||
|
||||
storage_root = tmp_path / "storage"
|
||||
_copy_project(storage_root / "1" / "3" / PROJECT_ID)
|
||||
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root))
|
||||
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary"))
|
||||
monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True))
|
||||
monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options"))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data"))
|
||||
monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections"))
|
||||
monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
|
||||
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute"))
|
||||
real_known = sections.get_cross_section_chainages
|
||||
|
||||
async def spy_known(connection, route_id):
|
||||
rows = await real_known(connection, route_id)
|
||||
rec.calls.append(("known", {"chainages": list(rows)}))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
try:
|
||||
pool = await aiomysql.create_pool(
|
||||
host=cfg.DB_HOST,
|
||||
port=cfg.DB_PORT,
|
||||
db=cfg.DB_NAME,
|
||||
user=cfg.DB_USER,
|
||||
password=cfg.DB_PASSWORD,
|
||||
minsize=1,
|
||||
maxsize=6,
|
||||
autocommit=True,
|
||||
charset="utf8mb4",
|
||||
# 읽기 전용 세션 — 가짜로 못 갈음한 쓰기가 있으면 DB 를 바꾸지 않고 터짐
|
||||
init_command="SET time_zone='+00:00', SESSION tx_read_only=1",
|
||||
)
|
||||
except Exception as exc: # DB 가 안 닿는 곳
|
||||
pytest.skip(f"DB 에 안 닿음: {exc}")
|
||||
config_db.db_pool = pool
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
config_db.db_pool = None
|
||||
pool.close()
|
||||
await pool.wait_closed()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
for module in (basins, route, lifecycle, structures, sections):
|
||||
app.include_router(module.router)
|
||||
with TestClient(app) as client:
|
||||
client.rec = rec # type: ignore[attr-defined]
|
||||
client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined]
|
||||
yield client
|
||||
|
||||
|
||||
# ── 화면 코드 그대로 본문 만들기(node) ────────────────────────────────────────
|
||||
HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs")
|
||||
STATION_INTERVAL_M = 20.0
|
||||
# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김
|
||||
# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`)
|
||||
PIPE_MOVE_THRESHOLD_M = 0.005
|
||||
|
||||
|
||||
def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
out = subprocess.run(
|
||||
["node", str(HELPER), str(ROOT)],
|
||||
input=json.dumps(jobs),
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
check=True,
|
||||
)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
# ── 비교 모양 ─────────────────────────────────────────────────────────────────
|
||||
LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json"
|
||||
PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json"
|
||||
STRUCTURES_FILE = "B05_Profile/route/structures.json"
|
||||
|
||||
|
||||
def _key(chainage: float) -> str:
|
||||
return f"{chainage:.2f}"
|
||||
|
||||
|
||||
def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌)."""
|
||||
return {
|
||||
_key(p["chainage_m"]): {
|
||||
"facility": p.get("facility", "pipe"),
|
||||
"span": (p.get("start_m"), p.get("end_m")),
|
||||
"options": p.get("options") or {},
|
||||
}
|
||||
for p in points
|
||||
}
|
||||
|
||||
|
||||
def _read(root: Path, rel: str) -> Any:
|
||||
return json.loads((root / rel).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _strip(point: dict[str, Any]) -> dict[str, Any]:
|
||||
"""화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`)."""
|
||||
return {k: v for k, v in point.items() if k != "lonlat"}
|
||||
|
||||
|
||||
class Flow:
|
||||
"""화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]).
|
||||
|
||||
사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤
|
||||
다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름.
|
||||
"""
|
||||
|
||||
def __init__(self, api) -> None:
|
||||
self.api = api
|
||||
self.base = f"/api/projects/{PROJECT_ID}"
|
||||
latest = api.get(f"{self.base}/route/latest").json()
|
||||
self.route_id = latest["route"]["id"]
|
||||
self.surface = latest["surface_params"]
|
||||
self.model_id = latest["route"]["surface_model_id"]
|
||||
self.diffs: list[str] = []
|
||||
self.sent_irregular: list[float] = []
|
||||
self._plan()
|
||||
|
||||
def server_pipes(self) -> list[dict[str, Any]]:
|
||||
got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
return [_strip(p) for p in got]
|
||||
|
||||
# ── 사용자가 고른 값 ──
|
||||
def _plan(self) -> None:
|
||||
pipes = self.server_pipes()
|
||||
if len(pipes) < 9:
|
||||
pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요")
|
||||
self.pipes0 = pipes
|
||||
ch = [p["chainage_m"] for p in pipes]
|
||||
self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함
|
||||
self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로
|
||||
self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약
|
||||
self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약
|
||||
self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관)
|
||||
want = []
|
||||
for p in pipes:
|
||||
item = dict(p)
|
||||
if item["chainage_m"] == self.pipe_move[0]:
|
||||
item.update(chainage_m=self.pipe_move[1], source="user")
|
||||
elif item["chainage_m"] == self.pipe_option[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]}
|
||||
elif item["chainage_m"] == self.culvert[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.culvert[1]}
|
||||
want.append(item)
|
||||
want.append({"chainage_m": self.pipe_new, "source": "user"})
|
||||
self.want_pipes = sorted(want, key=lambda x: x["chainage_m"])
|
||||
|
||||
got = self.api.get(f"{self.base}/route/structures").json()
|
||||
self.revision0, items = got["revision"], got["structures"]
|
||||
spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None]
|
||||
if len(spans) < 3:
|
||||
pytest.skip("구간 구조물이 3 개 넘게 필요")
|
||||
moved, optioned, memoed = spans[0], spans[1], spans[2]
|
||||
width = moved["end_m"] - moved["start_m"]
|
||||
to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음
|
||||
self.struct_move = (moved["structure_id"], to, round(to + width, 2))
|
||||
self.want_structures = json.loads(json.dumps(items))
|
||||
for s in self.want_structures:
|
||||
if s["structure_id"] == moved["structure_id"]:
|
||||
s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2))
|
||||
s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침
|
||||
elif s["structure_id"] == optioned["structure_id"]:
|
||||
s["options"] = {**s["options"], "height_m": 3}
|
||||
s["memo"] = "왕복 시험 메모"
|
||||
elif s["structure_id"] == memoed["structure_id"]:
|
||||
s["memo"] = "메모만 바꿈"
|
||||
|
||||
lon = _read(self.api.root, LONGITUDINAL)
|
||||
grid = [s for s in lon["stations"] if s.get("kind") == "regular"]
|
||||
g0, g1 = grid[4], grid[len(grid) // 2]
|
||||
self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2}
|
||||
side = grid[3].get("uphill_side")
|
||||
self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right")
|
||||
self.cross_patches = [
|
||||
{
|
||||
"chainage_m": g0["chainage_m"],
|
||||
"rock_boundary_offset_m": -0.8,
|
||||
"cut_slope_ratio_user": 0.5,
|
||||
},
|
||||
{"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0},
|
||||
]
|
||||
|
||||
# ── 화면 코드가 만드는 본문 ──
|
||||
def b05_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`."""
|
||||
(station,) = _screen(
|
||||
[{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}]
|
||||
)
|
||||
entries = [dict(p) for p in self.pipes0]
|
||||
screen_ch = {}
|
||||
for p in self.want_pipes:
|
||||
c = p["chainage_m"]
|
||||
if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M:
|
||||
c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김
|
||||
screen_ch[p["chainage_m"]] = c
|
||||
for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감
|
||||
if e["chainage_m"] == self.pipe_option[0]:
|
||||
e["chainage_m"] = screen_ch[self.pipe_option[0]]
|
||||
e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]}
|
||||
points = [
|
||||
{
|
||||
"chainage_m": screen_ch[p["chainage_m"]],
|
||||
"source": "confirmed" if p.get("source") == "user" else p.get("source", "user"),
|
||||
}
|
||||
for p in self.want_pipes
|
||||
]
|
||||
(attached,) = _screen([{"op": "attach", "entries": entries, "points": points}])
|
||||
return attached
|
||||
|
||||
def b06_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감)."""
|
||||
before = {p["chainage_m"]: p.get("options") for p in self.pipes0}
|
||||
out = []
|
||||
for p in self.want_pipes:
|
||||
item = dict(p)
|
||||
if p["chainage_m"] == self.culvert[0]:
|
||||
item.pop("options", None)
|
||||
if before[self.culvert[0]]:
|
||||
item["options"] = before[self.culvert[0]]
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]:
|
||||
"""구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음."""
|
||||
items = json.loads(json.dumps(self.want_structures))
|
||||
if not reread:
|
||||
return items
|
||||
sid, start, end = self.struct_move
|
||||
ch = next(s for s in items if s["structure_id"] == sid)
|
||||
a, b, c = _screen(
|
||||
[
|
||||
{"op": "station", "chainage": v, "interval": STATION_INTERVAL_M}
|
||||
for v in (start, ch["chainage_m"], end)
|
||||
]
|
||||
)
|
||||
ch.update(start_m=a, chainage_m=b, end_m=c)
|
||||
return items
|
||||
|
||||
def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
"""`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음."""
|
||||
pending = {_key(self.culvert[0]): self.culvert[1]}
|
||||
moves = {_key(self.stale_move[0]): self.stale_move[1]}
|
||||
(body,) = _screen(
|
||||
[{"op": "culvert", "server": current, "pending": pending, "moves": moves}]
|
||||
)
|
||||
return body
|
||||
|
||||
# ── API(화면 Api_Fetch 와 같은 경로 · 본문) ──
|
||||
def put_pipes(self, pipes: list[dict[str, Any]]) -> None:
|
||||
res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes})
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def flush_culvert(self) -> None:
|
||||
body = self.culvert_payload(
|
||||
self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
)
|
||||
if body is not None:
|
||||
self.put_pipes(body)
|
||||
|
||||
def put_alignment(self) -> None:
|
||||
body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}}
|
||||
res = self.api.put(f"{self.base}/route/profile-alignment", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def put_structures(self, items: list[dict[str, Any]]) -> None:
|
||||
revision = self.api.get(f"{self.base}/route/structures").json()["revision"]
|
||||
body = {"base_revision": revision, "structures": items}
|
||||
res = self.api.put(f"{self.base}/route/structures", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def confirm(self, *, with_irregular: bool) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}]
|
||||
}
|
||||
if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물
|
||||
self.sent_irregular = [
|
||||
p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement"
|
||||
]
|
||||
body.update(
|
||||
filter_key=self.surface["source_filter"],
|
||||
method=self.surface["method"],
|
||||
smooth=self.surface["smooth"],
|
||||
surface_model_id=self.model_id,
|
||||
irregular_stations=[
|
||||
{"chainage_m": c, "structure": "배관"} for c in self.sent_irregular
|
||||
],
|
||||
)
|
||||
res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def save_sections(self) -> None:
|
||||
body = {"cross_patches": self.cross_patches}
|
||||
res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
# ── 다시 읽기 · 대조(사용자가 고른 값 기준) ──
|
||||
def check(self) -> None:
|
||||
root, d = self.api.root, self.diffs
|
||||
want = _pipes_view(self.want_pipes)
|
||||
for where, got in (
|
||||
("응답", _pipes_view(self.server_pipes())),
|
||||
("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])),
|
||||
):
|
||||
for k in sorted(set(want) | set(got), key=float):
|
||||
if want.get(k) != got.get(k):
|
||||
d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}")
|
||||
saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]}
|
||||
for s in self.want_structures:
|
||||
got_s = saved.get(s["structure_id"], {})
|
||||
for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"):
|
||||
if s.get(field) != got_s.get(field):
|
||||
d.append(
|
||||
f"구조물 {s['type_id']} {field} — "
|
||||
f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}"
|
||||
)
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {}
|
||||
if edits != self.offsets:
|
||||
d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}")
|
||||
up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {})
|
||||
if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"):
|
||||
d.append(
|
||||
f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · "
|
||||
f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}"
|
||||
)
|
||||
for c in self.sent_irregular:
|
||||
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
|
||||
if not any(abs(n - c) < 0.005 for n in near):
|
||||
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
|
||||
self._check_designs()
|
||||
|
||||
def _check_designs(self) -> None:
|
||||
"""횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림)."""
|
||||
rec = self.api.rec
|
||||
known = [c for call in rec.of("known") for c in call["chainages"]]
|
||||
designs = rec.of("designs")
|
||||
made = [e[0] for call in designs if call.get("replace") for e in call["entries"]]
|
||||
patched = {
|
||||
round(e[0], 2): e[1]
|
||||
for call in designs
|
||||
if call.get("replace") is False
|
||||
for e in call["entries"]
|
||||
}
|
||||
for patch in self.cross_patches:
|
||||
ch = patch["chainage_m"]
|
||||
body = {k: v for k, v in patch.items() if k != "chainage_m"}
|
||||
got = patched.get(round(ch, 2))
|
||||
if got is None or any(got.get(k) != v for k, v in body.items()):
|
||||
self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}")
|
||||
elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]):
|
||||
self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림")
|
||||
|
||||
|
||||
def _snapshot(root: Path) -> dict[str, Any]:
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
return {
|
||||
"pipe_points.json": _read(root, PIPES_FILE)["points"],
|
||||
"structures.json": _read(root, STRUCTURES_FILE)["structures"],
|
||||
"계획고 수정": lon["profile_alignment"].get("edits"),
|
||||
"종단 측점": [
|
||||
(s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resave_unchanged(flow: Flow) -> None:
|
||||
"""다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함."""
|
||||
flow.put_pipes(flow.server_pipes())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.flush_culvert()
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"])
|
||||
def test_저장_왕복_0_차이(api, save):
|
||||
flow = Flow(api)
|
||||
save(flow)
|
||||
flow.check()
|
||||
before = _snapshot(api.root)
|
||||
_resave_unchanged(flow)
|
||||
after = _snapshot(api.root)
|
||||
flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]]
|
||||
assert not flow.diffs, "\n".join(flow.diffs)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""시설 속성 붙이기 거울 시험 — 화면 `carryIndices`(B05_Profile_UI_Drainage_Facility.ts) ↔
|
||||
서버 `carry_indices`(common_util_drainage_pipes.py) 가 같은 답을 내는지.
|
||||
|
||||
규칙: 허용 오차(0.05m) 안 가장 가까운 원본만 · 한 원본은 한 번만 · 못 붙인 관은 속성 없음.
|
||||
화면 보관소는 관을 끌면(`move`) · 되돌리기로 옮기면(`follow`) 속성을 따라 옮김.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_drainage_pipes import CARRY_TOLERANCE_M, carry_indices
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FACILITY = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts"
|
||||
CULVERT = ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts"
|
||||
|
||||
CASES = [
|
||||
([100.0, 200.0], [100.0, 200.0]), # 제자리
|
||||
([100.004], [100.0]), # 반올림 차이
|
||||
([95.0, 100.0], [100.0]), # 관 수 > 원본 수 — 앞 관이 BOX 를 가져가지 않음
|
||||
([150.37], [150.0]), # 허용 오차 밖
|
||||
([100.01, 100.02], [100.0]), # 한 원본은 한 번만
|
||||
([100.0], [99.97, 100.02]), # 허용 오차 안 가장 가까운 것
|
||||
([100.0, 100.0], [100.06, 100.04]), # 경계 — 0.06 은 밖
|
||||
([100.0], []), # 원본 없음
|
||||
]
|
||||
|
||||
|
||||
def _ts_block(body: str, head: str) -> str:
|
||||
"""최상위 선언 하나를 떼어 냄 — 상수는 한 줄 · 함수는 첫 칸 `}` 까지."""
|
||||
tail = r"[^\n]*;$" if head.startswith("const") else r"[\s\S]*?^\}$"
|
||||
found = re.search(rf"^export {head}{tail}", body, re.M)
|
||||
assert found, head
|
||||
return found.group(0).replace("export ", "")
|
||||
|
||||
|
||||
def _run_node(script: str) -> object:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_서버_규칙():
|
||||
assert CARRY_TOLERANCE_M == 0.05
|
||||
assert [carry_indices(t, s) for t, s in CASES] == [
|
||||
[0, 1],
|
||||
[0],
|
||||
[None, 0],
|
||||
[None],
|
||||
[0, None],
|
||||
[1],
|
||||
[1, None],
|
||||
[None],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면과_서버가_같은_답():
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
_ts_block(body, "const CARRY_TOLERANCE_M")
|
||||
+ "\n"
|
||||
+ _ts_block(body, "function carryIndices")
|
||||
+ f"\nconst cases = {json.dumps(CASES)};"
|
||||
+ "\nconsole.log(JSON.stringify([CARRY_TOLERANCE_M,"
|
||||
+ " cases.map(([t, s]) => carryIndices(t, s))]));"
|
||||
)
|
||||
tolerance, picks = _run_node(script)
|
||||
assert tolerance == CARRY_TOLERANCE_M
|
||||
assert picks == [carry_indices(t, s) for t, s in CASES]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면_보관소_끌기_되돌리기_때_속성이_따라감():
|
||||
"""BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만."""
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
"type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n"
|
||||
"interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;"
|
||||
" options?: Record<string, string | number> }\n"
|
||||
+ "\n".join(
|
||||
_ts_block(body, head)
|
||||
for head in (
|
||||
"const CARRY_TOLERANCE_M",
|
||||
"function carryIndices",
|
||||
"function createFacilityStore",
|
||||
)
|
||||
)
|
||||
+ "\n"
|
||||
+ re.search(r"^function hasExtras[\s\S]*?^\}$", body, re.M).group(0)
|
||||
+ """
|
||||
const store = createFacilityStore();
|
||||
store.replaceFromResponse([{ chainage_m: 80 }, { chainage_m: 100, facility: "box_culvert" }]);
|
||||
const kinds = (list) => store.attach(list.map((c) => ({ chainage_m: c, source: "user" })))
|
||||
.map((p) => p.facility ?? "-");
|
||||
const out = {};
|
||||
out.관을_BOX_곁으로 = kinds([99, 100]); // 옵션 없는 관을 BOX 곁으로 끌기(보관소 변화 없음)
|
||||
store.move(100, 130); // BOX 를 끌기
|
||||
out.BOX_끌기 = kinds([80, 130]);
|
||||
store.follow([80, 130], [80, 100]); // 되돌리기 — BOX 가 제자리로
|
||||
out.되돌리기 = kinds([80, 100]);
|
||||
store.follow([80, 100], [81, 120]); // 둘이 한꺼번에 바뀜 — 옮기지 않음
|
||||
out.둘_바뀜 = kinds([81, 120]);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
assert _run_node(script) == {
|
||||
"관을_BOX_곁으로": ["-", "box_culvert"],
|
||||
"BOX_끌기": ["-", "box_culvert"],
|
||||
"되돌리기": ["-", "box_culvert"],
|
||||
"둘_바뀜": ["-", "-"],
|
||||
}
|
||||
|
||||
|
||||
def test_옛_관_이동_예약은_저장_때_안_씀():
|
||||
body = CULVERT.read_text(encoding="utf-8")
|
||||
assert "readMoves" not in body and "movedTo" not in body and "moveKey" not in body
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증.
|
||||
|
||||
비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과
|
||||
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만
|
||||
보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸.
|
||||
|
||||
Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_비중은_늘_합_100_정수(results):
|
||||
for weights in results["moves"]:
|
||||
assert list(weights) == ["expected", "earthwork", "slope"]
|
||||
assert sum(weights.values()) == 100
|
||||
assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values())
|
||||
|
||||
|
||||
def test_비중은_나머지를_지금_비율대로_나눔(results):
|
||||
moves = results["moves"]
|
||||
assert moves[0] == {"expected": 70, "earthwork": 15, "slope": 15}
|
||||
assert moves[1] == {"expected": 40, "earthwork": 40, "slope": 20}
|
||||
# 나머지가 모두 0 이면 반씩.
|
||||
assert moves[2] == {"expected": 60, "earthwork": 20, "slope": 20}
|
||||
assert moves[3] == {"expected": 0, "earthwork": 0, "slope": 100}
|
||||
assert moves[5]["slope"] == 0
|
||||
|
||||
|
||||
def test_노드_목록_꼴을_편집값으로(results):
|
||||
snap = results["plain"]["snapshot"]
|
||||
assert snap["planned"] == [[0, 0], [50, 10], [100, 0]]
|
||||
assert snap["curveOn"] == [True, True, False]
|
||||
assert snap["curveRadius"] == [None, 30, None]
|
||||
assert snap["curveLock"] == [None, None, None]
|
||||
assert snap["apexLock"] == [False, False, False]
|
||||
assert snap["picked"] == -1
|
||||
|
||||
|
||||
def test_노선계획_꼴도_같은_규칙으로(results):
|
||||
shape = results["planShape"]
|
||||
snap = shape["snapshot"]
|
||||
assert snap["planned"] == [[0, 0], [40, 30], [90, 30]]
|
||||
assert snap["curveOn"] == [False, True, False]
|
||||
assert snap["curveRadius"] == [None, 25, None]
|
||||
assert shape["weights"] == {"expected": 60, "earthwork": 20, "slope": 20}
|
||||
assert shape["metrics"] is None and shape["criteria"] is None
|
||||
|
||||
|
||||
def test_노드가_모자라면_싣지_않음(results):
|
||||
assert "모자랍니다" in results["tooShort"]
|
||||
|
||||
|
||||
def test_비교값과_기준값_글(results):
|
||||
labels = results["labels"]
|
||||
assert labels["follow"] == "예상노선 추종"
|
||||
assert labels["weighted"] == "비중 계산 60/20/20"
|
||||
assert labels["metrics"] == (
|
||||
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥"
|
||||
" · 기준 초과 2구간 · 이격 3.3m"
|
||||
)
|
||||
assert "로직을 누르면" in labels["metricsNone"]
|
||||
assert labels["criteria"] == (
|
||||
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)"
|
||||
)
|
||||
assert labels["criteriaConfirmed"].endswith("5.0m")
|
||||
assert labels["criteriaFallback"] == (
|
||||
"공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
|
||||
)
|
||||
|
||||
|
||||
def test_캐시는_결과_통_프로젝트_칸(results):
|
||||
assert results["keys"] == ["aislo:result:routeedit-initial:p-1"]
|
||||
assert results["readBack"]["logic"] == "earthwork"
|
||||
assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]]
|
||||
assert results["other"] is None
|
||||
assert results["badLogic"] is None
|
||||
assert results["cleared"] is None
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""구조물 측점 왕복 — 옵션 · 메모만 고쳐 저장해도 측점이 그대로(PLAN 19장 sub6).
|
||||
|
||||
측점 칸은 잔여거리를 소수 1자리로 보인다. 칸을 안 고쳤으면 실어 둔 누가거리를 그대로
|
||||
읽어야 한다(123.37 → 123.4 로 뭉개져 옮겨 저장되던 흠). 서버가 관 측점을 0.01m 로
|
||||
반올림해 돌려주면 계획고 편집(station_offsets)은 지우지 않고 새 측점으로 옮긴다.
|
||||
|
||||
Node 헬퍼(`helper_b05_station_roundtrip.cjs`)가 TS 를 트랜스파일해 돌리고 여기서 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_station_roundtrip.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_칸을_안_고치면_실어_둔_측점_그대로(results):
|
||||
assert results["shown"] == ["6", "3.4"]
|
||||
assert results["untouched"] == 123.37
|
||||
assert results["untouchedRaw"] == 123.3721
|
||||
assert results["retypedSame"] == 123.37
|
||||
|
||||
|
||||
def test_칸을_고치면_친_값(results):
|
||||
assert results["edited"] == pytest.approx(125.2)
|
||||
assert results["normalized"] == ["2", "10.0", 50]
|
||||
assert results["cleared"] is None
|
||||
|
||||
|
||||
def test_계획고_편집은_가까운_새_측점으로_옮김(results):
|
||||
assert results["moved"] == {
|
||||
"station_offsets": {"200.000": 0.3, "123.370": 0.5},
|
||||
"curve_radii": {"123.370": 40},
|
||||
}
|
||||
# 옮길 곳이 없으면 종전대로 지움
|
||||
assert "200.000" not in results["deleted"]["station_offsets"]
|
||||
@@ -7,8 +7,8 @@ B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바
|
||||
여기서 지키는 것은 둘이다.
|
||||
① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면
|
||||
관 목록을 담는 자리가 다시 둘이 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔
|
||||
예약이 조용히 사라지면 안 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 안 씀** — 관 위치는 스냅샷으로만 옮김
|
||||
(적용하면 관이 이웃 측점으로 튀어 BOX · 세월교 속성이 넘어감).
|
||||
|
||||
실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식).
|
||||
"""
|
||||
@@ -62,21 +62,17 @@ global.window = {
|
||||
};
|
||||
|
||||
const api = require(process.argv[3]);
|
||||
const writer = api.createCulvertOptionWriter(
|
||||
() => "p1",
|
||||
() => "opt",
|
||||
undefined,
|
||||
() => "move",
|
||||
);
|
||||
const writer = api.createCulvertOptionWriter(() => "p1", () => "opt", undefined);
|
||||
|
||||
// ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다.
|
||||
const methods = Object.keys(writer).sort();
|
||||
|
||||
// ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다.
|
||||
// ② 옛 세션의 이동 예약은 남아 있어도 안 씀 — 구간값만 제 측점에 나감.
|
||||
store.set("move", JSON.stringify({ "120.00": 135 }));
|
||||
store.set("opt", JSON.stringify({ "120.00": { pipe_count: 2 } }));
|
||||
|
||||
api
|
||||
.flushCulvertOptions("p1", "opt", "move")
|
||||
.flushCulvertOptions("p1", "opt")
|
||||
.then(() => {
|
||||
writeFileSync(
|
||||
process.argv[2],
|
||||
@@ -134,6 +130,5 @@ def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None:
|
||||
# ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다.
|
||||
assert result["methods"] == ["flush", "queue"]
|
||||
|
||||
# ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다.
|
||||
assert result["savedChainages"] == [135, 200]
|
||||
assert result["moveLeft"] is None
|
||||
# ② 옛 세션의 이동 예약은 저장 때 안 씀 — 관은 제 측점 그대로.
|
||||
assert result["savedChainages"] == [120, 200]
|
||||
|
||||
Reference in New Issue
Block a user