Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts
T
eomsangdonandClaude Opus 5 bf7d780f9a fix(B06): 구간 물량 말풍선화, 패널 제목·캐럿 정리, 곡선 2종 축소
- 횡단 카드 측점 종류 표기에서 "일반 측점" 제거(BP/EP만 남김).
- 구간 물량을 패널 아래 칩 줄에서 유토곡선 위 둥근 사각 말풍선으로 옮긴다. 말풍선이 놓일
  가로 구간의 곡선 세로 범위를 실측해 위/아래 빈 곳 중 들어가는 쪽에 놓고, 좌우 후보를
  모두 재서 여유가 큰 조합을 고른다. HTML 오버레이 대신 SVG 안에 그려 X축 정렬을 지킨다.
- 패널 제목 문구 삭제. 헤더는 캐럿만 남긴 손잡이 행이며, 캐럿은 좌측 사이드 패널과 같은
  공용 규칙을 쓰되 방향만 상하(펼침 ▴ / 접힘 ▾)로 바꿨다.
- 측점 라벨을 종단 그래프 바닥에 붙이고 유토곡선 상하 여백을 좁혀 두 그래프가 측점 표기를
  공유하는 것처럼 보이게 한다. 좌우 여백(LONG_PAD)은 X축 정렬 때문에 그대로 둔다.
- 패널을 줄이면 그래프 폭까지 줄던 문제: SVG가 flex 기본값으로 세로로 눌리면서
  preserveAspectRatio(meet)가 가로도 같은 비율로 축소한 것. flex: none으로 막고 패널에
  최소 높이를 건다.
- 토사 단일 환산·환산 없음 곡선 삭제. 절토가 암반 경계선으로 갈려 나온 뒤로는 부피비를
  단일값이나 1로 넣은 곡선에 정보가 없다. 곡선 키 형식이 바뀌어 세션 저장 키를 v2로 올렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 13:50:38 +09:00

362 lines
14 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",
}),
);
}
}
}
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 + 8,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom + 8,
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 - 7,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
);
// 구조물(비정규) 측점: 구조물 이름을 측점선 상단에 표기해 위치를 식별할 수 있게 한다.
if (station.structure) {
marker.append(
svgText(station.structure, {
x: stationX,
y: LONG_PAD.top + 12,
"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",
}),
// X축 제목은 그래프 **상단**에 둔다(2026-08-02 사용자 지시). 아래쪽 여백은 측점 라벨 몫이며,
// B06에서는 종단면도 바로 밑에 유토곡선이 붙어 두 그래프가 X축을 공유하는 것처럼 보여야 한다.
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
x: widthPx / 2,
y: 14,
"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",
}),
);
wrapper.append(svg);
return wrapper;
}