feat(b06): [상세] 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭 · [⤢] · 더블클릭 원복 같은 보기 (295-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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 # 도형 층 없음 = 줌 없음
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user