feat(512-2 배수유역도 업데이트): 점선 원 · 유역 재분할 · 배관 삭제 · 배관 배치 초기화 걷음 → [업데이트] 하나 · 늘 지금 노선으로 처음부터 셈 · 노선 바뀌어도 옛 그림 그대로(단추 낡음 점 · 자동 모드 걷음) · 노선선 = 답의 노선(route_xy · 초안 노선 셈 뒤 저장 노선을 그려 생기던 노선 곁 빈 곳) · 낡은 답엔 옛 그림 유지 · B06 [>] 낡으면 확인 창 → 업데이트 → 이동 · 요구 326-6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,8 +106,6 @@ export const STATE_REGISTRY = {
|
||||
"input-prefs": { bucket: "pref", scope: "global" },
|
||||
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
|
||||
"b05-3d-projection": { bucket: "pref", scope: "global" },
|
||||
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
|
||||
"drainage-mode": { bucket: "pref", scope: "global" },
|
||||
"section-panel-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -413,9 +413,22 @@ async function alreadyConfirmed(projectId: string): Promise<boolean> {
|
||||
/** B06 [>] · 단계 판 B07~B09(496-1) — 확정 뒤 그 주소 · 실패 · 자료 서기 전 = 머묾. */
|
||||
export function confirmAndGo(shared: DesignPageShared, route: RoutePath): void {
|
||||
if (!shared.ready) return void showToast(L("Design_Next_NotReady"), "info");
|
||||
void alreadyConfirmed(shared.projectId).then((skip) =>
|
||||
skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route),
|
||||
);
|
||||
const go = () =>
|
||||
alreadyConfirmed(shared.projectId).then((skip) =>
|
||||
skip ? goToWorkflowStage(shared.projectId, route) : saveSharedDesign(shared, true, route),
|
||||
);
|
||||
void drainageFirst(shared).then((ready) => (ready ? go() : undefined));
|
||||
}
|
||||
|
||||
/** 배수유역도가 낡은 채 B07 로 — 확인 창 → [업데이트] 끝난 뒤 감 · 취소 · 실패 = 머묾(요구 326-6). */
|
||||
async function drainageFirst(shared: DesignPageShared): Promise<boolean> {
|
||||
const side = shared.profile?.side;
|
||||
if (!side?.stale()) return true;
|
||||
if (!(await showConfirmDialog(L("Design_Drainage_Stale"), L("Design_Drainage_Stale_Go"))))
|
||||
return false;
|
||||
if (await side.update()) return true;
|
||||
showToast(L("Design_Drainage_Stale_Failed"), "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -599,6 +599,8 @@ export interface DetailBasinResponse {
|
||||
stale?: string | null;
|
||||
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
|
||||
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
|
||||
/** 이 답을 셈한 노선(사업지 CRS m) — 배수유역도 노선선 · 관 자리가 유역과 짝(요구 326-6). */
|
||||
route_xy?: Array<[number, number]>;
|
||||
}
|
||||
|
||||
let lastNote = { text: "", at: 0 };
|
||||
|
||||
@@ -340,6 +340,8 @@ def _raw_payload(
|
||||
# ── 아래는 B05 배수유역도가 그리는 데 필요한 값. B04 지도는 자체 오버레이가 있어
|
||||
# 쓰지 않지만, 두 화면이 같은 응답을 받아야 결과가 갈리지 않는다(2026-08-01 일원화).
|
||||
"route_lonlat": detail.route_lonlat,
|
||||
# 이 답을 셈한 노선(사업지 CRS m) — B05 배수유역도 노선선 · 관 자리 · 유역이 늘 짝(요구 326-6)
|
||||
"route_xy": [[round(v.x, 3), round(v.y, 3)] for v in context.vertices],
|
||||
# 2차 전체 유역 외곽선 = 분수령. 해석 결과 그대로(손으로 고치는 기능 없음).
|
||||
"main_polygon_lonlat": detail.basin_lonlat,
|
||||
"grid_cell_m": detail.grid_cell_m,
|
||||
|
||||
@@ -18,9 +18,8 @@ export interface DrainageChrome {
|
||||
root: HTMLElement;
|
||||
panelHandle: ReturnType<typeof createWorkflowPanelHandle>;
|
||||
layerButtons: HTMLElement;
|
||||
analyzeButton: HTMLButtonElement;
|
||||
deleteButton: HTMLButtonElement;
|
||||
autoButton: HTMLButtonElement;
|
||||
/** [업데이트](요구 326-6) — 지금 노선으로 처음부터 셈 · 배선은 `B05_Profile_UI_Drainage_Live`. */
|
||||
updateButton: HTMLButtonElement;
|
||||
/** [자동 제안](510) — 후보 목록 · 배선은 패널(`B05_Profile_UI_Drainage_Propose`). */
|
||||
proposeButton: HTMLButtonElement;
|
||||
viewport: HTMLElement;
|
||||
@@ -38,12 +37,6 @@ export interface DrainageChrome {
|
||||
|
||||
export interface DrainageChromeLabels {
|
||||
title: string;
|
||||
analyze: string;
|
||||
analyzeTip: string;
|
||||
deleteSelected: string;
|
||||
deleteSelectedTip: string;
|
||||
auto: string;
|
||||
autoTip: string;
|
||||
imageAlt: string;
|
||||
statusNeedRoute: string;
|
||||
}
|
||||
@@ -77,32 +70,16 @@ export function createDrainageChrome(
|
||||
layerButtons.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
header.append(title);
|
||||
|
||||
// 세부유역 산정 — B04가 미리 분석해 둔 결과를 읽어 관을 보충하고 세부유역만 나눈다.
|
||||
// 격자 해석은 하지 않으므로 즉시 끝난다.
|
||||
const analyzeButton = document.createElement("button");
|
||||
analyzeButton.type = "button";
|
||||
analyzeButton.className = "b05-drainage__analyze";
|
||||
analyzeButton.textContent = labels.analyze;
|
||||
analyzeButton.title = labels.analyzeTip;
|
||||
// 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다.
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.type = "button";
|
||||
deleteButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
deleteButton.textContent = labels.deleteSelected;
|
||||
deleteButton.title = labels.deleteSelectedTip;
|
||||
deleteButton.disabled = true;
|
||||
// 배관 배치 초기화 — 편집한 배관 배치를 버리고 세류선 교차 자리만(초안 · 369).
|
||||
const autoButton = document.createElement("button");
|
||||
autoButton.type = "button";
|
||||
autoButton.className = "b05-drainage__analyze b05-drainage__tool";
|
||||
autoButton.textContent = labels.auto;
|
||||
autoButton.title = labels.autoTip;
|
||||
const updateButton = document.createElement("button");
|
||||
updateButton.type = "button";
|
||||
updateButton.className = "b05-drainage__analyze b05-drainage__update";
|
||||
updateButton.textContent = "업데이트";
|
||||
const proposeButton = document.createElement("button");
|
||||
proposeButton.type = "button";
|
||||
proposeButton.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__propose";
|
||||
proposeButton.textContent = ui_locales.B05_Drainage_Btn_Propose[currentLanguageIndex];
|
||||
proposeButton.title = ui_locales.B05_Drainage_Btn_Propose_Tip[currentLanguageIndex];
|
||||
header.append(analyzeButton, deleteButton, autoButton, proposeButton);
|
||||
header.append(updateButton, proposeButton);
|
||||
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "b05-drainage__viewport";
|
||||
@@ -160,9 +137,7 @@ export function createDrainageChrome(
|
||||
root,
|
||||
panelHandle,
|
||||
layerButtons,
|
||||
analyzeButton,
|
||||
deleteButton,
|
||||
autoButton,
|
||||
updateButton,
|
||||
proposeButton,
|
||||
viewport,
|
||||
backgroundImage,
|
||||
|
||||
@@ -25,8 +25,7 @@ import type { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
|
||||
const CLICK_SLOP_PX = 4;
|
||||
/** 지도 집기 · 유역 고르기 · 팬에서 빼는 지도 위 요소. */
|
||||
const HTML_CONTROLS =
|
||||
"button, input, select, textarea, a, label, [role='button'], .b05-drainage__veil";
|
||||
const HTML_CONTROLS = "button, input, select, textarea, a, label, [role='button']";
|
||||
|
||||
export interface DrainageInteractParams {
|
||||
viewport: HTMLElement;
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석
|
||||
* 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
|
||||
* 배수유역 [업데이트](요구 326-6 · 347-2) — 묶음 drainage 단계 · 낡음 표시 · 구석 「계산 중」 ·
|
||||
* 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
|
||||
*
|
||||
* - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 —
|
||||
* 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음).
|
||||
* - 노선 초안이 있으면 「낡음」 — 자동 = 흐림 없이 3 s 조용한 뒤 셈(구석 「계산 중」만) · 수동 = 칸만
|
||||
* 흐림 + 가운데 [업데이트]. 낡은 동안 제안 [적용]은 끔(두 모드 · 새 답이 오면 다시 켬).
|
||||
* - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s.
|
||||
* 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음.
|
||||
* - 노선 초안이 있으면 「낡음」 — 저절로 셈하지 않음 · 옛 그림 그대로 · [업데이트] 단추에 표시만
|
||||
* (508-2 [3D 업데이트] 꼴). 낡은 동안 제안 [적용] · [자동 제안]은 끔(새 답이 오면 다시 켬).
|
||||
* - [업데이트] = 늘 지금 노선(초안 · 저장 노선)으로 배수유역을 처음부터 셈 — 노선이 그대로여도.
|
||||
* 뒤에서 조용히(덮개 · 잠금 없음) · 답이 오면 그림을 갈아 끼움.
|
||||
* - 다시 조작하면 떠 있는 요청을 끊고(AbortController) 다시 낡음. 묶음이 같은 노선 · 관으로 다시
|
||||
* 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음.
|
||||
* - 초안이 없어지면(되돌리기 · 버림) 저장 배수유역을 다시 받아(열 때와 같은 길 · 계산 아님) 그 답의
|
||||
* 낡음대로 — 떠 있던 초안용 답은 버림 · 자동도 3 s 셈 대신 저장분.
|
||||
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤.
|
||||
* 낡음대로 — 떠 있던 초안용 답은 버림.
|
||||
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 낡음 표시.
|
||||
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
|
||||
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
|
||||
* - [배관 배치 초기화] = 세류선 교차 관만 · 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은
|
||||
* 답도 앞 걸음에 안 묻힘) · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길로
|
||||
* (저장 분석으로 셈 = 옛 노선 자리 · 409).
|
||||
* - [자동 제안](510 · 후보 목록 = `B05_Profile_UI_Drainage_Propose`) — 낡은 동안 끔 · 낡으면 목록 닫음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { StageFn } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import {
|
||||
computeDetailBasins,
|
||||
@@ -36,23 +31,20 @@ import {
|
||||
draftMakesStale,
|
||||
inputKey,
|
||||
liveRequestBody,
|
||||
parseDrainageMode,
|
||||
QUIET_MS,
|
||||
type DrainageMode,
|
||||
} from "./logic/B05_Profile_Drainage_Live";
|
||||
import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest";
|
||||
|
||||
const MODE_KEY = "drainage-mode";
|
||||
|
||||
export interface DrainageLiveDeps {
|
||||
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
|
||||
/** 패널 뿌리 — 머리([자동 제안]) · 지도(구석 「계산 중」)를 이 안에서 찾음. */
|
||||
root: HTMLElement;
|
||||
/** [업데이트] 단추(머리 줄 · `_Drainage_Chrome`). */
|
||||
updateButton: HTMLButtonElement;
|
||||
/** 제안 목록을 이 앞에 둠. */
|
||||
basinList: HTMLElement;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
runner: DetailRunner;
|
||||
/** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */
|
||||
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */
|
||||
rememberPipes: () => void;
|
||||
/** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */
|
||||
loadSaved: () => Promise<boolean>;
|
||||
@@ -63,14 +55,12 @@ export interface DrainageLiveDeps {
|
||||
export interface DrainageLive {
|
||||
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
|
||||
stage: StageFn<"drainage">;
|
||||
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
|
||||
refresh(): void;
|
||||
/** 서버 답이 「낡음」 — 흐림 + [업데이트](자동이면 3 s 뒤 셈). 이미 낡음이면 그대로. */
|
||||
/** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */
|
||||
refresh(): Promise<boolean>;
|
||||
/** 서버 답이 「낡음」 — 낡음 표시. 이미 낡음이면 그대로. */
|
||||
markStale(): void;
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청도 끊음). */
|
||||
markFresh(): void;
|
||||
/** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */
|
||||
resetToStreams(): void;
|
||||
readonly stale: boolean;
|
||||
suggest: DrainageSuggest;
|
||||
}
|
||||
@@ -78,7 +68,7 @@ export interface DrainageLive {
|
||||
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
|
||||
async function fetchLive(
|
||||
projectId: string,
|
||||
body: ReturnType<typeof liveRequestBody> | (Proposal & { generation: number }),
|
||||
body: ReturnType<typeof liveRequestBody>,
|
||||
signal: AbortSignal,
|
||||
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
|
||||
@@ -93,102 +83,75 @@ async function fetchLive(
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function readDrainageMode(): DrainageMode {
|
||||
return parseDrainageMode(readStateRaw(MODE_KEY));
|
||||
}
|
||||
|
||||
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
||||
|
||||
/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */
|
||||
const STALE_PREFIX = "배수유역 낡음";
|
||||
|
||||
/** [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */
|
||||
type Proposal = { points: DetailPipeInput[] | null };
|
||||
const UPDATE_LABEL = "업데이트";
|
||||
const UPDATE_TIP =
|
||||
"지금 노선으로 배수유역을 처음부터 다시 셉니다 — 뒤에서 셈 · 끝나면 그림을 바꿈.";
|
||||
const UPDATE_STALE_TIP =
|
||||
"노선 · 관 자리가 바뀌어 배수유역도가 낡았습니다 — 누르면 지금 노선으로 다시 셈.";
|
||||
|
||||
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
|
||||
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
|
||||
const propose = header.querySelector<HTMLButtonElement>(".b05-drainage__propose");
|
||||
/** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */
|
||||
const draft = (request: (projectId: string) => Promise<DetailBasinResponse>): void => {
|
||||
const update = deps.updateButton;
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
void deps.runner
|
||||
.run(() => request(projectId))
|
||||
.run(() => computeDetailBasins(projectId, points))
|
||||
.then((ok) => {
|
||||
if (!ok) return;
|
||||
deps.rememberPipes();
|
||||
if (ok) deps.rememberPipes();
|
||||
});
|
||||
};
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) =>
|
||||
draft((projectId) => computeDetailBasins(projectId, points)),
|
||||
);
|
||||
});
|
||||
deps.basinList.before(suggest.root);
|
||||
let mode = readDrainageMode();
|
||||
let route: unknown = null;
|
||||
let stale = false;
|
||||
/** 마지막 단계에 노선 초안이 있었나 — 없어지면 저장분으로 돌아옴. */
|
||||
let drafted = false;
|
||||
/** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */
|
||||
let savedStale = false;
|
||||
/** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [배관 배치 초기화]는 지금 노선으로 셈. */
|
||||
let analysisStale = false;
|
||||
let generation = 0;
|
||||
/** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */
|
||||
let key = "";
|
||||
let timer = 0;
|
||||
let inflight: AbortController | null = null;
|
||||
|
||||
const veil = document.createElement("div");
|
||||
veil.className = "b05-drainage__veil";
|
||||
veil.hidden = true;
|
||||
const update = document.createElement("button");
|
||||
update.type = "button";
|
||||
update.className = "b05-drainage__analyze b05-drainage__update";
|
||||
update.textContent = "업데이트";
|
||||
update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다.";
|
||||
veil.append(update);
|
||||
const busy = createProgressCircle({ size: 22, label: "계산 중" });
|
||||
busy.root.classList.add("b05-drainage__busy");
|
||||
busy.root.hidden = true;
|
||||
viewport.append(veil, busy.root);
|
||||
|
||||
const gear = document.createElement("button");
|
||||
gear.type = "button";
|
||||
gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear";
|
||||
gear.textContent = "⚙";
|
||||
gear.title = "배수유역 계산 설정 — 수동(기본) / 자동";
|
||||
header.append(gear);
|
||||
viewport.append(busy.root);
|
||||
|
||||
const paint = (): void => {
|
||||
deps.root.classList.toggle("is-stale", stale && mode === "manual");
|
||||
deps.root.classList.toggle("is-stale", stale);
|
||||
update.classList.toggle("is-pending", stale);
|
||||
update.disabled = inflight !== null;
|
||||
update.textContent = inflight ? "계산 중…" : UPDATE_LABEL;
|
||||
update.title = stale ? UPDATE_STALE_TIP : UPDATE_TIP;
|
||||
suggest.setStale(stale);
|
||||
if (propose) {
|
||||
propose.disabled = stale;
|
||||
propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip");
|
||||
}
|
||||
veil.hidden = !stale || mode === "auto" || inflight !== null;
|
||||
busy.root.hidden = inflight === null;
|
||||
};
|
||||
|
||||
function cancel(): void {
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
inflight?.abort();
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
/** 지금 노선으로 셈 — `proposal` 이면 그 관 목록 · 방식으로(답 = 관 초안 · 사진첩 한 걸음). */
|
||||
function refresh(proposal?: Proposal): void {
|
||||
/** 지금 노선으로 처음부터 셈 — 노선 초안이 있으면 그 노선 · 없으면 저장 노선. */
|
||||
function refresh(): Promise<boolean> {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
if (!projectId) return Promise.resolve(false);
|
||||
cancel();
|
||||
const sent = { generation: ++generation, key };
|
||||
const controller = new AbortController();
|
||||
inflight = controller;
|
||||
paint();
|
||||
const body = { ...liveRequestBody(sent.generation, route, deps.pipeRequest()), ...proposal };
|
||||
void deps.runner
|
||||
const body = liveRequestBody(sent.generation, route, deps.pipeRequest());
|
||||
return deps.runner
|
||||
.run(async () => {
|
||||
const response = await fetchLive(projectId, body, controller.signal);
|
||||
// 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌.
|
||||
@@ -197,42 +160,23 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
return response;
|
||||
}, true)
|
||||
.then((applied) => {
|
||||
if (inflight !== controller) return;
|
||||
if (inflight !== controller) return applied;
|
||||
inflight = null;
|
||||
if (applied) stale = false;
|
||||
if (applied && proposal) {
|
||||
deps.rememberPipes();
|
||||
}
|
||||
paint();
|
||||
return applied;
|
||||
});
|
||||
}
|
||||
|
||||
/** [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(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);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error;
|
||||
analysisStale = true;
|
||||
queueMicrotask(() => refresh(proposal)); // 이 요청은 조용히 버림(뒤 차례가 받음)
|
||||
throw new DOMException("stale", "AbortError");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
|
||||
/** 평면 · 관 자리 조작 — 떠 있는 요청을 끊고 낡음(저절로 셈하지 않음). */
|
||||
function invalidate(): void {
|
||||
cancel();
|
||||
stale = true;
|
||||
deps.onStale?.();
|
||||
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
/** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(흐림은 답이 올 때까지 그대로). */
|
||||
/** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(표시는 답이 올 때까지 그대로). */
|
||||
function backToSaved(): void {
|
||||
cancel();
|
||||
const sent = ++generation;
|
||||
@@ -241,51 +185,12 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
void deps.loadSaved().then((applied) => {
|
||||
if (sent !== generation) return;
|
||||
stale = !applied || savedStale;
|
||||
if (stale && mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
});
|
||||
}
|
||||
|
||||
function openSettings(): void {
|
||||
openModal({
|
||||
title: "배수유역 계산",
|
||||
closeLabel: "닫기",
|
||||
mount(body, close) {
|
||||
const choices: Array<[DrainageMode, string, string]> = [
|
||||
["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"],
|
||||
["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"],
|
||||
];
|
||||
choices.forEach(([value, label, hint]) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b05-drainage__mode";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "b05-drainage-mode";
|
||||
input.checked = mode === value;
|
||||
input.addEventListener("change", () => {
|
||||
setMode(value);
|
||||
close();
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${label} — ${hint}`;
|
||||
row.append(input, text);
|
||||
body.append(row);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setMode(next: DrainageMode): void {
|
||||
mode = next;
|
||||
writeStateRaw(MODE_KEY, next);
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
update.addEventListener("click", () => refresh());
|
||||
gear.addEventListener("click", openSettings);
|
||||
update.addEventListener("click", () => void refresh());
|
||||
paint();
|
||||
|
||||
return {
|
||||
stage(ctx) {
|
||||
@@ -299,23 +204,19 @@ export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
else if (wasDraft) backToSaved();
|
||||
return null;
|
||||
},
|
||||
refresh: () => refresh(),
|
||||
refresh,
|
||||
suggest,
|
||||
markStale() {
|
||||
savedStale = true;
|
||||
analysisStale = true;
|
||||
if (stale) return;
|
||||
stale = true;
|
||||
if (mode === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
},
|
||||
markFresh() {
|
||||
cancel();
|
||||
stale = false;
|
||||
analysisStale = false;
|
||||
paint();
|
||||
},
|
||||
resetToStreams: () => proposeDraft({ points: null }),
|
||||
get stale() {
|
||||
return stale;
|
||||
},
|
||||
|
||||
@@ -83,12 +83,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
const chrome = createDrainageChrome(
|
||||
{
|
||||
title: L("B05_Drainage_Title"),
|
||||
analyze: L("B05_Drainage_Btn_Analyze"),
|
||||
analyzeTip: L("B05_Drainage_Btn_Analyze_Tip"),
|
||||
deleteSelected: L("B05_Drainage_Btn_DeleteSelected"),
|
||||
deleteSelectedTip: L("B05_Drainage_Btn_DeleteSelected_Tip"),
|
||||
auto: L("B05_Drainage_Btn_Auto"),
|
||||
autoTip: L("B05_Drainage_Btn_Auto_Tip"),
|
||||
imageAlt: L("B05_Drainage_ImageAlt"),
|
||||
statusNeedRoute: L("B05_Drainage_Status_NeedRoute"),
|
||||
},
|
||||
@@ -102,9 +96,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
root,
|
||||
panelHandle,
|
||||
layerButtons,
|
||||
analyzeButton,
|
||||
deleteButton,
|
||||
autoButton,
|
||||
updateButton,
|
||||
proposeButton,
|
||||
viewport,
|
||||
backgroundImage,
|
||||
@@ -245,7 +237,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
markedChainage,
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
candidates: propose.candidates(),
|
||||
});
|
||||
updateImageTransform();
|
||||
}
|
||||
@@ -260,10 +251,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
|
||||
const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage);
|
||||
|
||||
/** 마커 선택 ↔ 유역 강조 동기화 + 삭제 버튼 갱신(편집 폼은 사이드 소관). */
|
||||
/** 마커 선택 ↔ 유역 강조 동기화(편집 폼은 사이드 소관). */
|
||||
function syncPipeSelection(notify = true): void {
|
||||
const index = pipeEditor.selected();
|
||||
deleteButton.disabled = index === null;
|
||||
const picked = index === null ? null : pipeEditor.pipes()[index];
|
||||
selectBasin(basinIndexOfPipe(basins, picked ?? null), notify);
|
||||
// 유역 유무와 무관하게 선택 자체를 알린다 — 그래프·3D·리스트 동기화(재진입은
|
||||
@@ -304,12 +294,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
callbacks.onBasinSelected?.(picked ? picked.chainage_m : null);
|
||||
}
|
||||
|
||||
/** 응답을 화면 상태로 옮긴다. B04 지도와 **같은 엔드포인트·같은 응답**을 쓴다
|
||||
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
|
||||
/** 응답을 화면 상태로 옮긴다. B04 지도와 같은 엔드포인트 · 같은 응답(2026-08-01 일원화). */
|
||||
function apply(response: DetailBasinResponse): void {
|
||||
// 낡은 답(노선 [저장] 뒤 등 · 세부 유역 없음) = 옛 그림 그대로 · 관만 받음 · [업데이트] 표시(326-6)
|
||||
const keep = !!response.stale && basins.length > 0;
|
||||
if (response.stale) live.markStale();
|
||||
if (keep) return void takePipes(response);
|
||||
basins = response.basins;
|
||||
live.suggest.update(response);
|
||||
if (response.stale) live.markStale(); // 노선 [저장] 뒤 등 — 흐림 + [업데이트](자동이면 3 s 뒤)
|
||||
// 노선선 = 이 답을 셈한 노선 — 유역 · 관 자리와 늘 짝(초안 노선 [업데이트] 뒤 빈 곳 · 326-6)
|
||||
const answered = response.route_xy?.map(([x, y]) => ({ x, y })) ?? [];
|
||||
if (answered.length) takeRoute(answered, false);
|
||||
mainBoundary = response.main_polygon_lonlat ?? [];
|
||||
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
|
||||
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
|
||||
@@ -323,7 +318,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
maxStrength = built.maximum;
|
||||
legend.update(maxStrength, show.strength);
|
||||
maxHotspotArea = hotspots.reduce((max, spot) => (spot.area > max ? spot.area : max), 0);
|
||||
// 실제 사용된 배관 목록으로 마커를 동기화한다(유역 없는 관 포함).
|
||||
takePipes(response);
|
||||
}
|
||||
|
||||
/** 답의 관 목록(유역 없는 관 포함) · 시설 정보를 마커 · 폼에 올림(유역 그림은 그대로). */
|
||||
function takePipes(response: DetailBasinResponse): void {
|
||||
pipeEditor.setPipes(
|
||||
response.pipe_points.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
@@ -351,7 +350,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
/** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */
|
||||
const runner = createDetailRunner({
|
||||
projectId: () => projectId,
|
||||
analyzeButton,
|
||||
updateButton,
|
||||
status,
|
||||
showProgress,
|
||||
apply,
|
||||
@@ -395,9 +394,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
|
||||
|
||||
// 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
|
||||
// [업데이트](326-6 · 347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
|
||||
const live = createDrainageLive({
|
||||
root,
|
||||
updateButton,
|
||||
basinList,
|
||||
pipeRequest,
|
||||
runner,
|
||||
@@ -416,13 +416,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
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)
|
||||
@@ -432,8 +428,20 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
pipeEditor.addAtChainage(chainageM);
|
||||
}
|
||||
|
||||
let routeFromAnswer = false; // 노선선이 답에서 왔나 — 그 뒤 페이지 노선은 그림 안 바꿈(326-6)
|
||||
|
||||
function takeRoute(points: ReadonlyArray<RoutePoint>, fit: boolean): void {
|
||||
routeFromAnswer ||= !fit;
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
// 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다.
|
||||
strengthSamples = resampleRoute(points);
|
||||
pipeEditor.setContext(meta, points);
|
||||
if (routeLayer && fit) fitToRoute();
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/** 노선 전체가 보이도록 배율·중심을 맞춘다(초기 보기 = 도로 기준 줌인).
|
||||
*
|
||||
* B05는 노선 주변 배수유역을 보는 화면이라 도로에 맞춰 확대한 상태로 연다
|
||||
* (2026-08-01 사용자 지시). 배경 전체를 보려면 휠로 축소하면 된다.
|
||||
* 노선이 없으면 배경 전체를 그대로 보여준다. */
|
||||
@@ -672,14 +680,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
scheduleDraw();
|
||||
},
|
||||
setRoute(points) {
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
// 강도 색칠은 백엔드가 준 1m 구간 값과 인덱스를 맞춰야 하므로 같은 규칙으로 다시 찍는다.
|
||||
strengthSamples = resampleRoute(points);
|
||||
pipeEditor.setContext(meta, points);
|
||||
if (routeLayer) fitToRoute();
|
||||
scheduleDraw();
|
||||
if (!routeFromAnswer) takeRoute(points, true); // 페이지 노선 = 첫 답 전만 · 그 뒤 답의 노선(326-6)
|
||||
},
|
||||
stale: () => live.stale,
|
||||
update: () => live.refresh(),
|
||||
dispose() {
|
||||
loadSequence += 1;
|
||||
if (frameHandle) {
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface DrainagePanel {
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
|
||||
pipelineStage: StageFn<"drainage">;
|
||||
/** 배수유역도가 낡았나(노선 · 관 자리가 바뀜 · 서버 「낡음」) — B06 [>] 확인 창(326-6). */
|
||||
stale: () => boolean;
|
||||
/** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */
|
||||
update: () => Promise<boolean>;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
selectBasinByChainage: (chainageM: number | null) => void;
|
||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Propose.ts
|
||||
* 배수유역 칸 [자동 제안](510) — 서버가 후보를 셈(`post_pipe_proposals` · 세류선 교차 + 간격 보충 ·
|
||||
* 있는 관 · 지운 관 상태) · 화면은 목록 + 배수유역도 점선 원 · 고른 것만 관 넣기 길(`addPipe`)로.
|
||||
* 있는 관 · 지운 관 상태) · 화면은 목록만(점선 원 걷음 · 326-6) · 고른 것만 관 넣기 길(`addPipe`)로.
|
||||
*
|
||||
* - 있는 관(라벨 짝 · 교차 5 m 안 관) = 목록에서 뺌(머리 글에 수만) · 지운 관 = 「지운 관」 표시 ·
|
||||
* 고르면 다시 넣음(서버가 지움 표지 걷음).
|
||||
@@ -42,8 +42,6 @@ export interface DrainagePropose {
|
||||
/** 서버에 후보를 물어 목록을 엶. */
|
||||
open(): Promise<void>;
|
||||
close(): void;
|
||||
/** 목록에 오른 후보 누가거리 — 배수유역도 점선 원. */
|
||||
candidates(): number[];
|
||||
}
|
||||
|
||||
export function createDrainagePropose(deps: {
|
||||
@@ -51,7 +49,6 @@ export function createDrainagePropose(deps: {
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
/** 고른 후보를 관 넣기 길로(패널 `addPipe` 와 같은 길). */
|
||||
addPipes: (picks: PipeProposal[]) => void;
|
||||
redraw: () => void;
|
||||
}): DrainagePropose {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-drainage__suggest b05-drainage__proposals";
|
||||
@@ -82,7 +79,6 @@ export function createDrainagePropose(deps: {
|
||||
shown = [];
|
||||
picked.clear();
|
||||
list.replaceChildren();
|
||||
deps.redraw();
|
||||
};
|
||||
|
||||
const render = (response: ProposalResponse): void => {
|
||||
@@ -110,7 +106,6 @@ export function createDrainagePropose(deps: {
|
||||
);
|
||||
add.disabled = true;
|
||||
root.hidden = false;
|
||||
deps.redraw();
|
||||
};
|
||||
|
||||
add.addEventListener("click", () => {
|
||||
@@ -150,6 +145,5 @@ export function createDrainagePropose(deps: {
|
||||
}
|
||||
},
|
||||
close,
|
||||
candidates: () => (root.hidden ? [] : shown.map((item) => item.chainage_m)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@ export interface DrainageScene {
|
||||
stationIntervalM: number;
|
||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
|
||||
intervalSpans: ReadonlyArray<RouteSpanBand>;
|
||||
/** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */
|
||||
candidates: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
export function drawDrainageScene(
|
||||
@@ -170,20 +168,6 @@ export function drawDrainageScene(
|
||||
scene.maxHotspotArea,
|
||||
);
|
||||
}
|
||||
// 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음).
|
||||
scene.candidates.forEach((chainage) => {
|
||||
const sample = scene.strengthSamples[Math.round(chainage)];
|
||||
if (!sample) return;
|
||||
const [x, y] = projector.toScreen(sample.x, sample.y);
|
||||
context.save();
|
||||
context.setLineDash([3, 3]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#1e88e5";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 9, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
// 배관(관 매설) 마커 — 계획선 위.
|
||||
scene.pipeEditor.draw(context, view, scene.pipeColor);
|
||||
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
|
||||
|
||||
@@ -17,7 +17,7 @@ type StatusKey =
|
||||
|
||||
export interface DetailRunnerDeps {
|
||||
projectId: () => string | null;
|
||||
analyzeButton: HTMLButtonElement;
|
||||
updateButton: HTMLButtonElement;
|
||||
status: HTMLElement;
|
||||
showProgress: (ratio: number | null, label: string | null) => void;
|
||||
apply: (response: DetailBasinResponse) => void;
|
||||
@@ -33,14 +33,14 @@ export interface DetailRunner {
|
||||
}
|
||||
|
||||
export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
|
||||
const { analyzeButton, status, showProgress, label } = deps;
|
||||
const { updateButton, status, showProgress, label } = deps;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
return {
|
||||
projectId: deps.projectId,
|
||||
async run(request, quiet = false) {
|
||||
if (!deps.projectId()) return false;
|
||||
if (!quiet) {
|
||||
analyzeButton.disabled = true;
|
||||
updateButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = label("B05_Drainage_Status_Analyzing");
|
||||
showProgress(null, label("B05_Drainage_Status_Analyzing"));
|
||||
@@ -63,7 +63,7 @@ export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
|
||||
return false;
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
updateButton.disabled = false;
|
||||
showProgress(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 배관 편집 도구 버튼 — "유역 산정" 우측에 나란히(auto 마진 해제). */
|
||||
/* 머리 줄 둘째 단추([자동 제안]) — [업데이트] 우측에 나란히(auto 마진 해제). */
|
||||
.b05-drainage__tool {
|
||||
margin-left: var(--spacing-4, 4px);
|
||||
}
|
||||
@@ -267,32 +267,21 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */
|
||||
.b05-drainage.is-stale .b05-drainage__canvas,
|
||||
.b05-drainage.is-stale .b05-drainage__image,
|
||||
.b05-drainage.is-stale .b05-drainage__summary,
|
||||
.b05-drainage.is-stale .b05-drainage__basins,
|
||||
.b05-drainage.is-stale .b05-drainage__suggest {
|
||||
filter: blur(2px);
|
||||
opacity: 0.6;
|
||||
/* 배수유역 낡음(326-6) — 옛 그림 그대로 · [업데이트] 단추에 점([3D 업데이트] 꼴) · 구석 「계산 중」. */
|
||||
.b05-drainage__update.is-pending {
|
||||
position: relative;
|
||||
border-color: var(--color-warning, #d98a00);
|
||||
}
|
||||
|
||||
.b05-drainage__veil {
|
||||
.b05-drainage__update.is-pending::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-drainage__veil[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__veil .b05-drainage__update {
|
||||
margin-left: 0; /* 머리 줄 단추의 오른쪽 붙임(auto) 풂 — 흐린 칸 가운데 */
|
||||
pointer-events: auto;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning, #d98a00);
|
||||
}
|
||||
|
||||
.b05-drainage__busy {
|
||||
@@ -305,14 +294,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__mode {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
padding: var(--spacing-8) 0;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */
|
||||
.b05-drainage__suggest {
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`.
|
||||
* 배수유역 [업데이트](요구 326-6 · 347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`.
|
||||
*
|
||||
* - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음).
|
||||
* - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤.
|
||||
* - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동.
|
||||
* - 저절로 셈하지 않음 — [업데이트]를 누를 때만 지금 노선으로 처음부터 셈.
|
||||
* - 묶음이 같은 입력으로 다시 돌면(입력 지문 같음) 낡음 · 떠 있는 요청을 그대로 둠.
|
||||
* - 답 채택 = 보낸 차례 · 입력 지문이 지금과 같고 서버가 그만두지 않았을 때만(늦은 답 버림).
|
||||
* ========================================================================== */
|
||||
|
||||
export type DrainageMode = "manual" | "auto";
|
||||
|
||||
/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */
|
||||
export const QUIET_MS = 3000;
|
||||
|
||||
/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */
|
||||
export const parseDrainageMode = (raw: string | null | undefined): DrainageMode =>
|
||||
raw === "auto" ? "auto" : "manual";
|
||||
|
||||
/** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */
|
||||
export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined;
|
||||
|
||||
|
||||
@@ -34,12 +34,7 @@ const applied = sug.applySuggestions(pipes, suggestions);
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
quietMs: live.QUIET_MS,
|
||||
modes: [
|
||||
live.parseDrainageMode(null),
|
||||
live.parseDrainageMode("auto"),
|
||||
live.parseDrainageMode("x"),
|
||||
],
|
||||
noAutoMode: live.QUIET_MS === undefined && live.parseDrainageMode === undefined, // 326-6
|
||||
stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })],
|
||||
accept: [
|
||||
live.acceptLive({ status: "success", generation: 3 }, at(3), at(3)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 349 배수유역 자동/수동 흐름 헬퍼 — 화면 파일 `B05_Profile_UI_Drainage_Live.ts` · `_Run.ts` ·
|
||||
/* 349 · 326-6 배수유역 [업데이트] 흐름 헬퍼 — 화면 파일 `B05_Profile_UI_Drainage_Live.ts` · `_Run.ts` ·
|
||||
* `_Suggest.ts` 를 그 자리에서 트랜스파일해 가짜 DOM · 시계 · fetch 로 돌리고 단계별 상태를 JSON 으로 냄.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
@@ -86,21 +86,10 @@ const flush = async () => {
|
||||
/* ---- 모듈 읽기(그 자리 트랜스파일 · 바깥 모듈은 가짜) ---- */
|
||||
const state = new Map();
|
||||
const detailCalls = [];
|
||||
const detailAutos = []; // [자동 제안](369) 방식 — 없으면 null
|
||||
let records = 0; // 사진첩 한 걸음(369)
|
||||
let detailStale = false; // detail-basins 가 409 낡음으로 답함(369 — 화면이 몰랐던 낡음)
|
||||
let modeRow = 1; // ⚙ · [자동 제안] 창에서 고를 줄 — ⚙ 0 수동 · 1 자동 / 제안 0 전체 · 1 지정 포함
|
||||
const stubs = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api" },
|
||||
"@ui/ui_template_progress": { createProgressCircle: () => ({ root: el("progress") }) },
|
||||
// ⚙ 창 — 둘째 줄(자동) 라디오를 고름
|
||||
"@ui/ui_template_modal": {
|
||||
openModal: (options) => {
|
||||
const body = el("modal");
|
||||
options.mount(body, () => undefined);
|
||||
body.children[modeRow].children[0].fire("change");
|
||||
},
|
||||
},
|
||||
"@ui/ui_template_locale": {
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_target, key) => [key, key] }),
|
||||
@@ -110,10 +99,8 @@ const stubs = {
|
||||
writeStateRaw: (key, value) => state.set(key, value),
|
||||
},
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
|
||||
computeDetailBasins: async (projectId, points, auto) => {
|
||||
computeDetailBasins: async (projectId, points) => {
|
||||
detailCalls.push(points);
|
||||
detailAutos.push(auto ?? null);
|
||||
if (detailStale) throw new Error("배수유역 낡음 — 노선이 분석 때와 다릅니다.");
|
||||
return { basins: [], pipe_points: points ?? [], stale: null };
|
||||
},
|
||||
},
|
||||
@@ -145,6 +132,7 @@ async function main() {
|
||||
const header = el("header");
|
||||
const root = el("root");
|
||||
const propose = el("button");
|
||||
const update = el("button");
|
||||
header.querySelector = (selector) => (selector === ".b05-drainage__propose" ? propose : null);
|
||||
root.querySelector = (selector) =>
|
||||
selector === ".b05-drainage__viewport"
|
||||
@@ -163,7 +151,7 @@ async function main() {
|
||||
const status = el("status");
|
||||
const runner = createDetailRunner({
|
||||
projectId: () => "p1",
|
||||
analyzeButton: el("button"),
|
||||
updateButton: update,
|
||||
status,
|
||||
showProgress: () => undefined,
|
||||
apply: (response) => {
|
||||
@@ -177,6 +165,7 @@ async function main() {
|
||||
});
|
||||
live = createDrainageLive({
|
||||
root,
|
||||
updateButton: update,
|
||||
basinList: el("list"),
|
||||
pipeRequest: () => pipes,
|
||||
runner,
|
||||
@@ -188,13 +177,12 @@ async function main() {
|
||||
return runner.run(() => new Promise((resolve) => (savedAnswer = resolve)), true);
|
||||
},
|
||||
});
|
||||
const [veil, busy] = viewport.children;
|
||||
const update = veil.children[0];
|
||||
const gear = header.children[0];
|
||||
const [busy] = viewport.children;
|
||||
const look = () => ({
|
||||
stale: live.stale,
|
||||
blur: root.classList.contains("is-stale"),
|
||||
veil: !veil.hidden,
|
||||
pending: update.classList.contains("is-pending"),
|
||||
off: update.disabled,
|
||||
label: update.textContent,
|
||||
busy: !busy.hidden,
|
||||
fetches: calls.length,
|
||||
applyOff: live.suggest.root.children[0].children[1].disabled,
|
||||
@@ -221,11 +209,14 @@ async function main() {
|
||||
...extra,
|
||||
});
|
||||
|
||||
// 수동 — 꼭짓점 옮김 = 흐림 + [업데이트] · 판 번호만 바뀐 같은 노선 = 그대로
|
||||
out.start = look();
|
||||
// 꼭짓점 옮김 = 낡음 표시만 · 저절로 셈 없음 · 판 번호만 바뀐 같은 노선 = 그대로
|
||||
stage(draft(1, 50));
|
||||
out.moved = look();
|
||||
stage(draft(2, 50));
|
||||
out.sameAgain = look();
|
||||
advance(60000);
|
||||
out.noAuto = look();
|
||||
// [업데이트] → 요청 하나 · 묶음이 같은 입력으로 다시 돌아도 답을 받음
|
||||
update.fire("click");
|
||||
out.sent = { ...look(), body: calls[0].body };
|
||||
@@ -244,7 +235,7 @@ async function main() {
|
||||
await flush();
|
||||
out.applied = { points: detailCalls[0], remembered };
|
||||
|
||||
// 다시 옮김 → 요청 → 그 사이 또 옮김 = 앞 요청 끊김 · 다시 흐림
|
||||
// 다시 옮김 → 요청 → 그 사이 또 옮김 = 앞 요청 끊김 · 다시 낡음
|
||||
stage(draft(4, 80));
|
||||
update.fire("click");
|
||||
stage(draft(5, 90));
|
||||
@@ -253,64 +244,36 @@ async function main() {
|
||||
live.suggest.root.children[0].children[1].fire("click");
|
||||
await flush();
|
||||
out.staleApply = detailCalls.length;
|
||||
update.fire("click");
|
||||
// [업데이트] 약속 — 답이 화면에 오르면 참(B06 [>] 확인 창 뒤 이동 · 326-6)
|
||||
const done = live.refresh();
|
||||
answer(calls[2]);
|
||||
out.promised = await done;
|
||||
await flush();
|
||||
out.fresh = look();
|
||||
// 낡지 않아도 [업데이트] = 다시 셈(노선 · 관 그대로 · 326-6)
|
||||
update.fire("click");
|
||||
out.again = { ...look(), route: calls[3].body.route };
|
||||
answer(calls[3]);
|
||||
await flush();
|
||||
out.againDone = look();
|
||||
|
||||
// 노선 [저장] 뒤 — 서버 답이 「낡음」 = 흐림 + [업데이트](수동)
|
||||
// 노선 [저장] 뒤 — 서버 답이 「낡음」 = 낡음 표시 · 저절로 셈 없음
|
||||
stage(null);
|
||||
await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }));
|
||||
out.savedStale = { ...look(), status: status.textContent };
|
||||
|
||||
// ⚙ 자동 — 바꾸는 순간 3 s 뒤 셈 · 흐림 막 · [업데이트] 없음
|
||||
gear.fire("click");
|
||||
out.autoSwitch = look();
|
||||
advance(2999);
|
||||
out.auto2999 = look();
|
||||
advance(1);
|
||||
out.auto3000 = { ...look(), route: calls[3].body.route };
|
||||
answer(calls[3]);
|
||||
await flush();
|
||||
out.autoDone = look();
|
||||
// 자동 · 꼭짓점 옮김 = 조용히 3 s 뒤 · 그 사이 또 옮기면 처음부터
|
||||
stage(draft(6, 120));
|
||||
advance(2000);
|
||||
stage(draft(7, 130));
|
||||
advance(2000);
|
||||
out.autoRestart = look();
|
||||
advance(1000);
|
||||
advance(60000);
|
||||
out.savedStaleNoAuto = look();
|
||||
update.fire("click");
|
||||
answer(calls[4]);
|
||||
await flush();
|
||||
out.autoMoved = { ...look(), mode: state.get("drainage-mode") };
|
||||
// 자동 · 저장 뒤 낡음 = 3 s 뒤 조용히
|
||||
await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }));
|
||||
out.autoSavedStale = look();
|
||||
advance(3000);
|
||||
answer(calls[5]);
|
||||
await flush();
|
||||
out.autoSavedDone = look();
|
||||
out.savedDone = look();
|
||||
|
||||
// 349-0i 되돌리기 — 자동: 끌기 → 낡음 → Ctrl+Z(초안 없어짐) = 3 s 셈 대신 저장분
|
||||
// 저장분 답에도 관경 제안 하나 — [적용] 켜짐은 낡음 · 관경/시설 제안 유무로 갈림(369)
|
||||
// 되돌리기 — 끌기 → [업데이트] 요청 중 → Ctrl+Z = 초안용 답 끊고 저장분 · 표시는 답 올 때까지 그대로
|
||||
const savedOk = {
|
||||
basins: [{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 }],
|
||||
pipe_points: pipes,
|
||||
stale: null,
|
||||
};
|
||||
stage(draft(20, 300));
|
||||
advance(1000);
|
||||
stage(null);
|
||||
out.autoUndoWaiting = { ...look(), savedLoads };
|
||||
advance(5000);
|
||||
out.autoUndoNoFetch = look();
|
||||
savedAnswer(savedOk);
|
||||
await flush();
|
||||
out.autoUndo = { ...look(), savedLoads };
|
||||
|
||||
// 수동: 끌기 → [업데이트] 요청 중 → Ctrl+Z = 초안용 답 끊고 저장분 · 흐림은 답 올 때까지
|
||||
modeRow = 0;
|
||||
gear.fire("click");
|
||||
stage(draft(21, 310));
|
||||
update.fire("click");
|
||||
const draftCall = calls[calls.length - 1];
|
||||
@@ -319,64 +282,17 @@ async function main() {
|
||||
savedAnswer(savedOk);
|
||||
await flush();
|
||||
out.undo = { ...look(), savedLoads };
|
||||
// 저장분 자체가 낡음 = 흐림 · [업데이트] 그대로
|
||||
// 저장분 자체가 낡음 = 낡음 표시 그대로
|
||||
stage(draft(22, 320));
|
||||
stage(null);
|
||||
savedAnswer({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" });
|
||||
await flush();
|
||||
out.undoSavedStale = { ...look(), savedLoads, mode: state.get("drainage-mode") };
|
||||
// [자동 제안] — 낡은 동안 끔 · 누름은 패널 후보 목록(510 · 여기 안 셈) · [배관 배치 초기화] = 관 목록 없이(세류선만)
|
||||
out.undoSavedStale = { ...look(), savedLoads };
|
||||
// [자동 제안] — 낡은 동안 끔 · 새 답이면 켬
|
||||
out.proposeStale = { off: propose.disabled, title: propose.title };
|
||||
const beforeStale = detailCalls.length;
|
||||
propose.fire("click");
|
||||
await flush();
|
||||
out.proposeStale.calls = detailCalls.length - beforeStale;
|
||||
live.markFresh();
|
||||
out.proposeFresh = { off: propose.disabled, title: propose.title };
|
||||
const n0 = detailCalls.length;
|
||||
const remembered0 = remembered;
|
||||
const records0 = records;
|
||||
propose.fire("click");
|
||||
await flush();
|
||||
live.resetToStreams();
|
||||
await flush();
|
||||
out.propose = {
|
||||
autos: detailAutos.slice(n0),
|
||||
points: detailCalls.slice(n0).map((points) => (points === null ? null : points.length)),
|
||||
remembered: remembered - remembered0,
|
||||
records: records - records0,
|
||||
};
|
||||
// 화면이 몰랐던 낡음 — 빠른 길이 409 낡음이면 곧바로 지금 노선으로 셈 길로 다시
|
||||
detailStale = true;
|
||||
const fallbackFrom = calls.length;
|
||||
live.resetToStreams();
|
||||
await flush();
|
||||
const fallbackCall = calls[calls.length - 1];
|
||||
out.proposeFallback = {
|
||||
sent: calls.length - fallbackFrom,
|
||||
points: fallbackCall.body.points,
|
||||
status: status.textContent,
|
||||
};
|
||||
answer(fallbackCall);
|
||||
await flush();
|
||||
detailStale = false;
|
||||
// 저장 분석이 옛 노선(서버 낡음 답) → [업데이트] 뒤 [자동 제안] = 지금 노선으로 셈 길에 방식 실음
|
||||
await runner.run(async () => ({ basins: [], pipe_points: pipes, stale: "배수유역 낡음" }));
|
||||
update.fire("click");
|
||||
answer(calls[calls.length - 1]);
|
||||
await flush();
|
||||
const viaDetail = detailCalls.length;
|
||||
live.resetToStreams();
|
||||
const resetCall = calls[calls.length - 1];
|
||||
answer(resetCall);
|
||||
await flush();
|
||||
out.proposeLive = {
|
||||
resetPoints: resetCall.body.points,
|
||||
resetAuto: resetCall.body.auto ?? null,
|
||||
detailCalls: detailCalls.length - viaDetail,
|
||||
remembered: remembered - remembered0,
|
||||
records: records - records0,
|
||||
};
|
||||
out.records = records;
|
||||
// 입력 지문 — 판 번호 · 관 차례는 안 봄 · 꼭짓점 · 관 자리(cm) · id 는 봄
|
||||
const { inputKey } = load(path.join(B05, "logic", "B05_Profile_Drainage_Live.ts"));
|
||||
const a = { id: "a1", chainage_m: 100 };
|
||||
|
||||
@@ -69,7 +69,6 @@ const { createFacilityStore } = load("B05_Profile", "B05_Profile_UI_Drainage_Fac
|
||||
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;
|
||||
@@ -80,7 +79,7 @@ const { createFacilityStore } = load("B05_Profile", "B05_Profile_UI_Drainage_Fac
|
||||
rows: list.children.map((row) => row.children[0].children[1].text.trim()),
|
||||
title: title.textContent,
|
||||
addOff: add.disabled,
|
||||
candidates: propose.candidates(),
|
||||
noCircles: propose.candidates === undefined, // 점선 원 걷음(326-6)
|
||||
};
|
||||
boxes[1].checked = true;
|
||||
boxes[1].fire("change");
|
||||
@@ -88,7 +87,7 @@ const { createFacilityStore } = load("B05_Profile", "B05_Profile_UI_Drainage_Fac
|
||||
boxes[2].fire("change");
|
||||
add.fire("click");
|
||||
out.added = added;
|
||||
out.afterClose = { hidden: propose.root.hidden, candidates: propose.candidates() };
|
||||
out.afterClose = { hidden: propose.root.hidden };
|
||||
|
||||
// 관 속성 보관소 — 응답 라벨이 다음 요청에 실림(라벨만 있는 맨 배관도 보관)
|
||||
const store = createFacilityStore();
|
||||
|
||||
@@ -107,7 +107,9 @@ def test_다시_나눔은_세션_초안으로_계산만_저장_안_함():
|
||||
load = _body(PANEL, "const loadSaved = ", "const analyze = ")
|
||||
assert "readPendingPipes(projectId as string)" in load
|
||||
assert "computeDetailBasins(projectId as string, pending)" in load
|
||||
analyze = _body(PANEL, "const analyze = ", "analyzeButton.addEventListener")
|
||||
analyze = _body(
|
||||
PANEL, "const analyze = ", "const live = createDrainageLive("
|
||||
) # 326-6 [유역 재분할] 걷음
|
||||
assert "computeDetailBasins(" in analyze
|
||||
run = RUN[RUN.index("async run(") :]
|
||||
assert "saveDetailPipePoints" not in run + load + analyze
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일).
|
||||
|
||||
- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음.
|
||||
- 자동 모드 없음(요구 326-6 · [업데이트]만) · 노선 초안이 있을 때만 낡음.
|
||||
- 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림.
|
||||
- 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) ·
|
||||
[적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음).
|
||||
@@ -30,8 +30,7 @@ def r():
|
||||
|
||||
|
||||
def test_mode_and_stale(r):
|
||||
assert r["quietMs"] == 3000
|
||||
assert r["modes"] == ["manual", "auto", "manual"]
|
||||
assert r["noAutoMode"]
|
||||
assert r["stale"] == [False, True]
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""349 배수유역 자동/수동 흐름 — 화면 파일(`B05_Profile_UI_Drainage_Live` · `_Run` · `_Suggest`)을
|
||||
"""349 · 326-6 배수유역 [업데이트] 흐름 — 화면 파일(`B05_Profile_UI_Drainage_Live` · `_Run` · `_Suggest`)을
|
||||
가짜 DOM · 시계 · fetch 로 돌림(`helper_349_drainage_live_flow.cjs`).
|
||||
|
||||
- 수동: 꼭짓점 옮김 = 흐림 + [업데이트] · [업데이트] 뒤 묶음이 같은 노선 · 관으로 다시 돌아도 답을 받음
|
||||
(입력 지문) · 답이 오면 흐림 · 「계산 중」 사라짐 · 제안 「제안 N건」 · [적용].
|
||||
- 요청 중 또 옮기면 앞 요청 끊고 다시 흐림.
|
||||
- 노선 [저장] 뒤 서버 답이 「낡음」 = 수동은 흐림 + [업데이트] · 자동은 3 s 뒤 조용히.
|
||||
- ⚙ 자동: 흐림 없음 · 3 s 조용한 뒤 셈 · 그 사이 또 옮기면 처음부터 · [업데이트] 막 없음(349-0e).
|
||||
- 낡은 동안 제안 [적용] 끔(두 모드) · 새 답이 오면 다시 켬(349-0e).
|
||||
- 되돌리기로 초안이 없어지면 저장분 다시 받기 · 그 답의 낡음대로 · 초안용 답 끊음 · 자동도 3 s 셈 대신(349-0i).
|
||||
- 꼭짓점 옮김 = 옛 그림 그대로 · [업데이트] 단추에 낡음 점만 · 저절로 셈 없음(시계 60 s 흘려도).
|
||||
- [업데이트] = 뒤에서 조용히 셈(단추만 「계산 중…」 · 구석 표시) · 묶음이 같은 입력으로 다시 돌아도 답을 받음.
|
||||
- 낡지 않아도 [업데이트] = 다시 셈(326-6) · 약속은 답이 오르면 참(B06 [>] 확인 창 뒤 이동).
|
||||
- 요청 중 또 옮기면 앞 요청 끊고 다시 낡음 · 낡은 동안 제안 [적용] · [자동 제안] 끔.
|
||||
- 노선 [저장] 뒤 서버 답이 「낡음」 = 낡음 표시 · 저절로 셈 없음.
|
||||
- 되돌리기로 초안이 없어지면 저장분 다시 받기 · 그 답의 낡음대로 · 초안용 답 끊음(349-0i).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -33,30 +32,40 @@ def r():
|
||||
|
||||
|
||||
STALE_TITLE = "노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용"
|
||||
IDLE = "업데이트"
|
||||
BUSY = "계산 중…"
|
||||
|
||||
|
||||
def state(stale, veil, busy, fetches, auto=False):
|
||||
def state(stale, busy, fetches):
|
||||
return {
|
||||
"stale": stale,
|
||||
"blur": stale and not auto, # 자동 = 흐림 없음
|
||||
"veil": veil,
|
||||
"pending": stale, # 낡음 = 단추 점(흐림 · 덮개 없음)
|
||||
"off": busy, # 셈 동안 단추만 잠금
|
||||
"label": BUSY if busy else IDLE,
|
||||
"busy": busy,
|
||||
"fetches": fetches,
|
||||
"applyOff": stale,
|
||||
}
|
||||
|
||||
|
||||
def look(row):
|
||||
return {k: v for k, v in row.items() if k != "applyTitle"}
|
||||
def look(row, *skip):
|
||||
return {k: v for k, v in row.items() if k not in ("applyTitle", *skip)}
|
||||
|
||||
|
||||
def test_manual_update_answer_lands_after_same_rerun(r):
|
||||
assert look(r["moved"]) == state(True, True, False, 0)
|
||||
assert look(r["sameAgain"]) == state(True, True, False, 0) # 판 번호만 바뀜 — 다시 안 흐림
|
||||
assert r["sent"]["fetches"] == 1 and r["sent"]["busy"] and not r["sent"]["veil"]
|
||||
def test_moved_keeps_old_picture_no_auto(r):
|
||||
assert look(r["start"]) == {**state(False, False, 0), "applyOff": True} # 제안 없음
|
||||
assert look(r["moved"]) == state(True, False, 0)
|
||||
assert look(r["sameAgain"]) == state(True, False, 0) # 판 번호만 바뀜
|
||||
assert look(r["noAuto"]) == state(True, False, 0) # 60 s 지나도 셈 없음
|
||||
|
||||
|
||||
def test_update_answer_lands_after_same_rerun(r):
|
||||
assert look(r["sent"], "body") == state(True, True, 1)
|
||||
assert r["sent"]["body"]["route"]["rev"] == 2 # 지금 초안 노선으로
|
||||
rerun = r["rerunWhileBusy"]
|
||||
assert not rerun["aborted"] and rerun["busy"] # 같은 입력 다시 돎 — 요청 그대로
|
||||
assert look(r["answered"]) == {**state(False, False, False, 1), "applied": 1}
|
||||
assert look(r["answered"], "applied") == state(False, False, 1)
|
||||
assert r["answered"]["applied"] == 1
|
||||
|
||||
|
||||
def test_suggestions_and_apply(r):
|
||||
@@ -65,92 +74,49 @@ def test_suggestions_and_apply(r):
|
||||
assert len(r["suggest"]["rows"]) == 1
|
||||
points = {p["chainage_m"]: p for p in r["applied"]["points"]}
|
||||
assert points[100]["options"]["pipe_diameter_mm"] == 1200
|
||||
assert 200 not in points # 관 자리 후보는 [적용]이 안 더함 — [자동 제안] 몫(369)
|
||||
assert 200 not in points
|
||||
assert r["applied"]["remembered"] == 1
|
||||
|
||||
|
||||
def test_자동_제안_두_방식_초기화_세류선만_한_걸음(r):
|
||||
assert r["proposeStale"] == {
|
||||
"off": True,
|
||||
"title": "B05_Drainage_Btn_Propose_Stale",
|
||||
"calls": 0, # 낡은 동안 끔 — 눌러도 요청 없음
|
||||
}
|
||||
assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"}
|
||||
# [자동 제안] 누름 = 패널 후보 목록(510 · test_510) — 여기선 초기화만 초안
|
||||
assert r["propose"] == {
|
||||
"autos": [None],
|
||||
"points": [None], # 초기화 = 목록 없이(세류선만)
|
||||
"remembered": 1,
|
||||
"records": 1, # 답마다 사진첩 한 걸음
|
||||
}
|
||||
# 저장 분석이 옛 노선 — 지금 노선으로 셈 길(detail-basins 안 부름) · 같은 초안 · 한 걸음
|
||||
assert r["proposeLive"] == {
|
||||
"resetPoints": None,
|
||||
"resetAuto": None,
|
||||
"detailCalls": 0,
|
||||
"remembered": 3, # 앞 「409 → 다시」 한 번 포함
|
||||
"records": 3,
|
||||
}
|
||||
fallback = r["proposeFallback"]
|
||||
assert fallback["sent"] == 1 and fallback["points"] is None
|
||||
assert "낡음" not in fallback["status"] # 409 글을 오류로 안 띄움(조용히 다시)
|
||||
assert r["records"] == 1 # [적용] 한 번 = 사진첩 한 걸음
|
||||
|
||||
|
||||
def test_move_while_busy_cancels(r):
|
||||
moved = r["movedWhileBusy"]
|
||||
assert moved["aborted"] and moved["veil"] and not moved["busy"]
|
||||
assert look(r["fresh"]) == state(False, False, False, 3)
|
||||
assert moved["aborted"] and look(moved, "aborted") == state(True, False, 2)
|
||||
assert r["promised"] is True
|
||||
assert look(r["fresh"]) == state(False, False, 3)
|
||||
|
||||
|
||||
def test_update_always_recomputes(r):
|
||||
assert look(r["again"], "route") == {**state(False, True, 4), "applyOff": False}
|
||||
assert look(r["againDone"]) == state(False, False, 4)
|
||||
|
||||
|
||||
def test_stale_blocks_apply(r):
|
||||
assert r["movedWhileBusy"]["applyOff"]
|
||||
assert r["movedWhileBusy"]["applyTitle"] == STALE_TITLE
|
||||
assert r["staleApply"] == 1 # 눌러도 옛 제안이 안 들어감(앞 [적용] 한 번뿐)
|
||||
assert not r["fresh"]["applyOff"] and r["fresh"]["applyTitle"] != STALE_TITLE
|
||||
assert r["fresh"]["applyTitle"] != STALE_TITLE
|
||||
assert r["proposeStale"] == {"off": True, "title": "B05_Drainage_Btn_Propose_Stale"}
|
||||
assert r["proposeFresh"] == {"off": False, "title": "B05_Drainage_Btn_Propose_Tip"}
|
||||
|
||||
|
||||
def test_saved_stale_shows_update(r):
|
||||
assert {k: v for k, v in look(r["savedStale"]).items() if k != "status"} == state(
|
||||
True, True, False, 3
|
||||
)
|
||||
def test_saved_stale_marks_only(r):
|
||||
assert look(r["savedStale"], "status") == state(True, False, 4)
|
||||
assert r["savedStale"]["status"] == "배수유역 낡음"
|
||||
|
||||
|
||||
def test_auto_mode(r):
|
||||
assert look(r["autoSwitch"]) == state(
|
||||
True, False, False, 3, True
|
||||
) # 자동 — 흐림 없음 · [업데이트] 없음
|
||||
assert r["auto2999"]["fetches"] == 3
|
||||
assert r["auto3000"]["fetches"] == 4 and r["auto3000"]["route"] is None
|
||||
assert r["auto3000"]["busy"] and not r["auto3000"]["blur"] # 계산 중 = 구석 표시만
|
||||
assert look(r["autoDone"]) == state(False, False, False, 4)
|
||||
assert look(r["autoRestart"]) == state(
|
||||
True, False, False, 4, True
|
||||
) # 2 s + 2 s — 처음부터 다시 3 s
|
||||
assert look(r["autoMoved"]) == {**state(False, False, False, 5), "mode": "auto"}
|
||||
assert look(r["autoSavedStale"]) == state(True, False, False, 5, True)
|
||||
assert look(r["autoSavedDone"]) == state(False, False, False, 6)
|
||||
assert look(r["savedStaleNoAuto"]) == state(True, False, 4)
|
||||
assert look(r["savedDone"]) == state(False, False, 5)
|
||||
|
||||
|
||||
def test_undo_back_to_saved(r):
|
||||
# 자동 — 끌기 뒤 1 s 에 되돌리기: 3 s 셈 없음 · 저장분 하나 · 답 오면 낡음 풂
|
||||
assert look(r["autoUndoWaiting"]) == {**state(True, False, False, 6, True), "savedLoads": 2}
|
||||
assert r["autoUndoNoFetch"]["fetches"] == 6
|
||||
assert look(r["autoUndo"]) == {**state(False, False, False, 6), "savedLoads": 2}
|
||||
# 수동 — [업데이트] 요청 중 되돌리기: 초안용 요청 끊음 · 저장분 답 올 때까지 흐림 그대로
|
||||
waiting = r["undoWaiting"]
|
||||
assert waiting["aborted"] and waiting["stale"] and waiting["blur"] and not waiting["busy"]
|
||||
assert look(r["undo"]) == {**state(False, False, False, 7), "savedLoads": 3}
|
||||
# 저장분 자체가 낡음 = 흐림 · [업데이트] · [적용] 꺼짐 그대로
|
||||
assert look(r["undoSavedStale"]) == {
|
||||
**state(True, True, False, 7),
|
||||
"savedLoads": 4,
|
||||
"mode": "manual",
|
||||
}
|
||||
assert waiting["aborted"] and look(waiting, "aborted", "savedLoads") == state(True, False, 6)
|
||||
assert look(r["undo"], "savedLoads") == state(False, False, 6)
|
||||
assert r["undo"]["savedLoads"] == 2
|
||||
assert look(r["undoSavedStale"], "savedLoads") == state(True, False, 6)
|
||||
assert r["undoSavedStale"]["savedLoads"] == 3
|
||||
|
||||
|
||||
def test_input_key(r):
|
||||
assert look(r["keys"]) == {
|
||||
assert r["keys"] == {
|
||||
"rev": True,
|
||||
"order": True,
|
||||
"node": False,
|
||||
|
||||
@@ -7,7 +7,9 @@ LOCALE = Path(__file__).resolve().parents[2] / "ui_template" / "ui_template_loca
|
||||
|
||||
def test_drainage_buttons_are_nouns():
|
||||
text = LOCALE.read_text(encoding="utf-8")
|
||||
assert 'B05_Drainage_Btn_Analyze: ["유역 재분할",' in text
|
||||
assert 'B05_Drainage_Btn_DeleteSelected: ["관 삭제",' in text
|
||||
# [유역 재분할] · [관 삭제] · [배관 배치 초기화] 걷음 → [업데이트] 하나(요구 326-6)
|
||||
assert "B05_Drainage_Btn_Analyze" not in text
|
||||
assert "B05_Drainage_Btn_DeleteSelected" not in text
|
||||
assert "B05_Drainage_Btn_Auto:" not in text
|
||||
assert "유역 다시 나누기" not in text
|
||||
assert "선택한 관 삭제" not in text
|
||||
|
||||
@@ -152,7 +152,7 @@ def test_화면_고른_것만_넣음_라벨_왕복():
|
||||
"측점 420 m — 간격 보충(흐름이 모이는 자리)",
|
||||
] # held 는 목록 밖
|
||||
assert got["title"] == "자동 제안 3건 · 있는 관 1 뺌"
|
||||
assert got["addOff"] is True and got["candidates"] == [50, 320, 420]
|
||||
assert got["addOff"] is True and got["noCircles"] # 배수유역도 점선 원 없음(326-6)
|
||||
assert got["added"] == [[320, "S4"], [420, None]] # 고른 둘만
|
||||
assert got["afterClose"] == {"hidden": True, "candidates": []}
|
||||
assert got["afterClose"] == {"hidden": True}
|
||||
assert got["carried"] == {"stream": "S4", "order": 1} # 응답 → 보관소 → 요청
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""512-2(요구 326-6) 배수유역도 [업데이트] — 점선 원 · 단추 셋 걷음 · 노선선 = 답의 노선 · B06 [>] 확인 창.
|
||||
|
||||
- 서버 답 `route_xy` = 그 답을 셈한 노선(사업지 m) — 화면 노선선 · 관 자리 · 유역이 늘 짝
|
||||
(초안 노선 [업데이트] 뒤 저장 노선을 그려 생기던 「노선 곁 빈 곳」).
|
||||
- 낡은 답(세부 유역 없음)이 와도 그림이 있으면 옛 그림 그대로 · 관만 받음.
|
||||
- 흐름(낡음 · 늘 다시 셈 · 저절로 셈 없음)은 test_349 · 화면 실측은 PLAN 512-2.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
|
||||
from common_util.common_util_drainage_context import DrainageContext
|
||||
from common_util.common_util_drainage_detail import DrainageDetail, pipes_from_chainages
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_답에_셈한_노선이_실림():
|
||||
vertices = [RouteVertex(10.0, 20.0, 100.0, 0.0), RouteVertex(310.1234, 20.0, 90.0, 300.1234)]
|
||||
context = DrainageContext(stored_path="x", project_root=ROOT, vertices=vertices)
|
||||
detail = DrainageDetail(route_lonlat=[], pipes=pipes_from_chainages(vertices, [100.0]))
|
||||
points = basins.carry_facility_attributes(basins._rebuilt(detail), [])
|
||||
payload = basins._payload(uuid4(), context, detail, points, False)
|
||||
assert payload["route_xy"] == [[10.0, 20.0], [310.123, 20.0]]
|
||||
|
||||
|
||||
def test_단추는_업데이트_자동제안_둘():
|
||||
chrome = _read(B05 / "B05_Profile_UI_Drainage_Chrome.ts")
|
||||
assert "header.append(updateButton, proposeButton);" in chrome
|
||||
for gone in ("analyzeButton", "deleteButton", "autoButton"):
|
||||
assert gone not in chrome
|
||||
locale = _read(ROOT / "ui_template" / "ui_template_locale_b2.ts")
|
||||
assert "B05_Drainage_Btn_Auto:" not in locale and "B05_Drainage_Btn_Analyze" not in locale
|
||||
|
||||
|
||||
def test_점선_원_없음():
|
||||
render = _read(B05 / "B05_Profile_UI_Drainage_Render.ts")
|
||||
assert "candidates" not in render and "setLineDash" not in render
|
||||
assert "candidates" not in _read(B05 / "B05_Profile_UI_Drainage_Propose.ts")
|
||||
|
||||
|
||||
def test_노선선은_답의_노선_낡은_답은_옛_그림():
|
||||
panel = _read(B05 / "B05_Profile_UI_Drainage_Panel.ts")
|
||||
apply = panel[panel.index("function apply(") : panel.index("function takePipes(")]
|
||||
assert apply.index("if (keep) return void takePipes(response);") < apply.index("basins = ")
|
||||
assert "const keep = !!response.stale && basins.length > 0;" in apply
|
||||
assert "takeRoute(answered, false)" in apply
|
||||
assert "if (!routeFromAnswer) takeRoute(points, true);" in panel # 페이지 노선 = 첫 답 전만
|
||||
|
||||
|
||||
def test_B06_다음은_낡으면_확인_뒤_업데이트():
|
||||
shared = _read(ROOT / "A00_Common" / "design" / "design_shared.ts")
|
||||
go = shared[shared.index("export function confirmAndGo(") :]
|
||||
go = go[: go.index("\n}\n", go.index("async function drainageFirst("))]
|
||||
assert "void drainageFirst(shared).then((ready) => (ready ? go() : undefined));" in go
|
||||
first = go[go.index("async function drainageFirst(") :]
|
||||
assert (
|
||||
first.index("side?.stale()")
|
||||
< first.index("showConfirmDialog(")
|
||||
< first.index("side.update()")
|
||||
)
|
||||
@@ -30,23 +30,6 @@ export const ui_locales_b2 = {
|
||||
"유역 안쪽 상류 세류망을 굵게 강조합니다.",
|
||||
"Emphasize the upstream stream network inside the basin.",
|
||||
],
|
||||
// 버튼 이름만으로 무엇이 바뀌는지 알기 어려워 동작을 이름·툴팁에 함께 적는다
|
||||
// (2026-08-19 사용자 지시 4).
|
||||
B05_Drainage_Btn_Analyze: ["유역 재분할", "Recompute sub-basins"],
|
||||
B05_Drainage_Btn_Analyze_Tip: [
|
||||
"B04에서 분석해 둔 배수유역을 불러와, 지금 배치된 배관을 기준으로 세부유역(관이 담당하는 구역)을 다시 나눕니다. 관을 더하지 않습니다 — 간격 보충은 [자동 제안]. B04 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.",
|
||||
"Reloads the B04 drainage analysis and re-splits sub-basins around the current culverts without adding any — use [Auto suggest] to fill spacing gaps. Run the analysis in B04 first if none exists.",
|
||||
],
|
||||
B05_Drainage_Btn_DeleteSelected: ["관 삭제", "Delete selected culvert"],
|
||||
B05_Drainage_Btn_DeleteSelected_Tip: [
|
||||
"지도에서 고른 배관 한 개를 지웁니다. 관을 먼저 눌러 고른 뒤에 쓸 수 있습니다.",
|
||||
"Removes the culvert selected on the map. Select a culvert marker first.",
|
||||
],
|
||||
B05_Drainage_Btn_Auto: ["배관 배치 초기화", "Reset culvert layout"],
|
||||
B05_Drainage_Btn_Auto_Tip: [
|
||||
"손으로 넣거나 옮기거나 지운 배관을 모두 버리고, 세류선(계곡)이 노선과 만나는 자리에만 배관을 둡니다. 유역 구획도 함께 다시 나뉩니다. 초안이라 [저장] 때 남습니다.",
|
||||
"Discards every manual culvert edit and keeps culverts only where streams cross the route; sub-basins are re-split as well. This is a draft — it is kept on [Save].",
|
||||
],
|
||||
B05_Drainage_Menu_Add: ["배수관 추가", "Add culvert"],
|
||||
B05_Drainage_Menu_Delete: ["배수관 삭제", "Remove culvert"],
|
||||
B05_Drainage_Layer_Hotspots: ["집중유역", "Inflow hotspots"],
|
||||
@@ -1028,4 +1011,14 @@ export const ui_locales_b2 = {
|
||||
],
|
||||
Design_Route_Moved_Go: ["B07 로", "Go to B07"],
|
||||
Design_Route_Moved_Stay: ["머묾", "Stay"],
|
||||
/* 배수유역도 낡은 채 B06 [>](요구 326-6) — 확인 → [업데이트] 뒤 이동 */
|
||||
Design_Drainage_Stale: [
|
||||
"배수유역도가 지금 노선 · 관 자리보다 낡았음 — 배수유역을 다시 셈(업데이트)한 뒤 B07 로 넘어감.",
|
||||
"The drainage map is older than the current route and culverts — it will be recomputed (Update) before going to B07.",
|
||||
],
|
||||
Design_Drainage_Stale_Go: ["업데이트 뒤 이동", "Update and go"],
|
||||
Design_Drainage_Stale_Failed: [
|
||||
"배수유역 업데이트 실패 — 머묾.",
|
||||
"Drainage update failed — staying.",
|
||||
],
|
||||
} as const satisfies Record<string, LocaleEntry>;
|
||||
|
||||
Reference in New Issue
Block a user