feat(B05/B06): 계획서 1·2·4·5·6·8·11 구현 + 유토곡선 오버레이 서브패널 전환(7)

- 4: volumeRange 기본 ±200㎥ 고정, 초과 시에만 확장 (B05·B06 공용)
- 11: 진행단계 오버레이 최상단 대시보드(🏠) 버튼 — 항상 활성, B01 이동
- 6: 편집 버튼 방향색 — 올림(▲⇧) --color-danger, 내림(▼⇩) 파랑 #5b8def, hover 배경 포함
- 2: 구조물 목록 5개 높이 상한 + 내부 스크롤, 선택 항목 scrollIntoView
- 5: 종단 렌더러 bottomInsetPx 옵션 신설 — B05만 X축·측점 라벨 17px 상향,
  ▼ 버튼과 오버랩 해소 (B06 무변화)
- 1: 구조물 측점 열 기본값 미표기 — 측점 값 7행·곡선 2행 빈 셀, 선택 오버레이가 값 표시
- 8: B06 진입 stale 재계산을 측점별 순차 루프에서 일괄 프리뷰 1회로 교체
  · preview API에 full_designs·rock_boundary_offsets 추가
  · B05 세션 초안(readAlignmentDraft) 우선 사용해 두 화면 계획선 일치
- 7: 유토곡선을 1:1 분할에서 패널 바닥 오버레이 서브패널로 전환
  · 종단도+테이블 배치 불변, 오버레이가 위를 덮음
  · 높이는 공용 createPanelResizer(위 경계, 세션 보존), splitBar·MASS_SPLIT 제거
  · 자체 가로 스크롤러 + 종단 스크롤러 양방향 동기화, 전용 ㎥ sticky Y축
  · buildStickyYAxis 정의처를 MassHaul 모듈로 이동(Panel과 공용)

typecheck·vite build·ruff 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-04 17:54:53 +09:00
co-authored by Claude Opus 5
parent c6d930b3f1
commit 6080d122bb
16 changed files with 497 additions and 278 deletions
@@ -190,6 +190,9 @@ export function createIrregularStationsSection(
item.append(name, info);
item.addEventListener("click", () => loadForm(station));
list.append(item);
// 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에
// 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다.
if (station.id === editingId) item.scrollIntoView({ block: "nearest" });
});
}
@@ -77,6 +77,17 @@ export interface ProfileEditStore {
edited(): boolean;
}
/**
* 노선의 미저장 편집 초안을 읽는다(B06 공용).
*
* B06이 진입 시 stale 횡단 설계를 일괄 재계산할 때, B05가 확정 없이 남긴 세션 초안까지
* 반영해야 두 화면의 계획선이 같은 값으로 계산된다 — 저장분(profile_alignment.edits)만
* 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다.
*/
export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null {
return readDraft(`${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`);
}
function readDraft(storageKey: string): AlignmentEdits | null {
try {
const raw = sessionStorage.getItem(storageKey);
@@ -1,6 +1,6 @@
/* =============================================================================
* B05_wf2_Route_UI_Profile_MassHaul.ts
* B05 유토곡선 — 종단면 패널 안에 다시 접히는 2차 하단 슬라이드 패널.
* B05 유토곡선 — 종단면 패널 바닥에 붙는 **오버레이 서브패널**.
*
* ── B06과 같은 그림이어야 한다 (2026-08-03 사용자 확정) ──────────────
* 이 프로그램은 파일 입력 시 기본 설정값으로 B06 몫까지 미리 계산해 둔다 — 종횡단 상세
@@ -14,10 +14,12 @@
* 같은 영구저장소(`longitudinal_sections.data.mass_haul`)를 읽으므로, B06에서 횡단을
* 고쳐 임시저장/확정하면 B05는 재조회만으로 같은 곡선을 받는다.
*
* ── 배치 ─────────────────────────────────────────────────────────
* 펼치면 12행 도면 테이블 자리를 덮고, 종단 그래프와 세로 1:1로 나눈다(2026-08-03 지시).
* 곡선 SVG는 종단 그래프와 **같은 가로 스크롤러(canvas)** 안 형제로 넣어 X축이 저절로
* 맞물리게 하고, 범례·요약처럼 폭에 관여하면 안 되는 것만 스크롤러 밖 막대에 둔다.
* ── 배치 (2026-08-04 사용자 확정: 오버레이 전환) ─────────────────────
* 예전에는 펼치면 종단 그래프와 세로 1:1 분할 + 도면 테이블 숨김이었다. 이제는 종단도·
* 테이블 배치를 **건드리지 않고**, 패널 바닥에 붙는 오버레이가 그 위를 덮는다. 높이는
* 오버레이 위 경계의 리사이저(메인 하단 패널과 같은 `createPanelResizer`)로 조절하고
* 세션 동안 유지한다. 곡선 SVG는 오버레이 안 자체 가로 스크롤러에 들어가며, 종단
* 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
* ========================================================================== */
import type {
@@ -46,11 +48,19 @@ import {
MASS_HAUL_MIN_HEIGHT,
} from "@util/common_util_mass_haul_view";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import "@util/common_util_mass_haul.css";
import "./B05_wf2_Route_UI_Style_MassHaul.css";
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
const OPEN_KEY = "b05-route-profile-masshaul-open";
/** 오버레이 높이(px) 세션 키 — 메인 하단 패널 높이와 같은 수명. */
const OVERLAY_HEIGHT_KEY = "b05-route-profile-masshaul-height";
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
const OVERLAY_MIN_HEIGHT = 160;
const OVERLAY_DEFAULT_HEIGHT = 280;
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
const OVERLAY_MAX_RATIO = 0.8;
/**
* 표시 곡선·레이어 키 — **B06 유토곡선과 같은 키**를 쓴다. 두 화면은 같은 그림의 두 창이라
* 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정).
@@ -67,9 +77,34 @@ export interface RouteMassHaulContext {
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`;
tick.textContent = label;
inner.append(tick);
});
anchor.append(inner);
return anchor;
}
export interface RouteMassHaulDrawParams {
/** 범례 오버레이의 세로 위치(px) — 종단 그래프 높이 + 여백. B06과 같은 문법. */
legendTopPx: number;
/** 측점선을 세울 목록 — 종단 그래프에 넣은 것과 **같은 배열**이어야 자리가 맞는다. */
stationSource: MassHaulStationSource;
/** 종단 개략 곡선 입력 — 편집이 반영된 현재 계획선을 담은 종단 데이터. */
@@ -79,31 +114,23 @@ export interface RouteMassHaulDrawParams {
/** 종단 그래프와 같은 X 매핑(누가거리 최댓값·좌우 여백). */
axis: MassHaulAxis;
stationInterval: number;
/** 종단 캔버스와 같은 콘텐츠 폭(px) — 같아야 측점 세로선이 맞물린다. */
widthPx: number;
heightPx: number;
selectedStationId: string | null;
onSelectStation: (stationId: string) => void;
/** 가로 스크롤에도 왼쪽에 고정되는 Y축(누가토량)을 그리도록 눈금을 되돌려 준다. */
onAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void;
}
export interface RouteMassHaulPanel {
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이. */
/** 종단면 패널 바닥에 붙는 2차 슬라이드 손잡이(펼치기/접기). */
handle: HTMLElement;
/** 요약 막대 — 가로 스크롤러 **밖**에 놓는다. */
bar: HTMLElement;
/**
* 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 동일 문법, 2026-08-03 사용자 지시).
* 호출한 쪽이 `position: relative`인 패널 본문에 붙인다.
*/
legendLayer: HTMLElement;
/** 오버레이 서브패널 — 호출한 쪽이 `position: relative`인 패널 본문 래퍼에 붙인다. */
overlay: HTMLElement;
isOpen(): boolean;
setContext(next: RouteMassHaulContext | null): void;
/**
* 곡선 SVG를 만든다. 펼침 상태가 아니거나 계산이 안 되면 null을 돌려주고,
* 이때 호출한 쪽은 원래대로 도면 테이블을 그린다.
*/
draw(params: RouteMassHaulDrawParams): SVGSVGElement | null;
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
attachScrollSync(main: HTMLElement): void;
/** 오버레이 내용을 다시 그린다. 접혀 있으면 오버레이만 숨기고 끝낸다. */
draw(params: RouteMassHaulDrawParams): void;
}
function readVisible(): Set<string> {
@@ -121,7 +148,7 @@ function readVisible(): Set<string> {
}
/**
* @param onChanged 펼침·범례 토글로 다시 그려야 할 때 호출된다(패널 전체 redraw).
* @param onChanged 펼침·범례 토글·높이 조절로 다시 그려야 할 때 호출된다(패널 전체 redraw).
*/
export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel {
const handleControl = createWorkflowPanelHandle("bottom", "down");
@@ -132,11 +159,36 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
caption.textContent = "유토곡선";
handle.append(handleControl.root, caption);
// 오버레이 뼈대 — 위 경계 리사이저 + 요약 막대 + 가로 스크롤러 + 범례.
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";
legendLayer.hidden = true;
// 높이 조절 — 메인 하단 패널과 같은 공용 리사이저(위 경계, 위로 끌면 커짐, 세션 보존).
let resizeRedrawPending = false;
const resizer = createPanelResizer({
axis: "vertical",
target: overlay,
cssVar: "--b05-masshaul-height",
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;
requestAnimationFrame(() => {
resizeRedrawPending = false;
onChanged();
});
},
});
overlay.append(resizer.root, bar, scroll, legendLayer);
let open = sessionStorage.getItem(OPEN_KEY) === "true";
let context: RouteMassHaulContext | null = null;
@@ -146,14 +198,12 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
sessionStorage.setItem(OPEN_KEY, String(next));
handleControl.setOpen(next);
handle.classList.toggle("is-open", next);
bar.hidden = !next;
legendLayer.hidden = !next;
overlay.hidden = !next;
onChanged();
}
handleControl.setOpen(open);
handle.classList.toggle("is-open", open);
bar.hidden = !open;
legendLayer.hidden = !open;
overlay.hidden = !open;
handleControl.root.addEventListener("click", () => applyOpen(!open));
caption.addEventListener("click", () => applyOpen(!open));
@@ -164,65 +214,113 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
onChanged();
}
// 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다.
let syncing = false;
function mirror(from: HTMLElement, to: HTMLElement): void {
from.addEventListener("scroll", () => {
if (syncing) return;
syncing = true;
to.scrollLeft = from.scrollLeft;
syncing = false;
});
}
let syncedMain: HTMLElement | null = null;
function attachScrollSync(main: HTMLElement): void {
if (syncedMain === main) return;
syncedMain = main;
mirror(main, scroll);
mirror(scroll, main);
}
function note(message: string): void {
bar.replaceChildren();
scroll.replaceChildren();
legendLayer.replaceChildren();
const empty = document.createElement("span");
empty.className = "b05-profile__masshaul-empty";
empty.textContent = message;
bar.append(empty);
}
function draw(params: RouteMassHaulDrawParams): void {
overlay.hidden = !open;
if (!open) return;
if (!context) {
note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
return;
}
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
);
if (!series.length) {
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
return;
}
bar.replaceChildren();
legendLayer.replaceChildren();
const visible = readVisible();
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
const banded = series.find((entry) => visible.has(entry.key));
const haulPlan: HaulPlan | null =
banded && visible.has(MASS_HAUL_BALANCE_KEY)
? computeHaulPlan(banded.result, context.haulLimits)
: null;
const summarySeries = banded ?? series[0];
bar.append(createMassHaulSummary(summarySeries, haulPlan));
// 곡선 몫 = 오버레이에서 요약 막대를 뺀 나머지. 첫 렌더(레이아웃 전)에는 0이 나올 수
// 있어 기본 높이로 시작한다 — Panel이 다음 프레임에 한 번 더 그려 실측으로 수렴한다.
const overlayHeight = overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT;
const chartHeight = Math.max(overlayHeight - bar.offsetHeight, MASS_HAUL_MIN_HEIGHT);
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const chart = createMassHaulChart(
series,
visible,
params.stationSource,
params.axis,
params.selectedStationId,
params.stationInterval,
params.widthPx,
chartHeight,
params.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 = `${params.widthPx}px`;
wrap.style.height = `${chartHeight}px`;
wrap.append(chart);
if (massAxis) wrap.append(buildStickyYAxis(massAxis, chartHeight));
scroll.replaceChildren(wrap);
// 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다.
if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
legendLayer.append(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
}
return {
handle,
bar,
legendLayer,
overlay,
isOpen: () => open,
setContext(next) {
context = next;
},
draw(params) {
bar.replaceChildren();
legendLayer.replaceChildren();
if (!open) return null;
const note = (message: string): null => {
const empty = document.createElement("span");
empty.className = "b05-profile__masshaul-empty";
empty.textContent = message;
bar.append(empty);
return null;
};
if (!context) return note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
);
if (!series.length) return note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
const visible = readVisible();
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
const banded = series.find((entry) => visible.has(entry.key));
const haulPlan: HaulPlan | null =
banded && visible.has(MASS_HAUL_BALANCE_KEY)
? computeHaulPlan(banded.result, context.haulLimits)
: null;
const chart = createMassHaulChart(
series,
visible,
params.stationSource,
params.axis,
params.selectedStationId,
params.stationInterval,
params.widthPx,
Math.max(params.heightPx, MASS_HAUL_MIN_HEIGHT),
params.widthPx,
params.onSelectStation,
haulPlan,
params.onAxis,
);
legendLayer.style.top = `${params.legendTopPx}px`;
legendLayer.append(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
const summarySeries = banded ?? series[0];
bar.append(createMassHaulSummary(summarySeries, haulPlan));
return chart;
},
attachScrollSync,
draw,
};
}
+65 -142
View File
@@ -50,6 +50,7 @@ import {
import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
buildStickyYAxis,
createRouteMassHaulPanel,
type RouteMassHaulContext,
} from "./B05_wf2_Route_UI_Profile_MassHaul";
@@ -78,10 +79,6 @@ const HEIGHT_KEY = "b05-route-profile-height";
const TABLE_HEIGHT_KEY = "b05-route-profile-table-height";
/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */
const CHART_HEIGHT_RATIO = 0.4;
/** 유토곡선 펼침 시 종단:곡선 분할 비율(종단 몫). 경계 드래그로 조절, 세션 보존. */
const MASS_SPLIT_KEY = "b05-route-profile-masshaul-split";
const MASS_SPLIT_MIN = 0.25;
const MASS_SPLIT_MAX = 0.75;
const MIN_CHART_HEIGHT = 100;
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
const MIN_PANEL_HEIGHT = 180;
@@ -146,33 +143,6 @@ function irregularGraphStations(list: IrregularStation[], maxChainage: number):
}));
}
/**
* 가로 스크롤에도 좌측에 고정되는 Y축(표고 눈금) 오버레이.
*
* SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가
* 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 절대배치한다.
*/
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`;
tick.textContent = label;
inner.append(tick);
});
anchor.append(inner);
return anchor;
}
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
function chainageMapper(
data: LongitudinalSection,
@@ -273,9 +243,12 @@ export function createRouteProfilePanel(
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
bodyWrap.append(body, progress.root);
// 유토곡선 — 2차 하단 슬라이드. 펼치면 도면 테이블 자리를 덮는다.
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
const massHaul = createRouteMassHaulPanel(() => draw());
bodyWrap.append(massHaul.legendLayer, massHaul.bar, massHaul.handle);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
const content = document.createElement("div");
content.className = "b05-route-profile__content";
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
@@ -490,47 +463,39 @@ export function createRouteProfilePanel(
fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO));
sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight));
}
// 유토곡선을 펼치면 종단 그래프와 곡선이 세로를 나눈다. 기본 1:1(2026-08-03 사용자 지시),
// 두 그래프 사이 경계선을 끌어 비율을 바꿀 수 있고 세션 동안 유지된다.
const massSplit = Math.min(
MASS_SPLIT_MAX,
Math.max(MASS_SPLIT_MIN, Number(sessionStorage.getItem(MASS_SPLIT_KEY)) || 0.5),
);
const chartHeight = massHaul.isOpen()
? Math.max(MIN_CHART_HEIGHT, Math.floor(available * massSplit))
: alignment
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
: available;
// 종단도·테이블 배치는 유토곡선과 무관하게 항상 같다(2026-08-04 사용자 확정).
// 유토곡선은 별도 오버레이 서브패널이 그 위를 덮는다.
const chartHeight = alignment
? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight)
: available;
const tableHeight = available - chartHeight;
// 계획 유토곡선을 펼치면 도면 테이블은 그리지 않는다 — 같은 자리를 나눠 쓰면 둘 다 뭉개진다.
const table =
alignment && !massHaul.isOpen()
? createProfileTable({
alignment,
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
width,
height: tableHeight,
// 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다).
cellWidth: layout.cellWidth,
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
// 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다.
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
selectedStationId,
stationDisplay,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
})
: null;
const table = alignment
? createProfileTable({
alignment,
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
width,
height: tableHeight,
// 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다).
cellWidth: layout.cellWidth,
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
// 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다.
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
selectedStationId,
stationDisplay,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
})
: null;
const chartWrap = document.createElement("div");
chartWrap.className = "b05-profile__chart";
@@ -574,6 +539,9 @@ export function createRouteProfilePanel(
);
if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target);
},
// 계획고 편집 ▼ 버튼(바닥 2px + 높이 15px)과 측점 라벨이 겹치지 않게 X축·라벨을
// 버튼 높이만큼 올린다(2026-08-04 사용자 지시). B06은 편집 버튼이 없어 0 유지.
17,
),
);
// 구조물 우클릭 메뉴 — 측점선 위면 삭제, 빈 자리면 배관 추가. 이동도 여기(측점선 끌기)서 한다.
@@ -612,81 +580,36 @@ export function createRouteProfilePanel(
}),
);
}
// 유토곡선 SVG는 종단 그래프와 **같은 부모의 형제**로 넣는다. 감싸는 상자를 하나라도
// 끼우면 그 상자가 스크롤 컨테이너 폭 계산에 끼어들어 두 그래프의 측점선이 어긋난다.
// 유토곡선의 Y축(누가토량)도 종단 표고축과 같은 방식으로 왼쪽에 고정한다 —
// 안 그러면 가로로 훑을 때 유토곡선 눈금은 흘러가고 종단 표고축만 남아,
// 유토곡선 Y축이 "높이"로 읽힌다(2026-08-03 사용자 보고).
let massYAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const massHaulChart =
alignment && designProfiles[0]
? massHaul.draw({
stationSource: graphLongitudinal,
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
crossSections: detail.cross_sections,
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
padLeft: LONG_PAD.left + originOffset,
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
},
legendTopPx: chartHeight + 6,
onAxis: (axis) => {
massYAxis = axis;
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
heightPx: tableHeight,
selectedStationId,
onSelectStation,
})
: null;
canvas.style.height = `${available}px`;
canvas.append(chartWrap);
if (table) canvas.append(table);
if (massHaulChart) {
// 종단 그래프와 유토곡선 사이 경계 — 세로로 끌어 두 그래프의 높이 배분을 바꾼다.
const splitBar = document.createElement("div");
splitBar.className = "b05-profile__masshaul-split";
splitBar.title = "끌어서 종단 그래프와 유토곡선의 높이 비율을 조절합니다.";
// 절대배치 — 흐름에 끼우면 6px만큼 세로가 넘쳐 곡선 아래가 잘린다.
splitBar.style.top = `${chartHeight - 3}px`;
splitBar.addEventListener("pointerdown", (event) => {
event.preventDefault();
splitBar.setPointerCapture(event.pointerId);
const startY = event.clientY;
const startRatio = chartHeight / Math.max(available, 1);
const onMove = (move: PointerEvent): void => {
const ratio = startRatio + (move.clientY - startY) / Math.max(available, 1);
sessionStorage.setItem(
MASS_SPLIT_KEY,
String(Math.min(MASS_SPLIT_MAX, Math.max(MASS_SPLIT_MIN, ratio))),
);
rebuild();
};
const onUp = (): void => {
splitBar.removeEventListener("pointermove", onMove);
splitBar.removeEventListener("pointerup", onUp);
};
splitBar.addEventListener("pointermove", onMove);
splitBar.addEventListener("pointerup", onUp);
});
// 종단 그래프와 같은 문법으로 감싼다 — sticky Y축 앵커의 기준(position: relative)이 필요하다.
const massWrap = document.createElement("div");
massWrap.className = "b05-profile__chart";
massWrap.style.height = `${tableHeight}px`;
massWrap.append(massHaulChart);
if (massYAxis) massWrap.append(buildStickyYAxis(massYAxis, tableHeight));
canvas.append(splitBar, massWrap);
}
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
if (alignment && designProfiles[0]) {
massHaul.draw({
stationSource: graphLongitudinal,
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
crossSections: detail.cross_sections,
axis: {
maxChainageM: maxChainageOf(longitudinal),
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
padLeft: LONG_PAD.left + originOffset,
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리(62px)에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
},
stationInterval: stationIntervalM ?? 1,
widthPx: width,
selectedStationId,
onSelectStation,
});
}
}
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
@@ -292,7 +292,12 @@ function curveTitle(curve: AlignmentCurve): string {
* 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄
* 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다.
*/
function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLElement[] {
function buildCurveRows(
options: ProfileTableOptions,
centers: number[],
/** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */
structureStationKeys: ReadonlySet<string>,
): HTMLElement[] {
const { alignment, onCurveRadiusChange } = options;
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
@@ -311,7 +316,10 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
alignment.stations.forEach((station, index) => {
const lengthCell = element("span", "b05-profile-table__curve");
const radiusCell = element("span", "b05-profile-table__curve");
const curve = curveByChainage.get(station.chainage_m.toFixed(3));
// 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지).
const curve = structureStationKeys.has(station.chainage_m.toFixed(3))
? undefined
: curveByChainage.get(station.chainage_m.toFixed(3));
if (curve) {
// L·R은 L = R × |대수차| 로 연동된다. 둘 다 입력 가능하고, 어느 쪽을 고쳐도 R로 환산해
// 같은 파이프라인(onCurveRadiusChange)을 태운다. 재계산 후 마지막에 입력한 값이 반영된다.
@@ -516,6 +524,12 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
.filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3)))
.map((node) => node.chainage_m.toFixed(3)),
);
// 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다.
// 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의
// chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다.
const structureStationKeys = new Set(
(options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)),
);
const selectedIrregularEntry = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
@@ -534,13 +548,19 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
spec.unit,
);
// 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다.
alignment.stations.forEach((_station, stationIndex) => {
const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex));
alignment.stations.forEach((station, stationIndex) => {
// 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다.
const structural = structureStationKeys.has(station.chainage_m.toFixed(3));
const cell = element(
"span",
`b05-profile-table__cell${structural ? " is-structure" : ""}`,
structural ? "" : spec.cell(stationIndex),
);
placeCell(row, centers[stationIndex], cell);
});
table.append(row);
});
table.append(...buildCurveRows(options, centers));
table.append(...buildCurveRows(options, centers, structureStationKeys));
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
+33 -4
View File
@@ -359,6 +359,11 @@
margin: 0;
padding: 0;
list-style: none;
/* 목록이 5개를 넘으면 사이드 패널을 밀어내지 않고 내부 스크롤로 돈다(2026-08-04 사용자
지시). 상한 = 항목 5개 높이(항목 약 30px + gap 4px) — 5개까지는 현행과 동일하게 다
보이고 6개째부터 스크롤이 생긴다. */
max-height: 170px;
overflow-y: auto;
}
.b05-route__irregular-item {
@@ -867,10 +872,34 @@
}
/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만
렌더러가 좌우로 비켜 배치한다. 구분을 위해 밝은 자수정색으로 표시한다. */
.b05-profile-edit__btn.is-segment {
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
color: color-mix(in srgb, var(--color-royal-amethyst, rgb(139 92 246)) 85%, var(--color-text));
렌더러가 좌우로 비켜 배치한다. 글리프(⇧⇩ vs ▲▼)로 구분한다. */
/* 방향별 색 — 올림은 빨강, 내림은 파랑(2026-08-04 사용자 지시). 무채색이던 버튼이
배경에 묻혀 잘 안 보였다. --color-danger는 테마별로 정의돼 있고(라이트 #c0392b /
다크 #e8695a), 파랑 #5b8def(--color-chart-0)는 두 테마 모두에서 대비가 나온다.
is-segment(⇧⇩)에도 같은 방향색을 적용한다 — 구분은 글리프가 맡는다. */
.b05-profile-edit__btn.is-up {
border-color: color-mix(in srgb, var(--color-danger) 65%, transparent);
color: var(--color-danger);
}
.b05-profile-edit__btn.is-down {
border-color: color-mix(in srgb, var(--color-chart-0, #5b8def) 65%, transparent);
color: var(--color-chart-0, #5b8def);
}
.b05-route-profile .b05-profile-edit__btn.is-up:hover,
.b05-route-profile .b05-profile-edit__btn.is-up:focus-visible {
border-color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 14%, var(--color-surface-raised));
color: var(--color-danger);
}
.b05-route-profile .b05-profile-edit__btn.is-down:hover,
.b05-route-profile .b05-profile-edit__btn.is-down:focus-visible {
border-color: var(--color-chart-0, #5b8def);
background: color-mix(in srgb, var(--color-chart-0, #5b8def) 14%, var(--color-surface-raised));
color: var(--color-chart-0, #5b8def);
}
.b05-profile-edit__btn.is-reset {
@@ -6,26 +6,58 @@
* 여기서는 종단면 패널 안에서 그것들이 놓이는 **자리**만 잡는다.
* ========================================================================== */
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 요약 막대·손잡이가 붙게 한다. */
/* 본문 칸을 세로로 쌓아 그래프 스크롤러 아래에 손잡이가 붙게 한다.
오버레이의 절대배치 기준이기도 하다. */
.b05-route-profile__body-wrap {
position: relative;
flex-direction: column;
}
/* 오버레이 서브패널 — 패널 바닥(접기 손잡이 위)에 붙어 종단도·테이블 위를 덮는다
(2026-08-04 사용자 확정). 높이는 위 경계 리사이저가 --b05-masshaul-height로 조절한다. */
.b05-profile__masshaul-overlay {
position: absolute;
right: 0;
bottom: 20px; /* 유토곡선 접기 손잡이(.b05-profile__masshaul-handle height) 몫 */
left: 0;
z-index: 5;
display: flex;
flex-direction: column;
height: var(--b05-masshaul-height, 280px);
border-top: 1px solid var(--color-border);
background: var(--color-surface-raised);
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
}
/* 공용 리사이저는 절대배치라 오버레이 위 경계에 그대로 앉는다. */
.b05-profile__masshaul-overlay > .ui-resizer--vertical {
top: -3px;
}
.b05-profile__masshaul-bar {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8) var(--spacing-16);
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
/* 범례 오버레이 — B06과 같은 문법으로 유토곡선 우상단에 절대배치한다(2026-08-03 지시).
/* 곡선 가로 스크롤러 — 종단 스크롤러와 scrollLeft를 동기화한다(JS). 세로는 잘라낸다. */
.b05-profile__masshaul-scroll {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
overflow-y: hidden;
}
/* 범례 오버레이 — 유토곡선 우상단에 절대배치(B06과 같은 문법). 요약 막대 아래에 앉힌다.
공용 범례(.b06-masshaul__legend)가 이미 absolute + right 배치라 top만 이 래퍼가 정한다. */
.b05-profile__masshaul-legend {
position: absolute;
top: 0;
top: 34px;
right: 0;
left: 0;
z-index: 3;
@@ -37,21 +69,6 @@
pointer-events: auto;
}
/* 종단 그래프와 유토곡선 사이 경계 — 세로 드래그로 높이 배분을 바꾼다. */
.b05-profile__masshaul-split {
position: absolute;
left: 0;
right: 0;
height: 6px;
z-index: 2;
cursor: row-resize;
touch-action: none;
}
.b05-profile__masshaul-split:hover {
background: color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.b05-profile__masshaul-bar .b06-masshaul__summary {
border-top: none;
padding-top: 0;
@@ -479,12 +479,23 @@ export async function previewCrossDesigns(
routeId: number,
edits: ProfileAlignmentEdits,
standardCrossSection?: StandardCrossSection,
options?: {
/** true면 설계 전체(설계선 좌표 포함)를 받는다 — B06 진입 시 stale 일괄 재계산용. */
fullDesigns?: boolean;
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
rockBoundaryOffsets?: Record<string, number>;
},
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
`/projects/${projectId}/sections/${routeId}/cross-design/preview`,
{
method: "POST",
body: JSON.stringify({ ...edits, standard_cross_section: standardCrossSection ?? null }),
body: JSON.stringify({
...edits,
standard_cross_section: standardCrossSection ?? null,
full_designs: options?.fullDesigns ?? false,
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
}),
},
);
}
@@ -507,12 +507,14 @@ def _recompute_designs_for_alignment(
cross_sections: list[dict[str, Any]],
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
) -> None:
"""새 계획선 기준으로 전 측점 횡단 설계를 다시 계산해 얹는다(메모리 프리뷰).
사용자가 이미 고른 지반유형·단면유형·측구·암 경계는 **그대로 유지**하고 계획고만
새 선형 값으로 바꿔 넣는다 — 계획선을 끌었다고 측점 선택이 초기화되면 안 된다.
지정이 없는 측점은 기본값(리핑암 + 암반 경계 0.5m + 상단측 절토)으로 계산한다.
`rock_boundary_offsets`(B06 세션값)가 오면 DB 저장분보다 우선한다.
"""
default_modes = _default_section_modes(longitudinal)
pavement = _pavement_suggestions(longitudinal)
@@ -520,11 +522,21 @@ def _recompute_designs_for_alignment(
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
}
# 세션 오프셋 키는 프론트 표기(소수 자릿수)가 제각각일 수 있어 수치로 정규화한다.
session_offsets: dict[float, float] = {}
for raw_key, offset in (rock_boundary_offsets or {}).items():
try:
session_offsets[round(float(raw_key), 3)] = float(offset)
except (TypeError, ValueError):
continue
for section in cross_sections:
chainage_m = float(section.get("chainage_m", 0.0))
key = round(chainage_m, 3)
stored = stored_by_chainage.get(key) or {}
suggested = pavement.get(key, False)
rock_offset = session_offsets.get(
key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M)
)
try:
design = compute_cross_design(
section.get("samples", []),
@@ -535,9 +547,7 @@ def _recompute_designs_for_alignment(
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=bool(stored.get("paved", suggested)),
standard=standard,
rock_boundary_offset_m=stored.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
rock_boundary_offset_m=rock_offset,
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
)
@@ -587,17 +597,22 @@ async def preview_cross_designs(
detail["cross_sections"],
designs,
request.standard_cross_section,
request.rock_boundary_offsets,
)
await asyncio.to_thread(rebuild)
# 계획고를 끄는 동안 오가는 값이라 **유토곡선이 실제로 읽는 필드만** 싣는다.
# 계획고를 끄는 동안 오가는 값이라 기본은 **유토곡선이 실제로 읽는 필드만** 싣는다.
# 지반선 샘플·설계선 좌표(design_line)·선형 구조를 다 담으면 1MB가 넘어 편집이 굼떠진다
# (2026-08-03 사용자 지적). 계획선 자체는 화면이 이미 같은 규칙으로 계산해 들고 있다.
# `full_designs`(B06 진입 시 stale 일괄 재계산)일 때만 설계 전체를 1회 싣는다 —
# 측점별 순차 호출 N번보다 왕복 1번이 훨씬 싸다(2026-08-04 사용자 확인).
return CrossDesignPreviewResponse(
designs=[
{
"chainage_m": section.get("chainage_m"),
"design": {
"design": section["design"]
if request.full_designs
else {
key: section["design"].get(key)
for key in _PREVIEW_DESIGN_FIELDS
if key in section["design"]
@@ -188,6 +188,12 @@ class CrossDesignPreviewRequest(BaseModel):
curve_radii: dict[str, float] = Field(default_factory=dict)
# B06 설정 패널 편집값(있으면 그 표준단면으로 계산).
standard_cross_section: dict[str, Any] | None = None
# True면 설계 전체(설계선 좌표 포함)를 돌려준다 — B06 진입 시 stale 설계 일괄 재계산용.
# B05 편집 프리뷰(기본 False)는 유토곡선 필드만 받아 응답을 가볍게 유지한다.
full_designs: bool = False
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
rock_boundary_offsets: dict[str, float] | None = None
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
@@ -159,6 +159,13 @@ export function createLongitudinalProfile(
* B05가 구조물·배관 위치를 그래프에서 바로 조정하는 데 쓴다(2026-08-01 사용자 지시).
*/
onDragStation?: (stationId: string, toChainageM: number) => void,
/**
* X축·측점 라벨을 그래프 바닥에서 이만큼(px) 더 위로 올린다(기본 0 = 현행).
* B05는 계획고 편집 오버레이의 ▼ 버튼이 바닥(높이 15px + 여백 2px)에 붙어 있어
* 라벨과 겹친다 — 버튼은 그대로 두고 라벨 띠만 밀어 올린다(2026-08-04 사용자 지시).
* LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다.
*/
bottomInsetPx = 0,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
@@ -190,7 +197,9 @@ export function createLongitudinalProfile(
// 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아,
// 0측점·종점이 좌우 끝에 딱 붙지 않고 반 칸씩 안으로 들어오게 한다.
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right - 2 * originOffsetPx;
const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom;
// X축·라벨을 바닥에서 밀어 올리는 몫까지 합친 실효 bottom 여백 — 아래 모든 y 계산의 기준.
const padBottom = LONG_PAD.bottom + bottomInsetPx;
const plotHeight = heightPx - LONG_PAD.top - padBottom;
const elevationSpan = yScaleOptions
? plotHeight / yScaleOptions.pixelsPerMeter
: Math.max(rawMax - rawMin, 1);
@@ -244,7 +253,7 @@ export function createLongitudinalProfile(
x1: x(segment.start_chainage_m),
y1: LONG_PAD.top,
x2: x(segment.start_chainage_m),
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - padBottom,
class: "b06-chart__balance-boundary",
}),
);
@@ -277,21 +286,21 @@ export function createLongitudinalProfile(
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - padBottom,
class: "b06-chart__station-hit",
}),
svgElement("line", {
x1: stationX,
y1: LONG_PAD.top,
x2: stationX,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - padBottom,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
x: stationX,
y: heightPx - 5,
y: heightPx - bottomInsetPx - 5,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
@@ -303,7 +312,7 @@ export function createLongitudinalProfile(
structureLabels.push(
svgText(station.structure, {
x: stationX,
y: heightPx - LONG_PAD.bottom - 5,
y: heightPx - padBottom - 5,
"text-anchor": "middle",
class: "b06-chart__structure-label",
}),
@@ -333,16 +342,16 @@ export function createLongitudinalProfile(
svgElement("polyline", { points, class: "b06-chart__profile" }),
svgElement("line", {
x1: LONG_PAD.left,
y1: heightPx - LONG_PAD.bottom,
y1: heightPx - padBottom,
x2: widthPx - LONG_PAD.right,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - padBottom,
class: "b06-chart__axis",
}),
svgElement("line", {
x1: LONG_PAD.left,
y1: LONG_PAD.top,
x2: LONG_PAD.left,
y2: heightPx - LONG_PAD.bottom,
y2: heightPx - padBottom,
class: "b06-chart__axis",
}),
// 그래프 이름표는 플롯 **안쪽 상단에 겹쳐** 놓는다(2026-08-02 사용자 지시) — 위쪽 여백을
@@ -23,11 +23,13 @@ import {
type CrossSectionPatch,
fetchSectionContext,
getSections,
previewCrossDesigns,
regenerateSections,
type SectionContextResponse,
type SectionDetailResponse,
type StandardCrossSection,
} from "./B06_wf3_ProfileCross_Api_Fetch";
import { readAlignmentDraft } from "../B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit";
import {
type CrossDesignChange,
createSectionView,
@@ -198,10 +200,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
* 호출이 없다. 측점별 버튼 선택값은 `changeFromDesign()` 경유로 보존한다.
* 호출이 없다.
*
* 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인
* — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼
* 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선),
* 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형·
* 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다.
*/
async function reconcileStaleDesigns(): Promise<void> {
if (!sectionDetail) return;
if (!sectionDetail || !projectId || currentRouteId === null) return;
const profiles = sectionDetail.longitudinal.design_profiles;
const stale = sectionDetail.cross_sections.filter((section) => {
const design = section.design;
@@ -213,10 +221,39 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!stale.length) return;
showLoadingOverlay();
try {
for (const section of stale) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = readAlignmentDraft(currentRouteId) ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
const response = await previewCrossDesigns(
projectId,
currentRouteId,
edits,
standardPanel?.getValues(),
{ fullDesigns: true, rockBoundaryOffsets: Object.fromEntries(rockOffsets) },
);
const designByChainage = new Map(
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of sectionDetail.cross_sections) {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (next) {
// full_designs 응답은 설계 전체(설계선 좌표 포함)라 통째로 교체한다.
section.design = next as typeof section.design;
sectionView.refreshCard(section.chainage_m);
}
}
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
} finally {
hideLoadingOverlay();
}
+13 -5
View File
@@ -116,10 +116,15 @@ export function formatVolume(value: number): string {
}
/**
* Y축 상·하한을 잡는다. 0선은 항상 보이도록 범위에 포함하고, 위아래로 5% 여유를 준다.
* 값이 전부 0이면 폭이 0이 되어 나눗셈이 깨지므로 최소 폭을 씌운다.
*
* 범위는 **표시 중인 곡선이 아니라 전체 곡선**에서 잡는다(축 고정).
* Y축 기본 스케일(㎥). 계획선을 편집할 때마다 범위가 데이터로 다시 잡히면 그래프가
* 출렁여 변화를 읽기 어렵다(2026-08-04 사용자 확정) — 기본 ±200㎥으로 고정하고,
* 곡선이 이 범위를 벗어날 때만 그 방향으로 넓힌다.
*/
const VOLUME_RANGE_BASE_M3 = 200;
/**
* Y축 상·하한을 잡는다. 기본 −200~+200㎥ 고정(0선 항상 포함), 곡선이 넘치는 쪽만
* 데이터에 5% 여유를 더해 확장한다. B05·B06이 같은 함수를 쓰므로 두 화면이 함께 고정된다.
*/
function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
let rawMin = 0;
@@ -129,7 +134,10 @@ function volumeRange(series: MassHaulSeries[]): { min: number; max: number } {
rawMax = Math.max(rawMax, entry.result.max_cumulative_m3);
}
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
return { min: rawMin - padding, max: rawMax + padding };
return {
min: Math.min(-VOLUME_RANGE_BASE_M3, rawMin - padding),
max: Math.max(VOLUME_RANGE_BASE_M3, rawMax + padding),
};
}
/**
+1
View File
@@ -49,6 +49,7 @@ export const ui_locales_common = {
Common_Msg_RequiredField: ["필수 입력 항목입니다", "This field is required"],
Common_Msg_InvalidValue: ["올바르지 않은 값입니다", "Invalid value"],
Workflow_Progress_Title: ["진행단계", "Progress"],
Workflow_Step_Dashboard: ["대시보드", "Dashboard"],
Workflow_Overlay_Collapse: ["패널 접기", "Collapse panel"],
Workflow_Overlay_Expand: ["패널 펼치기", "Expand panel"],
@@ -62,6 +62,14 @@
cursor: pointer;
}
/* 대시보드 이동 버튼 — 워크플로 단계 목록의 탈출구. 단계 버튼과 구분되게 아래 구분선을 둔다. */
.ui-workflow-layout__step--home {
margin-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.ui-workflow-layout__step.is-enabled:hover {
background: var(--color-surface);
}
@@ -1,6 +1,8 @@
import "./ui_template_workflow_layout.css";
import { ROUTES } from "@config/config_frontend";
import { t } from "./ui_template_locale";
import { createWorkflowOverlays } from "./ui_template_overlay";
import { navigateTo } from "../A00_Common/router";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
let dashboardRolePromise: Promise<string> | undefined;
@@ -42,6 +44,8 @@ export interface StepBarOptions {
orientation?: "horizontal" | "vertical";
icons?: readonly string[];
onStepClick?: (stepIndex: number, route: string) => void;
/** 목록 최상단에 대시보드 이동 버튼을 단다(진행단계 오버레이 전용). */
homeButton?: boolean;
}
export const WORKFLOW_STEP_ICONS = ["📁", "🗺️", "🛣️", "📐", "🏗️", "∑", "📄"] as const;
@@ -55,6 +59,24 @@ export function createStepBar(
bar.className = "ui-workflow-layout__steps";
if (options?.compact) bar.classList.add("is-compact");
if (options?.orientation === "vertical") bar.classList.add("is-vertical");
// 진행단계 오버레이(세로형) 최상단에 대시보드로 돌아가는 버튼을 둔다(2026-08-04 사용자
// 지시). 워크플로 단계가 아니라 탈출구라 stage 배지 없이 항상 활성이다.
if (options?.homeButton) {
const home = document.createElement("button");
home.type = "button";
home.className = "ui-workflow-layout__step is-enabled ui-workflow-layout__step--home";
const icon = document.createElement("span");
icon.className = "ui-workflow-layout__step-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = "🏠";
const label = document.createElement("span");
label.className = "ui-workflow-layout__step-label";
label.textContent = t("Workflow_Step_Dashboard");
home.append(icon, label);
home.title = t("Workflow_Step_Dashboard");
home.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT));
bar.append(home);
}
steps.forEach((step, index) => {
const button = document.createElement("button");
button.type = "button";
@@ -141,6 +163,7 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
orientation: "vertical",
icons: WORKFLOW_STEP_ICONS,
onStepClick: options.onStepClick,
homeButton: true,
});
const overlays = createWorkflowOverlays({
title: options.title,