fix(B04): 우클릭 메뉴 동작 복구 + 배수유역 토글 정리, 세부유역 서클 번호
- 관 매설 추가·삭제가 안 되던 원인 수정: 메뉴 항목을 누를 때 pointerdown이 뷰포트로 전파돼 메뉴가 먼저 닫히면서 click이 발생하지 않았다. 메뉴 안에서 난 pointerdown/pointerup/contextmenu는 지도 조작에서 제외한다. - 관 매설·세부유역 토글을 "세부유역" 하나로 합쳤다(따로 끄면 관을 옮겨도 유역이 보이지 않아 판단할 수 없다). - 유입 집중점 마커를 흐름 강도 색칠에서 떼어 "집중유역" 전용 토글로 옮기고 기본을 꺼짐으로 두었다. 끄면 골라 둔 유입 외곽선도 함께 걷는다. - 세부유역마다 서클 번호를 폴리곤 면적 중심에 얹었다(시점에서 종점 순). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 출처 */
|
||||
|
||||
Reference in New Issue
Block a user