feat(B05 배수유역): 347-2 자동/수동 다시 셈 · ⚙ 설정 창 · 347-3 제안 목록 · [적용]

- 계산 묶음 drainage 단계(pipelineStage · 붙이기 = 묶음 쪽) — 노선 초안 · 관이 바뀌면 낡음 · 자동 = 3 s 조용한 뒤 「지금 노선으로 셈」 · 수동 = 칸만 흐림 + 가운데 [업데이트] · 구석 「계산 중」 · 다시 조작하면 요청 끊고 처음으로 · 덮개 없음
- ⚙ 설정 창(배수유역 칸 머리 · openModal) 수동(기본) / 자동 · 계정 화면 설정 drainage-mode(b_page_state 등록표 한 줄 · build:corridor 다시 지음)
- 늦은 답 버림 = 차례 번호 · 서버 superseded · 세부유역 요청 감싸기와 같은 차례(B05_Profile_UI_Drainage_Run 으로 나눔)
- 제안 = 관경(Ø→Ø) · 시설(BOX암거 · 세월교) · 관 자리 후보(지도 점선 원) · [적용] 한 번에 관 초안 · 자동 바꿈 없음
- [확정] savePipes(confirm) = PUT ?recompute=true · [저장] = 낡음 달고 저장 · 패널 691줄
- 순수 규칙 logic/B05_Profile_Drainage_Live · _Suggest · 시험 test_347_2_drainage_live_ui · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-16 11:47:55 +09:00
co-authored by Claude Opus 5.5
parent d5d6415966
commit a5c36a4a29
14 changed files with 747 additions and 36 deletions
+2
View File
@@ -107,6 +107,8 @@ export const STATE_REGISTRY = {
"input-prefs": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
"drainage-mode": { bucket: "pref", scope: "global" },
"section-panel-height": {
bucket: "pref",
scope: "global",
+6 -1
View File
@@ -591,6 +591,8 @@ export interface DetailBasinResponse {
pipe_signature?: string;
/** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */
stale?: string | null;
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
}
let lastNote = { text: "", at: 0 };
@@ -640,10 +642,13 @@ export async function resetDetailPipePoints(projectId: string): Promise<DetailBa
export async function saveDetailPipePoints(
projectId: string,
points: DetailPipeInput[],
recompute = false,
): Promise<DetailBasinResponse> {
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
const saved = await requestJson<DetailBasinResponse>(
`/projects/${projectId}/drainage/pipe-points`,
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
{ method: "PUT", body: JSON.stringify({ points }) },
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
);
noteSnapshotFailed(saved.snapshot_failed);
return saved;
+225
View File
@@ -0,0 +1,225 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Live.ts
* 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석
* 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
*
* - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 —
* 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음).
* - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트].
* - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s.
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createProgressCircle } from "@ui/ui_template_progress";
import { openModal } from "@ui/ui_template_modal";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
import {
computeDetailBasins,
type DetailBasinResponse,
type DetailPipeInput,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
acceptLive,
draftMakesStale,
liveRequestBody,
parseDrainageMode,
QUIET_MS,
type DrainageMode,
} from "./logic/B05_Profile_Drainage_Live";
import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run";
import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest";
const MODE_KEY = "drainage-mode";
export interface DrainageLiveDeps {
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
root: HTMLElement;
/** 제안 목록을 이 앞에 둠. */
basinList: HTMLElement;
pipeRequest: () => DetailPipeInput[];
runner: DetailRunner;
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
rememberPipes: () => void;
}
export interface DrainageLive {
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
stage: StageFn<"drainage">;
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
refresh(): void;
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
markFresh(): void;
readonly stale: boolean;
suggest: DrainageSuggest;
}
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
async function fetchLive(
projectId: string,
body: ReturnType<typeof liveRequestBody>,
signal: AbortSignal,
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function readDrainageMode(): DrainageMode {
return parseDrainageMode(readStateRaw(MODE_KEY));
}
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
const projectId = deps.runner.projectId();
if (!projectId) return;
void deps.runner
.run(() => computeDetailBasins(projectId, points))
.then((ok) => ok && deps.rememberPipes());
});
deps.basinList.before(suggest.root);
let mode = readDrainageMode();
let route: unknown = null;
let stale = false;
let generation = 0;
let timer = 0;
let inflight: AbortController | null = null;
const veil = document.createElement("div");
veil.className = "b05-drainage__veil";
veil.hidden = true;
const update = document.createElement("button");
update.type = "button";
update.className = "b05-drainage__analyze b05-drainage__update";
update.textContent = "업데이트";
update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다.";
veil.append(update);
const busy = createProgressCircle({ size: 22, label: "계산 중" });
busy.root.classList.add("b05-drainage__busy");
busy.root.hidden = true;
viewport.append(veil, busy.root);
const gear = document.createElement("button");
gear.type = "button";
gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear";
gear.textContent = "⚙";
gear.title = "배수유역 계산 설정 — 수동(기본) / 자동";
header.append(gear);
const paint = (): void => {
deps.root.classList.toggle("is-stale", stale);
veil.hidden = !stale || mode === "auto" || inflight !== null;
busy.root.hidden = inflight === null;
};
function cancel(): void {
window.clearTimeout(timer);
timer = 0;
inflight?.abort();
inflight = null;
}
function refresh(): void {
const projectId = deps.runner.projectId();
if (!projectId) return;
cancel();
const sent = generation;
const controller = new AbortController();
inflight = controller;
paint();
const body = liveRequestBody(sent, route, deps.pipeRequest());
void deps.runner
.run(async () => {
const response = await fetchLive(projectId, body, controller.signal);
// 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌.
if (!acceptLive(response, sent, generation))
throw new DOMException("superseded", "AbortError");
return response;
}, true)
.then((applied) => {
if (inflight !== controller) return;
inflight = null;
if (applied) stale = false;
paint();
});
}
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
function invalidate(): void {
generation += 1;
cancel();
stale = true;
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
paint();
}
function openSettings(): void {
openModal({
title: "배수유역 계산",
closeLabel: "닫기",
mount(body, close) {
const choices: Array<[DrainageMode, string, string]> = [
["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"],
["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"],
];
choices.forEach(([value, label, hint]) => {
const row = document.createElement("label");
row.className = "b05-drainage__mode";
const input = document.createElement("input");
input.type = "radio";
input.name = "b05-drainage-mode";
input.checked = mode === value;
input.addEventListener("change", () => {
setMode(value);
close();
});
const text = document.createElement("span");
text.textContent = `${label} — ${hint}`;
row.append(input, text);
body.append(row);
});
},
});
}
function setMode(next: DrainageMode): void {
mode = next;
writeStateRaw(MODE_KEY, next);
window.clearTimeout(timer);
timer = 0;
if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
paint();
}
update.addEventListener("click", refresh);
gear.addEventListener("click", openSettings);
return {
stage(ctx) {
route = ctx.inputs.route;
if (draftMakesStale(route) || stale) invalidate();
return null;
},
refresh,
suggest,
markFresh() {
cancel();
stale = false;
paint();
},
get stale() {
return stale;
},
};
}
+24 -32
View File
@@ -38,6 +38,8 @@ import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Dra
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run";
import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
@@ -47,7 +49,6 @@ import {
DRAINAGE_LAYERS,
fetchDrainageLayers,
createProgressReporter,
analyzeFailed,
fitCanvasToViewport,
fitViewToRoute,
observeViewportSize,
@@ -180,7 +181,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
let frameView: Window = window;
let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
const fordWaits = () => fordWaitsOf(projectId);
let canvasWidth = 0;
@@ -276,6 +276,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
markedChainage,
stationIntervalM: MAP_STATION_INTERVAL_M,
intervalSpans,
candidates: live.suggest.candidates(),
});
updateImageTransform();
}
@@ -338,6 +339,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
function apply(response: DetailBasinResponse): void {
basins = response.basins;
live.suggest.update(response);
mainBoundary = response.main_polygon_lonlat ?? [];
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
@@ -374,32 +376,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
syncPipeSelection();
}
/** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */
async function run(request: () => Promise<DetailBasinResponse>): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
const seq = ++requestSeq;
try {
const response = await request();
if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
apply(response);
status.hidden = basins.length > 0;
if (!basins.length) status.textContent = response.stale ?? L("B05_Drainage_Status_NoBasin");
scheduleDraw();
} catch (error) {
if (seq !== requestSeq) return;
status.hidden = false;
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
} finally {
if (seq === requestSeq) {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
}
/** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */
const runner = createDetailRunner({
projectId: () => projectId,
analyzeButton,
status,
showProgress,
apply,
basinCount: () => basins.length,
scheduleDraw,
label: L,
});
const { run } = runner;
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
const pipeRequest = () =>
@@ -418,7 +406,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
* 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */
const loadSaved = (): Promise<void> =>
const loadSaved = (): Promise<boolean> =>
run(() => {
const pending = readPendingPipes(projectId as string);
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
@@ -428,12 +416,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
const resetPipes = (): Promise<void> => run(() => resetDetailPipePoints(projectId as string));
const resetPipes = (): Promise<boolean> => run(() => resetDetailPipePoints(projectId as string));
/** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김).
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
// 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
const live = createDrainageLive({ root, basinList, pipeRequest, runner, rememberPipes });
analyzeButton.addEventListener("click", () => void analyze());
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
autoButton.addEventListener("click", () => void resetPipes());
@@ -668,10 +658,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
syncPipeSelection(notify);
scheduleDraw();
},
async savePipes() {
async savePipes(confirm = false) {
if (!projectId) return 0;
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
const response = await saveDetailPipePoints(projectId, pipeRequest());
const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm);
if (confirm && !response.stale) live.markFresh();
apply(response);
scheduleDraw();
return response.pipe_count;
@@ -695,5 +686,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
widthResizer.dispose();
},
rehome: () => resizeObserver.rehome(),
pipelineStage: live.stage,
};
}
@@ -13,6 +13,7 @@ import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
export interface DrainagePanel {
root: HTMLElement;
@@ -40,7 +41,10 @@ export interface DrainagePanel {
) => void;
removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>;
/** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */
savePipes: (confirm?: boolean) => Promise<number>;
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
pipelineStage: StageFn<"drainage">;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
+2 -2
View File
@@ -61,8 +61,8 @@ export interface PipeEditor {
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
/** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */
export function batched(work: () => Promise<void>): () => Promise<void> {
let queued: Promise<void> | null = null;
export function batched<T>(work: () => Promise<T>): () => Promise<T> {
let queued: Promise<T> | null = null;
return () =>
(queued ??= Promise.resolve().then(() => {
queued = null;
@@ -70,6 +70,8 @@ export interface DrainageScene {
stationIntervalM: number;
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
intervalSpans: ReadonlyArray<RouteSpanBand>;
/** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */
candidates: ReadonlyArray<number>;
}
export function drawDrainageScene(
@@ -168,6 +170,20 @@ export function drawDrainageScene(
scene.maxHotspotArea,
);
}
// 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음).
scene.candidates.forEach((chainage) => {
const sample = scene.strengthSamples[Math.round(chainage)];
if (!sample) return;
const [x, y] = projector.toScreen(sample.x, sample.y);
context.save();
context.setLineDash([3, 3]);
context.lineWidth = 2;
context.strokeStyle = "#1e88e5";
context.beginPath();
context.arc(x, y, 9, 0, Math.PI * 2);
context.stroke();
context.restore();
});
// 배관(관 매설) 마커 — 계획선 위.
scene.pipeEditor.draw(context, view, scene.pipeColor);
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
@@ -0,0 +1,72 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Run.ts
* 세부유역 요청 한 번 감싸기 — 버튼 잠금 · 진행 문구 · 오류 표기 · 최신 응답만 채택(차례 번호).
*
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄이라 나눔(347-2). 「지금 노선으로 셈」 답도
* 같은 차례 번호를 타서, 늦게 온 세부유역 답이 새 답을 덮지 않음 — `quiet` 이면 버튼 · 가운데 진행
* 원 · 상태 글을 안 건드림(칸 구석 「계산 중」은 부르는 쪽 · 조작을 막지 않음).
* ========================================================================== */
import type { DetailBasinResponse } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { analyzeFailed } from "./B05_Profile_UI_Drainage_Parts";
type StatusKey =
| "B05_Drainage_Status_Analyzing"
| "B05_Drainage_Status_NoBasin"
| "B05_Drainage_Status_AnalyzeFailed";
export interface DetailRunnerDeps {
projectId: () => string | null;
analyzeButton: HTMLButtonElement;
status: HTMLElement;
showProgress: (ratio: number | null, label: string | null) => void;
apply: (response: DetailBasinResponse) => void;
basinCount: () => number;
scheduleDraw: () => void;
label: (key: StatusKey) => string;
}
export interface DetailRunner {
projectId: () => string | null;
/** 요청 하나 — 답을 화면에 올렸으면 true(뒤에 보낸 요청이 있으면 false). */
run(request: () => Promise<DetailBasinResponse>, quiet?: boolean): Promise<boolean>;
}
export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
const { analyzeButton, status, showProgress, label } = deps;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
return {
projectId: deps.projectId,
async run(request, quiet = false) {
if (!deps.projectId()) return false;
if (!quiet) {
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = label("B05_Drainage_Status_Analyzing");
showProgress(null, label("B05_Drainage_Status_Analyzing"));
}
const seq = ++requestSeq;
try {
const response = await request();
if (seq !== requestSeq) return false; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
deps.apply(response);
status.hidden = deps.basinCount() > 0;
if (!deps.basinCount())
status.textContent = response.stale ?? label("B05_Drainage_Status_NoBasin");
deps.scheduleDraw();
return true;
} catch (error) {
if (seq !== requestSeq) return false;
if (error instanceof DOMException && error.name === "AbortError") return false;
status.hidden = false;
status.textContent = analyzeFailed(error, label("B05_Drainage_Status_AnalyzeFailed"));
return false;
} finally {
if (seq === requestSeq) {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
},
};
}
@@ -0,0 +1,87 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Suggest.ts
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로.
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
* ========================================================================== */
import type {
DetailBasinResponse,
DetailPipeInput,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
applySuggestions,
collectSuggestions,
type Suggestion,
} from "./logic/B05_Profile_Drainage_Suggest";
const FACILITY_NAME: Record<string, string> = {
pipe: "배관",
box_culvert: "BOX암거",
ford_bridge: "세월교",
};
const facilityName = (id: string): string => FACILITY_NAME[id] ?? id;
/** 제안 한 줄 글. */
export function suggestionText(item: Suggestion): string {
const at = `측점 ${Math.round(item.chainageM)} m`;
if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`;
if (item.kind === "facility")
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
}
export interface DrainageSuggest {
root: HTMLElement;
update(response: DetailBasinResponse): void;
/** 관 자리 후보 누가거리 — 배수유역도 점선 원. */
candidates(): number[];
}
export function createDrainageSuggest(
pipeRequest: () => DetailPipeInput[],
applyPoints: (points: DetailPipeInput[]) => void,
): DrainageSuggest {
const root = document.createElement("div");
root.className = "b05-drainage__suggest";
root.hidden = true;
const head = document.createElement("div");
head.className = "b05-drainage__suggest-head";
const title = document.createElement("span");
const apply = document.createElement("button");
apply.type = "button";
apply.className = "b05-drainage__analyze";
apply.textContent = "적용";
apply.title =
"제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다.";
head.append(title, apply);
const list = document.createElement("ul");
list.className = "b05-drainage__suggest-list";
root.append(head, list);
let suggestions: Suggestion[] = [];
apply.addEventListener("click", () => {
if (suggestions.length) applyPoints(applySuggestions(pipeRequest(), suggestions));
});
return {
root,
update(response) {
suggestions = response.stale
? []
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
root.hidden = suggestions.length === 0;
title.textContent = `제안 ${suggestions.length}건`;
list.replaceChildren(
...suggestions.map((item) => {
const row = document.createElement("li");
row.textContent = suggestionText(item);
return row;
}),
);
},
candidates: () =>
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
};
}
@@ -266,3 +266,70 @@
.b05-drainage[hidden] {
display: none;
}
/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */
.b05-drainage.is-stale .b05-drainage__canvas,
.b05-drainage.is-stale .b05-drainage__image,
.b05-drainage.is-stale .b05-drainage__summary,
.b05-drainage.is-stale .b05-drainage__basins,
.b05-drainage.is-stale .b05-drainage__suggest {
filter: blur(2px);
opacity: 0.6;
}
.b05-drainage__veil {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.b05-drainage__veil[hidden] {
display: none;
}
.b05-drainage__veil .b05-drainage__update {
pointer-events: auto;
}
.b05-drainage__busy {
position: absolute;
right: var(--spacing-8);
bottom: var(--spacing-8);
}
.b05-drainage__busy[hidden] {
display: none;
}
.b05-drainage__mode {
display: flex;
gap: var(--spacing-8);
align-items: center;
padding: var(--spacing-8) 0;
color: var(--color-text-body);
}
/* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */
.b05-drainage__suggest {
flex: 0 0 auto;
border-top: 1px solid var(--color-border);
padding: var(--spacing-8);
font-size: var(--text-caption);
color: var(--color-text-body);
}
.b05-drainage__suggest-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.b05-drainage__suggest-list {
max-height: 6em;
margin: var(--spacing-8) 0 0;
padding-left: 1.2em;
overflow-y: auto;
}
@@ -0,0 +1,43 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Drainage_Live.ts
* 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`.
*
* - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음).
* - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤.
* - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동.
* - 답 채택 = 보낸 차례가 지금 차례와 같고 서버가 그만두지 않았을 때만(늦은 답 버림).
* ========================================================================== */
export type DrainageMode = "manual" | "auto";
/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */
export const QUIET_MS = 3000;
/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */
export const parseDrainageMode = (raw: string | null | undefined): DrainageMode =>
raw === "auto" ? "auto" : "manual";
/** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */
export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined;
/** 「지금 노선으로 셈」 몸통 — 서버 `post_drainage_live`. */
export function liveRequestBody<P>(
generation: number,
route: unknown,
points: P[],
): { generation: number; route: unknown; points: P[] } {
return { generation, route: route ?? null, points };
}
/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 답은 버림. */
export function acceptLive(
response: { status?: string; generation?: number },
sentGeneration: number,
currentGeneration: number,
): boolean {
return (
response.status !== "superseded" &&
sentGeneration === currentGeneration &&
(response.generation === undefined || response.generation === sentGeneration)
);
}
@@ -0,0 +1,83 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Drainage_Suggest.ts
* 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음.
* 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`).
*
* - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」).
* - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때.
* - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳).
* - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐.
* ========================================================================== */
/** 관 자리가 같은 유역으로 볼 거리(m) — 화면 `PIPE_MATCH_M` 과 같은 값. */
const SAME_PIPE_M = 0.5;
interface PipeLike {
chainage_m: number;
source?: string;
facility?: string;
options?: Record<string, string | number>;
}
interface BasinLike {
chainage_m: number;
recommended_facility?: string;
recommended_diameter_mm?: number | null;
}
export type Suggestion =
| { kind: "diameter"; chainageM: number; from: number | null; to: number }
| { kind: "facility"; chainageM: number; from: string; to: string }
| { kind: "position"; chainageM: number };
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_PIPE_M;
/** 응답(유역 · 관 자리 후보) + 지금 관 목록 → 제안 목록(관 자리 차례). */
export function collectSuggestions(
pipes: ReadonlyArray<PipeLike>,
basins: ReadonlyArray<BasinLike>,
candidates: ReadonlyArray<{ chainage_m: number }>,
): Suggestion[] {
const found: Suggestion[] = [];
basins.forEach((basin) => {
const pipe = pipes.find((item) => near(item.chainage_m, basin.chainage_m));
if (!pipe) return;
const facility = pipe.facility ?? "pipe";
const want = basin.recommended_facility ?? "pipe";
if (want !== "pipe" && facility !== want) {
found.push({ kind: "facility", chainageM: pipe.chainage_m, from: facility, to: want });
return;
}
const to = basin.recommended_diameter_mm;
const raw = pipe.options?.pipe_diameter_mm;
const from = raw === undefined || raw === "" ? null : Number(raw);
if (want === "pipe" && facility === "pipe" && to != null && from !== to)
found.push({ kind: "diameter", chainageM: pipe.chainage_m, from, to });
});
candidates
.filter((item) => !pipes.some((pipe) => near(pipe.chainage_m, item.chainage_m)))
.forEach((item) => found.push({ kind: "position", chainageM: item.chainage_m }));
return found.sort((a, b) => a.chainageM - b.chainageM);
}
/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */
export function applySuggestions<P extends PipeLike>(
pipes: ReadonlyArray<P>,
suggestions: ReadonlyArray<Suggestion>,
): P[] {
const next = pipes.map(
(pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P,
);
suggestions.forEach((item) => {
if (item.kind === "position") {
if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM)))
next.push({ chainage_m: item.chainageM, source: "spacing" } as P);
return;
}
const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM));
if (!pipe) return;
if (item.kind === "facility") pipe.facility = item.to;
else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to };
});
return next.sort((a, b) => a.chainage_m - b.chainage_m);
}
@@ -0,0 +1,57 @@
/* 347-2 · 347-3 배수유역 자동/수동 · 제안 헬퍼 — logic 두 파일을 그 자리에서 트랜스파일해
* Node 로 돌리고 값을 JSON 으로 낸다(틀 = helper_281_1).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
return box.exports;
}
const live = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Live.ts"));
const sug = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Suggest.ts"));
const pipes = [
{ chainage_m: 100, source: "stream", options: { pipe_diameter_mm: 1000 } },
{ chainage_m: 300, source: "user", facility: "pipe" },
{ chainage_m: 500, source: "stream", facility: "box_culvert" },
];
const basins = [
{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 },
{ chainage_m: 300, recommended_facility: "ford_bridge", recommended_diameter_mm: null },
{ chainage_m: 500, recommended_facility: "box_culvert", recommended_diameter_mm: null },
];
const candidates = [{ chainage_m: 200 }, { chainage_m: 500.2 }];
const before = JSON.stringify(pipes);
const suggestions = sug.collectSuggestions(pipes, basins, candidates);
const applied = sug.applySuggestions(pipes, suggestions);
process.stdout.write(
JSON.stringify({
quietMs: live.QUIET_MS,
modes: [
live.parseDrainageMode(null),
live.parseDrainageMode("auto"),
live.parseDrainageMode("x"),
],
stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })],
accept: [
live.acceptLive({ status: "success", generation: 3 }, 3, 3),
live.acceptLive({ status: "success", generation: 3 }, 3, 4),
live.acceptLive({ status: "superseded" }, 3, 3),
live.acceptLive({ status: "success", generation: 2 }, 3, 3),
],
body: live.liveRequestBody(5, undefined, [{ chainage_m: 1 }]),
suggestions,
applied,
untouched: JSON.stringify(pipes) === before,
}),
);
@@ -0,0 +1,58 @@
"""347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일).
- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음.
- 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림.
- 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) ·
[적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음).
Node 헬퍼(`helper_347_2_drainage_live.cjs`)가 TS 를 그 자리에서 돌린다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_347_2_drainage_live.cjs")],
capture_output=True,
text=True,
timeout=60,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_mode_and_stale(r):
assert r["quietMs"] == 3000
assert r["modes"] == ["manual", "auto", "manual"]
assert r["stale"] == [False, True]
def test_late_answers_dropped(r):
assert r["accept"] == [True, False, False, False]
assert r["body"] == {"generation": 5, "route": None, "points": [{"chainage_m": 1}]}
def test_suggestions_collected(r):
assert r["suggestions"] == [
{"kind": "diameter", "chainageM": 100, "from": 1000, "to": 1200},
{"kind": "position", "chainageM": 200},
{"kind": "facility", "chainageM": 300, "from": "pipe", "to": "ford_bridge"},
]
def test_apply_once_without_touching_input(r):
assert r["untouched"]
applied = {pipe["chainage_m"]: pipe for pipe in r["applied"]}
assert sorted(applied) == [100, 200, 300, 500]
assert applied[100]["options"]["pipe_diameter_mm"] == 1200
assert applied[200]["source"] == "spacing"
assert applied[300]["facility"] == "ford_bridge"
assert applied[500]["facility"] == "box_culvert" # 이미 그 종류 — 그대로