feat(B06 횡단): 카드 그래프 Ctrl+휠 = 마우스 자리 확대 · 축소 — 맨 휠은 목록 스크롤 그대로 (397)
공용 줌 부품 wheelZoom 에 "ctrl" 갈래 · 트랙패드 핀치 같은 확대 · 그래프 위 브라우저 확대 막음. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
* · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다
|
||||
* (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선
|
||||
* 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다.
|
||||
* B06 카드는 Ctrl+휠(트랙패드 핀치 포함)만 줌 — `wheelZoom: "ctrl"`(요구 245 · PLAN 397).
|
||||
*
|
||||
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
|
||||
* ========================================================================== */
|
||||
@@ -71,8 +72,9 @@ export interface SvgZoomPanOptions {
|
||||
onChange?: (state: ZoomPanState) => void;
|
||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
content?: ContentBounds;
|
||||
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
|
||||
wheelZoom?: boolean;
|
||||
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다.
|
||||
* `"ctrl"` = Ctrl+휠만 줌 · 맨 휠은 페이지 스크롤에 둠. */
|
||||
wheelZoom?: boolean | "ctrl";
|
||||
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
|
||||
panStart?: (event: PointerEvent) => boolean;
|
||||
}
|
||||
@@ -210,7 +212,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
svg.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
if (options.wheelZoom === "ctrl" && !event.ctrlKey) return;
|
||||
event.preventDefault(); // Ctrl+휠이면 브라우저 확대도 막음(그림 위만)
|
||||
// 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다.
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
|
||||
@@ -224,7 +227,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
|
||||
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
|
||||
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
|
||||
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
|
||||
svg.style?.setProperty("touch-action", options.wheelZoom === true ? "none" : "pan-x pan-y");
|
||||
const touches = new Map<number, { x: number; y: number }>();
|
||||
const touchPair = (): { x: number; y: number; dist: number } => {
|
||||
const [a, b] = [...touches.values()];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ⚠ 줌·팬 **본문은 공용 조각**(`A00_Common/b_svg_zoom_pan.ts`)으로 옮겼다
|
||||
* (2026-09-12 사용자 지시 「공용화해줘」) — 계획노선 편집 횡단이 같은 코드를 쓴다.
|
||||
* 여기에는 **B06 것**만 남는다: 카드 여백(`CROSS_PAD`)을 씌운 감싸개, 측점별 배율 기억,
|
||||
* 우측 상단 버튼 묶음. 동작 규칙(휠은 줌이 아님)은 그대로다 — 카드가 수십 장 깔리는
|
||||
* 우측 상단 버튼 묶음. 맨 휠은 줌이 아님(Ctrl+휠만 줌 · PLAN 397) — 카드가 수십 장 깔리는
|
||||
* 화면에서 휠을 줌에 묶으면 목록을 훑을 수가 없다(2026-08-02 사용자 확정).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -57,8 +57,8 @@ export function attachZoomPan(
|
||||
initial,
|
||||
onChange,
|
||||
content,
|
||||
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
|
||||
wheelZoom: false,
|
||||
// 맨 휠은 카드 목록을 훑는 몫 · Ctrl+휠만 마우스 자리 줌(요구 245 · PLAN 397).
|
||||
wheelZoom: "ctrl",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* PLAN 397 단위검증 헬퍼 — B06 횡단 카드 Ctrl+휠 줌.
|
||||
* 실제 TS(`B06_Section_UI_Cross_View_Zoom.attachZoomPan` → 공용 `b_svg_zoom_pan`)를 Node 로 돌림.
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_397_cross_ctrl_wheel.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const common = fs.readFileSync(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Section_Common.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
const STUBS = { "./B06_Section_UI_Section_Common": { CROSS_PAD, L: (k) => k } };
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => STUBS[request] ?? {};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
globalThis.localStorage = { getItem: () => null }; // 휠 방향 기본(밂 = 확대)
|
||||
STUBS["./input_prefs"] = loadTs(path.join(ROOT, "A00_Common", "input_prefs.ts"));
|
||||
STUBS["../A00_Common/b_svg_zoom_pan"] = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts"));
|
||||
const zoomMod = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_View_Zoom.ts"));
|
||||
|
||||
const W = 600;
|
||||
const H = 300;
|
||||
function fakeSvg() {
|
||||
const listeners = {};
|
||||
const style = {};
|
||||
return {
|
||||
style: { setProperty: (k, v) => (style[k] = v) },
|
||||
styles: style,
|
||||
setAttribute() {},
|
||||
classList: { add() {}, remove() {} },
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
/** 사건을 쏘고 preventDefault 가 불렸는지 돌려줌. */
|
||||
fire: (type, event) => {
|
||||
let prevented = false;
|
||||
(listeners[type] ?? []).forEach((fn) =>
|
||||
fn({
|
||||
preventDefault: () => (prevented = true),
|
||||
stopPropagation() {},
|
||||
pointerId: 1,
|
||||
ctrlKey: false,
|
||||
...event,
|
||||
}),
|
||||
);
|
||||
return prevented;
|
||||
},
|
||||
setPointerCapture() {},
|
||||
releasePointerCapture() {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: W, height: H }),
|
||||
};
|
||||
}
|
||||
|
||||
const round = (v) => Math.round(v * 1e6) / 1e6;
|
||||
const svg = fakeSvg();
|
||||
let state = { scale: 1, tx: 0, ty: 0 };
|
||||
const handle = zoomMod.attachZoomPan(svg, fakeSvg(), W, H, undefined, (s) => (state = s));
|
||||
/** 화면 (x, y) 아래 도형 층 좌표 — 마우스 중심 판정. */
|
||||
const under = (x, y) => [round((x - state.tx) / state.scale), round((y - state.ty) / state.scale)];
|
||||
const out = { touchAction: svg.styles["touch-action"] };
|
||||
|
||||
const at = [400, 120];
|
||||
out.plain = { prevented: svg.fire("wheel", { clientX: 400, clientY: 120, deltaY: -100 }) };
|
||||
out.plain.scale = state.scale;
|
||||
const before = under(...at);
|
||||
out.ctrl = {
|
||||
prevented: svg.fire("wheel", { clientX: 400, clientY: 120, deltaY: -100, ctrlKey: true }),
|
||||
};
|
||||
out.ctrl.scale = round(state.scale);
|
||||
out.ctrl.anchorKept = JSON.stringify(under(...at)) === JSON.stringify(before);
|
||||
svg.fire("wheel", { clientX: 400, clientY: 120, deltaY: 100, ctrlKey: true });
|
||||
out.ctrlOut = round(state.scale);
|
||||
svg.fire("wheel", { clientX: 200, clientY: 150, deltaY: -8, ctrlKey: true }); // 트랙패드 핀치 벌림
|
||||
out.pinch = round(state.scale);
|
||||
handle.zoom(1 / 0.85); // [+] 단추 — 같은 상태에 이어 붙음
|
||||
out.button = round(state.scale);
|
||||
for (let i = 0; i < 40; i++)
|
||||
svg.fire("wheel", { clientX: 1, clientY: 1, deltaY: -100, ctrlKey: true });
|
||||
out.max = round(state.scale);
|
||||
handle.reset(); // [⤢]
|
||||
out.fit = state;
|
||||
|
||||
// 휠 = 줌 화면(B06 상세 · B05 판 · B08)은 그대로 — 맨 휠 줌
|
||||
const plainSvg = fakeSvg();
|
||||
let plainState = null;
|
||||
STUBS["../A00_Common/b_svg_zoom_pan"].attachSvgZoomPan({
|
||||
svg: plainSvg,
|
||||
layer: fakeSvg(),
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
pad: CROSS_PAD,
|
||||
wheelZoom: true,
|
||||
onChange: (s) => (plainState = s),
|
||||
});
|
||||
plainSvg.fire("wheel", { clientX: 300, clientY: 150, deltaY: -100 });
|
||||
out.wheelTrue = { scale: round(plainState.scale), touchAction: plainSvg.styles["touch-action"] };
|
||||
console.log(JSON.stringify(out));
|
||||
@@ -0,0 +1,52 @@
|
||||
"""397 B06 횡단 카드 Ctrl+휠 확대 · 축소(요구 245).
|
||||
|
||||
Node 헬퍼(`helper_397_cross_ctrl_wheel.cjs`)가 실제 TS 를 트랜스파일해 가짜 SVG 로 돌림.
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_397_cross_ctrl_wheel.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_397_cross_ctrl_wheel.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_맨_휠은_스크롤(r):
|
||||
# 줌 안 바뀜 · 기본 동작(카드 목록 스크롤) 안 막음
|
||||
assert r["plain"] == {"prevented": False, "scale": 1}
|
||||
assert r["touchAction"] == "pan-x pan-y" # 한 손가락 목록 밀기 그대로
|
||||
|
||||
|
||||
def test_Ctrl_휠은_마우스_자리_줌(r):
|
||||
assert r["ctrl"]["prevented"] is True # 브라우저 확대 막음
|
||||
assert r["ctrl"]["scale"] == 1.15
|
||||
assert r["ctrl"]["anchorKept"] is True
|
||||
assert r["ctrlOut"] == round(1.15 * 0.87, 6)
|
||||
|
||||
|
||||
def test_트랙패드_핀치도_확대(r):
|
||||
assert r["pinch"] == round(1.15 * 0.87 * 1.15, 6)
|
||||
|
||||
|
||||
def test_단추와_같은_상태_같은_한도(r):
|
||||
assert r["button"] == round(1.15 * 0.87 * 1.15 / 0.85, 6)
|
||||
assert r["max"] == 8
|
||||
assert r["fit"] == {"scale": 1, "tx": 0, "ty": 0} # ⤢ 원배율
|
||||
|
||||
|
||||
def test_휠_줌_화면은_그대로(r):
|
||||
assert r["wheelTrue"] == {"scale": 1.15, "touchAction": "none"}
|
||||
Reference in New Issue
Block a user