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:
@@ -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)는 서버가 다시 계산하므로 뺀다. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} # 응답 → 보관소 → 요청
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user