From 4eccca40d01357c19e730447433a15a25abc3cfc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 22:01:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20[=EC=83=81=EC=84=B8]=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B2=AB=20=EB=B3=B4=EA=B8=B0=20=3D=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=EC=A0=90=EC=9D=B4=20=EC=B9=B8=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=C2=B7=20=EA=B7=B8=EB=A6=BC=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=EA=B0=80=20=EB=93=A4=EC=96=B4=EA=B0=80=EA=B2=8C=20=EC=B6=95?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=ED=81=B0=20=EC=AA=BD=20=EB=B0=98=ED=8F=AD?= =?UTF-8?q?=20=C2=B7=20[=E2=A4=A2]=20=C2=B7=20=EB=8D=94=EB=B8=94=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=9B=90=EB=B3=B5=20=EA=B0=99=EC=9D=80=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20(295-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- A00_Common/b_svg_zoom_pan.ts | 8 ++- B06_Section/B06_Section_UI_Cross_Detail.ts | 14 +++++- .../B06_Section_UI_Cross_Detail_Home.ts | 49 +++++++++++++++++++ resources/tester/helper_164_detail_zoom.cjs | 6 ++- resources/tester/test_164_detail_zoom.py | 4 +- resources/tester/test_266_detail_home.py | 2 +- 6 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Detail_Home.ts diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 5dc6d9c80..72301e3d8 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -63,6 +63,8 @@ export interface SvgZoomPanOptions { initial?: ZoomPanState; /** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */ homeScale?: number; + /** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */ + home?: ZoomPanState; /** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */ onChange?: (state: ZoomPanState) => void; /** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */ @@ -88,11 +90,13 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { }; const maxScale = options.maxScale ?? 8; const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale); - const home: ZoomPanState = { + const home: ZoomPanState = options.home ?? { scale: homeScale, tx: (plot.x + plot.width / 2) * (1 - homeScale), ty: (plot.y + plot.height / 2) * (1 - homeScale), }; + /** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */ + const minScale = Math.min(1, home.scale); let scale = initial?.scale ?? home.scale; let tx = initial?.tx ?? home.tx; let ty = initial?.ty ?? home.ty; @@ -132,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { /** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */ const zoomAt = (factor: number, cx: number, cy: number): void => { - const next = Math.min(maxScale, Math.max(1, scale * factor)); + const next = Math.min(maxScale, Math.max(minScale, scale * factor)); tx = cx - ((cx - tx) / scale) * next; ty = cy - ((cy - ty) / scale) * next; scale = next; diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts index ad6f93454..1cd162b62 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -37,6 +37,7 @@ import { type ZoomPanState, } from "../A00_Common/b_svg_zoom_pan"; import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom"; +import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home"; import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table"; import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { @@ -194,7 +195,7 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT /** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 · * 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) · - * 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */ + * 처음 · 원복 = 중심점이 칸 가운데 · 그림 전체(가로 = 카드 반폭 266-1 · 세로 = 그 안 그림)(295-2). */ export function attachDetailZoom( clone: SVGSVGElement, axes: CrossAxesOptions | null, @@ -220,6 +221,16 @@ export function attachDetailZoom( ); }; follow(initial ?? IDENTITY_VIEW); + const plot = { + x: CROSS_PAD.left, + y: CROSS_PAD.top, + width: width - CROSS_PAD.left - CROSS_PAD.right, + height: height - CROSS_PAD.top - CROSS_PAD.bottom, + }; + const home = + markerLine && Number.isFinite(cx) && Number.isFinite(cy) + ? detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16) + : undefined; return attachSvgZoomPan({ svg: clone, layer, @@ -232,6 +243,7 @@ export function attachDetailZoom( wheelZoom: true, initial, homeScale, + home, onChange: (state) => { follow(state); onChange(state); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Home.ts b/B06_Section/B06_Section_UI_Cross_Detail_Home.ts new file mode 100644 index 000000000..1f98041b0 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail_Home.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail_Home.ts + * [상세] 모달 첫 보기(295-2) — 중심점(중심 십자)이 칸 가운데 · 그림 전체가 들어가게 + * 축마다 중심에서 큰 쪽 반폭으로. [⤢] · 더블클릭 원복도 같은 보기. 카드 보기는 그대로. + * + * 가로 반폭 = 카드 반폭(266-1 `homeScale` 이 되좁히던 폭)의 큰 쪽 · 세로 반폭 = 그 가로 안에 + * 그려진 꼭짓점이 중심에서 위 · 아래로 먼 쪽. + * ========================================================================== */ + +import type { ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; + +type Pt = [number, number]; +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** 첫 보기 배율 · 이동 — 중심 `center` 를 `plot` 가운데에. `homeScale` = 1배 대비 카드 반폭 배율. */ +export function detailHomeView( + points: Pt[], + center: Pt, + plot: Rect, + homeScale: number, + maxScale: number, +): ZoomPanState { + const [cx, cy] = center; + const halfX = Math.max(cx - plot.x, plot.x + plot.width - cx) / Math.max(homeScale, 1e-6); + const ys = points.filter(([x]) => Math.abs(x - cx) <= halfX).map(([, y]) => y); + const halfY = Math.max(1, ...ys.map((y) => Math.abs(y - cy))); + const scale = Math.min(plot.width / 2 / halfX, plot.height / 2 / halfY, maxScale); + return { + scale, + tx: plot.x + plot.width / 2 - scale * cx, + ty: plot.y + plot.height / 2 - scale * cy, + }; +} + +/** 그림 층의 꺾은선 · 다각형 꼭짓점(그림 px). */ +export function layerPoints(layer: Element): Pt[] { + return [...layer.querySelectorAll("polyline, polygon")].flatMap((el) => + (el.getAttribute("points") ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p): p is Pt => p.length === 2 && p.every(Number.isFinite)), + ); +} diff --git a/resources/tester/helper_164_detail_zoom.cjs b/resources/tester/helper_164_detail_zoom.cjs index c45d6ecb1..f58025313 100644 --- a/resources/tester/helper_164_detail_zoom.cjs +++ b/resources/tester/helper_164_detail_zoom.cjs @@ -92,6 +92,8 @@ function el(tag) { }; return walk(self); }, + // 첫 보기 꼭짓점(295-2) — 이 그림엔 꺾은선 · 다각형 없음 + querySelectorAll: () => [], addEventListener: (type, fn) => (listeners[type] ??= []).push(fn), fire: (type, event) => { const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event }; @@ -109,7 +111,7 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts")); -// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150) +// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 319,142 = 칸 가운데 · 295-2 첫 보기 = 1배) const AXES = { widthPx: 600, heightPx: 300, @@ -126,7 +128,7 @@ function chart() { const clipGroup = svgElement("g", { "clip-path": "url(#c)" }); const plot = svgElement("g", { class: "b06-chart__plot-layer" }); const center = svgElement("g", { class: "b06-chart__center" }); - center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 })); + center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 })); plot.append(center); clipGroup.append(plot); svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" })); diff --git a/resources/tester/test_164_detail_zoom.py b/resources/tester/test_164_detail_zoom.py index 96e1842e4..58565d78a 100644 --- a/resources/tester/test_164_detail_zoom.py +++ b/resources/tester/test_164_detail_zoom.py @@ -54,8 +54,8 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out): assert out["axesTransform"] is None and out["clipTransform"] is None assert out["ticksChanged"] is True and out["ticksAfter"] > 0 assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로 - # 확대 — 십자는 중심(300,150)에서 배율 되돌림 - assert out["centerTransform"].startswith("translate(300 150) scale(0.869") + # 확대 — 십자는 중심(319,142 · 칸 가운데)에서 배율 되돌림 + assert out["centerTransform"].startswith("translate(319 142) scale(0.869") assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만 assert out["noLayer"] is None # 도형 층 없음 = 줌 없음 diff --git a/resources/tester/test_266_detail_home.py b/resources/tester/test_266_detail_home.py index 31dcecc2f..f3ea83ef2 100644 --- a/resources/tester/test_266_detail_home.py +++ b/resources/tester/test_266_detail_home.py @@ -81,5 +81,5 @@ def test_배선(): in view ) src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split()) - assert "drawn.homeScale" in src and "homeScale, onChange" in src + assert "drawn.homeScale" in src and "homeScale, home, onChange" in src # 295-2 첫 보기 assert "homeScale: drawn.homeScale" in src