- 흐름 강도 색띠 범례를 지도 우측에 세로로 세운다(B04 2D 지도·B05 배수유역도 공용). 색은 화면과 같은 색띠를, 눈금은 같은 로그 정규화를 되돌려 적는다. 제목 "흐름강도". - 배관 누가거리 라벨에 흰 테두리를 깔았다. --map-* 토큰은 다크 테마에서 바뀌지 않아 색만으로는 위성사진·다크 배경에서 묻힌다. - 배수유역 영역 · 종단 그래프 세로선 · 좌측 구조물 폼 · 3D 마커의 선택이 서로를 갱신한다. B05_wf2_Route_UI_Selection.ts로 경로를 한곳에 모으고 isSyncing 가드로 재진입을 막았다. 유역 강조는 origin === "pipe" 항목에만 붙는다. 700줄 규칙 유지를 위한 분리 - B04_wf1_Surface_UI_MapOverlays.ts 신설(유역 채움·번호 배지·분수령·상류 세류망) - reconcilePipes / fetchDrainageLayers / fitViewToRoute → _Drainage_Parts.ts Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
578 lines
25 KiB
TypeScript
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_wf1_Surface_Api_Fetch";
|
|
import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows";
|
|
import type { Normalizer, ViewState } from "./B04_wf1_Surface_UI_MapRender";
|
|
import { drawUpstreamLines } from "./B04_wf1_Surface_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);
|
|
},
|
|
};
|
|
}
|