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:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user