Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts
T
eomsangdonandClaude Opus 5 bc877b832a feat(B06): 유토곡선 운반 블록을 장비 경계현으로 띠 분할 + 도면 정합 5건
참고 도면의 토량배분도는 블록마다 수평선이 여러 개(A-A' 기선 / B-B' / C-C')이고
그 사이 띠마다 번호가 붙어 종단면 해칭 구간과 1:1로 대응한다. 하단 브래킷의 폭이
각 수평선의 길이와 같다. 즉 수평선 높이는 그 현의 길이가 장비 경계거리와 같아지는
높이이고, 세로축이 곧 토량이므로 두 수평선 사이 띠 두께가 그 장비의 운반토량이다.

블록 하나에 평균운반거리 하나 / 장비 하나이던 모델을 띠(HaulBand) 여럿으로 바꿨다.

경계 탐색이 단조가 아니라는 것을 무작위 검증이 잡았다. 가지치기로 블록을 하나로
묶어도 원래 폴리라인에 잔요철이 남아 높이를 올리는 도중 현이 잠깐 다시 길어진다.
순수 이분법은 엉뚱한 근으로 수렴했다(종무대 경계현 45m). 훑기 64칸 + 이분법 24회
2단계로 바꿔, 현이 상한을 넘는 가장 높은 자리 위를 경계로 잡는다.

- 장비 경계 dozer 50 -> 70m (사용자 확정, 정의처는 config 한 곳 유지)
- balloon 도형 = 운반수단: 종무대 육각 / 도쟈 원 / 덤프 사각, 사토는 언더바만
- 띠 면 클릭 시 balloon 강조, 다른 띠·빈 곳 클릭 시 해제
- 선타입: 기선 실선 / 경계현 파선 / 평균운반거리 점선 + 방향 화살촉
- 유토곡선 0선을 파선에서 실선으로 (파선은 경계현의 몫)
- 암 경계선 오프셋 상한 0 (양수면 경계선이 지표면 위로 떠 토사층이 음수가 된다)
- 종단도 구조물 라벨을 플롯 하단으로 옮기고 맨 마지막에 그려 가리지 않게
- 암 경계선 제어를 그래프 하단 행 가운데로

검증: 수동 4종 + 실측 26측점 + 무작위 100건에서 띠 합=Q, 띠 연속성, 경계현<=상한,
안분합, 보존 성립. 렌더 스모크 4개 크기 NaN 0건 + 도형·클릭 강조 확인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 16:42:10 +09:00

369 lines
15 KiB
TypeScript

/* =============================================================================
* B06_wf3_ProfileCross_UI_Longitudinal.ts
* 종단면도(지반선·계획선·절성토 음영·측점 마커) SVG 렌더러.
*
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 종단 렌더링만 분리했다. 공통 상수·유틸은
* `_UI_Section_Common`을 참조한다. 종단은 종/횡 공통 Y스케일(`YScaleOptions`)을 그대로 쓴다.
* ========================================================================== */
import type { DesignProfile, LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
import {
emptyView,
inferStationInterval,
L,
LONG_HEIGHT,
LONG_PAD,
LONG_WIDTH,
longitudinalMaxChainage,
stationLabel,
svgElement,
svgText,
validElevation,
type YScaleOptions,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */
function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string {
const base = stationLabel(chainageM, interval);
if (!stationOffset) return base;
const [number, remainder] = base.split("+");
return `${Number(number) + stationOffset}+${remainder}`;
}
export function longitudinalMinimumWidth(
data: LongitudinalSection,
configuredStationInterval?: number,
): number {
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
const longestLabelLength = Math.max(
1,
...data.stations.map((station) => stationLabel(station.chainage_m, stationInterval).length),
);
const labelWidth = Math.max(48, longestLabelLength * 6 + 16);
return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth;
}
/**
* 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다.
* 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다.
*/
function appendCutFillBands(
svg: SVGSVGElement,
profile: DesignProfile,
x: (chainage: number) => number,
planY: (index: number) => number,
groundY: (index: number) => number,
): void {
const samples = profile.samples;
let start = 0;
const flush = (end: number): void => {
if (end - start < 1) return;
const sign = samples[start].difference_m;
if (Math.abs(sign) < 1e-9) return;
const top: string[] = [];
const bottom: string[] = [];
for (let index = start; index <= end; index += 1) {
top.push(`${x(samples[index].chainage_m)},${planY(index)}`);
bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`);
}
svg.append(
svgElement("polygon", {
points: [...top, ...bottom].join(" "),
class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`,
}),
);
};
for (let index = 1; index < samples.length; index += 1) {
const previous = samples[index - 1].difference_m;
const current = samples[index].difference_m;
if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) {
flush(index);
start = index;
}
}
flush(samples.length - 1);
}
/** 화면 x(px)를 누가거리로 되돌린다. `x()`와 같은 선형 매핑의 역함수다. */
function inverseOf(x: (chainageM: number) => number, maxChainageM: number): (px: number) => number {
const origin = x(0);
const span = x(maxChainageM) - origin;
return (px: number) => (span > 0 ? ((px - origin) / span) * maxChainageM : 0);
}
/** 구조물 측점선 하나에 끌기 동작을 붙인다. 끌지 않고 뗐으면 아무 일도 하지 않는다(선택은 click). */
function attachStationDrag(
marker: SVGElement,
svg: SVGElement,
station: { station_id: string; chainage_m: number },
x: (chainageM: number) => number,
xInverse: (px: number) => number,
onDragStation: (stationId: string, toChainageM: number) => void,
): void {
marker.classList.add("b06-chart__station--draggable");
let startX: number | null = null;
let moved = false;
marker.addEventListener("pointerdown", (event) => {
// 이동은 좌클릭 전용 — 우클릭은 메뉴다.
if (event.button !== 0) return;
event.preventDefault();
startX = event.clientX;
moved = false;
marker.setPointerCapture(event.pointerId);
});
marker.addEventListener("pointermove", (event) => {
if (startX === null) return;
if (Math.abs(event.clientX - startX) > STATION_DRAG_SLOP_PX) moved = true;
if (!moved) return;
const local = event.clientX - svg.getBoundingClientRect().left;
marker.setAttribute("transform", `translate(${local - x(station.chainage_m)}, 0)`);
});
marker.addEventListener("pointerup", (event) => {
if (startX === null) return;
startX = null;
marker.removeAttribute("transform");
if (!moved) return;
const local = event.clientX - svg.getBoundingClientRect().left;
onDragStation(station.station_id, Number(xInverse(local).toFixed(2)));
});
}
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
const STATION_DRAG_SLOP_PX = 3;
export function createLongitudinalProfile(
data: LongitudinalSection,
selectedStationId: string | null,
verticalExaggeration: number,
yScaleOptions: YScaleOptions | undefined,
onSelectStation: (stationId: string) => void,
configuredStationInterval?: number,
widthPx = LONG_WIDTH,
heightPx = LONG_HEIGHT,
minimumWidthPx = widthPx,
designProfiles: DesignProfile[] = [],
/**
* 데이터(측점선·프로파일)를 그래프 축 프레임 안쪽으로 더 들여쓰는 좌우 여백(px).
* B05 도면 테이블과 정렬할 때 0측점을 이름표 열 바깥으로 밀어내는 데 쓴다(기본 0).
*/
originOffsetPx = 0,
/**
* Y축(표고 눈금) 정보를 넘겨받는 콜백(선택). B05가 가로 스크롤에도 고정되는 sticky Y축
* 오버레이를 그릴 때 SVG와 **같은 Y-스케일**을 공유하려고 쓴다. B06은 넘기지 않는다.
*/
onYAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void,
/** 이어 공사 시작 측점 오프셋. 측점 라벨의 측점번호에 이만큼 더한다(B05 표시용, 기본 0). */
stationNumberOffset = 0,
/**
* 구조물(비정규) 측점선을 끌어 옮겼을 때(선택). 넘기면 그 측점선만 잡아 끌 수 있게 된다.
* B05가 구조물·배관 위치를 그래프에서 바로 조정하는 데 쓴다(2026-08-01 사용자 지시).
*/
onDragStation?: (stationId: string, toChainageM: number) => void,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
const wrapper = document.createElement("div");
wrapper.className = "b06-section__chart-wrap";
const svg = svgElement("svg", {
class: "b06-section__chart",
width: widthPx,
height: heightPx,
viewBox: `0 0 ${widthPx} ${heightPx}`,
role: "img",
"aria-label": L("B06_Profile_View_Longitudinal"),
});
svg.style.width = "100%";
svg.style.minWidth = `${minimumWidthPx}px`;
svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" }));
// 유토곡선과 X축을 맞추려면 최댓값 계산이 한 곳이어야 한다(_UI_Section_Common).
const maxChainage = longitudinalMaxChainage(data);
// 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다.
const elevations = samples
.map((sample) => sample.elevation_m)
.concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m)));
const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations);
const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations);
const elevationMid = (rawMin + rawMax) / 2;
const exaggeration = Math.max(verticalExaggeration, 0.1);
// 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아,
// 0측점·종점이 좌우 끝에 딱 붙지 않고 반 칸씩 안으로 들어오게 한다.
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right - 2 * originOffsetPx;
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
const elevationSpan = yScaleOptions
? plotHeight / yScaleOptions.pixelsPerMeter
: Math.max(rawMax - rawMin, 1);
const x = (chainage: number) =>
LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth;
const xInverse = inverseOf(x, maxChainage);
const y = (elevation: number) =>
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
const yAxisTicks: Array<{ y: number; label: string }> = [];
for (const ratio of [0, 0.25, 0.5, 0.75, 1]) {
const gridY = LONG_PAD.top + ratio * plotHeight;
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
yAxisTicks.push({ y: gridY, label: `${rawValue.toFixed(1)}m` });
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: gridY,
x2: widthPx - LONG_PAD.right,
y2: gridY,
class: "b06-chart__grid",
}),
svgText(`${rawValue.toFixed(1)}m`, {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
}),
);
}
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration);
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
appendCutFillBands(
svg,
profile,
x,
(index) => toY(profile.samples[index].elevation_m),
(index) => toY(profile.samples[index].ground_elevation_m),
);
if (profile.balance_segments.length > 1) {
for (const segment of profile.balance_segments.slice(1)) {
svg.append(
svgElement("line", {
x1: x(segment.start_chainage_m),
y1: LONG_PAD.top,
x2: x(segment.start_chainage_m),
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__balance-boundary",
}),
);
}
}
}
// 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선·
// 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위).
const structureLabels: SVGTextElement[] = [];
for (const station of data.stations) {
const stationX = x(station.chainage_m);
const selected = station.station_id === selectedStationId;
const marker = svgElement("g", {
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
tabindex: "0",
role: "button",
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`,
});
marker.addEventListener("click", () => onSelectStation(station.station_id));
marker.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
});
// 구조물 측점선만 끌 수 있다 — 규칙 측점은 격자라 옮길 대상이 아니다.
if (onDragStation && station.kind === "irregular") {
attachStationDrag(marker, svg, station, x, xInverse, onDragStation);
}
marker.append(
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
x: stationX,
y: heightPx - 5,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
);
// 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다.
// 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가
// 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시).
if (station.structure) {
structureLabels.push(
svgText(station.structure, {
x: stationX,
y: heightPx - LONG_PAD.bottom - 5,
"text-anchor": "middle",
class: "b06-chart__structure-label",
}),
);
}
svg.append(marker);
}
const points = samples
.map((sample) => {
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
})
.join(" ");
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
svg.append(
svgElement("polyline", {
points: profile.samples
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
.join(" "),
class: "b06-chart__design-profile",
}),
);
}
svg.append(
svgElement("polyline", { points, class: "b06-chart__profile" }),
svgElement("line", {
x1: LONG_PAD.left,
y1: heightPx - LONG_PAD.bottom,
x2: widthPx - LONG_PAD.right,
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_PAD.top,
x2: LONG_PAD.left,
y2: heightPx - LONG_PAD.bottom,
class: "b06-chart__axis",
}),
// 그래프 이름표는 플롯 **안쪽 상단에 겹쳐** 놓는다(2026-08-02 사용자 지시) — 위쪽 여백을
// 이름표 전용으로 비워 두지 않고 그래프 몫으로 돌리기 위해서다.
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
x: widthPx / 2,
y: LONG_PAD.top + 12,
"text-anchor": "middle",
class: "b06-chart__axis-label",
}),
svgText(L("B06_Profile_View_ElevationAxis"), {
x: 15,
y: heightPx / 2,
"text-anchor": "middle",
transform: `rotate(-90 15 ${heightPx / 2})`,
class: "b06-chart__axis-label",
}),
);
// 구조물 이름표는 지반선·계획선·축까지 다 그린 **뒤에** 얹는다 — 무엇에도 가리지 않는다.
if (structureLabels.length) svg.append(...structureLabels);
wrapper.append(svg);
return wrapper;
}