Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit.ts
T
eomsangdonandClaude Opus 5.5 84e9151b7c feat(B05 평면): 예정노선 그리기 — 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선 다시 추적 = 편집기 한 걸음 → [저장] (PLAN 626-3 · 요구 530 · 531 · D3 · D4 · D9 · D10)
- 도구줄 「예정노선 그리기」 칸(공용 표 b05 · 연필 아이콘) → 떠 있는 판(단추 셋 + 공용 방식 칸) · 고치는 예정노선 맨 위 · 판 켠 동안 캔버스 누름을 먼저 받음(계획선 · 구조물 · 등고선 고르기 · Shift 재기 · 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠 그대로)
- 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab · Enter · Esc · Backspace(셈 `logic/B05_Profile_ExpectedDraw` · 그림 D03 `paintPreview` 그대로)
- 끝 → 확인창(직접 고친 평면 버림) → `POST /route/follow`(저장 0) → `restoreSnapshot(…, true)` 한 걸음(되돌리기 됨) · 지움 = 파일입력 예정노선으로 다시 추적
- 걸음 사진 · 노선 초안에 `expected`(같은 선 판정 · 세션 되살림 · 페이지 되돌리기) · [저장] = 손 편집과 같은 길 + 초안 `expected`(null 안 실음 · [] 지움) · 다시 열면 `expected_edited`
- `/route/replan` · `as_initial` · `expected_route.csv` · 초기값 · 추천 저장본 안 건드림 · 시험 test_626_3_expected_edit(7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:58:15 +09:00

685 lines
31 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit.ts
* 계획노선 편집(평면) — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
*
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)이고 **페이지 수명**이다.
* 이력 · 키 듣기는 보기의 보임 · 숨김 · 떠남(`host`)을 따른다. [저장]이 노선을 보내면 배수유역부터
* 종·횡단·유토곡선까지 다시 돌고(무거움), 끝나면 부른 쪽이 새로고침 없이 페이지를 다시 세운다.
* **편집 중에는 아무 계산도 나가지 않는다**(띠 · 기울기 · 횡단 미리보기 묻기만).
*
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
*
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
* ========================================================================== */
import {
computeMapRect,
metricToScreen,
pickLevelStep,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
bindMinorContourToggle,
createSheetFollow,
fetchRouteSheets,
loadRouteEditBackground,
contourHintText,
type RouteEditContours,
type SheetLayers,
} from "./B05_Profile_UI_RouteEdit_Contour";
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
import {
fetchRoutePlan,
fetchTerrainSources,
type AnalysisTerrain,
} from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import type { EditedCurve, EditedNode } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, routeHeadText, watchTheme } from "./B05_Profile_UI_RouteEdit_Render";
import { bindRouteEditMouse } from "./B05_Profile_UI_RouteEdit_Mouse";
import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import { createExpectedDraw } from "./B05_Profile_UI_ExpectedDraw";
import { createRouteMeasure } from "./B05_Profile_UI_RouteEdit_Measure";
import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope";
import {
captureView,
createViewMemory,
fitCanvas,
openingView,
restoreSavedView,
screenToMetric,
viewPxPerMeter,
} from "./B05_Profile_UI_RouteEdit_View";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
lockColumn,
readRouteLocks,
routeLocksOf,
writeRouteLocks,
} from "./B05_Profile_UI_RouteEdit_Lock";
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
import * as edits from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
bindHistoryControls,
createRouteEditHistory,
lineChanged,
type RouteEditHistory,
type RouteEditSnapshot,
} from "./B05_Profile_UI_RouteEdit_History";
import type { DesignPageHost } from "../A00_Common/design/design_page";
import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft";
import { bindStationSnap, paintStationSnap } from "./B05_Profile_UI_RouteEdit_SnapStep";
import * as fit from "./logic/B05_Profile_RouteEdit_StationFit";
import { calcChecked } from "../A00_Common/design/design_calc_server";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number];
/** 편집기 곁값. 노선 저장이 끝나면 `onApplied`를 부른다(화면 다시 세우기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
surfaceModelId?: number | null;
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number;
smooth?: boolean;
/** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */
tools?: HTMLElement[];
draft?: draftLink.RouteDraftBridge; // 계산 묶음 다리(346-1 · `_Draft`)
}
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
export type RouteEditHost = Pick<DesignPageHost, "visible" | "onShow" | "onHide" | "onDispose">;
/** 편집기를 `parent` 에 세운다 — 자료를 읽고 나면 끝난다(떠날 때 정리는 `host.onDispose`). */
export async function mountRouteEditor(
parent: HTMLElement,
host: RouteEditHost,
projectId: string,
onApplied: () => void | Promise<void>,
options: RouteEditOptions,
): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome(parent, projectId, options.tools, host);
const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome;
const { minorContourButton, stationSnapButton } = chrome;
chrome.onRedraw(() => draw()); // 겹 켬끔 · 추천 보기 = 장면 새로(624-3)
const context = canvas.getContext("2d")!;
/** 예정노선 그리기(626-3) — 고친 예정노선 따라 평면 다시 추적 = 편집기 한 걸음 · 배선은 `_ExpectedDraw` 몫. */
const expectedDraw = createExpectedDraw({
projectId,
canvas,
button: chrome.expectedDrawButton,
host: overlay.querySelector<HTMLElement>(".b05-routeedit__tools")!,
toScreen: (vertex) => toScreen(vertex),
toMetric: (px, py) => toMetric(px, py),
unrotate: (px, py) => rotation.unrotate(px, py),
pxPerMeter: () => pxPerMeter(),
uprightRad: () => rotation.uprightRad(),
redraw: () => draw(),
onOpen: () => ((picked = -1), syncCurveBar()),
onFollow: (flat, line) =>
restoreSnapshot(
{ ...flat, apexLock: [], picked: -1, rec: initial.tag(), expected: line },
"예정노선을 따라 평면을 다시 추적했습니다.",
true,
),
});
let expected: Vertex[] = [];
/** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */
let plannedLine: Vertex[] = [];
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
let planned: Vertex[] = [];
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: EditedNode[] = [];
let minRadiusM = 0;
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
let limitRadiusM = 0;
let limitArcM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: EditedCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
let curveOn: boolean[] = [];
let curveRadius: Array<number | null> = [];
/** 붙들어 둔 값 — 무엇을 고정할지와, 길이를 고정했을 때의 그 길이(m).
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
let curveLock: edits.CurveLock[] = [];
let curveArc: Array<number | null> = [];
/** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점
* (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다.
* 고정 한 벌의 저장 · 읽기는 `_Lock` 몫. */
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
/** [측점 스냅](224-2 · 366-3) — 켬 = 곡선 MC 를 고른 간격 배수에 · 켬끔 자체는 되돌리기 밖 · [저장] 때 저장. */
let stationSnap = false;
/** 간격 · 저장 비교 · 편집 창 측점(끔이면 null · 그리기 · 누름 · 기울기가 봄)은 `_SnapStep` 몫(390-2 · 5). */
const snap = bindStationSnap(chrome, projectId, stationIntervalM, (toggle) => {
if (toggle) setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit(snap.text()); // 전체 · 한 걸음
tellDraft(); // 끔 · 간격 바꿈도 계산 줄에 바로(430)
draw();
});
const stations = () => snap.stations(plannedLine.length ? plannedLine : planned, curveInfo);
const setStationSnap = (on: boolean) => paintStationSnap(stationSnapButton, (stationSnap = on));
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
let meta: VWorldMeta | null = null;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 지금 설계 지형 — 편집 분석이 쓰는 지형 · 바꾸기는 도구줄 지형 칩(626-1 · `_Terrain`). */
let designTerrain: AnalysisTerrain = "las";
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */
const contourLock = createContourLock(
canvas,
() => contours && { ...contours, view, rotation, stepM: contourStepM(), picked: pickedContour },
(index) => ((pickedContour = index), draw()),
);
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
request: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
station_snap: stationSnap,
}),
// 횡단이 뜨면 그 측점 반폭 선을 그린다 — 새로 고침 뒤 고른 측점은 만드는 도중 불려 아래 선언이 아직이라 한 박자 뒤에(505-4).
onShown: () => queueMicrotask(draw),
});
/** 구간 재기 — 계획·예상 노선 위 두 점 · 재기 단추·작은 창. 셈·서버 묻기 · 배선은 `_Measure` 몫(계획서 0-9 ⑤). */
const { measure, measureControls } = createRouteMeasure({
projectId,
stationIntervalM,
lines: () => ({
planned: plannedLine.length ? plannedLine : planned,
expected: expectedDraw.line(),
}),
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
box: measureBox,
text: measureText,
button: measureButton,
closeButton: overlay.querySelector<HTMLElement>(".b05-routeedit__measure-close")!,
onShown: () => {
if (picked < 0) return;
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
syncCurveBar();
},
redraw: () => draw(),
});
let view: ViewState = {
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: computeMapRect(null, 0, 0),
};
let closed = false;
/** 놓은 뒤 노선이 도엽 범위 밖이면 다시 받음(508-2 ③) — 자료를 읽은 뒤 섬. */
let sheetFollow: ReturnType<typeof createSheetFollow> | null = null;
/** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
const rotation = createMapRotation({
overlay,
size: () => view,
onChange: () => {
syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다.
draw();
},
});
/** 측점 사이 기울기 라벨 — 높이 묻기·그리기는 `_Slope` 몫(계획서 18 ①). */
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
let viewReady = false;
const viewMemory = createViewMemory(projectId, () =>
meta && viewReady && view.width > 0 ? captureView(meta, view, rotation.radians()) : null,
);
/** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */
const dispose = (): void => {
unbindApply(); // 노선 저장 등록부에서 풂(445-2)
viewMemory.flush();
closed = true;
sheetFollow?.dispose();
sizeWatch.disconnect();
themeWatch();
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다.
curveBar.destroy();
overlay.remove();
};
/** 지금 추천 — [저장]에 실을 값 · 걸음 짝(계획서 20 · 추천 고르기는 걷음 626-1). */
const initial = createInitialPanel({ projectId });
function resize(): void {
if (closed || !overlay.isConnected) return;
// 크기가 바뀌어도 가운데 자리 · 배율을 지킨다 — px 이동량을 그대로 두면 지도가 밀려
// 노선이 화면 밖으로 나가고, 그 자리가 보던 화면으로 저장돼 다시 열어도 빈 화면이 된다.
const kept = meta && viewReady ? captureView(meta, view, rotation.radians()) : null;
const { width, height } = fitCanvas(canvas, context);
if (width <= 0 || height <= 0) return; // 숨은 동안(크기 0) — 화면 · 기억 그대로 · 보이면 다시 셈(349-0h).
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) };
if (!viewReady) openView(); // 숨은 채 섰으면(크기 0) 처음 보일 때 맞춘다.
draw();
}
// 칸 크기를 따라감 — 창 크기 · 아래 종단 판 끌기 · 좌측 접기 모두(345-3).
const sizeWatch = new ResizeObserver(() => resize());
sizeWatch.observe(overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!);
const themeWatch = watchTheme(() => draw()); // 밝음 · 어두움 바꿈 — 색 다시 읽어 그림(349).
let viewWanted = false; // 자료를 읽었다 — 칸 크기가 서면 보던 화면(18 ③) · 없으면 노선 전체로.
function openView(): void {
if (!viewWanted || !meta || view.width <= 0) return;
// B06 겹침 평면(436)에서 처음 보여도 B05 칸 크기로 셈 — 그 배율이 B05 평면에 남음(433-8).
const atHome = overlay.closest(".b05-route__main") !== null;
const page = atHome ? null : overlay.closest(".design-page__center");
const [w, h] = page ? [page.clientWidth, page.clientHeight] : [view.width, view.height];
const home = { ...view, width: w, height: h, mapRect: computeMapRect(meta, w, h) };
const opening = openingView(projectId, meta, home, planned, contours?.layer ?? null);
view = { ...view, ...restoreSavedView(meta, view, captureView(meta, opening.view, 0)) };
rotation.set(opening.rotationRad);
viewReady = true;
}
const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
/** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */
const toMetric = (px: number, py: number): Vertex =>
meta ? screenToMetric(meta, view, px, py) : [0, 0];
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
function draw(): void {
if (closed) return;
drawRouteEditScene(context, {
view,
toScreen,
pxPerMeter: pxPerMeter(),
hasMeta: meta !== null,
contours,
contourHidden: !chrome.layers.own().contour, // 624-3 겹 줄 등고선 끔
otherSheets,
pickedContour,
contourStepM: contourStepM(),
rotationRad: rotation.radians(),
uprightRad: rotation.uprightRad(),
minorContours: showMinorContours() ? (contours?.minor?.() ?? null) : null,
underRoute: (ctx) => chrome.preview.draw(ctx, toScreen), // 추천 노선 보기(626-1)
overStations: (ctx) =>
slope.draw(ctx, {
line: plannedLine.length ? plannedLine : planned,
intervalM: stationIntervalM,
pxPerMeter: pxPerMeter(),
toScreen,
uprightRad: rotation.uprightRad(),
dragging: pointer.dragging(),
chainages: stations(),
}),
measure: measure.marks(),
crossWidth: crossPreview.watched(),
expected: expectedDraw.line(),
overAll: (ctx) => expectedDraw.paint(ctx), // 예정노선 그리기 맨 위(626-3)
plannedLine,
planned,
nodeInfo,
curveInfo,
curveOn,
locked: apexLock,
picked,
stationIntervalM,
...snap.ticks(plannedLine.length ? plannedLine : planned, curveInfo),
});
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다.
}
/** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②).
*
* 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부
* 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다.
* 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때
* 서버가 정본으로 다시 내는 것은 그대로다. */
function markEdited(): void {
// 길이 고정 R → 지정값 하한(R·L · 내각 155° 이상은 R 만) 끌어올림 → 선(셈은 `_Edits` · 0-9 ④).
const built = edits.rebuildLine(
{ planned, curveOn, curveRadius, curveLock, curveArc },
fitLimits(),
);
plannedLine = built.vertices;
curveInfo = built.curves;
nodeInfo = built.nodes;
}
/** 상태줄 머리 · 고른 등고선 글 — 짓기는 `_Render` · `_Contour` 몫. */
const routeHead = (): string =>
routeHeadText(expectedDraw.line(), plannedLine.length ? plannedLine : planned, planned.length);
const contourHint = (): string => contourHintText(contours, pickedContour);
const offStation = (): number =>
stationSnap ? fit.offStationCount(plannedLine, curveInfo, snap(), stationIntervalM) : 0;
/** 상태줄 꼬리 · 측점 밖 곡선 수(441 막힘 · 432) — 셈은 `_Edits` · `StationFit` 몫. */
const curveHint = (): string =>
edits.curveSummary({
nodeCount: planned.length,
curveOn,
curveRadius,
curveLock,
apexLock: apexLock.slice(1, -1), // 시점 · 종점은 기본 고정이라 「고정 N곳」에 안 센다.
curveCount: curveInfo.length,
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
fresh: nodeInfo.length === 0,
offStation: offStation(),
flags: edits.routeFlags(planned, curveOn, curveInfo), // 겹친 노드 · 못 그린 곡선(430)
});
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
container: overlay,
state: () => ({
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
curveArc,
apexLock,
limitRadiusM,
limitArcM,
line: plannedLine,
snap: stationSnap ? { stepM: snap(), stationM: stationIntervalM } : null,
}),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
onUnselect: () => {
picked = -1;
syncCurveBar();
draw();
},
});
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다.
* 측점 스냅 켬이면 고친 자리 앞 곡선부터 MC 를 일반측점에(366-3 · 되살림 `fitting=false` 은 안 맞춤).
* 하한 검사 한 곳(430) — 지난 걸음이 지키던 하한을 새로 깨면 그 걸음으로 되돌리고 까닭을 상태줄에. */
function applyEdit(message: string, record = true, fitting = true): void {
const before = history?.current?.();
markEdited();
const stop = fitting && before ? edits.floorStop(before, planned, nodeInfo, fitLimits()) : "";
if (stop && before) return restoreSnapshot(before, stop, false);
// 계산 자리 fixed_browser = server 면 잠잠 뒤 서버 대조만(511-A)
if (stationSnap && fitting)
planned = calcChecked("fixed_browser", "route.station_fit", fit.fitAfter, [
before,
snapshotNow(),
fitLimits(),
]);
markEdited();
if (stationSnap && fitting && before && fit.pushedOff(before, snapshotNow(), fitLimits()))
return restoreSnapshot(before, "곡선 가운데를 측점에 못 맞춰 되돌렸습니다.", false); // 432
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
syncCurveBar();
status.textContent = `${routeHead()} — ${message} ${curveHint()}`;
draw();
if (record) history?.commit(snapshotNow());
historyControls.sync();
sheetFollow?.poke();
}
/** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */
const otherChanged = (): boolean => snap.changed();
function tellDraft(): void {
if (!history) return;
const dirty = lineChanged(history) || otherChanged();
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), snap.draft()) : null);
}
/** 측점 스냅 맞춤 값 — 하한 · 스냅 간격(390-2). */
function fitLimits(): fit.FitLimits {
return { minRadiusM, limitRadiusM, limitArcM, intervalM: snap(), stationM: stationIntervalM };
}
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
function snapshotNow(): RouteEditSnapshot {
return {
planned,
curveOn,
curveRadius,
curveLock,
curveArc,
apexLock,
picked,
rec: initial.tag(),
expected: expectedDraw.edit(),
};
}
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
// 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로.
const locks = record ? routeLocksOf(planned, apexLock) : null;
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
expectedDraw.setEdit(snapshot.expected ?? null); // 고친 예정노선도 그 걸음 것(626-3)
if (locks) apexLock = lockColumn(planned, locks);
applyEdit(message, record, false); // 되돌리기 · 추천 [적용] · 초안 되살림은 안 맞춤(366-3)
}
const historyControls = bindHistoryControls({
overlay,
getHistory: () => history,
restore: (snapshot, message) => {
initial.restoreTag(snapshot.rec); // 「현재 추천」도 그 걸음 것으로 — 노선과 짝.
restoreSnapshot(snapshot, message, false);
},
});
// ── 조작 — 노드 끌기 · 집기는 `_Pointer` 몫 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
// 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다.
const pointer = bindRouteEditPointer({
canvas,
status,
stationIntervalM,
stations,
state: () => ({
planned,
plannedLine,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
apexLock,
limitRadiusM,
limitArcM,
picked,
pickedContour,
contours,
view,
}),
setPicked: (index) => {
picked = index;
},
setPickedContour: (index) => {
if (contourLock.canPick()) pickedContour = index;
},
toScreen,
toMetric,
unrotate: rotation.unrotate,
contourStepM,
measure,
measureMode: () => measureControls.mode(),
crossPreview,
slopeRefresh: () => slope.refresh(),
markEdited,
syncCurveBar: () => syncCurveBar(),
draw,
routeHead,
curveHint,
contourHint,
commit: (index) => {
const before = history?.current?.()?.planned ?? []; // 놓을 때 맞춤(366-3) · 하한 · 보고(430)
applyEdit(stationSnap ? snap.text() : "옮겼습니다.");
if (stationSnap && index !== undefined) curveBar.noteFit(snap.text(), before, index);
},
snapStepM: snap,
fitDrag: (index) => {
const base = stationSnap ? history?.current?.() : undefined; // 누르기 전 노선 — 280-4 유지
if (base) planned = fit.fitDrag(snapshotNow(), base.planned, index, fitLimits(), base);
if (base) markEdited();
return base !== undefined;
},
offStation,
});
// 두 번 클릭 삽입 · 오른쪽 클릭 삭제 · 지도 이동 · 확대 · 돌리기 — 배선은 `_Mouse` 몫.
bindRouteEditMouse({
canvas,
rotation,
pointer,
toMetric,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
snapshot: snapshotNow,
setPicked: (index) => (picked = index),
applyEdit: (message) => applyEdit(message),
draw,
});
const unbindApply = bindRouteApply({
projectId,
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
changed: () => (!!history && lineChanged(history)) || otherChanged(),
snap: snap.draft,
expected: () => expectedDraw.edit(),
initial,
onApplied,
});
// 페이지 수명 — 보일 때만 키를 듣고 숨으면 뗌 · 떠나면 정리(design_page host · 345-3).
historyControls.listen(host.visible());
host.onShow(() => (historyControls.listen(true), resize()));
host.onHide(() => (historyControls.listen(false), viewMemory.flush()));
host.onDispose(dispose);
// ── 자료 읽기 — 노선 두 벌 + 노선 띠로 자른 도엽(509-1) ──
try {
const [plan, drainage, sources] = await Promise.all([
fetchRoutePlan(projectId),
fetchRouteSheets(projectId, () => (planned.length ? planned : plannedLine)), // 띠 = 편집 노드
fetchTerrainSources(projectId).catch(() => null),
]);
if (closed) return;
expected = plan.expected as Vertex[];
expectedDraw.load(expected, (plan.expected_edited ?? []) as Vertex[]); // 고친 예정노선 저장본(626-3)
plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
// 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
setStationSnap(snap.load(plan));
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = edits.flattenServerPlan(nodes, plan.curves ?? [], plan.node_edits);
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다.
({ nodes: nodeInfo, curves: curveInfo } = flat);
picked = -1;
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
initial.ready(snapshotNow(), plan.current_initial ?? null);
history = createRouteEditHistory(snapshotNow());
history = draftLink.watch(history, options.draft, snap.draft, otherChanged);
draftLink.restore(options.draft, restoreSnapshot);
historyControls.sync();
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
// 설계 지형 = 도구줄 지형 칩(626-1) — 없는 지형은 막힘 · 바꾸면 확인창 · 뒤 단계 다시 셈.
designTerrain = sources?.design ?? "las";
chrome.terrain.load(sources);
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
let sheets: SheetLayers = drainage;
const loadBackground = (reuse: RouteEditContours | null = null) =>
loadRouteEditBackground(
projectId,
sheets,
{
surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
},
reuse,
);
const useBackground = (background: Awaited<ReturnType<typeof loadBackground>>): void => {
sheets = background.sheets;
otherSheets = background.otherSheets;
contours = background.contours;
pickedContour = contourLock.reattach(contours?.layer ?? null);
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
minorContourButton.disabled = !contours?.minor;
minorContourButton.title = contours?.minor
? "1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)"
: "LAS 등고선이 없어 1m 선이 없습니다";
};
const showStatus = (): void => {
status.textContent =
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
`설계 지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"} · ` +
curveHint();
};
useBackground(await loadBackground());
if (closed) return;
sheetFollow = createSheetFollow(
projectId,
() => sheets,
async (next) => {
sheets = next;
const background = await loadBackground(contours);
if (!closed) (useBackground(background), draw());
},
);
viewWanted = true;
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
showStatus();
draw();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
}
}