feat(510-1 자동 관 제안 = B05 배수유역 후보 고름): [자동 제안] = 서버 후보(지금 노선 × 세류선 교차 5 m 솎기 · 정수 m · 라벨 + 분석이 지금 노선이면 간격 보충) · 상태 있음(라벨 짝 · 교차 5 m 안 관) · 지운 관(지움 표지 짝) · 새 · 배수유역 칸 목록 + 지도 점선 원 · 고른 것만 관 넣기 길(addPipe · 세류선 교차 = 라벨 실음) · 옛 두 방식 창(전체 자동 · 지정 포함 추가) 걷음

- 서버 POST drainage/pipe-proposals(아무것도 안 씀) · propose_pipes(common_util_stream_crossing) · claim_crossings = {교차점: 라벨} 짝
- 라벨 왕복 — 계산 carry 가 화면 라벨 실음 · 관 속성 보관소가 응답 · 초안 · 요청에 라벨 실어 나름 · 옵션 고침에도 남김
- 지운 관을 다시 넣으면 지움 표지 걷음(keep_labels)
- 계산 등록부 D2 runner = propose_pipes · 시험 test_510 · 349 낡은 두 방식 기대 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:36:48 +09:00
co-authored by Claude Opus 5.5
parent 349c266064
commit 1abac6377f
16 changed files with 601 additions and 107 deletions
@@ -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",
@@ -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<string, string | number> | null;
}
/** 편집·저장 요청에 싣는 관 1건 — 좌표(lonlat)는 서버가 다시 계산하므로 뺀다. */
+46 -1
View File
@@ -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}
@@ -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;
@@ -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<string, string | number>;
summary_item?: SummaryItem | null;
group_items?: GroupItems | null;
}>,
): void {
replaceFromResponse(points: ReadonlyArray<Omit<DetailPipeInput, "source">>): 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,
};
+12 -40
View File
@@ -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<boolean>;
/** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(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<HTMLElement>(".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) {
+31 -10
View File
@@ -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이 재계산을 돌리고,
@@ -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<void>;
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<number>();
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)),
};
}
@@ -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;
}
@@ -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)
+63 -4
View File
@@ -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"])
@@ -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,
+98
View File
@@ -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));
})();
@@ -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 글을 오류로 안 띄움(조용히 다시)
@@ -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} # 응답 → 보관소 → 요청
+2 -13
View File
@@ -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",