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이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다 * 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다
* (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다. * (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" }); const group = svgElement("g", { class: "b06-balance" });
appendBalanceLine(group, plan, box); appendBalanceLine(group, plan, box);
@@ -482,7 +489,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
balloons.forEach((balloon, index) => bind(balloon, index)); balloons.forEach((balloon, index) => bind(balloon, index));
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다. // 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
svg.addEventListener("click", clearAll); 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( function appendVolumeCallout(
svg: SVGSVGElement, /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */
svg: SVGSVGElement | SVGGElement,
point: MassHaulPoint, point: MassHaulPoint,
stationInterval: number, stationInterval: number,
box: CalloutPlacement, box: CalloutPlacement,
@@ -368,6 +369,27 @@ export function createMassHaulChart(
const span = Math.max(max - min, 1e-6); const span = Math.max(max - min, 1e-6);
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight; 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개를 그대로 두면 라벨이 서로 겹친다. // 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1]; const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
const axisTicks: Array<{ y: number; label: string }> = []; const axisTicks: Array<{ y: number; label: string }> = [];
@@ -395,8 +417,10 @@ export function createMassHaulChart(
} }
const zeroY = y(0); const zeroY = y(0);
// 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다. // 0선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에
axisTicks.push({ y: zeroY, label: "0㎥" }); // 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고).
const zeroInside = zeroY >= padTop && zeroY <= plotBottom;
if (zeroInside) axisTicks.push({ y: zeroY, label: "0㎥" });
onAxis?.({ padLeft: axisX, ticks: axisTicks }); onAxis?.({ padLeft: axisX, ticks: axisTicks });
const visible = series.filter((entry) => visibleKeys.has(entry.key)); const visible = series.filter((entry) => visibleKeys.has(entry.key));
@@ -420,7 +444,7 @@ export function createMassHaulChart(
const banded = visible[0]; const banded = visible[0];
if (banded) { if (banded) {
const last = banded.result.points[banded.result.points.length - 1]; const last = banded.result.points[banded.result.points.length - 1];
svg.append( plotBack.append(
svgElement("path", { svgElement("path", {
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다. // 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
d: d:
@@ -431,6 +455,8 @@ export function createMassHaulChart(
); );
} }
svg.append(plotBack);
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화). // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지). // 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
for (const station of longitudinal.stations) { for (const station of longitudinal.stations) {
@@ -465,27 +491,33 @@ export function createMassHaulChart(
svg.append(marker); svg.append(marker);
} }
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다. // 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그다.
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다. // 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정:
svg.append( // 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면
svgElement("line", { // 사라지므로 따로 붙인다.
x1: axisX, if (zeroInside) {
y1: zeroY, plotFront.append(
x2: widthPx - axis.padRight, svgElement("line", {
y2: zeroY, x1: axisX,
class: "b06-masshaul__zero", y1: zeroY,
}), x2: widthPx - axis.padRight,
svgText("0", { y2: zeroY,
x: axisX - 9, class: "b06-masshaul__zero",
y: zeroY + 4, }),
"text-anchor": "end", );
class: "b06-chart__tick b06-masshaul__zero-tick", 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()) { for (const entry of [...visible].reverse()) {
svg.append( plotFront.append(
svgElement("path", { svgElement("path", {
d: curvePath(entry.result.points, x, y), d: curvePath(entry.result.points, x, y),
class: seriesClass(entry, "b06-masshaul__curve"), class: seriesClass(entry, "b06-masshaul__curve"),
@@ -504,7 +536,7 @@ export function createMassHaulChart(
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다. // 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth); const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4); 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("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
svgElement("rect", { svgElement("rect", {
x: labelX - labelWidth, x: labelX - labelWidth,
@@ -527,23 +559,29 @@ export function createMassHaulChart(
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을 // 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면). // 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
if (haulPlan) { if (haulPlan) {
appendBalanceLayer(svg, haulPlan, { appendBalanceLayer(
x, svg,
y, haulPlan,
left: axis.padLeft, {
right: widthPx - axis.padRight, x,
top: padTop, y,
bottom: heightPx - MASS_PAD_BOTTOM, left: axis.padLeft,
curveYAt, right: widthPx - axis.padRight,
stationInterval, top: padTop,
}); bottom: heightPx - MASS_PAD_BOTTOM,
curveYAt,
stationInterval,
},
// 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다.
plotFront,
);
} }
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선. // 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null; const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
if (focus) { if (focus) {
const focusX = x(focus.chainage_m); const focusX = x(focus.chainage_m);
svg.append( plotFront.append(
svgElement("circle", { svgElement("circle", {
cx: focusX, cx: focusX,
cy: y(focus.cumulative_volume_m3), cy: y(focus.cumulative_volume_m3),
@@ -551,7 +589,7 @@ export function createMassHaulChart(
class: "b06-masshaul__focus", class: "b06-masshaul__focus",
}), }),
); );
appendVolumeCallout(svg, focus, stationInterval, { appendVolumeCallout(plotFront, focus, stationInterval, {
anchorX: focusX, anchorX: focusX,
anchorY: y(focus.cumulative_volume_m3), anchorY: y(focus.cumulative_volume_m3),
left: axis.padLeft, left: axis.padLeft,
@@ -562,6 +600,8 @@ export function createMassHaulChart(
}); });
} }
svg.append(plotFront);
svg.append( svg.append(
svgElement("line", { svgElement("line", {
x1: axisX, x1: axisX,