Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -57,6 +57,8 @@ export const STATE_REGISTRY = {
|
||||
legacy: () => "b05-route-drainage-collapsed",
|
||||
},
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
|
||||
"drainage-visible": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
@@ -110,6 +112,8 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
||||
},
|
||||
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
|
||||
selection: { bucket: "draft", scope: "project" },
|
||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
||||
/** 배수관 이동(측점 옮김) 예약. */
|
||||
@@ -154,10 +158,19 @@ export const STATE_REGISTRY = {
|
||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||
crossdesign: { bucket: "draft", scope: "route" },
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
|
||||
"alignment-draft": {
|
||||
bucket: "draft",
|
||||
scope: "route",
|
||||
legacy: (_p, r) => `b05-profile-alignment-draft:${r}`,
|
||||
},
|
||||
|
||||
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
||||
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
||||
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
|
||||
/** 종단도 최소 흙덮기 강제 여부 — 종단 보기 「페이지 설정」 값을 두 보기가 같이 본다(24-2c). */
|
||||
"profile-enforce-min-cover": { bucket: "result", scope: "project" },
|
||||
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
|
||||
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
|
||||
* 노선이 바뀌면 `latest` 와 함께 버린다. */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가
|
||||
두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */
|
||||
.b05-route__dock .b05-route__actions .ui-btn,
|
||||
.b05-route__dock .b06-profile__actions .ui-btn {
|
||||
padding-inline: var(--spacing-8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* =============================================================================
|
||||
* design_dock.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 맨 아래 고정 영역**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 구성 = [위에 놓을 것(구조물 목록)][구분선][단추 행]. 단추 넷은 두 보기가 **같다**:
|
||||
* [초기화] [저장] [확정] [보기 전환](종단 보기에서는 「횡단 보기」 · 횡단 보기에서는
|
||||
* 「종단 보기」). 누르면 무엇을 하는지는 페이지가 `DesignDockActions` 로 건넨다 — 이 파일은
|
||||
* 단추를 만들고 켜고 끄기만 한다(저장 흐름은 `design_save.ts` 몫).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import "./design_dock.css";
|
||||
|
||||
/** 보기 이름 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/** 단추 하나의 동작 — `enabled` 가 거짓이면 눌리지 않고 `title` 이 까닭을 알린다. */
|
||||
export interface DesignDockAction {
|
||||
run: () => void | Promise<void>;
|
||||
enabled?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface DesignDockActions {
|
||||
reset: DesignDockAction;
|
||||
save: DesignDockAction;
|
||||
confirm: DesignDockAction;
|
||||
/** [보기 전환] — 지금은 주소 이동이다(2단계에서 같은 페이지 안 전환으로 바뀜). */
|
||||
switchView: DesignDockAction;
|
||||
}
|
||||
|
||||
export interface DesignDock {
|
||||
root: HTMLElement;
|
||||
/** 단추 넷의 켬·끔·설명을 다시 준다(자료가 늦게 오는 페이지가 부름). */
|
||||
setActions: (actions: Partial<DesignDockActions>) => void;
|
||||
/** 보기가 바뀌면 [보기 전환] 글자가 따라 바뀐다. */
|
||||
setView: (view: DesignView) => void;
|
||||
}
|
||||
|
||||
function label(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 보기 전환 단추 글자 — 가려는 **쪽** 이름이다. */
|
||||
export function switchViewLabel(view: DesignView): string {
|
||||
return label(view === "profile" ? "Design_Btn_ToCross" : "Design_Btn_ToProfile");
|
||||
}
|
||||
|
||||
/**
|
||||
* 아래 고정 영역을 만든다. `above` 는 단추 행 위에 놓을 것(구조물 목록) — 목록을 「구조물 배치」
|
||||
* 본문에 두면 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
|
||||
*/
|
||||
export function createDesignDock(
|
||||
view: DesignView,
|
||||
above: ReadonlyArray<HTMLElement>,
|
||||
initial: DesignDockActions,
|
||||
): DesignDock {
|
||||
let current = view;
|
||||
let actions = { ...initial };
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-route__dock ui-sidebar-actions";
|
||||
const divider = document.createElement("hr");
|
||||
divider.className = "b05-structure__divider";
|
||||
const row = document.createElement("div");
|
||||
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
|
||||
const press = (name: keyof DesignDockActions) => (): void => {
|
||||
void Promise.resolve(actions[name].run());
|
||||
};
|
||||
const resetButton = createButton({
|
||||
label: label("Common_Btn_Reset"),
|
||||
variant: "danger",
|
||||
onClick: press("reset"),
|
||||
});
|
||||
const saveButton = createButton({
|
||||
label: label("B06_Profile_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: press("save"),
|
||||
});
|
||||
const confirmButton = createButton({
|
||||
label: label("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: press("confirm"),
|
||||
});
|
||||
const switchButton = createButton({
|
||||
label: switchViewLabel(view),
|
||||
variant: "ghost",
|
||||
onClick: press("switchView"),
|
||||
});
|
||||
resetButton.dataset.dockAction = "reset";
|
||||
saveButton.dataset.dockAction = "save";
|
||||
confirmButton.dataset.dockAction = "confirm";
|
||||
switchButton.dataset.dockAction = "switchView";
|
||||
const buttons = {
|
||||
reset: resetButton,
|
||||
save: saveButton,
|
||||
confirm: confirmButton,
|
||||
switchView: switchButton,
|
||||
};
|
||||
|
||||
const sync = (): void => {
|
||||
(Object.keys(buttons) as Array<keyof DesignDockActions>).forEach((name) => {
|
||||
const action = actions[name];
|
||||
buttons[name].disabled = action.enabled === false;
|
||||
if (action.title) buttons[name].title = action.title;
|
||||
else buttons[name].removeAttribute("title");
|
||||
});
|
||||
(switchButton.querySelector(".ui-btn__label") ?? switchButton).textContent =
|
||||
switchViewLabel(current);
|
||||
row.className = current === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
};
|
||||
|
||||
row.append(resetButton, saveButton, confirmButton, switchButton);
|
||||
root.append(...above, divider, row);
|
||||
sync();
|
||||
|
||||
return {
|
||||
root,
|
||||
setActions: (next) => {
|
||||
actions = { ...actions, ...next };
|
||||
sync();
|
||||
},
|
||||
setView: (next) => {
|
||||
current = next;
|
||||
sync();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =============================================================================
|
||||
* design_panel.ts
|
||||
* B05 · B06 한 페이지 두 보기의 **좌측 패널 칸 등록부**(계획서 24-1 · 24-2b).
|
||||
*
|
||||
* 칸(구조물 배치 · 페이지 설정 · 표준 횡단면 설정 …)은 **한 번만 정의**하고 보일 보기만 적는다.
|
||||
* 페이지 파일은 칸도 단추도 직접 만들지 않고 `createDesignPanel(ctx, view)` 한 줄만 부른다 —
|
||||
* 시험(`test_design_view_parity.py`)이 이를 지킨다.
|
||||
*
|
||||
* registerPanelSection({ id, title, views, order, build }) ← 칸 정의(모듈이 읽힐 때 한 번)
|
||||
* createDesignPanel(ctx, view) → { root, setView } ← 페이지가 부름
|
||||
*
|
||||
* 칸이 쓰는 페이지 쪽 부품(구조물 패널 · 입력 칸 묶음 …)은 `ctx.parts` 에서 이름으로 꺼낸다 —
|
||||
* 칸 정의가 B05 · B06 의 구체 타입을 몰라도 되게 하려는 것이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import {
|
||||
createDesignDock,
|
||||
type DesignDock,
|
||||
type DesignDockActions,
|
||||
type DesignView,
|
||||
} from "./design_dock";
|
||||
|
||||
export type { DesignView } from "./design_dock";
|
||||
|
||||
/** 좌측 패널 하나가 페이지에서 받는 것 — 값은 바뀌므로 부품은 이름으로, 단추 동작은 함수로. */
|
||||
export interface DesignPanelContext {
|
||||
projectId: string | null;
|
||||
/** 이름 → 페이지가 만든 부품. 칸 정의가 `part()` 로 꺼낸다. */
|
||||
parts: Record<string, unknown>;
|
||||
/** 아래 단추 넷의 동작 — 페이지가 채운다(저장 흐름은 `design_save`). */
|
||||
dock: DesignDockActions;
|
||||
}
|
||||
|
||||
/** 칸 정의가 돌려주는 것 — 칸 뿌리 하나, 그리고 (있으면) 아래 고정 영역 위에 얹을 것. */
|
||||
export type PanelSectionBuild = HTMLElement | { root: HTMLElement; dock?: HTMLElement };
|
||||
|
||||
export interface PanelSectionDef {
|
||||
id: string;
|
||||
title: string;
|
||||
/** 이 칸이 보일 보기 — 둘 다 적으면 한 정의로 두 보기(구조물 배치). */
|
||||
views: ReadonlyArray<DesignView>;
|
||||
/** 칸 차례 — 작을수록 위. */
|
||||
order: number;
|
||||
build: (ctx: DesignPanelContext, view: DesignView) => PanelSectionBuild;
|
||||
}
|
||||
|
||||
const registry = new Map<string, PanelSectionDef>();
|
||||
|
||||
/** 칸 정의를 등록한다. 같은 id 는 덮어쓴다(같은 모듈이 다시 읽혀도 한 벌). */
|
||||
export function registerPanelSection(def: PanelSectionDef): void {
|
||||
registry.set(def.id, def);
|
||||
}
|
||||
|
||||
/** 그 보기에 보일 칸 정의 — 차례대로. 시험이 「두 보기가 같은 칸을 쓰는지」 볼 때도 쓴다. */
|
||||
export function panelSectionsFor(view: DesignView): PanelSectionDef[] {
|
||||
return [...registry.values()]
|
||||
.filter((def) => def.views.includes(view))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/** 칸이 부품을 이름으로 꺼낸다 — 페이지가 안 건넸으면 바로 알리도록 던진다. */
|
||||
export function part<T>(ctx: DesignPanelContext, name: string): T {
|
||||
const found = ctx.parts[name];
|
||||
if (found === undefined)
|
||||
throw new Error(`좌측 패널 부품 「${name}」 을 페이지가 건네지 않았습니다.`);
|
||||
return found as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 칸 틀 — 접는 제목 + 본문. 두 보기가 **다른 클래스**를 쓰던 것(`b05-route__panel-section` ·
|
||||
* `b06-profile__group`)을 그대로 살린다(2단계에서 한 벌로 합침). 종단 보기 본문은 별도 div,
|
||||
* 횡단 보기는 칸 뿌리에 바로 붙인다.
|
||||
*/
|
||||
export function createSectionFrame(
|
||||
view: DesignView,
|
||||
title: string,
|
||||
collapsed = false,
|
||||
): { root: HTMLElement; body: HTMLElement } {
|
||||
const root = document.createElement("section");
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = title;
|
||||
if (view === "profile") {
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "ui-collapsible__title";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
heading.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
root.append(heading);
|
||||
return { root, body: root };
|
||||
}
|
||||
|
||||
export interface DesignPanel {
|
||||
root: HTMLElement;
|
||||
/** 아래 고정 영역 — 단추 켬·끔을 페이지가 다시 줄 때 쓴다. */
|
||||
dock: DesignDock;
|
||||
/** 보기가 바뀌면 그 보기에 없는 칸은 숨기고 있는 칸은 (처음이면 만들어) 보인다. */
|
||||
setView: (view: DesignView) => void;
|
||||
}
|
||||
|
||||
/** 좌측 패널 — 그 보기의 칸을 차례대로 세우고 아래 고정 영역을 붙인다. */
|
||||
export function createDesignPanel(ctx: DesignPanelContext, view: DesignView): DesignPanel {
|
||||
const root = document.createElement("div");
|
||||
root.className = view === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
const built = new Map<string, HTMLElement>();
|
||||
const dockParts: HTMLElement[] = [];
|
||||
const dock = createDesignDock(view, dockParts, ctx.dock);
|
||||
|
||||
const show = (target: DesignView): void => {
|
||||
const wanted = new Set(panelSectionsFor(target).map((def) => def.id));
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
if (built.has(def.id)) continue;
|
||||
const made = def.build(ctx, target);
|
||||
const sectionRoot = "root" in made ? made.root : made;
|
||||
if ("root" in made && made.dock) dock.root.prepend(made.dock);
|
||||
built.set(def.id, sectionRoot);
|
||||
root.insertBefore(sectionRoot, dock.root);
|
||||
}
|
||||
// 차례를 정의 순서로 다시 맞춘다 — 늦게 만들어진 칸이 맨 아래에 남지 않게.
|
||||
for (const def of panelSectionsFor(target)) {
|
||||
const element = built.get(def.id);
|
||||
if (element) root.insertBefore(element, dock.root);
|
||||
}
|
||||
built.forEach((element, id) => {
|
||||
// 칸이 스스로 숨긴 것(선택 포인트)은 건드리지 않고, 보기 밖 칸만 가리고 되살린다.
|
||||
element.style.display = wanted.has(id) ? "" : "none";
|
||||
});
|
||||
};
|
||||
|
||||
root.append(dock.root);
|
||||
show(view);
|
||||
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
||||
// 접힘 항목은 손대지 않는다.
|
||||
attachCollapsible(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
dock,
|
||||
setView: (next) => {
|
||||
dock.setView(next);
|
||||
root.className = next === "profile" ? "b05-route__panel" : "b06-profile__form";
|
||||
show(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
/* =============================================================================
|
||||
* 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 { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
import type {
|
||||
StructureInstance,
|
||||
StructureType,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
||||
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,
|
||||
chainageKey,
|
||||
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,
|
||||
structureIdAtStation as structureIdAtStationOf,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createRouteMassHaulPanel,
|
||||
type MassHaulSummaryValues,
|
||||
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,
|
||||
snapToMasterStations,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
|
||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
|
||||
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;
|
||||
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
|
||||
sideTool?: (board: DesignSideBoard) => 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)이면 연속 입력은 여전히 마지막 값만 계산한다. */
|
||||
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();
|
||||
};
|
||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||
* 유토곡선을 접어도 이 값은 남아야 한다. */
|
||||
const massBadge = createMassHaulBadge();
|
||||
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
const haulPrefetch = createHaulPlanPrefetch(
|
||||
projectId,
|
||||
() => routeId ?? undefined,
|
||||
subPanelChanged,
|
||||
);
|
||||
const massHaul = createRouteMassHaulPanel(
|
||||
subPanelChanged,
|
||||
(summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(subPanelChanged);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
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),
|
||||
// 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록
|
||||
// (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로).
|
||||
structureMenuItems: (chainage) =>
|
||||
structureGroupMenuItems(structureTypes, (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;
|
||||
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줄 한계). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
structureIdAtStationOf(stationId, irregularStations, structures);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
|
||||
/**
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(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: 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 sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "b05-route-profile__trailing";
|
||||
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
|
||||
|
||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
|
||||
const { tools, history, handleToolPick } = 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||
* 실제 렌더링하도록 모은다.
|
||||
*/
|
||||
function rebuild(): void {
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
|
||||
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||
scheduleCrossPreview();
|
||||
if (redrawPending) return;
|
||||
redrawPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
redrawPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 횡단 설계 프리뷰는 `_Profile_Preview` 몫(700줄 한계). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
detail: () => detail,
|
||||
routeId: () => routeId,
|
||||
edits: () => store.edits(),
|
||||
debounceMs: 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({
|
||||
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",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
updateElevationWindow = update;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (elevationHold.editing()) return;
|
||||
const fitted = updateElevationWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
draw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
attachWheelHorizontalScroll(body);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (
|
||||
body.clientWidth <= 0 ||
|
||||
body.clientHeight <= 0 ||
|
||||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
||||
)
|
||||
return;
|
||||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||||
scheduleLightSync();
|
||||
drag.scheduleDraw();
|
||||
});
|
||||
resizeObserver.observe(body);
|
||||
|
||||
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
|
||||
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(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());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
// 이월분은 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();
|
||||
requestAnimationFrame(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();
|
||||
},
|
||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||
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 || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
||||
alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
|
||||
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
|
||||
drainagePanel?.setRoute(points);
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = snapToMasterStations(alignment, stations);
|
||||
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();
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
setSelectedStructure(structureId: string | null) {
|
||||
selectedStructureId = structureId;
|
||||
selectedStationId = stationIdAtStructure(structureId);
|
||||
draw();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
stationDisplay = next;
|
||||
draw();
|
||||
},
|
||||
/**
|
||||
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
|
||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||
*/
|
||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||
const key = chainageKey(chainageM);
|
||||
const edits = store.edits();
|
||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||||
store.resetStation(chainageM, moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
async save(): Promise<void> {
|
||||
if (!routeId || !store.dirty()) return;
|
||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
alignment = next;
|
||||
if (detail) detail.longitudinal.profile_alignment = next;
|
||||
}
|
||||
store.markSaved();
|
||||
draw();
|
||||
},
|
||||
clear() {
|
||||
detail = null;
|
||||
base = 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);
|
||||
},
|
||||
dispose() {
|
||||
drag.dispose();
|
||||
crossPreview.dispose();
|
||||
resizeObserver.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 종단도 한 벌 — `createDesignProfile` 이 돌려주는 것. */
|
||||
export type DesignProfile = ReturnType<typeof createDesignProfile>;
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_slots.ts
|
||||
* 종단도 한 벌의 **판 자리 약속**(계획서 24-1 · 24-2c) — `design_profile.ts` 가 다시
|
||||
* 내보낸다(부르는 쪽은 `design_profile.ts` 에서 가져간다).
|
||||
*
|
||||
* 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게).
|
||||
* · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다.
|
||||
* · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
DrainagePanel,
|
||||
DrainagePanelCallbacks,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/**
|
||||
* 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 ·
|
||||
* 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가
|
||||
* 최소 흙덮기를 판정한다.
|
||||
*/
|
||||
export interface DesignSideHost extends DrainagePanelCallbacks {
|
||||
projectId: string;
|
||||
view: DesignView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` ·
|
||||
* 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` ·
|
||||
* 보기가 바뀌면 `setView` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다.
|
||||
*/
|
||||
export type DesignSideBoard = DrainagePanel & {
|
||||
/** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */
|
||||
setView?: (view: DesignView) => void;
|
||||
};
|
||||
|
||||
/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */
|
||||
export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard;
|
||||
|
||||
/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */
|
||||
export interface DesignOverlayBoard {
|
||||
overlay: HTMLElement;
|
||||
handle: HTMLElement;
|
||||
attachScrollSync: (scroller: HTMLElement) => void;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/* =============================================================================
|
||||
* design_reset.ts
|
||||
* [초기화] 한 줄기 — 두 보기의 아래 단추가 **같은 함수**를 부른다(계획서 24-2b).
|
||||
*
|
||||
* 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. 네이티브 confirm 은
|
||||
* 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다(2026-08-19 진단) — 화면 안 모달로
|
||||
* 확인받는다. (`B05_Profile_UI_Page_Actions.resetDesignAction` 에서 옮겨 왔다 — 동작 그대로.)
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { clearDrafts } from "./../b_page_state";
|
||||
import { navigateTo } from "./../router";
|
||||
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* [초기화] — 확인을 받고, 서버 초기값 복원 뒤 옛 경로 기준 캐시를 전부 비우고 종단 보기로 다시 들어간다.
|
||||
* `onCleared` 는 페이지만 아는 조작 캐시(상단측 변경분 등)를 비울 자리다.
|
||||
*/
|
||||
export async function resetDesign(
|
||||
projectId: string,
|
||||
routeId: number | null,
|
||||
onCleared?: () => void,
|
||||
): Promise<void> {
|
||||
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetRouteDesign(projectId);
|
||||
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
|
||||
clearRouteLatestCache(projectId);
|
||||
invalidateSectionDetail(projectId);
|
||||
// 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화).
|
||||
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
|
||||
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
|
||||
clearDrafts(projectId, routeId);
|
||||
onCleared?.();
|
||||
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
|
||||
// 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남).
|
||||
clearAlignmentDrafts();
|
||||
showToast(L("B05_Route_Reset_Success"), "success");
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_save.ts
|
||||
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
|
||||
*
|
||||
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
*
|
||||
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
|
||||
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
|
||||
* 그 몫만 `steps` · `cross` 로 받는다. 서버 라우트는 그대로다.
|
||||
*
|
||||
* ⚠ 차례를 바꾸면 결과가 갈린다 — 관 목록은 스냅샷이라 구간값(암거 옵션)보다 먼저, 상단측은
|
||||
* 횡단 patch 보다 먼저 나가야 사용자 수정이 맨 위에 얹힌다(2026-09-06 · 09-12).
|
||||
* 영구저장은 여기서만 한다(CLAUDE.md 5장 — 자동저장 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { flushPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { flushPendingStructures } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
flushUphillOverrides,
|
||||
saveProfileAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import {
|
||||
markAlignmentSaved,
|
||||
pendingAlignment,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { stateKey } from "../b_page_state";
|
||||
|
||||
/** 저장 차례 — 이 순서 그대로 돈다. */
|
||||
export const DESIGN_SAVE_ORDER = [
|
||||
"pipes",
|
||||
"structures",
|
||||
"alignment",
|
||||
"culvertOptions",
|
||||
"uphill",
|
||||
"cross",
|
||||
] as const;
|
||||
|
||||
export type DesignSaveStep = (typeof DESIGN_SAVE_ORDER)[number];
|
||||
|
||||
export interface DesignSaveContext {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
/** 저장 앞 준비 — 면적을 모으기 전 전 측점 재계산(B06). */
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
|
||||
*/
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
|
||||
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
|
||||
cross: (confirm: boolean) => Promise<void>;
|
||||
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
|
||||
done?: (confirm: boolean) => Promise<void> | void;
|
||||
/** 멈춘 저장을 알린다. */
|
||||
failed: (error: unknown, confirm: boolean) => void;
|
||||
}
|
||||
|
||||
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
|
||||
function reportOnly(label: string): (error: unknown) => void {
|
||||
return (error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${label} 저장에 실패했습니다.${detail}`, "error");
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
|
||||
const draft = pendingAlignment(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
markAlignmentSaved(routeId);
|
||||
}
|
||||
|
||||
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
|
||||
const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
|
||||
pipes: "배수관",
|
||||
structures: "구조물",
|
||||
alignment: "계획선",
|
||||
};
|
||||
|
||||
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
|
||||
function defaultStep(
|
||||
step: Exclude<DesignSaveStep, "cross">,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "pipes":
|
||||
return () => flushPendingPipes(projectId);
|
||||
case "structures":
|
||||
return () => flushPendingStructures(projectId);
|
||||
case "alignment":
|
||||
return () => flushAlignmentDraft(projectId, routeId);
|
||||
case "culvertOptions":
|
||||
return () =>
|
||||
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
case "uphill":
|
||||
return () => flushUphillOverrides(projectId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [저장] · [확정] — 세션에 쌓인 조작을 정해진 차례로 정본에 보낸다.
|
||||
* 노선이 없으면 아무 일도 하지 않고 false. 끝까지 나가면 true.
|
||||
*/
|
||||
export async function saveDesign(
|
||||
ctx: DesignSaveContext,
|
||||
options: { confirm?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const confirm = options.confirm ?? false;
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return false;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await ctx.prepare?.();
|
||||
for (const step of DESIGN_SAVE_ORDER) {
|
||||
if (step === "cross") {
|
||||
await ctx.cross(confirm);
|
||||
continue;
|
||||
}
|
||||
const own = ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId);
|
||||
const run =
|
||||
step === "pipes"
|
||||
? async () => {
|
||||
await base();
|
||||
await own?.();
|
||||
}
|
||||
: (own ?? base);
|
||||
const label = TOLERANT[step];
|
||||
await (label ? run().catch(reportOnly(label)) : run());
|
||||
}
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
ctx.failed(error, confirm);
|
||||
return false;
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* =============================================================================
|
||||
* design_sections.ts
|
||||
* 두 보기가 **함께 쓰는 좌측 칸 정의** — 「구조물 배치」(계획서 24-2b).
|
||||
*
|
||||
* 한 정의에 `views: ["profile", "cross"]` 를 적어 두 보기가 같은 칸을 같은 자리(맨 위)에 세운다.
|
||||
* 칸 알맹이는 페이지가 건네는 부품 `structures`(뿌리 · 아래 고정 영역에 얹을 목록 뿌리)다 —
|
||||
* 종단 보기는 배치 콜백, 횡단 보기는 거기에 횡단 조정 콜백을 더해 만들지만 **칸 정의는 하나**다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { part, registerPanelSection } from "./design_panel";
|
||||
|
||||
/** 페이지가 건네는 「구조물 배치」 부품의 모양 — 뿌리와 목록 뿌리만 안다. */
|
||||
export interface StructuresPart {
|
||||
root: HTMLElement;
|
||||
listRoot: HTMLElement;
|
||||
}
|
||||
|
||||
export const STRUCTURES_SECTION_ID = "structures";
|
||||
|
||||
registerPanelSection({
|
||||
id: STRUCTURES_SECTION_ID,
|
||||
title: "구조물 배치",
|
||||
views: ["profile", "cross"],
|
||||
order: 10,
|
||||
build: (ctx) => {
|
||||
const structures = part<StructuresPart>(ctx, "structures");
|
||||
// 구조물 목록은 「구조물 배치」 본문이 아니라 아래 고정 영역에 놓는다 — 본문에 두면
|
||||
// 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
|
||||
return { root: structures.root, dock: structures.listRoot };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_store.ts
|
||||
* B05 · B06 설계 상태 **한 곳**(계획서 24-1) — 두 보기가 같은 값을 같은 창구로 읽고 쓴다.
|
||||
*
|
||||
* const store = useDesignStore(projectId, routeId);
|
||||
* store.get("pipes") · store.set("pipes", next) · store.subscribe("pipes", fn)
|
||||
*
|
||||
* 밑은 지금 캐시 그대로다 — 값마다 `b_page_state` 등록 키 하나(새 키는 등록부에 먼저).
|
||||
* 가게는 값을 따로 들고 있지 않는다: 읽을 때마다 캐시를 읽고, 쓸 때 캐시에 쓴 뒤 듣는 쪽을
|
||||
* 부른다. 그래서 가게를 거치지 않고 캐시를 고친 옛 자리가 남아 있어도 읽는 값은 갈리지 않는다
|
||||
* (다만 그 자리는 듣는 쪽을 못 부른다 — 쓰는 길은 가게로 옮길 것).
|
||||
*
|
||||
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
|
||||
* 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
|
||||
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
|
||||
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
|
||||
/** 한 페이지의 두 보기(계획서 24-1). */
|
||||
export type DesignView = "profile" | "cross";
|
||||
|
||||
/** 고른 자리 — 측점 · 구조물. 둘 다 비면 고른 것이 없다. */
|
||||
export interface DesignSelection {
|
||||
chainageM: number | null;
|
||||
structureId: string | null;
|
||||
}
|
||||
|
||||
/** 가게가 드는 값 — 없으면 null(= 만진 적 없음 · 저장분을 쓸 것). */
|
||||
export interface DesignStoreValues {
|
||||
/** 미저장 구조물 목록(정본 `structures.json`). */
|
||||
structures: StructureInstance[] | null;
|
||||
/** 미저장 관 목록(정본 `pipe_points.json`) — 주인은 이 가게 하나다. */
|
||||
pipes: DetailPipeInput[] | null;
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경). 노선이 있어야 한다. */
|
||||
alignmentDraft: AlignmentEdits | null;
|
||||
/** 종횡단 상세 — 세션 사본. 화면이 함께 쥐는 객체는 `B06_Section_Section_Store`. */
|
||||
sectionDetail: SectionDetailResponse | null;
|
||||
/** 고른 측점 · 구조물. */
|
||||
selection: DesignSelection | null;
|
||||
}
|
||||
|
||||
export type DesignStoreKey = keyof DesignStoreValues;
|
||||
|
||||
export interface DesignStore {
|
||||
get<K extends DesignStoreKey>(key: K): DesignStoreValues[K];
|
||||
set<K extends DesignStoreKey>(key: K, value: DesignStoreValues[K]): void;
|
||||
/** 가게로 쓸 때마다 부른다. 돌려받은 함수로 끊는다. */
|
||||
subscribe<K extends DesignStoreKey>(
|
||||
key: K,
|
||||
fn: (value: DesignStoreValues[K]) => void,
|
||||
): () => void;
|
||||
}
|
||||
|
||||
/** 옛 계획고 초안 키 — 위 머리 주석 참조(남은 옛 값을 쓸어 낼 때만 쓴다). */
|
||||
export const ALIGNMENT_DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
|
||||
/** 계획고 초안 칸의 프로젝트 — 부르는 쪽이 모르면 지금 연 프로젝트, 그것도 없으면 `-`
|
||||
* (노선 번호는 프로젝트를 건너 겹치지 않는다). */
|
||||
export function draftProjectId(projectId: string | null = null): string {
|
||||
if (projectId) return projectId;
|
||||
try {
|
||||
return window.localStorage.getItem(CURRENT_PROJECT_ID_KEY) || "-";
|
||||
} catch {
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
interface Slot<T> {
|
||||
/** 듣는 쪽을 묶는 이름 — 같은 캐시 칸이면 같은 이름. 칸을 못 정하면 null. */
|
||||
id: string | null;
|
||||
read(): T | null;
|
||||
write(value: T | null): void;
|
||||
}
|
||||
|
||||
function slotOf<K extends DesignStoreKey>(
|
||||
key: K,
|
||||
projectId: string | null,
|
||||
routeId: number | null,
|
||||
): Slot<DesignStoreValues[K]> {
|
||||
type V = DesignStoreValues[K];
|
||||
if (key === "alignmentDraft") {
|
||||
const project = draftProjectId(projectId);
|
||||
if (routeId === null) return { id: null, read: () => null, write: () => undefined };
|
||||
return {
|
||||
id: `alignment-draft:${project}:${routeId}`,
|
||||
read: () => {
|
||||
const parsed = readState<Partial<AlignmentEdits>>("alignment-draft", project, routeId);
|
||||
if (!parsed) return null;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_radii: parsed.curve_radii ?? {},
|
||||
} as V;
|
||||
},
|
||||
write: (value) => writeState("alignment-draft", value, project, routeId),
|
||||
};
|
||||
}
|
||||
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
|
||||
const name = (
|
||||
{
|
||||
structures: "structures",
|
||||
pipes: "pipes",
|
||||
sectionDetail: "section-detail",
|
||||
selection: "selection",
|
||||
} as const
|
||||
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
|
||||
const routeScoped = name === "section-detail";
|
||||
if (!projectId || (routeScoped && routeId === null)) {
|
||||
return { id: null, read: () => null, write: () => undefined };
|
||||
}
|
||||
const scopeRoute = routeScoped ? routeId : undefined;
|
||||
return {
|
||||
id: `${name}:${projectId}${routeScoped ? `:${routeId}` : ""}`,
|
||||
read: () => readState<V>(name, projectId, scopeRoute),
|
||||
write: (value) => writeState(name, value, projectId, scopeRoute),
|
||||
};
|
||||
}
|
||||
|
||||
const listeners = new Map<string, Set<(value: unknown) => void>>();
|
||||
|
||||
/**
|
||||
* 그 프로젝트 · 노선의 가게. 부를 때마다 새로 만들어도 된다 — 값도 듣는 쪽도 가게 밖
|
||||
* (캐시 · 모듈 표)에 있어서 같은 칸을 가리키면 같은 가게다.
|
||||
*
|
||||
* 노선을 모르면 `routeId = null` — 노선 칸(`alignmentDraft` · `sectionDetail`)은 비어 보이고
|
||||
* 쓰기는 버려진다. 프로젝트를 모르면 프로젝트 칸이 그렇다.
|
||||
*/
|
||||
export function useDesignStore(projectId: string | null, routeId: number | null): DesignStore {
|
||||
return {
|
||||
get(key) {
|
||||
return slotOf(key, projectId, routeId).read() as DesignStoreValues[typeof key];
|
||||
},
|
||||
set(key, value) {
|
||||
const slot = slotOf(key, projectId, routeId);
|
||||
slot.write(value);
|
||||
if (slot.id) listeners.get(`${key}|${slot.id}`)?.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(key, fn) {
|
||||
const id = slotOf(key, projectId, routeId).id;
|
||||
if (!id) return () => undefined;
|
||||
const bucket = `${key}|${id}`;
|
||||
const set = listeners.get(bucket) ?? new Set();
|
||||
listeners.set(bucket, set);
|
||||
const listener = fn as (value: unknown) => void;
|
||||
set.add(listener);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,12 @@
|
||||
* 하나뿐이었다). 다른 조작값과 같은 규칙으로 세션에 쌓고 저장 앞단에서 함께 내보낸다.
|
||||
*
|
||||
* 자동저장은 하지 않는다(CLAUDE.md 5장).
|
||||
*
|
||||
* 관 목록의 주인은 설계 가게(`design_store` 의 `pipes`) 하나다(계획서 24-2a) — B05 배수유역도 ·
|
||||
* B06 구조물 목록 모두 이 창구로 읽고 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
@@ -17,11 +20,11 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch
|
||||
export type PendingPipes = DetailPipeInput[];
|
||||
|
||||
export function readPendingPipes(projectId: string): PendingPipes | null {
|
||||
return readState<PendingPipes>("pipes", projectId);
|
||||
return useDesignStore(projectId, null).get("pipes");
|
||||
}
|
||||
|
||||
export function writePendingPipes(projectId: string, next: PendingPipes | null): void {
|
||||
writeState("pipes", next, projectId);
|
||||
useDesignStore(projectId, null).set("pipes", next);
|
||||
}
|
||||
|
||||
/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다.
|
||||
· earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소.
|
||||
· gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소).
|
||||
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대 + 평균) 세 값을 후보 범위로
|
||||
0~1 정규화해 비중(합 100)으로 더한 값 최소.
|
||||
· weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보
|
||||
범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 —
|
||||
토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e).
|
||||
|
||||
후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들.
|
||||
갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값
|
||||
@@ -230,12 +231,16 @@ def generate_initial_route(
|
||||
follow=follow,
|
||||
)
|
||||
|
||||
bound = None
|
||||
if mode == "follow":
|
||||
def follow_line() -> tuple[Any, dict[str, Any]]:
|
||||
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 →
|
||||
# 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸.
|
||||
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
|
||||
metrics = measure(outline, None, follow=True)
|
||||
return outline, measure(outline, None, follow=True)
|
||||
|
||||
bound = None
|
||||
# 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름
|
||||
if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0):
|
||||
outline, metrics = follow_line()
|
||||
else:
|
||||
key = _pool_key(
|
||||
expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth
|
||||
@@ -354,7 +359,8 @@ def _pick(
|
||||
return metrics["cut_m3"] + metrics["fill_m3"]
|
||||
|
||||
def grade(metrics: dict[str, Any]) -> float:
|
||||
return metrics["max_grade_pct"] + metrics["avg_grade_pct"]
|
||||
# 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01)
|
||||
return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"]
|
||||
|
||||
def offset(metrics: dict[str, Any]) -> float:
|
||||
return metrics["offset_avg_m"]
|
||||
|
||||
@@ -32,6 +32,7 @@ import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
|
||||
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
@@ -355,31 +356,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
summaryText(summary, pipeEditor.pipes().length, basins.length, zSource);
|
||||
// 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을
|
||||
// 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다.
|
||||
callbacks.onPipesChanged?.(
|
||||
// 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
|
||||
// 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
|
||||
// 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
|
||||
// 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
|
||||
// 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합).
|
||||
pipeEditor.pipes().map((pipe) => {
|
||||
const chainage = Math.round(pipe.chainage_m * 100) / 100;
|
||||
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
|
||||
const attributes = facilityStore.get(chainage);
|
||||
return {
|
||||
chainage_m: chainage,
|
||||
effective_diameter_mm:
|
||||
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
|
||||
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
|
||||
design_flow_m3s: basin?.design_flow_m3s ?? null,
|
||||
facility: attributes?.facility ?? "pipe",
|
||||
start_m: attributes?.start_m,
|
||||
end_m: attributes?.end_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
options: attributes?.options,
|
||||
summary_item: attributes?.summary_item,
|
||||
};
|
||||
}),
|
||||
);
|
||||
callbacks.onPipesChanged?.(buildPipeReports(pipeEditor.pipes(), basins, facilityStore));
|
||||
syncPipeSelection();
|
||||
}
|
||||
|
||||
@@ -405,18 +382,19 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
}
|
||||
}
|
||||
|
||||
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
|
||||
const pipeRequest = () =>
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
);
|
||||
|
||||
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
|
||||
function rememberPipes(): void {
|
||||
if (!projectId) return;
|
||||
writePendingPipes(
|
||||
projectId,
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
),
|
||||
);
|
||||
writePendingPipes(projectId, pipeRequest());
|
||||
}
|
||||
|
||||
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
|
||||
@@ -436,17 +414,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
/** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다.
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = (): Promise<void> =>
|
||||
run(() =>
|
||||
computeDetailBasins(
|
||||
projectId as string,
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
),
|
||||
),
|
||||
);
|
||||
run(() => computeDetailBasins(projectId as string, pipeRequest()));
|
||||
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
@@ -664,16 +632,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
},
|
||||
async savePipes() {
|
||||
if (!projectId) return 0;
|
||||
const response = await saveDetailPipePoints(
|
||||
projectId,
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
facilityStore.attach(
|
||||
pipeEditor.pipes().map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
})),
|
||||
),
|
||||
);
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest());
|
||||
apply(response);
|
||||
scheduleDraw();
|
||||
return response.pipe_count;
|
||||
|
||||
@@ -52,24 +52,25 @@ export interface DrainagePanel {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */
|
||||
export interface DrainagePipeReport {
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
|
||||
design_flow_m3s?: number | null;
|
||||
facility: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}
|
||||
|
||||
export interface DrainagePanelCallbacks {
|
||||
/** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를
|
||||
* 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */
|
||||
onPipesChanged?: (
|
||||
pipes: Array<{
|
||||
chainage_m: number;
|
||||
effective_diameter_mm: number | null;
|
||||
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
|
||||
design_flow_m3s?: number | null;
|
||||
facility: PipeFacility;
|
||||
start_m?: number;
|
||||
end_m?: number;
|
||||
source?: PipeSource;
|
||||
options?: Record<string, string | number>;
|
||||
/** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */
|
||||
summary_item?: SummaryItem | null;
|
||||
}>,
|
||||
) => void;
|
||||
onPipesChanged?: (pipes: DrainagePipeReport[]) => void;
|
||||
/** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */
|
||||
onBasinSelected?: (chainageM: number | null) => void;
|
||||
/** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Report.ts
|
||||
* 배수유역 패널이 관 목록이 바뀔 때 밖에 알리는 **관 보고**(`DrainagePipeReport`)를 만든다.
|
||||
* `B05_Profile_UI_Drainage_Panel` 에서 떼어냄(700줄 제한) — 셈은 그대로, 상태를 안 든다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types";
|
||||
|
||||
/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null.
|
||||
* 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고,
|
||||
* 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조).
|
||||
* 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이
|
||||
* 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */
|
||||
export function buildPipeReports(
|
||||
pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>,
|
||||
basins: ReadonlyArray<DetailBasin>,
|
||||
facilityStore: ReturnType<typeof createFacilityStore>,
|
||||
): DrainagePipeReport[] {
|
||||
return pipes.map((pipe) => {
|
||||
const chainage = Math.round(pipe.chainage_m * 100) / 100;
|
||||
const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51);
|
||||
const attributes = facilityStore.get(chainage);
|
||||
return {
|
||||
chainage_m: chainage,
|
||||
effective_diameter_mm:
|
||||
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
|
||||
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
|
||||
design_flow_m3s: basin?.design_flow_m3s ?? null,
|
||||
facility: attributes?.facility ?? "pipe",
|
||||
start_m: attributes?.start_m,
|
||||
end_m: attributes?.end_m,
|
||||
source: (pipe.reason || "user") as PipeSource,
|
||||
options: attributes?.options,
|
||||
summary_item: attributes?.summary_item,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Side.ts
|
||||
* 배수유역도를 종단도 오른쪽 판(`side`)으로 끼우는 홀로 선 함수(계획서 24-2c · 24-2e).
|
||||
* 판 자리 약속은 `A00_Common/design/design_profile.ts` 의 `DesignSideFactory` 다 —
|
||||
* `createDrainageSide` 가 그 모양 그대로다(`(host) => 판`).
|
||||
*
|
||||
* 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `host`
|
||||
* (`DrainagePanelCallbacks` + projectId · view, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은
|
||||
* 판의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` ·
|
||||
* `selectPipeAtChainage` …)로만 한다.
|
||||
* · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged`
|
||||
* · 관 자리 읽기 · 쓰기(되돌리기) = `pipeChainages()` · `setPipeChainages()`
|
||||
* · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems`
|
||||
* · 구간 띠 · 노선 = `setIntervalSpans()` · `setRoute()`
|
||||
* 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역
|
||||
* 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import "./B05_Profile_UI_Style_Drainage.css"; // 홀로 서려면 판이 자기 모양을 들고 온다.
|
||||
import type { DesignSideBoard, DesignSideHost } from "../A00_Common/design/design_profile";
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
|
||||
/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`)에 projectId · view 를 더한 것. */
|
||||
export type DrainageSideHost = DesignSideHost;
|
||||
|
||||
/** 종단도 side 판 = 배수유역 판 창구 그대로 + 켜고 끔. */
|
||||
export type DrainageSideBoard = DesignSideBoard & {
|
||||
/** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */
|
||||
setVisible: (visible: boolean) => void;
|
||||
isVisible: () => boolean;
|
||||
/** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */
|
||||
onVisibleChange: (listener: (visible: boolean) => void) => () => void;
|
||||
};
|
||||
|
||||
export interface DrainageSideOptions {
|
||||
/** 처음 켤지(기본 켬). */
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/** `DesignSideFactory` — 종단도를 만들 때 한 번 부른다. */
|
||||
export function createDrainageSide(
|
||||
host: DrainageSideHost,
|
||||
options: DrainageSideOptions = {},
|
||||
): DrainageSideBoard {
|
||||
const panel = createDrainagePanel(host);
|
||||
const listeners = new Set<(visible: boolean) => void>();
|
||||
let visible = true;
|
||||
|
||||
function setVisible(next: boolean): void {
|
||||
if (next === visible) return;
|
||||
visible = next;
|
||||
panel.root.hidden = !visible;
|
||||
listeners.forEach((listener) => listener(visible));
|
||||
}
|
||||
// 처음 상태는 알림 없이 맞춘다.
|
||||
if (options.visible === false) {
|
||||
visible = false;
|
||||
panel.root.hidden = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...panel,
|
||||
// 보기가 바뀌어도 판은 다시 안 만든다 — 켬 · 끔은 끼우는 쪽이 `setVisible` 로 정한다.
|
||||
setView: () => {},
|
||||
setVisible,
|
||||
isVisible: () => visible,
|
||||
onVisibleChange(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
dispose() {
|
||||
listeners.clear();
|
||||
panel.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 판을 뒤집는다. */
|
||||
export function createDrainageToggleButton(side: DrainageSideBoard): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ui-btn ui-btn--ghost b05-drainage__toggle";
|
||||
button.textContent = "배수유역도";
|
||||
button.title = "배수유역도 판을 켜고 끕니다";
|
||||
const sync = (visible: boolean): void => {
|
||||
button.setAttribute("aria-pressed", String(visible));
|
||||
button.classList.toggle("is-active", visible);
|
||||
};
|
||||
sync(side.isVisible());
|
||||
side.onVisibleChange(sync);
|
||||
button.addEventListener("click", () => side.setVisible(!side.isVisible()));
|
||||
return button;
|
||||
}
|
||||
@@ -7,33 +7,17 @@
|
||||
* 세션에 쌓인 조작은 [저장]·[확정]에서만 정본으로 나간다).
|
||||
* ========================================================================== */
|
||||
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import {
|
||||
clearRouteLatestCache,
|
||||
confirmRoute,
|
||||
resetRouteDesign,
|
||||
solveRoute,
|
||||
type RouteLatestResponse,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { stateKey } from "../A00_Common/b_page_state";
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { resetDesign } from "../A00_Common/design/design_reset";
|
||||
import { confirmRoute, solveRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
|
||||
import { saveDesign } from "../A00_Common/design/design_save";
|
||||
import {
|
||||
invalidateSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import { clearDrafts } from "../A00_Common/b_page_state";
|
||||
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
|
||||
import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers";
|
||||
import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit";
|
||||
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";
|
||||
@@ -127,100 +111,74 @@ export async function solveRouteAction(ctx: PageActionContext): Promise<void> {
|
||||
}
|
||||
|
||||
/** [임시저장] — 현재 편집을 영구저장소에 남기되 워크플로 단계·페이지는 그대로 둔다.
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의). */
|
||||
* 종·횡 통합 확정은 B06 [확정]이 담당한다(2026-08-08 워크플로우 재정의).
|
||||
* 차례는 저장 한 줄기(`saveDesign`)가 정한다 — B06 [저장]과 같다(계획서 24-2a). */
|
||||
export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
if (!ctx.routeReady()) return;
|
||||
const latest = ctx.latest();
|
||||
const projectId = ctx.projectId;
|
||||
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다.
|
||||
// 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다.
|
||||
await flushPendingPipes(projectId).catch(() => undefined);
|
||||
await ctx
|
||||
.profilePanel()
|
||||
.drainage.savePipes()
|
||||
.catch(() => 0);
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다.
|
||||
await ctx.profilePanel().save();
|
||||
// 구조물도 조작분이 세션에만 있다(2026-08-29 — CLAUDE.md 5장). 실패는 자체 토스트로
|
||||
// 알리고 세션에 남겨 두므로, 여기서 저장 전체를 멈추지 않는다.
|
||||
await ctx.bridge().saveStructuresIfDirty();
|
||||
// 비정규 측점·상단측 변경분까지 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
|
||||
await confirmRoute(
|
||||
projectId,
|
||||
{
|
||||
filter_key: latest?.surface_params.source_filter,
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
|
||||
irregular_stations: ctx
|
||||
.bridge()
|
||||
.irregularStations()
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
|
||||
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
|
||||
chainage_m: Number(chainage),
|
||||
side,
|
||||
})),
|
||||
await saveDesign({
|
||||
projectId,
|
||||
routeId: () => latest?.route?.id ?? null,
|
||||
steps: {
|
||||
// 세션 초안이 나간 뒤 화면 목록으로 한 번 더 맞춘다(시설 보관소 되붙이기 · 응답으로 화면 갱신).
|
||||
pipes: async () => {
|
||||
await ctx.profilePanel().drainage.savePipes();
|
||||
},
|
||||
false,
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
// 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄.
|
||||
if (latest?.route?.id != null) {
|
||||
await flushCulvertOptions(
|
||||
projectId,
|
||||
stateKey("culvertopt", projectId, latest.route.id),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// 구조물 — 저장 뒤 서버 목록(새 식별자)으로 화면을 맞춘다. 실패는 자체 토스트로 알리고
|
||||
// 세션에 남겨 두므로 저장 전체를 멈추지 않는다(2026-08-29 — CLAUDE.md 5장).
|
||||
structures: () => ctx.bridge().saveStructuresIfDirty(),
|
||||
// 종단 계획선 편집은 화면에서만 계산해 두었으므로 저장 시점에 영속화한다(패널도 다시 그림).
|
||||
alignment: () => ctx.profilePanel().save(),
|
||||
// 상단측 + 비정규 측점 — 데이터로는 확정 저장하되, 단계 완료 전이는 하지 않는다.
|
||||
uphill: async () => {
|
||||
await confirmRoute(
|
||||
projectId,
|
||||
{
|
||||
filter_key: latest?.surface_params.source_filter,
|
||||
method: latest?.surface_params.method,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
|
||||
irregular_stations: ctx
|
||||
.bridge()
|
||||
.irregularStations()
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure,
|
||||
})),
|
||||
// 상단측(측구 방향) 사용자 변경분 — 종단 정본에 병합되어 B06이 그대로 소비한다.
|
||||
uphill_overrides: [...ctx.uphillOverrides.entries()].map(([chainage, side]) => ({
|
||||
chainage_m: Number(chainage),
|
||||
side,
|
||||
})),
|
||||
},
|
||||
false,
|
||||
);
|
||||
},
|
||||
},
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
|
||||
// 최종본으로 얹힌다(2026-08-24 사용자 지적).
|
||||
if (latest?.route?.id != null) {
|
||||
await saveCachedCrossPatches(projectId, latest.route.id);
|
||||
}
|
||||
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
|
||||
invalidateSectionDetail(projectId);
|
||||
ctx.renderLatest(await ctx.loadLatest(true));
|
||||
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id);
|
||||
showToast(L("B05_Route_TempSave_Success"), "success");
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
cross: async () => {
|
||||
if (latest?.route?.id != null) await saveCachedCrossPatches(projectId, latest.route.id);
|
||||
},
|
||||
done: async () => {
|
||||
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 B06이 옛 계획선을 못 보게 한다.
|
||||
invalidateSectionDetail(projectId);
|
||||
ctx.renderLatest(await ctx.loadLatest(true));
|
||||
// 임시저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 임시저장은 성공 처리.
|
||||
if (latest?.route?.id) void saveCorridorIfDirty(projectId, latest.route.id);
|
||||
showToast(L("B05_Route_TempSave_Success"), "success");
|
||||
},
|
||||
failed: (error) =>
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_TempSave_Failed"), "error"),
|
||||
});
|
||||
}
|
||||
|
||||
/** [초기화] — 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입.
|
||||
* 네이티브 confirm은 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다
|
||||
* (2026-08-19 진단) — 화면 안 모달로 확인받는다. */
|
||||
/** [초기화] — 두 보기가 같은 함수를 부른다(`design_reset` · 계획서 24-2b). 페이지만 아는
|
||||
* 상단측 변경분만 여기서 비운다. */
|
||||
export async function resetDesignAction(ctx: PageActionContext): Promise<void> {
|
||||
if (!(await showConfirmDialog(L("B05_Route_Reset_Confirm"), "초기화"))) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await resetRouteDesign(ctx.projectId);
|
||||
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
|
||||
clearRouteLatestCache(ctx.projectId);
|
||||
invalidateSectionDetail(ctx.projectId);
|
||||
// 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화).
|
||||
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
|
||||
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
|
||||
clearDrafts(ctx.projectId, ctx.latest()?.route?.id ?? null);
|
||||
ctx.uphillOverrides.clear();
|
||||
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
|
||||
// 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남).
|
||||
clearAlignmentDrafts();
|
||||
showToast(L("B05_Route_Reset_Success"), "success");
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : L("B05_Route_Reset_Failed"), "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
await resetDesign(ctx.projectId, ctx.latest()?.route?.id ?? null, () =>
|
||||
ctx.uphillOverrides.clear(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +1,29 @@
|
||||
import type { PlacedRoutePoint, RoutePointKind } from "./B05_Profile_UI_Markers";
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel.ts
|
||||
* 종단 보기(B05) 좌측 패널 — 칸 재료를 모아 **공용 등록부**(`design_panel`)에 넘겨 세운다.
|
||||
*
|
||||
* 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는
|
||||
* `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields`
|
||||
* 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만
|
||||
* 여기 남는다. 아래 고정 영역(단추 넷)은 `design_dock` 이 만든다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { PlacedRoutePoint } from "./B05_Profile_UI_Markers";
|
||||
import type { StructureInstance } from "./B05_Profile_Api_Structures";
|
||||
import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { createStructuresSection, type StructuresSection } from "./B05_Profile_UI_Structures_Panel";
|
||||
import { type ButtonVariant, createButton, createSelectField } from "@ui/ui_template_elements";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { createDesignPanel } from "../A00_Common/design/design_panel";
|
||||
import "./B05_Profile_UI_Panel_Sections";
|
||||
import {
|
||||
STATION_INTERVAL_M,
|
||||
parseOptional,
|
||||
type RoutePanelValues,
|
||||
} from "./B05_Profile_UI_Panel_Fields";
|
||||
import { createRoutePart } from "./B05_Profile_UI_Panel_Route";
|
||||
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
|
||||
import { createViewControls } from "./B05_Profile_UI_Panel_View";
|
||||
|
||||
export interface RoutePanelValues {
|
||||
contourInterval: number;
|
||||
algorithm: "dijkstra" | "ridge_valley";
|
||||
/** 임도 종류 — 현행 3종(간선·산불진화·작업). branch(지선)는 폐지됐으나 기존
|
||||
* 저장분 호환을 위해 값으로는 받는다(2026-08-19). 값의 원천은 프로젝트 등록
|
||||
* 정보(projects.road_type)이며 화면에서는 고르지 않는다. */
|
||||
gradeClass: "trunk" | "fire" | "branch" | "work";
|
||||
/** 설계속도(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;
|
||||
terrainType: "normal" | "special";
|
||||
maxGradePct: number | null;
|
||||
minVerticalRadius: number | null;
|
||||
minTangentLength: number | null;
|
||||
startElevationOffset: number | null;
|
||||
endElevationOffset: number | null;
|
||||
/** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시).
|
||||
* 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */
|
||||
enforcePipeClearance: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
const STATION_INTERVAL_M = 20;
|
||||
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["designSpeed"],
|
||||
{ grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
|
||||
const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
trunk: "간선임도",
|
||||
fire: "산불진화임도",
|
||||
work: "작업임도",
|
||||
branch: "작업임도(구 지선)",
|
||||
};
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
interface PanelCallbacks {
|
||||
onSolve: () => void;
|
||||
@@ -136,372 +77,35 @@ interface PanelCallbacks {
|
||||
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
||||
}
|
||||
|
||||
type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function section(title: string, collapsed = false): { root: HTMLElement; body: HTMLElement } {
|
||||
const root = document.createElement("section");
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
|
||||
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = title;
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
return { root, body };
|
||||
}
|
||||
|
||||
function button(
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
variant: ButtonVariant = "ghost",
|
||||
): HTMLButtonElement {
|
||||
return createButton({ label, variant, onClick: () => onClick() });
|
||||
}
|
||||
|
||||
function numberField(label: string, value = ""): WrappedInput {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = label;
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "0.01";
|
||||
input.value = value;
|
||||
wrapper.append(caption, input);
|
||||
return Object.assign(input, { wrapper });
|
||||
}
|
||||
|
||||
function checkbox(label: string, checked: boolean): WrappedInput {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__check";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = checked;
|
||||
wrapper.append(input, document.createTextNode(label));
|
||||
return Object.assign(input, { wrapper });
|
||||
}
|
||||
|
||||
function toggleButton(
|
||||
label: string,
|
||||
checked: boolean,
|
||||
onChange: (checked: boolean) => void,
|
||||
): HTMLButtonElement {
|
||||
const element = button(
|
||||
label,
|
||||
() => {
|
||||
const active = !element.classList.contains("is-active");
|
||||
element.classList.toggle("is-active", active);
|
||||
element.setAttribute("aria-pressed", String(active));
|
||||
onChange(active);
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
element.classList.toggle("is-active", checked);
|
||||
element.setAttribute("aria-pressed", String(checked));
|
||||
return element;
|
||||
}
|
||||
|
||||
function parseOptional(input: HTMLInputElement): number | null {
|
||||
if (!input.value.trim()) return null;
|
||||
const value = Number(input.value);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-route__panel";
|
||||
|
||||
const viewControls = document.createElement("div");
|
||||
viewControls.className = "b05-route__view-controls";
|
||||
const viewButtons = document.createElement("div");
|
||||
viewButtons.className = "b05-route__view-group";
|
||||
(["iso", "top", "front", "side"] as const).forEach((preset) =>
|
||||
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
|
||||
);
|
||||
// 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을
|
||||
// 가리킨다(누르면 그쪽으로 간다).
|
||||
let projection: "perspective" | "ortho" = "perspective";
|
||||
const projectionButton = button(
|
||||
"직교로",
|
||||
() => {
|
||||
projection = projection === "perspective" ? "ortho" : "perspective";
|
||||
projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로";
|
||||
callbacks.onProjection(projection);
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
viewButtons.append(projectionButton);
|
||||
const visibilityButtons = document.createElement("div");
|
||||
visibilityButtons.className = "b05-route__view-group";
|
||||
visibilityButtons.append(
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
|
||||
toggleButton("예상형상", true, callbacks.onCorridorVisible),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
|
||||
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
|
||||
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
|
||||
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
|
||||
);
|
||||
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
|
||||
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
|
||||
// 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장).
|
||||
const corridorRefresh = button(
|
||||
"3D 업데이트",
|
||||
() => {
|
||||
corridorRefresh.disabled = true;
|
||||
corridorRefresh.textContent = "갱신 중…";
|
||||
void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => {
|
||||
corridorRefresh.disabled = false;
|
||||
corridorRefresh.textContent = "3D 업데이트";
|
||||
corridorRefresh.classList.remove("is-pending");
|
||||
});
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
corridorRefresh.title =
|
||||
"계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다.";
|
||||
const separator1 = document.createElement("span");
|
||||
separator1.className = "b05-route__view-separator";
|
||||
const separator2 = separator1.cloneNode() as HTMLSpanElement;
|
||||
const separator3 = separator1.cloneNode() as HTMLSpanElement;
|
||||
viewControls.append(
|
||||
viewButtons,
|
||||
separator1,
|
||||
visibilityButtons,
|
||||
separator2,
|
||||
button("뷰 초기화", callbacks.onResetView, "glass"),
|
||||
separator3,
|
||||
corridorRefresh,
|
||||
);
|
||||
|
||||
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
|
||||
const contourInterval = numberField("간격 (m), 최소 0.5m", "1");
|
||||
const contourRow = document.createElement("div");
|
||||
contourRow.className = "b05-route__contour-row";
|
||||
// 등고선 재적용은 화면 전체를 잠그지 않는다(CLAUDE.md 5장) — 누른 버튼만 잠가
|
||||
// 진행을 알리고, 나머지 조작은 그대로 열어 둔다.
|
||||
const contourApply = button("재적용", () => {
|
||||
contourApply.disabled = true;
|
||||
contourApply.textContent = "적용 중…";
|
||||
void Promise.resolve(callbacks.onContourApply(Number(contourInterval.value) || 1)).finally(
|
||||
() => {
|
||||
contourApply.disabled = false;
|
||||
contourApply.textContent = "재적용";
|
||||
},
|
||||
);
|
||||
});
|
||||
contourRow.append(contourInterval.wrapper, contourApply);
|
||||
|
||||
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
|
||||
// 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다
|
||||
// (2026-08-08 사용자 지시).
|
||||
// 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색
|
||||
// (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다.
|
||||
const plannedRoute = section("계획노선");
|
||||
const plannedRouteBtn = document.createElement("button");
|
||||
plannedRouteBtn.type = "button";
|
||||
plannedRouteBtn.className = "b05-route__btn";
|
||||
plannedRouteBtn.textContent = "계획노선 편집";
|
||||
plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다.";
|
||||
plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute());
|
||||
plannedRoute.body.append(plannedRouteBtn);
|
||||
|
||||
const routeCalc = section("경로 계산 설정");
|
||||
routeCalc.root.classList.add("is-collapsed");
|
||||
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);
|
||||
});
|
||||
routeCalc.body.append(paletteGrid);
|
||||
|
||||
const selected = section("선택 포인트 상세 설정");
|
||||
selected.root.hidden = true;
|
||||
const selectedName = document.createElement("strong");
|
||||
const radius = numberField("회피/금지 반경 (m)", "25");
|
||||
radius.addEventListener("change", () => callbacks.onRadiusChange(Number(radius.value) || 1));
|
||||
const selectedActions = document.createElement("div");
|
||||
selectedActions.className = "b05-route__actions";
|
||||
selectedActions.append(
|
||||
button("위치 이동", callbacks.onMovePoint),
|
||||
button("삭제", callbacks.onDeletePoint, "danger"),
|
||||
);
|
||||
selected.body.append(selectedName, radius.wrapper, selectedActions);
|
||||
|
||||
// 드롭다운은 공통 컴포넌트(createSelectField) 재사용. `.select`로 받아 이하 로직 불변.
|
||||
const algorithmField = createSelectField({
|
||||
label: "알고리즘",
|
||||
options: [
|
||||
{ value: "dijkstra", text: "Dijkstra" },
|
||||
{ value: "ridge_valley", text: "능선·계곡" },
|
||||
],
|
||||
});
|
||||
const algorithm = algorithmField.select;
|
||||
// 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는
|
||||
// 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다.
|
||||
const gradeField = createSelectField({
|
||||
label: "임도 종류 (프로젝트 등록값)",
|
||||
options: [
|
||||
{ value: "trunk", text: ROAD_TYPE_LABELS.trunk },
|
||||
{ value: "fire", text: ROAD_TYPE_LABELS.fire },
|
||||
{ value: "work", text: ROAD_TYPE_LABELS.work },
|
||||
{ value: "branch", text: ROAD_TYPE_LABELS.branch },
|
||||
],
|
||||
});
|
||||
const gradeClass = gradeField.select;
|
||||
gradeClass.disabled = true;
|
||||
gradeClass.title = "프로젝트 등록 정보의 임도 종류입니다. 변경은 대시보드에서 하세요.";
|
||||
// 설계속도 — 임도는 속도를 낼 수 없어 기본 20km/h다. 종류에 따라 선택지가 갈린다.
|
||||
const speedField = createSelectField({
|
||||
label: "설계속도 (km/h)",
|
||||
options: [20, 30, 40].map((value) => ({ value: String(value), text: `${value} km/h` })),
|
||||
});
|
||||
const designSpeed = speedField.select;
|
||||
designSpeed.value = "20";
|
||||
designSpeed.title =
|
||||
"종단기울기·종단곡선의 법정 기준은 설계속도로 정해집니다(별표2).\n" +
|
||||
"간선·산불진화임도는 20~40, 작업임도는 20만 쓸 수 있습니다.";
|
||||
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;
|
||||
routeCalc.body.append(
|
||||
algorithmField.root,
|
||||
paved.wrapper,
|
||||
avoidPass.wrapper,
|
||||
details,
|
||||
solveButton,
|
||||
);
|
||||
|
||||
// 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합
|
||||
// (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정.
|
||||
const sectionOptions = section("페이지 설정");
|
||||
sectionOptions.root.classList.add("is-collapsed");
|
||||
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
||||
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
||||
// 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시).
|
||||
const sampleRow = document.createElement("div");
|
||||
sampleRow.className = "b05-route__field-row";
|
||||
sampleRow.append(longSampleInterval.wrapper, crossSampleInterval.wrapper);
|
||||
sectionOptions.body.append(sampleRow);
|
||||
const terrainField = createSelectField({
|
||||
label: "지형 구분",
|
||||
options: [
|
||||
{ value: "normal", text: "일반지형" },
|
||||
{ value: "special", text: "특수지형" },
|
||||
],
|
||||
});
|
||||
const terrainType = terrainField.select;
|
||||
// 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라
|
||||
// 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다.
|
||||
terrainType.value = "special";
|
||||
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
|
||||
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
|
||||
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
|
||||
// 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는
|
||||
// 종단 상단 표시줄에 계속 뜬다.
|
||||
const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false);
|
||||
enforcePipeClearance.title =
|
||||
"켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" +
|
||||
"끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본).";
|
||||
const maxGradePct = numberField("최대 종단기울기 (%)");
|
||||
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
|
||||
const minTangentLength = numberField("최소 직선 길이 (m)");
|
||||
const startElevationOffset = numberField("시점 계획고 조정 (m)");
|
||||
const endElevationOffset = numberField("종점 계획고 조정 (m)");
|
||||
const criteriaNote = document.createElement("p");
|
||||
criteriaNote.className = "b05-route__note";
|
||||
const gradeAdvanced = document.createElement("details");
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
minVerticalRadius.wrapper,
|
||||
minTangentLength.wrapper,
|
||||
startElevationOffset.wrapper,
|
||||
endElevationOffset.wrapper,
|
||||
);
|
||||
// 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준.
|
||||
const settingsDivider1 = document.createElement("hr");
|
||||
settingsDivider1.className = "b05-structure__divider";
|
||||
const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement;
|
||||
sectionOptions.body.append(
|
||||
settingsDivider1,
|
||||
contourRow,
|
||||
settingsDivider2,
|
||||
// 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다
|
||||
// (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서).
|
||||
gradeField.root,
|
||||
speedField.root,
|
||||
terrainField.root,
|
||||
criteriaNote,
|
||||
enforcePipeClearance.wrapper,
|
||||
gradeAdvanced,
|
||||
);
|
||||
|
||||
// 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다.
|
||||
// (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0.
|
||||
// 별도 "공사 시작점" 컨테이너를 없애고 "시작 측점 및 샘플링 설정" 최상단으로 일원화
|
||||
// (2026-08-06 사용자 지시).
|
||||
const startStation = numberField("시작 측점", "0");
|
||||
startStation.step = "1";
|
||||
startStation.min = "0";
|
||||
const startCumulative = numberField("시작 누가거리 (m)", "0");
|
||||
// 2열×2행: 1행 항목명 · 2행 값 입력 / 1열 시작 측점 · 2열 시작 누가거리.
|
||||
const startRow = document.createElement("div");
|
||||
startRow.className = "b05-route__field-row";
|
||||
startRow.append(startStation.wrapper, startCumulative.wrapper);
|
||||
sectionOptions.body.prepend(startRow);
|
||||
const stationDisplayOffset = (): { station: number; cumulative: number } => ({
|
||||
station: Math.max(0, Math.round(Number(startStation.value) || 0)),
|
||||
cumulative: Number(startCumulative.value) || 0,
|
||||
});
|
||||
[startStation, startCumulative].forEach((input) =>
|
||||
input.addEventListener("change", () =>
|
||||
callbacks.onStationDisplayChange(stationDisplayOffset()),
|
||||
),
|
||||
);
|
||||
const { viewControls, setCorridorPending } = createViewControls(callbacks);
|
||||
const settings = createSettingsPart(callbacks);
|
||||
const route = createRoutePart(callbacks);
|
||||
const { fields: routeFields } = route;
|
||||
const {
|
||||
contourInterval,
|
||||
gradeClass,
|
||||
designSpeed,
|
||||
terrainType,
|
||||
crossSampleInterval,
|
||||
longSampleInterval,
|
||||
maxGradePct,
|
||||
minVerticalRadius,
|
||||
minTangentLength,
|
||||
startElevationOffset,
|
||||
endElevationOffset,
|
||||
enforcePipeClearance,
|
||||
} = settings.fields;
|
||||
const {
|
||||
algorithm,
|
||||
paved,
|
||||
avoidPass,
|
||||
minCurveRadius,
|
||||
maxUphillGrade,
|
||||
maxDownhillGrade,
|
||||
minUphillGrade,
|
||||
minDownhillGrade,
|
||||
} = routeFields;
|
||||
|
||||
// 「구조물 배치」 — 구 비정규 측점 섹션을 흡수한 단일 섹션(2026-08-17 컨테이너 병합).
|
||||
// 타입 목록과 옵션 칸은 서버 레지스트리에서 받아 그리고, 계곡 통과 시설(A군)은 관
|
||||
@@ -517,74 +121,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
onReveal: () => callbacks.onStructureReveal?.(),
|
||||
});
|
||||
|
||||
/** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면
|
||||
* 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다
|
||||
* (2026-08-19 사용자 지시 6). 특수지형+포장 예외(18%)는 서버가 최종 판정한다. */
|
||||
function currentSpeed(): RoutePanelValues["designSpeed"] {
|
||||
const value = Number(designSpeed.value);
|
||||
return (value === 40 || value === 30 ? value : 20) as RoutePanelValues["designSpeed"];
|
||||
}
|
||||
|
||||
function gradeLimitPct(): number {
|
||||
const criteria = PROFILE_CRITERIA[currentSpeed()];
|
||||
const terrain = terrainType.value as RoutePanelValues["terrainType"];
|
||||
const typed = parseOptional(maxGradePct);
|
||||
return typed != null && typed > 0 ? typed : criteria.grade[terrain];
|
||||
}
|
||||
|
||||
/** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */
|
||||
function syncSpeedChoices(): void {
|
||||
const allowed = SPEED_CHOICES[gradeClass.value] ?? [20];
|
||||
const keep = allowed.includes(currentSpeed()) ? currentSpeed() : allowed[0];
|
||||
designSpeed.replaceChildren(
|
||||
...allowed.map((value) => new Option(`${value} km/h`, String(value))),
|
||||
);
|
||||
designSpeed.value = String(keep);
|
||||
// 고를 것이 하나뿐이면 잠근다 — 열어 두면 바꿀 수 있다는 오해를 준다.
|
||||
designSpeed.disabled = allowed.length < 2;
|
||||
}
|
||||
|
||||
/** 설계속도·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
|
||||
function syncCriteria(): void {
|
||||
const speed = currentSpeed();
|
||||
const criteria = PROFILE_CRITERIA[speed];
|
||||
const terrain = terrainType.value as RoutePanelValues["terrainType"];
|
||||
maxGradePct.placeholder = String(criteria.grade[terrain]);
|
||||
minVerticalRadius.placeholder = String(criteria.radius);
|
||||
minTangentLength.placeholder = "20";
|
||||
const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value;
|
||||
criteriaNote.textContent =
|
||||
`${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` +
|
||||
`종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` +
|
||||
"비워두면 이 기준이 적용됩니다.";
|
||||
}
|
||||
// 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이
|
||||
// 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다.
|
||||
[designSpeed, terrainType, maxGradePct].forEach((input) =>
|
||||
input.addEventListener("change", syncCriteria),
|
||||
);
|
||||
maxGradePct.addEventListener("input", syncCriteria);
|
||||
syncSpeedChoices();
|
||||
syncCriteria();
|
||||
|
||||
// 하단 고정 액션 행: [초기화][임시저장][횡단 이동] — 경로 확정 개념 폐지, 종·횡 통합
|
||||
// 확정은 B06에서 한다(2026-08-08 워크플로우 재정의).
|
||||
const resetButton = button(L("Common_Btn_Reset"), callbacks.onReset, "danger");
|
||||
const tempSaveButton = button(L("B05_Route_Btn_TempSave"), callbacks.onTempSave);
|
||||
const goCrossButton = button(L("B05_Route_Btn_GoCross"), callbacks.onGoCross, "filled");
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b05-route__actions";
|
||||
actionRow.append(resetButton, tempSaveButton, goCrossButton);
|
||||
|
||||
// 사이드 최하단 고정 영역(2026-08-05 사용자 지시) — 위 구분선(dock 상단 테두리) →
|
||||
// 구조물 목록 → 중간 구분선 → 액션 버튼 순. 목록을 「구조물 배치」 본문에 두면
|
||||
// 고른 구조물의 폼 길이에 밀려 화면 밖으로 나갔다(2026-08-18 사용자 지시).
|
||||
const dockDivider = document.createElement("hr");
|
||||
dockDivider.className = "b05-structure__divider";
|
||||
const actionDock = document.createElement("div");
|
||||
actionDock.className = "b05-route__dock ui-sidebar-actions";
|
||||
actionDock.append(structures.listRoot, dockDivider, actionRow);
|
||||
|
||||
const inputElements = [
|
||||
algorithm,
|
||||
gradeClass,
|
||||
@@ -608,40 +144,38 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
|
||||
// 경로 계산 설정 — 사용여부 미결정으로 조작 불가(숨기지 않는다, 2026-08-18 사용자
|
||||
// 지시). inert가 본문 전체 입력·드래그를 막고, is-disabled가 흐림을 그린다.
|
||||
// 제목 클릭(접기/펼치기)은 본문 밖이라 그대로 산다.
|
||||
routeCalc.root.classList.add("is-disabled");
|
||||
routeCalc.body.inert = true;
|
||||
|
||||
// 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트
|
||||
// (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정).
|
||||
root.append(
|
||||
structures.root,
|
||||
sectionOptions.root,
|
||||
plannedRoute.root,
|
||||
routeCalc.root,
|
||||
selected.root,
|
||||
actionDock,
|
||||
// 좌측 패널 = 등록부가 칸을 차례대로 세우고 아래 고정 영역(단추 넷)을 붙인다. 하단 단추의
|
||||
// [확정]은 저장 흐름(`design_save`)이 이어질 때까지 꺼 둔다 — 종·횡 통합 확정은 횡단 보기에서
|
||||
// 한다(2026-08-08 워크플로우 재정의).
|
||||
const design = createDesignPanel(
|
||||
{
|
||||
projectId: null,
|
||||
parts: { structures, settings, route },
|
||||
dock: {
|
||||
reset: { run: callbacks.onReset },
|
||||
save: { run: callbacks.onTempSave },
|
||||
confirm: {
|
||||
run: () => undefined,
|
||||
enabled: false,
|
||||
title: "확정은 횡단 보기에서 합니다(저장 한 줄기가 이어지면 이 자리에서 켜집니다).",
|
||||
},
|
||||
switchView: { run: callbacks.onGoCross },
|
||||
},
|
||||
},
|
||||
"profile",
|
||||
);
|
||||
|
||||
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
|
||||
// 접힘 항목은 손대지 않는다.
|
||||
attachCollapsible(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
root: design.root,
|
||||
viewControls,
|
||||
/** 「구조물 배치」 섹션 API(타입 주입·목록 교체·계곡 시설 병합·선택·추가/이동/삭제). */
|
||||
structures: structures as StructuresSection,
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
|
||||
stationDisplayOffset,
|
||||
stationDisplayOffset: settings.stationDisplayOffset,
|
||||
/** 지금 적용되는 종단기울기 상한(%) — 그래프 위반 판정 즉시 반영용(2026-08-19). */
|
||||
gradeLimitPct,
|
||||
gradeLimitPct: settings.gradeLimitPct,
|
||||
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
|
||||
setCorridorPending(pending: boolean) {
|
||||
corridorRefresh.classList.toggle("is-pending", pending);
|
||||
},
|
||||
setCorridorPending,
|
||||
values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
@@ -657,7 +191,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
stationInterval: STATION_INTERVAL_M,
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
designSpeed: currentSpeed(),
|
||||
designSpeed: settings.currentSpeed(),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
maxGradePct: parseOptional(maxGradePct),
|
||||
minVerticalRadius: parseOptional(minVerticalRadius),
|
||||
@@ -685,7 +219,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
longSampleInterval.value = String(values.longSampleInterval);
|
||||
if (values.gradeClass) {
|
||||
gradeClass.value = values.gradeClass;
|
||||
syncSpeedChoices();
|
||||
settings.syncSpeedChoices();
|
||||
}
|
||||
if (values.designSpeed) designSpeed.value = String(values.designSpeed);
|
||||
if (values.terrainType) terrainType.value = values.terrainType;
|
||||
@@ -699,14 +233,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
endElevationOffset.value = String(values.endElevationOffset);
|
||||
if (values.enforcePipeClearance != null)
|
||||
enforcePipeClearance.checked = values.enforcePipeClearance;
|
||||
syncCriteria();
|
||||
settings.syncCriteria();
|
||||
},
|
||||
setSelected(point: PlacedRoutePoint | null) {
|
||||
selected.root.hidden = !point;
|
||||
if (!point) return;
|
||||
selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`;
|
||||
radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp";
|
||||
radius.value = String(point.radius_m ?? 25);
|
||||
route.setSelected(point);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Fields.ts
|
||||
* 좌측 패널이 함께 쓰는 **값 · 상수 · 입력 칸 만들기** — `B05_Profile_UI_Panel.ts` 가 714줄이라
|
||||
* 떼어낸 조각이다(계획서 24-2b). 본문 로직과 수치는 그대로다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { type ButtonVariant, createButton } from "@ui/ui_template_elements";
|
||||
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)이며 화면에서는 고르지 않는다. */
|
||||
gradeClass: "trunk" | "fire" | "branch" | "work";
|
||||
/** 설계속도(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;
|
||||
terrainType: "normal" | "special";
|
||||
maxGradePct: number | null;
|
||||
minVerticalRadius: number | null;
|
||||
minTangentLength: number | null;
|
||||
startElevationOffset: number | null;
|
||||
endElevationOffset: number | null;
|
||||
/** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시).
|
||||
* 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */
|
||||
enforcePipeClearance: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임도설치 및 관리 등에 관한 규정」[별표 1-2] 기준값.
|
||||
* 등급을 바꾸면 이 값이 계획선 폼의 placeholder(미입력 시 서버 기본값)로 반영된다.
|
||||
* 실제 기본값 결정은 서버 config가 단일 소스이며 여기서는 안내만 한다.
|
||||
*/
|
||||
/** 측점 간격(m) — 20m 고정(2026-08-18 사용자 확정, 입력 UI 삭제). */
|
||||
export const STATION_INTERVAL_M = 20;
|
||||
|
||||
/* 종단기울기·종단곡선 반경 — 지식DB `설계제원_총괄 §6·§7`(간선·산불진화 공통,
|
||||
* 설계속도 40/30/20 → 7·10 / 8·12 / 9·14 %, 반경 450/250/100 m) 값과 일치한다
|
||||
* (2026-08-19 재점검). 서버 `legal_grade_limit_pct`가 같은 표를 쓰므로 화면 안내와
|
||||
* 실제 적용값이 어긋나지 않는다.
|
||||
*
|
||||
* ⚠ 미해결(사용자 협의 필요, 2026-08-19 재점검에서 확인):
|
||||
* ① 등급 선택지가 간선·지선·작업인데 현행 규칙은 **지선 폐지**, 3종은 간선·
|
||||
* 산불진화·작업이다(설계제원_총괄 §1 [구현] 지침).
|
||||
* ② 지식DB의 40/30/20 표는 **설계속도**별이고 간선·산불진화 공통이다. 지금은
|
||||
* 등급에 속도를 고정 매핑(trunk=40·branch=30·work=20)했는데, 작업임도는
|
||||
* 설계속도 20 이하 + 종단기울기 최대 20% 범위 조정이라는 별도 규정이다.
|
||||
* ③ "최소 곡선반경" 입력 칸의 안내값이 **종단**곡선 반경(450/250/100)이다.
|
||||
* 평면 최소곡선반지름은 일반지형 60/30/15 (설계제원_총괄 §4).
|
||||
* 값을 임의로 바꾸지 않고 남긴다 — 기본값 확정은 사용자 협의 영역이다. */
|
||||
export const PROFILE_CRITERIA: Record<
|
||||
RoutePanelValues["designSpeed"],
|
||||
{ grade: Record<RoutePanelValues["terrainType"], number>; radius: number }
|
||||
> = {
|
||||
40: { grade: { normal: 7, special: 10 }, radius: 450 },
|
||||
30: { grade: { normal: 8, special: 12 }, radius: 250 },
|
||||
20: { grade: { normal: 9, special: 14 }, radius: 100 },
|
||||
};
|
||||
|
||||
/** 임도 종류별 표시 이름 — 프로젝트 등록(B02)과 같은 말을 쓴다(2026-08-19 확정). */
|
||||
export const ROAD_TYPE_LABELS: Record<string, string> = {
|
||||
trunk: "간선임도",
|
||||
fire: "산불진화임도",
|
||||
work: "작업임도",
|
||||
branch: "작업임도(구 지선)",
|
||||
};
|
||||
|
||||
/** 종류별로 고를 수 있는 설계속도 — 작업임도는 20 이하라 20만 둔다(별표2 Ⅰ.3). */
|
||||
export const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
|
||||
trunk: [20, 30, 40],
|
||||
fire: [20, 30, 40],
|
||||
work: [20],
|
||||
branch: [20],
|
||||
};
|
||||
|
||||
export type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export function button(
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
variant: ButtonVariant = "ghost",
|
||||
): HTMLButtonElement {
|
||||
return createButton({ label, variant, onClick: () => onClick() });
|
||||
}
|
||||
|
||||
export function numberField(label: string, value = ""): WrappedInput {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = label;
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = "0.01";
|
||||
input.value = value;
|
||||
wrapper.append(caption, input);
|
||||
return Object.assign(input, { wrapper });
|
||||
}
|
||||
|
||||
export function checkbox(label: string, checked: boolean): WrappedInput {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__check";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.checked = checked;
|
||||
wrapper.append(input, document.createTextNode(label));
|
||||
return Object.assign(input, { wrapper });
|
||||
}
|
||||
|
||||
export function toggleButton(
|
||||
label: string,
|
||||
checked: boolean,
|
||||
onChange: (checked: boolean) => void,
|
||||
): HTMLButtonElement {
|
||||
const element = button(
|
||||
label,
|
||||
() => {
|
||||
const active = !element.classList.contains("is-active");
|
||||
element.classList.toggle("is-active", active);
|
||||
element.setAttribute("aria-pressed", String(active));
|
||||
onChange(active);
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
element.classList.toggle("is-active", checked);
|
||||
element.setAttribute("aria-pressed", String(checked));
|
||||
return element;
|
||||
}
|
||||
|
||||
export function parseOptional(input: HTMLInputElement): number | null {
|
||||
if (!input.value.trim()) return null;
|
||||
const value = Number(input.value);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Route.ts
|
||||
* 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추 · 경로 계산 설정(포인트 팔레트 · 알고리즘 ·
|
||||
* 조건) · 선택 포인트 상세. 칸 틀은 `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부).
|
||||
* `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";
|
||||
|
||||
export interface RoutePartCallbacks {
|
||||
onSolve: () => void;
|
||||
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
|
||||
onEditPlannedRoute: () => void;
|
||||
onMovePoint: () => void;
|
||||
onDeletePoint: () => void;
|
||||
onRadiusChange: (radius: number) => void;
|
||||
}
|
||||
|
||||
export function createRoutePart(callbacks: RoutePartCallbacks) {
|
||||
// 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색
|
||||
// (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다.
|
||||
const plannedRouteBtn = document.createElement("button");
|
||||
plannedRouteBtn.type = "button";
|
||||
plannedRouteBtn.className = "b05-route__btn";
|
||||
plannedRouteBtn.textContent = "계획노선 편집";
|
||||
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));
|
||||
const selectedActions = document.createElement("div");
|
||||
selectedActions.className = "b05-route__actions";
|
||||
selectedActions.append(
|
||||
button("위치 이동", callbacks.onMovePoint),
|
||||
button("삭제", callbacks.onDeletePoint, "danger"),
|
||||
);
|
||||
// 칸 뿌리는 등록부가 만든 뒤 알려 준다 — 평소 숨김(포인트를 고를 때만 보인다).
|
||||
let selectedRoot: HTMLElement | null = null;
|
||||
|
||||
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;
|
||||
},
|
||||
setSelected(point: PlacedRoutePoint | null): void {
|
||||
if (selectedRoot) selectedRoot.hidden = !point;
|
||||
if (!point) return;
|
||||
selectedName.textContent = `${point.type.toUpperCase()} (${point.x.toFixed(2)}, ${point.y.toFixed(2)})`;
|
||||
radius.wrapper.hidden = point.type !== "ap" && point.type !== "fp";
|
||||
radius.value = String(point.radius_m ?? 25);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RoutePart = ReturnType<typeof createRoutePart>;
|
||||
@@ -0,0 +1,69 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Sections.ts
|
||||
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선 ·
|
||||
* 경로 계산 설정 · 선택 포인트 상세. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
|
||||
*
|
||||
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 경로 계산 설정(비활성)
|
||||
* > 선택 포인트(평소 숨김) > 아래 고정 영역. 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어
|
||||
* `ctx.parts` 로 건넨다.
|
||||
* ========================================================================== */
|
||||
|
||||
import "../A00_Common/design/design_sections";
|
||||
import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel";
|
||||
import type { RoutePart } from "./B05_Profile_UI_Panel_Route";
|
||||
import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings";
|
||||
|
||||
registerPanelSection({
|
||||
id: "page-settings",
|
||||
title: "페이지 설정",
|
||||
views: ["profile"],
|
||||
order: 20,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, "페이지 설정", true);
|
||||
frame.body.append(...part<SettingsPart>(ctx, "settings").nodes);
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
|
||||
registerPanelSection({
|
||||
id: "planned-route",
|
||||
title: "계획노선",
|
||||
views: ["profile"],
|
||||
order: 30,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, "계획노선");
|
||||
frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton);
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
|
||||
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: "선택 포인트 상세 설정",
|
||||
views: ["profile"],
|
||||
order: 50,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, "선택 포인트 상세 설정");
|
||||
const route = part<RoutePart>(ctx, "route");
|
||||
frame.body.append(...route.selectedNodes);
|
||||
route.bindSelectedRoot(frame.root);
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_Settings.ts
|
||||
* 좌측 패널 「페이지 설정」 칸의 **재료** — 시작 측점 · 샘플 간격 · 등고선 간격 · 임도 종류 · 설계속도 ·
|
||||
* 지형 구분 · 기준값. 칸 틀은 `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부).
|
||||
* `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다.
|
||||
*
|
||||
* 「페이지 설정」 — 등고선 간격·시작 측점 및 샘플링·종단 설계 기준 세 섹션 병합
|
||||
* (2026-08-18 사용자 확정). 측점 간격 입력은 삭제 — 20m 고정.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSelectField } from "@ui/ui_template_elements";
|
||||
import {
|
||||
L,
|
||||
PROFILE_CRITERIA,
|
||||
ROAD_TYPE_LABELS,
|
||||
SPEED_CHOICES,
|
||||
button,
|
||||
checkbox,
|
||||
numberField,
|
||||
parseOptional,
|
||||
type RoutePanelValues,
|
||||
} from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
export interface SettingsPartCallbacks {
|
||||
onContourApply: (interval: number) => void | Promise<void>;
|
||||
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
|
||||
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
||||
}
|
||||
|
||||
export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
// 등고선 간격 행 — 「페이지 설정」 섹션에 들어간다(2026-08-18 컨테이너 병합).
|
||||
const contourInterval = numberField("간격 (m), 최소 0.5m", "1");
|
||||
const contourRow = document.createElement("div");
|
||||
contourRow.className = "b05-route__contour-row";
|
||||
// 등고선 재적용은 화면 전체를 잠그지 않는다(CLAUDE.md 5장) — 누른 버튼만 잠가
|
||||
// 진행을 알리고, 나머지 조작은 그대로 열어 둔다.
|
||||
const contourApply = button("재적용", () => {
|
||||
contourApply.disabled = true;
|
||||
contourApply.textContent = "적용 중…";
|
||||
void Promise.resolve(callbacks.onContourApply(Number(contourInterval.value) || 1)).finally(
|
||||
() => {
|
||||
contourApply.disabled = false;
|
||||
contourApply.textContent = "재적용";
|
||||
},
|
||||
);
|
||||
});
|
||||
contourRow.append(contourInterval.wrapper, contourApply);
|
||||
|
||||
// 임도 종류는 **프로젝트 등록(B02)에서 정한 값**을 그대로 쓴다 — 여기서 고치는
|
||||
// 값이 아니다(2026-08-19 사용자 확정). 화면에는 읽기 전용으로 보여만 준다.
|
||||
const gradeField = createSelectField({
|
||||
label: "임도 종류 (프로젝트 등록값)",
|
||||
options: [
|
||||
{ value: "trunk", text: ROAD_TYPE_LABELS.trunk },
|
||||
{ value: "fire", text: ROAD_TYPE_LABELS.fire },
|
||||
{ value: "work", text: ROAD_TYPE_LABELS.work },
|
||||
{ value: "branch", text: ROAD_TYPE_LABELS.branch },
|
||||
],
|
||||
});
|
||||
const gradeClass = gradeField.select;
|
||||
gradeClass.disabled = true;
|
||||
gradeClass.title = "프로젝트 등록 정보의 임도 종류입니다. 변경은 대시보드에서 하세요.";
|
||||
// 설계속도 — 임도는 속도를 낼 수 없어 기본 20km/h다. 종류에 따라 선택지가 갈린다.
|
||||
const speedField = createSelectField({
|
||||
label: "설계속도 (km/h)",
|
||||
options: [20, 30, 40].map((value) => ({ value: String(value), text: `${value} km/h` })),
|
||||
});
|
||||
const designSpeed = speedField.select;
|
||||
designSpeed.value = "20";
|
||||
designSpeed.title =
|
||||
"종단기울기·종단곡선의 법정 기준은 설계속도로 정해집니다(별표2).\n" +
|
||||
"간선·산불진화임도는 20~40, 작업임도는 20만 쓸 수 있습니다.";
|
||||
|
||||
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
||||
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
||||
// 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시).
|
||||
const sampleRow = document.createElement("div");
|
||||
sampleRow.className = "b05-route__field-row";
|
||||
sampleRow.append(longSampleInterval.wrapper, crossSampleInterval.wrapper);
|
||||
const terrainField = createSelectField({
|
||||
label: "지형 구분",
|
||||
options: [
|
||||
{ value: "normal", text: "일반지형" },
|
||||
{ value: "special", text: "특수지형" },
|
||||
],
|
||||
});
|
||||
const terrainType = terrainField.select;
|
||||
// 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라
|
||||
// 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다.
|
||||
terrainType.value = "special";
|
||||
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
|
||||
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
|
||||
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
|
||||
// 자리를 시설 여유만큼 들어 올리고, 그 아래로 내리는 편집도 막힌다. 꺼도 부족분 경고는
|
||||
// 종단 상단 표시줄에 계속 뜬다.
|
||||
const enforcePipeClearance = checkbox("횡단배수 최소고 강제", false);
|
||||
enforcePipeClearance.title =
|
||||
"켜면 계획선이 배수관·BOX암거·세월교 자리에서 관경(구체 높이)+토피 0.5m 만큼 들립니다.\n" +
|
||||
"끄면 들어 올리지 않고 부족분은 경고로만 알립니다(기본).";
|
||||
const maxGradePct = numberField("최대 종단기울기 (%)");
|
||||
const minVerticalRadius = numberField("종단곡선 최소 반경 (m)");
|
||||
const minTangentLength = numberField("최소 직선 길이 (m)");
|
||||
const startElevationOffset = numberField("시점 계획고 조정 (m)");
|
||||
const endElevationOffset = numberField("종점 계획고 조정 (m)");
|
||||
const criteriaNote = document.createElement("p");
|
||||
criteriaNote.className = "b05-route__note";
|
||||
const gradeAdvanced = document.createElement("details");
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
minVerticalRadius.wrapper,
|
||||
minTangentLength.wrapper,
|
||||
startElevationOffset.wrapper,
|
||||
endElevationOffset.wrapper,
|
||||
);
|
||||
// 병합 구획 구분선 — 샘플링 / 등고선 / 종단 설계 기준.
|
||||
const settingsDivider1 = document.createElement("hr");
|
||||
settingsDivider1.className = "b05-structure__divider";
|
||||
const settingsDivider2 = settingsDivider1.cloneNode() as HTMLHRElement;
|
||||
|
||||
// 공사 시작 기준 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다.
|
||||
// (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0.
|
||||
// 별도 "공사 시작점" 컨테이너를 없애고 "시작 측점 및 샘플링 설정" 최상단으로 일원화
|
||||
// (2026-08-06 사용자 지시).
|
||||
const startStation = numberField("시작 측점", "0");
|
||||
startStation.step = "1";
|
||||
startStation.min = "0";
|
||||
const startCumulative = numberField("시작 누가거리 (m)", "0");
|
||||
// 2열×2행: 1행 항목명 · 2행 값 입력 / 1열 시작 측점 · 2열 시작 누가거리.
|
||||
const startRow = document.createElement("div");
|
||||
startRow.className = "b05-route__field-row";
|
||||
startRow.append(startStation.wrapper, startCumulative.wrapper);
|
||||
const stationDisplayOffset = (): { station: number; cumulative: number } => ({
|
||||
station: Math.max(0, Math.round(Number(startStation.value) || 0)),
|
||||
cumulative: Number(startCumulative.value) || 0,
|
||||
});
|
||||
[startStation, startCumulative].forEach((input) =>
|
||||
input.addEventListener("change", () =>
|
||||
callbacks.onStationDisplayChange(stationDisplayOffset()),
|
||||
),
|
||||
);
|
||||
|
||||
/** 지금 적용되는 종단기울기 상한(%) — 사용자가 값을 넣었으면 그 값, 비웠으면
|
||||
* 등급·지형의 법정 기준. 종단 그래프의 위반 판정이 이 값을 즉시 따라온다
|
||||
* (2026-08-19 사용자 지시 6). 특수지형+포장 예외(18%)는 서버가 최종 판정한다. */
|
||||
function currentSpeed(): RoutePanelValues["designSpeed"] {
|
||||
const value = Number(designSpeed.value);
|
||||
return (value === 40 || value === 30 ? value : 20) as RoutePanelValues["designSpeed"];
|
||||
}
|
||||
|
||||
function gradeLimitPct(): number {
|
||||
const criteria = PROFILE_CRITERIA[currentSpeed()];
|
||||
const terrain = terrainType.value as RoutePanelValues["terrainType"];
|
||||
const typed = parseOptional(maxGradePct);
|
||||
return typed != null && typed > 0 ? typed : criteria.grade[terrain];
|
||||
}
|
||||
|
||||
/** 임도 종류에 맞춰 설계속도 선택지를 갈아 끼운다 — 작업임도는 20만 쓸 수 있다. */
|
||||
function syncSpeedChoices(): void {
|
||||
const allowed = SPEED_CHOICES[gradeClass.value] ?? [20];
|
||||
const keep = allowed.includes(currentSpeed()) ? currentSpeed() : allowed[0];
|
||||
designSpeed.replaceChildren(
|
||||
...allowed.map((value) => new Option(`${value} km/h`, String(value))),
|
||||
);
|
||||
designSpeed.value = String(keep);
|
||||
// 고를 것이 하나뿐이면 잠근다 — 열어 두면 바꿀 수 있다는 오해를 준다.
|
||||
designSpeed.disabled = allowed.length < 2;
|
||||
}
|
||||
|
||||
/** 설계속도·지형 선택에 맞춰 법정 기준값을 placeholder와 안내문에 반영한다. */
|
||||
function syncCriteria(): void {
|
||||
const speed = currentSpeed();
|
||||
const criteria = PROFILE_CRITERIA[speed];
|
||||
const terrain = terrainType.value as RoutePanelValues["terrainType"];
|
||||
maxGradePct.placeholder = String(criteria.grade[terrain]);
|
||||
minVerticalRadius.placeholder = String(criteria.radius);
|
||||
minTangentLength.placeholder = "20";
|
||||
const roadType = ROAD_TYPE_LABELS[gradeClass.value] ?? gradeClass.value;
|
||||
criteriaNote.textContent =
|
||||
`${roadType} · 설계속도 ${speed}km/h · ${terrain === "special" ? "특수지형" : "일반지형"} 기준 — ` +
|
||||
`종단기울기 ${criteria.grade[terrain]}% 이하, 종단곡선 반경 ${criteria.radius}m 이상. ` +
|
||||
"비워두면 이 기준이 적용됩니다.";
|
||||
}
|
||||
// 설계속도·지형뿐 아니라 기울기 세부 입력까지 — 바꾸는 즉시 안내문과 위반 기준이
|
||||
// 따라온다(2026-08-19 사용자 지시 6). onInputChange가 그래프 쪽 갱신을 잇는다.
|
||||
[designSpeed, terrainType, maxGradePct].forEach((input) =>
|
||||
input.addEventListener("change", syncCriteria),
|
||||
);
|
||||
maxGradePct.addEventListener("input", syncCriteria);
|
||||
syncSpeedChoices();
|
||||
syncCriteria();
|
||||
|
||||
return {
|
||||
/** 「페이지 설정」 본문 — 화면 순서대로. */
|
||||
nodes: [
|
||||
startRow,
|
||||
sampleRow,
|
||||
settingsDivider1,
|
||||
contourRow,
|
||||
settingsDivider2,
|
||||
// 임도 종류(등록값) → 설계속도 → 지형 구분 순 — 법정 기준이 정해지는 순서다
|
||||
// (2026-08-19 사용자 확정: 종류는 B02에서, 속도·지형은 여기서).
|
||||
gradeField.root,
|
||||
speedField.root,
|
||||
terrainField.root,
|
||||
criteriaNote,
|
||||
enforcePipeClearance.wrapper,
|
||||
gradeAdvanced,
|
||||
] as HTMLElement[],
|
||||
fields: {
|
||||
contourInterval,
|
||||
gradeClass,
|
||||
designSpeed,
|
||||
terrainType,
|
||||
crossSampleInterval,
|
||||
longSampleInterval,
|
||||
maxGradePct,
|
||||
minVerticalRadius,
|
||||
minTangentLength,
|
||||
startElevationOffset,
|
||||
endElevationOffset,
|
||||
enforcePipeClearance,
|
||||
},
|
||||
stationDisplayOffset,
|
||||
gradeLimitPct,
|
||||
currentSpeed,
|
||||
syncSpeedChoices,
|
||||
syncCriteria,
|
||||
};
|
||||
}
|
||||
|
||||
export type SettingsPart = ReturnType<typeof createSettingsPart>;
|
||||
@@ -0,0 +1,101 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Panel_View.ts
|
||||
* 3D 화면 위에 얹는 **보기 조작 줄**(ISO·TOP·… · 표시 켬끔 · 뷰 초기화 · [3D 업데이트]).
|
||||
* 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b).
|
||||
* ========================================================================== */
|
||||
|
||||
import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
/** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */
|
||||
export interface ViewControlCallbacks {
|
||||
onSurfaceVisible: (visible: boolean) => void;
|
||||
onCorridorVisible: (visible: boolean) => void;
|
||||
onContoursVisible: (visible: boolean) => void;
|
||||
onAxesVisible: (visible: boolean) => void;
|
||||
onStationLinesVisible: (visible: boolean) => void;
|
||||
onStationLabelsVisible: (visible: boolean) => void;
|
||||
onSurfaceGrayscale: (grayscale: boolean) => void;
|
||||
onView: (view: "iso" | "top" | "front" | "side") => void;
|
||||
onProjection: (kind: "perspective" | "ortho") => void;
|
||||
onResetView: () => void;
|
||||
onCorridorRefresh: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createViewControls(callbacks: ViewControlCallbacks): {
|
||||
viewControls: HTMLElement;
|
||||
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
|
||||
setCorridorPending: (pending: boolean) => void;
|
||||
} {
|
||||
const viewControls = document.createElement("div");
|
||||
viewControls.className = "b05-route__view-controls";
|
||||
const viewButtons = document.createElement("div");
|
||||
viewButtons.className = "b05-route__view-group";
|
||||
(["iso", "top", "front", "side"] as const).forEach((preset) =>
|
||||
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
|
||||
);
|
||||
// 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을
|
||||
// 가리킨다(누르면 그쪽으로 간다).
|
||||
let projection: "perspective" | "ortho" = "perspective";
|
||||
const projectionButton = button(
|
||||
"직교로",
|
||||
() => {
|
||||
projection = projection === "perspective" ? "ortho" : "perspective";
|
||||
projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로";
|
||||
callbacks.onProjection(projection);
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
viewButtons.append(projectionButton);
|
||||
const visibilityButtons = document.createElement("div");
|
||||
visibilityButtons.className = "b05-route__view-group";
|
||||
visibilityButtons.append(
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
|
||||
toggleButton("예상형상", true, callbacks.onCorridorVisible),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
|
||||
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
|
||||
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
|
||||
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
|
||||
);
|
||||
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
|
||||
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
|
||||
// 이 버튼만 잠근다(화면 전체 잠금 금지 — CLAUDE.md 5장).
|
||||
const corridorRefresh = button(
|
||||
"3D 업데이트",
|
||||
() => {
|
||||
corridorRefresh.disabled = true;
|
||||
corridorRefresh.textContent = "갱신 중…";
|
||||
void Promise.resolve(callbacks.onCorridorRefresh()).finally(() => {
|
||||
corridorRefresh.disabled = false;
|
||||
corridorRefresh.textContent = "3D 업데이트";
|
||||
corridorRefresh.classList.remove("is-pending");
|
||||
});
|
||||
},
|
||||
"glass",
|
||||
);
|
||||
corridorRefresh.title =
|
||||
"계획선 편집을 3D 예상형상·측점선에 반영합니다.\n편집이 쌓이면 버튼에 표시가 붙습니다.";
|
||||
const separator1 = document.createElement("span");
|
||||
separator1.className = "b05-route__view-separator";
|
||||
const separator2 = separator1.cloneNode() as HTMLSpanElement;
|
||||
const separator3 = separator1.cloneNode() as HTMLSpanElement;
|
||||
viewControls.append(
|
||||
viewButtons,
|
||||
separator1,
|
||||
visibilityButtons,
|
||||
separator2,
|
||||
button("뷰 초기화", callbacks.onResetView, "glass"),
|
||||
separator3,
|
||||
corridorRefresh,
|
||||
);
|
||||
|
||||
return {
|
||||
viewControls,
|
||||
setCorridorPending(pending: boolean) {
|
||||
corridorRefresh.classList.toggle("is-pending", pending);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Drag.ts
|
||||
* 종단도 **높이 끌기** — 위쪽 경계 손잡이와, 메인 · 서브패널(유토곡선 · 표) 손잡이를
|
||||
* 끄는 동안의 출처 추적.
|
||||
*
|
||||
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
|
||||
* (계획서 24-2c). 본문 로직과 수치는 그대로다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { MASSHAUL_MIN_HEIGHT } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { TABLE_OVERLAY_MIN_HEIGHT } from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 위 칸(3D · 횡단 카드)이 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
|
||||
/** 접힘·높이는 화면 취향(①) — 두 보기가 **같은 키 하나**를 쓴다(계획서 24-2c). */
|
||||
const HEIGHT_KEY = stateKey("profile-height") ?? "";
|
||||
|
||||
/** 옛 횡단 보기 위 종단 패널의 접힘 · 높이 값을 한 번 옮겨 받는다(새 키가 비었을 때만). */
|
||||
export function adoptLegacyProfileKeys(): void {
|
||||
const pairs = [
|
||||
["section-panel-collapsed", "profile-collapsed"],
|
||||
["section-panel-height", "profile-height"],
|
||||
] as const;
|
||||
for (const [from, to] of pairs) {
|
||||
const old = readStateRaw(from);
|
||||
if (old === null) continue;
|
||||
if (readStateRaw(to) === null) writeStateRaw(to, old);
|
||||
writeStateRaw(from, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 접기 손잡이 — 누르면 접고 펴며, 펼칠 때 다시 그린다. 처음 상태는 저장값. */
|
||||
export function bindProfileCollapse(
|
||||
root: HTMLElement,
|
||||
handle: { root: HTMLElement; setOpen: (open: boolean) => void },
|
||||
onOpen: () => void,
|
||||
): void {
|
||||
const setCollapsed = (collapsed: boolean): void => {
|
||||
root.classList.toggle("is-collapsed", collapsed);
|
||||
handle.setOpen(!collapsed);
|
||||
writeStateRaw("profile-collapsed", String(collapsed));
|
||||
if (!collapsed) onOpen();
|
||||
};
|
||||
handle.root.addEventListener("click", () =>
|
||||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||||
);
|
||||
setCollapsed(readStateRaw("profile-collapsed") === "true");
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다 — 끌어 올리는 동안 축까지 따라
|
||||
* 움직이면 조작 감각이 깨진다. 손을 뗀 뒤 한 번만 다시 맞춘다(`redraw`).
|
||||
*/
|
||||
export function createElevationHold(
|
||||
body: HTMLElement,
|
||||
redraw: () => void,
|
||||
): {
|
||||
hold: (next: { min: number; max: number } | null) => { min: number; max: number } | undefined;
|
||||
editing: () => boolean;
|
||||
} {
|
||||
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
|
||||
let elevationWindow: { min: number; max: number } | undefined;
|
||||
let heightEditing = false;
|
||||
body.addEventListener("pointerdown", (event) => {
|
||||
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
|
||||
heightEditing = true;
|
||||
const release = (): void => {
|
||||
heightEditing = false;
|
||||
window.removeEventListener("pointerup", release);
|
||||
window.removeEventListener("pointercancel", release);
|
||||
redraw();
|
||||
};
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
});
|
||||
return {
|
||||
hold(next) {
|
||||
if (heightEditing) return elevationWindow;
|
||||
elevationWindow = next ?? undefined;
|
||||
return elevationWindow;
|
||||
},
|
||||
editing: () => heightEditing,
|
||||
};
|
||||
}
|
||||
|
||||
/** 겹침 판(유토곡선 · 표) 중 끌기가 보는 것만. */
|
||||
interface SubPanel {
|
||||
overlay: HTMLElement;
|
||||
commitHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export interface ProfileDragContext {
|
||||
root: HTMLElement;
|
||||
body: HTMLElement;
|
||||
massHaul: SubPanel;
|
||||
tableOverlay: SubPanel & { isOpen: () => boolean };
|
||||
/** 손을 뗀 뒤 한 번 다시 세운다. */
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
export interface ProfileDrag {
|
||||
resizerRoot: HTMLElement;
|
||||
isMainDragging: () => boolean;
|
||||
isSubDragging: () => boolean;
|
||||
mainDragRef: () => { chart: number; mass: number; table: number } | null;
|
||||
isMainDragCooldown: () => boolean;
|
||||
clearMainDragCooldown: () => void;
|
||||
/** 전체 재구성을 120ms 뒤로 미룬다(연속 호출은 마지막 한 번만). */
|
||||
scheduleDraw: () => void;
|
||||
cancelDraw: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag {
|
||||
const { root, body, massHaul, tableOverlay } = ctx;
|
||||
let resizeTimer = 0;
|
||||
const scheduleDraw = (): void => {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(ctx.draw, 120);
|
||||
};
|
||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||||
const heightResizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
target: root,
|
||||
cssVar: "--b05-profile-height",
|
||||
direction: -1,
|
||||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
||||
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
||||
min: () => {
|
||||
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
||||
const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT;
|
||||
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
return Math.max(
|
||||
MIN_PANEL_HEIGHT,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
);
|
||||
},
|
||||
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
storageKey: HEIGHT_KEY,
|
||||
});
|
||||
|
||||
/* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ──────────────
|
||||
* 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는
|
||||
* 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */
|
||||
let mainPanelDragging = false;
|
||||
let subPanelDragging = false;
|
||||
/** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||||
heightResizer.root.addEventListener("pointerdown", () => {
|
||||
mainPanelDragging = true;
|
||||
const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
|
||||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||||
mainDragRef = {
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
[massHaul.overlay, tableOverlay.overlay].forEach((overlay) =>
|
||||
overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => {
|
||||
subPanelDragging = true;
|
||||
}),
|
||||
);
|
||||
const clearDragFlags = (): void => {
|
||||
if (mainPanelDragging) mainDragCooldown = true;
|
||||
if (mainPanelDragging && mainDragRef) {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
// 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다.
|
||||
if (mainPanelDragging || subPanelDragging) scheduleDraw();
|
||||
mainPanelDragging = false;
|
||||
subPanelDragging = false;
|
||||
};
|
||||
window.addEventListener("pointerup", clearDragFlags);
|
||||
window.addEventListener("pointercancel", clearDragFlags);
|
||||
|
||||
return {
|
||||
resizerRoot: heightResizer.root,
|
||||
isMainDragging: () => mainPanelDragging,
|
||||
isSubDragging: () => subPanelDragging,
|
||||
mainDragRef: () => mainDragRef,
|
||||
isMainDragCooldown: () => mainDragCooldown,
|
||||
clearMainDragCooldown: () => {
|
||||
mainDragCooldown = false;
|
||||
},
|
||||
scheduleDraw,
|
||||
cancelDraw: () => window.clearTimeout(resizeTimer),
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
heightResizer.dispose();
|
||||
window.removeEventListener("pointerup", clearDragFlags);
|
||||
window.removeEventListener("pointercancel", clearDragFlags);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,8 +17,13 @@
|
||||
|
||||
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
ALIGNMENT_DRAFT_KEY_PREFIX,
|
||||
draftProjectId,
|
||||
useDesignStore,
|
||||
} from "../A00_Common/design/design_store";
|
||||
import { clearState } from "../A00_Common/b_page_state";
|
||||
|
||||
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
|
||||
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
|
||||
const BUTTON_CLEARANCE_PX = 23;
|
||||
const BUTTON_HALF_PX = 10;
|
||||
@@ -79,6 +84,9 @@ export interface ProfileEditStore {
|
||||
edited(): boolean;
|
||||
}
|
||||
|
||||
/** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */
|
||||
const draftOf = (routeId: number) => useDesignStore(null, routeId);
|
||||
|
||||
/**
|
||||
* 노선의 미저장 편집 초안을 읽는다(B06 공용).
|
||||
*
|
||||
@@ -87,7 +95,7 @@ export interface ProfileEditStore {
|
||||
* 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다.
|
||||
*/
|
||||
export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null {
|
||||
return routeId === null ? null : readDraft(`${DRAFT_KEY_PREFIX}:${routeId}`);
|
||||
return routeId === null ? null : draftOf(routeId).get("alignmentDraft");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,13 +104,17 @@ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | nul
|
||||
* 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에
|
||||
* 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측:
|
||||
* 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남).
|
||||
* 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다.
|
||||
*/
|
||||
export function clearAlignmentDrafts(): void {
|
||||
shared.clear();
|
||||
// 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다.
|
||||
clearState("alignment-draft", draftProjectId());
|
||||
try {
|
||||
const keys: string[] = [];
|
||||
for (let index = 0; index < sessionStorage.length; index += 1) {
|
||||
const key = sessionStorage.key(index);
|
||||
if (key?.startsWith(`${DRAFT_KEY_PREFIX}:`)) keys.push(key);
|
||||
if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key);
|
||||
}
|
||||
keys.forEach((key) => sessionStorage.removeItem(key));
|
||||
} catch {
|
||||
@@ -110,18 +122,47 @@ export function clearAlignmentDrafts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function readDraft(storageKey: string): AlignmentEdits | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<AlignmentEdits>;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_radii: parsed.curve_radii ?? {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
/**
|
||||
* 노선마다 **한 벌**인 편집 상태 — B05 종단 패널이 주인이고 B06 계획선 편집은 같은 벌을
|
||||
* 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 —
|
||||
* 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다.
|
||||
*/
|
||||
interface EditState {
|
||||
current: AlignmentEdits;
|
||||
unsaved: boolean;
|
||||
/**
|
||||
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
|
||||
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
|
||||
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
|
||||
*/
|
||||
savedBaseline: AlignmentEdits;
|
||||
/** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */
|
||||
saved: AlignmentEdits;
|
||||
}
|
||||
|
||||
const shared = new Map<number, EditState>();
|
||||
|
||||
/** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */
|
||||
function freshState(routeId: number | null, saved: AlignmentEdits): EditState {
|
||||
const draft = readAlignmentDraft(routeId);
|
||||
return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved };
|
||||
}
|
||||
|
||||
/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */
|
||||
export function pendingAlignment(routeId: number): AlignmentEdits | null {
|
||||
const state = shared.get(routeId);
|
||||
if (state) return state.unsaved ? state.current : null;
|
||||
return readAlignmentDraft(routeId);
|
||||
}
|
||||
|
||||
/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */
|
||||
export function markAlignmentSaved(routeId: number): void {
|
||||
const state = shared.get(routeId);
|
||||
if (state) {
|
||||
state.unsaved = false;
|
||||
state.savedBaseline = state.current;
|
||||
}
|
||||
draftOf(routeId).set("alignmentDraft", null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +172,9 @@ function readDraft(storageKey: string): AlignmentEdits | null {
|
||||
* 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면
|
||||
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
|
||||
*
|
||||
* 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤
|
||||
* 다시 읽는 길). 이미 선 한 벌에 붙기만 하려면 `useProfileEditStore`.
|
||||
*
|
||||
* routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에
|
||||
* 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다.
|
||||
*/
|
||||
@@ -139,56 +183,62 @@ export function createProfileEditStore(
|
||||
saved: AlignmentEdits,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const storageKey = routeId === null ? null : `${DRAFT_KEY_PREFIX}:${routeId}`;
|
||||
const draft = storageKey ? readDraft(storageKey) : null;
|
||||
let current = draft ?? saved;
|
||||
let unsaved = draft !== null;
|
||||
/**
|
||||
* [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시).
|
||||
* 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동
|
||||
* 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다.
|
||||
*/
|
||||
let savedBaseline = saved;
|
||||
const fresh = freshState(routeId, saved);
|
||||
// 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다.
|
||||
const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh);
|
||||
if (routeId !== null) shared.set(routeId, state);
|
||||
return handleOf(routeId, state, onChange);
|
||||
}
|
||||
|
||||
/** 그 노선에 이미 선 한 벌에 붙는다 — 없으면 `createProfileEditStore` 와 같다(B06 계획선 편집). */
|
||||
export function useProfileEditStore(
|
||||
routeId: number,
|
||||
saved: AlignmentEdits,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const state = shared.get(routeId);
|
||||
return state
|
||||
? handleOf(routeId, state, onChange)
|
||||
: createProfileEditStore(routeId, saved, onChange);
|
||||
}
|
||||
|
||||
function handleOf(
|
||||
routeId: number | null,
|
||||
state: EditState,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const draft = routeId === null ? null : draftOf(routeId);
|
||||
|
||||
function dropDraft(): void {
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다.
|
||||
}
|
||||
draft?.set("alignmentDraft", null);
|
||||
}
|
||||
|
||||
function commit(next: AlignmentEdits): void {
|
||||
current = next;
|
||||
unsaved = true;
|
||||
try {
|
||||
if (storageKey) sessionStorage.setItem(storageKey, JSON.stringify(current));
|
||||
} catch {
|
||||
// 초안 보관 실패는 편집을 막지 않는다.
|
||||
}
|
||||
state.current = next;
|
||||
state.unsaved = true;
|
||||
draft?.set("alignmentDraft", state.current);
|
||||
onChange();
|
||||
}
|
||||
|
||||
return {
|
||||
edits: () => current,
|
||||
edits: () => state.current,
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
unsaved = false;
|
||||
savedBaseline = current;
|
||||
state.unsaved = false;
|
||||
state.savedBaseline = state.current;
|
||||
dropDraft();
|
||||
},
|
||||
restoreSaved() {
|
||||
current = savedBaseline;
|
||||
unsaved = false;
|
||||
state.current = state.savedBaseline;
|
||||
state.unsaved = false;
|
||||
dropDraft();
|
||||
onChange();
|
||||
},
|
||||
canRestoreSaved: () => JSON.stringify(current) !== JSON.stringify(savedBaseline),
|
||||
canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline),
|
||||
resetStation(chainageM, moveTo) {
|
||||
const key = chainageKey(chainageM);
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
const curveRadii = { ...current.curve_radii };
|
||||
const stationOffsets = { ...state.current.station_offsets };
|
||||
const curveRadii = { ...state.current.curve_radii };
|
||||
if (moveTo !== undefined) {
|
||||
const to = chainageKey(moveTo);
|
||||
if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key];
|
||||
@@ -200,7 +250,7 @@ export function createProfileEditStore(
|
||||
delete stationOffsets[key];
|
||||
// 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면
|
||||
// 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23).
|
||||
const savedRadius = saved.curve_radii?.[key];
|
||||
const savedRadius = state.saved.curve_radii?.[key];
|
||||
if (savedRadius === undefined) delete curveRadii[key];
|
||||
else curveRadii[key] = savedRadius;
|
||||
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
|
||||
@@ -209,10 +259,10 @@ export function createProfileEditStore(
|
||||
// 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경
|
||||
// (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 —
|
||||
// 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단).
|
||||
commit({ ...emptyEdits(), curve_radii: { ...(saved.curve_radii ?? {}) } });
|
||||
commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } });
|
||||
},
|
||||
dirty: () => unsaved,
|
||||
edited: () => hasEdits(current),
|
||||
dirty: () => state.unsaved,
|
||||
edited: () => hasEdits(state.current),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Guard.ts
|
||||
* 종단도가 편집 · 경고에 쓰는 **셈만** — 계획선 샘플 보간, 평면 최소곡선반지름 경고,
|
||||
* 구조물 측점을 종단 정본 측점으로 맞추기.
|
||||
*
|
||||
* 종단도 한 벌(`A00_Common/design/design_profile.ts`)이 700줄을 넘겨 떼어낸 조각이다
|
||||
* (계획서 24-2c). 본문 로직과 수치는 그대로이고 화면 상태를 인자로 받는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
|
||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||||
|
||||
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
||||
export function sampleAt(
|
||||
alignment: ProfileAlignment | null,
|
||||
chainageM: number,
|
||||
field: "elevation_m" | "ground_elevation_m",
|
||||
): number | null {
|
||||
const samples = alignment?.samples;
|
||||
if (!samples?.length) return null;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0][field];
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
if (chainageM <= samples[i].chainage_m) {
|
||||
const span = samples[i].chainage_m - samples[i - 1].chainage_m;
|
||||
const t = span <= 1e-12 ? 0 : (chainageM - samples[i - 1].chainage_m) / span;
|
||||
return samples[i - 1][field] + (samples[i][field] - samples[i - 1][field]) * t;
|
||||
}
|
||||
}
|
||||
return samples[samples.length - 1][field];
|
||||
}
|
||||
|
||||
/**
|
||||
* 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06).
|
||||
* 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다.
|
||||
* **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다.
|
||||
*/
|
||||
export function planCurveWarning(
|
||||
alignment: ProfileAlignment | null,
|
||||
detail: SectionDetailResponse | null,
|
||||
): {
|
||||
count: number;
|
||||
worstRadiusM: number;
|
||||
limitM: number;
|
||||
hairpin: number;
|
||||
} | null {
|
||||
const limit = alignment?.policy.min_plan_radius_m ?? 0;
|
||||
const sections = detail?.cross_sections ?? [];
|
||||
if (!limit || !sections.length) return null;
|
||||
const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0;
|
||||
let count = 0;
|
||||
let hairpin = 0;
|
||||
let worst = Number.POSITIVE_INFINITY;
|
||||
sections.forEach((section) => {
|
||||
const radius = section.plan_radius_m;
|
||||
if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return;
|
||||
count += 1;
|
||||
worst = Math.min(worst, radius);
|
||||
if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1;
|
||||
});
|
||||
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||||
*
|
||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
*
|
||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||||
*/
|
||||
export function snapToMasterStations(
|
||||
alignment: ProfileAlignment | null,
|
||||
stations: IrregularStation[],
|
||||
): IrregularStation[] {
|
||||
const master = alignment?.stations;
|
||||
if (!master?.length) return stations;
|
||||
return stations.map((station) => {
|
||||
let best: number | null = null;
|
||||
let gap = STATION_SNAP_TOLERANCE_M;
|
||||
for (const node of master) {
|
||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||||
if (distance <= gap) {
|
||||
gap = distance;
|
||||
best = node.chainage_m;
|
||||
}
|
||||
}
|
||||
return best === null || best === station.chainage_m
|
||||
? station
|
||||
: { ...station, chainage_m: best };
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
|
||||
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
|
||||
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-", "aislo:draft:alignment-draft:"];
|
||||
|
||||
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
|
||||
function isLayoutKey(key: string): boolean {
|
||||
|
||||
@@ -1,113 +1,18 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Panel.ts
|
||||
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
|
||||
* 종단 보기의 하단 종단도 — 종단도 한 벌(`A00_Common/design/design_profile.ts`)에
|
||||
* 오른쪽 판(배수유역도)을 끼워 만든다(계획서 24-2c).
|
||||
*
|
||||
* 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에
|
||||
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
|
||||
* 본문 세로는 그래프 40% : 테이블 60%로 나눈다.
|
||||
*
|
||||
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
|
||||
* `saveProfileAlignment()`로 편집 델타만 보낸다.
|
||||
* 본체는 종단도 한 벌로 옮겼다. 여기는 종단 보기가 부르던 이름 · 모양을 지킨다 —
|
||||
* 페이지는 `drainage` 로 배수유역도를 바로 부른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { routeSpansFromStructures } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { createHaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
|
||||
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
|
||||
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
createProfileTableOverlay,
|
||||
TABLE_OVERLAY_MIN_HEIGHT,
|
||||
} from "./B05_Profile_UI_Profile_TableOverlay";
|
||||
import { hasLegacyAlignment, readAlignment, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
|
||||
import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import {
|
||||
blocksMinCover,
|
||||
findMinCoverViolations,
|
||||
minCoverPoints,
|
||||
type MinCoverPoint,
|
||||
} from "./B05_Profile_UI_Profile_MinCover";
|
||||
import type {
|
||||
AlignmentBase,
|
||||
AlignmentEdits,
|
||||
ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
emptyEdits,
|
||||
toAlignmentBase,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { createProfileEditStore } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { structureGroupMenuItems } from "./B05_Profile_UI_Profile_Structures";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
createRouteMassHaulPanel,
|
||||
type MassHaulSummaryValues,
|
||||
MASSHAUL_MIN_HEIGHT,
|
||||
type RouteMassHaulContext,
|
||||
} from "./B05_Profile_UI_Profile_MassHaul";
|
||||
import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance";
|
||||
import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights";
|
||||
import { renderProfile } from "./B05_Profile_UI_Profile_Render";
|
||||
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview";
|
||||
import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools";
|
||||
import { createProfileZoom } from "./B05_Profile_UI_Profile_Zoom";
|
||||
import {
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
structureIdAtStation as structureIdAtStationOf,
|
||||
} from "./B05_Profile_UI_Profile_Structures";
|
||||
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";
|
||||
|
||||
/** 접힘·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
const HEIGHT_KEY = stateKey("profile-height") ?? "";
|
||||
/* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 —
|
||||
예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */
|
||||
const MIN_CHART_HEIGHT = 100;
|
||||
/** 접힌 유토곡선 손잡이가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게.
|
||||
* 구조물 알약 레인이 그 위에 붙은 뒤로는 사이가 떠 보여 절반으로 줄였다
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
/** 패널을 끌어 줄일 수 있는 하한(px) — 이보다 낮으면 그래프도 테이블도 못 읽는다. */
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
|
||||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
|
||||
* 60→16ms(2026-09-06) — ▲ 한 번에 라벨이 서기까지 중앙 130ms 를 쟀는데 그중 60ms 가
|
||||
* 이 대기였다. 한 프레임(16ms)이면 연속 입력은 여전히 마지막 값만 계산한다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
|
||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||||
|
||||
/**
|
||||
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
||||
*
|
||||
* 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에
|
||||
* `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는
|
||||
* 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다.
|
||||
*/
|
||||
|
||||
/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로
|
||||
* 동작하도록 여기서 다시 내보낸다(2026-09-04). */
|
||||
import { createDesignProfile } from "../A00_Common/design/design_profile";
|
||||
import type { DesignSideBoard } from "../A00_Common/design/design_profile";
|
||||
import { drainageSide, drainageSideTool } from "./B05_Profile_UI_Profile_SideTool";
|
||||
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
|
||||
/** 콜백 타입은 `_Panel_Types` 에 있다 — 옛 임포트 경로가 그대로 동작하도록 다시 내보낸다. */
|
||||
export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
|
||||
|
||||
export function createRouteProfilePanel(
|
||||
@@ -118,710 +23,16 @@ export function createRouteProfilePanel(
|
||||
onResetAll?: () => void,
|
||||
callbacks?: RouteProfilePanelCallbacks,
|
||||
) {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-route-profile";
|
||||
// 이름표 "종단" — 무엇의 손잡이인지 화살표 옆에 밝힌다(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 (subPanelDragging) scheduleLightSync();
|
||||
else draw();
|
||||
};
|
||||
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
|
||||
* 유토곡선을 접어도, B06 처럼 곡선을 아예 안 그려도 이 값은 남아야 한다. */
|
||||
const massBadge = createMassHaulBadge();
|
||||
// B05 는 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
const haulPrefetch = createHaulPlanPrefetch(
|
||||
const profile = createDesignProfile({
|
||||
projectId,
|
||||
() => routeId ?? undefined,
|
||||
subPanelChanged,
|
||||
);
|
||||
const massHaul = createRouteMassHaulPanel(
|
||||
subPanelChanged,
|
||||
(summary) => {
|
||||
const before = massHaulSummary;
|
||||
massHaulSummary = summary;
|
||||
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
|
||||
},
|
||||
haulPrefetch,
|
||||
);
|
||||
bodyWrap.append(massHaul.overlay, massHaul.handle);
|
||||
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
|
||||
massHaul.attachScrollSync(body);
|
||||
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
|
||||
const tableOverlay = createProfileTableOverlay(subPanelChanged);
|
||||
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
|
||||
tableOverlay.attachScrollSync(body);
|
||||
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
|
||||
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
|
||||
body.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
const content = document.createElement("div");
|
||||
content.className = "b05-route-profile__content";
|
||||
// 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일).
|
||||
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
|
||||
let minCoverTargets: MinCoverPoint[] = [];
|
||||
/** 최소고를 편집에서 강제할지 — 사이드 「페이지 설정」 체크박스를 따라온다(기본 해제). */
|
||||
let enforceMinCover = false;
|
||||
const drainagePanel = createDrainagePanel({
|
||||
onPipesChanged: (pipes) => {
|
||||
// 횡단배수 최소 계획고(2026-08-23) — 관경·구체높이가 바뀌면 경고도 다시 본다.
|
||||
minCoverTargets = minCoverPoints(pipes);
|
||||
renderBalance();
|
||||
callbacks?.onPipesChanged?.(pipes);
|
||||
},
|
||||
onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM),
|
||||
onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM),
|
||||
// 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록
|
||||
// (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로).
|
||||
structureMenuItems: (chainage) =>
|
||||
structureGroupMenuItems(structureTypes, (typeId) =>
|
||||
callbacks?.onStructureTypeAdd?.(chainage, typeId),
|
||||
),
|
||||
view: "profile",
|
||||
side: drainageSide,
|
||||
sideTool: drainageSideTool,
|
||||
onSelectStation,
|
||||
onResetAll,
|
||||
callbacks,
|
||||
});
|
||||
content.append(bodyWrap, drainagePanel.root);
|
||||
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
|
||||
// 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다.
|
||||
const heightResizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
target: root,
|
||||
cssVar: "--b05-profile-height",
|
||||
direction: -1,
|
||||
// 동적 하한: 종단 최소 + 열린 서브패널들의 최소 높이 합(2026-08-05 사용자 확정).
|
||||
// 패널을 아래로 끌면 종단이 먼저 줄고 → 서브패널들이 같이 줄고(draw의 캐스케이드) →
|
||||
// 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단).
|
||||
min: () => {
|
||||
const chrome = Math.max(0, root.offsetHeight - body.clientHeight);
|
||||
const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT;
|
||||
const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0;
|
||||
return Math.max(
|
||||
MIN_PANEL_HEIGHT,
|
||||
chrome + MIN_CHART_HEIGHT + MASSHAUL_HANDLE_GUTTER_PX + massMin + tableMin,
|
||||
);
|
||||
},
|
||||
max: () => (root.parentElement?.clientHeight ?? window.innerHeight) * MAX_PANEL_HEIGHT_RATIO,
|
||||
storageKey: HEIGHT_KEY,
|
||||
});
|
||||
root.append(panelHandle.root, heightResizer.root, content);
|
||||
drainagePanel.load(projectId);
|
||||
|
||||
/* ── 리사이저 드래그 출처 추적(2026-08-05 진동·복귀 버그 수정) ──────────────
|
||||
* 메인 패널을 끄는 중엔 grow(자동 확장)가 포인터와 싸우면 안 되고, 서브패널을 끄는
|
||||
* 중엔 캐스케이드의 임시 축소(인라인)가 드래그 변수 값을 덮으면 안 된다. */
|
||||
let mainPanelDragging = false;
|
||||
let subPanelDragging = false;
|
||||
/** 메인 드래그 손을 뗀 뒤 **첫 전체 재구성(draw)까지** grow 금지 — 120ms 지연 draw는
|
||||
* 플래그가 이미 풀린 채 돌아, 드래그 중 금지했던 자동 확대가 손 떼는 순간 발동해
|
||||
* 포인터가 정한 높이를 되돌렸다(2026-08-06 분석 원인 4). draw()가 끝나며 푼다. */
|
||||
let mainDragCooldown = false;
|
||||
/** 메인 드래그 시작 시점의 3영역(종단·유토곡선·테이블) 실제 높이 — 비례 연동 기준.
|
||||
* 드래그 중 이 비율대로 같이 늘리고 줄인다(2026-08-06 사용자 확정). */
|
||||
let mainDragRef: { chart: number; mass: number; table: number } | null = null;
|
||||
heightResizer.root.addEventListener("pointerdown", () => {
|
||||
mainPanelDragging = true;
|
||||
const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight;
|
||||
const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0;
|
||||
mainDragRef = {
|
||||
chart: Math.max(
|
||||
MIN_CHART_HEIGHT,
|
||||
body.clientHeight - massH - tableH - MASSHAUL_HANDLE_GUTTER_PX,
|
||||
),
|
||||
mass: massH,
|
||||
table: tableH,
|
||||
};
|
||||
});
|
||||
[massHaul.overlay, tableOverlay.overlay].forEach((overlay) =>
|
||||
overlay.querySelector(".ui-resizer")?.addEventListener("pointerdown", () => {
|
||||
subPanelDragging = true;
|
||||
}),
|
||||
);
|
||||
const clearDragFlags = (): void => {
|
||||
if (mainPanelDragging) mainDragCooldown = true;
|
||||
if (mainPanelDragging && mainDragRef) {
|
||||
// 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정
|
||||
// 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전
|
||||
// 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다).
|
||||
if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight);
|
||||
if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight);
|
||||
massHaul.overlay.style.height = "";
|
||||
tableOverlay.overlay.style.height = "";
|
||||
}
|
||||
mainDragRef = null;
|
||||
// 드래그 중엔 경량 동기화만 하므로, 손을 뗀 뒤 전체 재구성 1회를 예약한다.
|
||||
if (mainPanelDragging || subPanelDragging) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(draw, 120);
|
||||
}
|
||||
mainPanelDragging = false;
|
||||
subPanelDragging = false;
|
||||
};
|
||||
window.addEventListener("pointerup", clearDragFlags);
|
||||
window.addEventListener("pointercancel", clearDragFlags);
|
||||
|
||||
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;
|
||||
let alignment: ProfileAlignment | null = null;
|
||||
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
|
||||
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
|
||||
let savedEdits: AlignmentEdits = emptyEdits();
|
||||
let resizeTimer = 0;
|
||||
let redrawPending = false;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
|
||||
/**
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
|
||||
*/
|
||||
/** 측점선 ↔ 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
structureIdAtStationOf(stationId, irregularStations, structures);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
|
||||
/**
|
||||
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
|
||||
* 그래프(종단·유토곡선) 클릭이 모두 이 경로를 탄다. 해제는 null로 Page에 알린다.
|
||||
* 같은 자리 알약도 함께 켜고 끈다(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);
|
||||
}
|
||||
|
||||
/** 계획선 샘플에서 chainage로 계획고·지반고를 되짚는 보간기(최소고 판정 입력). */
|
||||
function sampleAt(chainageM: number, field: "elevation_m" | "ground_elevation_m"): number | null {
|
||||
const samples = alignment?.samples;
|
||||
if (!samples?.length) return null;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0][field];
|
||||
for (let i = 1; i < samples.length; i += 1) {
|
||||
if (chainageM <= samples[i].chainage_m) {
|
||||
const span = samples[i].chainage_m - samples[i - 1].chainage_m;
|
||||
const t = span <= 1e-12 ? 0 : (chainageM - samples[i - 1].chainage_m) / span;
|
||||
return samples[i - 1][field] + (samples[i][field] - samples[i - 1][field]) * t;
|
||||
}
|
||||
}
|
||||
return samples[samples.length - 1][field];
|
||||
}
|
||||
|
||||
/**
|
||||
* 법정 평면 최소곡선반지름을 밑도는 측점을 센다(별표2 Ⅰ.2.다.(1), 2026-09-06).
|
||||
* 반경은 횡단 측점마다 실려 오고(`plan_radius_m`), 하한은 계획선 정책이 들고 있다.
|
||||
* **경고만** 낸다 — 노선을 고칠지는 사용자 판단이다.
|
||||
*/
|
||||
function planCurveWarning(): {
|
||||
count: number;
|
||||
worstRadiusM: number;
|
||||
limitM: number;
|
||||
hairpin: number;
|
||||
} | null {
|
||||
const limit = alignment?.policy.min_plan_radius_m ?? 0;
|
||||
const sections = detail?.cross_sections ?? [];
|
||||
if (!limit || !sections.length) return null;
|
||||
const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0;
|
||||
let count = 0;
|
||||
let hairpin = 0;
|
||||
let worst = Number.POSITIVE_INFINITY;
|
||||
sections.forEach((section) => {
|
||||
const radius = section.plan_radius_m;
|
||||
if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return;
|
||||
count += 1;
|
||||
worst = Math.min(worst, radius);
|
||||
if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1;
|
||||
});
|
||||
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
|
||||
}
|
||||
|
||||
function renderBalance(): void {
|
||||
const minCoverViolations = findMinCoverViolations(
|
||||
minCoverTargets,
|
||||
(chainageM) => sampleAt(chainageM, "ground_elevation_m"),
|
||||
(chainageM) => sampleAt(chainageM, "elevation_m"),
|
||||
);
|
||||
renderBalanceBar({
|
||||
planCurve: planCurveWarning(),
|
||||
minCoverViolations,
|
||||
balanceBar,
|
||||
tools: tools.render(),
|
||||
trailing: profileZoom.bar,
|
||||
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());
|
||||
|
||||
/**
|
||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||||
*
|
||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
*
|
||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||||
*/
|
||||
function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] {
|
||||
const master = alignment?.stations;
|
||||
if (!master?.length) return stations;
|
||||
return stations.map((station) => {
|
||||
let best: number | null = null;
|
||||
let gap = STATION_SNAP_TOLERANCE_M;
|
||||
for (const node of master) {
|
||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||||
if (distance <= gap) {
|
||||
gap = distance;
|
||||
best = node.chainage_m;
|
||||
}
|
||||
}
|
||||
return best === null || best === station.chainage_m
|
||||
? station
|
||||
: { ...station, chainage_m: best };
|
||||
});
|
||||
}
|
||||
|
||||
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
|
||||
let elevationWindow: { min: number; max: number } | undefined;
|
||||
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
|
||||
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
||||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */
|
||||
let heightEditing = false;
|
||||
const holdElevationRange = (
|
||||
next: { min: number; max: number } | null,
|
||||
): { min: number; max: number } | undefined => {
|
||||
if (heightEditing) return elevationWindow;
|
||||
elevationWindow = next ?? undefined;
|
||||
return elevationWindow;
|
||||
};
|
||||
// 끌어 올리는 동안 축까지 따라 움직이면 조작 감각이 깨진다 — 손을 뗀 뒤 한 번만 다시 맞춘다.
|
||||
body.addEventListener("pointerdown", (event) => {
|
||||
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
|
||||
heightEditing = true;
|
||||
const release = (): void => {
|
||||
heightEditing = false;
|
||||
window.removeEventListener("pointerup", release);
|
||||
window.removeEventListener("pointercancel", release);
|
||||
draw();
|
||||
};
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
});
|
||||
|
||||
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */
|
||||
const { tools, history, handleToolPick } = 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,
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
|
||||
* 실제 렌더링하도록 모은다.
|
||||
*/
|
||||
function rebuild(): void {
|
||||
if (base) alignment = buildAlignment(base, store.edits());
|
||||
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 서버에 한 번 물어 전 측점을
|
||||
// 다시 계산해 공유 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선이 따라 움직인다
|
||||
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
|
||||
scheduleCrossPreview();
|
||||
if (redrawPending) return;
|
||||
redrawPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
redrawPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
detail: () => detail,
|
||||
routeId: () => routeId,
|
||||
edits: () => store.edits(),
|
||||
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
|
||||
onApplied: () => {
|
||||
draw();
|
||||
callbacks?.onCrossDesignsUpdated?.();
|
||||
},
|
||||
});
|
||||
const scheduleCrossPreview = (): void => crossPreview.schedule();
|
||||
|
||||
/**
|
||||
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
|
||||
* 줄고 → ② 테이블·유토곡선이 **같이**(비례) 각자 최소까지 줄고 → ③ 셋 다 최소면
|
||||
* 메인 패널 리사이저의 동적 하한이 더 못 내려가게 막는다. 반대로 서브패널을 갑자기
|
||||
* 펼쳐 자리가 모자라면 메인 패널 높이를 키운다(deficit 처리).
|
||||
*
|
||||
* draw()(전체 재구성)와 리사이즈 중 경량 동기화가 **같은 계산**을 쓴다 — 끌 때는
|
||||
* 이 함수만 프레임마다 돌리고, 무거운 차트·테이블 재구성은 손을 뗀 뒤 한 번만 한다.
|
||||
*/
|
||||
const heights = createHeightCascade({
|
||||
root,
|
||||
body,
|
||||
massHaul,
|
||||
tableOverlay,
|
||||
isMainDragging: () => mainPanelDragging,
|
||||
isSubDragging: () => subPanelDragging,
|
||||
mainDragRef: () => mainDragRef,
|
||||
isMainDragCooldown: () => mainDragCooldown,
|
||||
hasDetail: () => detail !== null,
|
||||
});
|
||||
const applyHeightCascade = (allowGrow: boolean): { chartHeight: number } =>
|
||||
heights.apply(allowGrow);
|
||||
const scheduleLightSync = (): void => heights.scheduleLightSync();
|
||||
|
||||
function draw(): void {
|
||||
renderProfile({
|
||||
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: () => {
|
||||
mainDragCooldown = false;
|
||||
},
|
||||
selectStation,
|
||||
applyEdits,
|
||||
handleToolPick,
|
||||
zoom: profileZoom.state,
|
||||
holdElevationRange,
|
||||
toolActive: () => tools.mode() !== "none",
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
updateElevationWindow = update;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (heightEditing) return;
|
||||
const fitted = updateElevationWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
draw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
attachWheelHorizontalScroll(body);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (
|
||||
body.clientWidth <= 0 ||
|
||||
body.clientHeight <= 0 ||
|
||||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
|
||||
)
|
||||
return;
|
||||
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
|
||||
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
|
||||
scheduleLightSync();
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(draw, 120);
|
||||
});
|
||||
resizeObserver.observe(body);
|
||||
|
||||
function setCollapsed(collapsed: boolean): void {
|
||||
root.classList.toggle("is-collapsed", collapsed);
|
||||
panelHandle.setOpen(!collapsed);
|
||||
writeStateRaw("profile-collapsed", String(collapsed));
|
||||
if (!collapsed) requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
panelHandle.root.addEventListener("click", () =>
|
||||
setCollapsed(!root.classList.contains("is-collapsed")),
|
||||
);
|
||||
setCollapsed(readStateRaw("profile-collapsed") === "true");
|
||||
|
||||
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
|
||||
function syncDrainageSpans(): void {
|
||||
drainagePanel.setIntervalSpans(routeSpansFromStructures(structures, structureTypes));
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
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());
|
||||
}
|
||||
base = stored ? toAlignmentBase(stored) : null;
|
||||
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
|
||||
if (carried) store.replace(carried);
|
||||
alignment = base ? buildAlignment(base, store.edits()) : null;
|
||||
// 유토곡선 balloon 위치 캐시 — B06과 **같은 scope**를 써서 두 화면이 같은 자리를 공유한다.
|
||||
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다(B06과 같은 규칙).
|
||||
configureBalloonOffsets(
|
||||
`${projectId}:${routeId ?? "-"}`,
|
||||
nextDetail.balloon_offsets ?? undefined,
|
||||
);
|
||||
draw();
|
||||
requestAnimationFrame(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,
|
||||
/**
|
||||
* 계획 유토곡선 계산에 필요한 프로젝트 설정(토량환산계수·운반장비 경계·노반폭).
|
||||
* Page가 `fetchSectionContext()` 응답에서 뽑아 넘긴다 — 프론트에 사본을 두지 않는다.
|
||||
*/
|
||||
setEarthworkContext(next: RouteMassHaulContext | null) {
|
||||
massHaul.setContext(next);
|
||||
draw();
|
||||
},
|
||||
setSelectedStation(stationId: string | null) {
|
||||
selectedStationId = stationId;
|
||||
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
|
||||
selectedStructureId = structureIdAtStation(stationId);
|
||||
draw();
|
||||
},
|
||||
/** 종단기울기 상한(%)을 즉시 갈아 끼운다 — 페이지 설정의 지형 구분·등급·세부
|
||||
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
|
||||
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
|
||||
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
|
||||
setEnforceMinCover(enforce: boolean) {
|
||||
enforceMinCover = enforce;
|
||||
},
|
||||
setGradeLimit(maxGradePct: number) {
|
||||
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
|
||||
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
|
||||
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
|
||||
alignment = buildAlignment(base, store.edits());
|
||||
draw();
|
||||
},
|
||||
/** 확정된 노선 평면 선형을 우측 배수유역 지도에 겹친다(사업지 좌표계 m). */
|
||||
setRoutePolyline(points: ReadonlyArray<{ x: number; y: number }>) {
|
||||
drainagePanel.setRoute(points);
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = snapToMasterStations(stations);
|
||||
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();
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
setSelectedStructure(structureId: string | null) {
|
||||
selectedStructureId = structureId;
|
||||
selectedStationId = stationIdAtStructure(structureId);
|
||||
draw();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
stationDisplay = next;
|
||||
draw();
|
||||
},
|
||||
/**
|
||||
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
|
||||
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
|
||||
*/
|
||||
resetStationEdit(chainageM: number, moveTo?: number) {
|
||||
const key = chainageKey(chainageM);
|
||||
const edits = store.edits();
|
||||
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
|
||||
store.resetStation(chainageM, moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
|
||||
async save(): Promise<void> {
|
||||
if (!routeId || !store.dirty()) return;
|
||||
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
|
||||
const next = saved.profile_alignment as ProfileAlignment | undefined;
|
||||
if (next?.base_pvi?.length) {
|
||||
base = toAlignmentBase(next);
|
||||
alignment = next;
|
||||
if (detail) detail.longitudinal.profile_alignment = next;
|
||||
}
|
||||
store.markSaved();
|
||||
draw();
|
||||
},
|
||||
clear() {
|
||||
detail = null;
|
||||
base = null;
|
||||
alignment = null;
|
||||
selectedStationId = null;
|
||||
balanceBar.replaceChildren();
|
||||
body.replaceChildren(empty);
|
||||
},
|
||||
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
|
||||
drainage: drainagePanel,
|
||||
/** 그래프 영역 로딩 서클. 문구를 주면 켜고 null이면 끈다. */
|
||||
setLoading(label: string | null) {
|
||||
progress.root.hidden = label === null;
|
||||
if (label !== null) progress.set(null, label);
|
||||
},
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
crossPreview.dispose();
|
||||
resizeObserver.disconnect();
|
||||
heightResizer.dispose();
|
||||
window.removeEventListener("pointerup", clearDragFlags);
|
||||
window.removeEventListener("pointercancel", clearDragFlags);
|
||||
},
|
||||
};
|
||||
/** 배수유역도 패널 — 경로 확정 흐름에서 유역선 편집 저장 여부를 묻는 데 쓴다. */
|
||||
const drainage = profile.side as DesignSideBoard;
|
||||
return { ...profile, drainage };
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface RouteProfilePanelCallbacks {
|
||||
/** 배수유역도에서 관 마커를 고름(유역 없는 관 포함) — 전 화면 동기화용(2026-08-17). */
|
||||
onPipeSelected?: (chainageM: number | null) => void;
|
||||
/** 계획선 편집 프리뷰가 공유 캐시의 횡단 설계(설계선 포함)를 갱신한 뒤 —
|
||||
* 3D 코리도 등 파생 표시 재빌드용(2026-08-23). */
|
||||
onCrossDesignsUpdated?: () => void;
|
||||
* 3D 코리도 등 파생 표시 재빌드용(2026-08-23). 다시 계산한 측점 누가거리를 넘긴다 —
|
||||
* 횡단 보기는 그 카드만 갈아 끼운다(계획서 24-2c). */
|
||||
onCrossDesignsUpdated?: (updated: ReadonlyArray<number>) => void;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface CrossPreviewContext {
|
||||
routeId: () => number | null;
|
||||
edits: () => AlignmentEdits;
|
||||
debounceMs: number;
|
||||
/** 반영이 끝난 뒤 다시 그린다. */
|
||||
onApplied: () => void;
|
||||
/** 반영이 끝난 뒤 다시 그린다 — 다시 계산한 측점 누가거리를 넘긴다(횡단 카드 갱신용). */
|
||||
onApplied: (updated: number[]) => void;
|
||||
}
|
||||
|
||||
export interface CrossPreview {
|
||||
@@ -56,7 +56,7 @@ export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
|
||||
shouldApply: isCurrent,
|
||||
})
|
||||
.then((updated) => {
|
||||
if (updated.length) ctx.onApplied();
|
||||
if (updated.length) ctx.onApplied(updated);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_SideTool.ts
|
||||
* 종단도 오른쪽 판(배수유역도)을 두 보기에 **같은 모양 · 같은 켬끔 값**으로 끼운다
|
||||
* (계획서 24-2c · 24-2e). 판 본체는 `_Drainage_Side`(sub7), 켬 · 끔 값은 `drainage-visible`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { DesignSideBoard, DesignSideFactory } from "../A00_Common/design/design_profile";
|
||||
import {
|
||||
createDrainageSide,
|
||||
createDrainageToggleButton,
|
||||
type DrainageSideBoard,
|
||||
} from "./B05_Profile_UI_Drainage_Side";
|
||||
|
||||
/** 오른쪽 판 — 켬 · 끔은 두 보기가 같은 값을 쓴다(기본 켬). */
|
||||
export const drainageSide: DesignSideFactory = (host) =>
|
||||
createDrainageSide(host, { visible: readStateRaw("drainage-visible") !== "false" });
|
||||
|
||||
/** 켬 · 끔 단추 — 누를 때마다 값을 적는다. */
|
||||
export function drainageSideTool(board: DesignSideBoard): HTMLElement {
|
||||
const side = board as DrainageSideBoard;
|
||||
side.onVisibleChange((visible) => writeStateRaw("drainage-visible", String(visible)));
|
||||
return createDrainageToggleButton(side);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
|
||||
* 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */
|
||||
const HALF_TURN_ICON = {
|
||||
export const HALF_TURN_ICON = {
|
||||
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
|
||||
@@ -42,9 +42,9 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__rec-now" data-rec-now></span>
|
||||
<span class="b05-routeedit__actions">
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
|
||||
title="예상노선 추적 · 토공 최소 · 기울기 순한 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface RouteEditNavigationParams {
|
||||
/** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
|
||||
* 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
|
||||
unrotateDelta?: (dx: number, dy: number) => [number, number];
|
||||
/** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */
|
||||
leftPan?: boolean;
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
@@ -78,7 +80,12 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
// 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다.
|
||||
if (event.button === 1) event.preventDefault();
|
||||
// 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫.
|
||||
if (event.pointerType === "mouse" && event.button !== 1) return;
|
||||
if (
|
||||
event.pointerType === "mouse" &&
|
||||
event.button !== 1 &&
|
||||
!(params.leftPan && event.button === 0)
|
||||
)
|
||||
return;
|
||||
const view = getView();
|
||||
dragStart = {
|
||||
x: event.clientX,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
criteriaLine,
|
||||
CRITERIA_KEYS,
|
||||
LOGIC_LABEL,
|
||||
mapPaths,
|
||||
metricsLine,
|
||||
rebalanceWeights,
|
||||
relaxedLine,
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
type MapLayer,
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
|
||||
import "./B05_Profile_UI_Style_RouteRecommend.css";
|
||||
|
||||
type Vertex = [number, number];
|
||||
@@ -61,34 +61,31 @@ const CRITERIA_FIELD: Record<
|
||||
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
|
||||
> = {
|
||||
max_grade_pct: {
|
||||
label: "기울기",
|
||||
label: "종단기울기 상한",
|
||||
unit: "%",
|
||||
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
|
||||
of: (c) => c.maxGradePct,
|
||||
},
|
||||
min_radius_m: {
|
||||
label: "최소R",
|
||||
label: "최소 반지름",
|
||||
unit: "m",
|
||||
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
|
||||
of: (c) => c.minRadiusM,
|
||||
},
|
||||
stream_offset_m: {
|
||||
label: "계류",
|
||||
label: "계류 이격",
|
||||
unit: "m",
|
||||
title: "계류 이격(m) — 비우면 기본값 100m",
|
||||
of: (c) => c.streamOffsetM,
|
||||
},
|
||||
grade_smooth_m: {
|
||||
label: "지반",
|
||||
label: "지반 고름 폭",
|
||||
unit: "m",
|
||||
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
|
||||
of: (c) => c.gradeSmoothM,
|
||||
},
|
||||
};
|
||||
|
||||
const MAP_W = 460;
|
||||
const MAP_H = 340;
|
||||
|
||||
export interface RecommendParams {
|
||||
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
|
||||
criteria: RouteInitialCriteria | null;
|
||||
@@ -120,13 +117,17 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
let seq = 0;
|
||||
|
||||
let bodyEl: HTMLElement | null = null;
|
||||
let map: ReturnType<typeof createRecommendMap> | null = null;
|
||||
openModal({
|
||||
title: "노선 추천",
|
||||
closeLabel: "✕",
|
||||
dialogClass: "b05-routerec",
|
||||
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
|
||||
backdropClass: "b05-routerec-backdrop",
|
||||
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
|
||||
beforeClose: () => {
|
||||
closed = true;
|
||||
map?.dispose();
|
||||
return true;
|
||||
},
|
||||
mount: (body, close) => {
|
||||
@@ -141,21 +142,28 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>
|
||||
<div class="b05-routerec__main">
|
||||
<div class="b05-routerec__mapbox">
|
||||
<svg class="b05-routerec__map" viewBox="0 0 ${MAP_W} ${MAP_H}" role="img"
|
||||
aria-label="추천 노선 미리 보기"></svg>
|
||||
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
|
||||
${RECOMMEND_MAP_HTML}
|
||||
</div>
|
||||
<div class="b05-routerec__legend">
|
||||
<span><i class="is-expected"></i>예상노선(원본)</span>
|
||||
<span><i class="is-current"></i>창을 연 노선</span>
|
||||
<span><i class="is-preview"></i>미리 보기</span>
|
||||
</div>
|
||||
<div class="b05-routerec__maphint">휠 = 확대 · 축소 끌기 = 이동 ↺ ↻ = 회전</div>
|
||||
</div>
|
||||
<div class="b05-routerec__side">
|
||||
<div class="b05-routerec__weights" hidden>
|
||||
${WEIGHT_KEYS.map(
|
||||
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
|
||||
<output data-weight-out="${key}"></output></label>`,
|
||||
).join("")}
|
||||
<div class="b05-routerec__bars">
|
||||
${WEIGHT_KEYS.map(
|
||||
(
|
||||
key,
|
||||
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}"
|
||||
aria-label="${WEIGHT_NAME[key]} 비중" />
|
||||
<span>${WEIGHT_NAME[key]}</span></label>`,
|
||||
).join("")}
|
||||
</div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
</div>
|
||||
<div class="b05-routerec__criteria">
|
||||
@@ -181,7 +189,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>`;
|
||||
|
||||
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
|
||||
const svg = body.querySelector<SVGSVGElement>(".b05-routerec__map")!;
|
||||
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
|
||||
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
|
||||
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
|
||||
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
|
||||
@@ -213,9 +221,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
{ kind: "current", points: params.current },
|
||||
];
|
||||
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
|
||||
svg.innerHTML = mapPaths(layers, MAP_W, MAP_H)
|
||||
.map((path) => `<path class="is-${path.kind}" d="${path.d}" />`)
|
||||
.join("");
|
||||
map?.setLayers(layers);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Recommend_Map.ts
|
||||
* [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기).
|
||||
*
|
||||
* 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` ·
|
||||
* 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기
|
||||
* `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는
|
||||
* 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
metricToScreen,
|
||||
ROUTE_VIEW_MARGIN_M,
|
||||
routeLineColor,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
||||
import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View";
|
||||
|
||||
/** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */
|
||||
const FIT_MARGIN_M = 30;
|
||||
|
||||
/** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */
|
||||
export const RECOMMEND_MAP_HTML = `
|
||||
<canvas class="b05-routerec__map"></canvas>
|
||||
<div class="b05-routeedit__spin">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
|
||||
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
|
||||
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
|
||||
</div>`;
|
||||
|
||||
export interface RecommendMap {
|
||||
/** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
|
||||
setLayers: (layers: MapLayer[]) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
|
||||
export function createRecommendMap(box: HTMLElement): RecommendMap {
|
||||
const canvas = box.querySelector<HTMLCanvasElement>("canvas")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
let layers: MapLayer[] = [];
|
||||
let meta: VWorldMeta | null = null;
|
||||
let fitted = false;
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
mapRect: computeMapRect(null, 0, 0),
|
||||
};
|
||||
const rotation = createMapRotation({ overlay: box, size: () => view, onChange: () => draw() });
|
||||
|
||||
function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null {
|
||||
const all = layers.flatMap((layer) => layer.points);
|
||||
if (!all.length) return null;
|
||||
const xs = all.map(([x]) => x);
|
||||
const ys = all.map(([, y]) => y);
|
||||
return {
|
||||
x_min: Math.min(...xs),
|
||||
x_max: Math.max(...xs),
|
||||
y_min: Math.min(...ys),
|
||||
y_max: Math.max(...ys),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */
|
||||
function makeMeta(range: NonNullable<ReturnType<typeof bounds>>): VWorldMeta {
|
||||
const x_min = range.x_min - ROUTE_VIEW_MARGIN_M;
|
||||
const y_min = range.y_min - ROUTE_VIEW_MARGIN_M;
|
||||
const x_max = range.x_max + ROUTE_VIEW_MARGIN_M;
|
||||
const y_max = range.y_max + ROUTE_VIEW_MARGIN_M;
|
||||
return {
|
||||
x_min,
|
||||
x_max,
|
||||
y_min,
|
||||
y_max,
|
||||
width_meters: x_max - x_min,
|
||||
height_meters: y_max - y_min,
|
||||
center_x: (x_min + x_max) / 2,
|
||||
center_y: (y_min + y_max) / 2,
|
||||
lon_min: 0,
|
||||
lon_max: 1,
|
||||
lat_min: 0,
|
||||
lat_max: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function refit(): void {
|
||||
const b = bounds();
|
||||
if (!b || !view.width) return;
|
||||
meta = makeMeta(b);
|
||||
view = {
|
||||
...view,
|
||||
mapRect: computeMapRect(meta, view.width, view.height),
|
||||
...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M),
|
||||
};
|
||||
fitted = true;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
if (!meta) return;
|
||||
context.save();
|
||||
context.translate(view.width / 2, view.height / 2);
|
||||
context.rotate(rotation.radians());
|
||||
context.translate(-view.width / 2, -view.height / 2);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
const styles: Record<MapLayer["kind"], () => void> = {
|
||||
expected: () => {
|
||||
context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af");
|
||||
context.lineWidth = 1.5;
|
||||
context.setLineDash([5, 4]);
|
||||
},
|
||||
current: () => {
|
||||
context.strokeStyle = routeLineColor();
|
||||
context.globalAlpha = 0.55;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([]);
|
||||
},
|
||||
preview: () => {
|
||||
context.strokeStyle = themeColor("--color-primary", "#6d28d9");
|
||||
context.lineWidth = 3;
|
||||
context.setLineDash([]);
|
||||
},
|
||||
};
|
||||
for (const layer of layers) {
|
||||
if (layer.points.length < 2) continue;
|
||||
context.save();
|
||||
styles[layer.kind]();
|
||||
context.beginPath();
|
||||
layer.points.forEach(([x, y], index) => {
|
||||
const [sx, sy] = metricToScreen(meta!, view, x, y);
|
||||
if (index) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function resize(): void {
|
||||
const { width, height } = fitCanvas(canvas, context);
|
||||
if (!width || !height) return;
|
||||
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
||||
// 처음 한 번만 맞춘다 — 그 뒤 창 크기가 바뀌어도 사용자가 만진 배율 · 자리를 지킨다.
|
||||
if (!fitted) refit();
|
||||
draw();
|
||||
}
|
||||
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
getView: () => view,
|
||||
setView: (next) => (view = next),
|
||||
getMeta: () => meta,
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
leftPan: true,
|
||||
draw,
|
||||
});
|
||||
// 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다.
|
||||
const observer = new ResizeObserver(() => resize());
|
||||
observer.observe(canvas.parentElement!);
|
||||
|
||||
return {
|
||||
setLayers(next) {
|
||||
layers = next;
|
||||
if (!fitted) refit();
|
||||
draw();
|
||||
},
|
||||
dispose: () => observer.disconnect(),
|
||||
};
|
||||
}
|
||||
@@ -288,3 +288,8 @@
|
||||
}
|
||||
|
||||
/* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */
|
||||
|
||||
/* 종단도 판 자리에서 끈 배수유역도(`_Drainage_Side` · 계획서 24-2e) — 자리를 비운다. */
|
||||
.b05-drainage[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,20 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */
|
||||
.ui-modal__backdrop.b05-routerec-backdrop {
|
||||
z-index: calc(var(--z-modal, 1000) + 2);
|
||||
}
|
||||
|
||||
.ui-modal.b05-routerec {
|
||||
width: min(900px, 94vw);
|
||||
width: min(960px, 94vw);
|
||||
max-height: 92vh;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-routerec__body {
|
||||
gap: var(--spacing-12);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 알약 — 하나만 고른 상태(라디오 묶음). */
|
||||
@@ -26,12 +33,12 @@
|
||||
}
|
||||
|
||||
.b05-routerec__pill {
|
||||
padding: 6px var(--spacing-16);
|
||||
padding: 8px var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
font-size: var(--text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -53,7 +60,7 @@
|
||||
|
||||
.b05-routerec__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-12);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -64,36 +71,27 @@
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b05-routerec__map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */
|
||||
.b05-routerec__mapwrap {
|
||||
position: relative;
|
||||
height: 380px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routerec__map path {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
.b05-routerec__map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.b05-routerec__map path.is-expected {
|
||||
stroke: var(--color-text-secondary, #9ca3af);
|
||||
stroke-dasharray: 5 4;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.b05-routerec__map path.is-current {
|
||||
stroke: var(--map-route, #f97316);
|
||||
stroke-width: 2;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.b05-routerec__map path.is-preview {
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2.5;
|
||||
.b05-routerec__maphint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-routerec__legend {
|
||||
@@ -101,7 +99,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-routerec__legend i {
|
||||
@@ -128,53 +126,81 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */
|
||||
.b05-routerec__weights {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b05-routerec__weights label {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5em 1fr 2.5em;
|
||||
align-items: center;
|
||||
.b05-routerec__weights[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-routerec__bars {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__weights output {
|
||||
.b05-routerec__bar {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
}
|
||||
|
||||
.b05-routerec__bar input[type="range"] {
|
||||
width: 28px;
|
||||
height: 130px;
|
||||
margin: 0;
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl; /* 위로 올릴수록 큰 값 */
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routerec__bar output {
|
||||
color: var(--color-text-body);
|
||||
text-align: right;
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routerec__bar span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__sum {
|
||||
text-align: right;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 공통 제약 네 칸 — 비우면 placeholder 가 기본값. */
|
||||
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
|
||||
.b05-routerec__criteria {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__criteria label {
|
||||
display: inline-flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routerec__criteria input {
|
||||
width: 4em;
|
||||
padding: 2px 4px;
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -191,8 +217,8 @@
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.45;
|
||||
font-size: var(--text-body-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
|
||||
@@ -212,7 +238,7 @@
|
||||
.b05-routerec__state {
|
||||
flex: 1 1 12em;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좁은 폭 — 지도 아래로 조작 칸. */
|
||||
@@ -220,4 +246,8 @@
|
||||
.b05-routerec__main {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.b05-routerec__mapwrap {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +492,20 @@
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
|
||||
}
|
||||
|
||||
/* 도구줄 오른쪽 묶음 — 가로 배율 조작구 + 오른쪽 판 켬끔 단추(두 보기 같은 자리, 계획서 24-2c). */
|
||||
.b05-route-profile__trailing {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.b05-route-profile__trailing > .b05-drainage__toggle {
|
||||
padding: 1px var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-profile__zoom-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@@ -4,11 +4,10 @@ import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
fetchWorkflowState,
|
||||
@@ -58,7 +57,10 @@ import "./B06_Section_UI_Style_Cross_Controls.css";
|
||||
import "./B06_Section_UI_Style_Cross_Areas.css";
|
||||
import { loadSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import { buildGroup, createSampleWidener, L } from "./B06_Section_UI_Page_Common";
|
||||
import { createSampleWidener, L } from "./B06_Section_UI_Page_Common";
|
||||
import { createCrossViewPart, createStandardSlot } from "./B06_Section_UI_Page_Panel";
|
||||
import { createDesignPanel } from "../A00_Common/design/design_panel";
|
||||
import { resetDesign } from "../A00_Common/design/design_reset";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
|
||||
export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
@@ -68,65 +70,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
let stationInterval: number | undefined;
|
||||
|
||||
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
|
||||
// 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다.
|
||||
const standardGroup = buildGroup(L("B06_Std_Title"), true);
|
||||
const standardPanelSlot = document.createElement("div");
|
||||
standardGroup.append(standardPanelSlot);
|
||||
const standardPanelSlot = createStandardSlot();
|
||||
let standardPanel: StandardPanelController | null = null;
|
||||
|
||||
// 높이 배율 옵션은 폐지 — 항상 1배율(2026-08-06 사용자 지시).
|
||||
// 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시)
|
||||
// — 표준 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계
|
||||
// 재계산을 기다려야 했다. 요청 반폭이 보유 샘플 폭(기본 20m, config
|
||||
// SECTION_CROSS_HALF_WIDTH_M) 이하면 표시만 바꾸므로 즉시 끝나고, 넘을 때만 재생성한다.
|
||||
const crossHalfWidthField = createInputField({
|
||||
label: L("B05_Route_Field_CrossHalfWidth"),
|
||||
type: "number",
|
||||
});
|
||||
crossHalfWidthField.input.min = "0.1";
|
||||
crossHalfWidthField.input.step = "0.1";
|
||||
const applyWidthButton = createButton({
|
||||
label: L("B06_View_Apply"),
|
||||
variant: "filled",
|
||||
onClick: () => void applyCrossHalfWidth(),
|
||||
});
|
||||
applyWidthButton.title = L("B06_View_Apply_Tip");
|
||||
const viewGroup = buildGroup(L("B06_View_Title"));
|
||||
// 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시).
|
||||
const viewRow = document.createElement("div");
|
||||
viewRow.className = "b06-profile__field-row";
|
||||
viewRow.append(crossHalfWidthField.root, applyWidthButton);
|
||||
viewGroup.append(viewRow);
|
||||
// 「횡단 보기 설정」 칸 재료(반폭 입력 + [보기 반폭 적용])는 `_Page_Panel` 이 만든다.
|
||||
const crossView = createCrossViewPart(() => void applyCrossHalfWidth());
|
||||
const crossHalfWidthField = crossView.field;
|
||||
/** 횡단면도 **표시** 반폭 기본값(m) — 2026-08-19 사용자 지시로 20 → 12. 계산·보유
|
||||
* 샘플 폭은 백엔드 기본(SECTION_CROSS_HALF_WIDTH_M = 20m) 그대로라 12는 표시
|
||||
* 범위만 좁힌다(12 ≤ 20이므로 재계산 없음). 계산 범위와 혼동 금지. */
|
||||
const DISPLAY_HALF_WIDTH_DEFAULT_M = 12;
|
||||
|
||||
// 하단 액션 [종단 이동][임시저장][확정] — 종단 이동은 저장 없이 B05로 페이지 이동만,
|
||||
// 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로 넘어간다
|
||||
// (2026-08-08 워크플로우 재정의).
|
||||
const goProfileButton = createButton({
|
||||
label: L("B06_Profile_Btn_GoProfile"),
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.B05_PROFILE),
|
||||
});
|
||||
const saveButton = createButton({
|
||||
label: L("B06_Profile_Btn_Save"),
|
||||
variant: "ghost",
|
||||
onClick: () => void saveCurrentSections(persistContext),
|
||||
});
|
||||
saveButton.title = L("B06_Profile_Btn_Save_Tip");
|
||||
saveButton.disabled = true;
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
variant: "filled",
|
||||
onClick: () => void confirmCurrentSections(persistContext),
|
||||
});
|
||||
confirmButton.disabled = true;
|
||||
const actionRow = document.createElement("div");
|
||||
actionRow.className = "b06-profile__actions";
|
||||
actionRow.append(goProfileButton, saveButton, confirmButton);
|
||||
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
|
||||
const focusStationAt = (chainageM: number): void => {
|
||||
const sections = sectionDetail?.cross_sections;
|
||||
@@ -204,18 +159,29 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
});
|
||||
|
||||
const dockDivider = document.createElement("hr");
|
||||
dockDivider.className = "b05-structure__divider";
|
||||
const actionDock = document.createElement("div");
|
||||
actionDock.className = "b05-route__dock ui-sidebar-actions";
|
||||
actionDock.append(structuresPanel.listRoot, dockDivider, actionRow);
|
||||
|
||||
const leftForm = document.createElement("div");
|
||||
leftForm.className = "b06-profile__form";
|
||||
// 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준.
|
||||
leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock);
|
||||
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
||||
attachCollapsible(leftForm);
|
||||
// 좌측 패널 = 등록부가 칸을 차례대로 세우고 아래 고정 영역(단추 넷)을 붙인다. 순서: 구조물 배치
|
||||
// (최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. 자료가 오기 전에는 [저장]·[확정]을
|
||||
// 잠근다(`updateActionState`). 확정은 임시저장과 같은 내용 저장 후 stage 2·3을 닫고 B07 상세설계로
|
||||
// 넘어간다(2026-08-08 워크플로우 재정의).
|
||||
const design = createDesignPanel(
|
||||
{
|
||||
projectId,
|
||||
parts: {
|
||||
structures: structuresPanel,
|
||||
crossView,
|
||||
standardSlot: standardPanelSlot,
|
||||
},
|
||||
dock: {
|
||||
reset: { run: () => resetDesign(projectId ?? "", currentRouteId) },
|
||||
save: { run: () => saveCurrentSections(persistContext), enabled: false },
|
||||
confirm: { run: () => confirmCurrentSections(persistContext), enabled: false },
|
||||
switchView: { run: () => navigateTo(ROUTES.B05_PROFILE) },
|
||||
},
|
||||
},
|
||||
"cross",
|
||||
);
|
||||
const leftForm = design.root;
|
||||
const saveTip = L("B06_Profile_Btn_Save_Tip");
|
||||
|
||||
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
||||
const {
|
||||
@@ -457,8 +423,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
function updateActionState(): void {
|
||||
saveButton.disabled = sectionDetail === null;
|
||||
confirmButton.disabled = sectionDetail === null;
|
||||
const ready = sectionDetail !== null;
|
||||
design.dock.setActions({
|
||||
save: { run: () => saveCurrentSections(persistContext), enabled: ready, title: saveTip },
|
||||
confirm: { run: () => confirmCurrentSections(persistContext), enabled: ready },
|
||||
});
|
||||
}
|
||||
|
||||
/** 세션에 남은 선택을 이어받는다(2026-09-04 — 두 화면이 한 페이지처럼).
|
||||
|
||||
@@ -11,16 +11,6 @@ export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export function buildGroup(legend: string, collapsed = false): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
|
||||
const title = document.createElement("h3");
|
||||
title.className = "b06-profile__group-legend ui-collapsible__title";
|
||||
title.textContent = legend;
|
||||
group.append(title);
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 반폭 확대(백엔드 재생성)에 필요한 페이지 상태 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
||||
export interface SampleWidenerDeps {
|
||||
/** 현재 대상 경로. 없으면 확대 불가. */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createProfileEditStore,
|
||||
useProfileEditStore,
|
||||
type ProfileEditStore,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
@@ -28,8 +28,8 @@ export interface GradeEditContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만
|
||||
* 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`),
|
||||
* 계획선 편집(▲/▼) — B05 종단 패널의 **같은 가게**(노선마다 한 벌)를 쓴다(계획서 24-2a ·
|
||||
* 2026-09-12 사용자: B06 에만 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`),
|
||||
* 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장).
|
||||
*/
|
||||
export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
|
||||
@@ -53,7 +53,7 @@ export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
|
||||
if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다.
|
||||
if (!gradeStore || gradeRouteId !== currentRouteId) {
|
||||
gradeRouteId = currentRouteId;
|
||||
gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined);
|
||||
gradeStore = useProfileEditStore(currentRouteId, stored.edits, () => undefined);
|
||||
}
|
||||
const store = gradeStore;
|
||||
const base = toAlignmentBase(stored);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Panel.ts
|
||||
* 횡단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`)와 그 재료 — 횡단 보기 설정 ·
|
||||
* 표준 횡단면 설정. 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
|
||||
*
|
||||
* 칸 차례(2026-08-29 사용자 지시): 구조물 배치(최상단) > 횡단 보기 설정 > 표준 횡단면 설정 >
|
||||
* 아래 고정 영역. 페이지(`B06_Section_UI_Page.ts`)는 칸도 단추도 직접 만들지 않고 여기 재료와
|
||||
* `createDesignPanel` 만 쓴다(계획서 24-2b · 시험 `test_design_view_parity.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import "../A00_Common/design/design_sections";
|
||||
import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel";
|
||||
import { createButton, createInputField } from "@ui/ui_template_elements";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
|
||||
/** 「횡단 보기 설정」 재료 — 반폭 입력 + 자체 [보기 반폭 적용] 한 줄.
|
||||
*
|
||||
* 횡단 반폭은 **자체 컨테이너 + 자체 [보기 반폭 적용] 버튼**이다(2026-08-23 사용자 지시) — 표준
|
||||
* 횡단면 설정의 [전체 측점 반영]에 물려 있어 반폭만 바꿔도 전 측점 설계 재계산을 기다려야 했다. */
|
||||
export function createCrossViewPart(onApply: () => void) {
|
||||
const field = createInputField({
|
||||
label: L("B05_Route_Field_CrossHalfWidth"),
|
||||
type: "number",
|
||||
});
|
||||
field.input.min = "0.1";
|
||||
field.input.step = "0.1";
|
||||
const applyButton = createButton({
|
||||
label: L("B06_View_Apply"),
|
||||
variant: "filled",
|
||||
onClick: onApply,
|
||||
});
|
||||
applyButton.title = L("B06_View_Apply_Tip");
|
||||
// 반폭 입력 + [적용]은 한 줄(2026-08-23 사용자 지시).
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-profile__field-row";
|
||||
row.append(field.root, applyButton);
|
||||
return { field, row };
|
||||
}
|
||||
|
||||
export type CrossViewPart = ReturnType<typeof createCrossViewPart>;
|
||||
|
||||
/** 「표준 횡단면 설정」 패널이 들어갈 자리 — context 를 받은 뒤 패널을 채운다. */
|
||||
export function createStandardSlot(): HTMLElement {
|
||||
return document.createElement("div");
|
||||
}
|
||||
|
||||
registerPanelSection({
|
||||
id: "cross-view",
|
||||
title: L("B06_View_Title"),
|
||||
views: ["cross"],
|
||||
order: 20,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, L("B06_View_Title"));
|
||||
frame.body.append(part<CrossViewPart>(ctx, "crossView").row);
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
|
||||
registerPanelSection({
|
||||
id: "standard-cross",
|
||||
title: L("B06_Std_Title"),
|
||||
views: ["cross"],
|
||||
order: 30,
|
||||
build: (ctx, view) => {
|
||||
// 표준 횡단면 설정은 기본 접힘(2026-08-23 사용자 지시) — 자주 여는 칸이 아니다.
|
||||
const frame = createSectionFrame(view, L("B06_Std_Title"), true);
|
||||
frame.body.append(part<HTMLElement>(ctx, "standardSlot"));
|
||||
return frame.root;
|
||||
},
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
* (CLAUDE.md 5장 데이터 3층: 영구저장은 [저장]·[확정]에서만).
|
||||
* ========================================================================== */
|
||||
|
||||
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
confirmSections,
|
||||
@@ -17,14 +17,7 @@ import {
|
||||
type SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import {
|
||||
clearAlignmentDrafts,
|
||||
readAlignmentDraft,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { saveDesign, type DesignSaveContext } from "../A00_Common/design/design_save";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
@@ -423,102 +416,64 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise<void> {
|
||||
if (routeId === null) return;
|
||||
const draft = readAlignmentDraft(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
clearAlignmentDrafts();
|
||||
}
|
||||
|
||||
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
|
||||
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
|
||||
// ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도
|
||||
// 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가·
|
||||
// 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로
|
||||
// 하면 스냅샷이 B06 편집을 통째로 덮는다.
|
||||
await flushPendingPipes(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
// 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await ctx.flushCulvertOptions();
|
||||
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
|
||||
// 실려, B06에서 저장·확정하면 세션에만 남아 옛 방향이 정본에 그대로 있었다
|
||||
// (2026-09-06). 서버가 종단 정본과 저장된 횡단 설계를 함께 갱신하므로 아래
|
||||
// 횡단 patch 저장보다 **먼저** 나가야 사용자 수정이 위에 얹힌다.
|
||||
await flushUphillOverrides(projectId).catch(() => undefined);
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||
// 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서
|
||||
// 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서
|
||||
// 저장하면 계획선 편집이 다음 진입 때 사라졌다.
|
||||
await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`계획선 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
await flushPendingStructures(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`구조물 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
/**
|
||||
* B06 의 저장 한 줄기 창구(계획서 24-2a) — 차례는 `saveDesign` 이 정하고(B05 [임시저장]과 같음),
|
||||
* 여기서는 B06 몫만 준다: 저장 앞 전 측점 재계산 · 조정창 구간값 · 횡단 본문 · 뒤처리.
|
||||
*/
|
||||
function sectionSaveContext(ctx: SectionPersistContext, projectId: string): DesignSaveContext {
|
||||
return {
|
||||
projectId,
|
||||
routeId: ctx.routeId,
|
||||
// ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤
|
||||
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
|
||||
prepare: () => ctx.reconcileDesigns(),
|
||||
// 조정창 구간값은 조정창 저장기가 낸다 — 실패하면 알리고 저장을 멈춘다(예전 그대로).
|
||||
steps: { culvertOptions: () => ctx.flushCulvertOptions() },
|
||||
cross: async (confirm) => {
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId === null) return;
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await (confirm ? confirmSections : saveSections)(
|
||||
projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
},
|
||||
done: (confirm) => {
|
||||
if (confirm) {
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
return;
|
||||
}
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
|
||||
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
|
||||
const routeId = ctx.routeId();
|
||||
if (routeId !== null) invalidateSectionDetail(projectId, routeId);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
},
|
||||
failed: (error, confirm) => {
|
||||
if (confirm) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
|
||||
return;
|
||||
}
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 임시 저장 — 저장만 하고 페이지는 그대로 둔다. */
|
||||
export async function saveCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
const routeId = ctx.routeId();
|
||||
if (!ctx.projectId || routeId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
// ⚠ 면적을 모으기 **전에** 전 측점을 다시 계산한다 — 안 그러면 표준단면을 고친 뒤
|
||||
// 안 만진 측점이 옛 면적으로 실려 나간다(2026-09-09 실측).
|
||||
await ctx.reconcileDesigns();
|
||||
await flushPendingEdits(ctx, ctx.projectId);
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await saveSections(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
// 저장으로 구조물 초안이 정본이 됐다 — 화면이 들고 있던 상세는 초안 기준으로 벽을
|
||||
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
|
||||
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
|
||||
invalidateSectionDetail(ctx.projectId, routeId);
|
||||
showToast(L("B06_Profile_Save_Success"), "success");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Profile_Save_Failed")}${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (!ctx.projectId) return;
|
||||
await saveDesign(sectionSaveContext(ctx, ctx.projectId));
|
||||
}
|
||||
|
||||
/** 확정 — 저장 뒤 다음 단계(상세설계)로 넘어간다. */
|
||||
export async function confirmCurrentSections(ctx: SectionPersistContext): Promise<void> {
|
||||
const routeId = ctx.routeId();
|
||||
if (!ctx.projectId || routeId === null) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await ctx.reconcileDesigns(); // 저장과 같은 이유 — 옛 면적이 정본으로 굳는 것을 막는다
|
||||
await flushPendingEdits(ctx, ctx.projectId);
|
||||
const edits = collectSectionEdits(ctx);
|
||||
await confirmSections(
|
||||
ctx.projectId,
|
||||
routeId,
|
||||
ctx.standardValues(),
|
||||
edits.crossPatches.length ? edits.crossPatches : undefined,
|
||||
edits.massHaul,
|
||||
);
|
||||
showToast(L("B06_Profile_Confirm_Success"), "success");
|
||||
goToWorkflowStage(ctx.projectId, WORKFLOW_STEP_ROUTES[4]);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B06_Profile_Confirm_Failed");
|
||||
showToast(`${L("B06_Profile_Confirm_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
if (!ctx.projectId) return;
|
||||
await saveDesign(sectionSaveContext(ctx, ctx.projectId), { confirm: true });
|
||||
}
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View.ts
|
||||
* 종·횡단 도면 뷰 컨트롤러: 상단 패널(종단면도 + 유토곡선)과 횡단 카드 그리드를 조립하고,
|
||||
* 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 패널 높이 조절, 같은 행 높이 통일,
|
||||
* 단건 카드 갱신을 관장한다.
|
||||
* 횡단 보기 컨트롤러: 위 칸 = 횡단 카드 그리드(그 안에서 스크롤) · 가운데 아래쪽 =
|
||||
* 종단도 한 벌(종단 보기와 같은 자리 · 같은 도구, 계획서 24-0 · 24-2c)을 조립하고,
|
||||
* 측점 선택/스크롤, 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을
|
||||
* 관장한다.
|
||||
*
|
||||
* 700줄 제한 대응으로 렌더러 본체는 분리했다:
|
||||
* 나눈 자리:
|
||||
* - 공통 상수·유틸: `_UI_Section_Common`
|
||||
* - 종단 렌더러: `_UI_Longitudinal`
|
||||
* - 유토곡선 계산: `_UI_MassHaul`
|
||||
* - 유토곡선 렌더러: `_UI_MassHaul_View`
|
||||
* - 종단도 이음: `_UI_Section_View_Profile`(종단도 한 벌 `design_profile`)
|
||||
* - 횡단 카드 렌더러: `_UI_Cross_View`
|
||||
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import type {
|
||||
CrossSection,
|
||||
EarthworkConversion,
|
||||
HaulEquipmentLimit,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||
@@ -42,33 +39,9 @@ import {
|
||||
effectiveCardHalfWidth,
|
||||
} from "./B06_Section_UI_Cross_View_Metrics";
|
||||
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
|
||||
import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import {
|
||||
attachWindowScroll,
|
||||
createElevationHold,
|
||||
drawLongitudinalPanel,
|
||||
type LongitudinalPanelInput,
|
||||
} from "./B06_Section_UI_Section_View_Draw";
|
||||
import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import {
|
||||
BASE_PANEL_HEIGHT,
|
||||
chartHeights,
|
||||
MAX_PANEL_HEIGHT_RATIO,
|
||||
MIN_LONG_HEIGHT,
|
||||
MIN_PANEL_HEIGHT,
|
||||
PANEL_CHROME_PX,
|
||||
PANEL_HEIGHT_KEY,
|
||||
} from "./B06_Section_UI_Section_View_Panel";
|
||||
import {
|
||||
CROSS_GRID_GAP,
|
||||
CROSS_GRID_MIN_WIDTH,
|
||||
@@ -82,6 +55,7 @@ import {
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
|
||||
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
|
||||
import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile";
|
||||
|
||||
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController };
|
||||
|
||||
@@ -103,6 +77,10 @@ export function createSectionView(
|
||||
): SectionViewController {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section";
|
||||
/** 위 칸 — 횡단 카드는 **이 안에서** 스크롤한다(종단도는 아래에 붙박이, 계획서 24-0). */
|
||||
const cards = document.createElement("div");
|
||||
cards.className = "b06-section__cards";
|
||||
root.append(cards);
|
||||
let currentDetail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
/** 선택 카드에서 강조 중인 면적. 카드를 다시 그려도 그대로 살아나야 하므로 여기 둔다. */
|
||||
@@ -110,192 +88,54 @@ export function createSectionView(
|
||||
let currentExaggeration = 1;
|
||||
let currentCrossHalfWidth: number | undefined;
|
||||
let currentStationInterval: number | undefined;
|
||||
let currentConversion: EarthworkConversion | undefined;
|
||||
let currentNaturalSpoilSlope: number | undefined;
|
||||
let markStructures: ReadonlyArray<StructureInstance> = [];
|
||||
let markTypes: ReadonlyArray<StructureType> = [];
|
||||
let structureEdit: SectionStructureEdit | null = null;
|
||||
let gradeEdit: (() => LongitudinalPanelInput["grade"]) | null = null;
|
||||
let renderWidth = 0;
|
||||
let resizeTimer = 0;
|
||||
let panelResizeTimer = 0;
|
||||
// 그래프 상자의 마지막 실측 높이와 보정 예약 플래그(빈 공간·세로 스크롤 둘 다 막는다).
|
||||
let lastChartAvailable = 0;
|
||||
let chartFitScheduled = false;
|
||||
// 카드 단위 재빌드에 재사용하는 렌더 컨텍스트 (draw에서 갱신)
|
||||
let cachedStationInterval = 1;
|
||||
let cachedCardWidth = CROSS_WIDTH;
|
||||
// 같은 행 카드는 같은 높이가 되도록 draw에서 측점별 행 높이를 계산해 둔다(단건 갱신도 이 값 재사용).
|
||||
const cachedRowHeight = new Map<string, number>();
|
||||
/** 종단도에 넘길 값 — 다시 그릴 때마다 같은 값을 다시 준다. */
|
||||
let profileInput: {
|
||||
balloonScope: string;
|
||||
conversion?: EarthworkConversion;
|
||||
haulLimits?: HaulEquipmentLimit[];
|
||||
naturalSpoilMinSlope?: number;
|
||||
} = { balloonScope: "-" };
|
||||
|
||||
/* ── 상단 슬라이드 패널(종단면도 + 유토곡선) ─────────────────────────
|
||||
* 패널·리사이저는 한 번만 만들고 draw()에서는 차트 내용만 갈아 끼운다.
|
||||
* 매 렌더마다 새로 만들면 리사이저 핸들과 접힘 상태가 초기화된다. */
|
||||
const panel = document.createElement("section");
|
||||
// 접기/펴기는 B05 하단 패널과 같은 방식이다 — 전역 `ui-collapsible` 규칙에 기대지 않고
|
||||
// `is-collapsed`에 걸 자체 CSS로 높이를 0으로 접고 자식을 숨긴다(손잡이만 남는다).
|
||||
panel.className = "b06-section__panel";
|
||||
// 최소 높이는 TS 상수 하나로만 정의하되 **접힘 상태에는 걸리지 않아야** 한다 — 인라인
|
||||
// min-height를 두면 접어도 그 높이만큼 빈 배경이 남는다. CSS 변수로 넘겨 펼친 상태에만 건다.
|
||||
panel.style.setProperty("--b06-profile-min-height", `${MIN_PANEL_HEIGHT}px`);
|
||||
// 제목 문구는 없앴다(2026-08-02 사용자 지시). 헤더 행은 상태 문구만 담고, 접기/펼치기는
|
||||
// 패널 하단 가운데 손잡이가 맡는다.
|
||||
const panelHeader = document.createElement("header");
|
||||
panelHeader.className = "b06-section__panel-status";
|
||||
const panelCount = document.createElement("span");
|
||||
panelHeader.append(panelCount);
|
||||
const chartWrap = document.createElement("div");
|
||||
chartWrap.className = "b06-section__chart-wrap";
|
||||
const panelBody = document.createElement("div");
|
||||
panelBody.className = "b06-section__panel-body";
|
||||
panelBody.append(chartWrap);
|
||||
// 접기 손잡이는 패널 **하단 가운데**에 둔다(2026-08-02 사용자 지시). 모양은 좌측 사이드
|
||||
// 패널과 같은 삼각형 손잡이를 90° 돌린 것이며, 위로 접히는 패널이라 `collapseToward: "up"`이다.
|
||||
//
|
||||
// 공용 `ui-collapsible__title`은 **쓰지 않는다**. 그 클래스를 달면 전역 규칙이 캐럿(▾/▸)을
|
||||
// 하나 더 그리는데, 접힘 상태 전역 선택자(`.ui-collapsible.is-collapsed > .ui-collapsible__title`)가
|
||||
// B06 쪽 무력화 규칙보다 특정도가 높아 캐럿이 삼각형 아이콘과 겹쳐 보였다. 토글은 여기서 직접 한다.
|
||||
const panelToggle = createWorkflowPanelHandle("bottom", "up", "종단/유토곡선");
|
||||
panelToggle.root.classList.add("b06-section__panel-toggle");
|
||||
panel.append(panelHeader, panelBody, panelToggle.root);
|
||||
// 접힘 상태는 세션에만 남긴다(리사이저와 같은 규칙).
|
||||
if (readStateRaw("section-panel-collapsed") === "true") panel.classList.add("is-collapsed");
|
||||
panelToggle.setOpen(!panel.classList.contains("is-collapsed"));
|
||||
panelToggle.root.addEventListener("click", () => {
|
||||
const collapsed = panel.classList.toggle("is-collapsed");
|
||||
writeStateRaw("section-panel-collapsed", String(collapsed));
|
||||
panelToggle.setOpen(!collapsed);
|
||||
// 펼칠 때는 레이아웃이 잡힌 **다음 프레임**에 그린다 — 그래프 몫을 실측으로 잡기 때문에
|
||||
// 같은 프레임에 그리면 접혀 있던 0 높이를 읽는다(B05 하단 패널과 같은 처리).
|
||||
if (!collapsed) requestAnimationFrame(drawPanel);
|
||||
});
|
||||
|
||||
const panelResizer = createPanelResizer({
|
||||
axis: "vertical",
|
||||
target: panel,
|
||||
cssVar: "--b06-profile-height",
|
||||
// 손잡이가 패널 아래쪽에 있으므로 아래로 끌면 커진다.
|
||||
direction: 1,
|
||||
min: MIN_PANEL_HEIGHT,
|
||||
// 부모(`.b06-profile__main`)는 횡단 카드까지 담은 스크롤 콘텐츠라 높이가 수천 px다.
|
||||
// 이 패널은 화면 위에 sticky로 붙어 있으니 상한은 화면 높이 기준이어야 한다.
|
||||
max: () => window.innerHeight * MAX_PANEL_HEIGHT_RATIO,
|
||||
storageKey: PANEL_HEIGHT_KEY,
|
||||
onResize: () => {
|
||||
// 끄는 동안 매 프레임 SVG를 다시 그리면 손이 늦게 따라온다 — 멈춘 뒤 한 번만 그린다.
|
||||
window.clearTimeout(panelResizeTimer);
|
||||
panelResizeTimer = window.setTimeout(() => {
|
||||
drawPanel();
|
||||
// 패널이 커지면 선택 카드가 그 아래로 들어간다 — 새 패널 높이에 맞춰 다시 끌어올린다.
|
||||
revealCard(selectedStationId, "auto");
|
||||
}, 120);
|
||||
},
|
||||
});
|
||||
panel.append(panelResizer.root);
|
||||
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로.
|
||||
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 카드 위 · 측점 누르면 그 카드로.
|
||||
const fillSlopeNotice = createFillSlopeNotice((id) =>
|
||||
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
|
||||
);
|
||||
|
||||
/**
|
||||
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
|
||||
*
|
||||
* 끝에 닿았다고 기본 동작을 넘기면 아래 횡단도 목록이 대신 스크롤돼 화면이 튄다 — 패널 위에
|
||||
* 커서가 있는 동안은 세로 스크롤을 통째로 막는다. 캡처 단계에서 잡는 이유는 패널 안의 다른
|
||||
* 가로 스크롤 상자(범례 줄)가 먼저 먹어 버리지 않게 하기 위해서다.
|
||||
*/
|
||||
panel.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.ctrlKey) return; // 브라우저 확대 제스처는 건드리지 않는다
|
||||
event.preventDefault();
|
||||
const room = chartWrap.scrollWidth - chartWrap.clientWidth;
|
||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||
if (room <= 1 || !delta) return;
|
||||
chartWrap.scrollLeft = Math.min(Math.max(chartWrap.scrollLeft + delta, 0), room);
|
||||
/** 종단도 한 벌 — 첫 그리기에서 프로젝트를 알게 되면 만든다(`_Section_View_Profile`). */
|
||||
const profile = createSectionProfile({
|
||||
selectCard: (stationId) => {
|
||||
if (stationId === null) clearSelection();
|
||||
else if (stationId !== selectedStationId) selectStation(stationId, true);
|
||||
else revealCard(stationId, "smooth");
|
||||
},
|
||||
{ passive: false, capture: true },
|
||||
);
|
||||
|
||||
/**
|
||||
* 가운데 버튼 드래그로 그래프를 좌우로 민다(2026-08-02 사용자 지시). 좌클릭은 측점 선택 몫이다.
|
||||
* 가운데 버튼을 누르면 브라우저가 자동 스크롤을 켜는데, 그건 `mousedown` 기본동작이라
|
||||
* `pointerdown`에서는 못 막는다 — `mousedown`/`auxclick`에서 막아야 팬만 남는다.
|
||||
*/
|
||||
let panPointerId: number | null = null;
|
||||
let panLastX = 0;
|
||||
chartWrap.addEventListener("mousedown", (event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
edit: () => structureEdit,
|
||||
refreshCards: (chainages) => refreshCards(chainages),
|
||||
});
|
||||
chartWrap.addEventListener("auxclick", (event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
chartWrap.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 1) return;
|
||||
event.preventDefault();
|
||||
panPointerId = event.pointerId;
|
||||
panLastX = event.clientX;
|
||||
chartWrap.classList.add("is-panning");
|
||||
chartWrap.setPointerCapture(event.pointerId);
|
||||
});
|
||||
chartWrap.addEventListener("pointermove", (event) => {
|
||||
if (panPointerId !== event.pointerId) return;
|
||||
chartWrap.scrollLeft -= event.clientX - panLastX;
|
||||
panLastX = event.clientX;
|
||||
});
|
||||
const endChartPan = (event: PointerEvent): void => {
|
||||
if (panPointerId !== event.pointerId) return;
|
||||
panPointerId = null;
|
||||
chartWrap.classList.remove("is-panning");
|
||||
try {
|
||||
chartWrap.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
chartWrap.addEventListener("pointerup", endChartPan);
|
||||
chartWrap.addEventListener("pointercancel", endChartPan);
|
||||
|
||||
const panelHeight = (): number => {
|
||||
// 첫 렌더 때 panel은 아직 root에 붙기 전(detached)이라 getComputedStyle이 빈 값을 준다.
|
||||
// 리사이저가 복원해 둔 인라인 변수부터 읽어야 저장된 높이가 첫 화면부터 반영된다.
|
||||
// (computed만 읽으면 기본값으로 폴백해 종단도가 잘린 채로 그려진다 — C05 검증 F1)
|
||||
const inline = Number.parseFloat(panel.style.getPropertyValue("--b06-profile-height"));
|
||||
if (Number.isFinite(inline) && inline > 0) return inline;
|
||||
const raw = Number.parseFloat(getComputedStyle(panel).getPropertyValue("--b06-profile-height"));
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : BASE_PANEL_HEIGHT;
|
||||
};
|
||||
|
||||
const contentWidth = (): number => {
|
||||
// 요소가 DOM 밖(detached)이면 computed padding이 ""라 parseFloat이 NaN을 만든다.
|
||||
// NaN이 renderWidth로 흘러가면 종단 SVG 전 좌표가 NaN이 되므로 0으로 방어한다.
|
||||
const style = getComputedStyle(root);
|
||||
// NaN이 renderWidth로 흘러가면 카드 폭이 NaN이 되므로 0으로 방어한다.
|
||||
const style = getComputedStyle(cards);
|
||||
const pad = (value: string): number => Number.parseFloat(value) || 0;
|
||||
const width = root.clientWidth - pad(style.paddingLeft) - pad(style.paddingRight);
|
||||
const width = cards.clientWidth - pad(style.paddingLeft) - pad(style.paddingRight);
|
||||
return Number.isFinite(width) ? Math.max(0, width) : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 상단 패널이 sticky라 카드 스크롤 정지선은 `scroll-margin-top`(패널 높이 + 손잡이 몫)이다.
|
||||
* 패널은 리사이저·접기 손잡이로 수시로 높이가 변하고, 접을 때는 drawPanel이 돌지 않으므로
|
||||
* **스크롤 직전에 실측**해야 정지선이 현재 패널과 맞는다.
|
||||
*/
|
||||
const syncScrollMargin = (): void => {
|
||||
// 접기 손잡이가 패널 아래 24px 밖으로 나와 있고, 선택 강조(빨간 테두리 + 2px ring)가
|
||||
// 잘리지 않도록 8px 여유를 더한다.
|
||||
const panelPx = panel.getBoundingClientRect().height || panelHeight();
|
||||
root.style.setProperty("--b06-cross-scroll-margin", `${Math.round(panelPx + 32)}px`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 선택 카드를 화면에 남긴다. `nearest`는 카드가 아래에 있을 때 카드 **하단**을 브라우저
|
||||
* 바닥에 맞춰 멈추는데, 그러면 카드 상단(측점 라벨)이 화면 밖이다(2026-08-04 사용자 지적).
|
||||
* `start`로 카드 **상단**을 접기 손잡이 바로 아래 정지선에 붙인다. 목록 맨 끝 카드는
|
||||
* 스크롤이 바닥에 걸려 그 자리까지 못 올라오며, 그대로 둔다(브라우저가 알아서 멈춘다).
|
||||
* 선택 카드를 위 칸 안에서 보이게 한다 — `start`로 카드 **상단**(측점 라벨)을 칸 위에
|
||||
* 붙인다(2026-08-04 사용자 지적). 목록 맨 끝 카드는 스크롤이 바닥에 걸려 그 자리까지 못
|
||||
* 올라오며, 그대로 둔다(브라우저가 알아서 멈춘다).
|
||||
*/
|
||||
const revealCard = (stationId: string | null, behavior: ScrollBehavior): void => {
|
||||
if (!stationId) return;
|
||||
syncScrollMargin();
|
||||
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "start" });
|
||||
};
|
||||
|
||||
@@ -322,19 +162,21 @@ export function createSectionView(
|
||||
};
|
||||
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
|
||||
|
||||
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
||||
let stationSelectListener: ((stationId: string | null) => void) | null = null;
|
||||
|
||||
/**
|
||||
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
|
||||
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다
|
||||
* (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와
|
||||
* 강조 상태만 갈아 끼운다.
|
||||
* 강조 상태만 갈아 끼운다. 종단도 선택 표시는 카드를 따라간다(계획서 24-0).
|
||||
*/
|
||||
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
||||
let stationSelectListener: ((stationId: string | null) => void) | null = null;
|
||||
|
||||
const applySelection = (previousStationId: string | null): void => {
|
||||
markCard(previousStationId);
|
||||
if (selectedStationId !== previousStationId) markCard(selectedStationId);
|
||||
drawPanel();
|
||||
profile.showCard(
|
||||
currentDetail?.cross_sections.find((entry) => entry.station_id === selectedStationId) ?? null,
|
||||
);
|
||||
if (selectedStationId !== previousStationId) stationSelectListener?.(selectedStationId);
|
||||
};
|
||||
|
||||
@@ -435,80 +277,34 @@ export function createSectionView(
|
||||
);
|
||||
};
|
||||
|
||||
/** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */
|
||||
/** 최종 누가토량 배지 — 종단 그래프 좌측 상단(2026-09-06 사용자 지시). */
|
||||
const massBadge = createMassHaulBadge();
|
||||
panelBody.append(massBadge.root);
|
||||
/** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */
|
||||
let updateChartWindow: (() => ElevationWindowResult | null) | null = null;
|
||||
|
||||
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
|
||||
function drawPanel(): void {
|
||||
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
|
||||
// 차트를 갈아 끼우면 가로 스크롤이 0으로 돌아간다 — 측점 버튼 하나 눌렀다고
|
||||
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
|
||||
const keepScrollLeft = chartWrap.scrollLeft;
|
||||
const detail = currentDetail;
|
||||
fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침
|
||||
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
|
||||
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
|
||||
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
|
||||
const measured = chartWrap.clientHeight;
|
||||
lastChartAvailable =
|
||||
measured > 0 ? measured : Math.max(panelHeight() - PANEL_CHROME_PX, MIN_LONG_HEIGHT);
|
||||
// 알약 레인도 `chartWrap` 안에 서므로 잰 높이에 **이미 들어 있다**. 그 몫을 빼지 않으면
|
||||
// 다음 회차에 잰 값이 그만큼 더 커지고, 그 값으로 그래프를 키우면 또 커지는 되먹임이
|
||||
// 생겨 패널이 16,000px 까지 부푼다(2026-09-07 실측). 빼 두면 한 번에 수렴한다.
|
||||
const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0;
|
||||
const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT));
|
||||
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
|
||||
updateChartWindow = drawLongitudinalPanel({
|
||||
detail,
|
||||
chartWrap,
|
||||
renderWidth: Math.max(renderWidth, minWidth),
|
||||
chartHeight: heights.long,
|
||||
keepScrollLeft,
|
||||
selectedStationId,
|
||||
exaggeration: currentExaggeration,
|
||||
stationInterval: cachedStationInterval,
|
||||
structures: markStructures,
|
||||
types: markTypes,
|
||||
edit: structureEdit,
|
||||
conversion: currentConversion,
|
||||
naturalSpoilSlope: currentNaturalSpoilSlope,
|
||||
selectStation: (stationId) => selectStation(stationId, true),
|
||||
setMassBadge: (values) => massBadge.set(values),
|
||||
grade: gradeEdit?.() ?? null,
|
||||
holdRange,
|
||||
});
|
||||
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
|
||||
syncScrollMargin();
|
||||
|
||||
// 첫 렌더(detached)나 패널 높이 변경 직후에는 위에서 잰 값이 아직 옛 레이아웃일 수 있다.
|
||||
// 다음 프레임에 한 번만 다시 재서 어긋나면 고쳐 그린다. `chartWrap` 높이는 내용과 무관하니
|
||||
// 되먹임 없이 한 번에 수렴한다.
|
||||
if (!chartFitScheduled) {
|
||||
chartFitScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
chartFitScheduled = false;
|
||||
const now = chartWrap.clientHeight;
|
||||
if (now > 0 && Math.abs(now - lastChartAvailable) > 2) drawPanel();
|
||||
});
|
||||
}
|
||||
/** 카드 면적이 바뀌었다 — 경고 줄 · 유토곡선 · 뱃지만 다시 싣는다. */
|
||||
function redrawProfile(): void {
|
||||
if (!currentDetail) return;
|
||||
fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
|
||||
profile.redraw();
|
||||
}
|
||||
|
||||
attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel);
|
||||
const holdRange = createElevationHold(chartWrap, drawPanel);
|
||||
/** 종단도 · 경고 줄을 지금 자료로 다시 맞춘다(카드는 안 건드린다). */
|
||||
function syncProfile(): void {
|
||||
if (!currentDetail) return;
|
||||
fillSlopeNotice.update(currentDetail.cross_sections, cachedStationInterval);
|
||||
profile.render({
|
||||
detail: currentDetail,
|
||||
stationInterval: cachedStationInterval,
|
||||
...profileInput,
|
||||
});
|
||||
const profileRoot = profile.root();
|
||||
if (profileRoot && profileRoot.parentElement !== root) root.append(profileRoot);
|
||||
}
|
||||
|
||||
const draw = (): void => {
|
||||
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
|
||||
const detail = currentDetail;
|
||||
cachedStationInterval =
|
||||
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
|
||||
drawPanel();
|
||||
|
||||
// 횡단면도 제목·개수 표기는 두지 않는다(2026-08-02 사용자 지시) — 상단 패널과 카드 사이
|
||||
// 공간만 먹고, 카드마다 측점이 적혀 있어 무엇을 보는지는 이미 분명하다.
|
||||
// 횡단면도 제목·개수 표기는 두지 않는다(2026-08-02 사용자 지시) — 카드마다 측점이
|
||||
// 적혀 있어 무엇을 보는지는 이미 분명하다.
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b06-section__grid";
|
||||
// 카드 사이 빈 자리를 누르면 선택 해제 — 카드 안 클릭은 카드가 먼저 처리한다.
|
||||
@@ -558,13 +354,14 @@ export function createSectionView(
|
||||
} else {
|
||||
grid.append(emptyView(L("B06_Profile_View_NoCross")));
|
||||
}
|
||||
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
|
||||
const keepScrollLeft = chartWrap.scrollLeft;
|
||||
root.replaceChildren(panel, fillSlopeNotice.root, grid);
|
||||
chartWrap.scrollLeft = keepScrollLeft;
|
||||
// 위 칸 스크롤 자리를 지킨다 — 다시 그렸다고 보던 카드가 맨 앞으로 튀면 못 쓴다.
|
||||
const keepScrollTop = cards.scrollTop;
|
||||
cards.replaceChildren(fillSlopeNotice.root, grid);
|
||||
cards.scrollTop = keepScrollTop;
|
||||
syncProfile();
|
||||
};
|
||||
|
||||
/** 카드 한 장만 갈아 끼운다(상단 패널은 안 건드린다). 못 찾으면 false. */
|
||||
/** 카드 한 장만 갈아 끼운다(종단도는 안 건드린다). 못 찾으면 false. */
|
||||
const rebuildCard = (chainageM: number): boolean => {
|
||||
if (!currentDetail) return false;
|
||||
const section = currentDetail.cross_sections.find(
|
||||
@@ -579,12 +376,12 @@ export function createSectionView(
|
||||
};
|
||||
|
||||
const refreshCard = (chainageM: number): void => {
|
||||
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음).
|
||||
if (rebuildCard(chainageM)) drawPanel();
|
||||
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 종단도만 다시 그린다(카드 전체 재렌더 없음).
|
||||
if (rebuildCard(chainageM)) redrawProfile();
|
||||
};
|
||||
|
||||
/**
|
||||
* 여러 측점을 한 번에 갈아 끼운다 — **상단 패널은 끝에 한 번만** 다시 그린다.
|
||||
* 여러 측점을 한 번에 갈아 끼운다 — **종단도는 끝에 한 번만** 다시 그린다.
|
||||
*
|
||||
* 측점마다 `refreshCard` 를 부르면 측점 수만큼 종단 그래프·유토곡선을 다시 그려,
|
||||
* 계획선 ▲▼ 한 번에 화면이 몇 백 ms 씩 멈췄다(2026-09-12 B05 대비 느림 원인).
|
||||
@@ -592,7 +389,7 @@ export function createSectionView(
|
||||
const refreshCards = (chainages: ReadonlyArray<number>): void => {
|
||||
let touched = false;
|
||||
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
|
||||
if (touched) drawPanel();
|
||||
if (touched) redrawProfile();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -605,7 +402,7 @@ export function createSectionView(
|
||||
draw();
|
||||
}, 150);
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
resizeObserver.observe(cards);
|
||||
|
||||
return {
|
||||
root,
|
||||
@@ -615,9 +412,7 @@ export function createSectionView(
|
||||
crossHalfWidth,
|
||||
stationInterval,
|
||||
earthworkConversion,
|
||||
// 운반 장비 한계는 곡선(운반 블록)에만 쓰던 값이라 B06 에서는 더 받지 않는다
|
||||
// (2026-09-06 유토곡선 그래프 제거). 인자 자리는 호출부 호환을 위해 남긴다.
|
||||
_haulEquipmentLimits,
|
||||
haulEquipmentLimits,
|
||||
balloonScope,
|
||||
naturalSpoilMinSlope,
|
||||
) {
|
||||
@@ -627,15 +422,21 @@ export function createSectionView(
|
||||
crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined;
|
||||
currentStationInterval =
|
||||
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
|
||||
if (earthworkConversion) currentConversion = earthworkConversion;
|
||||
if (Number.isFinite(naturalSpoilMinSlope)) currentNaturalSpoilSlope = naturalSpoilMinSlope;
|
||||
// balloon 위치는 **영구저장소 값이 이긴다** — 다른 브라우저에서 옮긴 자리를 그대로 받는다.
|
||||
configureBalloonOffsets(balloonScope ?? "default", detail.balloon_offsets ?? undefined);
|
||||
// 유토곡선 · 뱃지 설정과 balloon 자리 scope(프로젝트:노선)는 종단도 한 벌이 받는다 —
|
||||
// 영구저장소 값(detail.balloon_offsets)이 이기는 규칙도 그쪽에 있다.
|
||||
profileInput = {
|
||||
balloonScope: balloonScope ?? "-",
|
||||
conversion: earthworkConversion ?? profileInput.conversion,
|
||||
haulLimits: haulEquipmentLimits ?? profileInput.haulLimits,
|
||||
naturalSpoilMinSlope: Number.isFinite(naturalSpoilMinSlope)
|
||||
? naturalSpoilMinSlope
|
||||
: profileInput.naturalSpoilMinSlope,
|
||||
};
|
||||
// 진입 시 측점을 자동으로 고르지 않는다(2026-08-02 사용자 지시) — 0측점이 선택된 채로
|
||||
// 시작하면 사용자가 고르지도 않은 카드가 강조돼 있고 유토곡선 말풍선도 떠 있다.
|
||||
renderWidth = contentWidth();
|
||||
draw();
|
||||
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
|
||||
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(cards));
|
||||
},
|
||||
refreshCard,
|
||||
refreshCards,
|
||||
@@ -648,15 +449,12 @@ export function createSectionView(
|
||||
},
|
||||
setStructureEdit(edit) {
|
||||
structureEdit = edit;
|
||||
drawPanel();
|
||||
},
|
||||
setGradeEdit(provider) {
|
||||
gradeEdit = provider;
|
||||
drawPanel();
|
||||
},
|
||||
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은
|
||||
// 막기 규칙. 옛 제공자는 받기만 하고 쓰지 않는다(페이지 쪽 정리는 24-2a 몫).
|
||||
setGradeEdit() {},
|
||||
setStructureMarks(structures, types) {
|
||||
markStructures = structures;
|
||||
markTypes = types;
|
||||
profile.setMarks(structures, types);
|
||||
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
||||
// (`__corridorBuild` 등과 같은 용도).
|
||||
(window as unknown as { __b06Marks?: unknown }).__b06Marks = {
|
||||
@@ -664,18 +462,16 @@ export function createSectionView(
|
||||
types: types.length,
|
||||
drawn: !!currentDetail,
|
||||
};
|
||||
if (currentDetail) drawPanel();
|
||||
},
|
||||
clear() {
|
||||
currentDetail = null;
|
||||
selectedStationId = null;
|
||||
root.replaceChildren();
|
||||
cards.replaceChildren();
|
||||
},
|
||||
dispose() {
|
||||
window.clearTimeout(resizeTimer);
|
||||
window.clearTimeout(panelResizeTimer);
|
||||
panelResizer.dispose();
|
||||
resizeObserver.disconnect();
|
||||
profile.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Chart.ts
|
||||
* B06 상단 패널의 **종단면도 몫**만 떼어 낸 조립기 (2026-09-04 · 700줄 제한).
|
||||
*
|
||||
* 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, B05 와 같은 규칙·같은
|
||||
* 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. 스크롤할 때 그래프를 다시
|
||||
* 만들지 않고 세로 창을 **변환으로** 옮기는 것도 B05 와 같다
|
||||
* (`common_util_chart_ywindow`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { createLongitudinalProfile } from "./B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
LONG_PAD,
|
||||
longitudinalMaxChainage,
|
||||
windowElevationRange,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
import { unwrapChart } from "./B06_Section_UI_Section_View_Panel";
|
||||
|
||||
export interface LongitudinalChartInput {
|
||||
detail: SectionDetailResponse;
|
||||
selectedStationId: string | null;
|
||||
/** 세로 과장(사용자 조절값). */
|
||||
exaggeration: number;
|
||||
selectStation: (stationId: string) => void;
|
||||
stationInterval: number;
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
minWidth: number;
|
||||
/** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */
|
||||
scrollLeft: number;
|
||||
viewportWidth: number;
|
||||
/** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */
|
||||
onDragStation?: (stationId: string, toChainageM: number) => void;
|
||||
/** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */
|
||||
bottomInsetPx?: number;
|
||||
/** 세로 창을 가로채는 자리 — 계획고를 ▲▼ 로 만지는 동안 창을 **고정**하는 데 쓴다.
|
||||
* 안 넘기면 늘 보이는 구간에 맞춘다(종전 동작). */
|
||||
holdRange?: (
|
||||
next: { min: number; max: number } | null,
|
||||
) => { min: number; max: number } | undefined;
|
||||
}
|
||||
|
||||
export interface LongitudinalChartResult {
|
||||
node: Element;
|
||||
axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null;
|
||||
/** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */
|
||||
toChainage: (px: number) => number;
|
||||
/** 누가거리(m) → 화면 x(px). 구조물 알약 레인이 그래프와 같은 자리를 쓰게 한다. */
|
||||
toX: (chainageM: number) => number;
|
||||
maxChainageM: number;
|
||||
viewFromM: number;
|
||||
viewToM: number;
|
||||
}
|
||||
|
||||
/** 지금 보이는 구간(누가거리 m)을 잰다 — 그릴 때와 스크롤 갱신 때가 **같은 식**을 쓴다. */
|
||||
export function visibleChainageRange(
|
||||
toChainage: (px: number) => number,
|
||||
maxChainageM: number,
|
||||
scrollLeft: number,
|
||||
viewportWidth: number,
|
||||
): { fromM: number; toM: number } {
|
||||
return {
|
||||
fromM: Math.max(0, toChainage(scrollLeft)),
|
||||
toM: Math.min(maxChainageM, toChainage(scrollLeft + viewportWidth)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 보이는 구간의 표고 범위 — 종단 세로 창의 기준. */
|
||||
export function visibleElevationRange(
|
||||
detail: SectionDetailResponse,
|
||||
fromM: number,
|
||||
toM: number,
|
||||
): { min: number; max: number } | null {
|
||||
return (
|
||||
windowElevationRange(
|
||||
[
|
||||
detail.longitudinal.samples,
|
||||
...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples),
|
||||
],
|
||||
fromM,
|
||||
toM,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function buildLongitudinalChart(input: LongitudinalChartInput): LongitudinalChartResult {
|
||||
const { detail } = input;
|
||||
const maxChainageM = longitudinalMaxChainage(detail.longitudinal);
|
||||
const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right);
|
||||
const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM;
|
||||
const toX = (chainageM: number): number =>
|
||||
LONG_PAD.left + (maxChainageM > 0 ? (chainageM / maxChainageM) * plotWidth : 0);
|
||||
const { fromM, toM } = visibleChainageRange(
|
||||
toChainage,
|
||||
maxChainageM,
|
||||
input.scrollLeft,
|
||||
input.viewportWidth,
|
||||
);
|
||||
|
||||
// Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자
|
||||
// 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식).
|
||||
let axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
const node = unwrapChart(
|
||||
createLongitudinalProfile(
|
||||
detail.longitudinal,
|
||||
input.selectedStationId,
|
||||
input.exaggeration,
|
||||
// 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다.
|
||||
undefined,
|
||||
input.selectStation,
|
||||
input.stationInterval,
|
||||
input.chartWidth,
|
||||
input.chartHeight,
|
||||
input.minWidth,
|
||||
detail.longitudinal.design_profiles ?? [],
|
||||
0,
|
||||
(next) => {
|
||||
axis = next;
|
||||
},
|
||||
undefined,
|
||||
// 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화).
|
||||
input.onDragStation,
|
||||
input.bottomInsetPx ?? 0,
|
||||
0,
|
||||
input.holdRange
|
||||
? input.holdRange(visibleElevationRange(detail, fromM, toM))
|
||||
: (visibleElevationRange(detail, fromM, toM) ?? undefined),
|
||||
),
|
||||
);
|
||||
return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM };
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Draw.ts
|
||||
* B06 상단 패널의 **종단 그래프 한 벌**을 세운다 — 그래프·구조물 알약 레인·우클릭 메뉴·
|
||||
* 누가토량 배지·고정 Y축, 그리고 가로 스크롤에 따라붙는 세로 창 갱신기까지.
|
||||
*
|
||||
* 왜 갈랐나 — 종단 화면 본체(`_Section_View`)가 700줄을 넘겨(CLAUDE.md 4장) 기능별로
|
||||
* 나눠야 했다. 이 파일은 **그리는 몫만** 맡고, 높이 재기·되먹임 보정·선택 상태는 본체가
|
||||
* 그대로 쥔다. 넘겨받는 값은 전부 인자라 여기에는 화면 상태가 남지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import { badgeValuesFrom } from "@util/common_util_mass_haul_badge";
|
||||
import {
|
||||
applyElevationWindow,
|
||||
needsFullRedraw,
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
buildLongitudinalChart,
|
||||
visibleChainageRange,
|
||||
visibleElevationRange,
|
||||
} from "./B06_Section_UI_Section_View_Chart";
|
||||
import {
|
||||
mountSectionStructureMenu,
|
||||
moveMarkById,
|
||||
type SectionStructureEdit,
|
||||
} from "./B06_Section_UI_Section_View_Menu";
|
||||
import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */
|
||||
export interface LongitudinalPanelInput {
|
||||
detail: SectionDetailResponse;
|
||||
/** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */
|
||||
chartWrap: HTMLElement;
|
||||
/** 본체가 잰 그릴 폭·높이. */
|
||||
renderWidth: number;
|
||||
chartHeight: number;
|
||||
/** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */
|
||||
keepScrollLeft: number;
|
||||
selectedStationId: string | null;
|
||||
exaggeration: number;
|
||||
stationInterval: number;
|
||||
structures: ReadonlyArray<StructureInstance>;
|
||||
types: ReadonlyArray<StructureType>;
|
||||
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */
|
||||
edit: SectionStructureEdit | null;
|
||||
conversion?: EarthworkConversion;
|
||||
naturalSpoilSlope?: number;
|
||||
selectStation: (stationId: string) => void;
|
||||
/** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */
|
||||
holdRange?: (
|
||||
next: { min: number; max: number } | null,
|
||||
) => { min: number; max: number } | undefined;
|
||||
/** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품).
|
||||
* 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */
|
||||
grade?: {
|
||||
alignment: ProfileAlignment;
|
||||
/** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */
|
||||
stepM: number;
|
||||
onStation: (chainageM: number, delta: number) => void;
|
||||
} | null;
|
||||
/** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */
|
||||
setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void;
|
||||
}
|
||||
|
||||
/** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */
|
||||
export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
|
||||
/**
|
||||
* 종단 그래프 한 벌을 세우고 **세로 창 갱신기**를 돌려준다.
|
||||
* 갱신기는 가로 스크롤마다 불러 보이는 구간에 세로를 맞추는 데 쓴다.
|
||||
*/
|
||||
export function drawLongitudinalPanel(
|
||||
input: LongitudinalPanelInput,
|
||||
): () => ElevationWindowResult | null {
|
||||
const { detail, chartWrap, structures, types, edit } = input;
|
||||
const chartWidth = input.renderWidth;
|
||||
|
||||
/** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */
|
||||
const moveMark = (markId: string, toChainageM: number): void => {
|
||||
if (!edit) return;
|
||||
moveMarkById(markId, toChainageM, {
|
||||
structures,
|
||||
stations: detail.longitudinal.stations ?? [],
|
||||
edit,
|
||||
});
|
||||
};
|
||||
|
||||
const chart = buildLongitudinalChart({
|
||||
detail,
|
||||
selectedStationId: input.selectedStationId,
|
||||
exaggeration: input.exaggeration,
|
||||
selectStation: input.selectStation,
|
||||
stationInterval: input.stationInterval,
|
||||
chartWidth,
|
||||
chartHeight: input.chartHeight,
|
||||
minWidth: chartWidth,
|
||||
scrollLeft: input.keepScrollLeft,
|
||||
viewportWidth: chartWrap.clientWidth || chartWidth,
|
||||
// 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다
|
||||
// (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다.
|
||||
bottomInsetPx: input.grade ? 15 : 0,
|
||||
holdRange: input.holdRange,
|
||||
// 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동.
|
||||
onDragStation: edit ? moveMark : undefined,
|
||||
});
|
||||
|
||||
const nodes: Element[] = [chart.node];
|
||||
// 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은
|
||||
// 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
|
||||
if (structures.length && types.length) {
|
||||
nodes.push(
|
||||
buildStructureLane({
|
||||
structures,
|
||||
types,
|
||||
x: chart.toX,
|
||||
chainageAt: chart.toChainage,
|
||||
maxChainageM: chart.maxChainageM,
|
||||
widthPx: chartWidth,
|
||||
axisWidthPx: LONG_PAD.left,
|
||||
stationIntervalM: input.stationInterval,
|
||||
selectedId: null,
|
||||
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
||||
onSelect: (structureId) => {
|
||||
if (!structureId) return;
|
||||
const hit = structures.find((entry) => entry.structure_id === structureId);
|
||||
if (!hit) return;
|
||||
const at = structureAnchorM(hit);
|
||||
let best: { id: string; gap: number } | null = null;
|
||||
for (const section of detail.cross_sections) {
|
||||
const gap = Math.abs(section.chainage_m - at);
|
||||
if (!best || gap < best.gap) best = { id: section.station_id, gap };
|
||||
}
|
||||
if (best) input.selectStation(best.id);
|
||||
},
|
||||
// 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서.
|
||||
onMove: moveMark,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
// 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자:
|
||||
// B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에
|
||||
// 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다.
|
||||
if (input.grade) {
|
||||
const editLayer = createEditOverlay({
|
||||
alignment: input.grade.alignment,
|
||||
width: chartWidth,
|
||||
x: chart.toX,
|
||||
step: input.grade.stepM,
|
||||
onStation: input.grade.onStation,
|
||||
});
|
||||
// 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은
|
||||
// **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측:
|
||||
// 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다.
|
||||
editLayer.style.height = `${input.chartHeight}px`;
|
||||
editLayer.style.bottom = "auto";
|
||||
chartWrap.append(editLayer);
|
||||
}
|
||||
// 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군
|
||||
// → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
|
||||
if (edit) {
|
||||
mountSectionStructureMenu(chartWrap, {
|
||||
structures,
|
||||
types,
|
||||
x: chart.toX,
|
||||
chainageAt: chart.toChainage,
|
||||
maxChainageM: chart.maxChainageM,
|
||||
edit,
|
||||
});
|
||||
}
|
||||
|
||||
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
|
||||
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
|
||||
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
|
||||
if (input.conversion) {
|
||||
const series = computeMassHaulSeries(
|
||||
detail.longitudinal,
|
||||
detail.cross_sections,
|
||||
input.conversion,
|
||||
input.naturalSpoilSlope,
|
||||
);
|
||||
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
|
||||
input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null);
|
||||
} else {
|
||||
input.setMassBadge(null);
|
||||
}
|
||||
|
||||
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
|
||||
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
|
||||
if (chart.axis) {
|
||||
const overlay = buildStickyYAxis(chart.axis, input.chartHeight);
|
||||
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
|
||||
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
|
||||
chartWrap.prepend(overlay);
|
||||
}
|
||||
chartWrap.scrollLeft = input.keepScrollLeft;
|
||||
|
||||
// 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다).
|
||||
return () => {
|
||||
const { fromM, toM } = visibleChainageRange(
|
||||
chart.toChainage,
|
||||
chart.maxChainageM,
|
||||
chartWrap.scrollLeft,
|
||||
chartWrap.clientWidth || chartWidth,
|
||||
);
|
||||
const next = visibleElevationRange(detail, fromM, toM);
|
||||
return applyElevationWindow(
|
||||
chartWrap,
|
||||
input.holdRange ? input.holdRange(next) : (next ?? undefined),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
|
||||
/**
|
||||
* 가로 스크롤에 세로 창을 따라붙인다 — **한 번만** 부른다(리스너를 남긴다).
|
||||
*
|
||||
* 세로 맞춤은 그리기가 아니라 **변환**이다(2026-09-04 사용자 확정, B05 와 같은 규칙).
|
||||
* 변환으로 못 살리는 자리에서만 스크롤이 멎은 뒤 한 번 다시 그린다.
|
||||
*/
|
||||
export function attachWindowScroll(
|
||||
chartWrap: HTMLElement,
|
||||
updater: () => (() => ElevationWindowResult | null) | null,
|
||||
redraw: () => void,
|
||||
): void {
|
||||
let settledScrollLeft = 0;
|
||||
let timer = 0;
|
||||
chartWrap.addEventListener("scroll", () => {
|
||||
const fitted = updater()?.() ?? null;
|
||||
window.clearTimeout(timer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
timer = window.setTimeout(() => {
|
||||
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = chartWrap.scrollLeft;
|
||||
redraw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다(B05 와 같은 장치).
|
||||
*
|
||||
* 안 그러면 창이 새 계획선에 맞춰 다시 잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼
|
||||
* 보인다(2026-09-12 사용자 보고: 「버튼을 눌러도 계획 종단선이 안 바뀐다」). 손을 뗀 뒤
|
||||
* 한 번만 다시 맞춘다.
|
||||
*/
|
||||
export function createElevationHold(
|
||||
chartWrap: HTMLElement,
|
||||
redraw: () => void,
|
||||
): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined {
|
||||
let editing = false;
|
||||
let held: { min: number; max: number } | undefined;
|
||||
chartWrap.addEventListener("pointerdown", (event) => {
|
||||
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
|
||||
editing = true;
|
||||
const release = (): void => {
|
||||
editing = false;
|
||||
window.removeEventListener("pointerup", release);
|
||||
window.removeEventListener("pointercancel", release);
|
||||
redraw();
|
||||
};
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
});
|
||||
return (next) => {
|
||||
if (editing) return held;
|
||||
held = next ?? undefined;
|
||||
return held;
|
||||
};
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { MASS_HAUL_DEFAULT_VISIBLE, normalizeVisibleBasis } from "@util/common_util_mass_haul";
|
||||
import { readStateRaw, stateKey } from "../A00_Common/b_page_state";
|
||||
import { LONG_HEIGHT } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
export const PANEL_CHROME_PX = 102;
|
||||
/* 유토곡선 그래프를 B06 에서 뺐다(2026-09-06 사용자 지시) — 패널 높이도 종단면도 몫만
|
||||
잡는다. 곡선은 B05 유토곡선 패널에서 보고, 여기서는 좌측 상단 배지가 총괄값을 보인다. */
|
||||
export const BASE_PANEL_HEIGHT = LONG_HEIGHT + PANEL_CHROME_PX;
|
||||
export const MIN_LONG_HEIGHT = 110;
|
||||
export const MIN_PANEL_HEIGHT = MIN_LONG_HEIGHT + PANEL_CHROME_PX;
|
||||
export const MAX_PANEL_HEIGHT_RATIO = 0.8;
|
||||
/* 패널 접힘·높이·유토곡선 범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다.
|
||||
범례 키는 B05 와 **같은 값**이어야 한다(두 화면이 같은 그림의 두 창). */
|
||||
export const PANEL_HEIGHT_KEY = stateKey("section-panel-height") ?? "";
|
||||
|
||||
/** 처음 열 때는 **곡선만** — 토량 분배는 범례에서 켠다(2026-09-06 사용자 지시).
|
||||
* B05 `_UI_Profile_MassHaul` 과 같은 값이어야 한다(두 화면 공용 키). */
|
||||
const DEFAULT_VISIBLE_KEYS = [...MASS_HAUL_DEFAULT_VISIBLE];
|
||||
|
||||
export function readVisibleSeries(): Set<string> {
|
||||
try {
|
||||
const raw = readStateRaw("masshaul-visible");
|
||||
if (!raw) return new Set(DEFAULT_VISIBLE_KEYS);
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? normalizeVisibleBasis(new Set(parsed.map(String)))
|
||||
: new Set(DEFAULT_VISIBLE_KEYS);
|
||||
} catch {
|
||||
return new Set(DEFAULT_VISIBLE_KEYS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 종단면도 높이 — 유토곡선을 빼면서 남는 자리를 **전부** 종단이 쓴다(2026-09-06). */
|
||||
export function chartHeights(availableHeightPx: number): { long: number; mass: number } {
|
||||
if (!Number.isFinite(availableHeightPx) || availableHeightPx <= 0) {
|
||||
return { long: LONG_HEIGHT, mass: 0 };
|
||||
}
|
||||
return { long: Math.max(MIN_LONG_HEIGHT, availableHeightPx), mass: 0 };
|
||||
}
|
||||
|
||||
export function unwrapChart(node: HTMLElement): Element {
|
||||
return node.classList.contains("b06-section__chart-wrap") && node.firstElementChild
|
||||
? node.firstElementChild
|
||||
: node;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Profile.ts
|
||||
* 횡단 보기의 종단도 — 종단 보기와 **같은 종단도 한 벌**(`design_profile`)을 가운데
|
||||
* 아래쪽에 두고, 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c).
|
||||
*
|
||||
* 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다.
|
||||
* 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙
|
||||
* (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createDesignProfile, type DesignProfile } from "../A00_Common/design/design_profile";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { drainageSide, drainageSideTool } from "../B05_Profile/B05_Profile_UI_Profile_SideTool";
|
||||
import type {
|
||||
CrossSection,
|
||||
EarthworkConversion,
|
||||
HaulEquipmentLimit,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import {
|
||||
irregularStationId,
|
||||
isPipeStation,
|
||||
type IrregularStation,
|
||||
} from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
isPipeMark,
|
||||
moveMarkById,
|
||||
type SectionStructureEdit,
|
||||
} from "./B06_Section_UI_Section_View_Menu";
|
||||
|
||||
/** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */
|
||||
const MATCH_M = 0.51;
|
||||
/** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */
|
||||
const LEGACY_TYPE_IDS: Record<string, string> = {
|
||||
기성막이: "revetment",
|
||||
대피로: "refuge",
|
||||
기타: "etc",
|
||||
};
|
||||
|
||||
export interface SectionProfileHost {
|
||||
/** 종단도에서 측점을 골랐다 — 그 카드를 고르고 보이게 한다(null = 해제). */
|
||||
selectCard: (stationId: string | null) => void;
|
||||
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭 · 끌기가 아무것도 안 함). */
|
||||
edit: () => SectionStructureEdit | null;
|
||||
/** 계획고 편집으로 다시 계산한 측점 카드만 갈아 끼운다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
}
|
||||
|
||||
export interface SectionProfile {
|
||||
root: () => HTMLElement | null;
|
||||
render: (input: {
|
||||
detail: SectionDetailResponse;
|
||||
balloonScope: string;
|
||||
stationInterval: number;
|
||||
conversion?: EarthworkConversion;
|
||||
haulLimits?: HaulEquipmentLimit[];
|
||||
naturalSpoilMinSlope?: number;
|
||||
}) => void;
|
||||
setMarks: (
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
) => void;
|
||||
/** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */
|
||||
redraw: () => void;
|
||||
/** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */
|
||||
showCard: (section: CrossSection | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */
|
||||
function pipeStations(
|
||||
marks: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
intervalM: number,
|
||||
): IrregularStation[] {
|
||||
return marks
|
||||
.filter((mark) => isPipeMark(mark) && mark.type_id !== "ford_pavement")
|
||||
.map((mark) => {
|
||||
const chainage = structureAnchorM(mark);
|
||||
const station = Math.floor(chainage / intervalM);
|
||||
return {
|
||||
id: String(mark.structure_id),
|
||||
station,
|
||||
remainder: chainage - station * intervalM,
|
||||
chainage_m: chainage,
|
||||
structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관",
|
||||
structureType: "배관",
|
||||
origin: "pipe",
|
||||
} as IrregularStation;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고,
|
||||
* 없으면 한 번 읽는다(읽기만 · 계산 없음).
|
||||
*/
|
||||
function loadRoute(projectId: string, profile: DesignProfile): void {
|
||||
// 최소 흙덮기 강제 여부 — 종단 보기를 거치지 않고 바로 들어왔으면 저장된 노선 설정값.
|
||||
const enforceKnown = readState<boolean>("profile-enforce-min-cover", projectId) !== null;
|
||||
const apply = (latest: RouteLatestResponse): void => {
|
||||
profile.setRoutePolyline(latest.route_points ?? []);
|
||||
if (!enforceKnown) {
|
||||
profile.setEnforceMinCover(latest.route_params?.enforce_pipe_clearance === true);
|
||||
}
|
||||
};
|
||||
const cached = readState<RouteLatestResponse>("latest", projectId);
|
||||
if (cached?.route_points?.length) {
|
||||
apply(cached);
|
||||
return;
|
||||
}
|
||||
void fetchLatestRoute(projectId)
|
||||
.then(apply)
|
||||
.catch(() => {
|
||||
/* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */
|
||||
});
|
||||
}
|
||||
|
||||
export function createSectionProfile(host: SectionProfileHost): SectionProfile {
|
||||
let profile: DesignProfile | null = null;
|
||||
let projectId: string | null = null;
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let marks: ReadonlyArray<StructureInstance> = [];
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
let stations: IrregularStation[] = [];
|
||||
let intervalM = 20;
|
||||
|
||||
/** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */
|
||||
const nearestCard = (chainageM: number): CrossSection | null => {
|
||||
let best: CrossSection | null = null;
|
||||
for (const section of detail?.cross_sections ?? []) {
|
||||
if (!best || Math.abs(section.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM))
|
||||
best = section;
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
/** 종단도 측점 id → 카드 id. 관 측점(`irregular:`)은 누가거리로 카드를 찾는다. */
|
||||
const cardOf = (stationId: string | null): string | null => {
|
||||
if (stationId === null) return null;
|
||||
const pipe = stations.find((entry) => irregularStationId(entry.id) === stationId);
|
||||
if (pipe) return nearestCard(pipe.chainage_m)?.station_id ?? null;
|
||||
return stationId;
|
||||
};
|
||||
|
||||
const syncMarks = (): void => {
|
||||
if (!profile) return;
|
||||
profile.setStructureTypes([...types]);
|
||||
profile.setStructures([...marks]);
|
||||
stations = pipeStations(marks, types, intervalM);
|
||||
profile.setIrregularStations(stations);
|
||||
// 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다.
|
||||
profile.setMinCoverPipes(
|
||||
marks.filter(isPipeMark).map((mark) => ({
|
||||
chainage_m: structureAnchorM(mark),
|
||||
facility: mark.type_id as never,
|
||||
options: mark.options,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
const build = (id: string): DesignProfile =>
|
||||
createDesignProfile({
|
||||
projectId: id,
|
||||
view: "cross",
|
||||
// 오른쪽 판(배수유역도) — 종단 보기와 같은 판 · 같은 켬끔 값(계획서 24-2e).
|
||||
side: drainageSide,
|
||||
sideTool: drainageSideTool,
|
||||
onSelectStation: (stationId) => host.selectCard(cardOf(stationId)),
|
||||
callbacks: {
|
||||
onCrossDesignsUpdated: (updated) => host.refreshCards(updated),
|
||||
onStructureMove: (from, to, station) => {
|
||||
if (isPipeStation(station)) host.edit()?.movePipe(from, to);
|
||||
},
|
||||
onStructureRemove: (station) => {
|
||||
if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m);
|
||||
},
|
||||
onPipeAdd: (chainage) => host.edit()?.addPipe(chainage),
|
||||
onStructureAdd: (chainage, type) =>
|
||||
host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
|
||||
onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId),
|
||||
onIrregularSelect: (station) =>
|
||||
host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null),
|
||||
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
||||
onStructureSelect: (structureId) => {
|
||||
const hit = marks.find((entry) => entry.structure_id === structureId);
|
||||
if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
|
||||
},
|
||||
onStructureMarkMove: (structureId, toChainageM) => {
|
||||
const edit = host.edit();
|
||||
if (!edit) return;
|
||||
moveMarkById(structureId, toChainageM, {
|
||||
structures: marks,
|
||||
stations: detail?.longitudinal.stations ?? [],
|
||||
edit,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
root: () => profile?.root ?? null,
|
||||
render(input) {
|
||||
const [id, route] = input.balloonScope.split(":");
|
||||
if (!id || id === "-") return;
|
||||
if (profile && projectId !== id) {
|
||||
profile.dispose();
|
||||
profile = null;
|
||||
}
|
||||
if (!profile) {
|
||||
projectId = id;
|
||||
profile = build(id);
|
||||
loadRoute(id, profile);
|
||||
syncMarks();
|
||||
}
|
||||
detail = input.detail;
|
||||
intervalM = input.stationInterval;
|
||||
const routeId = Number(route);
|
||||
profile.render(
|
||||
input.detail,
|
||||
input.stationInterval,
|
||||
Number.isFinite(routeId) ? routeId : undefined,
|
||||
);
|
||||
if (input.conversion) {
|
||||
profile.setEarthworkContext({
|
||||
conversion: input.conversion,
|
||||
haulLimits: input.haulLimits,
|
||||
naturalSpoilMinSlope: input.naturalSpoilMinSlope,
|
||||
});
|
||||
}
|
||||
syncMarks();
|
||||
},
|
||||
setMarks(nextMarks, nextTypes) {
|
||||
marks = nextMarks;
|
||||
types = nextTypes;
|
||||
syncMarks();
|
||||
},
|
||||
redraw() {
|
||||
profile?.redraw();
|
||||
},
|
||||
showCard(section) {
|
||||
if (!profile) return;
|
||||
if (!section) {
|
||||
profile.setSelectedStation(null);
|
||||
return;
|
||||
}
|
||||
const pipe = stations.find(
|
||||
(entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M,
|
||||
);
|
||||
profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id);
|
||||
},
|
||||
dispose() {
|
||||
profile?.dispose();
|
||||
profile = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -10,8 +10,15 @@ import type {
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 옛 계획선 편집 제공자가 돌려주던 모양 — 페이지 쪽 정리(24-2a) 전까지 받기만 한다. */
|
||||
export interface LegacyGradeEdit {
|
||||
alignment: ProfileAlignment;
|
||||
stepM: number;
|
||||
onStation: (chainageM: number, delta: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
|
||||
@@ -53,7 +60,7 @@ export interface SectionViewController {
|
||||
) => void;
|
||||
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
|
||||
setStructureEdit: (edit: SectionStructureEdit | null) => void;
|
||||
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
|
||||
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
|
||||
/** 옛 계획선 편집 제공자 자리 — 받기만 한다. ▲/▼ 는 종단도 한 벌이 맡는다(계획서 24-2c). */
|
||||
setGradeEdit: (provider: (() => LegacyGradeEdit | null) | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -4,104 +4,52 @@
|
||||
* 700줄 제한 대응으로 _UI_Style.css에서 분리(패널/폼/모식도는 원본 유지).
|
||||
* ========================================================================== */
|
||||
|
||||
/* --- 종·횡단 도면 --- */
|
||||
/* --- 종·횡단 도면 ---
|
||||
횡단 보기 = 위 칸(횡단 카드 · 그 안에서 스크롤) + 가운데 아래쪽 종단도 한 벌
|
||||
(종단 보기와 같은 자리 · 같은 높이 키, 계획서 24-0 · 24-2c). */
|
||||
.b06-profile__main,
|
||||
.b06-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b06-section {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding-inline: var(--spacing-24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b06-section__cards {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-24);
|
||||
min-height: 0;
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 종단도는 종단 보기에서 3D 위에 떠 있는 판(absolute)이다 — 횡단 보기에서는 카드 칸
|
||||
아래 흐름에 선다. 높이 · 접기 규칙은 종단 보기와 같은 것을 그대로 쓴다. */
|
||||
.b06-section > .b05-route-profile {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 상단 패널이 sticky라 카드로 스크롤하면 그 아래로 숨는다. 패널 높이를 JS가
|
||||
`--b06-cross-scroll-margin`으로 실어 주고, 스크롤 정지 위치를 그만큼 아래로 민다
|
||||
(패널을 키우거나 줄이면 이 여백도 따라 바뀐다). */
|
||||
.b06-cross-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
scroll-margin-top: var(--b06-cross-scroll-margin, 240px);
|
||||
scroll-margin-top: var(--spacing-8);
|
||||
/* 카드를 **자체 쌓임 맥락**으로 만든다. 이게 없으면 카드 안 오버레이(줌 버튼·면적 표)의
|
||||
z-index가 최상위 축에서 겨루게 되고, 상단 패널(sticky, z-index 2)과 같은 층에서 DOM
|
||||
순서로 이겨 **스크롤할 때 패널 위로 삐져나온다**(2026-08-02 사용자 지적). */
|
||||
z-index가 최상위 축에서 겨루게 되어 이웃 판 위로 삐져나온다(2026-08-02 사용자 지적). */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 상단 슬라이드 패널(종단면도 + 유토곡선).
|
||||
높이는 인라인이 아니라 CSS 변수로만 받는다 — 인라인 height는 접기 규칙을 이겨
|
||||
패널이 접히지 않는다(ui_template_resizer.ts 주석). sticky는 그 자체로 위치 지정
|
||||
요소라 리사이저 손잡이(absolute)의 기준이 되므로 position: relative가 따로 필요 없다. */
|
||||
.b06-section__panel {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--b06-profile-height, auto);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
/* `overflow: hidden`을 걸지 않는다 — 접기 손잡이가 패널 경계 **밖 아래**에 떠 있어
|
||||
잘라내면 통째로 사라진다(B05 하단 패널도 같은 이유로 걸지 않는다). 안쪽 스크롤은
|
||||
`.b06-section__panel-body`가 맡는다. */
|
||||
}
|
||||
|
||||
/* 최소 높이는 **펼친 상태에만** 건다. 접힘에도 걸리면(인라인 min-height가 그랬다) 접어도
|
||||
그 높이만큼 빈 배경이 남는다. 값은 JS가 `--b06-profile-min-height`로 실어 준다. */
|
||||
.b06-section__panel:not(.is-collapsed) {
|
||||
min-height: var(--b06-profile-min-height, 360px);
|
||||
}
|
||||
|
||||
/* 접으면 높이를 0으로 접고 알맹이를 숨긴다 — 패널 밖 아래에 뜬 손잡이만 남아 다시 펼 수 있다.
|
||||
(B05 하단 패널과 같은 방식: 전역 `ui-collapsible` 규칙에 기대지 않고 여기서 명시한다.) */
|
||||
.b06-section__panel.is-collapsed {
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b06-section__panel.is-collapsed > header,
|
||||
.b06-section__panel.is-collapsed > .b06-section__panel-body,
|
||||
.b06-section__panel.is-collapsed > .ui-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 공용 리사이저는 손잡이를 패널 위쪽 경계에 두지만(B05 하단 패널 기준),
|
||||
이 패널은 화면 위에 붙어 있으므로 아래쪽 경계로 옮긴다(아래로 끌면 커짐). */
|
||||
.b06-section__panel > .ui-resizer--vertical {
|
||||
top: auto;
|
||||
/* 경계에 그대로 둔다 — 손잡이가 패널 밖으로 나가 z축으로 덮으므로 자리를 비켜 줄 필요가 없다. */
|
||||
bottom: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* 상시 표시선은 히트 영역 가운데가 아니라 **패널 아래 경계**에 정확히 붙인다 —
|
||||
이 리사이저는 히트 영역이 패널 안쪽(bottom 0~8px)에 있어 가운데 정렬이면 표시선이
|
||||
경계에서 4px 위(요약 막대 위)로 넘어간다(2026-08-04 사용자 지적). */
|
||||
.b06-section__panel > .ui-resizer--vertical::before {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* position: relative — 유토곡선 범례가 이 상자를 기준으로 우측 상단에 겹친다. */
|
||||
.b06-section__panel-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-height: 0;
|
||||
padding-bottom: var(--spacing-8);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.b06-section__panel > header,
|
||||
.b06-cross-card > header,
|
||||
.b06-cross-card > footer {
|
||||
display: flex;
|
||||
@@ -114,27 +62,6 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 상태 문구만 담는 행 — 비어 있을 때가 많아 높이를 최소로 둔다. */
|
||||
.b06-section__panel > header {
|
||||
justify-content: center;
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* B05 하단 패널 손잡이와 **같은 구조**로, 상하만 뒤집는다: 공용 `--bottom` 손잡이(64×24)를
|
||||
패널 경계 **바깥**으로 내보내 리사이저를 z축으로 덮는다. 패널 안 흐름에 두면 리사이저와
|
||||
위아래로 나란히 서서, 손잡이를 누르러 가는 길에 리사이저 hover 색이 먼저 켜진다
|
||||
(2026-08-02 사용자 지적). 이 때문에 패널에는 `overflow: hidden`을 걸지 않는다. */
|
||||
.b06-section__panel-toggle {
|
||||
z-index: 3;
|
||||
top: auto;
|
||||
bottom: calc(-1 * var(--spacing-24));
|
||||
border-top: 0;
|
||||
/* 아래 테두리도 좌우와 같은 강조색 — 색이 섞이면 외곽선이 틀어져 보인다(2026-08-04). */
|
||||
border-bottom: 1px solid var(--panel-handle-border, var(--color-border));
|
||||
border-radius: 0 0 var(--radius-buttons) var(--radius-buttons);
|
||||
}
|
||||
|
||||
/* 종단면도와 유토곡선이 이 컨테이너 하나를 공유한다 — 가로 스크롤을 함께 써야
|
||||
같은 측점이 두 그래프에서 같은 자리에 선다. wrap을 나누면 정렬이 깨진다. */
|
||||
.b06-section__chart-wrap {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* 배수유역 관 보고(`B05_Profile_UI_Drainage_Report.ts`) 셈 단위검증 헬퍼 — TS 를 트랜스파일해 Node 로 돈다. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Report.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const { buildPipeReports } = box.exports;
|
||||
|
||||
const store = {
|
||||
get: (chainage) =>
|
||||
chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null,
|
||||
};
|
||||
const basins = [
|
||||
{ chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false },
|
||||
{ chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true },
|
||||
];
|
||||
const out = buildPipeReports(
|
||||
[
|
||||
{ chainage_m: 40.004, reason: "auto" },
|
||||
{ chainage_m: 100.499 },
|
||||
{ chainage_m: 200 },
|
||||
],
|
||||
basins,
|
||||
store,
|
||||
);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 종단도 한 벌(계획서 24-2c) 단위검증 헬퍼 — 계획고 초안이 등록부 칸(`alignment-draft`)으로
|
||||
* 옮겨졌는지 · 옛 키 값을 옮겨 받는지 · 되돌리기 사진이 새 칸을 찍는지를 Node 로 돌린다.
|
||||
* `A00_Common/design/design_store.ts` · `B05_Profile_UI_Profile_History.ts` 를 그 자리에서
|
||||
* 트랜스파일한다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
keys: () => [...map.keys()],
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
const local = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: local };
|
||||
globalThis.sessionStorage = session;
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
|
||||
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_History.ts"));
|
||||
|
||||
const out = {};
|
||||
// ① 옛 키에 남은 초안 — 처음 읽을 때 새 칸으로 옮겨 받고 옛 키는 지운다.
|
||||
local.setItem("frd_current_project_id", "p-1");
|
||||
session.setItem(
|
||||
"b05-profile-alignment-draft:7",
|
||||
JSON.stringify({ station_offsets: { "20.000": 0.3 }, curve_radii: {} }),
|
||||
);
|
||||
out.adopted = useDesignStore(null, 7).get("alignmentDraft");
|
||||
out.keysAfterAdopt = session.keys();
|
||||
|
||||
// ② 쓰기는 새 칸으로 — 프로젝트를 준 가게와 안 준 가게가 같은 칸.
|
||||
useDesignStore("p-1", 7).set("alignmentDraft", {
|
||||
station_offsets: { "40.000": 0.1 },
|
||||
curve_radii: {},
|
||||
});
|
||||
out.sameSlot = useDesignStore(null, 7).get("alignmentDraft");
|
||||
|
||||
// ③ 되돌리기가 새 칸을 사진 찍는다 — 편집 한 걸음을 쌓고 되돌리면 앞 값이 돌아온다.
|
||||
const undoStack = history.createProfileHistory(() => undefined);
|
||||
useDesignStore("p-1", 7).set("alignmentDraft", {
|
||||
station_offsets: { "40.000": 0.2 },
|
||||
curve_radii: {},
|
||||
});
|
||||
undoStack.record();
|
||||
out.beforeUndo = useDesignStore("p-1", 7).get("alignmentDraft");
|
||||
out.undone = undoStack.undo();
|
||||
out.afterUndo = useDesignStore("p-1", 7).get("alignmentDraft");
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,180 @@
|
||||
/* 설계 가게 · 저장 한 줄기(계획서 24-2a) 단위검증 헬퍼 —
|
||||
* `A00_Common/design/design_store.ts` · `design_save.ts` · `B05_Profile_UI_Profile_Edit.ts` 를
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌린다. 서버 창구는 가짜로 두고 부른 차례만 적는다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { sessionStorage: session, localStorage: memoryStorage() };
|
||||
globalThis.sessionStorage = session;
|
||||
|
||||
const calls = [];
|
||||
const record = (name, fail) => async () => {
|
||||
calls.push(name);
|
||||
if (fail) throw new Error(`${name} 실패`);
|
||||
};
|
||||
let failStructures = false;
|
||||
const STUBS = {
|
||||
"@ui/ui_template_elements": {
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}`),
|
||||
showLoadingOverlay: () => calls.push("overlay:on"),
|
||||
hideLoadingOverlay: () => calls.push("overlay:off"),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Pipes_Draft": {
|
||||
flushPendingPipes: () => record("pipes")(),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": {
|
||||
flushPendingStructures: () => record("structures", failStructures)(),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Fetch": {
|
||||
flushUphillOverrides: () => record("uphill")(),
|
||||
saveProfileAlignment: async (_p, routeId, edits) =>
|
||||
calls.push(`alignment:${routeId}:${JSON.stringify(edits.station_offsets)}`),
|
||||
},
|
||||
"../../B06_Section/B06_Section_Api_Culvert_Options": {
|
||||
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { useDesignStore } = loadTs(path.join(ROOT, "A00_Common", "design", "design_store.ts"));
|
||||
const edit = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Edit.ts"));
|
||||
const { saveDesign, DESIGN_SAVE_ORDER } = loadTs(
|
||||
path.join(ROOT, "A00_Common", "design", "design_save.ts"),
|
||||
);
|
||||
|
||||
async function run() {
|
||||
const out = {};
|
||||
|
||||
// ① 가게 — 등록 키 밑 · 같은 칸이면 두 가게가 같은 값 · 듣는 쪽
|
||||
const a = useDesignStore("p1", 7);
|
||||
const b = useDesignStore("p1", 99); // 프로젝트 칸은 노선과 무관
|
||||
const heard = [];
|
||||
const off = b.subscribe("pipes", (value) => heard.push(value.length));
|
||||
a.set("pipes", [{ chainage_m: 10, source: "user" }]);
|
||||
out.pipesShared = b.get("pipes");
|
||||
out.pipesKey = [...Array(session.length).keys()].map((i) => session.key(i));
|
||||
off();
|
||||
a.set("pipes", [{ chainage_m: 10 }, { chainage_m: 20 }]);
|
||||
out.heard = heard;
|
||||
out.otherProject = useDesignStore("p2", 7).get("pipes");
|
||||
out.noRoute = useDesignStore("p1", null).get("alignmentDraft");
|
||||
a.set("selection", { chainageM: 12.5, structureId: "s-1" });
|
||||
out.selection = b.get("selection");
|
||||
a.set("pipes", null);
|
||||
a.set("selection", null);
|
||||
|
||||
// ② 계획고 가게 한 벌 — B05 패널 · B06 계획선 편집이 같은 벌
|
||||
const saved = { station_offsets: {}, curve_radii: { "40.000": 30 } };
|
||||
const b05 = edit.createProfileEditStore(7, saved, () => undefined);
|
||||
const b06 = edit.useProfileEditStore(7, saved, () => undefined);
|
||||
b06.replace({ station_offsets: { "20.000": 0.5 }, curve_radii: saved.curve_radii });
|
||||
out.b05SeesB06 = b05.edits().station_offsets;
|
||||
out.b05Dirty = b05.dirty();
|
||||
// B05 가 자기 손에 쥔 값으로 이어 고쳐도 B06 편집이 안 사라짐(옛 두 가게 흠)
|
||||
b05.replace({
|
||||
station_offsets: { ...b05.edits().station_offsets, "60.000": -0.2 },
|
||||
curve_radii: b05.edits().curve_radii,
|
||||
});
|
||||
out.afterBoth = b06.edits().station_offsets;
|
||||
out.draft = useDesignStore(null, 7).get("alignmentDraft").station_offsets;
|
||||
out.pending = edit.pendingAlignment(7).station_offsets;
|
||||
// 되돌리기가 초안을 바꾼 뒤 B05 가 다시 세움 — B06 쪽 손잡이도 같은 벌을 봄
|
||||
useDesignStore(null, 7).set("alignmentDraft", {
|
||||
station_offsets: { "20.000": 0.1 },
|
||||
curve_radii: {},
|
||||
});
|
||||
edit.createProfileEditStore(7, saved, () => undefined);
|
||||
out.rebuiltSeenByB06 = b06.edits().station_offsets;
|
||||
|
||||
// ③ 저장 한 줄기 — 차례 · 기본 창구 · 보기 몫 · 계획고 저장됨 표시
|
||||
calls.length = 0;
|
||||
const ok = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
prepare: record("prepare"),
|
||||
steps: { pipes: record("pipes:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
done: async (confirm) => calls.push(`done:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.saveOk = ok;
|
||||
out.saveCalls = [...calls];
|
||||
out.afterSave = { dirty: b05.dirty(), draft: useDesignStore(null, 7).get("alignmentDraft") };
|
||||
out.order = DESIGN_SAVE_ORDER;
|
||||
|
||||
// ④ 구조물이 실패해도 다음 차례로(알림) · 확정 · 노선 없으면 안 나감
|
||||
calls.length = 0;
|
||||
failStructures = true;
|
||||
await saveDesign(
|
||||
{
|
||||
projectId: "p1",
|
||||
routeId: () => 7,
|
||||
steps: { uphill: record("uphill:screen") },
|
||||
cross: async (confirm) => calls.push(`cross:${confirm}`),
|
||||
failed: () => calls.push("failed"),
|
||||
},
|
||||
{ confirm: true },
|
||||
);
|
||||
failStructures = false;
|
||||
out.tolerant = [...calls];
|
||||
calls.length = 0;
|
||||
out.noRouteSave = await saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => null,
|
||||
cross: async () => calls.push("cross"),
|
||||
failed: () => calls.push("failed"),
|
||||
});
|
||||
out.noRouteCalls = [...calls];
|
||||
|
||||
// ⑤ [초기화] — 초안 · 한 벌 모두 버림
|
||||
b05.replace({ station_offsets: { "80.000": 1 }, curve_radii: {} });
|
||||
edit.clearAlignmentDrafts();
|
||||
out.cleared = {
|
||||
draft: useDesignStore(null, 7).get("alignmentDraft"),
|
||||
pending: edit.pendingAlignment(7),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
run().then(
|
||||
(out) => console.log(JSON.stringify(out)),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,240 @@
|
||||
/* 두 보기 좌측 패널 **같음 지킴** 헬퍼(계획서 24-2f) — 공용 등록부(`design_panel` · `design_dock` ·
|
||||
* `design_sections`)와 종단 보기 칸 정의를 트랜스파일해 가짜 DOM 위에 두 보기를 같은 견본으로 세운다.
|
||||
* 결과는 표준출력에 JSON 한 벌.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
// ── 화면 그릇 흉내 — 요소 · 클래스 · 속성 · 글자 · 자식 ──────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.attrs = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.title = "";
|
||||
this.listeners = {};
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((x) => self._classes.add(x)),
|
||||
remove: (...n) => n.forEach((x) => self._classes.delete(x)),
|
||||
toggle: (n, f) => {
|
||||
const on = f === undefined ? !self._classes.has(n) : !!f;
|
||||
on ? self._classes.add(n) : self._classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
contains: (n) => self._classes.has(n),
|
||||
};
|
||||
}
|
||||
set className(v) {
|
||||
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
this.children.forEach((c) => (c.parent = null));
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
_adopt(kid) {
|
||||
const node =
|
||||
typeof kid === "string" ? Object.assign(new FakeNode("#text"), { _text: kid }) : kid;
|
||||
if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node);
|
||||
node.parent = this;
|
||||
return node;
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(this._adopt(k)));
|
||||
}
|
||||
prepend(...kids) {
|
||||
this.children.unshift(...kids.map((k) => this._adopt(k)));
|
||||
}
|
||||
insertBefore(node, ref) {
|
||||
const adopted = this._adopt(node);
|
||||
const at = this.children.indexOf(ref);
|
||||
if (at < 0) this.children.push(adopted);
|
||||
else this.children.splice(at, 0, adopted);
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = String(v);
|
||||
}
|
||||
removeAttribute(k) {
|
||||
delete this.attrs[k];
|
||||
if (k === "title") this.title = "";
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
(this.listeners[type] ||= []).push(handler);
|
||||
}
|
||||
click() {
|
||||
if (this.disabled) return;
|
||||
(this.listeners.click || []).forEach((h) => h({ target: this }));
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const cls = selector.slice(1);
|
||||
const out = [];
|
||||
const walk = (n) =>
|
||||
n.children.forEach((k) => {
|
||||
if (k.classList && k.classList.contains(cls)) out.push(k);
|
||||
walk(k);
|
||||
});
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
|
||||
|
||||
function html(node) {
|
||||
if (node.tagName === "#text") return node._text;
|
||||
const cls = node.className ? ` class="${node.className}"` : "";
|
||||
const attrs = Object.entries(node.attrs)
|
||||
.sort()
|
||||
.map(([k, v]) => ` ${k}="${v}"`)
|
||||
.join("");
|
||||
const hidden = node.hidden ? " hidden" : "";
|
||||
const off = node.disabled ? " disabled" : "";
|
||||
const inner = node._text + node.children.map(html).join("");
|
||||
return `<${node.tagName}${cls}${attrs}${hidden}${off}>${inner}</${node.tagName}>`;
|
||||
}
|
||||
|
||||
// ── 바깥 모듈 대역 ─────────────────────────────────────────────────────────────
|
||||
const LOCALES = {
|
||||
Common_Btn_Reset: ["초기화", "Reset"],
|
||||
B06_Profile_Btn_Save: ["저장", "Save"],
|
||||
B06_Profile_Btn_Confirm: ["확정", "Confirm"],
|
||||
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
|
||||
Design_Btn_ToProfile: ["종단 보기", "Profile view"],
|
||||
};
|
||||
const STUBS = {
|
||||
"@ui/ui_template_locale": { ui_locales: LOCALES, currentLanguageIndex: 0 },
|
||||
"@ui/ui_template_collapsible": { attachCollapsible: () => undefined },
|
||||
"@ui/ui_template_elements": {
|
||||
createButton: ({ label, variant, onClick }) => {
|
||||
const button = new FakeNode("button");
|
||||
button.className = `ui-btn ui-btn--${variant ?? "ghost"}`;
|
||||
const span = new FakeNode("span");
|
||||
span.className = "ui-btn__label";
|
||||
span.textContent = label;
|
||||
button.append(span);
|
||||
button.addEventListener("click", () => onClick && onClick());
|
||||
return button;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const panel = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts"));
|
||||
loadTs(path.join(ROOT, "A00_Common", "design", "design_sections.ts"));
|
||||
loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts"));
|
||||
|
||||
// ── 같은 견본 — 두 보기가 **같은 구조물 배치 부품**을 받는다 ─────────────────────
|
||||
function sample() {
|
||||
const root = new FakeNode("section");
|
||||
root.className =
|
||||
"b05-route__panel-section b05-structure-section ui-collapsible ui-sidebar-section";
|
||||
const title = new FakeNode("h3");
|
||||
title.className = "ui-collapsible__title";
|
||||
title.textContent = "구조물 배치";
|
||||
root.append(title);
|
||||
const listRoot = new FakeNode("div");
|
||||
listRoot.className = "b05-structure__list";
|
||||
listRoot.textContent = "구조물 목록";
|
||||
return { root, listRoot };
|
||||
}
|
||||
const text = (t) => Object.assign(new FakeNode("div"), { _text: t });
|
||||
function context(view, calls) {
|
||||
const run = (name) => () => calls.push(`${view}:${name}`);
|
||||
return {
|
||||
projectId: "p",
|
||||
parts: {
|
||||
structures: sample(),
|
||||
settings: { nodes: [text("a"), text("b")] },
|
||||
route: {
|
||||
plannedRouteButton: text("plan"),
|
||||
calcNodes: [text("calc")],
|
||||
selectedNodes: [text("sel")],
|
||||
bindSelectedRoot: (root) => {
|
||||
root.hidden = true;
|
||||
},
|
||||
},
|
||||
},
|
||||
dock: {
|
||||
reset: { run: run("reset") },
|
||||
save: { run: run("save") },
|
||||
confirm: { run: run("confirm"), enabled: view === "cross" },
|
||||
switchView: { run: run("switch") },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
const views = {};
|
||||
for (const view of ["profile", "cross"]) {
|
||||
const built = panel.createDesignPanel(context(view, calls), view);
|
||||
const dockRoot = built.dock.root;
|
||||
const buttons = dockRoot.querySelectorAll(".ui-btn");
|
||||
buttons.forEach((b) => b.click());
|
||||
const structures = built.root.children.find((c) => c.classList.contains("b05-structure-section"));
|
||||
views[view] = {
|
||||
rootClass: built.root.className,
|
||||
sectionIds: panel.panelSectionsFor(view).map((d) => d.id),
|
||||
structuresHtml: html(structures),
|
||||
structuresFirst: built.root.children[0] === structures,
|
||||
dockFirstChild: html(dockRoot.children[0]),
|
||||
dockClass: dockRoot.className,
|
||||
dockLabels: buttons.map((b) => b.textContent),
|
||||
dockActions: buttons.map((b) => b.dataset.dockAction),
|
||||
dockDisabled: buttons.map((b) => b.disabled),
|
||||
dockRowClass: dockRoot.children[dockRoot.children.length - 1].className,
|
||||
dockIsLast: built.root.children[built.root.children.length - 1] === dockRoot,
|
||||
};
|
||||
}
|
||||
// 보기 전환 — 글자가 따라 바뀐다.
|
||||
const switched = panel.createDesignPanel(context("profile", []), "profile");
|
||||
switched.setView("cross");
|
||||
const switchLabelAfter = switched.dock.root
|
||||
.querySelectorAll(".ui-btn")
|
||||
.map((b) => b.textContent)
|
||||
.slice(-1)[0];
|
||||
|
||||
process.stdout.write(JSON.stringify({ views, calls, switchLabelAfter }));
|
||||
@@ -474,23 +474,25 @@ def _resave_unchanged(flow: Flow) -> None:
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 —
|
||||
# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a).
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
|
||||
"""B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
|
||||
"""B06 [저장] — 오르막 차례는 상단측만 확정."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""배수유역도 판이 종단 패널을 모르고 홀로 선다(계획서 24-2e).
|
||||
|
||||
- 배수유역 파일(`B05_Profile_UI_Drainage_*`)은 종단 패널(`_Profile_*`)이나 B06 을 가져오지 않는다 —
|
||||
종단쪽 일은 `_Panel_Types` 약속(콜백 · 창구)으로만 오간다.
|
||||
- 파일마다 700줄 이하.
|
||||
- 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null ·
|
||||
시설 정보 · 출처 기본값 user.
|
||||
- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B05 = ROOT / "B05_Profile"
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _drainage_files() -> list[Path]:
|
||||
return sorted(B05.glob("B05_Profile_UI_Drainage_*.ts"))
|
||||
|
||||
|
||||
def test_배수유역_파일은_종단_패널과_B06을_안_가져옴():
|
||||
files = _drainage_files()
|
||||
assert len(files) >= 10
|
||||
for path in files:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
for line in source.splitlines():
|
||||
if not line.lstrip().startswith(("import ", "} from ", "export {", "export type {")):
|
||||
continue
|
||||
assert "UI_Profile_" not in line, (path.name, line)
|
||||
assert "B06_Section" not in line, (path.name, line)
|
||||
|
||||
|
||||
def test_배수유역_파일은_700줄_이하():
|
||||
for path in _drainage_files():
|
||||
lines = len(path.read_text(encoding="utf-8").splitlines())
|
||||
assert lines <= 700, (path.name, lines)
|
||||
|
||||
|
||||
def test_켜고_끄는_창구():
|
||||
side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8")
|
||||
for name in (
|
||||
"createDrainageSide",
|
||||
"createDrainageToggleButton",
|
||||
"setVisible",
|
||||
"onVisibleChange",
|
||||
):
|
||||
assert name in side
|
||||
assert "root.hidden" in side or "panel.root.hidden" in side
|
||||
# 종단도 판 자리 약속(`DesignSideFactory` · design_profile.ts) 모양 그대로
|
||||
assert "DesignSideBoard" in side and "DesignSideHost" in side
|
||||
assert "export function createDrainageSide(" in side
|
||||
types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8")
|
||||
assert "export interface DrainagePipeReport" in types
|
||||
assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reports():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_drainage_report.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_관_보고_유역_매칭과_기본값(reports):
|
||||
first, box, plain = reports
|
||||
# 누가거리는 소수 둘째 자리로 · 담당 유역의 관경 · 설계유량이 붙는다.
|
||||
assert first["chainage_m"] == 40 and first["effective_diameter_mm"] == 800
|
||||
assert first["design_flow_m3s"] == 1.2 and first["facility"] == "pipe"
|
||||
assert first["source"] == "auto"
|
||||
# 세월교 검토 유역은 관경만 null · 설계유량은 남김 · 시설 정보를 데려옴.
|
||||
assert box["chainage_m"] == 100.5 and box["effective_diameter_mm"] is None
|
||||
assert box["design_flow_m3s"] == 3.4
|
||||
assert box["facility"] == "box_culvert" and (box["start_m"], box["end_m"]) == (95, 106)
|
||||
assert box["options"] == {"h": 2} and box["source"] == "user"
|
||||
# 유역 없는 관 — 모두 null · 맨 배관.
|
||||
assert plain["effective_diameter_mm"] is None and plain["design_flow_m3s"] is None
|
||||
assert plain["facility"] == "pipe"
|
||||
@@ -19,7 +19,8 @@ _B05 = PROJECT_ROOT / "B05_Profile"
|
||||
_SPANS = _B05 / "B05_Profile_UI_Drainage_Spans.ts"
|
||||
_RENDER = _B05 / "B05_Profile_UI_Drainage_Render.ts"
|
||||
_PANEL = _B05 / "B05_Profile_UI_Drainage_Panel.ts"
|
||||
_PROFILE = _B05 / "B05_Profile_UI_Profile_Panel.ts"
|
||||
# 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c) — 배수유역도는 오른쪽 판(side).
|
||||
_PROFILE = _B05.parent / "A00_Common" / "design" / "design_profile.ts"
|
||||
|
||||
|
||||
def test_띠를_그리는_부품이_있다():
|
||||
@@ -54,7 +55,7 @@ def test_종단과_같은_자료에서_뽑는다():
|
||||
assert 'item.placement !== "interval"' in spans, "구간형만 띠로 그린다"
|
||||
assert "type.style?.color" in spans, "색을 새로 정하지 않고 레지스트리 값을 쓴다"
|
||||
profile = _PROFILE.read_text(encoding="utf-8")
|
||||
assert "drainagePanel.setIntervalSpans(routeSpansFromStructures(" in profile
|
||||
assert "drainagePanel?.setIntervalSpans(routeSpansFromStructures(" in profile
|
||||
|
||||
|
||||
def test_목록이_바뀌면_다시_그린다():
|
||||
|
||||
@@ -17,7 +17,8 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
RENDER = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Render.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
|
||||
# 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c) — 편집 한 길도 그 안에 있다.
|
||||
PANEL = (PROJECT_ROOT / "A00_Common" / "design" / "design_profile.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
GUARD = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_MinCover.ts").read_text(
|
||||
|
||||
@@ -192,3 +192,24 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
|
||||
{"logic": "earthwork"},
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_노선_추천_다듬기_23_3():
|
||||
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
|
||||
page = os.path.join(HERE, "..", "..", "B05_Profile")
|
||||
|
||||
def read(name):
|
||||
return open(os.path.join(page, name), encoding="utf-8").read()
|
||||
|
||||
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
|
||||
rec = chrome.index('data-act="recommend"')
|
||||
divider = chrome.index("b05-routeedit__divider", rec)
|
||||
assert rec < divider < chrome.index('data-act="contour-minor"')
|
||||
assert chrome.count("b05-routeedit__divider") == 2
|
||||
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
|
||||
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
|
||||
assert f"{name}(" in shared
|
||||
css = read("B05_Profile_UI_Style_RouteRecommend.css")
|
||||
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
|
||||
assert "writing-mode: vertical-lr" in css
|
||||
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")
|
||||
|
||||
@@ -18,7 +18,8 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
COMMON = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
B05_PANEL = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Panel.ts").read_text(
|
||||
# 종단 패널 본체는 종단도 한 벌로 옮겼다(계획서 24-2c).
|
||||
B05_PANEL = (PROJECT_ROOT / "A00_Common" / "design" / "design_profile.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# 설계 재계산 자리는 700줄 제한으로 `_UI_Page_Design_Sync` 로 떨어져 나갔다(2026-09-13).
|
||||
|
||||
@@ -63,14 +63,36 @@ def test_창구_넷은_초안이_비면_요청을_안_낸다():
|
||||
|
||||
|
||||
def test_저장이_창구를_다섯_다_거친다():
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다."""
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
"""순서·개수가 바뀌면 위 성질의 뜻도 바뀐다 — 자리를 못박는다.
|
||||
|
||||
차례는 저장 한 줄기(`A00_Common/design/design_save.ts`) 한 곳에 있다(계획서 24-2a) —
|
||||
B05 [임시저장] · B06 [저장] · [확정]이 모두 그것을 탄다."""
|
||||
save = (ROOT / "A00_Common" / "design" / "design_save.ts").read_text(encoding="utf-8")
|
||||
for token in (
|
||||
"flushCulvertOptions()",
|
||||
"flushCulvertOptions(",
|
||||
"flushUphillOverrides(",
|
||||
"flushPendingPipes(",
|
||||
"flushPendingStructures(",
|
||||
):
|
||||
assert token in persist, f"{token} 가 저장 앞단에서 사라짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물은 `catch` 로 감싸 둔다.
|
||||
assert persist.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
assert token in save, f"{token} 가 저장 한 줄기에서 사라짐"
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "ctx.flushCulvertOptions()" in persist, "B06 조정창 구간값이 저장에서 빠짐"
|
||||
# 하나가 실패해도 나머지가 나가야 한다 — 관·구조물·계획선은 `catch` 로 감싸 둔다.
|
||||
assert save.count(".catch(") >= 3, "실패 격리(catch)가 줄었음"
|
||||
order = save[save.index("DESIGN_SAVE_ORDER = [") :]
|
||||
order = order[: order.index("]")]
|
||||
names = ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert [order.index(f'"{n}"') for n in names] == sorted(order.index(f'"{n}"') for n in names), (
|
||||
"저장 차례가 바뀜 — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단"
|
||||
)
|
||||
|
||||
|
||||
def test_두_페이지가_저장_한_줄기를_탄다():
|
||||
"""B05 [임시저장] · B06 [저장] · [확정]이 창구를 따로 부르지 않는다(계획서 24-2a)."""
|
||||
actions = (ROOT / "B05_Profile" / "B05_Profile_UI_Page_Actions.ts").read_text(encoding="utf-8")
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
for name, text in (("B05", actions), ("B06", persist)):
|
||||
assert "saveDesign(" in text, f"{name} 가 저장 한 줄기를 안 탐"
|
||||
for token in ("flushPendingPipes(", "flushPendingStructures(", "flushUphillOverrides("):
|
||||
assert token not in text, f"{name} 가 {token} 를 따로 부름 — 차례가 갈림"
|
||||
assert "{ confirm: true }" in persist, "[확정]이 한 줄기를 안 탐"
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
⇒ `saveCurrentSections`·`confirmCurrentSections` 가 `collectSectionEdits` **앞에서**
|
||||
`ctx.reconcileDesigns()` 를 부른다. 순서가 뒤집히면 옛 면적이 그대로 실린다.
|
||||
(계획서 24-2a — 둘 다 저장 한 줄기 `saveDesign` 을 탄다: B06 창구 `sectionSaveContext` 의
|
||||
`prepare` 가 재계산, `cross` 가 면적 수집이고, `saveDesign` 이 `prepare` 를 먼저 부른다.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,6 +19,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PERSIST = ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts"
|
||||
PAGE = ROOT / "B06_Section" / "B06_Section_UI_Page.ts"
|
||||
SAVE = ROOT / "A00_Common" / "design" / "design_save.ts"
|
||||
|
||||
|
||||
def _body(source: str, marker: str) -> str:
|
||||
@@ -30,11 +33,17 @@ def test_저장이_면적을_모으기_전에_다시_계산한다() -> None:
|
||||
"export async function saveCurrentSections",
|
||||
"export async function confirmCurrentSections",
|
||||
):
|
||||
body = _body(text, marker)
|
||||
assert "ctx.reconcileDesigns()" in body, f"{marker} 가 재계산을 안 부른다"
|
||||
assert body.index("ctx.reconcileDesigns()") < body.index("collectSectionEdits(ctx)"), (
|
||||
f"{marker}: 재계산이 면적 수집보다 뒤에 있다 — 옛 면적이 실린다"
|
||||
assert "saveDesign(sectionSaveContext(" in _body(text, marker)[:400], (
|
||||
f"{marker} 가 B06 창구로 저장 한 줄기를 안 탄다"
|
||||
)
|
||||
body = _body(text, "function sectionSaveContext")
|
||||
assert "prepare: () => ctx.reconcileDesigns()" in body, "B06 창구가 재계산을 안 넘긴다"
|
||||
assert "collectSectionEdits(ctx)" in body[body.index("cross:") :], "면적 수집이 횡단 차례 밖"
|
||||
save = SAVE.read_text(encoding="utf-8")
|
||||
run = _body(save, "export async function saveDesign")
|
||||
assert run.index("ctx.prepare?.()") < run.index("ctx.cross(confirm)"), (
|
||||
"저장 한 줄기가 재계산보다 면적 수집을 먼저 부른다 — 옛 면적이 실린다"
|
||||
)
|
||||
|
||||
|
||||
def test_페이지가_재계산_통로를_이어_준다() -> None:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""종단도 한 벌(계획서 24-2c) 회귀검증.
|
||||
|
||||
두 보기(B05 종단 · B06 횡단)가 **같은 종단도**(`A00_Common/design/design_profile.ts`)를 쓰고
|
||||
같은 오른쪽 판(배수유역도)을 끼우는지, 옛 B06 위쪽 붙박이 종단이 남지 않았는지, 계획고 초안이
|
||||
등록부 칸(`alignment-draft`)으로 옮겨져 되돌리기가 그 칸을 찍는지 본다.
|
||||
Node 헬퍼(`helper_design_profile_single.cjs`)가 TS 를 트랜스파일해 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_design_profile_single.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_두_보기가_같은_종단도와_같은_판():
|
||||
b05 = _read("B05_Profile/B05_Profile_UI_Profile_Panel.ts")
|
||||
b06 = _read("B06_Section/B06_Section_UI_Section_View_Profile.ts")
|
||||
for source in (b05, b06):
|
||||
assert "createDesignProfile(" in source
|
||||
assert "side: drainageSide" in source
|
||||
assert "sideTool: drainageSideTool" in source
|
||||
assert 'view: "profile"' in b05 and 'view: "cross"' in b06
|
||||
|
||||
|
||||
def test_옛_B06_위쪽_종단이_남지_않음():
|
||||
view = _read("B06_Section/B06_Section_UI_Section_View.ts")
|
||||
assert "createSectionProfile(" in view
|
||||
assert "drawLongitudinalPanel" not in view
|
||||
assert "b06-section__panel" not in view
|
||||
for gone in ("Draw", "Chart", "Panel"):
|
||||
assert not (ROOT / f"B06_Section/B06_Section_UI_Section_View_{gone}.ts").exists()
|
||||
|
||||
|
||||
def test_막기_규칙은_종단도_한_곳():
|
||||
profile = _read("A00_Common/design/design_profile.ts")
|
||||
assert profile.count("blocksMinCover(") == 1
|
||||
# 판이 없는 보기도 관 목록을 넘겨 최소 흙덮기를 판정한다.
|
||||
assert "setMinCoverPipes(" in profile
|
||||
assert "setMinCoverPipes(" in _read("B06_Section/B06_Section_UI_Section_View_Profile.ts")
|
||||
|
||||
|
||||
def test_크기_접기_키는_하나():
|
||||
drag = _read("B05_Profile/B05_Profile_UI_Profile_Drag.ts")
|
||||
assert 'stateKey("profile-height")' in drag
|
||||
assert '"profile-collapsed"' in drag
|
||||
assert '["section-panel-height", "profile-height"]' in drag # 옛 값 옮겨 받음
|
||||
|
||||
|
||||
def test_초안은_등록부_칸으로_옮겨_받음(results):
|
||||
assert results["adopted"] == {"station_offsets": {"20.000": 0.3}, "curve_radii": {}}
|
||||
assert results["keysAfterAdopt"] == ["aislo:draft:alignment-draft:p-1:7"]
|
||||
assert results["sameSlot"]["station_offsets"] == {"40.000": 0.1}
|
||||
|
||||
|
||||
def test_되돌리기가_새_칸을_찍음(results):
|
||||
assert results["beforeUndo"]["station_offsets"] == {"40.000": 0.2}
|
||||
assert results["undone"] is True
|
||||
assert results["afterUndo"]["station_offsets"] == {"40.000": 0.1}
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""설계 가게 한 곳 · 저장 한 줄기(계획서 24-2a) 회귀검증.
|
||||
|
||||
- 가게(`design_store`) — 밑은 등록 키 · 같은 칸이면 어느 가게든 같은 값 · 듣는 쪽.
|
||||
- 계획고 가게 한 벌 — B05 종단 패널 · B06 계획선 편집이 같은 벌을 씀(옛 두 가게 덮어쓰기 흠).
|
||||
- 저장 한 줄기(`saveDesign`) — 관 → 구조물 → 계획고 → 암거 옵션 → 오르막 → 횡단 · 관 · 구조물 ·
|
||||
계획고는 실패해도 다음 차례로.
|
||||
Node 헬퍼(`helper_design_store_save.cjs`)가 TS 를 트랜스파일해 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_design_store_save.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_가게는_등록_키_밑_같은_칸은_같은_값(out):
|
||||
assert out["pipesShared"] == [{"chainage_m": 10, "source": "user"}]
|
||||
assert out["pipesKey"] == ["aislo:draft:pipes:p1"]
|
||||
assert out["heard"] == [1], "끊은 뒤에는 안 불려야 함"
|
||||
assert out["otherProject"] is None
|
||||
assert out["noRoute"] is None
|
||||
assert out["selection"] == {"chainageM": 12.5, "structureId": "s-1"}
|
||||
|
||||
|
||||
def test_계획고_가게_한_벌(out):
|
||||
assert out["b05SeesB06"] == {"20.000": 0.5}
|
||||
assert out["b05Dirty"] is True
|
||||
assert out["afterBoth"] == {"20.000": 0.5, "60.000": -0.2}
|
||||
assert out["draft"] == out["afterBoth"] == out["pending"]
|
||||
assert out["rebuiltSeenByB06"] == {"20.000": 0.1}
|
||||
|
||||
|
||||
def test_저장_차례(out):
|
||||
assert out["order"] == ["pipes", "structures", "alignment", "culvertOptions", "uphill", "cross"]
|
||||
assert out["saveOk"] is True
|
||||
assert out["saveCalls"] == [
|
||||
"overlay:on",
|
||||
"prepare",
|
||||
"pipes",
|
||||
"pipes:screen",
|
||||
"structures",
|
||||
'alignment:7:{"20.000":0.1}',
|
||||
"culvert:aislo:draft:culvertopt:p1:7",
|
||||
"uphill",
|
||||
"cross:false",
|
||||
"done:false",
|
||||
"overlay:off",
|
||||
]
|
||||
assert out["afterSave"] == {"dirty": False, "draft": None}
|
||||
|
||||
|
||||
def test_구조물_실패는_알리고_넘어감_확정_노선없음(out):
|
||||
calls = out["tolerant"]
|
||||
assert "toast:error" in calls
|
||||
assert calls.index("structures") < calls.index("toast:error") < calls.index("uphill:screen")
|
||||
assert "uphill" not in calls, "보기 몫이 기본 창구를 갈음"
|
||||
assert calls[-2:] == ["cross:true", "overlay:off"]
|
||||
assert "failed" not in calls
|
||||
assert out["noRouteSave"] is False and out["noRouteCalls"] == []
|
||||
|
||||
|
||||
def test_초기화는_초안과_한_벌을_버림(out):
|
||||
assert out["cleared"] == {"draft": None, "pending": None}
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B05 · B06 한 페이지 두 보기 — **좌측 패널 같음 지킴**(계획서 24-2f).
|
||||
|
||||
① 두 보기의 좌측 패널을 같은 견본으로 세워 공용 칸(구조물 배치)의 DOM · 아래 고정 영역이 같은지 본다
|
||||
(Node 헬퍼 `helper_design_view_parity.cjs` 가 등록부를 트랜스파일해 가짜 DOM 위에서 돌린다).
|
||||
② 페이지 파일(`B05_Profile_UI_Page.ts` · `B06_Section_UI_Page.ts`)이 좌측 칸 · 아래 단추 · 저장 단추를
|
||||
직접 만들면 빨강 — 칸과 단추는 등록부(`A00_Common/design/`)만 만든다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
PAGE_FILES = (
|
||||
ROOT / "B05_Profile" / "B05_Profile_UI_Page.ts",
|
||||
ROOT / "B06_Section" / "B06_Section_UI_Page.ts",
|
||||
)
|
||||
# 페이지 파일에 나오면 안 되는 것 — 좌측 칸 · 아래 단추 · 저장 단추를 직접 만든다는 표시.
|
||||
FORBIDDEN_IN_PAGES = (
|
||||
"createButton(",
|
||||
"attachCollapsible(",
|
||||
"buildGroup(",
|
||||
"createSectionFrame(",
|
||||
"registerPanelSection(",
|
||||
"createDesignDock(",
|
||||
"ui-sidebar-actions",
|
||||
"b05-route__dock",
|
||||
"b05-route__actions",
|
||||
"b06-profile__actions",
|
||||
"b06-profile__form",
|
||||
"b05-route__panel",
|
||||
"b06-profile__group",
|
||||
"saveButton",
|
||||
"confirmButton",
|
||||
"actionDock",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_design_view_parity.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_공용_칸_구조물_배치는_두_보기가_같은_DOM(results):
|
||||
profile, cross = results["views"]["profile"], results["views"]["cross"]
|
||||
assert profile["structuresHtml"] == cross["structuresHtml"]
|
||||
assert profile["structuresFirst"] and cross["structuresFirst"] # 맨 위 칸
|
||||
|
||||
|
||||
def test_구조물_목록은_두_보기_모두_아래_고정_영역_맨_위(results):
|
||||
profile, cross = results["views"]["profile"], results["views"]["cross"]
|
||||
assert profile["dockFirstChild"] == cross["dockFirstChild"]
|
||||
assert profile["dockClass"] == cross["dockClass"]
|
||||
assert profile["dockIsLast"] and cross["dockIsLast"]
|
||||
|
||||
|
||||
def test_아래_단추는_네_개_두_보기_같은_차례(results):
|
||||
profile, cross = results["views"]["profile"], results["views"]["cross"]
|
||||
assert (
|
||||
profile["dockActions"]
|
||||
== cross["dockActions"]
|
||||
== [
|
||||
"reset",
|
||||
"save",
|
||||
"confirm",
|
||||
"switchView",
|
||||
]
|
||||
)
|
||||
assert profile["dockLabels"][:3] == cross["dockLabels"][:3] == ["초기화", "저장", "확정"]
|
||||
|
||||
|
||||
def test_보기_전환_단추는_가려는_쪽_이름(results):
|
||||
assert results["views"]["profile"]["dockLabels"][3] == "횡단 보기"
|
||||
assert results["views"]["cross"]["dockLabels"][3] == "종단 보기"
|
||||
assert results["switchLabelAfter"] == "종단 보기" # setView("cross") 뒤
|
||||
|
||||
|
||||
def test_꺼진_단추는_안_눌리고_켜진_단추는_동작을_부름(results):
|
||||
assert results["views"]["profile"]["dockDisabled"] == [False, False, True, False]
|
||||
assert results["views"]["cross"]["dockDisabled"] == [False, False, False, False]
|
||||
assert results["calls"] == [
|
||||
"profile:reset",
|
||||
"profile:save",
|
||||
"profile:switch",
|
||||
"cross:reset",
|
||||
"cross:save",
|
||||
"cross:confirm",
|
||||
"cross:switch",
|
||||
]
|
||||
|
||||
|
||||
def test_칸_차례는_보기마다_등록부_순서(results):
|
||||
assert results["views"]["profile"]["sectionIds"] == [
|
||||
"structures",
|
||||
"page-settings",
|
||||
"planned-route",
|
||||
"route-calc",
|
||||
"selected-point",
|
||||
]
|
||||
|
||||
|
||||
def test_횡단_보기_칸_정의와_차례():
|
||||
"""B06 칸 정의는 화면 부품(입력 칸 · 표준 패널)을 물어 Node 에서 못 읽는다 — 소스로 본다."""
|
||||
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Panel.ts").read_text(encoding="utf-8")
|
||||
found = re.findall(
|
||||
r'id: "([\w-]+)",\s*title:[^\n]*\n\s*views: \["cross"\],\s*order: (\d+)', source
|
||||
)
|
||||
ordered = [name for name, _ in sorted(found, key=lambda item: int(item[1]))]
|
||||
assert ordered == ["cross-view", "standard-cross"]
|
||||
shared = (ROOT / "A00_Common" / "design" / "design_sections.ts").read_text(encoding="utf-8")
|
||||
assert 'views: ["profile", "cross"]' in shared # 구조물 배치는 한 정의로 두 보기
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page", PAGE_FILES, ids=lambda path: path.name)
|
||||
def test_페이지_파일이_좌측_칸_단추_저장을_직접_만들지_않는다(page):
|
||||
text = page.read_text(encoding="utf-8")
|
||||
found = [marker for marker in FORBIDDEN_IN_PAGES if marker in text]
|
||||
assert not found, f"{page.name} 이(가) 좌측 칸 · 단추를 직접 만든다: {found}"
|
||||
|
||||
|
||||
def test_두_보기_패널은_같은_등록부로_세운다():
|
||||
b05 = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel.ts").read_text(encoding="utf-8")
|
||||
b06 = (ROOT / "B06_Section" / "B06_Section_UI_Page.ts").read_text(encoding="utf-8")
|
||||
assert "createDesignPanel(" in b05 and '"profile"' in b05
|
||||
assert "createDesignPanel(" in b06 and '"cross"' in b06
|
||||
@@ -113,6 +113,60 @@ def test_each_branch_best_at_own_goal(hill: Terrain) -> None:
|
||||
assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values())
|
||||
|
||||
|
||||
# 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선
|
||||
ENDS = {
|
||||
"follow": {"expected": 100, "earthwork": 0, "grade": 0},
|
||||
"earthwork": {"expected": 0, "earthwork": 100, "grade": 0},
|
||||
"gentle": {"expected": 0, "earthwork": 0, "grade": 100},
|
||||
}
|
||||
# 예상 100 → 0(나머지 반반) · 기울기 100 → 토공 100
|
||||
TOWARD_OTHERS = [
|
||||
(100, 0, 0),
|
||||
(90, 5, 5),
|
||||
(75, 12.5, 12.5),
|
||||
(50, 25, 25),
|
||||
(25, 37.5, 37.5),
|
||||
(0, 50, 50),
|
||||
]
|
||||
TOWARD_EARTH = [(0, 0, 100), (0, 25, 75), (0, 50, 50), (0, 75, 25), (0, 100, 0)]
|
||||
|
||||
|
||||
def _gap(first: dict, second: dict) -> float:
|
||||
return LineString(first["planned"]).hausdorff_distance(LineString(second["planned"]))
|
||||
|
||||
|
||||
def _check_weight_ends(run) -> None:
|
||||
for mode, weights in ENDS.items():
|
||||
end, own = run("weighted", weights), run(mode)
|
||||
assert _gap(end, own) < 0.01, (mode, _gap(end, own))
|
||||
assert end["nodes"] == own["nodes"]
|
||||
assert end["metrics"]["length_m"] == own["metrics"]["length_m"]
|
||||
|
||||
|
||||
def _check_weight_monotone(run) -> None:
|
||||
"""비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질)."""
|
||||
|
||||
def metrics(steps):
|
||||
keys = ("expected", "earthwork", "grade")
|
||||
return [run("weighted", dict(zip(keys, step)))["metrics"] for step in steps]
|
||||
|
||||
offsets = [m["offset_avg_m"] for m in metrics(TOWARD_OTHERS)]
|
||||
assert offsets == sorted(offsets), offsets
|
||||
earth = metrics(TOWARD_EARTH)
|
||||
cuts = [m["cut_m3"] + m["fill_m3"] for m in earth]
|
||||
tops = [m["max_grade_pct"] for m in earth]
|
||||
assert cuts == sorted(cuts, reverse=True), cuts
|
||||
assert tops == sorted(tops), tops
|
||||
|
||||
|
||||
def test_weight_ends_match_branches(hill: Terrain) -> None:
|
||||
_check_weight_ends(lambda mode, weights=None: _run(hill, mode, weights=weights))
|
||||
|
||||
|
||||
def test_weight_moves_monotone(hill: Terrain) -> None:
|
||||
_check_weight_monotone(lambda mode, weights=None: _run(hill, mode, weights=weights))
|
||||
|
||||
|
||||
def test_follow_stays_on_expected_where_allowed() -> None:
|
||||
"""고른 경사(2 %) — 추종은 예상노선을 그대로(격자 칸 반 안쪽)."""
|
||||
ramp = _terrain(lambda xx, yy: 0.02 * xx)
|
||||
@@ -301,18 +355,19 @@ def project_runs() -> dict:
|
||||
streams = stream_lines(_reproject_features(features, to_metric))
|
||||
route = read_planned_route_csv(expected_route_csv_path(PROJECT))
|
||||
expected = [(float(v.x), float(v.y)) for v in route.vertices]
|
||||
return {
|
||||
mode: generate_initial_route(
|
||||
|
||||
def run(mode: str, weights: dict | None = None) -> dict:
|
||||
return generate_initial_route(
|
||||
expected,
|
||||
terrain,
|
||||
streams,
|
||||
mode=mode,
|
||||
weights=None,
|
||||
weights=weights,
|
||||
max_grade=GRADE,
|
||||
min_radius_m=RADIUS,
|
||||
)
|
||||
for mode in MODES
|
||||
}
|
||||
|
||||
return {**{mode: run(mode) for mode in MODES}, "run": run}
|
||||
|
||||
|
||||
def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
|
||||
@@ -331,7 +386,7 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None:
|
||||
|
||||
def test_project_branches_differ_by_goal(project_runs: dict) -> None:
|
||||
"""갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음."""
|
||||
searched = {mode: project_runs[mode] for mode in SEARCHED}
|
||||
searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25
|
||||
for first in SEARCHED:
|
||||
for second in SEARCHED:
|
||||
if first < second:
|
||||
@@ -344,3 +399,13 @@ def test_project_branches_differ_by_goal(project_runs: dict) -> None:
|
||||
assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values())
|
||||
for result in searched.values():
|
||||
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
|
||||
|
||||
|
||||
def test_project_weight_ends_match_branches(project_runs: dict) -> None:
|
||||
"""랩탑_보조 — 비중 100/0/0 = 추적(1078.01 m) · 0/100/0 = 토공 최소 · 0/0/100 = 기울기 순한."""
|
||||
_check_weight_ends(project_runs["run"])
|
||||
assert project_runs["run"]("weighted", ENDS["follow"])["metrics"]["length_m"] == 1078.01
|
||||
|
||||
|
||||
def test_project_weight_moves_monotone(project_runs: dict) -> None:
|
||||
_check_weight_monotone(project_runs["run"])
|
||||
|
||||
@@ -144,6 +144,9 @@ export const ui_locales_b2 = {
|
||||
이름이 혼선을 줬다 — [저장]으로 바꾼다(2026-08-28 사용자 지시). */
|
||||
B05_Route_Btn_TempSave: ["저장", "Save"],
|
||||
B05_Route_Btn_GoCross: ["횡단 이동", "Go to Cross"],
|
||||
/* 좌측 패널 아래 단추 [보기 전환] — 가려는 쪽 이름(계획서 24-2b). */
|
||||
Design_Btn_ToCross: ["횡단 보기", "Cross view"],
|
||||
Design_Btn_ToProfile: ["종단 보기", "Profile view"],
|
||||
B05_Route_TempSave_Success: ["변경 내용을 저장했습니다.", "Changes saved."],
|
||||
B05_Route_TempSave_Failed: ["저장에 실패했습니다.", "Failed to save."],
|
||||
B05_Route_Reset_Confirm: [
|
||||
|
||||
Reference in New Issue
Block a user