feat(device): B05 · B06 손가락 고름 반경 — 공용 판정이 없어 touch_bridge pickPx 한 곳(접촉 크기 · 최소 24px · 마우스는 그대로) · 고름 상수 쓰는 자리 14곳을 감쌈 · 흉내 시험 넷에 stub 한 줄 (기기대응 4-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-13 13:39:48 +09:00
co-authored by Claude Opus 5.5
parent 4e0d7d2a4a
commit f5e7c839d0
13 changed files with 55 additions and 18 deletions
+7 -1
View File
@@ -26,6 +26,11 @@ const synthetic = new WeakSet<Event>();
/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */
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();
+6 -5
View File
@@ -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;
@@ -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));
@@ -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;
@@ -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) };
@@ -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) {
@@ -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;
@@ -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 };
@@ -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}`,
@@ -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}`,
@@ -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,
});
@@ -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}`,
+15
View File
@@ -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)