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:
2026-09-29 10:00:26 +09:00
31 changed files with 1948 additions and 144 deletions
+3
View File
@@ -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;
+8 -2
View File
@@ -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;
}
}
+50 -17
View File
@@ -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();
},
+7 -1
View File
@@ -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();
// 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다.
+2 -6
View File
@@ -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에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
+10 -1
View File
@@ -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));
+11 -2
View File
@@ -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).
+2 -2
View File
@@ -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(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
+43 -45
View File
@@ -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;
+17 -6
View File
@@ -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;
+7 -32
View File
@@ -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;
+37 -9
View File
@@ -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)
+128
View File
@@ -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]