Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into sub_laptop_5

This commit is contained in:
2026-10-06 12:40:50 +09:00
18 changed files with 308 additions and 240 deletions
+3 -2
View File
@@ -121,7 +121,8 @@ export interface DesignProfileContext {
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다.
* 횡단 보기는 120ms — 계산마다 카드를 갈아 끼워 길게 누르면 멈춤이 쌓임(9da48d5e7 · 54-22). */
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
export function createDesignProfile(ctx: DesignProfileContext) {
@@ -384,7 +385,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
onApplied: (updated) => {
draw();
callbacks?.onCrossDesignsUpdated?.(updated);
-2
View File
@@ -65,7 +65,6 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Corridor";
import {
profileSaveParts,
solveRouteAction,
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
@@ -238,7 +237,6 @@ export async function mountProfileView(
const panel = createRoutePanel(
{
onSolve: () => void solveRouteAction(actionContext),
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
onEditPlannedRoute: () =>
+2 -68
View File
@@ -1,21 +1,19 @@
/* =============================================================================
* B05_Profile_UI_Page_Actions.ts
* 종단 보기의 **버튼 동작** — [경로 계산] · [저장]·[확정]에 보태는 종단 보기 몫.
* 종단 보기의 **버튼 동작** — [저장]·[확정]에 보태는 종단 보기 몫. [경로 계산]은 `Old/` 로 옮김(54-24).
*
* 종단 보기 본체(`design_view_profile`)에서 떼어냈다(700줄 제한, 2026-09-02). 화면 조립·로딩
* 순서는 본체에 남고, 여기에는 영구저장소를 건드리는 갈래만 둔다(CLAUDE.md 5장: 세션에 쌓인
* 조작은 [저장]·[확정]에서만 정본으로 나간다). [초기화]는 한 벌(`design_reset`)이 한다.
* ========================================================================== */
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
import { confirmRoute, solveRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import {
invalidateSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
@@ -40,70 +38,6 @@ export interface PageActionContext {
restoreSections: (routeId: number) => Promise<void>;
}
/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */
export async function solveRouteAction(ctx: PageActionContext): Promise<void> {
const latest = ctx.latest();
const confirmedSurface = ctx.confirmedSurface();
if (!confirmedSurface || !latest) return;
const points = ctx.viewer().markers.getPoints();
if (!points.bp || !points.ep) {
showToast("BP와 EP를 지형에 배치하세요.", "error");
return;
}
const values = ctx.panel().values();
showLoadingOverlay();
try {
const solved = await solveRoute(ctx.projectId, {
filter_key: latest.surface_params.source_filter,
method: latest.surface_params.method,
smooth: latest.surface_params.smooth,
surface_model_id: confirmedSurface.model_id ?? undefined,
algorithm: values.algorithm,
bp: routePoint(points.bp),
ep: routePoint(points.ep),
cp: points.cp.map(routePoint),
ap: points.ap.map(circlePoint),
fp: points.fp.map(circlePoint),
grade_class: values.gradeClass,
paved: values.paved,
min_curve_radius_m: values.minCurveRadius,
max_uphill_grade: values.maxUphillGrade,
max_downhill_grade: values.maxDownhillGrade,
min_uphill_grade: values.minUphillGrade,
min_downhill_grade: values.minDownhillGrade,
allow_avoid_pass_through: values.allowAvoidPassThrough,
station_interval_m: values.stationInterval,
cross_half_width_m: null,
cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval,
terrain_type: values.terrainType,
design_speed_kph: values.designSpeed,
max_grade_pct: values.maxGradePct,
min_vertical_radius_m: values.minVerticalRadius,
min_tangent_length_m: values.minTangentLength,
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
enforce_pipe_clearance: values.enforcePipeClearance,
});
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
ctx.uphillOverrides.clear();
ctx.persistUphillOverrides();
ctx.renderLatest(await ctx.loadLatest(true));
await ctx.restoreSections(solved.route_id);
if (solved.cross_section_count === null) {
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
} else if (solved.grade_summary === null) {
showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error");
} else {
showToast("최적 경로 계산이 완료되었습니다.", "success");
}
} catch (error) {
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
/** [저장] · [확정]에 종단 보기가 보태는 몫 — 차례는 저장 한 줄기(`saveDesign`)가 정하고
* 한 벌(`design_shared`)이 두 보기 몫을 모아 한 번에 보낸다(계획서 24-2a · 24-3b). 관(배수유역도) ·
* 계획고는 한 벌 종단도가 낸다. */
@@ -90,15 +90,6 @@ export function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
};
}
export function routePoint(point: PlacedRoutePoint): RoutePoint {
// 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다.
return { x: point.x, y: point.y, z: point.z ?? undefined };
}
export function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/** 노선 폴리라인 위 chainage 지점의 (x, y) — 세그먼트 누적 길이로 정확히 되짚는다.
* 규칙 측점(20m 간격) 사이 직선보간은 곡선 구간에서 모서리를 잘라 3D 마커가 노선을
* 벗어난다(2026-08-19 사용자 보고 — 도엽 등고선 분석 좌표와 라이다 노선의 미세 차).
@@ -68,15 +68,7 @@ export function panelValuesOf(next: RouteLatestResponse): Partial<RoutePanelValu
const options = next.route_params?.options ?? {};
return {
contourInterval: next.surface_params.contour_interval_m,
algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined,
gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined,
paved: options.paved as boolean | undefined,
minCurveRadius: options.min_curve_radius_m as number | undefined,
maxUphillGrade: options.max_uphill_grade as number | undefined,
maxDownhillGrade: options.max_downhill_grade as number | undefined,
minUphillGrade: options.min_uphill_grade as number | undefined,
minDownhillGrade: options.min_downhill_grade as number | undefined,
allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined,
stationInterval: next.route_params?.station_interval_m ?? undefined,
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
-36
View File
@@ -31,7 +31,6 @@ import { createViewControls } from "./B05_Profile_UI_Panel_View";
export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
interface PanelCallbacks {
onSolve: () => void;
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
onEditPlannedRoute: () => void;
onContourApply: (interval: number) => void | Promise<void>;
@@ -78,7 +77,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
const { viewControls, setCorridorPending } = createViewControls(callbacks);
const settings = createSettingsPart(callbacks);
const route = createRoutePart(callbacks);
const { fields: routeFields } = route;
const {
contourInterval,
gradeClass,
@@ -93,16 +91,6 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
endElevationOffset,
enforcePipeClearance,
} = settings.fields;
const {
algorithm,
paved,
avoidPass,
minCurveRadius,
maxUphillGrade,
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
} = routeFields;
// 「구조물 배치」 — 두 보기가 같은 폼 한 벌을 쓴다(계획서 24-3b). 종단 보기가 보일 때 폼 알림을
// 받는 동작만 여기서 건다. 계곡 통과 시설(A군)은 관 지점 정본(배수유역도)과 연동한다.
@@ -120,15 +108,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
});
const inputElements = [
algorithm,
gradeClass,
paved,
avoidPass,
minCurveRadius,
maxUphillGrade,
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
crossSampleInterval,
longSampleInterval,
designSpeed,
@@ -160,15 +140,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
algorithm: algorithm.value as RoutePanelValues["algorithm"],
gradeClass: gradeClass.value as RoutePanelValues["gradeClass"],
paved: paved.checked,
minCurveRadius: parseOptional(minCurveRadius),
maxUphillGrade: parseOptional(maxUphillGrade),
maxDownhillGrade: parseOptional(maxDownhillGrade),
minUphillGrade: parseOptional(minUphillGrade),
minDownhillGrade: parseOptional(minDownhillGrade),
allowAvoidPassThrough: avoidPass.checked,
stationInterval: STATION_INTERVAL_M,
crossSampleInterval: parseOptional(crossSampleInterval),
longSampleInterval: parseOptional(longSampleInterval),
@@ -184,15 +156,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
},
restore(values: Partial<RoutePanelValues>) {
if (values.contourInterval != null) contourInterval.value = String(values.contourInterval);
if (values.algorithm) algorithm.value = values.algorithm;
if (values.gradeClass) gradeClass.value = values.gradeClass;
if (values.paved != null) paved.checked = values.paved;
if (values.minCurveRadius != null) minCurveRadius.value = String(values.minCurveRadius);
if (values.maxUphillGrade != null) maxUphillGrade.value = String(values.maxUphillGrade);
if (values.maxDownhillGrade != null) maxDownhillGrade.value = String(values.maxDownhillGrade);
if (values.minUphillGrade != null) minUphillGrade.value = String(values.minUphillGrade);
if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade);
if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough;
// stationInterval은 복원하지 않는다 — 20m 고정(2026-08-18).
if (values.crossSampleInterval != null)
crossSampleInterval.value = String(values.crossSampleInterval);
@@ -9,7 +9,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
export interface RoutePanelValues {
contourInterval: number;
algorithm: "dijkstra" | "ridge_valley";
/** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존
* 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록
* 정보(projects.road_type)이며 화면에서는 고르지 않는다. */
@@ -17,13 +16,6 @@ export interface RoutePanelValues {
/** 설계속도(km/h) — 임도는 속도를 낼 수 없는 노선이라 기본 20이다. 간선·산불진화만
* 20~40에서 고를 수 있고 작업임도는 20 고정(별표2, 2026-08-19 사용자 확정). */
designSpeed: 20 | 30 | 40;
paved: boolean;
minCurveRadius: number | null;
maxUphillGrade: number | null;
maxDownhillGrade: number | null;
minUphillGrade: number | null;
minDownhillGrade: number | null;
allowAvoidPassThrough: boolean;
stationInterval: number | null;
crossSampleInterval: number | null;
longSampleInterval: number | null;
+4 -76
View File
@@ -1,16 +1,14 @@
/* =============================================================================
* B05_Profile_UI_Panel_Route.ts
* 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 경로 계산 설정(포인트 팔레트 · 알고리즘 ·
* 조건) · 선택 포인트 상세. 칸 틀은 `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부).
* 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 선택 포인트 상세. 칸 틀은
* `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24).
* `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다.
* ========================================================================== */
import { createSelectField } from "@ui/ui_template_elements";
import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers";
import { button, checkbox, numberField } from "./B05_Profile_UI_Panel_Fields";
import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers";
import { button, numberField } from "./B05_Profile_UI_Panel_Fields";
export interface RoutePartCallbacks {
onSolve: () => void;
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
onEditPlannedRoute: () => void;
onMovePoint: () => void;
@@ -28,57 +26,6 @@ export function createRoutePart(callbacks: RoutePartCallbacks) {
plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다.";
plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute());
const paletteGrid = document.createElement("div");
paletteGrid.className = "b05-route__palette";
const pointLabels: Record<RoutePointKind, string> = {
bp: "BP 시작점",
ep: "EP 종료점",
cp: "CP 경유점",
ap: "AP 회피구역",
fp: "FP 금지구역",
};
(Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => {
const chip = document.createElement("div");
chip.className = `b05-route__chip is-${kind}`;
chip.draggable = true;
chip.textContent = pointLabels[kind];
chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind));
paletteGrid.append(chip);
});
// 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변.
const algorithmField = createSelectField({
label: "알고리즘",
options: [
{ value: "dijkstra", text: "Dijkstra" },
{ value: "ridge_valley", text: "능선·계곡" },
],
});
const algorithm = algorithmField.select;
const minCurveRadius = numberField("최소 곡선반경 (m)");
const maxUphillGrade = numberField("오르막 경사 상한 (%)");
const maxDownhillGrade = numberField("내리막 경사 상한 (%)");
const minUphillGrade = numberField("오르막 경사 하한 (%)");
const minDownhillGrade = numberField("내리막 경사 하한 (%)");
const paved = checkbox("포장 임도", false);
const avoidPass = checkbox("회피구역 통과 허용", false);
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "사용한 조건";
details.append(
summary,
minCurveRadius.wrapper,
maxUphillGrade.wrapper,
maxDownhillGrade.wrapper,
minUphillGrade.wrapper,
minDownhillGrade.wrapper,
);
// [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고
// 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다.
// 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다.
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
solveButton.hidden = true;
const selectedName = document.createElement("strong");
const radius = numberField("회피/금지 반경 (m)", "25");
radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1));
@@ -93,27 +40,8 @@ export function createRoutePart(callbacks: RoutePartCallbacks) {
return {
plannedRouteButton: plannedRouteBtn,
/** 「경로 계산 설정」 본문 — 화면 순서대로. */
calcNodes: [
paletteGrid,
algorithmField.root,
paved.wrapper,
avoidPass.wrapper,
details,
solveButton,
] as HTMLElement[],
/** 「선택 포인트 상세 설정」 본문 — 화면 순서대로. */
selectedNodes: [selectedName, radius.wrapper, selectedActions] as HTMLElement[],
fields: {
algorithm,
paved,
avoidPass,
minCurveRadius,
maxUphillGrade,
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
},
bindSelectedRoot(root: HTMLElement): void {
selectedRoot = root;
root.hidden = true;
+3 -20
View File
@@ -1,10 +1,10 @@
/* =============================================================================
* B05_Profile_UI_Panel_Sections.ts
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선 ·
* 경로 계산 설정 · 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
* 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
*
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 경로 계산 설정(비활성)
* > 선택 포인트(평소 숨김) > 아래 고정 영역. 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선
* > 선택 포인트(평소 숨김) > 아래 고정 영역. 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어
* `ctx.parts` 로 건넨다.
* ========================================================================== */
@@ -37,23 +37,6 @@ registerPanelSection({
},
});
registerPanelSection({
id: "route-calc",
title: "경로 계산 설정",
views: ["profile"],
order: 40,
build: (ctx, view) => {
const frame = createSectionFrame(view, "경로 계산 설정", true);
frame.body.append(...part<RoutePart>(ctx, "route").calcNodes);
// 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자
// 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다.
// 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다.
frame.root.classList.add("is-disabled");
frame.body.inert = true;
return frame.root;
},
});
registerPanelSection({
id: "selected-point",
title: "선택 포인트 상세 설정",
@@ -18,7 +18,8 @@ export interface CrossPreviewContext {
detail: () => SectionDetailResponse | null;
routeId: () => number | null;
edits: () => AlignmentEdits;
debounceMs: number;
/** 부를 때마다 읽음 — 보기마다 다름. */
debounceMs: () => number;
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
onApplied: (updated: number[]) => void;
}
@@ -61,7 +62,7 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
});
}, ctx.debounceMs);
}, ctx.debounceMs());
},
dispose() {
window.clearTimeout(timer);
+192
View File
@@ -0,0 +1,192 @@
/* Old — 54-24 · 나중에 삭제 */
/* =============================================================================
* B05_Profile_UI_RouteCalc.ts
* 좌측 패널 「경로 계산 설정」 칸 — 포인트 팔레트 · 알고리즘 · 조건 · [최적 경로 계산](숨김).
* `B05_Profile_UI_Panel_Route.ts` · `_Panel_Sections.ts` · `_Page_Actions.ts` · `_Page_Helpers.ts`
* 에서 떼어 옮겼다. 어디서도 import 하지 않는다(tsconfig 제외).
* ========================================================================== */
import {
createSelectField,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
createSectionFrame,
part,
registerPanelSection,
} from "../../A00_Common/design/design_panel";
import { solveRoute, type CirclePoint, type RoutePoint } from "../B05_Profile_Api_Fetch";
import type { PlacedRoutePoint, RoutePointKind } from "../B05_Profile_UI_Markers";
import { button, checkbox, numberField, parseOptional } from "../B05_Profile_UI_Panel_Fields";
import type { PageActionContext } from "../B05_Profile_UI_Page_Actions";
export function createRouteCalcPart(callbacks: { onSolve: () => void }) {
const paletteGrid = document.createElement("div");
paletteGrid.className = "b05-route__palette";
const pointLabels: Record<RoutePointKind, string> = {
bp: "BP 시작점",
ep: "EP 종료점",
cp: "CP 경유점",
ap: "AP 회피구역",
fp: "FP 금지구역",
};
(Object.keys(pointLabels) as RoutePointKind[]).forEach((kind) => {
const chip = document.createElement("div");
chip.className = `b05-route__chip is-${kind}`;
chip.draggable = true;
chip.textContent = pointLabels[kind];
chip.addEventListener("dragstart", (event) => event.dataTransfer?.setData("pointType", kind));
paletteGrid.append(chip);
});
// 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변.
const algorithmField = createSelectField({
label: "알고리즘",
options: [
{ value: "dijkstra", text: "Dijkstra" },
{ value: "ridge_valley", text: "능선·계곡" },
],
});
const algorithm = algorithmField.select;
const minCurveRadius = numberField("최소 곡선반경 (m)");
const maxUphillGrade = numberField("오르막 경사 상한 (%)");
const maxDownhillGrade = numberField("내리막 경사 상한 (%)");
const minUphillGrade = numberField("오르막 경사 하한 (%)");
const minDownhillGrade = numberField("내리막 경사 하한 (%)");
const paved = checkbox("포장 임도", false);
const avoidPass = checkbox("회피구역 통과 허용", false);
const details = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = "사용한 조건";
details.append(
summary,
minCurveRadius.wrapper,
maxUphillGrade.wrapper,
maxDownhillGrade.wrapper,
minUphillGrade.wrapper,
minDownhillGrade.wrapper,
);
// [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고
// 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다.
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
solveButton.hidden = true;
return {
/** 「경로 계산 설정」 본문 — 화면 순서대로. */
calcNodes: [
paletteGrid,
algorithmField.root,
paved.wrapper,
avoidPass.wrapper,
details,
solveButton,
] as HTMLElement[],
fields: {
algorithm,
paved,
avoidPass,
minCurveRadius,
maxUphillGrade,
maxDownhillGrade,
minUphillGrade,
minDownhillGrade,
},
};
}
export type RouteCalcPart = ReturnType<typeof createRouteCalcPart>;
registerPanelSection({
id: "route-calc",
title: "경로 계산 설정",
views: ["profile"],
order: 40,
build: (ctx, view) => {
const frame = createSectionFrame(view, "경로 계산 설정", true);
frame.body.append(...part<RouteCalcPart>(ctx, "routeCalc").calcNodes);
// 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자
// 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다.
// 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다.
frame.root.classList.add("is-disabled");
frame.body.inert = true;
return frame.root;
},
});
export function routePoint(point: PlacedRoutePoint): RoutePoint {
// 서버 스키마는 표고를 빼면 "모름"으로 받는다 — null은 undefined로 바꿔 보낸다.
return { x: point.x, y: point.y, z: point.z ?? undefined };
}
export function circlePoint(point: PlacedRoutePoint): CirclePoint {
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
}
/** [경로 계산] — 마커·패널 값으로 노선을 풀고 종횡단까지 다시 받는다. */
export async function solveRouteAction(
ctx: PageActionContext,
calc: RouteCalcPart["fields"],
): Promise<void> {
const latest = ctx.latest();
const confirmedSurface = ctx.confirmedSurface();
if (!confirmedSurface || !latest) return;
const points = ctx.viewer().markers.getPoints();
if (!points.bp || !points.ep) {
showToast("BP와 EP를 지형에 배치하세요.", "error");
return;
}
const values = ctx.panel().values();
showLoadingOverlay();
try {
const solved = await solveRoute(ctx.projectId, {
filter_key: latest.surface_params.source_filter,
method: latest.surface_params.method,
smooth: latest.surface_params.smooth,
surface_model_id: confirmedSurface.model_id ?? undefined,
algorithm: calc.algorithm.value,
bp: routePoint(points.bp),
ep: routePoint(points.ep),
cp: points.cp.map(routePoint),
ap: points.ap.map(circlePoint),
fp: points.fp.map(circlePoint),
grade_class: values.gradeClass,
paved: calc.paved.checked,
min_curve_radius_m: parseOptional(calc.minCurveRadius),
max_uphill_grade: parseOptional(calc.maxUphillGrade),
max_downhill_grade: parseOptional(calc.maxDownhillGrade),
min_uphill_grade: parseOptional(calc.minUphillGrade),
min_downhill_grade: parseOptional(calc.minDownhillGrade),
allow_avoid_pass_through: calc.avoidPass.checked,
station_interval_m: values.stationInterval,
cross_half_width_m: null,
cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval,
terrain_type: values.terrainType,
design_speed_kph: values.designSpeed,
max_grade_pct: values.maxGradePct,
min_vertical_radius_m: values.minVerticalRadius,
min_tangent_length_m: values.minTangentLength,
start_elevation_offset_m: values.startElevationOffset,
end_elevation_offset_m: values.endElevationOffset,
enforce_pipe_clearance: values.enforcePipeClearance,
});
// 새 경로는 측점 구성이 달라지므로 이전 상단측 변경분을 폐기한다(자동 판정 재사용).
ctx.uphillOverrides.clear();
ctx.persistUphillOverrides();
ctx.renderLatest(await ctx.loadLatest(true));
await ctx.restoreSections(solved.route_id);
if (solved.cross_section_count === null) {
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
} else if (solved.grade_summary === null) {
showToast("경로·종횡단은 저장되었지만 계획선 산출에 실패했습니다.", "error");
} else {
showToast("최적 경로 계산이 완료되었습니다.", "success");
}
} catch (error) {
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
} finally {
hideLoadingOverlay();
}
}
+6 -1
View File
@@ -124,8 +124,11 @@ function preserveUserFields(
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const sections = input.detail.cross_sections;
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
const updated = refreshLocally(input) ?? (await refreshFromServer(input));
// 구조물이 선 측점은 폐회로 면적이 정본이다 — 설계를 갈아 끼운 **이 자리에서 한 번** 다시
// 얹는다. 부르는 쪽마다 얹으면 B06 흐름만 얹고 B05 는 안 얹어 두 보기의 유토 뱃지가
@@ -136,7 +139,9 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
structureAreaRows(input.detail.cross_sections),
);
}
return updated;
return sections
.filter((section) => JSON.stringify(section.design) !== before.get(section))
.map((section) => section.chainage_m);
}
/**
+6 -3
View File
@@ -121,7 +121,8 @@ export function createSectionView(
else revealCard(stationId, "smooth");
},
edit: () => structureEdit,
refreshCards: (chainages) => refreshCards(chainages),
// 종단도는 보낸 쪽(계획고 편집 반영)이 이미 다시 그림 — 카드만(54-22)
refreshCards: (chainages) => refreshCards(chainages, false),
},
shared as DesignPageShared,
);
@@ -390,10 +391,12 @@ export function createSectionView(
* 측점마다 `refreshCard` 를 부르면 측점 수만큼 종단 그래프·유토곡선을 다시 그려,
* 계획선 ▲▼ 한 번에 화면이 몇 백 ms 씩 멈췄다(2026-09-12 B05 대비 느림 원인).
*/
const refreshCards = (chainages: ReadonlyArray<number>): void => {
const refreshCards = (chainages: ReadonlyArray<number>, redraw = true): void => {
let touched = false;
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
if (touched) redrawProfile();
if (!touched || !currentDetail) return;
if (redraw) redrawProfile();
else fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
};
const resizeObserver = new ResizeObserver(() => {
@@ -180,7 +180,10 @@ export function createSectionProfile(
profile: {
onSelectStation: (stationId) => host.selectCard(cardOf(stationId)),
callbacks: {
onCrossDesignsUpdated: (updated) => host.refreshCards(updated),
// 숨은 횡단 보기는 건너뜀 — 다시 보일 때 정합이 카드를 맞춤(onShow)
onCrossDesignsUpdated: (updated) => {
if (shared.view() === "cross") host.refreshCards(updated);
},
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) host.edit()?.movePipe(from, to);
},
@@ -199,7 +199,6 @@ const built = panel.createDesignPanel(
settings: { nodes: [text("a"), text("b")] },
route: {
plannedRouteButton: text("plan"),
calcNodes: [text("calc")],
selectedNodes: [text("sel")],
bindSelectedRoot: (root) => {
root.hidden = true;
+1 -2
View File
@@ -59,7 +59,7 @@ def results():
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
assert results["sameNodes"] == {"cross": True, "back": True}
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
assert results["childCount"] == 1 + 5 + 2 # 아래 고정 영역 + 종단 칸 다섯 + 횡단 칸 둘
assert results["childCount"] == 1 + 4 + 2 # 아래 고정 영역 + 종단 칸 넷 + 횡단 칸 둘
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
@@ -80,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results):
"구조물 배치",
"페이지 설정",
"계획노선",
"경로 계산 설정",
"선택 포인트 상세 설정",
]
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
@@ -0,0 +1,83 @@
/* 54-22 — 종단 점 ▲ 누름 멈춤(긴 작업) 재기: B06 은 120ms 디바운스 · 종단도 한 번 · B05 는 실시간 그대로
* 시작 화면: B06(#/b06-section) 또는 B05(#/b05-profile) · 검증 프로젝트 · 로그인
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5422 = "단계";` · 긴 단계는 `void` 로 띄우고
* `sessionStorage.getItem("__5422_out_{단계}")` 로 읽음
* snap → 처음 초안(세션 원문)을 `__5422_orig` 에 남김(이미 있으면 그대로)
* one → 가운데 측점 ▲ 한 번 — 긴 작업 최장 · 합 · 횡단 재계산까지 걸린 때
* hold → 가운데 측점 ▲ 2.5초 길게 — 긴 작업 최장 · 합 · 재계산 횟수
* restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload
* 판정: B06 one 최장 ≈200ms 안팎 · hold 합 ≈0.5초 안팎(고치기 전 587 / 2,684ms) ·
* B05 one 재계산 때 = 16ms 디바운스 근처(실시간)
* 쓰는 것: sessionStorage(계획선 초안) — 정본(서버)은 안 씀
*/
(async () => {
const MODE = window.__m5422 || "one";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const DRAFTS = /^aislo:draft:/;
const out = { mode: MODE, page: location.hash };
const save = () => sessionStorage.setItem(`__5422_out_${MODE}`, JSON.stringify(out));
if (MODE === "snap") {
if (!sessionStorage.getItem("__5422_orig")) {
const orig = Object.fromEntries(
Object.keys(sessionStorage)
.filter((k) => DRAFTS.test(k))
.map((k) => [k, sessionStorage.getItem(k)]),
);
sessionStorage.setItem("__5422_orig", JSON.stringify(orig));
}
out.keys = Object.keys(JSON.parse(sessionStorage.getItem("__5422_orig")));
save();
return JSON.stringify(out);
}
if (MODE === "restore") {
const orig = JSON.parse(sessionStorage.getItem("__5422_orig") || "null");
if (!orig) return "no orig";
for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k);
for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("__5422")) sessionStorage.removeItem(k);
return JSON.stringify({ restored: Object.keys(orig) });
}
const ups = [...document.querySelectorAll(".b05-profile-edit__btn.is-up")];
const button = ups[Math.floor(ups.length / 2)];
if (!button) {
out.error = "▲ 없음";
save();
return JSON.stringify(out);
}
out.button = button.title.split("\n")[0];
const tasks = [];
const observer = new PerformanceObserver((list) =>
list.getEntries().forEach((e) => tasks.push(Math.round(e.duration))),
);
observer.observe({ type: "longtask" });
// 횡단 재계산이 카드 · 캐시에 닿은 때 — 계획선 초안 말고 다른 캐시 쓰기
const writes = [];
const t0 = performance.now();
const origSet = Storage.prototype.setItem;
Storage.prototype.setItem = function (k, v) {
if (this === sessionStorage && !k.startsWith("__5422"))
writes.push([Math.round(performance.now() - t0), k.replace(/:[0-9a-f-]{36}.*/, "")]);
return origSet.call(this, k, v);
};
// 화면이 바뀐 때(ms) — 누른 뒤 재계산 반영이 닿는 때를 봄(B05 ≈16ms 디바운스 · B06 ≈120ms)
const domAt = new Set();
const mo = new MutationObserver(() => domAt.add(Math.round(performance.now() - t0)));
mo.observe(document.body, { subtree: true, childList: true, characterData: true });
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1 }));
await w(MODE === "hold" ? 2500 : 60);
window.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
await w(2500);
Storage.prototype.setItem = origSet;
observer.disconnect();
mo.disconnect();
out.domAt = [...domAt].slice(0, 12);
out.longTasks = tasks;
out.max = tasks.length ? Math.max(...tasks) : 0;
out.sum = tasks.reduce((a, b) => a + b, 0);
out.writes = writes;
save();
return JSON.stringify(out);
})();
+1 -1
View File
@@ -33,5 +33,5 @@
}
},
"include": ["A00_Common", "A0*", "B0*", "B1*", "M0*", "Z0*", "ui_template", "config/**/*", "common_util"],
"exclude": ["0_old", "node_modules", "venv", "dist"]
"exclude": ["0_old", "B05_Profile/Old", "node_modules", "venv", "dist"]
}