feat(B04): 표본 화살표 + 오버레이 갈래별 토글 버튼

화살표가 안 보이던 문제
  1m 격자를 도엽 전체 배율로 보면 셀이 1~2px 다. 줌 상한이 8배라 셀이
  화살표 최소 크기(7px)에 절대 못 미쳐 채움색만 보였다.
  -> 셀마다 그리지 않고 화면상 약 22px 간격이 되도록 건너뛰며 표본만 그린다.
     화살표 크기는 그 간격에 맞춰 커진다. 흐름장을 읽는 표준 방식이다.
  -> 채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 그린다.

갈래별 토글 버튼 추가 (1차 유역 / 2차 유역 / 유역 방향)
  GIS 버튼 줄에 배수유역 전체 토글 옆으로 붙는다. 전체 토글이 꺼져 있으면
  갈래 설정과 무관하게 아무것도 그리지 않는다.
  그리는 순서는 유역방향(격자/화살표) -> 1차 유역 -> 2차 유역/관.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 21:09:20 +09:00
co-authored by Claude Fable 5
parent 395e6b4b13
commit 79fe2fb7c9
2 changed files with 76 additions and 24 deletions
@@ -227,7 +227,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
if (text) status.textContent = text;
scheduleDraw();
});
gisButtons.append(watershed.button);
gisButtons.append(watershed.button, ...watershed.partButtons);
function updateImageTransform(): void {
backgroundImages.forEach((image) => {
+75 -23
View File
@@ -23,16 +23,29 @@ const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)";
const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)";
/** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */
const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)";
/** 셀이 이보다 작으면 화살표가 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */
/** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */
const ARROW_MIN_PX = 7;
/** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다.
* 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */
const ARROW_SPACING_PX = 22;
/** 2차 전체 배수유역 외곽선 = 분수령. */
const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)";
/** 기본 관 마커. */
const PIPE_COLOR = "rgba(249, 115, 22, 0.95)";
/** 개별로 켜고 끌 수 있는 오버레이 갈래. */
const PARTS = [
{ key: "primary", label: "1차 유역", color: "#059669" },
{ key: "basin", label: "2차 유역", color: "#92400e" },
{ key: "flow", label: "유역 방향", color: "#2563eb" },
] as const;
type PartKey = (typeof PARTS)[number]["key"];
export interface WatershedOverlay {
/** 레이어 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */
/** 분석 실행 + 전체 토글 버튼. 지도 헤더의 GIS 버튼 줄에 넣는다. */
button: HTMLButtonElement;
/** 갈래별 표시 토글 버튼(1차 유역 / 2차 유역 / 유역 방향). */
partButtons: HTMLButtonElement[];
/** 켜져 있는지. draw() 호출 전에 확인한다. */
visible: () => boolean;
/** 상태 문구(분석 요약 또는 오류). 없으면 빈 문자열. */
@@ -60,6 +73,24 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
"계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 분석합니다. " +
"30초 안팎이 걸리며 결과는 영구저장소에 남습니다.";
// 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다.
const shownParts: Record<PartKey, boolean> = { primary: true, basin: true, flow: true };
const partButtons = PARTS.map((part) => {
const element = document.createElement("button");
element.type = "button";
element.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
element.textContent = part.label;
element.style.setProperty("--b04-layer-color", part.color);
element.setAttribute("aria-pressed", "true");
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(
@@ -123,6 +154,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
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([]);
@@ -158,14 +193,18 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
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 * (colStart + offset),
left + cellW * col,
y,
cellW,
cellH,
cellPx,
sampled ? arrowPx : 0,
{ sink, invalid, steps },
);
}
@@ -182,6 +221,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
cellW: number,
cellH: number,
cellPx: number,
arrowPx: number,
codes: { sink: number; invalid: number; steps: number },
): void {
const azimuth = code & 0x3f;
@@ -199,7 +239,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
context.lineWidth = 0.5;
context.strokeRect(x, y, cellW, cellH);
}
if (cellPx < ARROW_MIN_PX || unanalyzed) return;
// arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다.
if (arrowPx < ARROW_MIN_PX || unanalyzed) return;
const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE;
const midX = x + cellW / 2;
const midY = y + cellH / 2;
@@ -207,7 +248,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
// 제자리(싱크) — 방향이 없으므로 점으로 표시한다.
context.fillStyle = stroke;
context.beginPath();
context.arc(midX, midY, Math.max(1, cellPx * 0.12), 0, Math.PI * 2);
context.arc(midX, midY, Math.max(1, arrowPx * 0.12), 0, Math.PI * 2);
context.fill();
return;
}
@@ -215,23 +256,32 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
const angle = (azimuth * 2 * Math.PI) / codes.steps;
const unitX = Math.cos(angle);
const unitY = Math.sin(angle);
const reach = cellPx * 0.38;
const reach = arrowPx * 0.38;
const tipX = midX + unitX * reach;
const tipY = midY + unitY * reach;
context.strokeStyle = stroke;
context.lineWidth = Math.max(0.6, cellPx * 0.09);
context.beginPath();
context.moveTo(midX - unitX * reach, midY - unitY * reach);
context.lineTo(tipX, tipY);
context.stroke();
// 촉 — 진행 방향 기준 좌우로 짧게 접는다.
const head = cellPx * 0.18;
context.beginPath();
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();
// 표본 화살표는 채움색 위에서도 읽혀야 하므로 흰 테두리를 한 겹 깔고 그 위에 그린다.
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차 배수유역 근거를 겹쳐 그린다 — 단계 검증용. */
@@ -372,6 +422,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
return {
button,
partButtons,
visible: () => shown && analysis !== null,
status: () => statusText,
reset() {
@@ -387,9 +438,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay {
},
draw(context, map, view) {
if (!shown || !analysis) return;
drawGridCells(context, map, view, analysis);
drawPrimaryRegion(context, map, view, analysis);
drawBasinAndPipes(context, map, view, analysis);
// 격자·화살표(유역 방향) → 1차 영역 → 2차 유역·관 순으로 아래에서 위로 쌓는다.
if (shownParts.flow) drawGridCells(context, map, view, analysis);
if (shownParts.primary) drawPrimaryRegion(context, map, view, analysis);
if (shownParts.basin) drawBasinAndPipes(context, map, view, analysis);
},
};
}