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:
@@ -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();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user