diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 951564e82..5dc6d9c80 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -61,6 +61,8 @@ export interface SvgZoomPanOptions { maxScale?: number; /** 이전 상태 — 다시 그려도 배율이 살아남게. */ initial?: ZoomPanState; + /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ + homeScale?: number; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -85,9 +87,15 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { height: Math.max(heightPx - pad.top - pad.bottom, 1), }; const maxScale = options.maxScale ?? 8; - let scale = initial?.scale ?? 1; - let tx = initial?.tx ?? 0; - let ty = initial?.ty ?? 0; + const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); + const home: ZoomPanState = { + scale: homeScale, + tx: (plot.x + plot.width / 2) * (1 - homeScale), + ty: (plot.y + plot.height / 2) * (1 - homeScale), + }; + let scale = initial?.scale ?? home.scale; + let tx = initial?.tx ?? home.tx; + let ty = initial?.ty ?? home.ty; const applyTransform = (): void => { layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`); onChange?.({ scale, tx, ty }); @@ -182,7 +190,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /* 이미 해제됨 */ } }; - if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) { + if (scale !== 1 || tx !== 0 || ty !== 0) { clampPan(); applyTransform(); } @@ -209,9 +217,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { } const currentScale = (): number => scale; const reset = (): void => { - scale = 1; - tx = 0; - ty = 0; + ({ scale, tx, ty } = home); applyTransform(); }; // 더블클릭 원복. diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index b82ab5469..d73a204e5 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -45,12 +45,12 @@ export interface CrossDetailHost { projectId: string; routeId: () => number | null; detail: () => SectionDetailResponse | null; - /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */ + /** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */ chart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; } let host: CrossDetailHost | null = null; @@ -187,12 +187,14 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */ /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · - * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */ + * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · + * 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */ export function attachDetailZoom( clone: SVGSVGElement, axes: CrossAxesOptions | null, initial: ZoomPanState | undefined, onChange: (state: ZoomPanState) => void, + homeScale = 1, ): ZoomPanHandle | null { const layer = clone.querySelector("g.b06-chart__plot-layer"); if (!layer) return null; @@ -223,6 +225,7 @@ export function attachDetailZoom( content: { x: -width, y: -height, width: 3 * width, height: 3 * height }, wheelZoom: true, initial, + homeScale, onChange: (state) => { follow(state); onChange(state); @@ -251,7 +254,7 @@ function drawChart( section: CrossSection, widthPx: number, heightPx: number, -): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null { +): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null { drawing = true; try { const drawn = host?.chart(section.station_id, widthPx, heightPx); @@ -264,7 +267,7 @@ function drawChart( .querySelectorAll(`[clip-path="url(#${id})"]`) .forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`)); }); - return { clone, axes: drawn.axes }; + return { clone, axes: drawn.axes, homeScale: drawn.homeScale }; } finally { drawing = false; } @@ -359,7 +362,13 @@ export function openCrossDetail(section: CrossSection): void { else { const { clone } = drawn; axes = drawn.axes; - const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next)); + const handle = attachDetailZoom( + clone, + axes, + zoom, + (next) => (zoom = next), + drawn.homeScale, + ); cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : [])); tablePick.redraw(); // 선 조각 고르기(209-1) } diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 88f3a9e64..dc327f46f 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -579,7 +579,9 @@ export function createSectionView( elevationMid: plot.elevationMid, exaggeration: Math.max(currentExaggeration, 0.1), }; - return { svg, axes }; + // 칸 맞춤이 넓힌 가로 범위를 처음 배율로 되좁힘 = 카드 반폭(266-1) + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + return { svg, axes, homeScale }; }, focusStation(stationId) { if (stationId === null) clearSelection(); diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 939e33f2b..a67c0d508 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -39,12 +39,12 @@ export interface SectionViewController { /** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */ refreshCards: (chainages: ReadonlyArray) => void; /** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null) - * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */ + * · 축 다시 그리기 옵션(179-3 · 기하 없으면 null) · 처음 배율(266-1 · 가로 = 카드 반폭). */ detailChart: ( stationId: string, widthPx: number, heightPx: number, - ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null; + ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null; /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */ focusStation: (stationId: string | null) => void; diff --git a/resources/tester/helper_266_detail_home.cjs b/resources/tester/helper_266_detail_home.cjs new file mode 100644 index 000000000..a5dfdbdbf --- /dev/null +++ b/resources/tester/helper_266_detail_home.cjs @@ -0,0 +1,110 @@ +/* PLAN 266-1 단위검증 헬퍼 — [상세] 처음 가로 범위 = 카드 반폭. + * 실제 TS(`_Cross_View_Metrics` 칸 맞춤 · `b_svg_zoom_pan` homeScale)를 Node 로 돌림. + * 처음 배율 = 뷰 `detailChart` 와 같은 식(base.ppm / fitted.ppm · 짝 시험이 원본 줄을 확인). + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_266_detail_home.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 B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +const CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]); +const STUBS = { + "./B06_Section_UI_Section_Common": { + CROSS_PAD, + CROSS_HEIGHT, + validElevation: (s) => s.valid !== false && Number.isFinite(s.elevation_m), + }, +}; +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; +} +const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts")); +const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); + +function fakeSvg(width, height) { + const listeners = {}; + const attrs = {}; + return { + attrs, + setAttribute: (name, value) => (attrs[name] = String(value)), + getAttribute: (name) => attrs[name] ?? null, + classList: { add() {}, remove() {} }, + addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), + fire: (type, event) => + (listeners[type] ?? []).forEach((fn) => + fn({ preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }), + ), + setPointerCapture() {}, + releasePointerCapture() {}, + getBoundingClientRect: () => ({ left: 0, top: 0, width, height }), + }; +} + +// 가파른 비탈 — ±30 m 샘플 · 1 m 마다 · 1 m 에 0.6 m 오름(칸 맞춤이 가로를 넓히게) +const section = { + samples: Array.from({ length: 61 }, (_, i) => ({ + offset_m: i - 30, + elevation_m: 100 + 0.6 * (i - 30), + })), +}; + +/** 반폭 · 칸 크기로 상세 창을 연 셈 → 처음 · 줌 · 팬 · 원복 뒤 보이는 가로 범위(m). */ +function open(halfWidth, W, H) { + const base = metrics.crossPlotBase(section, 1, W, halfWidth, 100); + const fitted = metrics.fitPlotBase(base, H); + const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1; + const svg = fakeSvg(W, H); + const layer = fakeSvg(W, H); + let state = null; + const handle = attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: CROSS_PAD, + maxScale: 16, + content: { x: -W, y: -H, width: 3 * W, height: 3 * H }, + wheelZoom: true, + homeScale, + onChange: (next) => (state = next), + }); + // 보이는 플롯 가로 끝(svg x) → 도형 층 x → 측점 거리(m) + const range = () => { + const s = state ?? { scale: 1, tx: 0, ty: 0 }; + const toM = (x) => + fitted.minOffset + ((x - s.tx) / s.scale - CROSS_PAD.left) / fitted.pixelsPerMeter; + return [toM(CROSS_PAD.left), toM(W - CROSS_PAD.right)].map((v) => Math.round(v * 1000) / 1000); + }; + const out = { homeScale, widened: fitted !== base, first: range() }; + svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: 100 }); + out.wheelScale = state.scale / homeScale; + svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 }); + svg.fire("pointermove", { clientX: 160, clientY: 100 }); + svg.fire("pointerup", {}); + out.panned = range()[0] !== out.first[0]; + handle.reset(); // [⤢] + out.reset = range(); + for (let i = 0; i < 40; i++) svg.fire("wheel", { clientX: W / 2, clientY: H / 2, deltaY: -100 }); + out.minScale = state.scale; // 줄이면 칸 맞춤 전체(1배)까지 + svg.fire("dblclick", {}); + out.dblclick = range(); + return out; +} + +process.stdout.write( + JSON.stringify({ + global15: open(15, 1200, 300), + station10: open(10, 1200, 300), + tall15: open(15, 1200, 900), + }), +); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 1b14444f5..96e1842e4 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -84,13 +84,16 @@ def test_모달_배선_단추_칸_비율(): css = _read("B06_Section_UI_Style_Cross_Detail.css") assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src - assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src + flat = " ".join(src.split()) + assert ( + "attachDetailZoom( clone, axes, zoom, (next) => (zoom = next), drawn.homeScale, )" in flat + ) assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3) assert 'clone.querySelector("g.b06-chart__plot-layer")' in src assert "createCrossAxes(axisLayer, axes)" in src view = _read("B06_Section_UI_Section_View.ts") assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view - assert "return { svg, axes };" in view + assert "return { svg, axes, homeScale };" in view assert "position: relative;" in css # 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득 assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css diff --git a/resources/tester/test_173_areas_detail.py b/resources/tester/test_173_areas_detail.py index 0ac4e246e..a772fc015 100644 --- a/resources/tester/test_173_areas_detail.py +++ b/resources/tester/test_173_areas_detail.py @@ -100,7 +100,10 @@ def test_상세_칸_크기로_새로_그림_배선(): assert "cardZoomStates.delete(stationId);" in view types = _read("B06_Section_UI_Section_View_Types.ts") # 179-3 — svg + 축 옵션 - assert " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;" in types + assert ( + " ) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;" + in types + ) assert ( " detailChart: (\n stationId: string,\n widthPx: number,\n heightPx: number," in types diff --git a/resources/tester/test_266_detail_home.py b/resources/tester/test_266_detail_home.py new file mode 100644 index 000000000..31dcecc2f --- /dev/null +++ b/resources/tester/test_266_detail_home.py @@ -0,0 +1,85 @@ +"""PLAN 266-1 — B06 [상세] 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(개별 표시 반폭이면 그것). + +짝: `B06_Section/B06_Section_UI_Section_View.ts`(`detailChart` 처음 배율) · +`B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` homeScale) · `A00_Common/b_svg_zoom_pan.ts`. +실제 TS 를 `helper_266_detail_home.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +B06 = os.path.join(ROOT, "B06_Section") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(name: str) -> str: + with open(os.path.join(B06, name), encoding="utf-8") as f: + return f.read().replace(chr(13), "") + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_266_detail_home.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_가로_반폭_15(out): + # 칸 맞춤이 가로를 넓혀도 처음 배율로 되좁힘 = −15 ~ +15 + case = out["global15"] + assert case["widened"] is True and case["homeScale"] > 1 + assert case["first"] == [-15, 15] + + +def test_개별_반폭_측점(out): + assert out["station10"]["first"] == [-10, 10] + + +def test_안_넓힌_칸_그대로(out): + case = out["tall15"] + assert case["widened"] is False and case["homeScale"] == 1 + assert case["first"] == [-15, 15] + + +@pytest.mark.parametrize("name", ["global15", "station10", "tall15"]) +def test_처음으로_같은_범위_줌_이동_그대로(out, name): + case = out[name] + assert case["reset"] == case["first"] # [⤢] + assert case["dblclick"] == case["first"] # 더블클릭 + assert case["wheelScale"] == pytest.approx(1.15) # 휠 줌 + assert case["panned"] is True # 가운데 단추 끌기 + assert case["minScale"] == 1 # 줄이면 칸 맞춤 전체까지 + + +def test_배선(): + view = _read("B06_Section_UI_Section_View.ts") + assert ( + "const homeScale = base && fitted ? base.pixelsPerMeter / fitted.pixelsPerMeter : 1;" + in view + ) + assert "return { svg, axes, homeScale };" in view + # 반폭 = 개별 표시 반폭 › 좌측 「횡단 보기 설정」(카드와 같은 길) + assert ( + "effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth)" + in view + ) + src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) + assert "drawn.homeScale" in src and "homeScale, onChange" in src + assert "homeScale: drawn.homeScale" in src