feat(B06): 상세 창 처음 가로 범위 = 횡단 보기 반폭 (266-1)

- 칸 맞춤이 넓힌 가로를 처음 배율(homeScale)로 되좁힘 · 개별 표시 반폭 측점은 그 값
- 공용 줌 · 팬 homeScale 칸 — 처음 · [⤢] · 더블클릭 = 그 배율 · 줄이면 칸 맞춤 전체까지
- 시험 test_266 · 164 · 173 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-11 16:35:42 +09:00
co-authored by Claude Opus 5.5
parent 81950486c7
commit 3a08e4df9d
8 changed files with 237 additions and 19 deletions
+13 -7
View File
@@ -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();
};
// 더블클릭 원복.
+15 -6
View File
@@ -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<SVGGElement>("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)
}
+3 -1
View File
@@ -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();
@@ -39,12 +39,12 @@ export interface SectionViewController {
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => 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;
+110
View File
@@ -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),
}),
);
+5 -2
View File
@@ -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<SVGGElement>("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
+4 -1
View File
@@ -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
+85
View File
@@ -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