diff --git a/A00_Common/design/logic/design_calc_registry_facility.ts b/A00_Common/design/logic/design_calc_registry_facility.ts index a0169494c..da11ddc46 100644 --- a/A00_Common/design/logic/design_calc_registry_facility.ts +++ b/A00_Common/design/logic/design_calc_registry_facility.ts @@ -212,10 +212,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [ site: "server_only", stage: null, ts: null, - runner: `py:${U}common_util_drainage_detail.py#suggest_pipe_positions`, + runner: `py:${U}common_util_stream_crossing.py#propose_pipes`, pair: null, mirror: [], - note: "TS 는 모으기만(B05_Profile/logic/B05_Profile_Drainage_Suggest.ts)", + note: "[자동 제안](510) — TS 는 후보 그리기 · 고름만(B05_Profile/B05_Profile_UI_Drainage_Propose.ts)", }, { id: "corridor.mesh", diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 6106be9bc..7fafa6703 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -525,6 +525,8 @@ export interface DetailPipePoint { summary_item?: SummaryItem | null; /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ group_items?: GroupItems | null; + /** 자동 세류선 관 라벨(507-2 · `{stream, order, x, y}`) — 서버 정본 · 화면은 실어 나르기만(510). */ + stream_label?: Record | null; } /** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */ diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index eb85536ba..305dfa617 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -37,6 +37,7 @@ from common_util.common_util_drainage_detail import ( DrainageDetail, build_detail, pipes_from_chainages, + read_road_routing, ) from common_util.common_util_drainage_pipes import ( PIPE_SOURCE_USER, @@ -57,7 +58,7 @@ from common_util.common_util_drainage_pipes import ( ) from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_route_geometry import StructureCandidate -from common_util.common_util_stream_crossing import keep_labels +from common_util.common_util_stream_crossing import keep_labels, propose_pipes from config.config_system import ( DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M, @@ -645,3 +646,47 @@ async def put_pipe_points( result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림 result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림 return result + + +@router.post("/{project_id}/drainage/pipe-proposals", response_model=None) +async def post_pipe_proposals( + project_id: UUID, payload: dict[str, Any] | None = None +) -> dict[str, Any] | JSONResponse: + """[자동 제안](510) — 지금 노선 × 세류선 교차 + 간격 보충 후보와 상태 · 아무것도 안 씀. + + `points` = 화면 관 목록(없으면 저장분) · 화면에서 지운 자동 관도 지움 표지로 봄(`keep_labels`). + 간격 보충은 분석이 지금 노선일 때만(`spacing` 거짓 = 교차만). + """ + from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes + from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare + + context, reason = await load_drainage_context(project_id) + if context is None: + return JSONResponse(status_code=404, content={"status": "error", "message": reason}) + path = pipe_points_path(context.stored_path) + stored = read_pipe_points_file(path) + try: + raw = (payload or {}).get("points") + points = ( + load_pipe_points( + context.stored_path, route_signature(context.vertices), context.vertices + ) + or [] + if raw is None + else parse_pipe_points(raw) + ) + except ValueError as error: + return JSONResponse(status_code=400, content={"status": "error", "message": str(error)}) + dismissed = read_dismissed(path) + keep_labels(points, stored, dismissed) + prepared = await _prepare(project_id) + streams = [] if isinstance(prepared, JSONResponse) else prepared["streams"] + fresh = analysis_route_signature(context.stored_path) == route_signature(context.vertices) + routing = read_road_routing(drainage_dir(context.stored_path)) if fresh else None + curve = routing.strength_curve if routing is not None else None + proposals = await asyncio.to_thread( + lambda: propose_pipes( + context.vertices, _base_pipes(context.vertices, streams), points, dismissed, curve + ) + ) + return {"status": "success", "proposals": proposals, "spacing": curve is not None} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts index 7a82c1545..2bf510e3d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Chrome.ts @@ -21,7 +21,7 @@ export interface DrainageChrome { analyzeButton: HTMLButtonElement; deleteButton: HTMLButtonElement; autoButton: HTMLButtonElement; - /** [자동 제안](369) — 고름 창 · 배선은 `B05_Profile_UI_Drainage_Live`. */ + /** [자동 제안](510) — 후보 목록 · 배선은 패널(`B05_Profile_UI_Drainage_Propose`). */ proposeButton: HTMLButtonElement; viewport: HTMLElement; backgroundImage: HTMLImageElement; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index f2cf3a0dc..14e7ebcea 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -94,11 +94,13 @@ export interface FacilityAttributes { summary_item?: SummaryItem | null; /** 옵션 그룹마다 고른 집계 항목(PLAN 46-3) — 없음 = 그룹 미지정. */ group_items?: GroupItems | null; + /** 자동 세류선 관 라벨(507-2) — [자동 제안]으로 넣은 관 · 응답 · 초안 왕복에 실어 나름(510). */ + stream_label?: DetailPipeInput["stream_label"]; } /** 확장 정보가 실제로 있는지 — 배관이면서 아무 부속도 없으면 보관할 이유가 없다. */ function hasExtras(attributes: FacilityAttributes): boolean { - if (hasLinks(attributes)) return true; // 집계 항목(본체 · 그룹)만 지정한 맨 배관도 보관한다 + if (hasLinks(attributes) || attributes.stream_label) return true; // 집계 항목 · 라벨만 있어도 if (attributes.facility !== "pipe") return true; if (attributes.start_m !== undefined || attributes.end_m !== undefined) return true; return !!attributes.options && Object.keys(attributes.options).length > 0; @@ -134,17 +136,7 @@ export function createFacilityStore() { return { /** 서버 응답으로 전체를 다시 채운다 — 응답이 정본이다. */ - replaceFromResponse( - points: ReadonlyArray<{ - chainage_m: number; - facility?: PipeFacility; - start_m?: number; - end_m?: number; - options?: Record; - summary_item?: SummaryItem | null; - group_items?: GroupItems | null; - }>, - ): void { + replaceFromResponse(points: ReadonlyArray>): void { entries = points .map((point) => { // 응답이 정본이다 — 서버는 미지정이면 칸을 안 싣는다(`as_dict`). 예전엔 칸이 없으면 화면이 쥔 @@ -156,6 +148,7 @@ export function createFacilityStore() { start_m: point.start_m, end_m: point.end_m, options: point.options, + ...(point.stream_label ? { stream_label: point.stream_label } : {}), ...linkFields(point), } as FacilityAttributes, }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Live.ts b/B05_Profile/B05_Profile_UI_Drainage_Live.ts index 0a92686af..e3a4116aa 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Live.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Live.ts @@ -14,10 +14,10 @@ * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. - * - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차 - * 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) · - * 낡은 동안 [자동 제안] 끔 · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길에 - * 방식을 실어 보냄(저장 분석으로 셈 = 옛 노선 자리 · 409). + * - [배관 배치 초기화] = 세류선 교차 관만 · 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 + * 답도 앞 걸음에 안 묻힘) · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길로 + * (저장 분석으로 셈 = 옛 노선 자리 · 409). + * - [자동 제안](510 · 후보 목록 = `B05_Profile_UI_Drainage_Propose`) — 낡은 동안 끔 · 낡으면 목록 닫음. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -56,6 +56,8 @@ export interface DrainageLiveDeps { rememberPipes: () => void; /** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */ loadSaved: () => Promise; + /** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(510). */ + onStale?: () => void; } export interface DrainageLive { @@ -100,8 +102,8 @@ const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLangu /** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */ const STALE_PREFIX = "배수유역 낡음"; -/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */ -type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" }; +/** [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */ +type Proposal = { points: DetailPipeInput[] | null }; export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const viewport = deps.root.querySelector(".b05-drainage__viewport") as HTMLElement; @@ -129,7 +131,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { let drafted = false; /** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */ let savedStale = false; - /** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */ + /** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [배관 배치 초기화]는 지금 노선으로 셈. */ let analysisStale = false; let generation = 0; /** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */ @@ -205,13 +207,13 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { }); } - /** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) · + /** [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) · * 그 길이 「낡음」(409 · 화면이 아직 몰랐던 낡음)이면 곧바로 지금 노선으로 셈 길로 다시. */ function proposeDraft(proposal: Proposal): void { if (analysisStale || draftMakesStale(route)) return refresh(proposal); draft(async (projectId) => { try { - return await computeDetailBasins(projectId, proposal.points, proposal.auto); + return await computeDetailBasins(projectId, proposal.points); } catch (error) { if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error; analysisStale = true; @@ -225,6 +227,7 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { function invalidate(): void { cancel(); stale = true; + deps.onStale?.(); if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS); paint(); } @@ -272,36 +275,6 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { }); } - /** [자동 제안] 고름 창 — ⚙ 창과 같은 틀 · 고르면 닫고 그 방식으로 초안. */ - function openPropose(): void { - if (stale) return; - openModal({ - title: L("B05_Drainage_Propose_Title"), - closeLabel: "닫기", - mount(body, close) { - const choices: Array<["all" | "add", string, string]> = [ - ["all", L("B05_Drainage_Propose_All"), L("B05_Drainage_Propose_All_Hint")], - ["add", L("B05_Drainage_Propose_Add"), L("B05_Drainage_Propose_Add_Hint")], - ]; - choices.forEach(([value, label, hint], order) => { - const row = document.createElement("label"); - row.className = "b05-drainage__mode"; - const input = document.createElement("input"); - input.type = "radio"; - input.name = "b05-drainage-propose"; - input.addEventListener("change", () => { - close(); - proposeDraft({ points: deps.pipeRequest(), auto: value }); - }); - const text = document.createElement("span"); - text.textContent = `${order === 0 ? "①" : "②"} ${label} — ${hint}`; - row.append(input, text); - body.append(row); - }); - }, - }); - } - function setMode(next: DrainageMode): void { mode = next; writeStateRaw(MODE_KEY, next); @@ -313,7 +286,6 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { update.addEventListener("click", () => refresh()); gear.addEventListener("click", openSettings); - propose?.addEventListener("click", openPropose); return { stage(ctx) { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a947d28ca..03e9f3ef5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -30,7 +30,7 @@ import { resampleRoute } from "../B04_PreProcess/logic/B04_PreProcess_UI_RouteSa import { batched, createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { followMovedPipe, PIPE_MATCH_M } from "../A00_Common/design/design_pipe_ids"; import { snapStructureStation } from "../common_util/common_util_structure_station"; -import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; +import { createFacilityStore, type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./logic/B05_Profile_UI_Drainage_Facility_Merge"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; @@ -38,6 +38,7 @@ import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_ 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 { createDrainagePropose } from "./B05_Profile_UI_Drainage_Propose"; 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"; @@ -104,6 +105,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra analyzeButton, deleteButton, autoButton, + proposeButton, viewport, backgroundImage, canvas, @@ -243,7 +245,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage, stationIntervalM: MAP_STATION_INTERVAL_M, intervalSpans, - candidates: live.suggest.candidates(), + candidates: [...live.suggest.candidates(), ...propose.candidates()], }); updateImageTransform(); } @@ -401,11 +403,35 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra runner, rememberPipes: () => (rememberPipes(), callbacks.onPipesDrafted?.()), // + 사진첩 한 걸음(369-4) loadSaved: () => run(loadSaved, true), // 초안 버림 = 저장분 조용히(349-0i) + onStale: () => propose.close(), }); + // [자동 제안](510) — 서버 후보 · 고른 것만 관 넣기 길(세류선 교차 = 라벨 실음) + const propose = createDrainagePropose({ + projectId: () => projectId, + pipeRequest, + addPipes: (picks) => + picks.forEach((item) => + addPipe( + item.chainage_m, + item.stream_label ? { facility: "pipe", stream_label: item.stream_label } : undefined, + ), + ), + redraw: scheduleDraw, + }); + basinList.before(propose.root); + proposeButton.addEventListener("click", () => void propose.open()); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); autoButton.addEventListener("click", live.resetToStreams); // 세류선만 · 초안(369) + function addPipe(chainageM: number, attributes?: FacilityAttributes): void { + chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) + // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. + facilityStore.set(chainageM, attributes ?? null); + if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM); + pipeEditor.addAtChainage(chainageM); + } + /** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인). * * B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다 @@ -596,13 +622,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra intervalSpans = spans; scheduleDraw(); }, - addPipe(chainageM, attributes) { - chainageM = snapStructureStation(chainageM); // 관 기준 측점 = 정수 m(234-1) - // 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다. - facilityStore.set(chainageM, attributes ?? null); - if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits().add(chainageM); - pipeEditor.addAtChainage(chainageM); - }, + addPipe, updatePipeFacility(fromChainageM, toChainageM, attributes) { // 옮겼으면 정수 m · 속성 열쇠도 그 자리로(234-1) — 값만 고친 옛 소수 관은 제자리 if (Math.abs(toChainageM - fromChainageM) > 0.005) @@ -610,7 +630,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra // ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임. const previous = facilityStore.get(fromChainageM); facilityStore.set(fromChainageM, null); - facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes)); + const label = previous?.stream_label ? { stream_label: previous.stream_label } : {}; // 510 + facilityStore.set(toChainageM, { ...mergeFacilityOptions(previous, attributes), ...label }); if (fordWaits().delete(fromChainageM)) fordWaits().add(toChainageM); if (Math.abs(fromChainageM - toChainageM) > 0.005) { // 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Propose.ts b/B05_Profile/B05_Profile_UI_Drainage_Propose.ts new file mode 100644 index 000000000..469ba638e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Propose.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Propose.ts + * 배수유역 칸 [자동 제안](510) — 서버가 후보를 셈(`post_pipe_proposals` · 세류선 교차 + 간격 보충 · + * 있는 관 · 지운 관 상태) · 화면은 목록 + 배수유역도 점선 원 · 고른 것만 관 넣기 길(`addPipe`)로. + * + * - 있는 관(라벨 짝 · 교차 5 m 안 관) = 목록에서 뺌(머리 글에 수만) · 지운 관 = 「지운 관」 표시 · + * 고르면 다시 넣음(서버가 지움 표지 걷음). + * - 아무것도 미리 안 고름 · [넣기] = 고른 것 · 세류선 교차 = 라벨 실음(노선 바뀐 [저장] 따라감 · 507-2). + * - 노선 · 관이 바뀌면(배수유역 낡음) 목록 닫음 — 옛 노선 후보가 새 노선에 안 들어가게. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +export interface PipeProposal { + chainage_m: number; + kind: "stream" | "spacing"; + state: "new" | "held" | "dismissed"; + stream_label?: DetailPipeInput["stream_label"]; + stream_name?: string | null; +} + +interface ProposalResponse { + proposals: PipeProposal[]; + /** 간격 보충까지 셈(분석이 지금 노선) — 거짓 = 세류선 교차만. */ + spacing: boolean; + message?: string; +} + +/** 후보 한 줄 글. */ +export function proposalText(item: PipeProposal): string { + const at = `측점 ${Math.round(item.chainage_m)} m`; + const what = + item.kind === "stream" + ? `세류선 교차${item.stream_name ? `(${item.stream_name})` : ""}` + : "간격 보충(흐름이 모이는 자리)"; + return `${at} — ${what}${item.state === "dismissed" ? " · 지운 관" : ""}`; +} + +export interface DrainagePropose { + root: HTMLElement; + /** 서버에 후보를 물어 목록을 엶. */ + open(): Promise; + close(): void; + /** 목록에 오른 후보 누가거리 — 배수유역도 점선 원. */ + candidates(): number[]; +} + +export function createDrainagePropose(deps: { + projectId: () => string | null; + pipeRequest: () => DetailPipeInput[]; + /** 고른 후보를 관 넣기 길로(패널 `addPipe` 와 같은 길). */ + addPipes: (picks: PipeProposal[]) => void; + redraw: () => void; +}): DrainagePropose { + const root = document.createElement("div"); + root.className = "b05-drainage__suggest b05-drainage__proposals"; + root.hidden = true; + const head = document.createElement("div"); + head.className = "b05-drainage__suggest-head"; + const title = document.createElement("span"); + const add = document.createElement("button"); + add.type = "button"; + add.className = "b05-drainage__analyze"; + add.textContent = "넣기"; + add.title = "고른 후보만 관으로 넣습니다 — 초안이라 [저장] 때 남습니다."; + const shut = document.createElement("button"); + shut.type = "button"; + shut.className = "b05-drainage__analyze"; + shut.textContent = "닫기"; + head.append(title, add, shut); + const list = document.createElement("ul"); + list.className = "b05-drainage__suggest-list b05-drainage__proposals-list"; + root.append(head, list); + let shown: PipeProposal[] = []; + const picked = new Set(); + let sequence = 0; + + const close = (): void => { + sequence += 1; + root.hidden = true; + shown = []; + picked.clear(); + list.replaceChildren(); + deps.redraw(); + }; + + const render = (response: ProposalResponse): void => { + shown = response.proposals.filter((item) => item.state !== "held"); + const held = response.proposals.length - shown.length; + title.textContent = + `자동 제안 ${shown.length}건` + + (held ? ` · 있는 관 ${held} 뺌` : "") + + (response.spacing ? "" : " · 배수유역 낡음 — 세류선 교차만"); + list.replaceChildren( + ...shown.map((item, index) => { + const row = document.createElement("li"); + const label = document.createElement("label"); + const box = document.createElement("input"); + box.type = "checkbox"; + box.addEventListener("change", () => { + if (box.checked) picked.add(index); + else picked.delete(index); + add.disabled = picked.size === 0; + }); + label.append(box, ` ${proposalText(item)}`); + row.append(label); + return row; + }), + ); + add.disabled = true; + root.hidden = false; + deps.redraw(); + }; + + add.addEventListener("click", () => { + const picks = shown.filter((_, index) => picked.has(index)); + close(); + if (picks.length) deps.addPipes(picks); + }); + shut.addEventListener("click", close); + + return { + root, + async open() { + const projectId = deps.projectId(); + if (!projectId) return; + const sent = ++sequence; + title.textContent = "자동 제안 — 후보 셈 중"; + root.hidden = false; + list.replaceChildren(); + add.disabled = true; + try { + const response = await fetch( + `${API_BASE_URL}/projects/${projectId}/drainage/pipe-proposals`, + { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ points: deps.pipeRequest() }), + }, + ); + const payload = (await response.json()) as ProposalResponse; + if (sent !== sequence) return; + if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + render(payload); + } catch (error) { + if (sent !== sequence) return; + title.textContent = `자동 제안 실패 — ${error instanceof Error ? error.message : error}`; + } + }, + close, + candidates: () => (root.hidden ? [] : shown.map((item) => item.chainage_m)), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 2ca878278..a3f553295 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -334,3 +334,18 @@ padding-left: 1.2em; overflow-y: auto; } + +/* [자동 제안] 후보 목록(510) — 고름 칸 · 목록 위 [넣기] · [닫기]. */ +.b05-drainage__proposals-list { + max-height: 9em; + padding-left: 0; + list-style: none; +} + +.b05-drainage__proposals .b05-drainage__suggest-head { + gap: var(--spacing-8); +} + +.b05-drainage__proposals .b05-drainage__suggest-head > span { + flex: 1 1 auto; +} diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 0895ce5bf..b31b98b98 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -500,6 +500,7 @@ def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) point.summary_item = ref.summary_item point.group_items = ref.group_items point.id = ref.id + point.stream_label = ref.stream_label # 화면이 실어 온 라벨(510 · 없으면 저장 때 id 로) # 땅 위 자리도 — 버리면 저장이 노선 위 측점 자리로 채워 정본 좌표를 덮음(493-4) point.x, point.y = ref.x, ref.y return ensure_pipe_ids(base) diff --git a/common_util/common_util_stream_crossing.py b/common_util/common_util_stream_crossing.py index 0b194c40e..150cdab77 100644 --- a/common_util/common_util_stream_crossing.py +++ b/common_util/common_util_stream_crossing.py @@ -119,8 +119,8 @@ def _gap(label: dict[str, Any], x: float, y: float) -> float: return math.hypot(float(label["x"]) - x, float(label["y"]) - y) -def claim_crossings(labels: Sequence[dict[str, Any]], crossings: Sequence[Any]) -> set[int]: - """라벨마다 같은 세류선 교차점 중 라벨 좌표와 가장 가까운 것 하나 — 잡힌 교차점 번호. +def claim_crossings(labels: Sequence[dict[str, Any]], crossings: Sequence[Any]) -> dict[int, int]: + """라벨마다 같은 세류선 교차점 중 라벨 좌표와 가장 가까운 것 하나 — {교차점 번호: 라벨 번호}. 가까운 짝부터 하나씩(라벨 하나 · 교차점 하나) · 거리 한계 없음. """ @@ -132,12 +132,12 @@ def claim_crossings(labels: Sequence[dict[str, Any]], crossings: Sequence[Any]) if getattr(crossing, "stream_id", None) == label["stream"] ) used_labels: set[int] = set() - used: set[int] = set() + used: dict[int, int] = {} for _, li, ci in pairs: if li in used_labels or ci in used: continue used_labels.add(li) - used.add(ci) + used[ci] = li return used @@ -220,3 +220,62 @@ def keep_labels( continue if mark not in dismissed: dismissed.append(mark) + # 지운 자동 관을 [자동 제안]으로 다시 넣음(510) — 그 지움 표지는 걷음 + held = [point.stream_label for point in points if point.stream_label] + dismissed[:] = [mark for mark in dismissed if mark not in held] + + +def propose_pipes( + vertices: Sequence[RouteVertex], + crossings: Sequence[Any], + pipes: Sequence[Any], + dismissed: Sequence[dict[str, Any]], + strength_curve: Any = None, +) -> list[dict[str, Any]]: + """[자동 제안] 후보(510) — 세류선 교차(`_base_pipes` · 라벨) + 간격 보충(흐름 세기 · 분석이 + 지금 노선일 때) · 관 목록엔 안 넣음(사용자가 골라 관 넣기 길로). + + 상태 = held(라벨 짝 관 · 교차 5 m 안 관) · dismissed(지움 표지 짝 · 라벨 = 그 표지) · new. + 짝 잡기 = 노선 [저장](`add_crossing_pipes`)과 같은 `claim_crossings`(관 라벨 + 지움 표지 함께). + 간격 보충 = 지금 관 + 새 교차 후보 사이가 최대 간격을 넘는 구간(`suggest_pipe_positions`). + """ + from common_util.common_util_drainage_detail import suggest_pipe_positions + from common_util.common_util_route_geometry import interpolate_vertex + from common_util.common_util_structure_station import snap_structure_station + + total = float(vertices[-1].chainage_m) + held = [pipe.stream_label for pipe in pipes if pipe.stream_label] + marks = [mark for mark in dismissed if "stream" in mark] + claimed = claim_crossings([*held, *marks], crossings) + labels = crossing_labels(crossings) + taken = [float(pipe.chainage_m) for pipe in pipes] + found: list[dict[str, Any]] = [] + for index, crossing in enumerate(crossings): + at = snap_structure_station(float(crossing.chainage_m), [total]) + label, state = labels[index], "new" + if index in claimed: + pick = claimed[index] + state = "held" if pick < len(held) else "dismissed" + if state == "dismissed": + label = marks[pick - len(held)] + elif any(abs(other - at) < DRAINAGE_PIPE_MIN_SPACING_M for other in taken): + state = "held" + found.append( + { + "chainage_m": at, + "kind": "stream", + "state": state, + "stream_label": dict(label), + "stream_name": getattr(crossing, "stream_name", None), + } + ) + if strength_curve is not None: + base = sorted({*taken, *(item["chainage_m"] for item in found if item["state"] == "new")}) + placed = [ + StructureCandidate(chainage_m=c, x=xy[0], y=xy[1], reason="confirmed") + for c in base + for xy in [interpolate_vertex(list(vertices), c)] + ] + for pipe in suggest_pipe_positions(list(vertices), placed, strength_curve): + found.append({"chainage_m": float(pipe.chainage_m), "kind": "spacing", "state": "new"}) + return sorted(found, key=lambda item: item["chainage_m"]) diff --git a/resources/tester/helper_349_drainage_live_flow.cjs b/resources/tester/helper_349_drainage_live_flow.cjs index 9946d7eaa..9e0306e01 100644 --- a/resources/tester/helper_349_drainage_live_flow.cjs +++ b/resources/tester/helper_349_drainage_live_flow.cjs @@ -325,7 +325,7 @@ async function main() { savedAnswer({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }); await flush(); out.undoSavedStale = { ...look(), savedLoads, mode: state.get("drainage-mode") }; - // 369 [자동 제안] — 낡은 동안 끔 · ① all · ② add · [배관 배치 초기화] = 관 목록 없이(세류선만) + // [자동 제안] — 낡은 동안 끔 · 누름은 패널 후보 목록(510 · 여기 안 셈) · [배관 배치 초기화] = 관 목록 없이(세류선만) out.proposeStale = { off: propose.disabled, title: propose.title }; const beforeStale = detailCalls.length; propose.fire("click"); @@ -336,10 +336,6 @@ async function main() { const n0 = detailCalls.length; const remembered0 = remembered; const records0 = records; - modeRow = 0; - propose.fire("click"); - await flush(); - modeRow = 1; propose.fire("click"); await flush(); live.resetToStreams(); @@ -352,14 +348,13 @@ async function main() { }; // 화면이 몰랐던 낡음 — 빠른 길이 409 낡음이면 곧바로 지금 노선으로 셈 길로 다시 detailStale = true; - modeRow = 0; const fallbackFrom = calls.length; - propose.fire("click"); + live.resetToStreams(); await flush(); const fallbackCall = calls[calls.length - 1]; out.proposeFallback = { sent: calls.length - fallbackFrom, - auto: fallbackCall.body.auto, + points: fallbackCall.body.points, status: status.textContent, }; answer(fallbackCall); @@ -371,18 +366,11 @@ async function main() { answer(calls[calls.length - 1]); await flush(); const viaDetail = detailCalls.length; - modeRow = 0; - propose.fire("click"); - const liveCall = calls[calls.length - 1]; - answer(liveCall); - await flush(); live.resetToStreams(); const resetCall = calls[calls.length - 1]; answer(resetCall); await flush(); out.proposeLive = { - auto: liveCall.body.auto, - points: liveCall.body.points.length, resetPoints: resetCall.body.points, resetAuto: resetCall.body.auto ?? null, detailCalls: detailCalls.length - viaDetail, diff --git a/resources/tester/helper_510_propose.cjs b/resources/tester/helper_510_propose.cjs new file mode 100644 index 000000000..6f0676ebf --- /dev/null +++ b/resources/tester/helper_510_propose.cjs @@ -0,0 +1,98 @@ +/* 510-1 [자동 제안] 후보 목록 — 진짜 `B05_Profile_UI_Drainage_Propose.ts` · 관 속성 보관소를 가짜 DOM · fetch 로. + * node helper_510_propose.cjs (짝 = `test_510_auto_pipe_propose.py`) */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); + +/* ---- 가짜 DOM(쓰는 것만) ---- */ +function el(tag) { + const listeners = {}; + const node = { + tag, + children: [], + hidden: false, + disabled: false, + checked: false, + textContent: "", + append(...items) { + items.forEach((item) => + node.children.push(typeof item === "string" ? { text: item } : item), + ); + }, + replaceChildren(...items) { + node.children = []; + node.append(...items); + }, + addEventListener(type, fn) { + (listeners[type] ??= []).push(fn); + }, + fire(type) { + (listeners[type] ?? []).forEach((fn) => fn()); + }, + }; + return node; +} +globalThis.document = { createElement: el }; + +const sent = []; +globalThis.fetch = async (url, init) => { + sent.push({ url, body: JSON.parse(init.body) }); + return { + ok: true, + json: async () => ({ + spacing: true, + proposals: [ + { chainage_m: 50, kind: "stream", state: "dismissed", stream_label: { stream: "S1", order: 1 } }, + { chainage_m: 150, kind: "stream", state: "held", stream_label: { stream: "S2", order: 1 } }, + { + chainage_m: 320, + kind: "stream", + state: "new", + stream_label: { stream: "S4", order: 1 }, + stream_name: "작은골", + }, + { chainage_m: 420, kind: "spacing", state: "new" }, + ], + }), + }; +}; + +const { load } = makeLoader({ root: ROOT }); +const { createDrainagePropose } = load("B05_Profile", "B05_Profile_UI_Drainage_Propose.ts"); +const { createFacilityStore } = load("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts"); + +(async () => { + const added = []; + const propose = createDrainagePropose({ + projectId: () => "p1", + pipeRequest: () => [{ chainage_m: 150, source: "user" }], + addPipes: (picks) => + picks.forEach((item) => added.push([item.chainage_m, item.stream_label?.stream ?? null])), + redraw: () => undefined, + }); + await propose.open(); + const [head, list] = propose.root.children; + const [title, add] = head.children; + const boxes = list.children.map((row) => row.children[0].children[0]); + const out = { + sent: { url: sent[0].url, points: sent[0].body.points.length }, + rows: list.children.map((row) => row.children[0].children[1].text.trim()), + title: title.textContent, + addOff: add.disabled, + candidates: propose.candidates(), + }; + boxes[1].checked = true; + boxes[1].fire("change"); + boxes[2].checked = true; + boxes[2].fire("change"); + add.fire("click"); + out.added = added; + out.afterClose = { hidden: propose.root.hidden, candidates: propose.candidates() }; + + // 관 속성 보관소 — 응답 라벨이 다음 요청에 실림(라벨만 있는 맨 배관도 보관) + const store = createFacilityStore(); + store.replaceFromResponse([{ chainage_m: 320, stream_label: { stream: "S4", order: 1 } }]); + out.carried = store.attach([{ chainage_m: 320, source: "user" }])[0].stream_label ?? null; + console.log(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_349_drainage_live_flow.py b/resources/tester/test_349_drainage_live_flow.py index 16ccc91f3..13bbc5bb1 100644 --- a/resources/tester/test_349_drainage_live_flow.py +++ b/resources/tester/test_349_drainage_live_flow.py @@ -75,24 +75,23 @@ def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r): "calls": 0, # 낡은 동안 끔 — 눌러도 요청 없음 } assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"} + # [자동 제안] 누름 = 패널 후보 목록(510 · test_510) — 여기선 초기화만 초안 assert r["propose"] == { - "autos": ["all", "add", None], - "points": [1, 1, None], # ① ② = 지금 관 목록 함께 · 초기화 = 목록 없이(세류선만) - "remembered": 3, # 셋 다 초안 - "records": 3, # 답마다 사진첩 한 걸음 + "autos": [None], + "points": [None], # 초기화 = 목록 없이(세류선만) + "remembered": 1, + "records": 1, # 답마다 사진첩 한 걸음 } # 저장 분석이 옛 노선 — 지금 노선으로 셈 길(detail-basins 안 부름) · 같은 초안 · 한 걸음 assert r["proposeLive"] == { - "auto": "all", - "points": 1, "resetPoints": None, "resetAuto": None, "detailCalls": 0, - "remembered": 6, # 앞 「409 → 다시」 한 번 포함 - "records": 6, + "remembered": 3, # 앞 「409 → 다시」 한 번 포함 + "records": 3, } fallback = r["proposeFallback"] - assert fallback["sent"] == 1 and fallback["auto"] == "all" + assert fallback["sent"] == 1 and fallback["points"] is None assert "낡음" not in fallback["status"] # 409 글을 오류로 안 띄움(조용히 다시) diff --git a/resources/tester/test_510_auto_pipe_propose.py b/resources/tester/test_510_auto_pipe_propose.py new file mode 100644 index 000000000..6169e971a --- /dev/null +++ b/resources/tester/test_510_auto_pipe_propose.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""510-1 [자동 제안] = B05 배수유역 영역 후보 → 사용자가 골라 넣음(요구 320-2). + +못박는 것 + - 후보 = 지금 노선 × 세류선 교차(5 m 솎기 · 정수 m · 라벨) + 간격 보충(흐름 세기 · 분석 있을 때만). + - 상태 = 라벨 짝 관 · 교차 5 m 안 관 = held · 지움 표지 짝 = dismissed(라벨 = 그 표지) · 나머지 new. + - 고른 교차 후보 = 라벨 실은 관 → 계산 왕복(carry) · 저장(keep_labels) 지나도 라벨 · 지운 관을 다시 + 넣으면 지움 표지 걷음. + - 넣은 라벨 관 = 노선 바뀐 [저장] 따라감 · 새 관 안 더함(겹침 없음). + - 화면: 아무것도 미리 안 고름 · [넣기] = 고른 것만 · held 는 목록 밖 · 관 속성 보관소가 라벨 왕복. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B05_Profile.B05_Profile_Route_Save as save # noqa: E402 +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes # noqa: E402 +from B05_Profile.B05_Profile_Route_Save_Parts import vertices_of # noqa: E402 +from common_util.common_util_drainage_pipes import ( # noqa: E402 + PipePoint, + carry_facility_attributes, + parse_pipe_points, +) +from common_util.common_util_stream_crossing import keep_labels, propose_pipes # noqa: E402 + +HERE = Path(__file__).resolve().parent +TSC = ROOT / "config" / "node_modules" / "typescript" + + +def _stream(ufid: str, x: float) -> dict: + return { + "type": "Feature", + "properties": {"UFID": ufid}, + "geometry": {"type": "LineString", "coordinates": [[x, -50.0], [x, 120.0]]}, + } + + +LINE = [[0.0, 0.0], [300.0, 0.0], [600.0, 0.0]] +STREAMS = [_stream("S1", 50.4), _stream("S2", 150.0), _stream("S3", 250.0), _stream("S4", 320.0)] + + +def _case(): + vertices = vertices_of(LINE) + crossings = _base_pipes(vertices, STREAMS) + s2 = {"stream": "S2", "order": 1, "x": 150.0, "y": 0.0} + pipes = [ + PipePoint(chainage_m=150.0, source="user", stream_label=s2, id="p15000"), + PipePoint(chainage_m=252.0, source="user", id="p25200"), # 라벨 없음 · S3 교차 5 m 안 + ] + dismissed = [{"stream": "S1", "order": 1, "x": 50.4, "y": 0.0}] + return vertices, crossings, pipes, dismissed + + +def test_후보_교차_상태_라벨(): + vertices, crossings, pipes, dismissed = _case() + found = propose_pipes(vertices, crossings, pipes, dismissed) + assert [(f["chainage_m"], f["kind"], f["state"]) for f in found] == [ + (50.0, "stream", "dismissed"), # 정수 m + (150.0, "stream", "held"), + (250.0, "stream", "held"), + (320.0, "stream", "new"), + ] + assert found[0]["stream_label"] == dismissed[0] # 지운 관 = 그 표지 그대로(다시 넣으면 걷음) + assert found[3]["stream_label"] == {"stream": "S4", "order": 1, "x": 320.0, "y": 0.0} + + +def test_간격_보충_분석_있을_때만(): + vertices, crossings, pipes, dismissed = _case() + curve = np.ones(601) + found = propose_pipes(vertices, crossings, pipes, dismissed, curve) + spacing = [f for f in found if f["kind"] == "spacing"] + assert spacing and all(f["state"] == "new" and "stream_label" not in f for f in spacing) + # 지금 관 + 새 교차 후보 사이 최대 간격(100 m) 넘는 구간만 · 320 뒤 280 m + taken = [150.0, 252.0, 320.0] + assert all(min(abs(f["chainage_m"] - t) for t in taken) > 0.5 for f in spacing) + assert any(f["chainage_m"] > 320.0 for f in spacing) + assert not [f for f in propose_pipes(vertices, crossings, pipes, dismissed) if f["kind"] == "spacing"] + + +def test_고른_라벨_관_계산_왕복_저장_지나도_라벨(): + label = {"stream": "S4", "order": 1, "x": 320.0, "y": 0.0} + request = parse_pipe_points([{"chainage_m": 320.0, "source": "user", "stream_label": label}]) + rebuilt = carry_facility_attributes([PipePoint(chainage_m=320.0, source="user")], request) + assert rebuilt[0].stream_label == label # 고치기 전 = 계산 왕복에서 사라짐 + dismissed: list = [] + keep_labels(rebuilt, [], dismissed) + assert rebuilt[0].stream_label == label and dismissed == [] + + +def test_지운_관_다시_넣으면_지움_표지_걷음(): + mark = {"stream": "S1", "order": 1, "x": 50.4, "y": 0.0} + other = {"stream": "S9", "order": 1, "x": 9.0, "y": 0.0} + dismissed = [mark, other] + keep_labels([PipePoint(chainage_m=50.0, stream_label=dict(mark))], [], dismissed) + assert dismissed == [other] + + +# 507-2 시험과 같은 꼴 — 앞 노선을 북으로 옮겨 세류선 교차가 30 m 옮음 +OLD = [[0.0, 0.0], [100.0, 0.0], [200.0, 0.0], [300.0, 0.0], [400.0, 0.0]] +NEW1 = [[0.0, 0.0], [100.0, 0.0], [200.0, 60.0], [300.0, 0.0], [400.0, 0.0]] +NEW2 = [[0.0, 0.0], [100.0, 0.0], [200.0, 90.0], [300.0, 0.0], [400.0, 0.0]] +ONE = [_stream("S1", 150.0)] + + +def _follow(old, old_xy, new_xy): + points = [{"x": x, "y": y} for x, y in new_xy] + pairs, crossing, _ = save.followed_pipes(old, old_xy, points, ONE, []) + return [pipe for _, pipe in pairs], crossing + + +def test_넣은_라벨_관_노선_바꿈_따라감_겹침_없음(): + [cand] = propose_pipes(vertices_of(OLD), _base_pipes(vertices_of(OLD), ONE), [], []) + assert cand["state"] == "new" + picked = PipePoint( + chainage_m=cand["chainage_m"], source="user", x=150.0, y=0.0, + stream_label=cand["stream_label"], id="p15000", + ) # fmt: skip + pipes, crossing = _follow([picked], OLD, NEW1) + assert crossing == 0 and len(pipes) == 1 and pipes[0].stream_label == cand["stream_label"] + again = parse_pipe_points([pipe.as_dict() for pipe in pipes]) + pipes2, crossing2 = _follow(again, NEW1, NEW2) + assert crossing2 == 0 and len(pipes2) == 1 + # 새 노선에서 다시 물으면 = 있는 관(held) · 후보 아님 + [again_cand] = propose_pipes(vertices_of(NEW2), _base_pipes(vertices_of(NEW2), ONE), pipes2, []) + assert again_cand["state"] == "held" + + +@pytest.mark.skipif(shutil.which("node") is None or not TSC.is_dir(), reason="node · typescript 없음") +def test_화면_고른_것만_넣음_라벨_왕복(): + run = subprocess.run( + ["node", str(HERE / "helper_510_propose.cjs")], + capture_output=True, text=True, encoding="utf-8", timeout=60, check=True, + ) # fmt: skip + got = json.loads(run.stdout) + assert got["sent"] == {"url": "http://fake/projects/p1/drainage/pipe-proposals", "points": 1} + assert got["rows"] == [ + "측점 50 m — 세류선 교차 · 지운 관", + "측점 320 m — 세류선 교차(작은골)", + "측점 420 m — 간격 보충(흐름이 모이는 자리)", + ] # held 는 목록 밖 + assert got["title"] == "자동 제안 3건 · 있는 관 1 뺌" + assert got["addOff"] is True and got["candidates"] == [50, 320, 420] + assert got["added"] == [[320, "S4"], [420, None]] # 고른 둘만 + assert got["afterClose"] == {"hidden": True, "candidates": []} + assert got["carried"] == {"stream": "S4", "order": 1} # 응답 → 보관소 → 요청 diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 42187a7d1..499da23d1 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -1005,24 +1005,13 @@ export const ui_locales_b2 = { B06_Std_Note_Paved: ["포장", "Paved"], B05_Drainage_Btn_Propose: ["자동 제안", "Auto suggest"], B05_Drainage_Btn_Propose_Tip: [ - "관 자리를 프로그램이 정합니다(세류선 교차 + 최대 간격 보충) — 두 방식 중 고름. 초안이라 [저장] 때 남습니다.", - "Let the program place culverts (stream crossings + max spacing rule) — choose one of two ways. This is a draft — it is kept on [Save].", + "관 자리 후보를 셉니다(지금 노선 × 세류선 교차 + 최대 간격 보충) — 고른 것만 관으로 넣음. 초안이라 [저장] 때 남습니다.", + "Find culvert candidates (current route × stream crossings + max spacing rule) — only the ones you pick are added. This is a draft — it is kept on [Save].", ], B05_Drainage_Btn_Propose_Stale: [ "노선 · 관 자리가 바뀌어 배수유역이 낡음 — [업데이트] 뒤 자동 제안", "Drainage basins are out of date — run [Update] first", ], - B05_Drainage_Propose_Title: ["관 자리 자동 제안", "Auto-suggest culvert positions"], - B05_Drainage_Propose_All: ["전체 자동", "Fully automatic"], - B05_Drainage_Propose_All_Hint: [ - "세류선 교차 + 최대 간격 규칙으로 관 자리를 새로 정함(같은 자리 관은 시설 · 관경 유지)", - "Re-place every culvert by stream crossings + max spacing (culverts at the same spot keep their facility and size)", - ], - B05_Drainage_Propose_Add: ["지정 포함 추가", "Add to my culverts"], - B05_Drainage_Propose_Add_Hint: [ - "지금 관은 그대로 두고 간격이 먼 구간에만 관을 더함", - "Keep the current culverts and add culverts only where the spacing is too long", - ], Design_Pipeline_Unstable: [ "계산 불안정 — 입력과 결과가 세 바퀴 안에 안 맞음", "Calculation unstable — inputs and results did not settle in three rounds",