From a5c36a4a29fe31c00dcad9bbcb6ef3e3efc818bc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:47:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD?= =?UTF-8?q?):=20347-2=20=EC=9E=90=EB=8F=99/=EC=88=98=EB=8F=99=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=85=88=20=C2=B7=20=E2=9A=99=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B0=BD=20=C2=B7=20347-3=20=EC=A0=9C=EC=95=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20[=EC=A0=81=EC=9A=A9]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계산 묶음 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 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- A00_Common/b_page_state.ts | 2 + B04_PreProcess/B04_PreProcess_Api_Fetch.ts | 7 +- B05_Profile/B05_Profile_UI_Drainage_Live.ts | 225 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 56 ++--- .../B05_Profile_UI_Drainage_Panel_Types.ts | 6 +- B05_Profile/B05_Profile_UI_Drainage_Pipes.ts | 4 +- B05_Profile/B05_Profile_UI_Drainage_Render.ts | 16 ++ B05_Profile/B05_Profile_UI_Drainage_Run.ts | 72 ++++++ .../B05_Profile_UI_Drainage_Suggest.ts | 87 +++++++ B05_Profile/B05_Profile_UI_Style_Drainage.css | 67 ++++++ .../logic/B05_Profile_Drainage_Live.ts | 43 ++++ .../logic/B05_Profile_Drainage_Suggest.ts | 83 +++++++ .../tester/helper_347_2_drainage_live.cjs | 57 +++++ .../tester/test_347_2_drainage_live_ui.py | 58 +++++ 14 files changed, 747 insertions(+), 36 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Live.ts create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Run.ts create mode 100644 B05_Profile/B05_Profile_UI_Drainage_Suggest.ts create mode 100644 B05_Profile/logic/B05_Profile_Drainage_Live.ts create mode 100644 B05_Profile/logic/B05_Profile_Drainage_Suggest.ts create mode 100644 resources/tester/helper_347_2_drainage_live.cjs create mode 100644 resources/tester/test_347_2_drainage_live_ui.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 4c697c6e6..3b0edc52f 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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", diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 86d1abe35..3cd0632df 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -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 { + // [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림. const saved = await requestJson( - `/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; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts new file mode 100644 index 000000000..06cf8df7e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -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, + signal: AbortSignal, +): Promise { + 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(".b05-drainage__viewport") as HTMLElement; + const header = deps.root.querySelector(".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; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 6c6b6be19..c7a482e8b 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -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>; flowArrows = (response.flow_arrows ?? []) as FlowArrow[]; @@ -374,32 +376,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra syncPipeSelection(); } - /** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */ - async function run(request: () => Promise): Promise { - 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 => + const loadSaved = (): Promise => 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 => run(() => resetDetailPipePoints(projectId as string)); + const resetPipes = (): Promise => 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, }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 365d8a1ef..cf31e5cb2 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -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; + /** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */ + savePipes: (confirm?: boolean) => Promise; + /** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */ + pipelineStage: StageFn<"drainage">; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; /** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 34d56b1f3..341a74acb 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -61,8 +61,8 @@ export interface PipeEditor { * 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). * `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */ /** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */ -export function batched(work: () => Promise): () => Promise { - let queued: Promise | null = null; +export function batched(work: () => Promise): () => Promise { + let queued: Promise | null = null; return () => (queued ??= Promise.resolve().then(() => { queued = null; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index e5c920046..c50ea8138 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -70,6 +70,8 @@ export interface DrainageScene { stationIntervalM: number; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ intervalSpans: ReadonlyArray; + /** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */ + candidates: ReadonlyArray; } 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자 선택 동기화의 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Run.ts b/B05_Profile/B05_Profile_UI_Drainage_Run.ts new file mode 100644 index 000000000..be63c32ef --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Run.ts @@ -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, quiet?: boolean): Promise; +} + +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); + } + } + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts new file mode 100644 index 000000000..c100f29d3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Suggest.ts @@ -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 = { + 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), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 17e1ea15f..67a1699d5 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -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; +} diff --git a/B05_Profile/logic/B05_Profile_Drainage_Live.ts b/B05_Profile/logic/B05_Profile_Drainage_Live.ts new file mode 100644 index 000000000..16ab31a16 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Drainage_Live.ts @@ -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

( + 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) + ); +} diff --git a/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts b/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts new file mode 100644 index 000000000..891d4e695 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Drainage_Suggest.ts @@ -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; +} + +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, + basins: ReadonlyArray, + 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

( + pipes: ReadonlyArray

, + suggestions: ReadonlyArray, +): 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); +} diff --git a/resources/tester/helper_347_2_drainage_live.cjs b/resources/tester/helper_347_2_drainage_live.cjs new file mode 100644 index 000000000..940201632 --- /dev/null +++ b/resources/tester/helper_347_2_drainage_live.cjs @@ -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, + }), +); diff --git a/resources/tester/test_347_2_drainage_live_ui.py b/resources/tester/test_347_2_drainage_live_ui.py new file mode 100644 index 000000000..77940dcd9 --- /dev/null +++ b/resources/tester/test_347_2_drainage_live_ui.py @@ -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" # 이미 그 종류 — 그대로