fix(B05,B06): 유토곡선이 그래프 칸 밖으로 새던 것 차단

사용자 보고(2026-09-04) — 「0선이 갑자기 위로 넘어가고 그 영역 채움색이 눈금 상한을 넘어
표현됨」. 보이는 구간에 세로를 맞추면서 0이 창 밖으로 나가는데, 유토곡선에는 **자르는
영역(clip)이 없어** 0선과 면 채움색이 범례·토글 버튼 위까지 그대로 칠해짐. 종단 그래프는
진작 자르고 있었는데 유토곡선만 빠져 있었음.

- 그래프 칸(축 오른쪽 · 상단 여백 아래)을 자르는 겹 둘을 둠 — 측점 세로선이 면과 곡선
  **사이**에 서야 해서 앞뒤로 나눔(그리는 순서 유지).
- 면·곡선·0선·EP 표식·선택 강조·말풍선·토량 분배를 그 겹 안으로 옮김.
- 0선은 창 안에 있을 때만 그리고 눈금에도 그때만 실음 — 밖으로 나갔는데 실으면 좌측
  고정 축 맨 위에 남의 값과 겹쳐 찍힘(사용자 확정: 0선은 나가도 됨).
- 눈금 글자는 축 **왼쪽**이라 자르는 겹에 넣지 않음(넣으면 사라짐).

자체검증(공용 브라우저 임시 탭): 확대 4칸 + 휠 25칸으로 0을 창 밖으로 보낸 상태에서
① 자르는 겹 2개 확인 ② 0선·0눈금 사라짐, 눈금 5개 전부 음수(겹쳐 찍힌 0 없음)
③ 영역 캡처로 면 채움색이 그래프 칸 위 경계에서 정확히 멈추는 것 확인.
전체 시험 381 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 22:31:09 +09:00
co-authored by Claude Opus 5
parent 685d44ac39
commit 331be116a9
2 changed files with 84 additions and 37 deletions
@@ -442,7 +442,14 @@ function appendTransfer(
* 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다
* (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다.
*/
export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void {
export function appendBalanceLayer(
svg: SVGSVGElement,
plan: HaulPlan,
box: BalanceLayerBox,
/** 실제로 붙일 자리 — 그래프 칸 밖을 자르는 겹이 있으면 그쪽(2026-09-04). 재는 일은
* `svg`(뷰박스가 필요)가 그대로 맡는다. */
host: SVGSVGElement | SVGGElement = svg,
): void {
const group = svgElement("g", { class: "b06-balance" });
appendBalanceLine(group, plan, box);
@@ -482,7 +489,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
balloons.forEach((balloon, index) => bind(balloon, index));
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
svg.addEventListener("click", clearAll);
svg.append(group);
host.append(group);
}
/**
+75 -35
View File
@@ -241,7 +241,8 @@ function textWidth(value: string): number {
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
*/
function appendVolumeCallout(
svg: SVGSVGElement,
/** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */
svg: SVGSVGElement | SVGGElement,
point: MassHaulPoint,
stationInterval: number,
box: CalloutPlacement,
@@ -368,6 +369,27 @@ export function createMassHaulChart(
const span = Math.max(max - min, 1e-6);
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;
// 그래프 칸 밖으로 나간 것은 그리지 않는다(2026-09-04 사용자 보고) — 보이는 구간에
// 세로를 맞추면 0선과 그 채움색이 눈금 상한을 넘어 **범례·토글 버튼 위까지** 칠해졌다.
// 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었다.
const plotBottom = heightPx - MASS_PAD_BOTTOM;
const clipId = `b06-mass-clip-${Math.random().toString(36).slice(2, 9)}`;
const clip = svgElement("clipPath", { id: clipId });
clip.append(
svgElement("rect", {
x: axisX,
y: padTop,
width: Math.max(0, widthPx - axis.padRight - axisX),
height: Math.max(0, plotHeight),
}),
);
const defs = svgElement("defs", {});
defs.append(clip);
// 두 겹으로 나누는 이유: 측점 세로선이 면과 곡선 **사이**에 서야 한다(그리는 순서 유지).
const plotBack = svgElement("g", { "clip-path": `url(#${clipId})` });
const plotFront = svgElement("g", { "clip-path": `url(#${clipId})` });
svg.append(defs);
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
const axisTicks: Array<{ y: number; label: string }> = [];
@@ -395,8 +417,10 @@ export function createMassHaulChart(
}
const zeroY = y(0);
// 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다.
axisTicks.push({ y: zeroY, label: "0㎥" });
// 0선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에
// 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고).
const zeroInside = zeroY >= padTop && zeroY <= plotBottom;
if (zeroInside) axisTicks.push({ y: zeroY, label: "0㎥" });
onAxis?.({ padLeft: axisX, ticks: axisTicks });
const visible = series.filter((entry) => visibleKeys.has(entry.key));
@@ -420,7 +444,7 @@ export function createMassHaulChart(
const banded = visible[0];
if (banded) {
const last = banded.result.points[banded.result.points.length - 1];
svg.append(
plotBack.append(
svgElement("path", {
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
d:
@@ -431,6 +455,8 @@ export function createMassHaulChart(
);
}
svg.append(plotBack);
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
for (const station of longitudinal.stations) {
@@ -465,27 +491,33 @@ export function createMassHaulChart(
svg.append(marker);
}
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다.
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
svg.append(
svgElement("line", {
x1: axisX,
y1: zeroY,
x2: widthPx - axis.padRight,
y2: zeroY,
class: "b06-masshaul__zero",
}),
svgText("0", {
x: axisX - 9,
y: zeroY + 4,
"text-anchor": "end",
class: "b06-chart__tick b06-masshaul__zero-tick",
}),
);
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그다.
// 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정:
// 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면
// 사라지므로 따로 붙인다.
if (zeroInside) {
plotFront.append(
svgElement("line", {
x1: axisX,
y1: zeroY,
x2: widthPx - axis.padRight,
y2: zeroY,
class: "b06-masshaul__zero",
}),
);
svg.append(
svgText("0", {
x: axisX - 9,
y: zeroY + 4,
"text-anchor": "end",
class: "b06-chart__tick b06-masshaul__zero-tick",
}),
);
}
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
for (const entry of [...visible].reverse()) {
svg.append(
plotFront.append(
svgElement("path", {
d: curvePath(entry.result.points, x, y),
class: seriesClass(entry, "b06-masshaul__curve"),
@@ -504,7 +536,7 @@ export function createMassHaulChart(
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4);
svg.append(
plotFront.append(
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
svgElement("rect", {
x: labelX - labelWidth,
@@ -527,23 +559,29 @@ export function createMassHaulChart(
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
if (haulPlan) {
appendBalanceLayer(svg, haulPlan, {
x,
y,
left: axis.padLeft,
right: widthPx - axis.padRight,
top: padTop,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
stationInterval,
});
appendBalanceLayer(
svg,
haulPlan,
{
x,
y,
left: axis.padLeft,
right: widthPx - axis.padRight,
top: padTop,
bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
stationInterval,
},
// 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다.
plotFront,
);
}
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
if (focus) {
const focusX = x(focus.chainage_m);
svg.append(
plotFront.append(
svgElement("circle", {
cx: focusX,
cy: y(focus.cumulative_volume_m3),
@@ -551,7 +589,7 @@ export function createMassHaulChart(
class: "b06-masshaul__focus",
}),
);
appendVolumeCallout(svg, focus, stationInterval, {
appendVolumeCallout(plotFront, focus, stationInterval, {
anchorX: focusX,
anchorY: y(focus.cumulative_volume_m3),
left: axis.padLeft,
@@ -562,6 +600,8 @@ export function createMassHaulChart(
});
}
svg.append(plotFront);
svg.append(
svgElement("line", {
x1: axisX,