fix(B04): 우클릭 메뉴 동작 복구 + 배수유역 토글 정리, 세부유역 서클 번호

- 관 매설 추가·삭제가 안 되던 원인 수정: 메뉴 항목을 누를 때 pointerdown이
  뷰포트로 전파돼 메뉴가 먼저 닫히면서 click이 발생하지 않았다. 메뉴 안에서 난
  pointerdown/pointerup/contextmenu는 지도 조작에서 제외한다.
- 관 매설·세부유역 토글을 "세부유역" 하나로 합쳤다(따로 끄면 관을 옮겨도 유역이
  보이지 않아 판단할 수 없다).
- 유입 집중점 마커를 흐름 강도 색칠에서 떼어 "집중유역" 전용 토글로 옮기고
  기본을 꺼짐으로 두었다. 끄면 골라 둔 유입 외곽선도 함께 걷는다.
- 세부유역마다 서클 번호를 폴리곤 면적 중심에 얹었다(시점에서 종점 순).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:04:29 +09:00
co-authored by Claude Opus 5
parent 07c4a7897c
commit 83f23aa43f
4 changed files with 133 additions and 33 deletions
+78 -27
View File
@@ -62,15 +62,62 @@ function basinColor(index: number, alpha: number): string {
return `hsla(${hue.toFixed(0)}, 70%, 55%, ${alpha})`;
}
/** 유역 번호 서클 반지름(px). 관 마커(8)보다 커야 둘이 겹쳐도 구분된다. */
const BASIN_NUMBER_RADIUS = 12;
interface PipeMarker {
chainage: number;
source: PipeSource;
}
/** 폴리곤 면적 중심(화면 px). 면적이 0에 가까우면 정점 평균으로 물러난다. */
function ringCentroid(ring: ReadonlyArray<[number, number]>): [number, number] {
let twiceArea = 0;
let cx = 0;
let cy = 0;
for (let index = 0; index < ring.length; index += 1) {
const [x0, y0] = ring[index];
const [x1, y1] = ring[(index + 1) % ring.length];
const cross = x0 * y1 - x1 * y0;
twiceArea += cross;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}
if (Math.abs(twiceArea) < 1e-6) {
const sum = ring.reduce((acc, [x, y]) => [acc[0] + x, acc[1] + y] as [number, number], [0, 0]);
return [sum[0] / ring.length, sum[1] / ring.length];
}
return [cx / (3 * twiceArea), cy / (3 * twiceArea)];
}
/** 유역 번호를 서클 숫자로 얹는다. 번호는 시점에서 종점 순(백엔드가 누가거리 순으로 준다). */
function drawBasinNumbers(
context: CanvasRenderingContext2D,
labels: ReadonlyArray<{ index: number; number: number; point: [number, number] }>,
): void {
if (labels.length === 0) return;
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
context.font = "bold 13px sans-serif";
labels.forEach(({ index, number, point: [x, y] }) => {
context.beginPath();
context.arc(x, y, BASIN_NUMBER_RADIUS, 0, Math.PI * 2);
context.fillStyle = basinColor(index, 0.92);
context.fill();
context.lineWidth = 2;
context.strokeStyle = haloColor();
context.stroke();
context.fillStyle = haloColor();
context.fillText(String(number), x, y);
});
context.restore();
}
export interface DetailBasinOverlay {
/** 세부유역 재계산 버튼. */
button: HTMLButtonElement;
/** 표시 토글 — 관 매설 마커 / 세부 유역. 유입 집중점과 겹쳐 볼 수 있게 따로 둔다. */
/** 표시 토글 — 관 마커와 세부유역을 함께 켜고 끈다(하나로 묶임). */
partButtons: HTMLButtonElement[];
statusElement: HTMLElement;
/** 우클릭 메뉴. 지도 뷰포트에 얹는다(뷰포트 기준 절대 위치). */
@@ -120,27 +167,23 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
button.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
button.title = L("B04_Surface_Basin_Btn_Tip");
const shownParts = { pipes: true, basins: true };
const partButtons = (
[
["pipes", "B04_Surface_Basin_Part_Pipes", PIPE_COLORS.stream],
["basins", "B04_Surface_Basin_Part_Basins", ["--map-pipe-user", "#16a34a"]],
] as const
).map(([key, labelKey, token]) => {
const element = document.createElement("button");
element.type = "button";
element.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
element.textContent = L(labelKey);
element.style.setProperty("--b04-layer-color", themeColor(token[0], token[1]));
element.setAttribute("aria-pressed", "true");
element.addEventListener("click", () => {
shownParts[key] = !shownParts[key];
element.classList.toggle("is-active", shownParts[key]);
element.setAttribute("aria-pressed", String(shownParts[key]));
onChange();
});
return element;
// 관 마커와 세부유역은 늘 같이 본다 — 따로 끄면 관을 옮겨도 유역이 안 보여 판단할 수 없다.
// 그래서 토글은 하나뿐이다(2026-08-01 사용자 지시).
let shown = true;
const partButton = document.createElement("button");
partButton.type = "button";
partButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active";
partButton.textContent = L("B04_Surface_Basin_Part_Basins");
partButton.style.setProperty("--b04-layer-color", themeColor("--map-pipe-user", "#16a34a"));
partButton.setAttribute("aria-pressed", "true");
partButton.addEventListener("click", () => {
shown = !shown;
partButton.classList.toggle("is-active", shown);
partButton.setAttribute("aria-pressed", String(shown));
if (!shown) closeMenu();
onChange();
});
const partButtons = [partButton];
function say(text: string): void {
statusElement.textContent = text;
@@ -320,7 +363,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
},
handlePointerDown(view, x, y) {
closeMenu();
if (!shownParts.pipes || pipes.length === 0) return false;
if (!shown || pipes.length === 0) return false;
const hit = hitPipe(view, x, y);
if (hit === null) return false;
dragging = hit;
@@ -344,7 +387,7 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
},
handleContextMenu(view, x, y) {
closeMenu();
if (!shownParts.pipes) return false;
if (!shown) return false;
const hit = hitPipe(view, x, y);
if (hit !== null) {
openMenu(x, y, [
@@ -388,14 +431,20 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
return response.pipe_count;
},
draw(context, normalizer, view) {
if (shownParts.basins && normalizer && basins.length > 0) {
if (!shown) return;
if (normalizer && basins.length > 0) {
// 유역 번호를 얹을 자리는 폴리곤을 그리면서 같이 모은다 — 화면 좌표를 두 번 계산하지
// 않는다. 번호는 채움 위에 한꺼번에 얹어야 이웃 유역 채움에 덮이지 않는다.
const labels: Array<{ index: number; number: number; point: [number, number] }> = [];
context.save();
context.lineJoin = "round";
basins.forEach((basin, index) => {
if (basin.polygon_lonlat.length < 3) return;
const ring = basin.polygon_lonlat.map(([lon, lat]) =>
lonLatToScreen(normalizer, view, lon, lat),
);
context.beginPath();
basin.polygon_lonlat.forEach(([lon, lat], order) => {
const [px, py] = lonLatToScreen(normalizer, view, lon, lat);
ring.forEach(([px, py], order) => {
if (order === 0) context.moveTo(px, py);
else context.lineTo(px, py);
});
@@ -405,10 +454,12 @@ export function createDetailBasinOverlay(onChange: () => void): DetailBasinOverl
context.strokeStyle = basinColor(index, 0.95);
context.lineWidth = 1.8;
context.stroke();
labels.push({ index, number: basin.index, point: ringCentroid(ring) });
});
context.restore();
drawBasinNumbers(context, labels);
}
if (!shownParts.pipes || pipes.length === 0) return;
if (pipes.length === 0) return;
context.save();
context.textAlign = "center";
context.textBaseline = "middle";
@@ -47,8 +47,10 @@ const MARKER_HIT_SLACK = 4;
export type { RoutePoint };
export interface FlowStrengthOverlay {
/** 지도 헤더 버튼 줄에 넣을 토글. */
/** 지도 헤더 버튼 줄에 넣을 토글 — 계획선 위 강도 색칠. */
button: HTMLButtonElement;
/** 유입 집중점 마커 전용 토글("집중유역"). 기본 꺼짐 — 마커가 관 마커와 겹쳐 읽기 어렵다. */
markerButton: HTMLButtonElement;
visible: () => boolean;
/** 선택 요약 문구(없으면 빈 문자열). */
status: () => string;
@@ -117,6 +119,31 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
onChange();
});
// 유입 집중점 마커는 관 매설 마커와 같은 계획선 위에 찍혀 서로 가린다. 그래서 강도 색칠과
// 떼어 내 별도 토글을 두고, 기본은 꺼 둔다(2026-08-01 사용자 지시).
let markersShown = false;
const markerButton = document.createElement("button");
markerButton.type = "button";
markerButton.className = "b04-map__layer-button b04-map__layer-button--gis";
markerButton.textContent = L("B04_Surface_Flow_Hotspots");
markerButton.style.setProperty("--b04-layer-color", themeColor("--map-flow-ramp-4", "#f97316"));
markerButton.setAttribute("aria-pressed", "false");
markerButton.title = L("B04_Surface_Flow_Hotspots_Tip");
markerButton.addEventListener("click", () => {
markersShown = !markersShown;
markerButton.classList.toggle("is-active", markersShown);
markerButton.setAttribute("aria-pressed", String(markersShown));
if (!markersShown) {
// 마커를 감추면 골라 둔 유입 외곽선도 같이 걷는다 — 고른 마커가 안 보이는데 외곽선만
// 남으면 무엇을 본 결과인지 알 수 없다.
selected = null;
selectionRings = [];
statusText = "";
requestSequence += 1;
}
onChange();
});
let projectId: string | null = null;
let meta: VWorldMeta | null = null;
let routePoints: ReadonlyArray<RoutePoint> = [];
@@ -253,6 +280,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
return {
button,
markerButton,
visible: () => shown,
status: () => statusText,
setProject(nextProjectId) {
@@ -290,7 +318,7 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
requestSequence += 1;
},
handleClick(_normalizer, view, x, y) {
if (!shown) return false;
if (!markersShown) return false;
// 마커가 없어도 남은 외곽선은 지워 준다 — 안 그러면 지울 방법이 없다.
if (hotspots.length === 0) {
if (selected === null && selectionRings.length === 0) return false;
@@ -336,8 +364,9 @@ export function createFlowStrengthOverlay(onChange: () => void): FlowStrengthOve
return true;
},
draw(context, normalizer, view) {
if (!shown) return;
drawStrengthLine(context, view);
if (shown) drawStrengthLine(context, view);
// 집중점 마커와 그 유입 외곽선은 전용 토글이 켜졌을 때만 그린다.
if (!markersShown) return;
drawSelection(context, normalizer, view);
drawMarkers(context, view);
},
@@ -298,6 +298,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
watershed.button,
...watershed.partButtons,
flowStrength.button,
flowStrength.markerButton,
detailBasins.button,
...detailBasins.partButtons,
);
@@ -534,7 +535,18 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) };
}
/** 이벤트가 우클릭 메뉴 안에서 났는지. 메뉴는 뷰포트의 자식이라 지도 조작과 섞인다. */
function inContextMenu(event: Event): boolean {
const target = event.target;
return target instanceof Node && detailBasins.menuElement.contains(target);
}
viewport.addEventListener("contextmenu", (event) => {
if (inContextMenu(event)) {
// 메뉴 위에서 다시 우클릭하면 브라우저 메뉴만 막고 메뉴는 그대로 둔다.
event.preventDefault();
return;
}
const rect = viewport.getBoundingClientRect();
const opened = detailBasins.handleContextMenu(
currentView(rect),
@@ -546,6 +558,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
});
viewport.addEventListener("pointerdown", (event) => {
// 우클릭 메뉴 위에서 누른 것은 지도 조작이 아니다. 여기서 걸러 내지 않으면 메뉴를
// 닫는 처리가 먼저 돌아 항목의 click이 영영 발생하지 않는다(추가·삭제가 안 되던 원인).
if (inContextMenu(event)) return;
// 중간 버튼은 브라우저 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹쳐 페이지 전체를
// 흔들므로 기본 동작을 차단하고 지도 팬으로만 사용한다.
if (event.button === 1) event.preventDefault();
@@ -580,6 +595,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
viewport.setPointerCapture(event.pointerId);
});
viewport.addEventListener("pointerup", (event) => {
if (inContextMenu(event)) return;
// 관 마커를 끌던 중이었으면 그것으로 끝낸다 — 집중점 선택까지 겹쳐 일어나면 안 된다.
if (detailBasins.handlePointerUp()) return;
const start = clickStart;
+6 -2
View File
@@ -709,6 +709,11 @@ export const ui_locales = {
"도로 1m 구간마다 그 자리로 모이는 상류 면적을 색으로 칠하고, 물이 특히 많이 모이는 자리를 마커로 찍습니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
"Colors each 1m stretch of road by the upstream area draining into it, and marks the spots that collect the most. Click a marker to outline the cells that drain into it.",
],
B04_Surface_Flow_Hotspots: ["집중유역", "Inflow hotspots"],
B04_Surface_Flow_Hotspots_Tip: [
"노선 위에서 물이 특히 많이 모이는 자리(유입 집중점)를 마커로 표시합니다. 마커를 누르면 그 지점으로 들어오는 셀들의 외곽선을 보여 줍니다.",
"Marks the spots along the route that collect the most water. Click a marker to outline the cells draining into it.",
],
B04_Surface_Flow_Inflow_Loading: ["유입 셀을 불러오는 중…", "Loading the contributing cells…"],
/* {index}=마커 번호, {chainage}=누가거리, {area}=유입면적, {cells}=셀 수, {path}=최장 유하장 */
B04_Surface_Flow_Inflow_Summary: [
@@ -726,8 +731,7 @@ export const ui_locales = {
"관 매설 지점을 기준으로 세부 배수유역을 나눕니다. 계획선 위에서 우클릭하면 관을 추가하고, 마커 위에서 우클릭하면 삭제합니다. 마커를 끌면 계획선을 따라 옮겨집니다.",
"Splits the basin per culvert. Right-click the route to add a culvert, right-click a marker to remove it, and drag a marker to slide it along the route.",
],
B04_Surface_Basin_Part_Pipes: ["관 매설", "Culverts"],
B04_Surface_Basin_Part_Basins: ["세부 유역", "Sub-basins"],
B04_Surface_Basin_Part_Basins: ["세부유역", "Sub-basins"],
B04_Surface_Basin_Menu_Add: ["관 매설 추가", "Add culvert"],
B04_Surface_Basin_Menu_Delete: ["관 매설 삭제", "Remove culvert"],
/* {pipes}=관 개수, {stream}=기본, {spacing}=자동 보충, {user}=수동, {basins}=세부유역 수, {source}=종단 Z 출처 */