fix(B04): 배수유역 상태 문구 전용 줄 분리 + 저장분 없을 때 안내

증상 두 가지
  1. 재산정 버튼을 눌러도 프론트에 아무 안내가 없었다.
     오버레이가 지도 자체 상태(.b04-map__status)에 문구를 썼는데,
     loadLayers 가 같은 칸에 3곳에서 덮어써 즉시 지워졌다.
  2. 페이지 접근 시 배수유역이 안 보였다.
     응답 캐시 파일(00_watershed_response.json)이 아직 없어 자동 조회가
     빈 손으로 끝나는데, 그 사실을 화면에 알리지 않아 이유를 알 수 없었다.

조치
- 배수유역 전용 상태 줄(.b04-map__watershed-status)을 컨트롤 영역에 따로 둔다.
  지도 로딩 문구와 서로 덮어쓰지 않는다.
- 컨트롤에 배수유역 그룹을 만들어 재산정 버튼 + 갈래 토글 4종 + 상태 줄을 묶었다.
- 진행 상태를 버튼 라벨에도 반영: 배수유역 재산정 -> 분석 중… / 불러오는 중…
- 결과 문구에 출처와 소요를 붙인다: [저장분] ... / [재산정 28.4초] ...
- 저장분이 없으면 오류가 아니라 안내로 표시:
  "저장된 배수유역 분석이 없습니다. [배수유역 재산정]을 누르세요."
- 실패하면 버튼 활성 상태를 되돌려 껐다 켠 것처럼 보이지 않게 했다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:26:32 +09:00
co-authored by Claude Fable 5
parent 2ddf81c86c
commit 500a3c9047
3 changed files with 70 additions and 18 deletions
@@ -222,12 +222,18 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
gisButtons.append(contourLabelButton);
// 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다.
const watershed = createWatershedOverlay(() => {
const text = watershed.status();
if (text) status.textContent = text;
scheduleDraw();
});
gisButtons.append(watershed.button, ...watershed.partButtons);
// 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면
// 나중에 끝난 쪽이 상대 문구를 지워 버린다.
const watershed = createWatershedOverlay(() => scheduleDraw());
const watershedGroup = document.createElement("div");
watershedGroup.className = "b04-map__control-group";
const watershedTitle = document.createElement("span");
watershedTitle.textContent = "배수유역";
const watershedButtons = document.createElement("div");
watershedButtons.className = "b04-map__layer-buttons";
watershedButtons.append(watershed.button, ...watershed.partButtons);
watershedGroup.append(watershedTitle, watershedButtons, watershed.statusElement);
controls.insertBefore(watershedGroup, resetButton);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
@@ -624,6 +624,25 @@
background: var(--color-surface-raised);
}
/* 배수유역 전용 상태 줄 — 지도 자체 상태(.b04-map__status)와 칸을 나눠 쓰면
나중에 끝난 쪽이 상대 문구를 지운다. 그래서 컨트롤 영역에 따로 둔다. */
.b04-map__watershed-status {
display: block;
width: 100%;
margin-top: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-inputs);
background: var(--color-surface-raised);
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
word-break: keep-all;
}
.b04-map__watershed-status[hidden] {
display: none;
}
@media (max-width: 760px) {
.b04-map__header {
align-items: flex-start;
+39 -12
View File
@@ -50,8 +50,10 @@ type PartKey = (typeof PARTS)[number]["key"];
export interface WatershedOverlay {
/** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */
button: HTMLButtonElement;
/** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향). */
/** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름). */
partButtons: HTMLButtonElement[];
/** 배수유역 전용 상태 줄. 지도 자체 상태(레이어 로딩 등)와 섞이면 서로 덮어쓴다. */
statusElement: HTMLElement;
/** 켜져 있는지. draw() 호출 전에 확인한다. */
visible: () => boolean;
/** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */
@@ -70,6 +72,17 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
let flowCache: { source: string; bytes: Uint8Array } | null = null;
let busy = false;
const statusElement = document.createElement("span");
statusElement.className = "b04-map__watershed-status";
statusElement.hidden = true;
/** 상태 줄을 갱신한다. 빈 문자열이면 줄 자체를 숨긴다. */
function say(text: string): void {
statusText = text;
statusElement.textContent = text;
statusElement.hidden = text === "";
}
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button b04-map__layer-button--gis";
@@ -457,31 +470,44 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
}
/** 분석 결과를 받아 화면에 올린다.
*
* `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때
* 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */
async function loadAnalysis(refresh: boolean): Promise<void> {
if (!projectId || busy) return;
busy = true;
button.disabled = true;
statusText = refresh
? "배수유역을 다시 분석하는 중… (30초 안팎)"
: "저장된 배수유역을 불러오는 중…";
onChange();
button.textContent = refresh ? "분석 중…" : "불러오는 중…";
say(
refresh
? "배수유역을 처음부터 다시 분석하는 중입니다. 30초 안팎 걸립니다…"
: "저장된 배수유역 분석을 불러오는 중…",
);
const started = performance.now();
try {
analysis = await fetchWatershedAnalysis(projectId, refresh);
shown = true;
button.classList.add("is-active");
button.setAttribute("aria-pressed", "true");
statusText = regionSummary(analysis);
const seconds = ((performance.now() - started) / 1000).toFixed(1);
const origin = analysis.from_cache ? "저장분" : `재산정 ${seconds}`;
say(`[${origin}] ${regionSummary(analysis)}`);
} catch (error) {
// 저장분이 없어 자동 조회가 실패한 경우는 오류가 아니다 — 재산정하면 된다.
analysis = null;
shown = false;
button.classList.remove("is-active");
button.setAttribute("aria-pressed", "false");
const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다.";
statusText = refresh ? message : "";
if (!refresh) analysis = null;
// 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다.
say(
refresh
? `배수유역 재산정 실패: ${message}`
: "저장된 배수유역 분석이 없습니다. [배수유역 재산정]을 누르세요.",
);
} finally {
busy = false;
button.disabled = false;
button.textContent = "배수유역 재산정";
onChange();
}
}
@@ -492,7 +518,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
shown = false;
button.classList.remove("is-active");
button.setAttribute("aria-pressed", "false");
statusText = "";
say("");
onChange();
return;
}
@@ -502,13 +528,14 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
return {
button,
partButtons,
statusElement,
visible: () => shown && analysis !== null,
status: () => statusText,
reset() {
analysis = null;
flowCache = null;
shown = false;
statusText = "";
say("");
button.classList.remove("is-active");
button.setAttribute("aria-pressed", "false");
},