- createCrossAxes designZ 옵션 · 원지반 상자 먼저 · 계획 상자 나중(위) · 색 = 원지반선 · 설계선 - y 차 16 px 미만이면 원지반 상자만 위아래 관계 따라 비킴 · 칸 밖이면 반대 · 값 그대로 - 배선 = 카드(십자선 계획고) · 상세 모달(cardDesignElevation) · 시험 test_318 겹침 경우 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
201 lines
8.6 KiB
TypeScript
201 lines
8.6 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Axes.ts
|
|
* 횡단 카드의 X·Y 축(격자선 + 눈금값). 확대·축소·팬을 하면 **눈금이 따라 다시 그려진다**
|
|
* (2026-08-23 사용자 보고: 줌인 후 축 값과 도형이 어긋남).
|
|
*
|
|
* 도형은 `plotLayer`의 transform(scale·translate)으로 움직이고 축은 제자리에 남으므로
|
|
* (2026-08-08 규칙), 축이 맞으려면 **현재 화면에 보이는 범위**를 역산해 그 범위의 눈금을
|
|
* 다시 만들어야 한다. 눈금값은 종단면도와 같은 1·2·5 계열(`niceTickStep`)이다.
|
|
* ========================================================================== */
|
|
|
|
import { CROSS_PAD, niceTickStep, svgElement, svgText } from "./B06_Section_UI_Section_Common";
|
|
import type { ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
|
|
|
|
/** 원배율·이동 없음 — 처음 그릴 때 쓴다. */
|
|
export const IDENTITY_VIEW: ZoomPanState = { scale: 1, tx: 0, ty: 0 };
|
|
|
|
export interface CrossAxesOptions {
|
|
widthPx: number;
|
|
heightPx: number;
|
|
/** 1m당 픽셀 — X·Y 공통(횡단은 등축). */
|
|
pixelsPerMeter: number;
|
|
/** x(offset) = CROSS_PAD.left + (maxOffset - offset) * pixelsPerMeter */
|
|
maxOffset: number;
|
|
/** y(elevation) = CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter */
|
|
displayMax: number;
|
|
/** 표고 과장 기준점과 배율 — 화면 표고 → 실제 표고 환산에 쓴다. */
|
|
elevationMid: number;
|
|
exaggeration: number;
|
|
/** 중심고(실제 표고) — Y축 라벨 자리에 둥근 상자로(318-2 · 소수 첫째 자리). 없으면 안 그림. */
|
|
centerZ?: number | null;
|
|
/** 계획 중심고(십자선 계획고) — 보라 상자를 원지반 상자 뒤(위)에(379-3). 없으면 안 그림. */
|
|
designZ?: number | null;
|
|
}
|
|
|
|
/** X 눈금 라벨("-12" 등)이 겹치지 않는 최소 간격(px). */
|
|
const X_LABEL_MIN_PX = 48;
|
|
/** Y 눈금 라벨이 겹치지 않는 최소 간격(px). */
|
|
const Y_LABEL_MIN_PX = 14;
|
|
const X_TARGET_TICKS = 7;
|
|
/** 표고(Y) 눈금 최소 간격(m) — 1m 아래 단위는 표기하지 않는다. */
|
|
const MIN_ELEVATION_STEP_M = 1;
|
|
const Y_TARGET_TICKS = 10;
|
|
/** Y 눈금 글 오른끝 ↔ Y 축선(px). */
|
|
const Y_LABEL_GAP_PX = 7;
|
|
/** 중심고 상자 — 글자 좌우 여백 · 높이(px). 글자 크기는 눈금과 같음(10px). */
|
|
const CENTER_PAD_X = 3;
|
|
const CENTER_BOX_H = 14;
|
|
/** 두 상자 y 차가 이보다 작으면 원지반 상자만 비킴(px · 379-3). */
|
|
const TAG_GAP_PX = 16;
|
|
|
|
/**
|
|
* 축 레이어를 그리는 함수를 만든다. 줌·팬이 바뀔 때마다 그 상태로 다시 부르면 된다.
|
|
* 레이어 내용은 매번 통째로 갈아 끼운다(카드 하나당 눈금 20개 남짓이라 충분히 싸다).
|
|
*/
|
|
export function createCrossAxes(
|
|
layer: SVGGElement,
|
|
options: CrossAxesOptions,
|
|
): (view: ZoomPanState) => void {
|
|
const { widthPx, heightPx, pixelsPerMeter, maxOffset, displayMax } = options;
|
|
const plotLeft = CROSS_PAD.left;
|
|
const plotRight = widthPx - CROSS_PAD.right;
|
|
const plotTop = CROSS_PAD.top;
|
|
const plotBottom = heightPx - CROSS_PAD.bottom;
|
|
const x = (offset: number) => plotLeft + (maxOffset - offset) * pixelsPerMeter;
|
|
const y = (displayElevation: number) =>
|
|
plotTop + (displayMax - displayElevation) * pixelsPerMeter;
|
|
|
|
return (view) => {
|
|
const { scale, tx, ty } = view;
|
|
layer.replaceChildren();
|
|
// 화면 px → 데이터 값 역산(도형 레이어의 transform을 거꾸로 푼다).
|
|
const offsetAt = (px: number) => maxOffset - ((px - tx) / scale - plotLeft) / pixelsPerMeter;
|
|
const displayAt = (py: number) => displayMax - ((py - ty) / scale - plotTop) / pixelsPerMeter;
|
|
const toRaw = (display: number) =>
|
|
options.elevationMid + (display - options.elevationMid) / options.exaggeration;
|
|
|
|
// ── X축: 왼쪽 화면 끝이 큰 offset(좌측), 오른쪽 끝이 작은 offset(우측).
|
|
const offsetHigh = offsetAt(plotLeft);
|
|
const offsetLow = offsetAt(plotRight);
|
|
const offsetSpan = offsetHigh - offsetLow;
|
|
const xStep = niceTickStep(
|
|
offsetSpan,
|
|
X_TARGET_TICKS,
|
|
Math.floor((plotRight - plotLeft) / X_LABEL_MIN_PX),
|
|
);
|
|
const xDecimals = Math.max(0, Math.ceil(-Math.log10(xStep) - 1e-9));
|
|
for (
|
|
let offset = Math.ceil(offsetLow / xStep) * xStep;
|
|
offset <= offsetHigh + 1e-9;
|
|
offset += xStep
|
|
) {
|
|
const screenX = tx + scale * x(offset);
|
|
const label = Math.abs(offset) < 1e-9 ? "0" : offset.toFixed(xDecimals);
|
|
layer.append(
|
|
svgElement("line", {
|
|
x1: screenX,
|
|
y1: plotTop,
|
|
x2: screenX,
|
|
y2: plotBottom,
|
|
class: "b06-chart__grid",
|
|
}),
|
|
svgText(label, {
|
|
x: screenX,
|
|
y: plotBottom + 16,
|
|
"text-anchor": "middle",
|
|
class: "b06-chart__tick",
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Y축: 화면 위쪽이 높은 표고. 라벨은 과장을 되돌린 **실제 표고**다.
|
|
const rawTop = toRaw(displayAt(plotTop));
|
|
const rawBottom = toRaw(displayAt(plotBottom));
|
|
const rawSpan = rawTop - rawBottom;
|
|
// 표고 눈금은 **1m 아래로 내려가지 않는다**(2026-08-23 사용자 지시) — 0.5m 표기는
|
|
// 도면에서 읽을 일이 없다. 확대해도 1m 간격까지만 촘촘해진다.
|
|
const yStep = Math.max(
|
|
niceTickStep(rawSpan, Y_TARGET_TICKS, Math.floor((plotBottom - plotTop) / Y_LABEL_MIN_PX)),
|
|
MIN_ELEVATION_STEP_M,
|
|
);
|
|
const yDecimals = Math.max(0, Math.ceil(-Math.log10(yStep) - 1e-9));
|
|
const screenYOf = (raw: number) =>
|
|
ty + scale * y(options.elevationMid + (raw - options.elevationMid) * options.exaggeration);
|
|
// 중심고 상자 자리 — 보이는 범위 안일 때만 · 겹친 눈금 글도 그림(상자를 맨 뒤에 붙여 위에 ·
|
|
// 바탕이 안 비쳐 가림 · 319-1) · 원지반(녹) 먼저 · 계획(보라) 나중(379-3)
|
|
const { centerZ, designZ } = options;
|
|
const inView = (z: number | null | undefined): number | null =>
|
|
typeof z === "number" && z >= rawBottom && z <= rawTop ? screenYOf(z) : null;
|
|
const groundY = inView(centerZ);
|
|
const designY = inView(designZ);
|
|
for (let raw = Math.ceil(rawBottom / yStep) * yStep; raw <= rawTop + 1e-9; raw += yStep) {
|
|
const screenY = screenYOf(raw);
|
|
layer.append(
|
|
svgElement("line", {
|
|
x1: plotLeft,
|
|
y1: screenY,
|
|
x2: plotRight,
|
|
y2: screenY,
|
|
class: "b06-chart__grid",
|
|
}),
|
|
);
|
|
layer.append(
|
|
svgText(raw.toFixed(yDecimals), {
|
|
x: plotLeft - Y_LABEL_GAP_PX,
|
|
y: screenY + 3,
|
|
"text-anchor": "end",
|
|
class: "b06-chart__tick",
|
|
}),
|
|
);
|
|
}
|
|
if (groundY !== null) {
|
|
const at = designY === null ? groundY : dodgeY(groundY, designY, plotTop, plotBottom);
|
|
appendCenterTag(layer, (centerZ as number).toFixed(1), plotLeft, at, "ground");
|
|
}
|
|
if (designY !== null)
|
|
appendCenterTag(layer, (designZ as number).toFixed(1), plotLeft, designY, "design");
|
|
};
|
|
}
|
|
|
|
/** 원지반 상자 y — 계획 상자와 `TAG_GAP_PX` 안이면 아래(원지반이 낮으면) · 위로 비킴 · 칸 밖이면 반대(379-3). */
|
|
function dodgeY(groundY: number, designY: number, top: number, bottom: number): number {
|
|
if (Math.abs(groundY - designY) >= TAG_GAP_PX) return groundY;
|
|
const below = groundY >= designY; // 화면 아래 = 낮은 표고
|
|
const half = CENTER_BOX_H / 2;
|
|
const away = designY + (below ? TAG_GAP_PX : -TAG_GAP_PX);
|
|
return away - half < top || away + half > bottom
|
|
? designY + (below ? -TAG_GAP_PX : TAG_GAP_PX)
|
|
: away;
|
|
}
|
|
|
|
/** 중심고 둥근 상자 — 글 오른끝 = 다른 눈금 자리 · 상자는 Y 축선(`axisX`) 4px 앞에서 멈춤 ·
|
|
* 글 폭은 잴 수 있으면 재고(붙은 뒤) 아니면 어림. */
|
|
function appendCenterTag(
|
|
layer: SVGGElement,
|
|
text: string,
|
|
axisX: number,
|
|
centerY: number,
|
|
kind: "ground" | "design",
|
|
): void {
|
|
const tone = `b06-chart__center-tag--${kind}`; // 색 = 원지반선 · 설계선(379-3)
|
|
const textRight = axisX - Y_LABEL_GAP_PX;
|
|
const box = svgElement("rect", {
|
|
y: centerY - CENTER_BOX_H / 2,
|
|
height: CENTER_BOX_H,
|
|
rx: 3,
|
|
ry: 3,
|
|
class: `b06-chart__center-box ${tone}`,
|
|
});
|
|
const label = svgText(text, {
|
|
x: textRight,
|
|
y: centerY + 3.5,
|
|
"text-anchor": "end",
|
|
class: `b06-chart__tick b06-chart__center-text ${tone}`,
|
|
});
|
|
layer.append(box, label);
|
|
const measured = (label as SVGTextElement).getComputedTextLength?.() ?? 0;
|
|
const width = (measured > 0 ? measured : text.length * 5.8) + CENTER_PAD_X * 2;
|
|
box.setAttribute("x", String(textRight + CENTER_PAD_X - width));
|
|
box.setAttribute("width", String(width));
|
|
}
|