diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 61006117..bc6979ad 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -45,7 +45,9 @@ import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, + createMassHaulWindowState, MASS_HAUL_MIN_HEIGHT, + scheduleMassHaulSettle, } from "@util/common_util_mass_haul_view"; import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; @@ -306,7 +308,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa bar.append(empty); } + // 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04). + const windowState = createMassHaulWindowState(); + /** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */ + let lastParams: RouteMassHaulDrawParams | null = null; + function draw(params: RouteMassHaulDrawParams): void { + lastParams = params; clearSelection = params.onClearSelection; overlay.hidden = !open; syncHandlePosition(); @@ -329,6 +337,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa } pendingChip.hidden = true; // B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다. + // 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는 + // 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림). const series: MassHaulSeries[] = computeMassHaulSeries( params.longitudinal, params.crossSections, @@ -365,7 +375,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa series, visible, params.stationSource, - params.axis, + { ...params.axis, window: windowState }, params.selectedStationId, params.stationInterval, params.widthPx, @@ -396,6 +406,10 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa }), ); drawnOnce = true; + // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다. + scheduleMassHaulSettle(windowState, () => { + if (lastParams) draw(lastParams); + }); } return { diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index 76ed148d..3ea62def 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -85,6 +85,9 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9; * 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 — * 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */ const CROSS_PREVIEW_DEBOUNCE_MS = 60; +/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는 + * mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */ +const STATION_SNAP_TOLERANCE_M = 0.1; /** * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. @@ -341,6 +344,36 @@ export function createRouteProfilePanel( 세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */ const profileZoom = createProfileZoom(() => draw()); + /** + * 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04). + * + * 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`, + * 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든 + * 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이 + * 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + * + * 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다. + * 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다. + */ + function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] { + const master = alignment?.stations; + if (!master?.length) return stations; + return stations.map((station) => { + let best: number | null = null; + let gap = STATION_SNAP_TOLERANCE_M; + for (const node of master) { + const distance = Math.abs(node.chainage_m - station.chainage_m); + if (distance <= gap) { + gap = distance; + best = node.chainage_m; + } + } + return best === null || best === station.chainage_m + ? station + : { ...station, chainage_m: best }; + }); + } + /** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */ let elevationWindow: { min: number; max: number } | undefined; /** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */ @@ -652,7 +685,7 @@ export function createRouteProfilePanel( }, /** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */ setIrregularStations(stations: IrregularStation[]) { - irregularStations = stations; + irregularStations = snapToMasterStations(stations); draw(); }, /** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */ diff --git a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts index d9ffc4c1..b28167fe 100644 --- a/B06_Section/B06_Section_UI_Section_View_MassHaul.ts +++ b/B06_Section/B06_Section_UI_Section_View_MassHaul.ts @@ -26,6 +26,9 @@ import { createMassHaulChart, createMassHaulLegend, createMassHaulSummary, + createMassHaulWindowState, + scheduleMassHaulSettle, + type MassHaulWindowState, } from "@util/common_util_mass_haul_view"; import { L, @@ -59,6 +62,8 @@ export interface MassHaulPanelInput { redraw: () => void; /** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */ viewRange?: { fromM: number; toM: number }; + /** 세로창 버티기·부드러운 이동 상태(2026-09-04). */ + windowState?: MassHaulWindowState; } export interface MassHaulPanelResult { @@ -80,6 +85,9 @@ export interface MassHaulPanelResult { export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult { const { detail, visibleSeries } = input; const pendingRecalc = hasStaleDesigns(detail); + // 계산 결과를 아껴 두지 **않는다**. 횡단 설계는 같은 객체를 제자리에서 고치므로 + // (`refreshCrossDesigns`) 객체가 같은지로는 바뀐 것을 못 잰다 — 2026-09-04 에 캐시를 + // 넣었다가 계획고를 조절해도 횡단 기준 곡선이 그대로였다(사용자 보고). const series: MassHaulSeries[] = input.conversion && !pendingRecalc ? computeMassHaulSeries( @@ -108,6 +116,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu padLeft: LONG_PAD.left, padRight: LONG_PAD.right, viewRange: input.viewRange, + window: input.windowState, }, input.selectedStationId, input.stationInterval, @@ -213,7 +222,15 @@ export function createMassHaulRenderer( targets: MassHaulMountTargets, ): (fromM: number, toM: number) => void { let mounted: MountedMassHaul = { chart: null, axis: null }; - return (fromM, toM) => { - mounted = mountMassHaulPanel({ ...base, viewRange: { fromM, toM } }, targets, mounted); + const windowState = createMassHaulWindowState(); + const render = (fromM: number, toM: number): void => { + mounted = mountMassHaulPanel( + { ...base, viewRange: { fromM, toM }, windowState }, + targets, + mounted, + ); + // 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더(2026-09-04 「부드럽게」). + scheduleMassHaulSettle(windowState, () => render(fromM, toM)); }; + return render; } diff --git a/common_util/common_util_chart_ywindow.ts b/common_util/common_util_chart_ywindow.ts index 5a631ea8..f0ad8d96 100644 --- a/common_util/common_util_chart_ywindow.ts +++ b/common_util/common_util_chart_ywindow.ts @@ -130,6 +130,16 @@ function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: numb return true; } +/** 지금 그려져 있는 눈금 글자 중 새 창 안에 남는 개수. */ +function countTicksInside(baked: Baked, scale: number, offset: number): number { + let count = 0; + baked.svg.querySelectorAll(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => { + const moved = scale * Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)) + offset; + if (moved >= baked.top && moved <= baked.top + baked.height) count += 1; + }); + return count; +} + /** * 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null. * 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다. @@ -147,14 +157,20 @@ export function applyElevationWindow( const pxPerM = (baked.exaggeration * baked.height) / span; const scale = pxPerM / baked.pxPerM; - // 배율이 크게 벌어지면 눈금 간격이 원래 의도와 어긋난다 — 눈금만 새로 만든다(요소 20개 - // 안팎). 그래프 전체를 다시 만들던 예전 방식이 34ms 끊김의 원인이었다. + const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM; + + // 눈금을 새로 만들 때 — 둘 중 하나만 걸려도 만든다(요소 20개 안팎이라 싸다). + // ① 배율이 크게 벌어짐 — 눈금 간격이 원래 의도(1·2·5 계열)와 어긋난다. + // ② **새 창에 있어야 할 눈금 수와 지금 화면에 남은 수가 다름** — 창이 위아래로 이동만 + // 하면 배율은 그대로인데 눈금이 한쪽으로 쓸려 나가 그 자리가 빈다(2026-09-04 사용자 + // 보고: 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우」). + const wanted = yWindowTickValues(center, span / baked.exaggeration, baked.height).length; + const staying = countTicksInside(baked, scale, offset); let rebuiltTicks = false; - if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX) { + if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX || staying !== wanted) { rebuiltTicks = rebuildTicks(baked, root, center, span); } - const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM; const transform = `translate(0 ${offset}) scale(1 ${scale})`; baked.svg.querySelectorAll(`.${Y_WINDOW_CLASS}`).forEach((layer) => { layer.setAttribute("transform", transform); diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 07a7edb7..b27ac82a 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -90,7 +90,11 @@ class PipePoint: def as_dict(self) -> dict[str, Any]: # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. payload: dict[str, Any] = { - "chainage_m": round(float(self.chainage_m), 2), + # 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던 + # 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를 + # 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로 + # 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고). + "chainage_m": round(float(self.chainage_m), 3), "source": self.source, } if self.facility != PIPE_FACILITY_PIPE: diff --git a/common_util/common_util_mass_haul_balance_view.ts b/common_util/common_util_mass_haul_balance_view.ts index dca257ff..0933fb5a 100644 --- a/common_util/common_util_mass_haul_balance_view.ts +++ b/common_util/common_util_mass_haul_balance_view.ts @@ -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); } /** diff --git a/common_util/common_util_mass_haul_view.ts b/common_util/common_util_mass_haul_view.ts index d06e516f..4ef8c6fc 100644 --- a/common_util/common_util_mass_haul_view.ts +++ b/common_util/common_util_mass_haul_view.ts @@ -49,8 +49,52 @@ export interface MassHaulAxis { * 전 구간 기준 ±200㎥ 고정이다. */ viewRange?: { fromM: number; toM: number }; + /** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */ + window?: MassHaulWindowState; } +/** + * 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 + + * 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에 + * 4~5배로 바뀌었다(실측 228 → 1,019㎥). + * + * 규칙 둘. + * ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**. + * ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다. + */ +export interface MassHaulWindowState { + /** 지금 쓰고 있는 창. */ + held: { min: number; max: number } | null; + /** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */ + settled: boolean; + /** 예약해 둔 다음 프레임(중복 예약 방지). */ + frame: number; +} + +export function createMassHaulWindowState(): MassHaulWindowState { + return { held: null, settled: true, frame: 0 }; +} + +/** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */ +export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void { + if (state.frame) cancelAnimationFrame(state.frame); + state.frame = 0; + if (state.settled) return; + state.frame = requestAnimationFrame(() => { + state.frame = 0; + redraw(); + }); +} + +/** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */ +const WINDOW_PAD_RATIO = 0.2; +/** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */ +const WINDOW_KEEP_FILL = 0.55; +/** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 — + * 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을 + * 더 먹었다(2026-09-04 실측). */ +const WINDOW_EASE = 0.2; + /** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */ export const MASS_HAUL_MIN_HEIGHT = 90; /** 패널을 건드리지 않았을 때의 유토곡선 높이. */ @@ -137,6 +181,7 @@ const VOLUME_RANGE_BASE_M3 = 200; function volumeRange( series: MassHaulSeries[], viewRange?: { fromM: number; toM: number }, + state?: MassHaulWindowState, ): { min: number; max: number } { let rawMin = 0; let rawMax = 0; @@ -168,8 +213,35 @@ function volumeRange( } if (found) { // 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다. - const padding = Math.max((rawMax - rawMin) * 0.05, 1); - return { min: rawMin - padding, max: rawMax + padding }; + const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1); + const target = { min: rawMin - padding, max: rawMax + padding }; + if (!state) return target; + const held = state.held; + if (held) { + // 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다. + const heldSpan = held.max - held.min; + const inside = rawMin >= held.min && rawMax <= held.max; + const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL; + if (inside && fills) { + state.settled = true; + return held; + } + // 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음). + const next = { + min: held.min + (target.min - held.min) * WINDOW_EASE, + max: held.max + (target.max - held.max) * WINDOW_EASE, + }; + const tolerance = Math.max((target.max - target.min) * 0.01, 0.5); + const done = + Math.abs(next.min - target.min) <= tolerance && + Math.abs(next.max - target.max) <= tolerance; + state.held = done ? target : next; + state.settled = done; + return state.held; + } + state.held = target; + state.settled = true; + return target; } } for (const entry of series) { @@ -241,7 +313,8 @@ function textWidth(value: string): number { * 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다. */ function appendVolumeCallout( - svg: SVGSVGElement, + /** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */ + svg: SVGSVGElement | SVGGElement, point: MassHaulPoint, stationInterval: number, box: CalloutPlacement, @@ -364,10 +437,35 @@ export function createMassHaulChart( // 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 — // 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백. const rangeSource = series.filter((entry) => visibleKeys.has(entry.key)); - const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange); + const { min, max } = volumeRange( + rangeSource.length ? rangeSource : series, + axis.viewRange, + axis.window, + ); 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 +493,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 +520,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 +531,8 @@ export function createMassHaulChart( ); } + svg.append(plotBack); + // 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화). // 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지). for (const station of longitudinal.stations) { @@ -465,27 +567,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 +612,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 +635,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 +665,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 +676,8 @@ export function createMassHaulChart( }); } + svg.append(plotFront); + svg.append( svgElement("line", { x1: axisX,