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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user