Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
539 lines
28 KiB
TypeScript
539 lines
28 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_Profile_MassHaul.ts
|
|
* B05 유토곡선 — 종단 열 아래 **자기 판**(354-1 · 배수유역도처럼 손잡이 · 높이 · 접기 따로).
|
|
*
|
|
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
|
|
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
|
|
* 조회(`fetchSectionDetail`)가 지정 설계 없는 측점에 기본 설계를 즉석 계산해 얹으므로
|
|
* (`_attach_default_designs`), B05 진입 시점에 이미 **횡단 단면적 기반 정식 곡선**을 그릴
|
|
* 수 있다. 그래서 B06과 똑같이 `computeMassHaulSeries`(횡단 기준만 · 종단 기준 숨김 354-2)를 쓰고,
|
|
* 범례·토량 분배·balloon·요약도 전부 공용 모듈 그대로 쓴다.
|
|
*
|
|
* 표시 토글 sessionStorage 키도 B06과 **공유**한다 — 두 화면은 같은 그림의 두 창이라
|
|
* 한쪽에서 켠 곡선이 다른 쪽에서도 켜져 있어야 한다. 데이터 자체도 같은 상세 API와
|
|
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
|
|
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
|
|
*
|
|
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
|
|
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
|
|
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
|
|
* 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에
|
|
* 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
|
|
* ========================================================================== */
|
|
|
|
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
|
import { pipelineWork } from "../A00_Common/design/logic/design_pipeline";
|
|
import type {
|
|
EarthworkConversion,
|
|
HaulEquipmentLimit,
|
|
MassHaulLongitudinal,
|
|
MassHaulSection,
|
|
MassHaulStationSource,
|
|
} from "@util/common_util_mass_haul_types";
|
|
import type { MassHaulSeries } from "@util/common_util_mass_haul";
|
|
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
|
import type { HaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
|
|
import type { MassHaulAxis } from "@util/common_util_mass_haul_view";
|
|
import {
|
|
applyLegendToggle,
|
|
computeMassHaulSeries,
|
|
MASS_HAUL_BALANCE_KEY,
|
|
MASS_HAUL_DEFAULT_VISIBLE,
|
|
normalizeVisibleBasis,
|
|
} from "@util/common_util_mass_haul";
|
|
import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge";
|
|
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
|
import {
|
|
createMassHaulChart,
|
|
createMassHaulLegend,
|
|
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";
|
|
import { createPanelResizer } from "@ui/ui_template_resizer";
|
|
import "@util/common_util_mass_haul.css";
|
|
import "./B05_Profile_UI_Style_MassHaul.css";
|
|
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
|
|
import { frameOf } from "../common_util/common_util_view_window";
|
|
|
|
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
|
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
|
/** 오버레이 높이(px) 세션 키 — 메인 하단 패널 높이와 같은 수명. */
|
|
const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? "";
|
|
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
|
|
const OVERLAY_MIN_HEIGHT = 160;
|
|
const OVERLAY_DEFAULT_HEIGHT = 280;
|
|
/** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */
|
|
const MASS_FIT_PAD_RIGHT = 12;
|
|
/** 판이 종단 열을 다 차지하지 않게 남기는 상한 비율. */
|
|
const OVERLAY_MAX_RATIO = 0.8;
|
|
/** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */
|
|
const HEIGHT_VAR = "--b05-masshaul-height";
|
|
/**
|
|
* 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라
|
|
* 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정).
|
|
* 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다.
|
|
*/
|
|
/** 처음 열 때는 **곡선만** 보인다 — 토량 분배(평형선·운반 블록)는 범례에서 켠다
|
|
* (2026-09-06 사용자 지시). 판을 v4 → v5 로 올려 이미 켜 둔 브라우저도 새 기본값으로
|
|
* 시작하게 한다. */
|
|
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE];
|
|
|
|
/** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */
|
|
export interface RouteMassHaulContext {
|
|
conversion: EarthworkConversion;
|
|
haulLimits?: HaulEquipmentLimit[];
|
|
/** 자연방토 판정 경사(rise/run). 못 받으면 전부 불가(보수적) — B06과 같은 규약. */
|
|
naturalSpoilMinSlope?: number;
|
|
}
|
|
|
|
/** 가로 스크롤에도 좌측에 고정되는 Y축 오버레이(표고·누가토량 공용).
|
|
*
|
|
* SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가
|
|
* 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을
|
|
* 절대배치한다. 종단 그래프(Panel)와 유토곡선(이 모듈)이 함께 쓰므로 여기가 정의처다.
|
|
*/
|
|
export function buildStickyYAxis(
|
|
axis: { padLeft: number; ticks: Array<{ y: number; label: string }> },
|
|
chartHeight: number,
|
|
): HTMLElement {
|
|
const anchor = document.createElement("div");
|
|
anchor.className = "b05-profile__yaxis";
|
|
const inner = document.createElement("div");
|
|
inner.className = "b05-profile__yaxis-inner";
|
|
inner.style.width = `${axis.padLeft}px`;
|
|
inner.style.height = `${chartHeight}px`;
|
|
axis.ticks.forEach(({ y, label }) => {
|
|
const tick = document.createElement("span");
|
|
tick.className = "b05-profile__yaxis-tick";
|
|
tick.style.top = `${y}px`;
|
|
// 종단 그래프의 세로 창 변환이 이 눈금도 같이 옮긴다 — 그릴 때의 y를 남겨 둔다
|
|
// (`_UI_Profile_YWindow`, 2026-09-04). 유토곡선 축에는 갱신이 오지 않아 무해하다.
|
|
tick.setAttribute(Y_WINDOW_BAKED_ATTR, String(y));
|
|
tick.textContent = label;
|
|
inner.append(tick);
|
|
});
|
|
anchor.append(inner);
|
|
return anchor;
|
|
}
|
|
|
|
export interface RouteMassHaulDrawParams {
|
|
/** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */
|
|
stationSource: MassHaulStationSource;
|
|
/** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */
|
|
longitudinal: MassHaulLongitudinal;
|
|
/** 횡단 정식 곡선 입력 — 상세 조회가 내려준 측점별 설계(기본 프리뷰 포함). */
|
|
crossSections: MassHaulSection[];
|
|
/** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */
|
|
axis: MassHaulAxis;
|
|
stationInterval: number;
|
|
/** 종단 캔버스와 같은 콘텐츠 폭(px) — 같아야 측점 세로선이 맞물린다. */
|
|
widthPx: number;
|
|
selectedStationId: string | null;
|
|
onSelectStation: (stationId: string) => void;
|
|
/**
|
|
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
|
|
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
|
|
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
|
|
*/
|
|
pendingRecalc?: boolean;
|
|
/** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */
|
|
staleChainages?: number[];
|
|
}
|
|
|
|
export interface RouteMassHaulPanel {
|
|
/** 판 머리줄의 접기 손잡이(펼치기/접기) — 판 안에 있다. */
|
|
handle: HTMLElement;
|
|
/** 유토곡선 판 — 호출한 쪽이 종단 열 아래(`b05-route-profile__column` 끝)에 붙인다. */
|
|
overlay: HTMLElement;
|
|
isOpen(): boolean;
|
|
setContext(next: RouteMassHaulContext | null): void;
|
|
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
|
|
attachScrollSync(main: HTMLElement): void;
|
|
/** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */
|
|
draw(params: RouteMassHaulDrawParams): void;
|
|
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
|
|
drawWindow(params: RouteMassHaulDrawParams): void;
|
|
}
|
|
|
|
function readVisible(): Set<string> {
|
|
try {
|
|
const raw = readStateRaw("masshaul-visible");
|
|
if (!raw) return new Set(DEFAULT_VISIBLE);
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
|
|
// 종단 기준은 안 그림(354-2) — 저장된 범례가 종단이면 횡단으로 맞춘다.
|
|
return Array.isArray(parsed)
|
|
? normalizeVisibleBasis(
|
|
new Set(parsed.map((key) => (key === "longitudinal" ? "cross" : String(key)))),
|
|
)
|
|
: new Set(DEFAULT_VISIBLE);
|
|
} catch {
|
|
return new Set(DEFAULT_VISIBLE);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw).
|
|
*/
|
|
/**
|
|
* 종단 상단줄이 **접힌 상태에서도** 보여 주는 유토곡선 총괄값(2026-09-06 사용자 지시).
|
|
* 곡선 자체는 접으면 사라져도 이 값은 계획선을 만지며 계속 봐야 한다.
|
|
*/
|
|
export type MassHaulSummaryValues = MassHaulBadgeValues;
|
|
|
|
export function createRouteMassHaulPanel(
|
|
onChanged: () => void,
|
|
/** 총괄값이 새로 나올 때마다 부른다 — 종단 상단줄이 받아 적는다. */
|
|
onSummary?: (summary: MassHaulSummaryValues | null) => void,
|
|
/** 유토 배분 선반입기 — **배분은 서버가 만든다**(2026-09-06). 없으면 분배는 안 그린다. */
|
|
haulPrefetch?: HaulPlanPrefetch,
|
|
): RouteMassHaulPanel {
|
|
// 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자
|
|
// 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는
|
|
// 툴팁으로 밝힌다.
|
|
const handleControl = createWorkflowPanelHandle("bottom", "down", "유토곡선");
|
|
const handle = document.createElement("div");
|
|
handle.className = "b05-profile__masshaul-handle";
|
|
handle.append(handleControl.root);
|
|
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
|
|
// 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1).
|
|
const head = document.createElement("div");
|
|
head.className = "b05-profile__masshaul-head";
|
|
head.append(handle);
|
|
|
|
// 판 뼈대 — 위 경계 리사이저 + 머리줄 + 안내 문구 자리 + 가로 스크롤러 + 범례.
|
|
// (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.)
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "b05-profile__masshaul-overlay";
|
|
const bar = document.createElement("div");
|
|
bar.className = "b05-profile__masshaul-bar";
|
|
const scroll = document.createElement("div");
|
|
scroll.className = "b05-profile__masshaul-scroll";
|
|
const legendLayer = document.createElement("div");
|
|
legendLayer.className = "b05-profile__masshaul-legend";
|
|
// 상태 선언은 반드시 리사이저 생성보다 **먼저** — createPanelResizer가 세션에 저장된
|
|
// 높이를 복원하며 생성 중에 onResize → syncHandlePosition을 부르는데, `open`이 그 아래
|
|
// 있으면 TDZ ReferenceError로 B05 페이지 전체가 죽는다(2026-08-04 사용자 보고 —
|
|
// 저장된 높이가 있는 브라우저에서만 재현되는 이유).
|
|
let open = readStateRaw("masshaul-open") === "true";
|
|
let context: RouteMassHaulContext | null = null;
|
|
// 높이 조절 — 메인 하단 패널과 같은 공용 리사이저(위 경계, 위로 끌면 커짐, 세션 보존).
|
|
let resizeRedrawPending = false;
|
|
const resizer = createPanelResizer({
|
|
axis: "vertical",
|
|
target: overlay,
|
|
cssVar: HEIGHT_VAR,
|
|
direction: -1,
|
|
min: OVERLAY_MIN_HEIGHT,
|
|
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
|
|
storageKey: OVERLAY_HEIGHT_KEY,
|
|
onResize: () => {
|
|
// 전체 redraw는 프레임당 한 번만.
|
|
if (resizeRedrawPending) return;
|
|
resizeRedrawPending = true;
|
|
frameOf(overlay, () => {
|
|
resizeRedrawPending = false;
|
|
onChanged();
|
|
});
|
|
},
|
|
});
|
|
// 재계산 대기 표시 — 곡선 영역 우측 상단에 **떠 있는** 알림이다. 예전에는 요약 막대에
|
|
// 붙였는데 막대가 줄바꿈되며 곡선을 아래로 밀어냈다(2026-09-04 사용자 보고: 「창 밀림」).
|
|
const pendingChip = document.createElement("span");
|
|
pendingChip.className = "b05-profile__masshaul-pending";
|
|
pendingChip.textContent = "다시 계산 중…";
|
|
pendingChip.hidden = true;
|
|
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
|
|
|
|
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
|
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
|
attachWheelHorizontalScroll(scroll);
|
|
|
|
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
|
|
const shown = (): boolean => open || overlay.classList.contains("is-popout");
|
|
|
|
function applyOpen(next: boolean): void {
|
|
open = next;
|
|
writeStateRaw("masshaul-open", String(next));
|
|
handleControl.setOpen(next);
|
|
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
|
|
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
|
|
handle.classList.toggle("is-open", next);
|
|
// 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음)
|
|
overlay.classList.toggle("is-collapsed", !next);
|
|
onChanged();
|
|
}
|
|
handleControl.setOpen(open);
|
|
handleControl.root.title = open ? "유토곡선 접기" : "유토곡선 펼치기";
|
|
handle.classList.toggle("is-open", open);
|
|
overlay.classList.toggle("is-collapsed", !open);
|
|
handleControl.root.addEventListener("click", () => applyOpen(!open));
|
|
|
|
function toggleSeries(key: string): void {
|
|
// 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다.
|
|
const visible = applyLegendToggle(readVisible(), key);
|
|
writeStateRaw("masshaul-visible", JSON.stringify([...visible]));
|
|
onChanged();
|
|
}
|
|
|
|
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
|
|
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
|
|
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
|
|
let fitted = false;
|
|
let syncedMain: HTMLElement | null = null;
|
|
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
|
|
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
|
|
let syncedIn: Document | null = null;
|
|
function attachScrollSync(main: HTMLElement): void {
|
|
if (syncedMain === main) return;
|
|
syncedMain = main;
|
|
putScroll = linkScrollLeft(main, scroll, () => fitted);
|
|
}
|
|
|
|
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
|
|
let drawnOnce = false;
|
|
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
|
|
let lastSeries: MassHaulSeries[] | null = null;
|
|
|
|
function note(message: string): void {
|
|
drawnOnce = false;
|
|
lastSeries = null;
|
|
syncedIn = null;
|
|
pendingChip.hidden = true;
|
|
bar.replaceChildren();
|
|
scroll.replaceChildren();
|
|
legendLayer.replaceChildren();
|
|
const empty = document.createElement("span");
|
|
empty.className = "b05-profile__masshaul-empty";
|
|
empty.textContent = message;
|
|
bar.append(empty);
|
|
}
|
|
|
|
// 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04).
|
|
const windowState = createMassHaulWindowState();
|
|
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
|
|
let lastParams: RouteMassHaulDrawParams | null = null;
|
|
|
|
/** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */
|
|
function emitSummary(series: MassHaulSeries[]): void {
|
|
if (!onSummary) return;
|
|
if (!series.length) return onSummary(null);
|
|
// 총괄값 기준은 **횡단 고정**이다(2026-09-06 사용자: 종단 기준 곡선은 뒤에 없앨 예정).
|
|
// 범례에서 무엇을 켜 두었든 배지·상단줄이 흔들리지 않는다.
|
|
const picked = series.find((entry) => entry.basis === "cross") ?? series[0];
|
|
// 배분 선반입도 **여기서** 건다 — 접힘 여부와 무관하게 지나는 자리다(2026-09-06).
|
|
// 아래 `draw` 안에 두었더니 접힘 가드 뒤라 한 번도 안 불렸고, 그래서 「편집이 멈추면
|
|
// 조용히 받아 둔다」가 성립하지 않았다(펼친 뒤에야 첫 요청이 나갔다).
|
|
haulPrefetch?.schedule(picked.result);
|
|
onSummary(badgeValuesFrom(picked.result));
|
|
}
|
|
|
|
/** 총괄값만 따로 낸다 — 패널을 접어도 상단줄이 값을 잃지 않게(2026-09-06).
|
|
* **횡단 기준만** 적분한다 — 접힌 상태에서 종단 기준까지 내던 것은 버려질 값이었다
|
|
* (2026-09-06 조작 실측: 계획고 ▲ → 라벨 갱신 중앙 130ms 중 낭비분). */
|
|
function reportSummary(params: RouteMassHaulDrawParams): void {
|
|
if (!onSummary) return;
|
|
if (!context || params.pendingRecalc) return;
|
|
emitSummary(
|
|
computeMassHaulSeries(
|
|
params.longitudinal,
|
|
params.crossSections,
|
|
context.conversion,
|
|
context.naturalSpoilMinSlope,
|
|
["cross"],
|
|
),
|
|
);
|
|
}
|
|
|
|
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
|
|
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
|
|
pipelineWork("유토곡선", () => drawNow(params, reuse));
|
|
}
|
|
|
|
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
|
|
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
|
|
let windowDirty = false;
|
|
function drawWindow(params: RouteMassHaulDrawParams): void {
|
|
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
|
|
lastParams = params;
|
|
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
|
|
if (windowDirty || windowState.frame) return;
|
|
windowDirty = true;
|
|
frameOf(overlay, () => {
|
|
if (windowDirty && lastParams) draw(lastParams, true);
|
|
});
|
|
}
|
|
|
|
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
|
|
lastParams = params;
|
|
windowDirty = false;
|
|
overlay.classList.toggle("is-collapsed", !open);
|
|
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
|
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
|
if (!shown()) {
|
|
syncedIn = null;
|
|
reportSummary(params);
|
|
return;
|
|
}
|
|
if (!context) {
|
|
note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
|
|
return;
|
|
}
|
|
// 저장된 횡단이 지금 계획선과 어긋나 있으면 그 면적은 옛 계획고로 만든 값이다 —
|
|
// 새 값이 올 때까지 기다린다(2026-09-03 사용자 확정: 「새 값만 보여주기」).
|
|
//
|
|
// 다만 **곡선을 지우지는 않는다**. 계획고를 연속으로 누르면(초당 10회) 매번 곡선이
|
|
// 사라졌다 다시 서서 화면이 깜빡였다(2026-09-03 사용자 보고). 재계산은 브라우저
|
|
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
|
|
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
|
|
if (params.pendingRecalc) {
|
|
pendingChip.textContent = "다시 계산 중…";
|
|
if (drawnOnce) pendingChip.hidden = false;
|
|
else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
|
return;
|
|
}
|
|
const stale = params.staleChainages ?? [];
|
|
pendingChip.hidden = !stale.length;
|
|
pendingChip.textContent = stale.length
|
|
? `낡은 측점 ${stale.length}곳 · ${stale
|
|
.slice(0, 5)
|
|
.map((chainage) => `${chainage.toFixed(2)} m`)
|
|
.join(" · ")}${stale.length > 5 ? " …" : ""}`
|
|
: "다시 계산 중…";
|
|
// B06과 같은 계산 — 횡단 기준(정식)만 낸다. 종단 기준은 안 셈 · 안 그림 · 범례 단추 없음
|
|
// (354-2 · 계산 코드는 공용에 남김).
|
|
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
|
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
|
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
|
|
const series: MassHaulSeries[] =
|
|
reuse && lastSeries
|
|
? lastSeries
|
|
: computeMassHaulSeries(
|
|
params.longitudinal,
|
|
params.crossSections,
|
|
context.conversion,
|
|
context.naturalSpoilMinSlope,
|
|
["cross"],
|
|
);
|
|
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
|
|
if (series !== lastSeries) emitSummary(series);
|
|
lastSeries = series;
|
|
if (!series.length) {
|
|
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
|
|
return;
|
|
}
|
|
bar.replaceChildren();
|
|
const visible = readVisible();
|
|
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
|
|
const banded = series.find((entry) => visible.has(entry.key));
|
|
// 배분은 **서버가 만든다** — 그 산식을 번들에 남기지 않으려고 옮겼다(2026-09-06).
|
|
// 요청은 `emitSummary` 가 걸어 두므로(접힘 여부와 무관) 펼칠 때는 이미 도착해 있다.
|
|
// 아직 못 받았으면 곡선만 그린다(분배 도형은 값이 오면 다시 그려진다).
|
|
const haulPlan: HaulPlan | null =
|
|
banded && visible.has(MASS_HAUL_BALANCE_KEY) ? (haulPrefetch?.current() ?? null) : null;
|
|
// 요약 막대는 2026-09-06 사용자 지시로 뺐다 — 총괄값은 종단 상단줄이 늘 보여 주고,
|
|
// 이 자리는 곡선이 넓게 쓴다. `bar` 는 안내 문구 자리로만 남는다.
|
|
|
|
// 곡선 몫 = 스크롤러의 **안쪽 높이**(clientHeight = 가로 스크롤바 제외). 오버레이 전체
|
|
// 높이로 잡으면 스크롤바가 곡선 바닥(Y축 -200 라벨)을 덮는다(2026-08-04 사용자 보고).
|
|
// 첫 렌더(레이아웃 전)에는 0이 나올 수 있어 기본 높이로 시작한다 — Panel이 다음
|
|
// 프레임에 한 번 더 그려 실측으로 수렴한다.
|
|
const scrollHeight =
|
|
scroll.clientHeight ||
|
|
(overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12;
|
|
const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT);
|
|
|
|
// 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시).
|
|
// 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로
|
|
// 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다.
|
|
// 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼
|
|
// 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」).
|
|
const fitAll = haulPlan !== null;
|
|
fitted = fitAll;
|
|
const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx;
|
|
const chartAxis = fitAll
|
|
? {
|
|
...params.axis,
|
|
padLeft: params.axis.axisX ?? params.axis.padLeft,
|
|
padRight: MASS_FIT_PAD_RIGHT,
|
|
viewRange: undefined,
|
|
window: undefined,
|
|
hideStations: true,
|
|
}
|
|
: {
|
|
...params.axis,
|
|
// 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2).
|
|
viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange,
|
|
window: windowState,
|
|
};
|
|
|
|
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
|
const chart = createMassHaulChart(
|
|
series,
|
|
visible,
|
|
params.stationSource,
|
|
chartAxis,
|
|
params.selectedStationId,
|
|
params.stationInterval,
|
|
widthPx,
|
|
chartHeight,
|
|
widthPx,
|
|
params.onSelectStation,
|
|
haulPlan,
|
|
(axis) => {
|
|
massAxis = axis;
|
|
},
|
|
);
|
|
// 종단 캔버스와 같은 폭의 래퍼 — sticky Y축 앵커의 기준(position: relative)이 된다.
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "b05-profile__chart b05-profile__masshaul-chart";
|
|
wrap.style.width = `${widthPx}px`;
|
|
wrap.style.height = `${chartHeight}px`;
|
|
wrap.append(chart);
|
|
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
|
|
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
|
|
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
|
|
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
|
|
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
|
|
scroll.replaceChildren(wrap);
|
|
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
|
|
if (fitAll) scroll.scrollLeft = 0;
|
|
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
|
|
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
|
|
syncedIn = fitAll ? null : scroll.ownerDocument;
|
|
|
|
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
|
|
if (!reuse || !legendLayer.firstChild)
|
|
legendLayer.replaceChildren(
|
|
createMassHaulLegend(series, visible, toggleSeries, () => {
|
|
resetBalloonOffsets();
|
|
onChanged();
|
|
}),
|
|
);
|
|
drawnOnce = true;
|
|
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
|
scheduleMassHaulSettle(
|
|
windowState,
|
|
() => {
|
|
if (lastParams) draw(lastParams, true);
|
|
},
|
|
overlay,
|
|
);
|
|
}
|
|
|
|
return {
|
|
handle,
|
|
overlay,
|
|
isOpen: shown,
|
|
setContext(next) {
|
|
context = next;
|
|
},
|
|
attachScrollSync,
|
|
draw: (params) => draw(params),
|
|
drawWindow,
|
|
};
|
|
}
|