fix(B05 배수유역): 369-3 지도 위 단추 · 흐림 판 누름 = viewport 집기 · 유역 고르기 · 팬에서 뺌

- 흐림 판 [업데이트] 아래 관 표지가 있으면 setPointerCapture 로 단추가 click 못 받던 것
- 시험 test_369_3_update_press(가짜 표지 + pointer 사건 차례)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-17 14:13:20 +09:00
co-authored by Claude Opus 5.5
parent 8fedfbed0d
commit 468cee8fb7
3 changed files with 195 additions and 0 deletions
@@ -24,6 +24,9 @@ import type { createMapContextMenu } from "@ui/ui_template_context_menu";
/** 이만큼(px) 이하로 움직였다 뗐으면 클릭으로 본다 — 손떨림으로 선택이 안 되는 일을 막는다. */
const CLICK_SLOP_PX = 4;
/** 지도 집기 · 유역 고르기 · 팬에서 빼는 지도 위 요소. */
const HTML_CONTROLS =
"button, input, select, textarea, a, label, [role='button'], .b05-drainage__veil";
export interface DrainageInteractParams {
viewport: HTMLElement;
@@ -92,6 +95,9 @@ export function bindDrainageInteractions(params: DrainageInteractParams): void {
viewport.addEventListener("pointerdown", (event) => {
if (contextMenu.contains(event.target)) return;
contextMenu.close();
// 지도 위 HTML 조작 요소(흐림 판 [업데이트] 등) 누름은 그 요소 몫 — 아래 관 표지를 집어
// setPointerCapture 하면 단추가 pointerup · click 을 못 받는다(369-3).
if (event.target instanceof Element && event.target.closest(HTML_CONTROLS)) return;
// 중간 버튼 기본 동작(페이지 자동 스크롤)이 지도 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
const rect = viewport.getBoundingClientRect();
@@ -0,0 +1,147 @@
/* 369-3 [업데이트] 진짜 누름 헬퍼 — `B05_Profile_UI_Drainage_Interact.ts` 를 그 자리에서
* 트랜스파일해 가짜 viewport · 단추 · 관 표지로 pointerdown → pointerup → click 차례를 돌림.
* 사로잡기(setPointerCapture)가 걸리면 pointerup · click 은 viewport 로 감(브라우저와 같음).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
class Element {
constructor(tag, parent = null) {
this.tag = tag;
this.parent = parent;
this.className = "";
this.listeners = {};
}
matches(selector) {
return selector.split(",").some((part) => {
const s = part.trim();
return s.startsWith(".") ? this.className.split(" ").includes(s.slice(1)) : s === this.tag;
});
}
closest(selector) {
for (let node = this; node; node = node.parent) if (node.matches(selector)) return node;
return null;
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
fire(type, event) {
(this.listeners[type] ?? []).forEach((fn) => fn(event));
}
}
global.Element = Element;
const stubs = {
"../A00_Common/touch_bridge": { wheelZoomsIn: () => true },
"../B04_PreProcess/B04_PreProcess_UI_MapRender": {
computeMaxScale: () => 10,
lonLatToScreen: () => ({ x: 0, y: 0 }),
},
"../B04_PreProcess/B04_PreProcess_UI_MapOverlays": { pointInRings: () => true },
"./B05_Profile_UI_Drainage_ZoomRange": { clampWheelScale: (value) => value },
};
const file = path.join(B05, "B05_Profile_UI_Drainage_Interact.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(
box.exports,
box,
(name) => stubs[name] ?? require(name),
);
function setup() {
const viewport = new Element("div");
viewport.className = "b05-drainage__viewport";
viewport.style = { removeProperty() {} };
viewport.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 300 });
viewport.captured = null;
viewport.setPointerCapture = (id) => (viewport.captured = id);
const canvas = new Element("canvas", viewport);
const veil = new Element("div", viewport);
veil.className = "b05-drainage__veil";
const update = new Element("button", veil);
update.className = "b05-drainage__analyze b05-drainage__update";
const log = { live: 0, grabs: 0, picks: 0 };
update.addEventListener("click", () => log.live++); // [업데이트] = drainage/live 한 번
let dragging = false;
const state = { scale: 1, offset: { x: 0, y: 0 } };
box.exports.bindDrainageInteractions({
viewport,
contextMenu: { contains: () => false, close() {} },
// 가짜 표지 — 단추 바로 아래(어디를 눌러도 집힘)
pipeEditor: {
handleDown: () => ((dragging = true), log.grabs++, true),
handleMove: () => dragging,
handleUp: () => ((dragging = false), false),
},
currentView: () => ({ mapRect: { width: 400 }, width: 400 }),
getScale: () => state.scale,
setScale: (v) => (state.scale = v),
getMeta: () => null,
getMinScale: () => 0.1,
getOffset: () => state.offset,
setOffset: (x, y) => (state.offset = { x, y }),
scheduleDraw() {},
getNormalizer: () => ({}),
getBasins: () => [
{
index: 0,
polygon_lonlat: [
[0, 0],
[1, 0],
[1, 1],
],
area_m2: 1,
},
],
getSelectedBasin: () => null,
selectBasin: () => log.picks++,
});
return { viewport, canvas, update, log, state };
}
/** 진짜 마우스 누름 — 사건은 target 에서 viewport 로 거품 · 사로잡히면 up · click 이 viewport 로. */
function press(world, target, button = 0) {
const event = { target, button, pointerId: 1, pointerType: "mouse", clientX: 200, clientY: 150 };
const bubble = (type, from) => {
for (let node = from; node; node = node.parent)
node.fire(type, { ...event, preventDefault() {} });
};
world.viewport.captured = null;
bubble("pointerdown", target);
const upTarget = world.viewport.captured !== null ? world.viewport : target;
bubble("pointerup", upTarget);
bubble("click", upTarget);
}
const onButton = setup();
press(onButton, onButton.update);
const onMap = setup();
press(onMap, onMap.canvas);
const panMap = setup();
const panEvent = {
target: panMap.canvas,
button: 1,
pointerId: 2,
pointerType: "mouse",
preventDefault() {},
};
panMap.viewport.fire("pointerdown", { ...panEvent, clientX: 0, clientY: 0 });
panMap.viewport.fire("pointermove", { ...panEvent, clientX: 30, clientY: 40 });
process.stdout.write(
JSON.stringify({
button: {
...onButton.log,
captured: onButton.viewport.captured,
offset: onButton.state.offset,
},
map: { ...onMap.log, captured: onMap.viewport.captured },
pan: { captured: panMap.viewport.captured, offset: panMap.state.offset },
}),
);
@@ -0,0 +1,42 @@
"""369-3 배수유역 흐림 판 [업데이트] 진짜 누름 — 아래 관 표지가 있어도 단추가 click 을 받음.
- 단추 위 pointerdown = viewport 집기 · 사로잡기 · 유역 고르기 안 함 → click = 계산 시작 1회.
- 지도(캔버스) 위 좌클릭 = 관 표지 집기 그대로 · 가운데 버튼 = 팬 그대로.
Node 헬퍼(`helper_369_3_update_press.cjs`)가 Interact.ts 를 그 자리에서 돌린다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_369_3_update_press.cjs")],
capture_output=True,
text=True,
timeout=60,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_단추_누름은_계산_시작_한_번(r):
assert r["button"] == {
"live": 1,
"grabs": 0,
"picks": 0,
"captured": None,
"offset": {"x": 0, "y": 0},
}
def test_지도_누름은_관_표지_집기_가운데는_팬(r):
assert r["map"] == {"live": 0, "grabs": 1, "picks": 0, "captured": 1}
assert r["pan"] == {"captured": 2, "offset": {"x": 30, "y": 40}}