Files
Aislo/B06_Section/B06_Section_UI_Section_View_Draw.ts
eomsangdonandClaude Opus 5 3518f346e4 fix(b06): 계획고를 누르는 동안 세로 창을 고정 — 선이 안 움직여 보이던 원인
B05 에는 있고 B06 에 없던 장치였음. ▲▼ 를 누를 때마다 세로 창이 새 계획선에 맞춰 다시
잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼 보였음. B05 와 같이 누르고 있는 동안
창을 고정하고 손을 뗀 뒤 한 번만 다시 맞춤.

실측(랩탑_보조 534.0m 측점): 계획고 837.24 → 839.84m 일 때 설계선이 3.2px 올라감.
종전에는 같은 조작에서 0.4px 만 움직였음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 19:07:30 +09:00

283 lines
12 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Section_View_Draw.ts
* B06 상단 패널의 **종단 그래프 한 벌**을 세운다 — 그래프·구조물 알약 레인·우클릭 메뉴·
* 누가토량 배지·고정 Y축, 그리고 가로 스크롤에 따라붙는 세로 창 갱신기까지.
*
* 왜 갈랐나 — 종단 화면 본체(`_Section_View`)가 700줄을 넘겨(CLAUDE.md 4장) 기능별로
* 나눠야 했다. 이 파일은 **그리는 몫만** 맡고, 높이 재기·되먹임 보정·선택 상태는 본체가
* 그대로 쥔다. 넘겨받는 값은 전부 인자라 여기에는 화면 상태가 남지 않는다.
* ========================================================================== */
import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
import { badgeValuesFrom } from "@util/common_util_mass_haul_badge";
import {
applyElevationWindow,
needsFullRedraw,
Y_AXIS_WINDOW_CLASS,
type ElevationWindowResult,
} from "@util/common_util_chart_ywindow";
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
import {
buildLongitudinalChart,
visibleChainageRange,
visibleElevationRange,
} from "./B06_Section_UI_Section_View_Chart";
import {
mountSectionStructureMenu,
moveMarkById,
type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu";
import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */
export interface LongitudinalPanelInput {
detail: SectionDetailResponse;
/** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */
chartWrap: HTMLElement;
/** 본체가 잰 그릴 폭·높이. */
renderWidth: number;
chartHeight: number;
/** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */
keepScrollLeft: number;
selectedStationId: string | null;
exaggeration: number;
stationInterval: number;
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */
edit: SectionStructureEdit | null;
conversion?: EarthworkConversion;
naturalSpoilSlope?: number;
selectStation: (stationId: string) => void;
/** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */
holdRange?: (
next: { min: number; max: number } | null,
) => { min: number; max: number } | undefined;
/** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품).
* 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */
grade?: {
alignment: ProfileAlignment;
/** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */
stepM: number;
onStation: (chainageM: number, delta: number) => void;
} | null;
/** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */
setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void;
}
/** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */
export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
/**
* 종단 그래프 한 벌을 세우고 **세로 창 갱신기**를 돌려준다.
* 갱신기는 가로 스크롤마다 불러 보이는 구간에 세로를 맞추는 데 쓴다.
*/
export function drawLongitudinalPanel(
input: LongitudinalPanelInput,
): () => ElevationWindowResult | null {
const { detail, chartWrap, structures, types, edit } = input;
const chartWidth = input.renderWidth;
/** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */
const moveMark = (markId: string, toChainageM: number): void => {
if (!edit) return;
moveMarkById(markId, toChainageM, {
structures,
stations: detail.longitudinal.stations ?? [],
edit,
});
};
const chart = buildLongitudinalChart({
detail,
selectedStationId: input.selectedStationId,
exaggeration: input.exaggeration,
selectStation: input.selectStation,
stationInterval: input.stationInterval,
chartWidth,
chartHeight: input.chartHeight,
minWidth: chartWidth,
scrollLeft: input.keepScrollLeft,
viewportWidth: chartWrap.clientWidth || chartWidth,
// 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다
// (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다.
bottomInsetPx: input.grade ? 15 : 0,
holdRange: input.holdRange,
// 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동.
onDragStation: edit ? moveMark : undefined,
});
const nodes: Element[] = [chart.node];
// 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은
// 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
if (structures.length && types.length) {
nodes.push(
buildStructureLane({
structures,
types,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
widthPx: chartWidth,
axisWidthPx: LONG_PAD.left,
stationIntervalM: input.stationInterval,
selectedId: null,
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
onSelect: (structureId) => {
if (!structureId) return;
const hit = structures.find((entry) => entry.structure_id === structureId);
if (!hit) return;
const at = structureAnchorM(hit);
let best: { id: string; gap: number } | null = null;
for (const section of detail.cross_sections) {
const gap = Math.abs(section.chainage_m - at);
if (!best || gap < best.gap) best = { id: section.station_id, gap };
}
if (best) input.selectStation(best.id);
},
// 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서.
onMove: moveMark,
}),
);
}
chartWrap.replaceChildren(...nodes);
// 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자:
// B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에
// 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다.
if (input.grade) {
const editLayer = createEditOverlay({
alignment: input.grade.alignment,
width: chartWidth,
x: chart.toX,
step: input.grade.stepM,
onStation: input.grade.onStation,
});
// 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은
// **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측:
// 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다.
editLayer.style.height = `${input.chartHeight}px`;
editLayer.style.bottom = "auto";
chartWrap.append(editLayer);
}
// 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군
// → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
if (edit) {
mountSectionStructureMenu(chartWrap, {
structures,
types,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
edit,
});
}
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
if (input.conversion) {
const series = computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
input.conversion,
input.naturalSpoilSlope,
);
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null);
} else {
input.setMassBadge(null);
}
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
if (chart.axis) {
const overlay = buildStickyYAxis(chart.axis, input.chartHeight);
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
chartWrap.prepend(overlay);
}
chartWrap.scrollLeft = input.keepScrollLeft;
// 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다).
return () => {
const { fromM, toM } = visibleChainageRange(
chart.toChainage,
chart.maxChainageM,
chartWrap.scrollLeft,
chartWrap.clientWidth || chartWidth,
);
const next = visibleElevationRange(detail, fromM, toM);
return applyElevationWindow(
chartWrap,
input.holdRange ? input.holdRange(next) : (next ?? undefined),
);
};
}
/** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
/**
* 가로 스크롤에 세로 창을 따라붙인다 — **한 번만** 부른다(리스너를 남긴다).
*
* 세로 맞춤은 그리기가 아니라 **변환**이다(2026-09-04 사용자 확정, B05 와 같은 규칙).
* 변환으로 못 살리는 자리에서만 스크롤이 멎은 뒤 한 번 다시 그린다.
*/
export function attachWindowScroll(
chartWrap: HTMLElement,
updater: () => (() => ElevationWindowResult | null) | null,
redraw: () => void,
): void {
let settledScrollLeft = 0;
let timer = 0;
chartWrap.addEventListener("scroll", () => {
const fitted = updater()?.() ?? null;
window.clearTimeout(timer);
if (!needsFullRedraw(fitted)) return;
timer = window.setTimeout(() => {
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = chartWrap.scrollLeft;
redraw();
}, SCROLL_SETTLE_MS);
});
}
/**
* 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다(B05 와 같은 장치).
*
* 안 그러면 창이 새 계획선에 맞춰 다시 잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼
* 보인다(2026-09-12 사용자 보고: 「버튼을 눌러도 계획 종단선이 안 바뀐다」). 손을 뗀 뒤
* 한 번만 다시 맞춘다.
*/
export function createElevationHold(
chartWrap: HTMLElement,
redraw: () => void,
): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined {
let editing = false;
let held: { min: number; max: number } | undefined;
chartWrap.addEventListener("pointerdown", (event) => {
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
editing = true;
const release = (): void => {
editing = false;
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
redraw();
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
return (next) => {
if (editing) return held;
held = next ?? undefined;
return held;
};
}