From 6aa77e1bb73d0e21101cc4b78b978dd565d4e12d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 7 Oct 2026 21:29:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EB=8B=A4=EC=84=AF=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=EC=9D=80=20=EA=B0=88=EB=9E=98=EA=B0=80=20?= =?UTF-8?q?=EC=A0=95=ED=95=A8=20=C2=B7=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EC=9C=84=20[=EC=A0=81=EC=9A=A9]/[=EB=8B=AB=EA=B8=B0]=20=C2=B7?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=EC=95=88=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EA=B2=B9=EC=B0=BD=20=C2=B7=20=EC=B6=94=EC=B2=9C=20=ED=86=A0?= =?UTF-8?q?=EB=9F=89=EC=97=90=20=EA=B3=A1=EC=84=A0=EB=B6=80=20=ED=99=95?= =?UTF-8?q?=ED=8F=AD(77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알약 = 예상노선 추적(종단 없음) · 영선 · 토공 최소 · 기울기 최소 · 비중 계산(넷은 종단 늘 반영) · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 화면에서 뺌 - 「종단 반영」 켬/끔 단추 없앰(추천 창 · 종단 띠) — 띠 · [확인]은 지금 추천 갈래로 종단 여부 - ✕ 없애고 [적용] · [닫기]를 제목줄 오른쪽 위로 · 옵션 칸은 지도 왼쪽 위 작은 겹창 · 창 폭 넓힘 - 적용한 추천 갈래가 있으면 창을 다시 열 때 그 갈래가 골라진 채 - 도움말: 종단 반영 = 측점마다 횡단을 보고 정한 종단(적분값 아님) - 균형 종단 단면적에 곡선부 확폭(B05 측점 생성과 같은 셈) — 빼서 확정 뒤 성토가 1.7배 벌어졌음(노선 267 같은 계획고: 성 10,102 → 17,819 · 본 설계 16,140) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019EMfGERKUg5nMMNQsyVZ2j --- ...B05_Profile_Engine_RouteInitial_Profile.py | 49 ++++++-- B05_Profile/B05_Profile_UI_RouteEdit.ts | 3 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 4 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 16 +-- .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 10 +- .../B05_Profile_UI_RouteEdit_ProfileStrip.ts | 39 +++--- .../B05_Profile_UI_RouteEdit_Recommend.ts | 113 ++++++++---------- ...B05_Profile_UI_RouteEdit_Recommend_Help.ts | 61 ++-------- .../B05_Profile_UI_Style_RouteRecommend.css | 60 ++++------ .../tester/helper_b05_routeedit_initial.cjs | 98 ++++++++++++--- .../tester/test_77_recommend_widening.py | 36 ++++++ .../tester/test_b05_routeedit_initial.py | 30 ++--- 12 files changed, 282 insertions(+), 237 deletions(-) create mode 100644 resources/tester/test_77_recommend_widening.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 87b991664..6aab58523 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -10,7 +10,8 @@ 1. 측점(20 m)마다 지반 횡단(±20 m · 0.5 m)을 DEM 에서 뜬다 — B05 측점 생성과 같은 꼴 (좌 = +offset · 상단측 = 좌우 평균 표고가 높은 쪽 · 그쪽 절토). 2. 측점마다 계획고 ±Δ 의 절 · 성 단면적 곡선 — B06 `compute_cross_design` 그대로(지반유형 - 리핑암 = B06 기본값) · `AREA_STEP_M` 간격으로 재고 사이는 직선 보간. + 리핑암 = B06 기본값 · 곡선부 확폭 = B05 측점 생성과 같은 `_plan_radii` · `_curve_widenings` + — 빼면 확정 뒤 성토가 1.7배로 벌어짐 · PLAN 77-7) · `AREA_STEP_M` 간격으로 재고 사이는 보간. 3. 동적계획(앞 두 측점 상태)으로 측점 계획고를 고른다 — 목표 = 절토(다짐 환산) + 성토 ㎥ + λ × (성토 − 절토). λ 를 이분해 |절 − 성| / 큰 쪽이 허용(10 %) 안인 가장 작은 λ. · 종단기울기 상한 — 넘지 못함. @@ -57,8 +58,8 @@ GRADE_EPS = 1e-6 def route_sections( vertices: list[tuple[float, float]], terrain: Terrain -) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str]]: - """측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토).""" +) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]: + """측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭.""" xy = np.asarray(vertices, dtype=np.float64) keep = np.r_[True, np.hypot(*np.diff(xy, axis=0).T) > 1e-9] xy = xy[keep] @@ -100,11 +101,27 @@ def route_sections( lefts, rights = row[(offsets > 0) & flags], row[(offsets < 0) & flags] up = "right" if lefts.size and rights.size and rights.mean() > lefts.mean() else "left" modes.append(f"{up}_cut") - return at, ground, sections, modes + return at, ground, sections, modes, _widenings(xy, chain, at) + + +def _widenings(xy: np.ndarray, chain: np.ndarray, at: np.ndarray) -> list[dict[str, Any]]: + """측점마다 `compute_cross_design` 확폭 인자 — B05 측점 생성(곡선표 없을 때)과 같은 셈.""" + from B05_Profile.B05_Profile_Engine_Sections_Core import _curve_widenings, _plan_radii + + radii, outer = _plan_radii(xy, chain, at, float(chain[-1])) + widenings, sides = _curve_widenings(at, radii, outer) + return [ + {"plan_radius_m": r, "curve_outer_side": s, "curve_widening_m": w} + for r, s, w in zip(radii, sides, widenings) + ] def _area_curves( - sections: list[list[dict[str, Any]]], modes: list[str], ground: np.ndarray, levels: np.ndarray + sections: list[list[dict[str, Any]]], + modes: list[str], + ground: np.ndarray, + levels: np.ndarray, + curves: list[dict[str, Any]] | None = None, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡).""" from B06_Section.B06_Section_Engine_Design import compute_cross_design @@ -113,6 +130,7 @@ def _area_curves( shape = levels.shape cut, cut_c, fill = np.zeros(shape), np.zeros(shape), np.zeros(shape) for i, (samples, mode) in enumerate(zip(sections, modes)): + widen = curves[i] if curves else {} delta = levels[i] - ground[i] lo = np.floor(delta.min() / AREA_STEP_M) * AREA_STEP_M at = np.arange(lo, delta.max() + AREA_STEP_M, AREA_STEP_M) @@ -120,7 +138,11 @@ def _area_curves( for d in at: try: r = compute_cross_design( - samples, float(ground[i] + d), ground_type=GROUND_TYPE, section_mode=mode + samples, + float(ground[i] + d), + ground_type=GROUND_TYPE, + section_mode=mode, + **widen, ) except ValueError: # 지반 샘플이 모자란 측점 — 토량 모름(0) rows.append((0.0, 0.0, 0.0)) @@ -196,6 +218,7 @@ def balanced_profile( *, max_grade: float, design_speed: int = 20, + curves: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """상한(종단기울기 · 종단곡선)을 지키며 절 · 성토를 횡단 ㎥ 로 맞춘 계획선과 토량. @@ -208,7 +231,9 @@ def balanced_profile( weight[1:] += spans / 2.0 offsets = np.arange(-LEVEL_RANGE_M, LEVEL_RANGE_M + 1e-9, LEVEL_STEP_M) levels = _reference(ground, spans, max_grade)[:, None] + offsets[None, :] - cut, cut_c, fill = (weight[:, None] * a for a in _area_curves(sections, modes, ground, levels)) + cut, cut_c, fill = ( + weight[:, None] * a for a in _area_curves(sections, modes, ground, levels, curves) + ) criteria = ( FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(design_speed) or (FOREST_ROAD_PROFILE_CRITERIA["design_speed"][20]) @@ -293,11 +318,17 @@ def route_profile( vertices: list[tuple[float, float]], terrain: Terrain, *, max_grade: float, design_speed: int ) -> dict[str, Any]: """후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`.""" - chainage, ground, sections, modes = route_sections(vertices, terrain) + chainage, ground, sections, modes, curves = route_sections(vertices, terrain) if len(chainage) < 2: raise ValueError("노선이 짧아 종단을 세울 수 없습니다.") return balanced_profile( - chainage, ground, sections, modes, max_grade=max_grade, design_speed=design_speed + chainage, + ground, + sections, + modes, + max_grade=max_grade, + design_speed=design_speed, + curves=curves, ) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 7ccf9177c..12224f0cf 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -226,13 +226,12 @@ export async function openRouteEditModal( projectId, planMinRadiusM: () => minRadiusM, apply: (snapshot, message) => restoreSnapshot(snapshot, message, true), - onProfileChange: () => strip.refresh(), }); /** 지도 아래 종단 띠 — 노드를 놓을 때마다 다시 묻는다(PLAN 75-1). 셈은 `_ProfileStrip` 몫. */ const strip = createProfileStrip({ root: overlay.querySelector("[data-strip]")!, projectId, - profileMemory: initial.profileMemory, + withProfile: initial.withProfile, now: () => ({ vertices: planned.map(([x, y], index) => ({ x, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index a3afcd055..2788805ea 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -43,7 +43,7 @@ export function createRouteEditChrome(): RouteEditChrome { + title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천 종단을 읽는 중…
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index 2dd5d0ddb..20d6e4766 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -33,6 +33,7 @@ import { readInitialChoice, sameBase, serverBase, + withProfileOf, writeInitialChoice, type CriteriaKey, type InitialBase, @@ -47,8 +48,6 @@ export interface InitialPanelParams { planMinRadiusM: () => number; /** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */ apply: (snapshot: RouteEditSnapshot, message: string) => void; - /** 추천 창에서 「종단 반영」을 바꿨다 — 종단 띠가 다시 묻는다. */ - onProfileChange: () => void; } export interface InitialPanel { @@ -60,9 +59,9 @@ export interface InitialPanel { weights: RouteInitialWeights | null; criteria: RouteInitialCriteria | null; }; - /** 「종단 반영」 — 추천 창 · 종단 띠가 함께 쥔다. 켬이면 [확인]이 지금 노선의 균형 종단을 - * 1차 종단으로 쓴다(추천을 손으로 고쳤어도 · PLAN 75-1). 편집 창이 사는 동안만 남는다. */ - profileMemory: { withProfile: boolean }; + /** 지금 노선이 종단을 반영하는가 — 지금 추천 갈래가 정한다(예상노선 추적만 아님 · PLAN 77-2). + * 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 갈래대로). */ + withProfile: () => boolean; /** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */ tag: () => InitialChoice | null; restoreTag: (tag: unknown) => void; @@ -96,8 +95,6 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { weights: null as RouteInitialWeights | null, locks: { ...NO_WEIGHT_LOCKS }, }; - /** 추천 「종단 반영」 — 이 편집 창이 사는 동안만 남는다(저장 · 캐시 · [취소] 되돌림 없이 창과 함께 사라짐). */ - const profileMemory = { withProfile: true }; /** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */ let planRead: Promise | null = null; @@ -146,12 +143,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { defaults, weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) }, weightMemory, - profileMemory, + logic: (choice ?? base).logic, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: opening!.planned.map(([x, y]) => [x, y]), request, - onProfileChange: params.onProfileChange, onApply: (picked) => { choice = picked; writeInitialChoice(projectId, choice); @@ -220,7 +216,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { const criteria = choice ? choice.criteriaOverrides : base.criteria; return { mode: now.logic, weights: now.weights, criteria }; }, - profileMemory, + withProfile: () => withProfileOf((choice ?? base).logic), tag: () => choice, restoreTag(tag) { if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index f972083ba..6e63790ff 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -20,13 +20,16 @@ export const LOGIC_LABEL: Record = { follow: "예상노선 추적", gradeline: "영선 노선", earthwork: "토공 최소", - gentle: "기울기 순한", + gentle: "기울기 최소", safe: "안전 우선", crossings: "계류 횡단 최소", cost: "공사비 어림 최소", weighted: "비중 계산", }; +/** 종단을 늘 반영하는 갈래 — 예상노선 추적만 종단 없음(전처리 계획선 = 지반고 · PLAN 77-2). */ +export const withProfileOf = (logic: RouteInitialLogic): boolean => logic !== "follow"; + /** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */ export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 }; export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const; @@ -432,8 +435,11 @@ const withParticle = (name: string): string => { /** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다). * 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */ +/** 화면에서 뺀 갈래(PLAN 77-1) — 서버는 아직 셈하므로 같은 노선 알림에서도 뺀다. */ +const HIDDEN_LOGICS = new Set(["safe", "crossings", "cost"]); + export function similarLine(choice: InitialChoice | null): string { - const similar = choice?.similar; + const similar = choice?.similar?.filter((item) => !HIDDEN_LOGICS.has(item.logic)); if (!choice || !similar || similar.length === 0) return ""; const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · "); const gap = Math.max(...similar.map((item) => item.gapM)); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts index 023645a9d..493f72db0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ProfileStrip.ts @@ -3,9 +3,10 @@ * 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 · * 측점 눈금 한 줄. * - * 「종단 반영」(추천 창과 같은 `profileMemory`) 켬 = 계획선은 지금 노드로 서버가 다시 셈한 균형 - * 종단(`/route/profile-preview` — [확인] 체인과 같은 셈). 끔 = 지반선만(계획고 = 지반고 · - * `/route/elevations`). [확인]도 같은 규칙을 탄다(`_Apply` 의 `balanced_profile`). + * 보여 주기만 한다(PLAN 77-5) — 종단 여부는 지금 추천 갈래가 정한다(`withProfile`). 종단 갈래 = + * 계획선은 지금 노드로 서버가 다시 낸 균형 종단(`/route/profile-preview` — [확인] 체인과 같은 셈). + * 예상노선 추적 = 지반선만(계획고 = 지반고 · `/route/elevations`). [확인]도 같은 규칙을 탄다 + * (`_Apply` 의 `balanced_profile`). * * 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게. * 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다. @@ -27,27 +28,26 @@ const STATION_M = 20; export interface ProfileStripParams { root: HTMLElement; projectId: string; - /** 추천 창 「종단 반영」과 한 벌 — 이 띠의 켬/끔 단추도 같은 값을 바꾼다. */ - profileMemory: { withProfile: boolean }; + /** 지금 추천 갈래가 종단을 반영하는가(예상노선 추적만 아님). */ + withProfile: () => boolean; /** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */ now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray; criteria: unknown }; } export interface ProfileStrip { - /** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용 · 켬/끔) — 다시 묻는다. */ + /** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용) — 다시 묻는다. */ refresh: () => void; /** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지 - * 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 켬/끔 값. */ + * 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 갈래 값. */ settle: () => Promise; } export function createProfileStrip(params: ProfileStripParams): ProfileStrip { - const { root, projectId, profileMemory } = params; - const toggle = root.querySelector("[data-strip-toggle]")!; + const { root, projectId } = params; const note = root.querySelector("[data-strip-note]")!; const graph = root.querySelector("[data-strip-graph]")!; let seq = 0; - /** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 켬/끔이면 다시 묻지 않는다. */ + /** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 갈래 종단이면 다시 묻지 않는다. */ let lastKey = ""; /** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */ let pending: Promise = Promise.resolve(); @@ -66,9 +66,7 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip { } function refresh(): void { - const on = profileMemory.withProfile; - toggle.setAttribute("aria-pressed", String(on)); - toggle.textContent = `종단 반영 ${on ? "켬" : "끔"}`; + const on = params.withProfile(); const { vertices, line, criteria } = params.now(); const key = JSON.stringify([on, vertices, criteria]); if (vertices.length < 2 || line.length < 2 || key === lastKey) return; @@ -84,21 +82,17 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip { ): Promise { const ticket = ++seq; root.classList.add("is-waiting"); - note.textContent = on ? "균형 종단을 셈하는 중…" : "지반선을 읽는 중…"; - const started = performance.now(); + note.textContent = "종단을 읽는 중…"; try { const profile = on ? parseBalance(await fetchProfilePreview(projectId, vertices, criteria)) : await groundOnly(line); if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다. if (!profile) throw new Error("균형 종단을 세우지 못했습니다."); - const seconds = ((performance.now() - started) / 1000).toFixed(1); const total = profile.stationsM[profile.stationsM.length - 1]; graph.innerHTML = profileSvg(profile, true); shownOn = on; - note.textContent = - `${on ? "계획선 = 균형 종단" : "지반선만(계획고 = 지반고)"} · ` + - `연장 ${total.toFixed(1)}m · 측점 ${profile.stationsM.length}개 · ${seconds}초`; + note.textContent = `${on ? "원지반선 · 계획 종단선" : "원지반선(계획고 = 지반고)"} · 연장 ${total.toFixed(1)}m`; } catch (error) { if (ticket !== seq) return; lastKey = ""; // 실패 — 다음 번에 다시 묻는다. @@ -110,17 +104,12 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip { } } - toggle.addEventListener("click", () => { - profileMemory.withProfile = !profileMemory.withProfile; - refresh(); - }); - return { refresh, settle: async () => { refresh(); await pending; - return shownOn ?? profileMemory.withProfile; + return shownOn ?? params.withProfile(); }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 9666692eb..1fa8d2e44 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -1,8 +1,9 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Recommend.ts - * 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2) — 알약 넷(예상노선 추적 · 토공 최소 · - * 기울기 순한 · 비중 계산) · 비중 슬라이더 셋 · 공통 제약 네 칸 · 미리 보기 지도 · 비교값 · - * 못 지킨 구간 · [적용] · [닫기]. + * 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 · + * 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 · + * 못 지킨 구간 · 오른쪽 위 [적용] · [닫기]. + * 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`). * * 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 편집 창의 노선은 * 건드리지 않는다 — [적용]을 눌러야 `onApply` 로 편집값에 실리고(되돌리기 한 걸음), [닫기]는 @@ -37,6 +38,7 @@ import { violationsLine, violationText, WEIGHT_KEYS, + withProfileOf, type CriteriaKey, type InitialChoice, type InitialCriteria, @@ -60,26 +62,14 @@ import "./B05_Profile_UI_Style_RouteRecommend.css"; type Vertex = [number, number]; -const PILLS: RouteInitialLogic[] = [ - "follow", - "gradeline", - "earthwork", - "gentle", - "safe", - "crossings", - "cost", - "weighted", -]; +const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const; -const PILL_TITLE: Record = { - follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다", - earthwork: "절토 · 성토가 가장 적은 노선", - gentle: "종단기울기가 가장 순한 노선", - gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) — 목표 기울기를 넣을 수 있음", - safe: "급한 옆경사 · 계류 가까운 곳을 피하는 노선", - crossings: "계류를 건너는 횟수가 가장 적은 노선", - cost: "토공 + 연장 + 계류 횡단을 섞은 공사비 어림(상대값)이 가장 낮은 노선", - weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선", +const PILL_TITLE: Record<(typeof PILLS)[number], string> = { + follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)", + gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단", + earthwork: "절토 · 성토가 가장 적은 노선 + 종단", + gentle: "종단기울기가 가장 낮은 노선 + 종단", + weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선 + 종단", }; const WEIGHT_NAME: Record = { @@ -132,8 +122,8 @@ export interface RecommendParams { weights: RouteInitialWeights; /** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */ weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks }; - /** 「종단 반영」 — 켬이 기본(지금 동작). 편집 창이 사는 동안만 남고 저장하지 않는다. */ - profileMemory: { withProfile: boolean }; + /** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */ + logic: RouteInitialLogic; planMinRadiusM: number; /** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */ expected: Vertex[]; @@ -147,12 +137,13 @@ export interface RecommendParams { ) => Promise; /** [적용] — 미리 본 노선을 편집 창에 놓는다. */ onApply: (choice: InitialChoice) => void; - /** 「종단 반영」을 바꿨다 — 편집 창 종단 띠가 다시 묻는다(PLAN 75-1). */ - onProfileChange?: () => void; } export function openRecommendModal(params: RecommendParams): void { - let logic: RouteInitialLogic = "follow"; + // 알약에 없는 옛 갈래(안전 우선 등)로 적용했던 노선이면 예상노선 추적에서 시작한다. + let logic: RouteInitialLogic = (PILLS as readonly string[]).includes(params.logic) + ? params.logic + : "follow"; const { locks } = params.weightMemory; let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) }; /** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */ @@ -171,11 +162,11 @@ export function openRecommendModal(params: RecommendParams): void { let map: ReturnType | null = null; openModal({ title: "노선 추천", - closeLabel: "✕", + closeLabel: "닫기", // 제목줄 단추는 mount 에서 [적용] · [닫기] 묶음으로 갈아 끼운다(PLAN 77-3) dialogClass: "b05-routerec", // 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다. backdropClass: "b05-routerec-backdrop", - // ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다. + // [닫기] · Esc · 바깥 누르기 모두 미리 보기를 버린다. beforeClose: () => { closed = true; window.clearInterval(ticker); @@ -186,30 +177,21 @@ export function openRecommendModal(params: RecommendParams): void { bodyEl = body; body.classList.add("b05-routerec__body"); body.innerHTML = ` -
-
- ${PILLS.map( - (key) => ``, - ).join("")} -
- +
+ + +
+
+ ${PILLS.map( + (key) => ``, + ).join("")}
-
- 예상노선(원본) - 창을 연 노선 - 미리 보기 -
-
휠 = 확대 · 축소   끌기 = 이동   ↺ ↻ = 회전
-
-
+
+
+ 예상노선(원본) + 창을 연 노선 + 미리 보기 +
+
휠 = 확대 · 축소   끌기 = 이동   ↺ ↻ = 회전
@@ -255,21 +245,22 @@ export function openRecommendModal(params: RecommendParams): void {
- -
${recommendHelpHtml()}`; const q = (sel: string): T => body.querySelector(sel)!; + // [적용] · [닫기]를 제목줄 오른쪽 위로 — 공용 겉틀의 닫기 단추와 갈아 끼운다(PLAN 77-3). + const actions = q(".b05-routerec__actions"); + const applyButton = q('[data-act="apply"]'); + const closeButton = q('[data-act="close"]'); + body.parentElement?.querySelector(".ui-modal__top > button")?.replaceWith(actions); map = createRecommendMap(q(".b05-routerec__mapwrap")); const pills = [...body.querySelectorAll("[data-logic]")]; const weightsBox = q(".b05-routerec__weights"); const rerun = q('[data-act="rerun"]'); - const applyButton = q('[data-act="apply"]'); const stateEl = q("[data-state]"); const line = (name: string): HTMLElement => q(`[data-line="${name}"]`); const criteriaInputs = body.querySelectorAll("[data-criteria]"); - const profileToggle = q("[data-with-profile]"); bindRecommendHelp(body); criteriaInputs.forEach((input) => { @@ -348,8 +339,6 @@ export function openRecommendModal(params: RecommendParams): void { input.disabled = busy; }); q("[data-target-row]").hidden = logic !== "gradeline"; - profileToggle.disabled = busy; - profileToggle.setAttribute("aria-pressed", String(params.profileMemory.withProfile)); rerun.hidden = !dirty; rerun.disabled = busy || invalid.size > 0; applyButton.disabled = busy || dirty || !preview || invalid.size > 0; @@ -398,7 +387,7 @@ export function openRecommendModal(params: RecommendParams): void { const mine = ++seq; const sentWeights = logic === "weighted" ? { ...weights } : null; const criteria = currentCriteria(); - const withProfile = params.profileMemory.withProfile; + const withProfile = withProfileOf(logic); busy = true; dirty = false; preview = null; @@ -408,7 +397,7 @@ export function openRecommendModal(params: RecommendParams): void { const tick = (): void => { const seconds = Math.round((Date.now() - started) / 1000); stateEl.textContent = `${label} 셈하는 중… ${seconds}초${ - withProfile ? " (후보마다 균형 종단을 셈합니다)" : " (종단 반영 끔 — 원지반만)" + withProfile ? " (후보마다 측점 횡단으로 종단을 셈합니다)" : " (종단 없음 — 원지반만)" }`; }; window.clearInterval(ticker); @@ -431,7 +420,7 @@ export function openRecommendModal(params: RecommendParams): void { } preview = choice; if (choice.balance) seen.set(label, choice.balance); - else seen.delete(label); // 종단 반영 끔 — 켜고 셈했던 같은 갈래 줄은 지움 + else seen.delete(label); // 종단 없는 갈래(예상노선 추적) stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`; } catch (error) { if (closed || mine !== seq) return; @@ -515,12 +504,6 @@ export function openRecommendModal(params: RecommendParams): void { render(); }); }); - profileToggle.addEventListener("click", () => { - params.profileMemory.withProfile = !params.profileMemory.withProfile; - dirty = preview !== null; - render(); - params.onProfileChange?.(); - }); rerun.addEventListener("click", () => void run()); applyButton.addEventListener("click", () => { if (!preview) return; @@ -528,13 +511,13 @@ export function openRecommendModal(params: RecommendParams): void { closed = true; close(); }); - q('[data-act="close"]').addEventListener("click", () => { + closeButton.addEventListener("click", () => { closed = true; close(); }); render(); - // 처음 고른 상태 = 예상노선 추적 — 열자마자 그 미리 보기를 받는다. + // 처음 고른 갈래(적용한 추천 · 없으면 예상노선 추적) — 열자마자 그 미리 보기를 받는다. void run(); }, }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index 473313265..5c101d209 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Recommend_Help.ts - * [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법. + * [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2 · 64-2 · 77) — 갈래 다섯 · 종단 반영 · 공통 제약 넷 · 결과 줄 읽는 법. * * 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을 * 바꾸면 여기 글도 같이 고친다. 갈래 설명은 **한 번에 한 갈래**(쪽) — 위 ◀ ▶ 단추와 「n / 전체」로 @@ -19,11 +19,13 @@ interface HelpEntry { when: string; /** 주의할 점. */ caution: string; - /** 「종단 반영」 켬 · 끔이 점수에 미치는 영향(토공 점수를 쓰는 갈래만). */ + /** 종단 반영이 이 갈래에 하는 일. */ profile?: string; } -const LOGIC_HELP: Record = { +type ShownLogic = "follow" | "gradeline" | "earthwork" | "gentle" | "weighted"; + +const LOGIC_HELP: Record = { follow: { summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선", how: [ @@ -33,6 +35,7 @@ const LOGIC_HELP: Record = { ], when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴", caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인", + profile: "없음 — 전처리 계획선처럼 계획고 = 지반고", }, gradeline: { summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선", @@ -54,8 +57,7 @@ const LOGIC_HELP: Record = { ], when: "토공량 · 사토 처리를 줄여야 할 때", caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것", - profile: - "켬 = 후보마다 균형 종단을 세워 절 + 성 + 반입 + 사토 ㎥ 로 비교 · 끔 = 종단 없이 원지반 절 + 성 어림으로 비교(더 빠름)", + profile: "후보마다 균형 종단을 세워 절 + 성 + 반입 + 사토 ㎥ 로 비교", }, gentle: { summary: "종단기울기가 가장 순한 노선", @@ -66,38 +68,6 @@ const LOGIC_HELP: Record = { when: "차량 주행 · 노면 침식이 걱정되는 노선", caution: "거리 비용이 작아 돌아가는 긴 노선이 나올 수 있음 — 연장을 확인", }, - safe: { - summary: "급한 옆경사 · 계류 가까운 곳을 피하는 노선", - how: [ - "옆경사 35°(약 70%) 이상 · 계류 이격 거리 안을 위험 구간으로 보고 피함", - "후보 중 위험 구간을 지나는 길이가 가장 짧은 것을 고름(같으면 평균 옆경사)", - "35° 기준 = 「35° 이상 전폭 절토」 기술 기준", - ], - when: "붕괴 · 유실 위험이 큰 급경사지 · 계곡 가까운 지형", - caution: - "계곡을 따라가는 지형에서는 위험 구간이 어디서나 길어 다른 갈래와 차이가 작음 — 계류 이격 값을 줄여 다시 셈해 볼 것", - }, - crossings: { - summary: "계류를 건너는 횟수가 가장 적은 노선", - how: [ - "계류 위를 지나는 칸에 벌점을 매겨 길을 찾음", - "후보 중 계류 횡단 수가 가장 적은 것을 고름(같으면 계류에서 더 먼 쪽)", - "10m 안에서 여러 번 건너는 것은 한 번으로 셈", - ], - when: "배수관 · 세월교 같은 횡단 구조물을 줄이고 싶을 때", - caution: "소하천 · 지방하천 · 국가하천만 셈 — 작은 세류는 빠짐", - }, - cost: { - summary: "토공 · 연장 · 계류 횡단을 한 숫자로 묶은 공사비 어림이 가장 낮은 노선", - how: [ - "공사비 어림 = 흙 1㎥ × 1 + 연장 1m × 2 + 계류 횡단 1곳 × 400(흙 ㎥ 상당)", - "같은 비율을 1m 당 값으로 바꿔 길을 찾고, 후보 중 어림값이 가장 낮은 것을 고름", - ], - when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때", - caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것", - profile: - "켬 = 토공 몫을 균형 종단 토량으로 셈 · 끔 = 원지반 절 + 성 어림으로 셈(종단을 세우지 않음)", - }, weighted: { summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선", how: [ @@ -113,7 +83,7 @@ const LOGIC_HELP: Record = { const CRITERIA_HELP: Array<{ name: string; text: string }> = [ { name: "종단 반영", - text: "켬(기본) = 후보마다 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(종단 그림 · 덮개 줄은 안 나옴). 켬으로 [적용]한 노선을 고치지 않고 [확인]하면 그 균형 종단이 첫 계획선이 됨 · 끔 · 손으로 고친 노선은 계획고 = 지반고", + text: "예상노선 추적을 뺀 네 갈래는 늘 반영. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [확인] 뒤 본 설계 값과 차이가 남. [확인]하면 편집 창 아래 띠에 보인 종단이 그대로 첫 계획선이 됨", }, { name: "종단기울기 상한(%)", @@ -152,19 +122,10 @@ const LINES_HELP: Array<{ name: string; text: string }> = [ }, ]; -const LOGIC_ORDER: RouteInitialLogic[] = [ - "follow", - "gradeline", - "earthwork", - "gentle", - "safe", - "crossings", - "cost", - "weighted", -]; +const LOGIC_ORDER: ShownLogic[] = ["follow", "gradeline", "earthwork", "gentle", "weighted"]; /** 갈래 설명 쪽 하나 — 고른 갈래(`is-on`)는 테두리로 알린다. */ -function logicPage(key: RouteInitialLogic): string { +function logicPage(key: ShownLogic): string { const entry = LOGIC_HELP[key]; const profile = entry.profile ? `

종단 반영 ${entry.profile}

` : ""; return `