Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts
T
eomsangdonandClaude Opus 5 abe956affc feat(b05): 계획노선 편집 손질 여섯 — 교각점 고정·횡단 벡터화·라벨 맨 위
- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`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
2026-09-12 20:37:13 +09:00

217 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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),
},
};
}