- 원지반 횡단선·기본 계획 횡단선·성토사면 길이 셋만 보임, 구조물은 안 그림 (계획서 0-9 ⑧) - 서버 `POST /route/cross-preview` 가 `generate_sections`+`compute_cross_design` 정본을 재사용 - 계획고는 편집 중에 없어 그 측점 지반고를 그대로 놓음(지반 추종) — 창에 그렇게 적음 - 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths` 를 그대로 부름 - 700줄 규정에 맞춰 곡선 패널 배선을 `_CurveBar.ts` 로 분리 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Cross.ts
|
|
* 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧).
|
|
*
|
|
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
|
|
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
|
*
|
|
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
|
|
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의
|
|
* 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다.
|
|
*
|
|
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
|
|
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기).
|
|
* ========================================================================== */
|
|
|
|
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
|
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
|
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
|
|
|
/** 그림 가장자리 여백(px). */
|
|
const PAD = 28;
|
|
|
|
export interface CrossPreviewParams {
|
|
projectId: string;
|
|
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
|
|
bounds: () => DOMRect;
|
|
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
|
|
request: () => {
|
|
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
|
min_radius_m: number;
|
|
station_interval_m: number;
|
|
};
|
|
}
|
|
|
|
export interface CrossPreviewWindow {
|
|
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
|
|
open: (chainageM: number) => Promise<void>;
|
|
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
|
|
destroy: () => void;
|
|
}
|
|
|
|
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
|
const root = document.createElement("div");
|
|
root.className = "b05-routeedit__cross";
|
|
root.hidden = true;
|
|
root.innerHTML = `
|
|
<div class="b05-routeedit__cross-head">
|
|
<strong class="b05-routeedit__cross-title">횡단 미리보기</strong>
|
|
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기">✕</button>
|
|
</div>
|
|
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
|
|
<div class="b05-routeedit__cross-foot"></div>`;
|
|
document.body.append(root);
|
|
|
|
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
|
|
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
|
|
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
|
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
|
|
const context = canvas.getContext("2d")!;
|
|
|
|
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
|
|
root.hidden = true;
|
|
});
|
|
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
|
|
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
|
root.addEventListener(type, (event) => event.stopPropagation());
|
|
}
|
|
|
|
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
|
|
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
|
head.addEventListener("pointerdown", (event) => {
|
|
if ((event.target as HTMLElement).closest("button")) return;
|
|
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
|
|
head.setPointerCapture(event.pointerId);
|
|
event.preventDefault();
|
|
});
|
|
head.addEventListener("pointermove", (event) => {
|
|
if (!dragFrom) return;
|
|
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
|
|
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
|
|
});
|
|
const stopDrag = (event: PointerEvent): void => {
|
|
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
|
|
dragFrom = null;
|
|
};
|
|
head.addEventListener("pointerup", stopDrag);
|
|
head.addEventListener("pointercancel", stopDrag);
|
|
|
|
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
|
|
let asked = -1;
|
|
|
|
return {
|
|
async open(chainageM) {
|
|
asked = chainageM;
|
|
root.hidden = false;
|
|
if (!root.style.left) {
|
|
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
|
|
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
|
|
const box = params.bounds();
|
|
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
|
|
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
|
|
}
|
|
title.textContent = "횡단 미리보기 — 읽는 중…";
|
|
foot.textContent = "";
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
let preview: CrossPreviewResponse;
|
|
try {
|
|
preview = await fetchCrossPreview(params.projectId, {
|
|
...params.request(),
|
|
chainage_m: chainageM,
|
|
});
|
|
} catch (error) {
|
|
if (asked !== chainageM) return;
|
|
title.textContent = "횡단 미리보기";
|
|
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
|
|
return;
|
|
}
|
|
if (asked !== chainageM || root.hidden) return;
|
|
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
|
|
drawCross(context, canvas, preview);
|
|
foot.textContent = summarize(preview);
|
|
},
|
|
destroy() {
|
|
root.remove();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
|
|
function summarize(preview: CrossPreviewResponse): string {
|
|
const design = preview.design;
|
|
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
|
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
|
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
|
const lengths = fillSlopeLengths({
|
|
samples: preview.samples,
|
|
design,
|
|
} as unknown as CrossSection);
|
|
const sides = (["left", "right"] as const)
|
|
.filter((side) => lengths[side] !== null)
|
|
.map((side) => {
|
|
const value = lengths[side]!;
|
|
const label = side === "left" ? "좌" : "우";
|
|
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
|
|
return `${label} ${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)}㎡` +
|
|
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
|
|
);
|
|
}
|
|
|
|
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
|
|
function drawCross(
|
|
context: CanvasRenderingContext2D,
|
|
canvas: HTMLCanvasElement,
|
|
preview: CrossPreviewResponse,
|
|
): void {
|
|
const ground = preview.samples
|
|
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
|
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
|
|
const design = (preview.design?.design_line ?? []).map(
|
|
(point) => [point.offset_m, point.elevation_m] as [number, number],
|
|
);
|
|
const all = [...ground, ...design];
|
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
if (all.length < 2) return;
|
|
|
|
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;
|
|
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
|
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
|
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
|
|
const centerOffset = (minOffset + maxOffset) / 2;
|
|
const centerZ = (minZ + maxZ) / 2;
|
|
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
|
|
const toScreen = (point: [number, number]): [number, number] => [
|
|
canvas.width / 2 + (centerOffset - point[0]) * scale,
|
|
canvas.height / 2 + (centerZ - point[1]) * scale,
|
|
];
|
|
|
|
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
|
|
if (points.length < 2) return;
|
|
context.beginPath();
|
|
points.forEach((point, index) => {
|
|
const [x, y] = toScreen(point);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.strokeStyle = color;
|
|
context.lineWidth = width;
|
|
context.stroke();
|
|
};
|
|
|
|
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
|
const [centerX] = toScreen([0, centerZ]);
|
|
context.save();
|
|
context.setLineDash([4, 4]);
|
|
context.strokeStyle = "rgba(148,163,184,0.7)";
|
|
context.lineWidth = 1;
|
|
context.beginPath();
|
|
context.moveTo(centerX, PAD / 2);
|
|
context.lineTo(centerX, canvas.height - PAD / 2);
|
|
context.stroke();
|
|
context.restore();
|
|
|
|
stroke(ground, "#94a3b8", 1.6); // 원지반
|
|
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
|
|
|
|
context.font = "11px system-ui, sans-serif";
|
|
context.textBaseline = "top";
|
|
context.fillStyle = "#94a3b8";
|
|
context.textAlign = "left";
|
|
context.fillText("원지반", PAD, 6);
|
|
context.fillStyle = "#f97316";
|
|
context.textAlign = "right";
|
|
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
|
|
context.fillStyle = "#94a3b8";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "bottom";
|
|
context.fillText(
|
|
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
|
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
|
canvas.width / 2,
|
|
canvas.height - 4,
|
|
);
|
|
}
|