diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts index e086450a..9b0f1f27 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Basins.ts @@ -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"; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts index 016ea844..14b6fba5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowStrength.ts @@ -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 = []; @@ -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); }, diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index c6e4f36b..acaf99e8 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -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; diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index acd7350a..a2e79df6 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -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 출처 */