Files
Aislo/A00_Common/design/design_profile.ts
T
eomsangdonandClaude Opus 5.5 972baf989d fix(b05): [3D 업데이트] = 낡은 횡단 설계부터 다시 셈한 뒤 예상형상 · 그린 뒤 생긴 낡음도 다시 셈(같은 낡음은 한 번) (317-1 · 요구 213)
- 공용 낡음 지문 staleKey(Section_Common) · 그래프가 그릴 때 잰 지문을 패널에 넘김(Render onStaleDesigns)
- 패널 = 새 지문일 때만 다시 셈 예약(Preview scheduleIfStale · 서버 폴백 반복 막음)
- 단추 refreshCorridor = 짓기 전 refreshStaleDesigns(세션 계획선 초안 → 저장분 · B06 과 같은 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:17:05 +09:00

701 lines
33 KiB
TypeScript

/* =============================================================================
* A00_Common/design/design_profile.ts
* 종단도 한 벌(계획서 24-1 · 24-2c) — 종단 보기 · 횡단 보기가 **같은 종단도**를 가운데
* 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 ·
* 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나.
*
* 그래프와 도면 테이블이 **하나의 가로 스크롤러** 안에 같은 폭으로 쌓여 X축이 자동으로
* 맞물린다. 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [저장]·[확정]
* 시점에 `saveProfileAlignment()`로 편집 델타만 보낸다.
*
* 판 자리 약속(side · overlay)은 `design_profile_slots.ts` 에 있고 여기서 다시 내보낸다.
* ========================================================================== */
import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drainage_Spans";
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import {
hasLegacyAlignment,
readAlignment,
toDesignProfile,
} from "../../B05_Profile/B05_Profile_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { hasStaleDesigns } from "../../B06_Section/B06_Section_UI_Section_Common";
import {
blocksMinCover,
findMinCoverViolations,
minCoverPoints,
type MinCoverPipe,
type MinCoverPoint,
} from "../../B05_Profile/B05_Profile_UI_Profile_MinCover";
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
emptyEdits,
toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
structureGroupMenuItems,
stationIdAtStructure as stationIdAtStructureOf,
structureIdKeeping,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
import {
irregularStationId,
type IrregularStation,
} from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import { createCrossPreview } from "../../B05_Profile/B05_Profile_UI_Profile_Preview";
import { createPanelTools } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Tools";
import { createProfileZoom } from "../../B05_Profile/B05_Profile_UI_Profile_Zoom";
import {
adoptLegacyProfileKeys,
bindProfileCollapse,
createElevationHold,
createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import { planCurveWarning, sampleAt } from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import {
irregularGraphStations,
maxChainageOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
import "../../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
export type {
DesignOverlayBoard,
DesignSideBoard,
DesignSideFactory,
DesignSideHost,
DesignView,
} from "./design_profile_slots";
/** 종단도를 만들 때 넘기는 것. */
export interface DesignProfileContext {
projectId: string;
/** 처음 보기. */
view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory;
/** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
toolLead?: HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
onResetAll?: () => void;
callbacks?: RouteProfilePanelCallbacks;
}
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다.
* 횡단 보기는 120ms — 계산마다 카드를 갈아 끼워 길게 누르면 멈춤이 쌓임(9da48d5e7 · 54-22). */
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
export function createDesignProfile(ctx: DesignProfileContext) {
const { projectId, onSelectStation, onResetAll, callbacks } = ctx;
adoptLegacyProfileKeys();
const root = document.createElement("section");
root.className = "b05-route-profile";
root.dataset.view = ctx.view;
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(2026-08-04 사용자 지시).
const panelHandle = createWorkflowPanelHandle("bottom", "down", "종단");
const balanceBar = document.createElement("div");
balanceBar.className = "b05-route-profile__balance";
const body = document.createElement("div");
body.className = "b05-route-profile__body";
const empty = document.createElement("p");
empty.className = "b05-route-profile__empty";
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
body.append(empty);
// 종단면 본문 + 우측 판(배수유역도)을 나란히 놓는 2단 구성.
// 우측 판이 이 안에 있으므로 하단 패널을 접으면 함께 사라진다(사용자 지시).
// 그래프 영역 정중앙 로딩 서클 — 종단면 자료가 도착할 때까지 빈 안내만 보인다.
// body는 그릴 때마다 자식이 통째로 교체되므로 서클은 감싸는 칸에 둔다.
const progress = createProgressCircle({ overlay: true });
progress.root.hidden = true;
const bodyWrap = document.createElement("div");
bodyWrap.className = "b05-route-profile__body-wrap";
// 절토·성토 요약 줄은 **종단 쪽 열 안**에 둔다 — 패널 전체 폭에 걸치면 우측 배수유역도
// 구분선이 그 높이만큼 끊겨 보인다(2026-08-04 사용자 보고).
bodyWrap.append(balanceBar, body, progress.root);
// 유토곡선 — 패널 바닥에 붙는 오버레이 서브패널(2026-08-04 사용자 확정).
// 종단도·테이블 배치는 건드리지 않고 그 위를 덮으며, 위 경계 리사이저로 높이를 조절한다.
// 서브패널 리사이저를 끄는 동안엔 메인 드래그와 같은 **경량 동기화**만 프레임마다 돌리고
// (차트 SVG·테이블 재생성은 무거워 덜컹였다 — 2026-08-06 분석 원인 3), 전체 재구성은
// 손을 뗀 뒤(clearDragFlags) 1회만 한다. 펼침·범례 토글 등 드래그 밖 알림은 전체 draw.
const subPanelChanged = (): void => {
if (drag.isSubDragging()) scheduleLightSync();
else draw();
};
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
const { massHaul, tableOverlay } = createProfileOverlays({
projectId,
routeId: () => routeId,
bodyWrap,
body,
onChange: subPanelChanged,
});
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
let minCoverTargets: MinCoverPoint[] = [];
/** 최소고를 편집에서 강제할지 — 종단 보기 「페이지 설정」 체크박스를 따라온다(기본 해제).
* 두 보기가 같은 값을 보도록 세션 한 칸에 비춰 둔다. */
let enforceMinCover = readState<boolean>("profile-enforce-min-cover", projectId) === true;
/** 관 목록이 바뀌었다 — 최소 흙덮기 판정 대상을 다시 잡는다(판 · 보기 어느 쪽에서 와도). */
const takePipes = (pipes: ReadonlyArray<MinCoverPipe>): void => {
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
minCoverTargets = minCoverPoints([...pipes]);
renderBalance();
};
// 오른쪽 판(배수유역도) — 판 쪽이 만들어 넘긴다(판 자리 약속 `DesignSideFactory`).
const drainagePanel: DesignSideBoard | null =
ctx.side?.({
projectId,
view: ctx.view,
onPipesChanged: (pipes) => {
takePipes(pipes);
callbacks?.onPipesChanged?.(pipes);
},
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
// 배수유역도 우클릭 빈 자리 = 사이드 패널과 같은 군 → 종류 목록(129-1 · addAt 경로).
structureMenuItems: (chainage) =>
structureGroupMenuItems(callbacks?.structureGroups?.() ?? [], (typeId) =>
callbacks?.onStructureTypeAdd?.(chainage, typeId),
),
}) ?? null;
content.append(bodyWrap);
if (drainagePanel) content.append(drainagePanel.root);
const drag = createProfileDrag({
root,
body,
massHaul,
tableOverlay,
draw: () => draw(),
});
root.append(panelHandle.root, drag.resizerRoot, content);
drainagePanel?.load(projectId);
let detail: SectionDetailResponse | null = null;
let selectedStationId: string | null = null;
let stationInterval: number | undefined;
let routeId: number | null = null;
let irregularStations: IrregularStation[] = [];
// 구조물 정본(structures.json) 목록과 타입 레지스트리 — 그래프 서클마크·벌룬에 쓴다.
let structures: StructureInstance[] = [];
let structureTypes: StructureType[] = [];
let selectedStructureId: string | null = null;
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
let storedBase: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
let savedEdits: AlignmentEdits = emptyEdits();
let redrawPending = false;
let lastWidth = 0;
let lastHeight = 0;
/** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 몫(700줄 한계). */
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
const structureIdAtStation = (stationId: string | null): string | null =>
structureIdKeeping(
stationId,
selectedStructureId,
irregularStations,
structures,
detail,
structureTypes,
);
const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
/**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 페이지에 알린다.
* 같은 자리 알약도 함께 켜고 끈다(2026-08-17 사용자 지시 — 둘은 한 구조물이다).
*/
function selectStation(stationId: string | null): void {
const next = stationId !== null && stationId === selectedStationId ? null : stationId;
selectedStationId = next;
selectedStructureId = structureIdAtStation(next);
draw();
onSelectStation(next);
}
function renderBalance(): void {
const minCoverViolations = findMinCoverViolations(
minCoverTargets,
(chainageM) => sampleAt(alignment, chainageM, "ground_elevation_m"),
(chainageM) => sampleAt(alignment, chainageM, "elevation_m"),
);
renderBalanceBar({
planCurve: planCurveWarning(alignment, detail),
minCoverViolations,
balanceBar,
tools: withToolLead(tools.render()),
trailing,
alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
edited: store.edited(),
hasIrregularStations: irregularStations.length > 0,
dirty: store.dirty(),
onResetAll: () => {
store.resetAll();
onResetAll?.();
},
});
}
/* 줌 조작구(가로 배율) — 상태를 종단도가 들고 있어 편집·재계산으로 다시 그려도 유지된다.
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
const profileZoom = createProfileZoom(() => draw());
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
return wrap;
};
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick, setListening } = createPanelTools({
root,
base: () => base,
alignment: () => alignment,
edits: () => store.edits(),
applyEdits: (next) => applyEdits(next),
selectedStationId: () => selectedStationId,
irregularStations: () => irregularStations,
stationIdOf: (station) => irregularStationId(station.id),
moveStation: (station, toChainageM) =>
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
// 판이 없는 보기는 관 자리를 되돌리기에 태우지 않는다.
drainage: drainagePanel ?? { pipeChainages: () => [], setPipeChainages: () => undefined },
restore: () => {
// 세션이 정본이므로 편집 초안을 다시 읽어 그린다.
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
rebuild();
},
restoreSaved: () => store.restoreSaved(),
canRestoreSaved: () => store.canRestoreSaved(),
refresh: () => renderBalance(),
});
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
function applyEdits(next: AlignmentEdits): void {
if (!base || !alignment) return;
const candidate = buildAlignment(base, next);
if (
base.policy.grade_violation_policy === "block" &&
candidate.violations.length > alignment.violations.length
) {
showToast(
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
"error",
);
return;
}
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
store.replace(next);
history.record();
}
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 측점 + 지금 구조물 측점 · 272) — 표 · ▲▼ 가 옛 자리
* 구조물 측점(옮긴 관 · 서버가 다시 맞추기 전)을 안 봄 · 그래프 · 3D 와 같은 목록. 사라진 구조물
* 측점의 꼭짓점은 [저장] 때 서버와 같게 옮기거나 지움(`followMovedNodes`). 늘 저장분에서 셈. */
function listed(): AlignmentBase | null {
if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations;
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
const list = oneStationList(server, planted);
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
const gone = server
.filter((station) => station.kind === "irregular" && !station.grid)
.map((station) => station.chainage_m)
.filter((chainage) => !after.has(chainage.toFixed(3)));
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
return {
...storedBase,
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
})),
};
}
/**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다.
*/
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
scheduleCrossPreview();
if (redrawPending) return;
redrawPending = true;
frameOf(root, () => {
redrawPending = false;
draw();
});
}
/** 횡단 설계 프리뷰는 `_Profile_Preview` 몫(700줄 한계). */
const crossPreview = createCrossPreview({
projectId,
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
onApplied: (updated) => {
draw();
callbacks?.onCrossDesignsUpdated?.(updated);
},
});
const scheduleCrossPreview = (): void => crossPreview.schedule();
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
*
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
*/
const heights = createHeightCascade({
root,
body,
massHaul,
tableOverlay,
isMainDragging: drag.isMainDragging,
isSubDragging: drag.isSubDragging,
mainDragRef: drag.mainDragRef,
isMainDragCooldown: drag.isMainDragCooldown,
hasDetail: () => detail !== null,
});
const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } =>
heights.apply(allowGrow);
const scheduleLightSync = (): void => heights.scheduleLightSync();
function draw(): void {
renderProfile({
onStaleDesigns: crossPreview.scheduleIfStale, // 그린 뒤 생긴 낡음도 다시 셈(317-1)
body,
callbacks,
massHaul,
tableOverlay,
store,
detail: () => detail,
alignment: () => alignment,
base: () => base,
stationInterval: () => stationInterval,
irregularStations: () => irregularStations,
minCoverTargets: () => minCoverTargets,
structures: () => structures,
structureTypes: () => structureTypes,
selectedStationId: () => selectedStationId,
setSelectedStationId: (value) => {
selectedStationId = value;
},
selectedStructureId: () => selectedStructureId,
setSelectedStructureId: (value) => {
selectedStructureId = value;
},
stationDisplay: () => stationDisplay,
setLastSize: (width, height) => {
lastWidth = width;
lastHeight = height;
},
renderBalance,
applyHeightCascade,
clearMainDragCooldown: drag.clearMainDragCooldown,
selectStation,
applyEdits,
handleToolPick,
zoom: profileZoom.state,
holdElevationRange: elevationHold.hold,
toolActive: () => tools.mode() !== "none",
tools,
stationIdAtStructure,
redraw: draw,
setElevationWindowUpdater: (update) => {
updateElevationWindow = update;
},
});
}
/** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
const reveal = createSelectionReveal(body);
const revealPick = (): void =>
reveal(
(selectedStationId ?? selectedStructureId)
? `${selectedStationId}|${selectedStructureId}`
: null,
);
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
const unbindViewport = bindProfileViewport({
body,
editing: () => elevationHold.editing(),
fitElevation: () => updateElevationWindow?.() ?? null,
draw: () => draw(),
lastSize: () => ({ width: lastWidth, height: lastHeight }),
scheduleLightSync,
scheduleDraw: () => drag.scheduleDraw(),
});
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void {
drainagePanel?.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
}
return {
root,
/** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */
setView(next: DesignView) {
root.dataset.view = next;
drainagePanel?.setView?.(next);
},
render(nextDetail: SectionDetailResponse, nextStationInterval?: number, nextRouteId?: number) {
detail = nextDetail;
stationInterval = nextStationInterval;
const stored = readAlignment(nextDetail.longitudinal);
// 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다.
// 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭).
const routeChanged = nextRouteId !== routeId;
const carried = routeChanged && store.edited() ? store.edits() : null;
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (routeChanged || !store.dirty()) {
routeId = nextRouteId ?? routeId;
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
}
storedBase = stored ? toAlignmentBase(stored) : null;
base = listed();
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
configureBalloonOffsets(
`${projectId}:${routeId ?? "-"}`,
nextDetail.balloon_offsets ?? undefined,
);
draw();
frameOf(root, draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
// (2026-09-03 사용자 보고 — 편집 측점 2개가 세션에 남은 상태에서 재현).
if (
hasStaleDesigns({
longitudinal: {
design_profiles: alignment
? [toDesignProfile(alignment, nextDetail.longitudinal.design_profiles?.[0])]
: nextDetail.longitudinal.design_profiles,
},
cross_sections: nextDetail.cross_sections,
})
) {
scheduleCrossPreview();
}
},
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
alignmentSamples: () => alignment?.samples ?? null,
/**
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
* 페이지가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.
*/
setEarthworkContext(next: RouteMassHaulContext | null) {
massHaul.setContext(next);
draw();
},
setSelectedStation(stationId: string | null) {
selectedStationId = stationId;
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
selectedStructureId = structureIdAtStation(stationId);
draw();
revealPick();
},
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
enforceMinCover = enforce;
writeState("profile-enforce-min-cover", enforce, projectId);
},
/** 판 없이 관 목록을 넘긴다 — 판이 없는 보기에서 최소 흙덮기를 판정하는 길. */
setMinCoverPipes(pipes: ReadonlyArray<MinCoverPipe>) {
takePipes(pipes);
},
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
setGradeLimit(maxGradePct: number) {
if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
const policy = { ...base.policy, max_grade_pct: maxGradePct };
storedBase = { ...storedBase, policy };
base = { ...base, policy };
alignment = buildAlignment(base, store.edits());
draw();
},
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
drainagePanel?.setRoute(points);
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = stations;
base = listed();
if (base) alignment = buildAlignment(base, store.edits());
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
setStructureTypes(types: StructureType[]) {
structureTypes = types;
syncDrainageSpans();
draw();
},
/** 구조물 정본 목록을 반영해 그래프 서클마크를 다시 그린다. */
setStructures(next: StructureInstance[]) {
structures = next;
if (selectedStructureId && !next.some((s) => s.structure_id === selectedStructureId)) {
selectedStructureId = null;
}
syncDrainageSpans();
draw();
},
/** 고른 구조물에 알약 · 측점선을 맞춤 — 그 자리면 선 그대로(53-2) · keepStation(B06 카드 = 선 주인) = 선 없는 구조물 · 풂도 카드 선 지킴(177-3). */
setSelectedStructure(structureId: string | null, keepStation = false) {
selectedStructureId = structureId;
const own = stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null);
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : own;
draw();
revealPick();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) {
stationDisplay = next;
draw();
},
/**
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/
resetStationEdit(chainageM: number, moveTo?: number) {
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
const edits = store.edits();
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
);
if (key !== undefined) store.resetStation(Number(key), moveTo);
},
isDirty: () => store.dirty(),
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
if (!routeId || !store.dirty()) return;
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
draw();
},
clear() {
detail = null;
base = null;
storedBase = null;
alignment = null;
selectedStationId = null;
balanceBar.replaceChildren();
body.replaceChildren(empty);
},
/** 밖에서 바뀐 횡단 면적(카드 갱신)을 유토곡선 · 뱃지에 다시 싣는다. */
redraw: () => draw(),
/** 오른쪽 판(배수유역도) — 판 없이 만들었으면 null. */
side: drainagePanel,
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
setLoading(label: string | null) {
progress.root.hidden = label === null;
if (label !== null) progress.set(null, label);
},
/** 보기가 숨으면 false — 창 키 듣기를 뗀다. 다시 보이면 잇고, 다른 보기가 고친 계획고 초안
* (같은 가게) · 관(판)을 다시 읽어 그린다(계획서 24-3a). */
setActive(active: boolean) {
setListening(active);
if (!active) return;
if (base) alignment = buildAlignment(base, store.edits());
draw();
drainagePanel?.load(projectId);
},
dispose() {
setListening(false);
drag.dispose();
crossPreview.dispose();
unbindViewport();
},
};
}
/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */
export type DesignProfile = ReturnType<typeof createDesignProfile>;