Files
Aislo/B04_PreProcess/B04_PreProcess_UI_Watershed.ts
eomsangdonandClaude Fable 5 f7528a4aa4 refactor(B04): B04_wf1_Surface -> B04_PreProcess 전면 개명
- 폴더·내부 파일 51개 접두사 개명 (git mv, 이력 보존)
- 저장소 전체 참조 치환 67파일: import 경로, 라우트 슬러그(b04-preprocess),
  라우트 키(B04_PREPROCESS), storage 경로 상수, locale, SQL 주석
- 로직 변경 없음 (기계적 치환). typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:01:36 +09:00

578 lines
25 KiB
TypeScript

import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
import { drawFlowArrows } from "./B04_PreProcess_UI_FlowArrows";
import type { Normalizer, ViewState } from "./B04_PreProcess_UI_MapRender";
import { drawUpstreamLines } from "./B04_PreProcess_UI_MapOverlays";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/* =============================================================================
* 배수유역 분석 오버레이 (B04 — 관리자 확인용)
*
* 2D 배경지도 위에 배수유역 분석 결과를 겹쳐 그린다. 계산은 백엔드가 하고 여기서는
* 그리기만 한다. 30초 안팎이 걸리는 요청이라 버튼을 눌렀을 때만 돈다.
*
* 겹쳐 그리는 것
* · 해석 격자 — 1차 영역에 걸치는 셀만, 흰 선
* · 셀별 흐름 — 도로 도달 적색 / 미도달 파랑 채움 + 백색 화살표 / 표고없음 회색
* · 상류 세류망(굵은 파랑) · 하류망(회색 파선) · 1차 영역(초록 채움)
* · 2차 전체 배수유역 외곽선(갈색 파선) · 기본 관 위치
* ========================================================================== */
/* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다 — 여기서 값을 새로 정하지 않는다.
(인자로 준 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */
// 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다.
const gridLineColor = (): string => themeColor("--map-grid-line", "rgba(255, 255, 255, 0.55)");
// 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표.
const flowToRoadFill = (): string =>
themeColor("--map-flow-to-road-fill", "rgba(220, 38, 38, 0.28)");
const flowToRoadLine = (): string =>
themeColor("--map-flow-to-road-line", "rgba(153, 27, 27, 0.95)");
const flowAwayFill = (): string => themeColor("--map-flow-away-fill", "rgba(37, 99, 235, 0.22)");
const flowAwayLine = (): string => themeColor("--map-flow-away-line", "rgba(255, 255, 255, 0.95)");
/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */
const flowUnknownFill = (): string =>
themeColor("--map-flow-unknown-fill", "rgba(120, 113, 108, 0.18)");
/** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */
const ARROW_MIN_PX = 7;
/** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다.
* 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */
const ARROW_SPACING_PX = 22;
/** 2차 전체 배수유역 외곽선 = 분수령. */
const basinRingColor = (): string => themeColor("--map-basin-ring", "rgba(146, 64, 14, 0.95)");
/** 기본 관 마커. */
const pipeMarkerColor = (): string => themeColor("--map-pipe-marker", "rgba(249, 115, 22, 0.95)");
/** 개별로 켜고 끌 수 있는 오버레이 갈래. 문구는 locale, 색은 theme.css가 정의처다. */
const PARTS = [
{
key: "primary",
labelKey: "B04_Surface_Watershed_Part_Primary",
token: ["--map-primary-region", "#059669"],
},
{
key: "basin",
labelKey: "B04_Surface_Watershed_Part_Basin",
token: ["--map-basin-outline", "#92400e"],
},
{
key: "flow",
labelKey: "B04_Surface_Watershed_Part_Flow",
token: ["--map-flow-region", "#2563eb"],
},
{
key: "arrows",
labelKey: "B04_Surface_Watershed_Part_Arrows",
token: ["--map-flow-arrow", "#7c3aed"],
},
] as const satisfies ReadonlyArray<{
key: string;
labelKey: keyof typeof ui_locales;
token: readonly [string, string];
}>;
type PartKey = (typeof PARTS)[number]["key"];
export interface WatershedOverlay {
/** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */
button: HTMLButtonElement;
/** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향 / 평균 흐름). */
partButtons: HTMLButtonElement[];
/** 배수유역 **결과** 줄(좌상단). 지도 자체 상태(레이어 로딩 등)와 섞이면 서로 덮어쓴다. */
statusElement: HTMLElement;
/** 분석 **진행 중** 줄(우상단). 결과를 지우지 않고 따로 뜬다. */
busyElement: HTMLElement;
/** 켜져 있는지. draw() 호출 전에 확인한다. */
visible: () => boolean;
/** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */
status: () => string;
/** 마지막으로 받은 분석 결과. 흐름 강도 오버레이가 강도 곡선·유입 집중점을 여기서 가져간다. */
analysis: () => WatershedAnalysis | null;
/** 프로젝트가 바뀌면 받아 둔 분석 결과를 버린다. */
reset: () => void;
/** 현재 프로젝트를 알려 준다. 지정 전에는 버튼이 아무 일도 하지 않는다. */
setProject: (projectId: string) => void;
draw: (context: CanvasRenderingContext2D, map: Normalizer, view: ViewState) => void;
}
export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
let analysis: WatershedAnalysis | null = null;
let shown = false;
let statusText = "";
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;
// 진행 문구는 결과 문구와 자리를 나눈다 — 결과는 좌상단에 남겨 두고 "분석 중…"만 우상단에
// 띄운다(2026-08-01 사용자 지시). 한 줄을 같이 쓰면 분석을 돌릴 때마다 직전 결과가 지워진다.
const busyElement = document.createElement("span");
busyElement.className = "b04-map__watershed-status";
busyElement.hidden = true;
/** 상태 줄을 갱신한다. 빈 문자열이면 줄 자체를 숨긴다. */
function say(text: string): void {
statusText = text;
statusElement.textContent = text;
statusElement.hidden = text === "";
}
/** 진행 중 문구(우상단). 빈 문자열이면 감춘다. */
function sayBusy(text: string): void {
busyElement.textContent = text;
busyElement.hidden = text === "";
}
const button = document.createElement("button");
button.type = "button";
button.className = "b04-map__layer-button b04-map__layer-button--gis";
button.textContent = L("B04_Surface_Watershed_Btn");
button.style.setProperty("--b04-layer-color", themeColor("--color-danger", "#dc2626"));
button.setAttribute("aria-pressed", "false");
button.title = L("B04_Surface_Watershed_Btn_Tip");
// 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다.
// 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시).
const shownParts: Record<PartKey, boolean> = {
primary: false,
basin: true,
flow: false,
arrows: true,
};
const partButtons = PARTS.map((part) => {
const element = document.createElement("button");
element.type = "button";
const initialActive = shownParts[part.key];
element.className =
"b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : "");
element.textContent = L(part.labelKey);
element.style.setProperty("--b04-layer-color", themeColor(part.token[0], part.token[1]));
element.setAttribute("aria-pressed", String(initialActive));
element.addEventListener("click", () => {
shownParts[part.key] = !shownParts[part.key];
element.classList.toggle("is-active", shownParts[part.key]);
element.setAttribute("aria-pressed", String(shownParts[part.key]));
onChange();
});
return element;
});
let projectId: string | null = null;
function strokeLonLat(
context: CanvasRenderingContext2D,
line: ReadonlyArray<readonly [number, number]>,
map: Normalizer,
view: ViewState,
): void {
if (line.length < 2) return;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
context.beginPath();
line.forEach(([lon, lat], index) => {
const x = ((lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (lat - map.latMin) / map.latRange) * ay + by;
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
/** 흐름 방향 바이트를 셀 순서대로 디코드한다(캐시 — 매 프레임 다시 풀지 않는다). */
function flowBytes(region: WatershedAnalysis): Uint8Array | null {
if (!region.flow) return null;
if (flowCache?.source === region.flow.data) return flowCache.bytes;
const binary = atob(region.flow.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
flowCache = { source: region.flow.data, bytes };
return bytes;
}
/** 1차 영역에 걸쳐 실제로 생성된 셀만 그린다.
*
* bbox 전체를 채우지 않는다 — 백엔드가 준 행별 구간(row_spans)만 그린다. 흐름 판정이
* 있으면 셀마다 방향 화살표를 얹고, 도로에 물이 닿는 셀은 적색·닿지 않으면 파랑으로
* 칠한다. 셀이 화면에서 작아지면 화살표가 안 보이므로 채움색만 남긴다. */
function drawGridCells(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
const ring = region.grid.bbox_lonlat;
if (ring.length < 4) return;
const lons = ring.map(([lon]) => lon);
const lats = ring.map(([, lat]) => lat);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
const left = ((Math.min(...lons) - map.lonMin) / map.lonRange) * ax + bx;
const right = ((Math.max(...lons) - map.lonMin) / map.lonRange) * ax + bx;
const top = (1 - (Math.max(...lats) - map.latMin) / map.latRange) * ay + by;
const bottom = (1 - (Math.min(...lats) - map.latMin) / map.latRange) * ay + by;
const { rows, cols, row_spans: spans } = region.grid;
const cellW = (right - left) / Math.max(cols, 1);
const cellH = (bottom - top) / Math.max(rows, 1);
const cellPx = Math.min(Math.abs(cellW), Math.abs(cellH));
const bytes = flowBytes(region);
// 1m 격자를 도엽 전체 배율로 보면 셀이 1~2px라 셀마다 화살표를 그리면 아무것도 안 보인다.
// 화면에서 대략 ARROW_SPACING_PX 간격이 되도록 셀을 건너뛰며 표본만 그린다.
const stride = Math.max(1, Math.ceil(ARROW_SPACING_PX / Math.max(cellPx, 0.01)));
const arrowPx = cellPx * stride;
context.save();
context.setLineDash([]);
context.lineCap = "round";
let cursor = 0; // row_spans를 훑은 순서 = 흐름 바이트 순서
spans.forEach(([row, colStart, colEnd]) => {
const count = colEnd - colStart + 1;
const base = cursor;
cursor += count;
const y = top + cellH * row;
if (y + cellH < -40 || y > view.height + 40) return;
const x = left + cellW * colStart;
const width = cellW * count;
if (x + width < -40 || x > view.width + 40) return;
if (!bytes) {
// 흐름 판정 전 — 격자만 흰 선으로 보여 준다.
if (cellPx >= 2) {
context.strokeStyle = gridLineColor();
context.lineWidth = 0.5;
context.beginPath();
for (let col = colStart; col <= colEnd; col += 1) {
context.rect(left + cellW * col, y, cellW, cellH);
}
context.stroke();
} else {
context.fillStyle = "rgba(255, 255, 255, 0.2)";
context.fillRect(x, y, width, cellH);
}
return;
}
const sink = region.flow?.sink_code ?? 32;
const invalid = region.flow?.invalid_code ?? 33;
const steps = region.flow?.azimuth_steps ?? 32;
for (let offset = 0; offset < count; offset += 1) {
const col = colStart + offset;
// 화살표는 표본만 그린다 — 격자가 촘촘하면 셀마다 그려 봐야 뭉개져서 안 보인다.
const sampled = row % stride === 0 && col % stride === 0;
drawFlowCell(
context,
bytes[base + offset],
left + cellW * col,
y,
cellW,
cellH,
cellPx,
sampled ? arrowPx : 0,
{ sink, invalid, steps },
);
}
});
context.restore();
}
/** 셀 하나 — 도달 여부로 칠하고, 충분히 크면 32방위 흐름 화살표를 얹는다. */
function drawFlowCell(
context: CanvasRenderingContext2D,
code: number,
x: number,
y: number,
cellW: number,
cellH: number,
cellPx: number,
arrowPx: number,
codes: { sink: number; invalid: number; steps: number },
): void {
const azimuth = code & 0x3f;
const reaches = (code & 0x80) !== 0;
// 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다.
const unanalyzed = azimuth === codes.invalid;
context.fillStyle = unanalyzed
? flowUnknownFill()
: reaches
? flowToRoadFill()
: flowAwayFill();
context.fillRect(x, y, cellW, cellH);
if (cellPx >= 2) {
context.strokeStyle = gridLineColor();
context.lineWidth = 0.5;
context.strokeRect(x, y, cellW, cellH);
}
// arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다.
if (arrowPx < ARROW_MIN_PX || unanalyzed) return;
const stroke = reaches ? flowToRoadLine() : flowAwayLine();
const midX = x + cellW / 2;
const midY = y + cellH / 2;
if (azimuth === codes.sink) {
// 제자리(싱크) — 방향이 없으므로 점으로 표시한다.
context.fillStyle = stroke;
context.beginPath();
context.arc(midX, midY, Math.max(1, arrowPx * 0.12), 0, Math.PI * 2);
context.fill();
return;
}
// 코드 0 = 화면 오른쪽(+x), 시계방향(캔버스 y는 아래가 +).
const angle = (azimuth * 2 * Math.PI) / codes.steps;
const unitX = Math.cos(angle);
const unitY = Math.sin(angle);
const reach = arrowPx * 0.38;
const tipX = midX + unitX * reach;
const tipY = midY + unitY * reach;
// 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다.
const width = Math.max(1, arrowPx * 0.08);
const head = arrowPx * 0.18;
const stem: [number, number][] = [
[midX - unitX * reach, midY - unitY * reach],
[tipX, tipY],
];
for (const [color, lineWidth] of [
["rgba(255, 255, 255, 0.85)", width + 1.6] as const,
[stroke, width] as const,
]) {
context.strokeStyle = color;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(stem[0][0], stem[0][1]);
context.lineTo(stem[1][0], stem[1][1]);
// 촉 — 진행 방향 기준 좌우로 짧게 접는다.
context.moveTo(tipX, tipY);
context.lineTo(tipX - (unitX + unitY * 0.7) * head, tipY - (unitY - unitX * 0.7) * head);
context.moveTo(tipX, tipY);
context.lineTo(tipX - (unitX - unitY * 0.7) * head, tipY - (unitY + unitX * 0.7) * head);
context.stroke();
}
}
/** 1차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */
function drawPrimaryRegion(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
context.save();
// 1차 배수유역 = 상류 세류망의 반경 버퍼 합집합. (격자는 draw()에서 먼저 깔았다)
context.setLineDash([]);
context.lineWidth = 2;
context.strokeStyle = "rgba(5, 150, 105, 0.95)";
context.fillStyle = "rgba(16, 185, 129, 0.12)";
region.region_rings.forEach((ring) => {
strokeLonLat(context, ring, map, view);
context.fill();
});
// ③ 도로 아래로 이어진 하류망 — 판정이 맞는지 대조하도록 회색 파선으로 남긴다.
context.setLineDash([6, 5]);
context.lineWidth = 2;
context.strokeStyle = "rgba(120, 113, 108, 0.85)";
region.downstream_lines.forEach((line) => strokeLonLat(context, line, map, view));
context.restore();
// ④ 채택된 상류망 = 1차 영역의 기준선. 가장 굵게, 맨 위에.
// 그리기는 B05 배수유역도와 같은 공용 렌더러에 맡긴다.
drawUpstreamLines(context, region.upstream_lines, map, view);
}
/** B05에도 같이 쓰는 평균 흐름 화살표. 그리기는 공용 렌더러에 맡긴다. */
function drawMeanArrows(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
// 격자 bbox의 경도 폭과 실폭(m)으로 1m당 픽셀을 환산한다.
const lons = region.grid.bbox_lonlat.map(([lon]) => lon);
const spanLon = Math.max(...lons) - Math.min(...lons);
if (!(spanLon > 0) || !(region.grid.width_m > 0)) return;
const ax = view.mapRect.width * view.scale;
const pxPerMeter = ((spanLon / map.lonRange) * ax) / region.grid.width_m;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
drawFlowArrows(
context,
region.flow_arrows ?? [],
region.arrow_spacing_m ?? 0,
pxPerMeter,
(lon, lat) => [
((lon - map.lonMin) / map.lonRange) * ax + bx,
(1 - (lat - map.latMin) / map.latRange) * ay + by,
],
view,
);
}
/** ⑦ 2차 전체 배수유역 외곽선(= 분수령)과 ⑧ 기본 관 위치. */
function drawBasinAndPipes(
context: CanvasRenderingContext2D,
map: Normalizer,
view: ViewState,
region: WatershedAnalysis,
): void {
context.save();
if (region.basin_polygon_lonlat.length > 2) {
context.setLineDash([8, 5]);
context.lineWidth = 2.5;
context.strokeStyle = basinRingColor();
strokeLonLat(context, region.basin_polygon_lonlat, map, view);
}
context.setLineDash([]);
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
region.pipes.forEach((pipe, index) => {
const x = ((pipe.lon - map.lonMin) / map.lonRange) * ax + bx;
const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by;
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fillStyle = pipeMarkerColor();
context.fill();
context.lineWidth = 1.5;
const markerText = themeColor("--map-marker-text", "#111827");
context.strokeStyle = markerText;
context.stroke();
context.fillStyle = markerText;
context.font = "bold 10px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(String(index + 1), x, y);
});
context.restore();
}
function regionSummary(region: WatershedAnalysis): string {
const cells = region.grid.cells.toLocaleString();
const outside =
region.road_outside_m > 0 ? ` · 노선 이탈 ${Math.round(region.road_outside_m)}m` : "";
const unknown =
region.flow && region.flow.unanalyzed > 0
? ` / 표고없음 ${region.flow.unanalyzed.toLocaleString()}(회)`
: "";
const burned =
region.flow && region.flow.burned > 0
? ` · 세류망 새김 ${region.flow.burned.toLocaleString()}셀`
: "";
const flow = region.flow
? ` · 흐름 도로도달 ${region.flow.reaches_road.toLocaleString()}(적) / ` +
`미도달 ${region.flow.no_road.toLocaleString()}(청)${unknown}, ` +
`최외곽 출발 ${region.flow.outer_seeds.toLocaleString()} + ` +
`내부 보충 ${region.flow.interior_seeds.toLocaleString()}${burned}`
: " · 흐름 판정 없음";
const expansion = region.expansion
? ` · 확장 ${region.expansion.rounds}회` +
`(${region.expansion.initial_cells.toLocaleString()}${cells}셀, ` +
`${region.expansion.closed ? "닫힘" : "상한 도달"})`
: "";
const basin = region.basin_area_m2
? ` · 2차 유역 ${formatArea(region.basin_area_m2)}, 기본 관 ${region.pipes.length}개`
: "";
return (
`1차 영역(반경 ${region.radius_m}m): 상류망 ${region.upstream_lines.length}조각 채택 / ` +
`하류망 ${region.downstream_lines.length}조각·미연결 ${region.no_contact_count}개 제외 · ` +
`격자 ${region.grid.cell_m}m(도로 시점 기준) 셀 ${cells}${outside}${expansion}${basin}${flow}`
);
}
function formatArea(areaM2: number): string {
return areaM2 >= 10000 ? `${(areaM2 / 10000).toFixed(2)}ha` : `${Math.round(areaM2)}㎡`;
}
/** 분석 결과를 받아 화면에 올린다.
* `refresh=false`면 영구저장소에 남은 결과를 그대로 받아 즉시 끝나므로 지도를 열 때
* 자동으로 부른다. `refresh=true`(재산정 버튼)면 처음부터 다시 계산한다. */
async function loadAnalysis(refresh: boolean): Promise<void> {
if (!projectId || busy) return;
busy = true;
button.disabled = true;
button.textContent = L(
refresh ? "B04_Surface_Watershed_Btn_Analyzing" : "B04_Surface_Watershed_Btn_Loading",
);
// 진행 문구는 우상단 전용 줄에 띄운다 — 좌상단의 직전 결과를 지우지 않는다.
sayBusy(
L(
refresh ? "B04_Surface_Watershed_Status_Analyzing" : "B04_Surface_Watershed_Status_Loading",
),
);
const started = performance.now();
try {
analysis = await fetchWatershedAnalysis(projectId, refresh);
shown = true;
button.classList.add("is-active");
button.setAttribute("aria-pressed", "true");
const seconds = ((performance.now() - started) / 1000).toFixed(1);
const origin = analysis.from_cache
? L("B04_Surface_Watershed_Origin_Cached")
: L("B04_Surface_Watershed_Origin_Recomputed").replace("{seconds}", 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 : L("B04_Surface_Watershed_LoadFailed");
// 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다.
say(
refresh
? L("B04_Surface_Watershed_Failed").replace("{message}", message)
: L("B04_Surface_Watershed_NoSaved"),
);
} finally {
busy = false;
sayBusy(""); // 끝났으면 진행 문구를 거둔다 — 결과는 좌상단 줄에 남는다
button.disabled = false;
button.textContent = L("B04_Surface_Watershed_Btn");
onChange();
}
}
// 재산정 버튼은 언제나 처음부터 다시 분석한다.
// (저장분을 자동으로 띄우게 바꾼 뒤로 이 버튼이 표시 토글로 먼저 걸려, 눌러도 아무 일이
// 없는 것처럼 보였다 — 2026-08-01. 보이기/숨기기는 갈래별 버튼이 맡는다.)
button.addEventListener("click", () => {
void loadAnalysis(true);
});
return {
button,
partButtons,
statusElement,
busyElement,
visible: () => shown && analysis !== null,
status: () => statusText,
analysis: () => analysis,
reset() {
analysis = null;
flowCache = null;
shown = false;
say("");
button.classList.remove("is-active");
button.setAttribute("aria-pressed", "false");
},
setProject(next: string) {
projectId = next;
// 저장분이 있으면 즉시 올린다 — 없으면 조용히 넘어가고, 재산정 버튼을 누르면 계산한다.
void loadAnalysis(false);
},
draw(context, map, view) {
if (!shown || !analysis) return;
// 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다.
if (shownParts.flow) drawGridCells(context, map, view, analysis);
if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis);
if (shownParts.arrows) drawMeanArrows(context, map, view, analysis);
if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis);
},
};
}