feat(B05): 우클릭 세월교 · 물넘이포장도 바로 추가 · 빈 월류 높이는 재계산 유량이 오면 그 항목에 채움(126-1)

- 우클릭 바로 추가에서 세월교 · 물넘이포장을 폼만 갈래에서 뺌 · 추가 뒤 그 측점 관 고름 + 알림 한 줄
- 배수유역 패널 — 바로 놓은 빈 높이 측점을 기다렸다가 응답 유량으로 필요 수심 채움(`fordHeightM` · 서버 `ford_height_m` 짝) · 손 값은 안 덮음 · 유량 전엔 빈칸 · 채우면 세션 초안에 다시 담음
- 세부유역 요청은 최신 응답만 채택(requestSeq)
- 시험 test_126_ford_right_click(헬퍼 helper_126_ford_fill) · test_116 기대값 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
2026-10-08 19:42:46 +09:00
co-authored by Claude Opus 5.5
parent 681efb23d1
commit 157111a094
6 changed files with 336 additions and 10 deletions
@@ -0,0 +1,60 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Ford_Fill.ts
* 우클릭으로 바로 추가한 세월교 · 물넘이포장의 빈 월류 높이 = 배수유역 재계산이 준 설계유량의
* 필요 수심(PLAN 126-1 · 폼 쪽 65-4 와 같은 풀이 `fordHeightM`).
*
* 기다리는 측점만 채움 · 사용자가 넣은 높이(응답 옵션에 있음)는 안 덮음 · 유량이 아직 없으면 다음
* 응답까지 기다림. 응답 옵션을 고쳐 두면 보관소(`replaceFromResponse`)가 그 값을 정본으로 받음.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
const FORD_KINDS: Record<string, number> = {
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M,
};
/** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */
export function waitsFordHeight(
facility: string | undefined,
options?: Record<string, string | number>,
): boolean {
return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined;
}
/** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
* 채운 것이 있으면 true(세션 초안에 다시 담을 것). */
export function fillFordHeights(
points: Array<{
chainage_m: number;
facility?: string;
options?: Record<string, string | number>;
}>,
basins: ReadonlyArray<{ chainage_m: number; design_flow_m3s?: number | null }>,
pending: Set<number>,
): boolean {
let filled = false;
for (const wait of [...pending]) {
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51);
if (!point?.facility || !(point.facility in FORD_KINDS)) {
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
continue;
}
if (point.options?.ford_height_m !== undefined && point.options.ford_height_m !== "") {
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
continue;
}
const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51);
const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
point.options = { ...point.options, ford_height_m: need };
pending.delete(wait);
filled = true;
}
return filled;
}
+18 -4
View File
@@ -33,6 +33,7 @@ import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { fillFordHeights, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
@@ -172,6 +173,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
let offsetY = 0;
let frameHandle = 0;
let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */
const fordWaits = new Set<number>();
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
@@ -346,8 +350,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
reason: pipe.source,
})),
);
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다.
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits);
facilityStore.replaceFromResponse(response.pipe_points);
if (fordFilled) rememberPipes();
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
// 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다.
lastNotifiedPipeChainage = null;
@@ -367,18 +373,24 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
const seq = ++requestSeq;
try {
apply(await request());
const response = await request();
if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
apply(response);
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin");
scheduleDraw();
} catch (error) {
if (seq !== requestSeq) return;
status.hidden = false;
status.textContent =
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
} finally {
analyzeButton.disabled = false;
showProgress(null, null);
if (seq === requestSeq) {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
}
@@ -591,6 +603,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
addPipe(chainageM, attributes) {
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
facilityStore.set(chainageM, attributes ?? null);
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM);
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
@@ -598,6 +611,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
@@ -2,14 +2,19 @@
* B05_Profile_UI_Structures_Panel_AddAt.ts
* 종단 우클릭 → 종류 고름 = 「[추가]까지 끝난 상태」(PLAN 116-1) — 세 길(B05 종단 · B06 종단 ·
* 3D)이 함께 쓰는 패널 `addAt` 의 끝맺음. 필수 빈칸이 없는 종류는 그 측점에 바로 추가하고
* 새 항목을 고른 채로 둠 · 남는 종류는 폼만.
* 새 항목을 고른 채로 둠 · 남는 종류는 폼만. 세월교 · 물넘이포장도 바로 추가 — 빈 월류 높이는
* 배수유역 재계산 유량이 오면 그 항목에 채움(126-1 · `_Drainage_Ford_Fill`).
* ========================================================================== */
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import { showToast } from "@ui/ui_template_elements";
/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창) · 세월교 · 물넘이포장(월류 높이가 유량 뒤 채워짐). */
const FORM_ONLY = new Set(["pavement_concrete", "ford_bridge", "ford_pavement"]);
/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창). */
const FORM_ONLY = new Set(["pavement_concrete"]);
/** 월류 높이를 유량 뒤에 받는 종류 — 추가 때 알림 한 줄. */
const FORD_KINDS = new Set(["ford_bridge", "ford_pavement"]);
export const FORD_WAIT_NOTICE = "월류 높이는 배수유역 재계산(설계유량)이 오면 채워집니다.";
/** 우클릭 한 번으로 [추가]까지 끝낼 수 있는지 — [추가]가 막는 빈 필수 칸이 없어야 함. */
export function addsAtOnce(
@@ -35,6 +40,7 @@ export async function finishAdd(ctx: {
// B05 는 재계산 뒤 배수유역 패널이 새 관 고름을 다시 알려 폼이 열림.
await ctx.commit();
ctx.pick.selectPipeByChainage(ctx.anchorM);
if (FORD_KINDS.has(ctx.type.type_id)) showToast(FORD_WAIT_NOTICE);
return true;
}
const before = new Set(ctx.structures.map((entry) => entry.structure_id));
+139
View File
@@ -0,0 +1,139 @@
/* 126-1 우클릭 세월교 · 물넘이포장 바로 추가 헬퍼 — `_Structures_Panel_AddAt`(판정 · 끝맺음 · 알림)과
* `_Drainage_Ford_Fill`(유량 응답 뒤 빈 월류 높이 채움)을 Node 로 돌려 표준출력 JSON 한 벌로.
* TS 로더는 `helper_116_right_click_add.cjs` 와 같다 · 설정값은 진짜 `config_frontend` 를 읽음.
*/
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();
const toasts = [];
const STUBS = { "@ui/ui_template_elements": { showToast: (message) => toasts.push(message) } };
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 === "@config/config_frontend")
return loadTs(path.join(ROOT, "config", "config_frontend.ts"));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} });
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const B05 = path.join(ROOT, "B05_Profile");
const { addsAtOnce, finishAdd, FORD_WAIT_NOTICE } = loadTs(
path.join(B05, "B05_Profile_UI_Structures_Panel_AddAt.ts"),
);
const { fillFordHeights, waitsFordHeight } = loadTs(
path.join(B05, "B05_Profile_UI_Drainage_Ford_Fill.ts"),
);
const { fordHeightM } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"),
);
const type = (type_id) => ({ type_id, group: "A", managed_by: "pipe_points" });
/** 우클릭 → 바로 추가 — 관 지점 정본에 한 번 넘기고 그 측점 관을 고름 · 알림 한 줄. */
async function rightClick(typeId) {
toasts.length = 0;
const picked = [];
let commits = 0;
const done = await finishAdd({
type: type(typeId),
anchorM: 120,
structures: [],
commit: async () => void commits++,
pick: {
selectById: (id) => picked.push(["id", id]),
selectPipeByChainage: (at) => picked.push(["pipe", at]),
},
});
return { done, commits, picked, toasts: [...toasts] };
}
/** 재계산 응답 한 번 — 응답 관 목록에 기다리던 높이를 얹은 결과. */
function respond(points, basins, pending) {
const filled = fillFordHeights(points, basins, pending);
return {
filled,
heights: points.map((p) => p.options?.ford_height_m ?? null),
pending: [...pending],
};
}
(async () => {
const flow = 8.0;
// 첫 응답은 유량 없음 → 빈칸 · 둘째 응답에 유량 → 그 항목에 채움(폭 = 항목 값)
const pendingA = new Set([120]);
const pointsA = () => [
{ chainage_m: 120.2, facility: "ford_bridge", options: { ford_width_m: 10 } },
];
const noFlow = respond(pointsA(), [{ chainage_m: 120.2, design_flow_m3s: null }], pendingA);
const withFlow = respond(pointsA(), [{ chainage_m: 120.2, design_flow_m3s: flow }], pendingA);
// 손 값은 안 덮음
const pendingB = new Set([300]);
const hand = respond(
[
{
chainage_m: 300,
facility: "ford_pavement",
options: { ford_width_m: 5, ford_height_m: 0.5 },
},
],
[{ chainage_m: 300, design_flow_m3s: flow }],
pendingB,
);
// 기다리지 않는 측점(옛 세월교)의 빈 높이는 그대로 · 폭 없으면 기본 폭
const pendingC = new Set([50]);
const other = respond(
[
{ chainage_m: 50, facility: "ford_pavement", options: {} },
{ chainage_m: 80, facility: "ford_bridge", options: { ford_width_m: 10 } },
],
[
{ chainage_m: 50, design_flow_m3s: flow },
{ chainage_m: 80, design_flow_m3s: flow },
],
pendingC,
);
// 종류가 바뀌었거나 관이 사라짐 → 그만 기다림
const pendingD = new Set([10, 20]);
const gone = respond([{ chainage_m: 10, facility: "pipe", options: {} }], [], pendingD);
const out = {
once: {
fordBridge: addsAtOnce(type("ford_bridge"), []),
fordPavement: addsAtOnce(type("ford_pavement"), []),
pavement: addsAtOnce({ type_id: "pavement_concrete", managed_by: null }, []),
},
bridge: await rightClick("ford_bridge"),
paveRc: await rightClick("ford_pavement"),
pipe: await rightClick("pipe"),
notice: FORD_WAIT_NOTICE,
waits: {
bridgeEmpty: waitsFordHeight("ford_bridge", { ford_width_m: 10 }),
paveEmpty: waitsFordHeight("ford_pavement", undefined),
bridgeHand: waitsFordHeight("ford_bridge", { ford_height_m: 0.4 }),
pipe: waitsFordHeight("pipe", {}),
},
noFlow,
withFlow,
hand,
other,
gone,
need: { w10: fordHeightM(flow, 10), w5: fordHeightM(flow, 5) },
};
process.stdout.write(JSON.stringify(out));
})();
+3 -3
View File
@@ -3,7 +3,7 @@
못박는 것
- 필수 빈칸 없는 종류 = 그 측점에 바로 추가(캐시) + 새 항목을 고른 채(구조물 = 임시 id · A군 = 측점).
- 필수 빈칸 남는 종류 · 노면공(확인창) · 세월교 · 물넘이포장(월류 높이 = 유량 뒤) = 폼만.
- 필수 빈칸 남는 종류 · 노면공(확인창) = 폼만 · 세월교 · 물넘이포장은 바로 추가(126-1 · test_126).
- [추가]가 거절하면 아무것도 안 듦 · 고르지 않음.
- 새로 놓기에서 시작 — 옛 집계 항목 · 그룹 잇기 · 메모가 새 항목에 안 실림.
- 세 길(B05 종단 · B06 종단 · 3D)이 모두 패널 `addAt` 한 곳 · 패널을 열고 「구조물 배치」를 폄.
@@ -47,10 +47,10 @@ def test_필수_빈칸_없는_종류만_바로_추가(out):
assert once["requiredEmpty"] is False
def test_노면공_세월교_물넘이포장은_폼만(out):
def test_노면공만_폼만_세월교_물넘이포장은_바로(out):
once = out["once"]
assert once["pavement"] is False
assert once["fordBridge"] is False and once["fordPavement"] is False
assert once["fordBridge"] is True and once["fordPavement"] is True # 126-1
def test_구조물은_목록에_들고_새_임시_id로_고른_채(out):
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""126-1 우클릭 세월교 · 물넘이포장도 바로 추가 · 월류 높이 실시간.
못박는 것
- 세월교 · 물넘이포장 = 바로 추가 + 그 측점 관을 고른 채 · 알림 한 줄(높이는 유량 뒤).
- 배수유역 재계산 응답에 유량이 오면 기다리던 항목의 빈 월류 높이 = 필요 수심(서버 `ford_height_m` 거울).
- 유량이 아직 없으면 빈칸으로 다음 응답까지 · 손 값은 안 덮음 · 기다리지 않는 옛 항목은 그대로.
- 세부유역 요청은 최신 응답만 채택 · 채운 뒤 세션 초안에 다시 담음.
Node 헬퍼 `helper_126_ford_fill.cjs` 가 판정 · 끝맺음 · 채움을 돌림. 화면은 사용자 확인.
"""
import json
import os
import subprocess
import pytest
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
return f.read()
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_126_ford_fill.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_우클릭_세월교_물넘이포장은_바로_추가(out):
assert out["once"] == {"fordBridge": True, "fordPavement": True, "pavement": False}
def test_추가_뒤_목록에_들고_그_관을_고른_채_알림_한_줄(out):
for key in ("bridge", "paveRc"):
r = out[key]
assert r["done"] is True and r["commits"] == 1
assert r["picked"] == [["pipe", 120]]
assert r["toasts"] == [out["notice"]]
assert out["pipe"]["toasts"] == [] # 배관은 알림 없음
def test_빈_높이만_기다림(out):
assert out["waits"] == {
"bridgeEmpty": True,
"paveEmpty": True,
"bridgeHand": False,
"pipe": False,
}
def test_유량_오기_전엔_빈칸_오면_그_항목에_채움(out):
assert out["noFlow"] == {"filled": False, "heights": [None], "pending": [120]}
assert out["withFlow"] == {"filled": True, "heights": [0.16], "pending": []}
def test_채운_높이는_서버_풀이와_같음(out):
assert out["need"]["w10"] == basins.ford_height_m(8.0, 10) == 0.16
assert out["need"]["w5"] == basins.ford_height_m(8.0, 5)
def test_손_값은_안_덮음(out):
assert out["hand"] == {"filled": False, "heights": [0.5], "pending": []}
def test_기다리지_않는_옛_항목은_그대로_폭_없으면_기본_폭(out):
assert out["other"]["heights"] == [0.16, None] # 물넘이 기본 폭 10 · 옛 세월교 빈칸 그대로
assert out["other"]["pending"] == []
def test_관이_사라지거나_종류가_바뀌면_그만_기다림(out):
assert out["gone"] == {"filled": False, "heights": [None], "pending": []}
def test_배수유역_패널_배선():
src = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts")
# 최신 응답만 채택
run = src[src.index("async function run(") : src.index("/** 지금 관 목록")]
assert "const seq = ++requestSeq;" in run
assert run.index("await request()") < run.index("if (seq !== requestSeq) return;")
assert run.index("if (seq !== requestSeq) return;") < run.index("apply(response)")
# 채움은 보관소가 응답을 받기 전에 · 채웠으면 세션 초안에 다시 담음
apply = src[src.index("function apply(") : src.index("async function run(")]
order = [
apply.index("fillFordHeights(response.pipe_points, response.basins, fordWaits)"),
apply.index("facilityStore.replaceFromResponse(response.pipe_points)"),
apply.index("if (fordFilled) rememberPipes()"),
apply.index("callbacks.onPipesChanged?.("),
]
assert order == sorted(order)
add = src[src.index(" addPipe(chainageM, attributes) {") : src.index(" removePipe(")]
assert "waitsFordHeight(attributes?.facility, attributes?.options)" in add
assert add.index("fordWaits.add(chainageM)") < add.index("pipeEditor.addAtChainage(chainageM)")
assert "if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);" in add