- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`) - 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것. 교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀 - 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임 - 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김 - 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움) - 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
217 lines
9.2 KiB
TypeScript
217 lines
9.2 KiB
TypeScript
/* =============================================================================
|
||
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
|
||
* 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약.
|
||
*
|
||
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
|
||
* 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다.
|
||
*
|
||
* **왜 캔버스가 아니라 SVG 인가**(2026-09-12 사용자 지시 ③) — 캔버스는 한 번 찍으면 그림이라
|
||
* 확대하면 뭉개진다. SVG 는 선이 그대로 남아 휠로 키워도 또렷하다. 확대·이동은 **공용
|
||
* 조각**(`A00_Common/b_svg_zoom_pan.ts`)이 맡는다 — B06 횡단 카드와 한 코드다.
|
||
*
|
||
* **계획 횡단선은 절·성토 교차점까지만 그린다**(2026-09-12 사용자 지시 ②). 서버가 주는
|
||
* `design_line` 은 계산 반폭 끝까지 이어지는데 **사면 끝 바깥은 손대지 않는 원지반**이라,
|
||
* 그대로 그으면 지반선과 겹쳐 같은 지형이 두 번 그려진다. B06 횡단 카드는 2026-08-20 에
|
||
* 같은 지적을 받아 이미 이렇게 끊고 있다(`appendCrossDesignOverlay`) — 교차점 찾기도
|
||
* 그쪽이 쓰는 `toeOffsets` 를 그대로 부른다.
|
||
* ========================================================================== */
|
||
|
||
import { svgElement, svgText } from "@util/common_util_svg";
|
||
import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
|
||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||
import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
||
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||
|
||
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
|
||
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
|
||
|
||
/** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */
|
||
export interface CrossPlot {
|
||
svg: SVGSVGElement;
|
||
layer: SVGGElement;
|
||
width: number;
|
||
height: number;
|
||
/** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */
|
||
content: ContentBounds;
|
||
}
|
||
|
||
/** 성토사면 길이·절성토 면적 한 줄. */
|
||
export function summarizeCross(preview: CrossPreviewResponse): string {
|
||
const design = preview.design;
|
||
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
||
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
||
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
||
const lengths = fillSlopeLengths(asSection(preview));
|
||
const sides = (["left", "right"] as const)
|
||
.filter((side) => lengths[side] !== null)
|
||
.map((side) => {
|
||
const value = lengths[side]!;
|
||
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
|
||
return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
|
||
});
|
||
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
|
||
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
|
||
}
|
||
|
||
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
|
||
function asSection(preview: CrossPreviewResponse): CrossSection {
|
||
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
|
||
}
|
||
|
||
type Point = [number, number];
|
||
|
||
/**
|
||
* 선을 **절·성토 교차점 사이만** 남긴다(2026-09-12 사용자 지시 ②).
|
||
*
|
||
* 교차점이 꼭짓점 사이에 떨어지면 그 자리에 점을 하나 만들어 **딱 거기서 끊는다** — 가까운
|
||
* 꼭짓점에서 끊으면 사면 끝이 지반에 닿기 전에 멈춘 것처럼 보인다.
|
||
* 그 측에서 교차점을 못 찾으면(사면이 지반을 못 만난 측점) 그쪽은 안 자른다.
|
||
*/
|
||
function clipToToes(line: Point[], low: number, high: number): Point[] {
|
||
if (line.length < 2) return line;
|
||
const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9;
|
||
const cutAt = (from: Point, to: Point, edge: number): Point => {
|
||
const span = to[0] - from[0];
|
||
const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span;
|
||
return [edge, from[1] + (to[1] - from[1]) * ratio];
|
||
};
|
||
const out: Point[] = [];
|
||
line.forEach((point, index) => {
|
||
if (inside(point)) out.push(point);
|
||
const next = line[index + 1];
|
||
if (!next) return;
|
||
// 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다.
|
||
[low, high]
|
||
.filter((edge) => (point[0] - edge) * (next[0] - edge) < 0)
|
||
.sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0]))
|
||
.forEach((edge) => out.push(cutAt(point, next, edge)));
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* 원지반선과 기본 계획 횡단선을 한 판에 짓는다. 좌(+offset)가 화면 왼쪽이다
|
||
* (`generate_sections` cad_exchange 규약과 같은 방향).
|
||
*
|
||
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
||
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
||
*/
|
||
export function buildCrossSvg(
|
||
preview: CrossPreviewResponse,
|
||
width: number,
|
||
height: number,
|
||
): CrossPlot {
|
||
const svg = svgElement("svg", {
|
||
viewBox: `0 0 ${width} ${height}`,
|
||
width: "100%",
|
||
height: "100%",
|
||
preserveAspectRatio: "none",
|
||
class: "b05-routeedit__cross-svg",
|
||
});
|
||
const layer = svgElement("g", { class: "b05-routeedit__cross-plot" });
|
||
|
||
const ground = preview.samples
|
||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
|
||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
|
||
const design = clipToToes(
|
||
(preview.design?.design_line ?? []).map(
|
||
(point) => [point.offset_m, point.elevation_m] as Point,
|
||
),
|
||
toes.right ?? -Infinity, // −offset = 우
|
||
toes.left ?? Infinity, // +offset = 좌
|
||
);
|
||
|
||
const all = [...ground, ...design];
|
||
if (all.length < 2) {
|
||
svg.append(layer);
|
||
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
|
||
}
|
||
|
||
const offsets = all.map((point) => point[0]);
|
||
const heights = all.map((point) => point[1]);
|
||
const minOffset = Math.min(...offsets);
|
||
const maxOffset = Math.max(...offsets);
|
||
const minZ = Math.min(...heights);
|
||
const maxZ = Math.max(...heights);
|
||
const spanX = maxOffset - minOffset || 1;
|
||
const spanZ = maxZ - minZ || 1;
|
||
const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1);
|
||
const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1);
|
||
const scale = Math.min(plotWidth / spanX, plotHeight / spanZ);
|
||
const centerOffset = (minOffset + maxOffset) / 2;
|
||
const centerZ = (minZ + maxZ) / 2;
|
||
const toScreen = (point: Point): Point => [
|
||
width / 2 + (centerOffset - point[0]) * scale,
|
||
height / 2 + (centerZ - point[1]) * scale,
|
||
];
|
||
|
||
const path = (points: Point[], className: string): void => {
|
||
if (points.length < 2) return;
|
||
layer.append(
|
||
svgElement("polyline", {
|
||
class: className,
|
||
points: points.map((point) => toScreen(point).join(",")).join(" "),
|
||
}),
|
||
);
|
||
};
|
||
|
||
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
||
const [centerX] = toScreen([0, centerZ]);
|
||
layer.append(
|
||
svgElement("line", {
|
||
class: "b05-routeedit__cross-axis",
|
||
x1: centerX,
|
||
y1: CROSS_PLOT_PAD.top / 2,
|
||
x2: centerX,
|
||
y2: height - CROSS_PLOT_PAD.bottom / 2,
|
||
}),
|
||
);
|
||
path(ground, "b05-routeedit__cross-ground");
|
||
path(design, "b05-routeedit__cross-design");
|
||
|
||
// 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다.
|
||
svg.append(layer);
|
||
svg.append(
|
||
svgText("원지반", {
|
||
class: "b05-routeedit__cross-key is-ground",
|
||
x: CROSS_PLOT_PAD.left,
|
||
y: 13,
|
||
"text-anchor": "start",
|
||
}),
|
||
svgText("기본 계획 횡단", {
|
||
class: "b05-routeedit__cross-key is-design",
|
||
x: width - CROSS_PLOT_PAD.right,
|
||
y: 13,
|
||
"text-anchor": "end",
|
||
}),
|
||
svgText(
|
||
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
||
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
||
{
|
||
class: "b05-routeedit__cross-key",
|
||
x: width / 2,
|
||
y: height - 6,
|
||
"text-anchor": "middle",
|
||
},
|
||
),
|
||
);
|
||
|
||
const screens = all.map(toScreen);
|
||
const xs = screens.map((point) => point[0]);
|
||
const ys = screens.map((point) => point[1]);
|
||
return {
|
||
svg,
|
||
layer,
|
||
width,
|
||
height,
|
||
content: {
|
||
x: Math.min(...xs),
|
||
y: Math.min(...ys),
|
||
width: Math.max(...xs) - Math.min(...xs),
|
||
height: Math.max(...ys) - Math.min(...ys),
|
||
},
|
||
};
|
||
}
|