diff --git a/A00_Common/touch_bridge.ts b/A00_Common/touch_bridge.ts index 3b09d9a04..6c83aa700 100644 --- a/A00_Common/touch_bridge.ts +++ b/A00_Common/touch_bridge.ts @@ -26,6 +26,11 @@ const synthetic = new WeakSet(); /** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */ export const isPinchWheel = (e: Event): boolean => synthetic.has(e); +let contactPx = 0; + +/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */ +export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px); + /** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */ export const wheelZoomsIn = (e: WheelEvent): boolean => isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0; @@ -114,6 +119,7 @@ function hintOnce(target: Element): void { } function onDown(e: PointerEvent): void { + contactPx = e.pointerType === "touch" ? Math.max(24, e.width / 2, e.height / 2) : 0; if (e.pointerType !== "touch") return; const p = { x: e.clientX, y: e.clientY }; touches.set(e.pointerId, p); @@ -223,4 +229,4 @@ function listen(): void { } // 브라우저에서만(노드 시험이 이 모듈을 거쳐 불러도 됨) -if (typeof window !== "undefined") listen(); +if (typeof window !== "undefined" && typeof window.addEventListener === "function") listen(); diff --git a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts index 864188844..12a99a0d6 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Pipes.ts @@ -1,4 +1,5 @@ import { themeColor } from "@ui/ui_template_palette"; +import { pickPx } from "../A00_Common/touch_bridge"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { snapStructureStation } from "../common_util/common_util_structure_station"; @@ -196,7 +197,7 @@ export function createPipeEditor( if (!xy) continue; const screen = metricToScreen(view, xy.x, xy.y); if (!screen) continue; - if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) return i; + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) return i; } return null; }, @@ -221,20 +222,20 @@ export function createPipeEditor( const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); - return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX; + return !!nearest && nearest.distance * pxPerMeter(view) <= pickPx(ADD_SNAP_PX); }, chainageAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return null; const nearest = nearestChainage(metric.x, metric.y); - if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; + if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return null; return onRoute(nearest.chainage); }, addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; const nearest = nearestChainage(metric.x, metric.y); - if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return false; + if (!nearest || nearest.distance * pxPerMeter(view) > pickPx(ADD_SNAP_PX)) return false; const at = onRoute(nearest.chainage); pipeList.push({ chainage_m: at, reason: "confirmed" }); sortPipes(); @@ -253,7 +254,7 @@ export function createPipeEditor( if (!xy) continue; const screen = metricToScreen(view, xy.x, xy.y); if (!screen) continue; - if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) { + if (Math.hypot(screenX - screen.x, screenY - screen.y) <= pickPx(HIT_RADIUS_PX)) { selectedIndex = i; draggingIndex = i; dragFrom = pipeList[i].chainage_m; diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 0ff9bc61a..1a6c3bc4d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -9,6 +9,7 @@ * 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다. * ========================================================================== */ +import { pickPx } from "../A00_Common/touch_bridge"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { irregularStationId, @@ -91,7 +92,7 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti // 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다. const near = options.stations .map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) })) - .filter((entry) => entry.distance <= GRAB_SLACK_PX) + .filter((entry) => entry.distance <= pickPx(GRAB_SLACK_PX)) .sort((left, right) => left.distance - right.distance)[0]; event.preventDefault(); const at = Number(chainage.toFixed(2)); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 2e3b6198d..fbef8b083 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -13,7 +13,7 @@ * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). * ========================================================================== */ -import { wheelZoomsIn } from "../A00_Common/touch_bridge"; +import { pickPx, wheelZoomsIn } from "../A00_Common/touch_bridge"; import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; @@ -211,7 +211,7 @@ export function arcAtScreen( toScreen: ScreenOf, px: number, py: number, - hitPx: number = ARC_HIT_PX, + hitPx: number = pickPx(ARC_HIT_PX), ): number { let best = -1; let bestDistance = hitPx; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 1a6a39ec6..98cda38de 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -16,6 +16,7 @@ * 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다. * ========================================================================== */ +import { pickPx } from "../A00_Common/touch_bridge"; import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input"; import { stationLabel as formatStation } from "../common_util/common_util_station"; @@ -275,7 +276,13 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { picked.length === 1 ? [picked[0].line] : ["planned", "expected"]; const hits = candidates .map((line) => { - const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX); + const hit = routePointAtScreen( + lines[line], + params.toScreen, + px, + py, + pickPx(MEASURE_HIT_PX), + ); if (!hit) return null; const [sx, sy] = params.toScreen(hit.point); return { ...hit, line, distance: Math.hypot(sx - px, sy - py) }; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index ac87885c4..3763161ea 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -8,6 +8,7 @@ * 그대로 건네받아 **제자리에서** 고친다(되돌리기 사진첩이 같은 배열을 본다). * ========================================================================== */ +import { pickPx } from "../A00_Common/touch_bridge"; import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; import { @@ -112,13 +113,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP toScreen, px, py, - NODE_HIT_PX + 2, + pickPx(NODE_HIT_PX) + 2, ); }; const nodeAt = (px: number, py: number): number => - nodeAtScreen(params.state().planned, toScreen, px, py, NODE_HIT_PX); + nodeAtScreen(params.state().planned, toScreen, px, py, pickPx(NODE_HIT_PX)); const segmentAt = (px: number, py: number): number => - segmentAtScreen(params.state().planned, toScreen, px, py, SEGMENT_HIT_PX); + segmentAtScreen(params.state().planned, toScreen, px, py, pickPx(SEGMENT_HIT_PX)); /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( @@ -191,7 +192,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP params.stationIntervalM, px, py, - STATION_HIT_PX, + pickPx(STATION_HIT_PX), stationLabelBoxes(canvas), ); if (chainage === null) return false; @@ -221,7 +222,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP state.view, px, py, - CONTOUR_HIT_PX, + pickPx(CONTOUR_HIT_PX), params.contourStepM(), ); if (hit !== state.pickedContour) { diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 9f883cc85..42f64a4a4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -14,6 +14,7 @@ * 그래프는 매 그리기마다 새로 만들어지므로 이 레인도 그때마다 다시 붙인다. * ========================================================================== */ +import { pickPx } from "../A00_Common/touch_bridge"; import { structureAnchorM, type StructureInstance, @@ -319,7 +320,7 @@ function attachMarkDrag( maxOffsetPx = Math.max(maxOffsetPx, Math.abs(event.clientX - startX)); // 여유를 벗어나기 전에는 시각적으로도 움직이지 않는다 — 클릭 중 마크가 흔들리면 // 이동으로 오해하기 쉽다(2026-08-19 민감도 완화). - if (maxOffsetPx <= GRAB_SLACK_PX) return; + if (maxOffsetPx <= pickPx(GRAB_SLACK_PX)) return; const lane = mark.parentElement; if (!lane) return; const localX = event.clientX - lane.getBoundingClientRect().left; @@ -331,7 +332,7 @@ function attachMarkDrag( dragging = false; mark.releasePointerCapture(event.pointerId); // 여유 안에서 뗐으면 이동이 아니라 클릭이다 — 고르려다 옮겨지면 곤란하다. - if (maxOffsetPx <= GRAB_SLACK_PX) return; + if (maxOffsetPx <= pickPx(GRAB_SLACK_PX)) return; const lane = mark.parentElement; if (!lane) return; const localX = event.clientX - lane.getBoundingClientRect().left; diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 16b25c7a7..a94566b48 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -13,6 +13,7 @@ * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. * ========================================================================== */ +import { pickPx } from "../A00_Common/touch_bridge"; import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; @@ -294,7 +295,7 @@ export function pickAt( .sort((a, b) => areas[a] - areas[b])[0]; if ( paths.length && - gaps[nearest] <= LINE_PICK_PX && + gaps[nearest] <= pickPx(LINE_PICK_PX) && (face === undefined || gaps[nearest] * 2 <= across) ) return { piece: nearest }; diff --git a/resources/tester/helper_165_pavement_flow.cjs b/resources/tester/helper_165_pavement_flow.cjs index 9da73a054..156a2845b 100644 --- a/resources/tester/helper_165_pavement_flow.cjs +++ b/resources/tester/helper_165_pavement_flow.cjs @@ -167,6 +167,7 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), { + "../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로 "@ui/ui_template_context_menu": { createMapContextMenu() {} }, "./B05_Profile_UI_IrregularStations": { irregularStationId: (id) => `irregular:${id}`, diff --git a/resources/tester/helper_231_1_pave_reload.cjs b/resources/tester/helper_231_1_pave_reload.cjs index 749a362d8..821a57de2 100644 --- a/resources/tester/helper_231_1_pave_reload.cjs +++ b/resources/tester/helper_231_1_pave_reload.cjs @@ -186,6 +186,7 @@ const panelModule = loadTs( "../B05_Profile/B05_Profile_UI_Profile_Structures": loadTs( path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), { + "../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로 "@ui/ui_template_context_menu": { createMapContextMenu() {} }, "./B05_Profile_UI_IrregularStations": { irregularStationId: (id) => `irregular:${id}`, diff --git a/resources/tester/helper_234_1_structure_station.cjs b/resources/tester/helper_234_1_structure_station.cjs index 87fff283b..865900800 100644 --- a/resources/tester/helper_234_1_structure_station.cjs +++ b/resources/tester/helper_234_1_structure_station.cjs @@ -172,6 +172,7 @@ Promise.resolve( // ⑤ 배수유역도 관 지점 — 옮김 · 우클릭 추가 = 정수 m const pipesEditor = loadTs("B05_Profile/B05_Profile_UI_Drainage_Pipes.ts", { + "../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로 "@ui/ui_template_palette": { themeColor: () => "#000" }, "../common_util/common_util_structure_station": station, }); diff --git a/resources/tester/helper_284_1_pill_pick.cjs b/resources/tester/helper_284_1_pill_pick.cjs index 1b7dd660f..42d219a92 100644 --- a/resources/tester/helper_284_1_pill_pick.cjs +++ b/resources/tester/helper_284_1_pill_pick.cjs @@ -26,6 +26,7 @@ const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selecti }); const PREFIX = "irregular:"; const rule = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Structures.ts"), { + "../A00_Common/touch_bridge": { pickPx: (px) => px }, // 기기대응 4-4 — 마우스 반경 그대로 "@ui/ui_template_context_menu": { createMapContextMenu() {} }, "./B05_Profile_UI_IrregularStations": { irregularStationId: (id) => `${PREFIX}${id}`, diff --git a/resources/tester/test_touch_bridge.py b/resources/tester/test_touch_bridge.py index ef4365988..a56c75620 100644 --- a/resources/tester/test_touch_bridge.py +++ b/resources/tester/test_touch_bridge.py @@ -4,6 +4,7 @@ sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자 ① 길게 누름 → contextmenu 한 번 · 뒤따르는 click 없음 ② 짧은 톡 → contextmenu 없음 ③ 두 번 톡 → dblclick 한 번 ④ 두 손가락 벌림 → Ctrl+휠(deltaY < 0) · 오므림 → deltaY > 0 ⑤ [data-touch-own] 상자는 번역 안 함 ⑥ 마우스 우클릭 · 더블클릭은 그대로(번역이 끼지 않음) +⑦ 고름 반경 `pickPx` — 손가락이면 24px 이상 · 마우스면 그대로 """ import json @@ -127,6 +128,20 @@ def test_touch_bridge_translates_finger_gestures(): names, raw = pinch(310, 20, 70) assert not [e for e in raw if not e["trusted"] and e["id"] == "tb-own"], raw + # ⑦ 고름 반경(4-4) — 손가락으로 누른 뒤 = 접촉 크기(최소 24) · 마우스로 누른 뒤 = 그대로 + # 앱이 불러온 그 주소(vite 가 ?t= 를 붙였을 수 있음) — 다른 사본이면 상태가 따로 + pick = ( + "async () => (await import(performance.getEntriesByType('resource')" + ".map((e) => e.name).find((n) => n.includes('/touch_bridge.ts')))).pickPx(4)" + ) + _touch(cdp, "touchStart", [(100, 200)]) + _touch(cdp, "touchEnd", []) + assert page.evaluate(pick) >= 24 + page.mouse.click(100, 400) + assert page.evaluate(pick) == 4 + page.wait_for_timeout(400) + _log(page) + # ⑥ 마우스 — 우클릭 · 더블클릭은 브라우저 것 하나씩만 page.mouse.click(100, 200, button="right") page.mouse.dblclick(100, 200)