From c7770b75296003422027366858740f44db9b2b17 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:59:48 +0900 Subject: [PATCH 01/10] =?UTF-8?q?fix(=ED=9A=A1=EB=8B=A8):=20=EC=98=B9?= =?UTF-8?q?=EB=B2=BD=20=EC=9D=98=EB=AC=B4=20=ED=8C=90=EC=A0=95=EC=9D=B4=20?= =?UTF-8?q?=EC=86=8C=EB=8B=A8=EC=9D=84=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B2=8C=20=ED=95=A8=20=E2=80=94=20=EB=B6=80=ED=98=B8?= =?UTF-8?q?=C2=B7=EC=AA=BC=EA=B0=9C=EC=A7=90=20=EB=91=90=20=EA=B3=B3=20(?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=9C=203-9=20=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋으로는 화면 값이 안 바뀌어 실화면에서 두 가지를 더 찾았음. ① **TS 결과에 소단을 안 실었음** — 파이썬만 되싣고 브라우저 판을 빠뜨렸음. 판정(`fillSlopeLengths`)은 브라우저에서 도는데 `design.berm` 이 없어 종전 식으로 갔음. ② **소단을 폭으로 찾으면 못 찾음** — 설계선 꼭짓점에는 지반 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 **두 도막으로 쪼개짐**(실측: 평탄부 0개). 게다가 설계선은 오프셋 오름차순이라 **우측(음수) 성토면은 같은 소단이 `-기울기`로 나와** 부호를 그대로 대면 역시 못 찾음. ⇒ **기울기로 가름.** 소단은 2°(0.035), 성토는 1:1.2~2.0(0.5~0.83)이라 성토 기울기의 절반만 잡아도 확실히 갈림. 쪼개져도 부호가 뒤집혀도 걸림. 실화면 확인(8001·5174, `stale:false`) — 측점 2820.0m, 성토사면이 긴 자리: · 소단 없음 → **≥32.54m** (옹벽·석축 의무 대상) · 폭 0.5m·3m마다 놓음 → **≥3.00m** (5m 이내 — 의무 해소). 3.00m 은 소단 간격 그대로임 · 옆 측점(2840.0m, 안 놓음) → **≥32.54m 그대로** **소단 없는 측점은 값이 안 바뀜 — 272측점 전수 대조, 달라진 곳 0.** 구조로도 보장됨(`design.berm` 이 없으면 종전 식을 그대로 탐). 전체 542 passed · 18 skipped. TS 타입 검사 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Fit.ts | 15 ++++++++++----- common_util/common_util_cross_design.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index af03913e..b5d09f6c 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -134,7 +134,12 @@ function longestFillRun( if (!berm) return Math.abs(endOffset - startOffset) * slant; const low = Math.min(startOffset, endOffset); const high = Math.max(startOffset, endOffset); - const bermRise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m; + // 소단인지는 **기울기**로 가른다 — 폭으로 찾으면 못 찾는다. 설계선 꼭짓점에는 지반 + // 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 두 도막으로 **쪼개지기** 때문이다 + // (2026-09-07 실측: 폭으로 찾으니 평탄부 0개). 소단 기울기는 2°(0.035)이고 성토 기울기는 + // 1:1.2~2.0(0.5~0.83)이라 절반만 잡아도 둘이 확실히 갈린다. + const fillGradient = 1 / Math.max(design.fill_slope_ratio, 1e-6); + const bermMaxGradient = fillGradient * 0.5; let longest = 0; let current = 0; const line = design.design_line; @@ -145,10 +150,10 @@ function longestFillRun( const to = Math.min(Math.max(a.offset_m, b.offset_m), high); if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다 const run = Math.abs(b.offset_m - a.offset_m); - const rise = b.elevation_m - a.elevation_m; - const isBerm = Math.abs(run - berm.width_m) < 1e-6 && Math.abs(rise - bermRise) < 1e-6; - if (isBerm) { - longest = Math.max(longest, current); + if (run <= 1e-9) continue; + const gradient = Math.abs(b.elevation_m - a.elevation_m) / run; + if (gradient < bermMaxGradient) { + longest = Math.max(longest, current); // 소단에서 도막이 끊긴다 current = 0; continue; } diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 83f060b0..a3c5c58a 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -134,6 +134,8 @@ export interface CrossDesignResult { design_line: CrossDesignEdge[]; /** 절토 사면 경사 구간(소단 제외) — 법정 경사 검사가 읽는다. 짝: `cut_slope_segments`. */ cut_slope_segments: CutSlopeSegment[]; + /** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */ + berm?: { width_m: number; interval_m: number; slope_deg: number }; surface_drop_m?: number; pavement_thickness_m?: number; rock_boundary_offset_m?: number; @@ -440,6 +442,15 @@ export function computeCrossDesign( cut_slope_segments: geometry.cutSlopeSegments(), }; if (drop > 0) result.surface_drop_m = round4(drop); + if (options.berm) { + // 소단 제원을 설계에 되싣는다 — 짝 파이썬과 같은 까닭이고, **옹벽 의무 판정 + // (`fillSlopeLengths`)이 이 값을 보고** 사면을 도막으로 끊어 잰다(계획서 3-9). + result.berm = { + width_m: round4(options.berm.widthM), + interval_m: round4(options.berm.intervalM), + slope_deg: round4(options.berm.slopeDeg), + }; + } if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m); if (presetKey === "rock" && rockBoundaryOffsetM !== null) { result.rock_boundary_offset_m = round4(rockBoundaryOffsetM); From 7833b9e677d01b6163f292e7f8c1aac5c4756014 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 17:29:50 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(=EA=B5=AC=EC=A1=B0=EB=AC=BC):=20?= =?UTF-8?q?=EC=86=8C=EB=8B=A8=EC=9D=84=20=E3=80=8C=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=B0=EC=B9=98=E3=80=8D=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80=20=E2=80=94=20C=EA=B5=B0=20=EC=82=AC=EB=A9=B4?= =?UTF-8?q?=EC=95=88=EC=A0=95=20=EC=8B=A0=EC=84=A4=20(=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=84=9C=203-9,=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=AC?= =?UTF-8?q?=ED=99=95=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07) — 소단은 좌측 별도 폼이 아니라 **다른 옹벽·기슭막이와 같은 자리** (구조물 배치)에서 놓는 것으로 바뀜. 기하는 그대로 두고 **입구만 옮겼음.** · 레지스트리에 **C군(사면안정) 「소단」** 신설. 옵션은 다른 C군 구간형과 같은 꼴 — 길이·기준측점 전·후 + 폭·간격(사면길이)·안쪽 기울기. · ⚠ **소단은 C군 구간형이지만 벽이 아님** — 기슭막이 제원 자리에 얹히지 않게 파이썬·TS 양쪽 벽 필터에서 뺐음. 안 뺐으면 소단을 놓는 순간 「독립 기슭막이」로 그려졌을 것임. · 구조물 목록이 바뀌면 소단 구간을 세션 사본(`berm`)으로 펴고, 달라졌을 때만 재계산함. 계산 계통(브라우저·서버·`USER_TOUCHED_KEYS`·옹벽 의무 판정)은 **한 줄도 안 바꿨음**. · ④에서 만든 좌측 별도 「소단」 패널은 걷어냈음(모듈 삭제). **안쪽 기울기 기본값 2° → 0°** (사용자 재확정). 기울이는 것은 실무이나 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받음. 주석·지식DB 방침도 그에 맞춰 고침. **소단측구**(B군 종단배수)도 같은 꼴로 옵션을 붙였음 — 소단 위에 놓이는 시설이라 폭·간격이 소단을 따르고, 기울기만 **사면 쪽**(소단과 반대). 소단이 실제로 서면서 `PENDING_TYPE_IDS` 빈 칸이 풀려 연장 수량이 다른 구조물과 같은 길로 나옴. 자체검증 — 「근거 없는 기본값 금지」 시험에 두 타입 12개 기본값을 근거와 함께 등재. 소단측구 연장 시험을 「빈 칸」에서 「연장 100m」로 뒤집음. 전체 544 passed · 18 skipped. TS 타입 검사·ruff 통과. 화면 확인은 다음 단계. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Structure_Types.json | 126 +++++++++++- .../B06_Section_Engine_Structures_Wall.py | 8 + B06_Section/B06_Section_Section_Store.ts | 6 +- B06_Section/B06_Section_UI_Berm_Panel.ts | 185 ------------------ B06_Section/B06_Section_UI_Page.ts | 33 ++-- B06_Section/B06_Section_UI_Page_Persist.ts | 51 +++++ common_util/common_util_cross_berm.py | 7 +- common_util/common_util_cross_berm.ts | 5 +- common_util/common_util_structure_lengths.py | 12 +- 9 files changed, 224 insertions(+), 209 deletions(-) delete mode 100644 B06_Section/B06_Section_UI_Berm_Panel.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index f6862a92..3abed2df 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -505,10 +505,65 @@ "placement": "interval", "style": { "color": "#2ec5dc", - "abbr": "소단" + "abbr": "소단구" }, "drawing_views": ["cross_section", "quantity"], - "options": [] + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] }, { "type_id": "chute", @@ -910,6 +965,73 @@ } ] }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": ["profile", "cross_section", "quantity"], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, { "type_id": "erosion_check", "group": "D", diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index e4f9c143..d4b3a70d 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -39,6 +39,10 @@ _FORM_BY_TYPE = { } +# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). +BERM_TYPE_ID = "berm" + + def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: """구조물 정본에서 C군 벽(구간형) 목록을 읽는다. 실패하면 빈 목록.""" try: @@ -48,6 +52,10 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: definition = types.get(structure.type_id) if definition is None or definition.group != "C" or definition.placement != "interval": continue + # 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + # 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + if structure.type_id == BERM_TYPE_ID: + continue start, end = structure.start_m, structure.end_m if start is None or end is None: continue diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index b82cef1a..38e54977 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -56,7 +56,11 @@ async function withDraftWalls( const types = await fetchStructureTypes().catch(() => []); const names = new Map( types - .filter((type) => type.group === "C" && type.placement === "interval") + // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 + // 기슭막이 제원 자리에 얹으면 안 된다(계획서 3-9). + .filter( + (type) => type.group === "C" && type.placement === "interval" && type.type_id !== "berm", + ) .map((type) => [type.type_id, type.name] as const), ); if (!names.size) return detail; diff --git a/B06_Section/B06_Section_UI_Berm_Panel.ts b/B06_Section/B06_Section_UI_Berm_Panel.ts deleted file mode 100644 index 4ee00280..00000000 --- a/B06_Section/B06_Section_UI_Berm_Panel.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Berm_Panel.ts - * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). - * - * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 - * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 - * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 - * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. - * - * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, - * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 - * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). - * ========================================================================== */ - -import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import { - BERM_DEFAULT_INTERVAL_M, - BERM_DEFAULT_SLOPE_DEG, - BERM_DEFAULT_WIDTH_M, -} from "@util/common_util_cross_berm"; -import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; -import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; -import { buildGroup } from "./B06_Section_UI_Page_Common"; -import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; - -/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ -const DEFAULT_BEFORE_M = 5; -const DEFAULT_AFTER_M = 5; - -export interface BermPanelDeps { - /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ - getInterval: () => number; - /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ - target: () => { projectId: string; routeId: number } | null; - /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ - onChange: () => void; -} - -export interface BermPanel { - root: HTMLElement; - /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ - reload: () => void; -} - -function numberField( - label: string, - value: number, - step: string, -): ReturnType { - const field = createInputField({ label, type: "number" }); - field.input.step = step; - field.input.min = "0"; - field.input.value = String(value); - return field; -} - -export function createBermPanel(deps: BermPanelDeps): BermPanel { - const root = buildGroup("소단"); - - const anchor = stationFields("기준 측점", () => deps.getInterval()); - const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); - const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); - const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); - const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); - const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); - - const spanRow = document.createElement("div"); - spanRow.className = "b05-structure__grid"; - spanRow.append(beforeField.root, afterField.root); - - const specRow = document.createElement("div"); - specRow.className = "b05-structure__grid"; - specRow.append(widthField.root, intervalField.root); - - const slopeRow = document.createElement("div"); - slopeRow.className = "b05-structure__grid"; - slopeRow.append(slopeField.root); - - const list = document.createElement("ul"); - list.className = "b05-route__irregular-list"; - - let selected = -1; - - const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); - const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); - removeButton.classList.add("is-danger"); - removeButton.disabled = true; - const actions = document.createElement("div"); - actions.className = "b06-profile__field-row"; - actions.append(addButton, removeButton); - - root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); - - function spans(): BermSpan[] { - const target = deps.target(); - return target ? readBermSpans(target.projectId, target.routeId) : []; - } - - function save(next: BermSpan[]): void { - const target = deps.target(); - if (!target) return; - writeBermSpans(target.projectId, target.routeId, next); - render(); - deps.onChange(); - } - - function readNumber(field: { input: HTMLInputElement }, fallback: number): number { - const parsed = Number(field.input.value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; - } - - function add(): void { - const interval = deps.getInterval(); - const chainage = anchor.read(interval, true); - if (chainage === null) { - showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); - return; - } - const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); - const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); - if (width <= 0 || gap <= 0) { - showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); - return; - } - const before = readNumber(beforeField, DEFAULT_BEFORE_M); - const after = readNumber(afterField, DEFAULT_AFTER_M); - save([ - ...spans(), - { - start_m: Math.max(chainage - before, 0), - end_m: chainage + after, - width_m: width, - interval_m: gap, - slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), - }, - ]); - selected = -1; - } - - function remove(): void { - if (selected < 0) return; - const next = spans().filter((_span, index) => index !== selected); - selected = -1; - save(next); - } - - function render(): void { - const interval = deps.getInterval(); - const current = spans(); - list.replaceChildren(); - removeButton.disabled = selected < 0 || selected >= current.length; - if (!current.length) { - const empty = document.createElement("li"); - empty.className = "b05-route__irregular-empty"; - empty.textContent = "놓은 소단이 없습니다."; - list.append(empty); - return; - } - current.forEach((span, index) => { - const item = document.createElement("li"); - item.className = "b05-route__irregular-item"; - item.classList.toggle("is-selected", index === selected); - const where = document.createElement("strong"); - where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; - const what = document.createElement("span"); - what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; - item.append(where, what); - item.addEventListener("click", () => { - selected = index === selected ? -1 : index; - render(); - }); - list.append(item); - }); - } - - render(); - - return { - root, - reload(): void { - selected = -1; - render(); - }, - }; -} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 890019f0..43675dbd 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -24,11 +24,13 @@ import { } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; -import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { + bermSpansFromStructures, confirmCurrentSections, createRockBoundaryStore, + readBermSpans, saveCurrentSections, + writeBermSpans, type SectionPersistContext, } from "./B06_Section_UI_Page_Persist"; import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; @@ -150,7 +152,10 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { reveal: () => layout.setOptionsOpen(true), // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. - onMarks: (structures, types) => structureMarksSink?.(structures, types), + onMarks: (structures, types) => { + structureMarksSink?.(structures, types); + syncBermSpans(structures); + }, // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). onStructuresChanged: () => void refreshDetailForStructures(), @@ -207,14 +212,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - // 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한). - const bermPanel = createBermPanel({ - getInterval: () => stationInterval ?? 20, - target: () => - projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, - onChange: () => void reconcileStaleDesigns({ force: true }), - }); - leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock); + leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -337,6 +335,20 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { } } + /** + * 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다. + * + * 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만 + * 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다. + */ + function syncBermSpans(structures: ReadonlyArray): void { + if (!projectId || currentRouteId === null) return; + const next = bermSpansFromStructures(structures); + if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; + writeBermSpans(projectId, currentRouteId, next); + void reconcileStaleDesigns({ force: true }); + } + /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; @@ -726,7 +738,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); - bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다. void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1f66dfc2..b5da86b4 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -22,6 +22,11 @@ import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structure import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state"; +import { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -86,6 +91,52 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp writeState("berm", spans, projectId, routeId); } +/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ +export const BERM_TYPE_ID = "berm"; + +/** + * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 + * (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리). + * + * 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을 + * 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은 + * 그 사본만 본다. + */ +export function bermSpansFromStructures( + structures: ReadonlyArray<{ + type_id: string; + start_m?: number | null; + end_m?: number | null; + chainage_m?: number | null; + options?: Record; + }>, +): BermSpan[] { + const spans: BermSpan[] = []; + for (const item of structures) { + if (item.type_id !== BERM_TYPE_ID) continue; + const anchor = item.chainage_m ?? item.start_m ?? null; + const start = item.start_m ?? anchor; + const end = item.end_m ?? anchor; + if (start === null || end === null) continue; + const options = item.options ?? {}; + const number = (key: string, fallback: number): number => { + const parsed = Number(options[key]); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + }; + const width = number("width_m", BERM_DEFAULT_WIDTH_M); + const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다 + spans.push({ + start_m: Math.min(start, end), + end_m: Math.max(start, end), + width_m: width, + interval_m: interval, + slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG), + }); + } + return spans; +} + /** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { const found = spans.find( diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index ae79cb0b..892cb56d 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -15,8 +15,9 @@ 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. 넓고 촘촘하면 설계 1:1 이 실제로는 1:1.7 로 서서 다른 비탈이 된다. -· 기울기 2°는 **2026-09-07 사용자 확정**이다 — 물이 고이지 않게 안쪽으로 기울이는 실무이고 - **법령·교본 근거가 없다**. 그래서 지식DB 에는 적지 않는다(사용자 지시). +· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다). + 기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다. + 그래서 지식DB 에도 적지 않는다(사용자 지시). """ import math @@ -25,7 +26,7 @@ from typing import Callable, NamedTuple # 소단 기본값 — 근거는 위 모듈 설명. BERM_DEFAULT_WIDTH_M = 0.5 BERM_DEFAULT_INTERVAL_M = 3.0 -BERM_DEFAULT_SLOPE_DEG = 2.0 +BERM_DEFAULT_SLOPE_DEG = 0.0 # 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다. _STEP_M = 0.05 diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 5d97e807..7e88897b 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -13,13 +13,14 @@ * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): * 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**. - * · 기울기 2°는 **2026-09-07 사용자 확정**이고 법령·교본 근거가 없다 — 지식DB 에 적지 않는다. + * · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나 + * 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다. * ========================================================================== */ /** 소단 기본값 — 근거는 위 설명. */ export const BERM_DEFAULT_WIDTH_M = 0.5; export const BERM_DEFAULT_INTERVAL_M = 3.0; -export const BERM_DEFAULT_SLOPE_DEG = 2.0; +export const BERM_DEFAULT_SLOPE_DEG = 0.0; /** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */ const STEP_M = 0.05; diff --git a/common_util/common_util_structure_lengths.py b/common_util/common_util_structure_lengths.py index 092ff85e..43c617f8 100644 --- a/common_util/common_util_structure_lengths.py +++ b/common_util/common_util_structure_lengths.py @@ -16,9 +16,11 @@ from typing import Any from B05_Profile.B05_Profile_Structures_Repository import load_structures from B05_Profile.B05_Profile_Structures_Schema import structure_type_map -# 아직 셈하지 않는 타입 — 계획서 3-9(소단 만들기)가 끝난 뒤에 채운다. 지금 설계에는 -# 소단(berm)이 없어 이 시설이 설 자리 자체가 없다(2026-09-07 조사·사용자 확정). -PENDING_TYPE_IDS = frozenset({"ditch_berm"}) +# 아직 셈하지 않는 타입 — 지금은 없다. +# +# 소단측구(`ditch_berm`)는 계획서 3-9 로 **소단이 실제로 서면서 빈 칸이 풀렸다** +# (2026-09-07). 이제 다른 구조물과 같은 길로 연장이 나온다. +PENDING_TYPE_IDS: frozenset[str] = frozenset() def _merge(spans: list[tuple[float, float]]) -> float: @@ -48,11 +50,11 @@ def _merge(spans: list[tuple[float, float]]) -> float: def structure_lengths(project_root: str | Path) -> list[dict[str, Any]]: """구간형 구조물의 시설별 연장을 돌려준다 (기준점 순, 종류별 한 줄). - 빼는 것 셋 — + 빼는 것 둘 — · `managed_by` 타입(배관 등): 구조물 정본이 아니라 관 지점 정본 소관이다. · `design_owner` 타입(측구 = 횡단 설계): 횡단이 이미 터파기 단면적까지 셈하므로 여기서 또 세면 **같은 것을 두 번 계상**한다(2026-09-07 사용자 확정). - · `PENDING_TYPE_IDS`(소단측구): 놓일 소단이 아직 없다. + (`PENDING_TYPE_IDS` 는 지금 비어 있다 — 소단측구가 3-9 로 풀렸다.) 시작·종료는 늘 있다 — 구간형은 스키마가 둘 다 없으면 저장을 막는다 (`StructureInstance.validate_placement_fields`). From 6b9c115baa8f5bf07d385f41fd9efc90e5cdaec1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 17:52:06 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(B06):=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=EB=A5=BC=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EB=84=A3=EA=B2=8C=20=E2=80=94=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EC=95=94=20=EC=A0=88=ED=86=A0=EA=B0=81(=C2=B0),=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=8A=94=20=ED=91=9C=EC=A4=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07): 「대신 경고부분은 삭제해주고 대신 각도를 사용자가 넣을수 있게 반영. 전체 공통으로 변경하는 경우에는 기본값 지정으로 하면 되지만 횡단도 하나만 변경하는 폼은 가져야함 / 개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함」 **화면** — 암 측점 카드 아래에 「암 절토 68.2° ↺」 칸이 섬. 값을 넣으면 그 측점만 사면이 새 경사로 다시 그려지고 절토량도 따라 바뀜. ↺ 는 표준값으로 되돌림. 전체를 바꾸는 자리는 종전대로 좌측 [표준 횡단면 설정]임. **계산에 넣는 자리는 한 곳씩** — 파이썬 `compute_cross_design(cut_slope_ratio=…)` · TS `computeCrossDesign({cutSlopeRatio})` 의 **그룹을 만든 바로 뒤**에서 경사비만 갈아 끼움. 부르는 쪽 9곳에서 표준값을 측점마다 복제하는 방식은 안 씀(한 곳만 빠져도 값이 조용히 사라지는 실패군). 기하·소단 코드는 한 줄도 안 건드림 — 25 확인대로 소단이 그 값을 읽어 서므로 **위치·개수가 새 경사를 저절로 따라옴**. ⚠ **경사는 나르는 값이 아니라 기하 입력임** — 계산 뒤에 키만 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 됨(소단에서 겪은 자리). 그래서 재계산 세 경로(포장 강제·세월교 하강· 선형 재계산)와 브라우저 재계산·서버 프리뷰 **모두 계산 인자로** 넘김. **되돌리기는 0 을 남김** — 세션에서 지우기만 하면 정본에 남은 옛 사용자 값이 되살아나 표준으로 못 돌아감. 0 = 「표준값을 씀」. 값의 길 — 세션 `cutslope`(등록표 한 줄) → 카드 입력 → [저장]·[확정]에서 `cross_patches` (`design.cut_slope_ratio_user`) → 정본. `USER_TOUCHED_KEYS` 양쪽에 넣어 **표준을 바꿔도 개별로 고친 측점은 그대로** 둠(사용자 원문 끝줄). 시험 — 새 7건(넣은 경사가 실제로 그려짐 · 무릎 위 토사 경사는 그대로 · 0 은 되돌림 · 재계산에도 남음 · **계산 전에 넘어감** · 각도↔경사비 · 칸은 암 측점에만), 거울 시험에 「암 절토 경사를 측점에서 바꿈」 한 갈래 추가, 7-3 회귀 한 줄 추가. 전체 **483 passed · 17 skipped**. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_page_state.ts | 4 + B06_Section/B06_Section_Api_Fetch.ts | 3 + B06_Section/B06_Section_Api_Types.ts | 7 ++ B06_Section/B06_Section_Cross_Refresh.ts | 35 ++++++ B06_Section/B06_Section_Engine_Design.py | 8 ++ B06_Section/B06_Section_Router.py | 4 + B06_Section/B06_Section_Router_Design.py | 30 +++++ B06_Section/B06_Section_Schema.py | 10 ++ .../B06_Section_UI_Cross_Card_Chrome.ts | 11 +- B06_Section/B06_Section_UI_Cross_CutSlope.ts | 114 ++++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 5 +- B06_Section/B06_Section_UI_Page.ts | 15 +++ B06_Section/B06_Section_UI_Page_Patches.ts | 6 + B06_Section/B06_Section_UI_Page_Persist.ts | 91 ++++++++++++++ B06_Section/B06_Section_UI_Section_View.ts | 4 + B06_Section/B06_Section_UI_Style_Cross.css | 35 ++++++ common_util/common_util_cross_design.ts | 12 +- 17 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_CutSlope.ts diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index fd30b73b..9c05c761 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -109,6 +109,10 @@ export const STATE_REGISTRY = { culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` }, /** 암 경계선 오프셋(측점별). */ rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` }, + /** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07). + * 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야 + * 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */ + cutslope: { bucket: "draft", scope: "route" }, /** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9. * 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을 * 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */ diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 26e84d89..e427bf2d 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -255,6 +255,8 @@ export async function previewCrossDesigns( rockBoundaryOffsets?: Record; /** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */ berms?: Record; + /** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */ + cutSlopeRatios?: Record; }, ): Promise { return requestJson( @@ -267,6 +269,7 @@ export async function previewCrossDesigns( full_designs: options?.fullDesigns ?? false, rock_boundary_offsets: options?.rockBoundaryOffsets ?? null, berms: options?.berms ?? null, + cut_slope_ratios: options?.cutSlopeRatios ?? null, }), }, ); diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 42dacd1b..c902c575 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -461,6 +461,9 @@ export interface CrossDesign { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06). 확정 시 병합되며 세션 값이 우선이다. */ display_half_width_m?: number; + /** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07). + * 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */ + cut_slope_ratio_user?: number; } export interface CrossDesignResponse { @@ -482,6 +485,8 @@ export interface CrossDesignRequest { rock_boundary_offset_m?: number | null; /** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */ two_stage_slope?: boolean; + /** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */ + cut_slope_ratio?: number | null; /** 측구 생성 여부. null/미지정=자동 판정, true/false=수동 override. */ ditch_enabled?: boolean | null; /** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */ @@ -494,6 +499,8 @@ export interface CrossSectionPatch { rock_boundary_offset_m?: number; /** 측점 개별 표시 반폭(m, 2026-08-06 사용자 지시). */ display_half_width_m?: number; + /** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */ + cut_slope_ratio_user?: number; inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; basin_adjust?: { innerWidthM: number; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 6509d231..09951ca5 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -42,6 +42,7 @@ import { toAlignmentBase, } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; +import { readState } from "../A00_Common/b_page_state"; /** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */ export interface CrossRefreshEdits { @@ -83,6 +84,8 @@ export const USER_TOUCHED_KEYS = [ "revet_follow_grade", // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", + // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. + "cut_slope_ratio_user", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -154,6 +157,23 @@ function bermPayload( return out; } +/** + * 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다. + * + * **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면 + * 저장분에 남은 옛 사용자 값이 되살아난다. + */ +function readCutSlopeRatios(projectId: string, routeId: number): Map { + const out = new Map(); + const raw = readState>("cutslope", projectId, routeId); + if (!raw) return out; + for (const [chainage, ratio] of Object.entries(raw)) { + const at = Number(chainage); + if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); + } + return out; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -184,6 +204,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockOffsets = readRockOffsets(projectId, input.routeId); const rockDefault = readRockBoundaryDefault(projectId); + // 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로 + // 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07). + const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). const bermSpans = readBermSpans(projectId, input.routeId); @@ -195,6 +218,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { }; // 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다 // (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]). + /** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */ + const cutSlopeAt = (chainageM: number, design: Record): number | null => { + const session = cutSlopeRatios.get(rockKey(chainageM)); + if (session !== undefined) return session > 0 ? session : null; + const stored = design.cut_slope_ratio_user; + return typeof stored === "number" && stored > 0 ? stored : null; + }; const choices = crossDesignChoices(projectId, input.routeId); const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100); @@ -237,6 +267,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { standard, berm: bermAt(section.chainage_m), rockBoundaryOffsetM, + cutSlopeRatio: cutSlopeAt(section.chainage_m, design), twoStageSlope: choice?.two_stage_slope ?? (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), @@ -276,6 +307,10 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), berms: bermPayload(projectId, routeId, detail), + // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 + // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). + cutSlopeRatios: + readState>("cutslope", projectId, routeId) ?? undefined, }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 7c7a86a7..be090efc 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -545,6 +545,7 @@ def compute_cross_design( standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, two_stage_slope: bool = True, + cut_slope_ratio: float | None = None, ditch_enabled: bool | None = None, surface_drop_m: float = 0.0, plan_radius_m: float | None = None, @@ -561,6 +562,8 @@ def compute_cross_design( standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며 + None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시). plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 입력이며, None·45m 이상이면 확폭이 없다. curve_outer_side: 곡선 **바깥쪽**("left"/"right"). 확폭은 그쪽으로만 붙는다 @@ -584,6 +587,11 @@ def compute_cross_design( if ditch_type == "l_type" and preset_key != "rock": raise ValueError("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다.") group = _resolve_group(preset_key, standard) + # 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + # ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 결과에 값만 + # 베껴 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + if cut_slope_ratio is not None and float(cut_slope_ratio) > 0: + group = {**group, "cut_slope_ratio": float(cut_slope_ratio)} paved_group = _resolve_group("paved", standard) # 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. cross_slope_pct = paved_group["cross_slope_pct"] if paved else group["cross_slope_pct"] diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 006ad692..1c445cd2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -44,6 +44,7 @@ from B06_Section.B06_Section_Router_Design import ( ford_drop_at, ford_surface_drops, stored_berm, + stored_cut_slope, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -555,6 +556,7 @@ async def preview_cross_designs( request.rock_boundary_offsets, project_root, request.berms, + request.cut_slope_ratios, ) await asyncio.to_thread(rebuild) @@ -636,6 +638,8 @@ async def compute_cross_section_design( standard=request.standard_cross_section, rock_boundary_offset_m=request.rock_boundary_offset_m, two_stage_slope=request.two_stage_slope, + # 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07). + cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}), ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), berm=stored_berm(stored_design or {}), diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index f94d15e0..5f1aaf8a 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -140,6 +140,9 @@ USER_TOUCHED_KEYS = ( "revet_follow_grade", # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). "berm", + # 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다 + # (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). + "cut_slope_ratio_user", ) @@ -193,6 +196,7 @@ def enforce_pavement_ranges( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=stored_berm(design), @@ -245,6 +249,7 @@ def enforce_ford_surface_drops( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, berm=stored_berm(design), @@ -346,6 +351,18 @@ def attach_default_designs( continue +def stored_cut_slope(stored: dict[str, Any]) -> float | None: + """저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None. + + ⚠ 소단과 같은 성격이다 — **경사는 나르는 값이 아니라 기하 입력**이라 계산에 넣어야 한다. + 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛 경사로 나오고 값만 새것이 되어 어긋난다. + """ + value = stored.get("cut_slope_ratio_user") + if isinstance(value, (int, float)) and float(value) > 0: + return float(value) + return None + + def stored_berm(stored: dict[str, Any]) -> BermSpec | None: """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" spec = stored.get("berm") @@ -369,6 +386,7 @@ def recompute_designs_for_alignment( rock_boundary_offsets: dict[str, float] | None = None, project_root: Path | None = None, berms: dict[str, dict[str, float]] | None = None, + cut_slope_ratios: dict[str, float] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -378,6 +396,13 @@ def recompute_designs_for_alignment( round(float(record["chainage_m"]), 3): (record.get("design") or {}) for record in stored_designs } + # 측점별 암 절토 경사(2026-09-07) — 0 은 「표준값을 씀」이라 저장분을 덮어 지운다. + session_cut_slopes: dict[float, float] = {} + for raw_key, ratio in (cut_slope_ratios or {}).items(): + try: + session_cut_slopes[round(float(raw_key), 3)] = float(ratio) + except (TypeError, ValueError): + continue session_offsets: dict[float, float] = {} for raw_key, offset in (rock_boundary_offsets or {}).items(): try: @@ -415,6 +440,11 @@ def recompute_designs_for_alignment( stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), two_stage_slope=bool(stored.get("two_stage_slope", True)), + cut_slope_ratio=( + (session_cut_slopes[key] or None) + if key in session_cut_slopes + else stored_cut_slope(stored) + ), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), berm=session_berms.get(key) or stored_berm(stored), diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 9b8dac61..8cf4525f 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -52,6 +52,9 @@ class CrossDesignRequest(BaseModel): rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. two_stage_slope: bool = True + # 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # None 이면 표준 횡단면 설정값을 쓴다. + cut_slope_ratio: float | None = Field(default=None, gt=0) # 측구 생성 여부. None=자동 판정(측구측 절토면만 생성), True/False=수동 override. ditch_enabled: bool | None = None # B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 기본값 순. @@ -136,6 +139,10 @@ class CrossSectionPatch(BaseModel): rock_boundary_offset_m: float | None = None # 측점별 표시 반폭(m) — 카드 개별 조절값. 전역 반폭과 다를 때만 실린다(2026-08-06). display_half_width_m: float | None = Field(default=None, gt=0) + # 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). + # **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야 + # 정본에 남은 옛 사용자 값이 지워진다. + cut_slope_ratio_user: float | None = Field(default=None, ge=0) inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None basin_adjust: BasinAdjustPatch | None = None # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). @@ -302,6 +309,9 @@ class CrossDesignPreviewRequest(BaseModel): # 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9). # 값이 없는 측점은 소단 없음 — 종전 설계 그대로다. berms: dict[str, dict[str, float]] | None = None + # 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 위와 같은 성격 — 확정 전 세션값을 + # 재계산에 반영한다(2026-09-07). **0 은 「표준값을 씀」**(되돌리기)이다. + cut_slope_ratios: dict[str, float] | None = None def edits(self) -> dict[str, Any]: return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii} diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index d7cd8c5c..b6e1b8d5 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -8,6 +8,7 @@ import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; +import { buildCutSlopeControl, type CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { buildDesignControls, buildRockBoundaryControl, @@ -122,11 +123,12 @@ export function appendCardHeader( card.append(header); } -/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선 제어. */ +/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */ export function appendCardFooter( card: HTMLElement, section: CrossSection, rockBoundary?: RockBoundaryControl, + cutSlope?: CutSlopeControl, ): void { const footer = document.createElement("footer"); const center = document.createElement("span"); @@ -138,5 +140,12 @@ export function appendCardFooter( rockControl.classList.add("b06-cross-card__rockb"); footer.append(rockControl); } + // 암 절토 경사(각도)는 암 측점에만 — 토사 측점은 암반이 없어 쓰이지 않는다 + // (2026-09-07 사용자 확정). 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이다. + if (cutSlope && section.design?.geometry_preset === "rock") { + const slopeControl = buildCutSlopeControl(section, cutSlope); + slopeControl.classList.add("b06-cross-card__cutslope"); + footer.append(slopeControl); + } card.append(footer); } diff --git a/B06_Section/B06_Section_UI_Cross_CutSlope.ts b/B06_Section/B06_Section_UI_Cross_CutSlope.ts new file mode 100644 index 00000000..23d1f842 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_CutSlope.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * B06_Section_UI_Cross_CutSlope.ts + * 측점 하나의 **암 절토 경사** 입력칸(2026-09-07 사용자 지시). + * + * 사용자 원문 — 「개별 횡단도에는 암 절토 각도의 개별 수정 가능해야함 / 전체 변경을 위해서는 + * 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점 + * 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다). + * + * 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의 + * 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + * + * ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은 + * 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다 + * (`computeCrossDesign(cutSlopeRatio)` · `compute_cross_design(cut_slope_ratio=…)`). + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +/** 측점별 암 절토 경사 제어기 — Page 가 세션 저장소와 연결해 구현한다. */ +export interface CutSlopeControl { + /** 세션 → design 저장값 → 표준값 순으로 지금 경사비를 돌려준다. */ + ratioFor: (section: CrossSection) => number; + /** 이 측점의 표준값(되돌릴 자리) — 사용자가 안 만진 상태의 값. */ + standardRatioFor: (section: CrossSection) => number; + /** 경사비를 넣는다. null 이면 표준값으로 되돌린다. */ + set: (chainageM: number, ratio: number | null) => void; +} + +/** 경사비(1:n 의 n) → 수평에서 잰 각도(°). n 이 작을수록 급하다. */ +export function ratioToDegrees(ratio: number): number { + return (Math.atan(1 / Math.max(ratio, 1e-6)) * 180) / Math.PI; +} + +/** 각도(°) → 경사비(1:n 의 n). 1~89° 밖은 사면이 서지 않아 잘라 받는다. */ +export function degreesToRatio(degrees: number): number { + const clamped = Math.min(Math.max(degrees, 1), 89); + return 1 / Math.tan((clamped * Math.PI) / 180); +} + +/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */ +function sameDegrees(a: number, b: number): boolean { + return Math.abs(a - b) < 0.05; +} + +/** + * 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다. + * + * 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도 + * 따라간다(사용자가 만진 흔적이 지워지므로). + */ +export function buildCutSlopeControl(section: CrossSection, control: CutSlopeControl): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "b06-design__seg b06-design__cutslope"; + const legend = document.createElement("span"); + legend.className = "b06-design__seg-legend"; + legend.textContent = "암 절토"; + wrap.append(legend); + + const group = document.createElement("div"); + group.className = "b06-design__seg-buttons"; + const ratio = control.ratioFor(section); + const standard = control.standardRatioFor(section); + const degrees = ratioToDegrees(ratio); + + const input = document.createElement("input"); + input.type = "number"; + input.className = "b06-design__cutslope-input"; + input.step = "0.5"; + input.min = "1"; + input.max = "89"; + input.value = degrees.toFixed(1); + input.title = + `이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` + + `지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` + + `전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`; + const commit = (): void => { + const entered = Number(input.value); + if (!Number.isFinite(entered)) { + input.value = degrees.toFixed(1); + return; + } + // 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다. + const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered); + control.set(section.chainage_m, next); + }; + input.addEventListener("change", commit); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } + }); + // 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도. + input.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const unit = document.createElement("span"); + unit.className = "b06-design__cutslope-unit"; + unit.textContent = "°"; + + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "b06-design__rockb-btn is-reset"; + reset.textContent = "↺"; + reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`; + reset.disabled = sameDegrees(degrees, ratioToDegrees(standard)); + reset.addEventListener("click", (event) => { + event.stopPropagation(); + control.set(section.chainage_m, null); + }); + + group.append(input, unit, reset); + wrap.append(group); + return wrap; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index f2ac7b13..b5c18b79 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -48,6 +48,7 @@ import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Do import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure"; import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom"; import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; @@ -150,6 +151,8 @@ export function createCrossSectionCard( box?: BoxControl, /** 행 높이를 재며 이미 만들어 둔 기하 — 있으면 다시 계산하지 않는다(2026-09-06). */ plotBase?: CrossPlotBase | null, + /** 측점별 암 절토 경사 입력(2026-09-07) — 암 측점 하단 정보행에 선다. */ + cutSlope?: CutSlopeControl, ): CrossCardElement { // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 @@ -704,7 +707,7 @@ export function createCrossSectionCard( card.append(chartWrap); } - appendCardFooter(card, section, rockBoundary); + appendCardFooter(card, section, rockBoundary, cutSlope); return card; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3877af0a..f127f66f 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -26,6 +26,7 @@ import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { confirmCurrentSections, + createCutSlopeStore, createRockBoundaryStore, saveCurrentSections, type SectionPersistContext, @@ -46,6 +47,7 @@ import { } from "./B06_Section_UI_Page_Pipe_Options"; import { createStandardPanel, + effectiveStandardCross, rememberRockBoundaryDefault, type StandardPanelController, } from "./B06_Section_UI_Standard_Panel"; @@ -423,6 +425,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const rockOffsets = rockStore.offsets; const rockBoundaryControl = rockStore.control; + // 측점별 암 절토 경사(2026-09-07 사용자 지시) — 암 경계선과 같은 꼴의 세션 저장소다. + // 전체를 바꾸는 자리는 좌측 [표준 횡단면 설정]이고, 여기 값은 그 측점 하나만 덮는다. + const cutSlopeStore = createCutSlopeStore({ + sessionKey: () => stateKey("cutslope", projectId, currentRouteId), + standard: () => (projectId ? effectiveStandardCross(projectId) : null), + refreshCard: (chainageM) => sectionView.refreshCard(chainageM), + recompute: (chainageM) => recomputeIfRock(chainageM), + }); + const cutSlopeRatios = cutSlopeStore.ratios; + const stationControls = createStationControls({ // 키는 등록표(`b_page_state`)가 만든다 — 이름만 넘기면 통·범위·옛 키 이관이 따라온다. // 형변환을 두지 않는다: 등록표에 없는 이름을 쓰면 **컴파일에서** 걸린다 @@ -452,6 +464,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.revetLink, stationControls.ford, stationControls.box, + cutSlopeStore.control, ); // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); @@ -595,6 +608,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { flushCulvertOptions: () => stationControls.flushCulvertOptions(), patchSources: () => ({ rockOffsets, + cutSlopeRatios, stationWidths, inletStructures, basinAdjustments, @@ -665,6 +679,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { currentRouteId = context.route_id; rockStore.load(); + cutSlopeStore.load(); stationControls.load(); // 구조물 배치 데이터(타입·정본·관 지점) — 카드 로드와 병행, 화면을 잠그지 않는다. void structuresPanel.load().then(restoreStructurePick); diff --git a/B06_Section/B06_Section_UI_Page_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts index cdb5025f..4853d895 100644 --- a/B06_Section/B06_Section_UI_Page_Patches.ts +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -16,6 +16,8 @@ import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert"; export interface CrossPatchSources { /** 암 경계선 오프셋(누가거리 문자열 키). */ rockOffsets: Map; + /** 측점별 암 절토 경사비(1:n 의 n). **0 은 「표준값을 씀」**(되돌리기)이다. */ + cutSlopeRatios: Map; /** 측점 개별 표시 반폭(m). */ stationWidths: Map; inletStructures: Map; @@ -42,6 +44,10 @@ export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch sources.rockOffsets.forEach((offset, chainage) => { patchFor(Number(chainage)).rock_boundary_offset_m = offset; }); + // 측점별 암 절토 경사(2026-09-07) — 0 이면 「표준값을 씀」이라 정본의 옛 값을 덮어 지운다. + sources.cutSlopeRatios.forEach((ratio, chainage) => { + patchFor(Number(chainage)).cut_slope_ratio_user = ratio; + }); // 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다. sources.stationWidths.forEach((width, chainage) => { patchFor(Number(chainage)).display_half_width_m = width; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 1f66dfc2..57844d2f 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -30,6 +30,7 @@ import { import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; +import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { L } from "./B06_Section_UI_Page_Common"; @@ -198,6 +199,96 @@ export function createRockBoundaryStore(options: { }; } +/** 측점별 암 절토 경사비 저장소 — 값(Map)과 카드 제어기를 함께 낸다(2026-09-07). */ +export interface CutSlopeStore { + /** 측점키(누가거리 2자리) → 경사비(1:n 의 n). `buildCrossPatches` 가 그대로 읽는다. + * **0 은 「표준값을 씀」**이다 — 되돌리기(↺)가 남기는 값이라, 저장분에 옛 사용자 값이 + * 남아 있어도 0 이 그것을 덮어 표준으로 돌려놓는다(지우기만 하면 옛 값이 되살아난다). */ + ratios: Map; + control: CutSlopeControl; + load: () => void; +} + +/** + * 측점 하나만 다른 **암 절토 경사** 세션 저장소. + * + * 암 경계선 저장소와 같은 꼴이다 — 세션(sessionStorage)에 쌓고 [저장]·[확정]에서 + * `cross_patches`(`design.cut_slope_ratio_user`)로 정본에 나간다. + * + * ⚠ 값을 넣으면 **재계산을 부른다**. 경사는 나르는 값이 아니라 기하 입력이라, 값만 바꾸고 + * 다시 계산하지 않으면 설계선은 옛 경사로 남고 숫자만 새것이 된다. + */ +export function createCutSlopeStore(options: { + sessionKey: () => string | null; + /** 지금 표준 횡단면 설정(세션 편집값 우선) — 되돌릴 자리를 여기서 읽는다. */ + standard: () => StandardCrossSection | null; + refreshCard: (chainageM: number) => void; + /** 경사 변경 → 사면·소단·단면적 재계산. */ + recompute: (chainageM: number) => void; +}): CutSlopeStore { + const { sessionKey, standard, refreshCard, recompute } = options; + const ratios = new Map(); + const key = (chainageM: number): string => chainageM.toFixed(2); + + /** 이 측점이 쓰는 표준 경사비 — 지반 프리셋(암/토사)의 값. */ + const standardRatio = (section: { design?: { geometry_preset?: string } | null }): number => { + const preset = section.design?.geometry_preset === "soil" ? "soil" : "rock"; + const group = standard()?.[preset] as { cut_slope_ratio?: number } | undefined; + const value = group?.cut_slope_ratio; + return typeof value === "number" && value > 0 ? value : 0.4; + }; + + function persist(): void { + const storageKey = sessionKey(); + if (!storageKey) return; + try { + writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + } catch { + /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ + } + } + + return { + ratios, + load(): void { + ratios.clear(); + const storageKey = sessionKey(); + if (!storageKey) return; + try { + const raw = readByKey(storageKey); + if (!raw) return; + const parsed = JSON.parse(raw) as Record; + Object.entries(parsed).forEach(([chainage, ratio]) => { + // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. + if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); + }); + } catch { + /* 손상된 세션 값은 무시 — 표준값으로 재시작. */ + } + }, + control: { + standardRatioFor: (section) => standardRatio(section), + ratioFor: (section) => { + const session = ratios.get(key(section.chainage_m)); + if (session === 0) return standardRatio(section); // 되돌림 — 저장분보다 세션이 먼저다. + if (typeof session === "number" && session > 0) return session; + const stored = (section.design as { cut_slope_ratio_user?: number } | undefined) + ?.cut_slope_ratio_user; + if (typeof stored === "number" && stored > 0) return stored; + return standardRatio(section); + }, + set: (chainageM, ratio) => { + // null = 되돌리기. 지우지 않고 0 을 남겨야 저장분에 있던 옛 값까지 표준으로 돌아간다. + if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) ratios.set(key(chainageM), 0); + else ratios.set(key(chainageM), Math.round(ratio * 10000) / 10000); + persist(); + refreshCard(chainageM); + recompute(chainageM); + }, + }, + }; +} + /** [저장]·[확정]이 함께 쓰는 페이지 상태 창구. */ export interface SectionPersistContext { projectId: string | null; diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 5f75c087..0d5111e9 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -13,6 +13,7 @@ * 외부(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"; @@ -146,6 +147,8 @@ export function createSectionView( revetLink?: RevetLinkControl, ford?: FordControl, box?: BoxControl, + /** 측점별 암 절토 경사 입력(2026-09-07). */ + cutSlope?: CutSlopeControl, ): SectionViewController { const root = document.createElement("div"); root.className = "b06-section"; @@ -457,6 +460,7 @@ export function createSectionView( ford, box, plotBase, + cutSlope, ); /** diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 2a4a874a..b2b89cd1 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -572,3 +572,38 @@ border-radius: var(--radius-inputs); padding: 1px 4px; } + +/* 측점별 암 절토 경사(2026-09-07) — 암 경계선이 행 가운데를 절대 배치로 쓰므로 + 이 칸은 행의 **오른쪽 끝**에 둔다. 중심고(왼쪽)와 겹치지 않는다. */ +.b06-cross-card__cutslope { + margin-left: auto; + flex: 0 0 auto; + border-radius: var(--radius-inputs); + padding: 1px 4px; +} + +.b06-design__cutslope-input { + width: 44px; + padding: 0 2px; + font-size: 0.72rem; + font-family: var(--font-mono); + text-align: right; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); +} + +/* 화살표(스피너)는 칸이 좁아 지운다 — 값은 직접 넣거나 ↺ 로 되돌린다. */ +.b06-design__cutslope-input::-webkit-outer-spin-button, +.b06-design__cutslope-input::-webkit-inner-spin-button { + margin: 0; + appearance: none; +} + +.b06-design__cutslope-unit { + padding: 0 2px 0 1px; + font-size: 0.72rem; + color: var(--color-text-secondary); + align-self: center; +} diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 83f060b0..275fc89b 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -81,6 +81,9 @@ export interface CrossDesignOptions { standard: StandardCrossSectionSpec; rockBoundaryOffsetM?: number | null; twoStageSlope?: boolean; + /** 이 측점만 쓰는 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값. 없으면 표준값. + * 짝: 파이썬 `compute_cross_design(cut_slope_ratio=…)`. */ + cutSlopeRatio?: number | null; ditchEnabled?: boolean | null; /** 세월교 월류 높이만큼 노면을 통째로 내린다(m). */ surfaceDropM?: number; @@ -250,7 +253,14 @@ export function computeCrossDesign( if (ditchType === "l_type" && presetKey !== "rock") { throw new Error("L형 측구는 암(리핑암/발파암) 구간에서만 선택할 수 있습니다."); } - const group = resolveGroup(presetKey, options.standard); + let group = resolveGroup(presetKey, options.standard); + // 측점 하나만 다른 절토 경사(2026-09-07 사용자 지시) — 표준 그룹의 경사비만 갈아 끼운다. + // ⚠ 여기서 갈아야 아래 기하·소단이 전부 새 경사를 따른다. 계산이 끝난 뒤 값만 베껴 + // 붙이면 설계선은 옛 경사로 그려지고 숫자만 새것이 되어 어긋난다. + const userCutRatio = options.cutSlopeRatio; + if (typeof userCutRatio === "number" && Number.isFinite(userCutRatio) && userCutRatio > 0) { + group = { ...group, cut_slope_ratio: userCutRatio }; + } const pavedGroup = resolveGroup("paved", options.standard); // 포장 중첩: 횡단경사와 포장층 두께만 포장 그룹을 따른다. const crossSlopePct = paved ? pavedGroup.cross_slope_pct : group.cross_slope_pct; From e7695c678f23fe4f3b76f0ce6e8606d5b788e1fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:05:46 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat(B06):=20=EC=A7=80=EB=B0=98=EC=9C=A0?= =?UTF-8?q?=ED=98=95=EC=9D=84=20=E3=80=8C=ED=86=A0=EC=82=AC=E3=80=8D=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=ED=95=98=EB=82=98=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EB=81=84=EB=A9=B4=20=EC=95=94(=EA=B8=B0=EB=B3=B8),=20=ED=91=9C?= =?UTF-8?q?=EC=A4=80=20=ED=8C=A8=EB=84=90=EC=97=90=20=EA=B0=81=EB=8F=84=20?= =?UTF-8?q?=EB=B3=91=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 확정(2026-09-07): 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화) / 비활성화 상태에서는 암경계선이 나오고 암경계선 아래는 사용자가 지정한 암 절토각 반영. 이후는 토사 절토 각도를 반영 / 활성화 상태에서는 암반이 없으니 절토는 토사 절토 각도로만 구현」 **화면** — 카드 제목줄의 지반유형 버튼 셋(토사·리핑암·발파암)이 **「토사」 단추 하나**가 됨. 색으로 켜짐/꺼짐을 보이고 **기본은 꺼짐(암)**. 측구 토글과 같은 꼴임. 실화면 확인(용화 5601e828 · route 169 · 측점 1+0.0): · **암**(꺼짐) — 암 경계선·암 절토각 칸 보임, 절토 토사 1.99 + 암 1.91 = **3.90㎡** · **토사**(켜짐) — 두 칸 사라짐, 절토 **전량 토사 7.20㎡**(토사 경사가 더 완만해 더 팜) · 다시 끄면 3.90㎡ 로 정확히 원복 **저장값은 종전 그대로** — 꺼짐 = `ripping_rock`, 켜짐 = `soil`. 옛 자료의 `blasting_rock` 도 「암」으로 읽힘. ⚠ 유토곡선·수량은 이제 암을 **한 종류(리핑암)**로 잡음 — 연암:경암 · 발파암:리핑암을 가르는 것은 **설계내역에서 설계자가 비율로**(계획서 8-1). 표준 횡단면 설정의 절토·성토 경사 칸에 **각도를 함께 보임**(툴팁) — 카드가 도(°)로 받으므로 두 자리의 말이 갈리지 않게 함. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. ⑤ **표준을 바꿔도 개별로 고친 측점은 그대로**를 실화면에서 확인 — 1번 카드에 40° 를 넣고 표준 암 절토경사를 0.4 → 0.8 로 바꿔 [전체 측점 반영]: · 1번(사용자 값) **40.0° 유지 · 암 절토 5.66㎡ 그대로** · 2번(값 없음) 68.2° → **51.3°**(=1:0.8) · 암 절토 2.61 → 4.01㎡ · 표준을 되돌리자 둘 다 원상 복귀 시험 3건 더 — 토글 하나인지 · 기본이 암인지 · 옛 발파암 자료도 암 기하로 읽히는지. 전체 **486 passed · 17 skipped**. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Design.ts | 44 +++++++++++++++----- B06_Section/B06_Section_UI_Standard_Panel.ts | 14 +++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 8e691cb0..e6cc50f5 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -69,11 +69,19 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -const GROUND_OPTIONS: Array<[GroundType, keyof typeof ui_locales]> = [ - ["soil", "B06_Design_Ground_Soil"], - ["ripping_rock", "B06_Design_Ground_Ripping"], - ["blasting_rock", "B06_Design_Ground_Blasting"], -]; +/** + * 지반유형은 **「토사」 토글 하나**다(2026-09-07 사용자 확정) — 켜면 토사, **끄면 암**(기본). + * + * 사용자 원문 — 「리핑암과 발파암 버튼을 삭제하면서 구분의 의미가 없어졌어. … 토사버튼만 + * 존재하고 이값은 활성화/비활성화로 반영(기본값은 비활성화)」. 까닭은 **암반 지정·범위가 + * 실무에서 애매해** 측점마다 암질을 못 박는 것이 오히려 틀리기 때문이고, 연암:경암 · + * 발파암:리핑암은 **설계내역에서 설계자가 비율로** 넣기로 했다(계획서 8-1). + * + * ⚠ 저장값은 종전 그대로 쓴다 — 끔 = `ripping_rock`, 켬 = `soil`. 옛 자료의 `blasting_rock` + * 도 「암(끔)」으로 읽힌다. 유토곡선·수량은 암을 한 종류(리핑암)로 잡으며, 갈라 넣는 것은 + * 설계내역 몫이다. + */ +const GROUND_ROCK_DEFAULT: GroundType = "ripping_rock"; const MODE_OPTIONS: Array<[SectionMode, keyof typeof ui_locales]> = [ ["left_cut", "B06_Design_Mode_LeftCut"], ["right_cut", "B06_Design_Mode_RightCut"], @@ -321,7 +329,8 @@ export function buildDesignControls( twoStage: boolean; ditchEnabled: boolean | null; } = { - ground: design?.ground_type ?? "soil", + // 설계가 아직 없는 측점의 기본은 **암**이다(2026-09-07 사용자: 「기본값은 비활성화」). + ground: design?.ground_type ?? GROUND_ROCK_DEFAULT, mode: design?.section_mode ?? (section.uphill_side === "right" ? "right_cut" : "left_cut"), ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", @@ -353,11 +362,24 @@ export function buildDesignControls( }); }; - // 지반유형: 제목행 배치용으로 분리 반환(D-6). 라벨 삭제(3번) — 버튼만. - const groundSegment = segment("", GROUND_OPTIONS, state.ground, (value) => { - state.ground = value; - emit(); - }); + // 지반유형: 제목행 배치용으로 분리 반환(D-6). 「토사」 토글 하나 — 끄면 암이다(2026-09-07). + // 버튼명은 「토사」로 고정하고 **켜짐/꺼짐(색)**으로 상태를 보인다 — 측구 토글과 같은 꼴 + // (사용자 원문: 「토사버튼만 존재하고 이값은 활성화/비활성화로 반영」). + const groundToggle = toggle( + "", + state.ground === "soil", + L("B06_Design_Ground_Soil"), + L("B06_Design_Ground_Soil"), + () => { + state.ground = state.ground === "soil" ? GROUND_ROCK_DEFAULT : "soil"; + emit(); + }, + ); + groundToggle.button.title = + state.ground === "soil" + ? "토사 — 암반이 없어 절토는 토사 경사 하나로만 그린다. 누르면 암으로 바뀐다." + : "암 — 암 경계선이 서고 그 아래는 암 절토각, 위는 토사 경사로 그린다. 누르면 토사로 바뀐다."; + const groundSegment = groundToggle.wrap; groundSegment.classList.add("b06-design__seg--header"); // 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성). // 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6). diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 9279ab3b..fed5075f 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -27,6 +27,7 @@ import { import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField } from "@ui/ui_template_elements"; import { buildStandardDiagram } from "./B06_Section_UI_Standard_Diagram"; +import { ratioToDegrees } from "./B06_Section_UI_Cross_CutSlope"; import { getCompanyStandard, listCompanyStandards, @@ -303,6 +304,19 @@ export function createStandardPanel( }); field.input.step = "0.1"; field.input.min = "0"; + // 경사 칸은 **각도도 함께 보인다** — 카드의 개별 절토각 칸이 도(°)로 받으므로 두 자리의 + // 말이 갈리지 않게 한다(2026-09-07). 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°. + if (spec.label === "B06_Std_Field_CutSlope" || spec.label === "B06_Std_Field_FillSlope") { + const showAngle = (): void => { + const ratio = Number(field.input.value); + field.input.title = + Number.isFinite(ratio) && ratio > 0 + ? `1:${ratio} = ${ratioToDegrees(ratio).toFixed(1)}° (횡단 카드의 각도 칸과 같은 값)` + : ""; + }; + showAngle(); + field.input.addEventListener("input", showAngle); + } grid.append(field.root); }; From 674d4ef14a2bf2c422a0e926068c61d88ec28f23 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:08:18 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix(=ED=86=A0=EA=B3=B5):=20=EB=8F=84?= =?UTF-8?q?=EC=9E=90=20=EC=9A=B4=EB=B0=98=20=ED=95=9C=EA=B3=84=EA=B1=B0?= =?UTF-8?q?=EB=A6=AC=2070m=20=E2=86=92=2060m=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 근거 — 실무 오솔길 EARTH.DAT 헤더 6개 공사지가 전부 `20.0 / 60.0` (거창·장수·진안·봉화·영월 본선·지선). 산림과임업기술 5장 「다. 공사수량의 산출」도 「도저운반성토 60m 이하 / 덤프운반성토 60m 초과(건설표준품셈 참조)」로 규정. 70m 는 Aislo 단독값이었음(2026-08-02 잠정 확정). - config_system_design.py — EARTHWORK_HAUL_EQUIPMENT_LIMITS_M 의 dozer 경계와 주석의 확정 이력·근거 갱신. 정의처가 이 상수 한 곳이라 다른 코드 변경 없음. - common_util_mass_haul_balance.ts — 경계현 설명 주석의 70m 표기 정정. 검증 — 백엔드 재시작 뒤 공용 브라우저에서 실제 API 호출, sections/context 의 dozer.max_distance_m = 60 확인. 회귀 369 passed (실패 3건은 haul 미참조 기존 깨짐). 지식DB 미결 No.21 해소 — 목록 정리는 위키 AI 몫. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_mass_haul_balance.ts | 2 +- config/config_system_design.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/common_util/common_util_mass_haul_balance.ts b/common_util/common_util_mass_haul_balance.ts index 31c454a7..1396df92 100644 --- a/common_util/common_util_mass_haul_balance.ts +++ b/common_util/common_util_mass_haul_balance.ts @@ -38,7 +38,7 @@ * 즉 **수평선의 높이는 그 현(弦)의 길이가 장비 경계거리와 같아지는 높이**다. 누가토량 * 그래프에서 세로축이 곧 토량이므로, 두 수평선 사이 띠의 두께가 그 장비가 옮기는 토량이다. * 현 길이 20m 되는 높이 위쪽 → 종무대 - * 거기서 70m 되는 높이까지 → 도쟈 + * 거기서 60m 되는 높이까지 → 도쟈 * 그 아래 ~ 평형선 → 덤프 * 경계값 정의처는 `config_system.EARTHWORK_HAUL_EQUIPMENT_LIMITS_M` 한 곳뿐이다. * diff --git a/config/config_system_design.py b/config/config_system_design.py index c239ab33..6811f5c9 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -379,9 +379,13 @@ EARTHWORK_CONVERSION_FACTORS = { # 평균운반거리(m)로 운반장비를 고른다. 위에서부터 순서대로 검사해 `max_distance_m` # **이하**면 그 장비를 쓰고, 마지막 항목(None)이 나머지를 전부 받는다. # -# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 70m < 덤프트럭 +# 종무대(무대운반) ≤ 20m < 도쟈(불도저 압토) ≤ 60m < 덤프트럭 # -# 2026-08-02 사용자 확정값(도쟈 50 → 70m). 유토곡선의 장비 경계현은 이 값을 +# 2026-09-07 사용자 확정값(도쟈 70 → 60m). 근거 — 실무 오솔길 `EARTH.DAT` 헤더 6개 +# 공사지가 전부 `20.0 / 60.0`(거창·장수·진안·봉화·영월 본선·지선), 산림과임업기술 +# 5장 「다. 공사수량의 산출」이 「도저운반성토 60m 이하 / 덤프운반성토 60m 초과 +# (건설표준품셈 참조)」로 못 박음. 70m 는 Aislo 단독값이었음(2026-08-02 잠정 확정). +# 유토곡선의 장비 경계현은 이 값을 # **수평 현의 길이**로 읽는다 — 현 길이가 20m가 되는 높이 위쪽이 종무대 몫, # 70m가 되는 높이까지가 도쟈 몫, 그 아래가 덤프 몫이다. # 현장·발주처 기준에 따라 달라질 수 있으므로 @@ -393,7 +397,7 @@ EARTHWORK_CONVERSION_FACTORS = { # ───────────────────────────────────────────────────────────────────────── EARTHWORK_HAUL_EQUIPMENT_LIMITS_M = ( ("free_haul", 20.0), - ("dozer", 70.0), + ("dozer", 60.0), ("dump_truck", None), ) From 4774eb3efb68d99f6b6c779a23abff2522b567e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:16:10 +0900 Subject: [PATCH 06/10] =?UTF-8?q?docs(=EC=86=8C=EB=8B=A8):=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=84=A4=EB=AA=85=20=EC=B2=AB=20=EC=A4=84=EC=9D=98?= =?UTF-8?q?=20=EA=B8=B0=EB=B3=B8=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=202=C2=B0?= =?UTF-8?q?=20=E2=86=92=200=C2=B0=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 아래 항목 설명은 0° 로 고쳐져 있었는데 머리말만 2° 로 남아 서로 어긋났음. 값 자체는 이미 0 이라 동작 변화 없음. Co-Authored-By: Claude Opus 5 (1M context) --- common_util/common_util_cross_berm.py | 2 +- common_util/common_util_cross_berm.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common_util/common_util_cross_berm.py b/common_util/common_util_cross_berm.py index 892cb56d..9649bbda 100644 --- a/common_util/common_util_cross_berm.py +++ b/common_util/common_util_cross_berm.py @@ -9,7 +9,7 @@ **바깥으로 걸어가며 그때그때 경사를 고르는** 방식으로 바꿨다. 소단이 없으면 종전과 같은 값이 나온다(거울 시험이 그것도 지킨다). -**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. +**소단 기본값** — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**이다. 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): diff --git a/common_util/common_util_cross_berm.ts b/common_util/common_util_cross_berm.ts index 7e88897b..bc12a09b 100644 --- a/common_util/common_util_cross_berm.ts +++ b/common_util/common_util_cross_berm.ts @@ -8,7 +8,7 @@ * 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다. * 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다. * - * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 2°. + * 소단 기본값 — 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 기울기 0°. * · 폭·간격은 별표2 범위(사면길이 2~3m마다 · 폭 50~100㎝) 안에서 **가장 적게 파는 조합**. * 기본값은 되돌리기 쉬운 쪽이어야 한다 — 더 넣는 것은 폼에서 한 번이지만 이미 판 것을 * 되돌리면 전 측점을 다시 계산해야 한다. 실효 경사로도 그렇다(경사 1:1 기준): From 10b4b32a855e7f6a451f3fb22e782e02f93c8a37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 18:50:42 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EA=B3=A1=EC=84=A0=EC=9D=84=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=EA=B0=80=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B2=8C=20=E2=80=94=20=EC=86=90=EB=8C=80?= =?UTF-8?q?=EB=A9=B4=20=EA=B3=A1=EC=84=A0=EC=9D=B4=20=EC=82=AC=EB=9D=BC?= =?UTF-8?q?=EC=A7=80=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 ①② 의 뿌리 하나를 고침. 곡선을 서버만 그려서, 노드를 끌거나 손잡이를 살짝 건드리기만 해도 그려 둔 선·손잡이·노드 요약을 통째로 비웠음. 그 결과 화면에서 곡선이 전부 사라져 「R 이 지워졌다」로 보였음(값 자체는 남아 [확인] 때 반영됐음). - `buildEditedPolyline` 신설 — `build_planned_polyline` 의 **편집 갈래** (simplify=False + curve_flags/radii) 파이썬·TS 짝. 단순화·IP 추출·반지름 피팅은 초기 변환 전용이라 안 옮김. - `markEdited` 가 비우는 대신 **같은 규칙으로 다시 그림**. 나머지 곡선은 안 사라짐. - 손잡이는 곡선 목록 자리가 아니라 **노드 번호**로 잡음 — 다시 그릴 때마다 목록이 새로 나므로 자리로 들면 엉뚱한 곡선을 가리킴. - 접선 자리가 모자라 R 이 눌리는 것이 끄는 즉시 보임(상태줄 「기준 미달 N곳」). - 끌기 중에도 상태줄을 갱신 — 예전에는 아무 말이 없어 사라진 인상만 남았음. - 서버가 곡선을 안 둔 자리(내각 179° 이상)는 **곡선 없음으로 열게** 고침. 전부 켬으로 열면 아무것도 안 만지고 [확인]만 눌러도 곡선이 새로 생겨 노선이 조용히 바뀌었음. 거울 시험 `tmp/tests/test_route_polyline_browser_mirror.py` 10건 추가. 원호 표본 개수는 `math.sin` 의 마지막 자리 차이로 90° 처럼 딱 떨어지는 자리에서 하나 갈릴 수 있어(같은 원 위의 같은 호), 개수가 같을 때는 1e-9 로 자리까지, 갈릴 때는 현 하나분 안쪽으로 모양을 맞춤. 일부러 공식을 틀어 시험이 잡는 것도 확인함. 실화면(5174) — 노드/손잡이를 끈 뒤에도 상태줄이 「곡선 22곳(하한 R 12m)」 유지, 「기준 미달 2곳」이 그 자리에서 뜸. 종전에는 「곡선 기준 R 12m」로 바뀌며 다 사라졌음. 전체 554 passed · 18 skipped, tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 67 ++--- B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts | 251 +++++++++++++++++- 2 files changed, 280 insertions(+), 38 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index fdf9159f..1d2b41fb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -29,7 +29,7 @@ import { showToast } from "@ui/ui_template_elements"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; -import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ @@ -259,18 +259,21 @@ export async function openRouteEditModal( context.restore(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */ - function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null { - let best: { curve: number; end: "start" | "end" } | null = null; + /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. + * + * 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록 + * 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */ + function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null { + let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = NODE_HIT_PX + 2; - curveInfo.forEach((curve, index) => { + curveInfo.forEach((curve) => { (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); const distance = Math.hypot(x - px, y - py); if (distance <= bestDistance) { bestDistance = distance; - best = { curve: index, end: which }; + best = { node: curve.node_first, end: which }; } }); }); @@ -279,13 +282,12 @@ export async function openRouteEditModal( /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( - curveIndex: number, + node: number, which: "start" | "end", to: Vertex, ): { apex: Vertex; radius: number } | null { - const curve = curveInfo[curveIndex]; + const curve = curveInfo.find((entry) => entry.node_first === node); if (!curve) return null; - const node = curve.node_first; const before = planned[node - 1]; const after = planned[node + 1]; if (!before || !after) return null; @@ -327,12 +329,17 @@ export async function openRouteEditModal( return best; } - /** 노드를 고쳤다 — 서버가 만든 폴리라인은 낡았으므로 지우고 직선으로 미리 보인다. - * 곡선은 [확인] 때 서버가 같은 R 규칙으로 다시 끼운다(계산을 두 벌로 짜지 않는다). */ + /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). + * + * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 + * 사라진 것처럼** 보였다 — 값은 남아 있는데 화면만 「지워졌다」고 말하니 되돌릴 길을 찾게 됐다. + * 지금은 서버와 **같은 규칙**(`buildEditedPolyline` 짝)으로 즉시 다시 만든다. [확인] 때 + * 서버가 정본으로 다시 내는 것은 그대로다. */ function markEdited(): void { - plannedLine = []; - nodeInfo = []; - curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다. + const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM); + plannedLine = built.vertices; + curveInfo = built.curves; + nodeInfo = built.nodes; } /** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */ @@ -429,7 +436,7 @@ export async function openRouteEditModal( // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── let dragNode = -1; /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ - let dragHandle: { curve: number; end: "start" | "end" } | null = null; + let dragHandle: { node: number; end: "start" | "end" } | null = null; let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; canvas.addEventListener("pointerdown", (event) => { @@ -441,7 +448,7 @@ export async function openRouteEditModal( dragHandle = handleAt(px, py); dragNode = dragHandle ? -1 : nodeAt(px, py); if (dragHandle) { - picked = curveInfo[dragHandle.curve]?.node_first ?? -1; + picked = dragHandle.node; syncCurveBar(); draw(); } else if (dragNode >= 0) { @@ -460,31 +467,28 @@ export async function openRouteEditModal( const py = event.clientY - rect.top; if (dragHandle) { // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). - const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py)); + const node = dragHandle.node; + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); if (moved) { - const node = curveInfo[dragHandle.curve].node_first; planned[node] = moved.apex; curveRadius[node] = Math.round(moved.radius * 100) / 100; curveOn[node] = true; picked = node; - // 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다. - const which = dragHandle.end === "start" ? "start" : "end"; - curveInfo[dragHandle.curve] = { - ...curveInfo[dragHandle.curve], - apex: [moved.apex[0], moved.apex[1]], - radius_m: moved.radius, - [which]: toMetric(px, py), - } as (typeof curveInfo)[number]; - plannedLine = []; - nodeInfo = []; + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + markEdited(); syncCurveBar(); + status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; } if (dragNode >= 0) { planned[dragNode] = toMetric(px, py); - markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다. + markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 + // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). + status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; draw(); return; } @@ -654,7 +658,10 @@ export async function openRouteEditModal( inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg, violations: curve ? (curve.violations ?? []) : (node.violations ?? []), }); - curveOn.push(true); + // 서버가 **곡선을 안 둔 자리는 「곡선 없음」으로 연다**(2026-09-07). 전부 켬으로 열면 + // 아무것도 안 만지고 [확인]만 눌러도 그 자리에 곡선이 새로 생겨 노선이 조용히 바뀐다 + // (서버의 편집 갈래는 켜진 자리마다 원호를 끼우기 때문). 내각 179° 이상인 자리가 그렇다. + curveOn.push(curve !== undefined); // 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다. curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null); if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts index 49b97466..4a35d75b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve.ts @@ -1,16 +1,57 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Curve.ts - * 곡선 손잡이 셈 — 편집 모달(`B05_Profile_UI_RouteEdit.ts`)에서 떼어냄 - * (700줄 제한, 2026-09-07). 화면·DOM 을 안 만지는 순수 기하만 둔다. + * 계획노선 편집의 **기하 셈** — 화면·DOM 을 안 만지는 순수 함수만 둔다. + * (편집 모달 `B05_Profile_UI_RouteEdit.ts` 에서 떼어냄, 700줄 제한 2026-09-07) * - * ⚠ 이 셈은 서버(`common_util/common_util_route_polyline.py`)의 **반대 방향**이다 — - * 서버는 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. - * 두 벌이 아니라 **짝**이며, 왕복이 제자리인지는 - * `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * 두 가지가 들어 있다. + * + * ① **손잡이 → 교각점·반지름**(`dragHandleTo`) — 서버 셈의 **반대 방향**이다. 서버는 + * 교각점·R 에서 접선점을 내고, 여기서는 끈 접선점에서 교각점·R 을 낸다. + * 왕복이 제자리인지는 `tmp/tests/test_route_polyline_handle_drag.py` 가 지킨다. + * + * ② **노드 → 폴리라인**(`buildEditedPolyline`) — ⚠⚠ **짝**: + * `common_util/common_util_route_polyline.py` 의 `build_planned_polyline` 중 + * **편집 갈래**(`simplify=False` + `curve_flags`/`radii`)와 같은 값을 내야 한다. + * 거울 시험: `tmp/tests/test_route_polyline_browser_mirror.py`. + * + * 왜 브라우저에도 두나(2026-09-07 사용자 지적 ①②) — 곡선을 서버만 그리면 노드를 잡는 + * 순간 그려 둔 선을 통째로 버려야 해서 **곡선이 전부 사라진 것처럼 보인다**. 값은 남아 + * 있는데 화면만 「지워졌다」고 말하니 더 나쁘다. 조작 중에는 브라우저가 같은 규칙으로 + * 즉시 그리고, [확인] 때 서버가 정본으로 다시 낸다(CLAUDE.md 「계산 자리」 ① 짝). + * + * 옮기지 않은 것 — 단순화·IP 추출·반지름 피팅(`_fit_radius_m`)은 **예상노선을 처음 + * 폴리라인으로 바꿀 때만** 쓰는 것이라 편집 갈래에서는 돌지 않는다(`node_indices` 가 None). * ========================================================================== */ export type Vertex = [number, number]; +/** 곡선 성분 하나 — 서버 `RoutePlanCurve` 와 **같은 꼴**이라 그대로 바꿔 쓸 수 있다. + * + * ⚠ 여기서 다시 적는 이유 — 이 파일은 거울 시험이 `tsc` 로 **혼자 컴파일**하므로 바깥 + * 모듈을 들이지 않는다(경로 별칭 `@config/…` 가 딸려 와 컴파일이 깨진다). */ +export interface EditedCurve { + apex: [number, number]; + radius_m: number; + tangent_m: number; + inner_angle_deg: number; + start: [number, number]; + end: [number, number]; + node_first: number; + node_last: number; + violations: string[]; +} + +/** 짝: `DUPLICATE_TOLERANCE_M`. 이보다 가까운 뒤엣점은 같은 자리로 보고 버린다. */ +const DUPLICATE_TOLERANCE_M = 0.5; +/** 짝: `ARC_STEP_DEG`. 원호를 몇 도마다 한 점씩 찍을지. */ +const ARC_STEP_DEG = 5.0; +/** 짝: `math.degrees`/`math.radians` — 파이썬은 **상수 하나를 곱한다**. 곱셈 순서가 다르면 + * 90° 가 89.999…9 로 떨어져 원호 점 수가 하나 어긋난다(2026-09-07 거울 시험에서 실제로 남). */ +const DEG_PER_RAD = 180 / Math.PI; +const RAD_PER_DEG = Math.PI / 180; +/** 짝: `build_planned_polyline` 의 `hairpin_min_radius_m` 기본값(배향곡선 하한). */ +export const HAIRPIN_MIN_RADIUS_M = 10.0; + /** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */ export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null { const dx1 = a2[0] - a1[0]; @@ -33,7 +74,7 @@ export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number const lb = Math.hypot(bx, by); if (la <= 0 || lb <= 0) return 180; const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb))); - return (Math.acos(cosine) * 180) / Math.PI; + return Math.acos(cosine) * DEG_PER_RAD; } /** 끈 접선점으로 **새 교각점과 새 반지름**을 구한다 — 「직선의 각도와 반지름 값 변경」. @@ -59,10 +100,204 @@ export function dragHandleTo( : intersect(before, oldApex, to, after); // 나가는 직선을 돌린다 if (!apex) return null; const inner = innerAngleDeg(before, apex, after); - const halfTan = Math.tan(((180 - inner) * Math.PI) / 360); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); if (!(halfTan > 1e-9)) return null; const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]); const radius = tangent / halfTan; if (!(radius > 0) || !Number.isFinite(radius)) return null; return { apex, radius }; } + +const distance = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]); + +/** 짝: `_unit`. from → to 방향의 단위벡터. 같은 자리면 (0,0). */ +function unit(from: Vertex, to: Vertex): Vertex { + const dx = to[0] - from[0]; + const dy = to[1] - from[1]; + const length = Math.hypot(dx, dy); + if (length <= 0) return [0, 0]; + return [dx / length, dy / length]; +} + +/** 짝: `_turn_sign`. 도는 방향 — 왼쪽 +1, 오른쪽 −1, 곧게 0. */ +function turnSign(before: Vertex, at: Vertex, after: Vertex): number { + const cross = (at[0] - before[0]) * (after[1] - at[1]) - (at[1] - before[1]) * (after[0] - at[0]); + if (Math.abs(cross) <= 1e-9) return 0; + return cross > 0 ? 1 : -1; +} + +/** 짝: `_arc_geometry`. 반지름 하나에 대한 (접선시작, 접선끝, 중심). 못 끼우면 null. */ +function arcGeometry( + before: Vertex, + at: Vertex, + after: Vertex, + innerDeg: number, + radius: number, + halfTan: number, +): { start: Vertex; end: Vertex; center: Vertex } | null { + const tangent = radius * halfTan; + const toBefore = unit(at, before); + const toAfter = unit(at, after); + const start: Vertex = [at[0] + toBefore[0] * tangent, at[1] + toBefore[1] * tangent]; + const end: Vertex = [at[0] + toAfter[0] * tangent, at[1] + toAfter[1] * tangent]; + const bisector: Vertex = [toBefore[0] + toAfter[0], toBefore[1] + toAfter[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + if (bisectorLength <= 1e-9) return null; + const centerDistance = radius / Math.sin((innerDeg * RAD_PER_DEG) / 2); + const center: Vertex = [ + at[0] + (bisector[0] / bisectorLength) * centerDistance, + at[1] + (bisector[1] / bisectorLength) * centerDistance, + ]; + return { start, end, center }; +} + +/** 짝: `_arc_points`. 원호 위 점(양 끝은 빼고 — 부르는 쪽이 붙인다). */ +function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolean): Vertex[] { + const radius = distance(center, start); + if (radius <= 0) return []; + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]); + const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]); + let sweep = endAngle - startAngle; + if (clockwise) { + while (sweep > 0) sweep -= 2 * Math.PI; + } else { + while (sweep < 0) sweep += 2 * Math.PI; + } + const steps = Math.max(1, Math.trunc(Math.abs(sweep * DEG_PER_RAD) / ARC_STEP_DEG)); + const points: Vertex[] = []; + for (let step = 1; step < steps; step += 1) { + const angle = startAngle + (sweep * step) / steps; + points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]); + } + return points; +} + +/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ +export interface EditedNode { + inner_angle_deg: number | null; + radius_m: number | null; + tangent_m: number | null; + violations: string[]; +} + +export interface EditedPolyline { + /** 그려 보이는 선(원호 포함). */ + vertices: Vertex[]; + /** 곡선 성분 — 손잡이·R 칸의 재료. `node_first`/`node_last` 는 **넘긴 목록의 자리**다. */ + curves: EditedCurve[]; + /** 넘긴 목록과 **자리가 같은** 노드 요약. */ + nodes: EditedNode[]; +} + +/** + * 짝: `build_planned_polyline` 의 편집 갈래. 노드·곡선 켬끔·반지름으로 폴리라인을 만든다. + * + * `curveOn[i]` 가 거짓이면 그 자리에 곡선을 두지 않는다(직선이 그대로 꺾인다). + * `curveRadius[i]` 가 있으면 그 반지름으로 못박고, 없으면 법정 하한을 쓴다. + * 접선 자리가 모자라면 **줄이되 막지 않고** 위반으로 표시한다(서버와 같은 규칙). + * + * ⚠ 서버는 0.5m 안에 겹친 점을 버린다. 여기서도 같이 버리되, 돌려주는 자리 번호는 + * **넘긴 목록 기준**으로 되돌려 놓는다 — 화면이 잡고 있는 배열이 그것이기 때문이다. + */ +export function buildEditedPolyline( + points: Vertex[], + curveOn: boolean[], + curveRadius: Array, + minRadiusM: number, + hairpinMinRadiusM: number = HAIRPIN_MIN_RADIUS_M, +): EditedPolyline { + const nodes: EditedNode[] = points.map(() => ({ + inner_angle_deg: null, + radius_m: null, + tangent_m: null, + violations: [], + })); + + // 겹친 점 버리기 — 편집값과 **함께** 걸러야 자리가 어긋나지 않는다. + const cleaned: Vertex[] = []; + const flags: boolean[] = []; + const forcedRadii: Array = []; + const origin: number[] = []; // cleaned 자리 → 넘긴 목록 자리 + points.forEach((point, index) => { + if (cleaned.length && distance(cleaned[cleaned.length - 1], point) <= DUPLICATE_TOLERANCE_M) { + return; + } + cleaned.push(point); + flags.push(curveOn[index] !== false); + forcedRadii.push(curveRadius[index] ?? null); + origin.push(index); + }); + + if (cleaned.length < 3) return { vertices: [...cleaned], curves: [], nodes }; + + for (let index = 1; index < cleaned.length - 1; index += 1) { + nodes[origin[index]].inner_angle_deg = innerAngleDeg( + cleaned[index - 1], + cleaned[index], + cleaned[index + 1], + ); + } + + const vertices: Vertex[] = [cleaned[0]]; + const curves: EditedCurve[] = []; + let cursor = 0; // 아직 선에 안 실은 첫 꺾임점 + + for (let at = 1; at < cleaned.length - 1; at += 1) { + if (!flags[at]) continue; // 곡선을 지운 자리 — 직선이 그대로 꺾인다. + const entryFrom = cleaned[at - 1]; + const apex = cleaned[at]; + const exitTo = cleaned[at + 1]; + const node = nodes[origin[at]]; + + const inner = innerAngleDeg(entryFrom, apex, exitTo); + const halfTan = Math.tan(((180 - inner) * RAD_PER_DEG) / 2); + // 접선이 들어갈 자리 — 앞뒤 직선을 이웃 곡선과 나눠 쓰므로 절반까지만. + const available = Math.min(distance(entryFrom, apex), distance(apex, exitTo)) / 2; + if (!(halfTan > 1e-9) || available <= 0) continue; + + const forced = forcedRadii[at]; + let radius = forced !== null && forced > 0 ? forced : minRadiusM; + let tangent = radius * halfTan; + if (tangent > available) { + radius = available / halfTan; + tangent = available; + } + + const geometry = arcGeometry(entryFrom, apex, exitTo, inner, radius, halfTan); + if (radius <= 0 || geometry === null) continue; + + if (radius < minRadiusM) { + node.violations.push(`최소곡선반지름 미달(${radius.toFixed(1)} < ${minRadiusM.toFixed(1)}m)`); + } + if (radius < hairpinMinRadiusM) { + node.violations.push( + `배향곡선 하한 미달(${radius.toFixed(1)} < ${hairpinMinRadiusM.toFixed(1)}m)`, + ); + } + node.radius_m = radius; + node.tangent_m = tangent; + curves.push({ + apex: [apex[0], apex[1]], + radius_m: radius, + tangent_m: tangent, + inner_angle_deg: inner, + start: [geometry.start[0], geometry.start[1]], + end: [geometry.end[0], geometry.end[1]], + node_first: origin[at], + node_last: origin[at], + violations: [...node.violations], + }); + + // 곡선 앞의 직선 위 꺾임점들은 그대로 잇는다. + for (let index = cursor + 1; index < at; index += 1) vertices.push(cleaned[index]); + cursor = at; + + const clockwise = turnSign(entryFrom, apex, exitTo) < 0; + vertices.push(geometry.start); + vertices.push(...arcPoints(geometry.center, geometry.start, geometry.end, clockwise)); + vertices.push(geometry.end); + } + + for (let index = cursor + 1; index < cleaned.length; index += 1) vertices.push(cleaned[index]); + return { vertices, curves, nodes }; +} From d03b46e22ecb23849be12dae5f03f0ec592a7c0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 19:07:53 +0900 Subject: [PATCH 08/10] =?UTF-8?q?chore(db):=20=EC=95=88=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EB=B9=88=20=ED=91=9C=20audit=5Flogs=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=E2=80=94=20=EC=A0=95=EB=B3=B8=EC=9D=80=20system=5F?= =?UTF-8?q?audit=5Flogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지시(2026-09-07 「묵은 것 청소」). 이름이 비슷한 표가 둘이라 **「감사 기록이 비었다」는 오진이 실제로 한 번 났던** 자리임. 지우기 전 확인(공용 DB) — `audit_logs` **0행** · 정본 `system_audit_logs` **81행** · 이 표를 참조하는 **외래키 0건** · 코드는 쓰기(`common_util_audit.py:61`) · 읽기 (`B01_Dashboard_Repository.py:455·460`) · 정리(`purge_expired_audit_logs`) 전부 정본만 씀. 최초 스키마(`001_create_schema.sql`)는 **안 고침** — 지나간 이력은 다시 쓰지 않는 것이 규칙임. 새로 설치하면 001 이 만들고 이 파일이 지움. 공용 DB 라 **적용 전 두 창(보조 워크트리 · 데스크톱)에 알리고** 「막을 것 없음」을 받은 뒤 돌림. 서버 재시작 없음. 적용 후 확인 — 표 사라짐, 정본 81행 그대로. Co-Authored-By: Claude Opus 5 (1M context) --- db_management/019_drop_legacy_audit_logs.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 db_management/019_drop_legacy_audit_logs.sql diff --git a/db_management/019_drop_legacy_audit_logs.sql b/db_management/019_drop_legacy_audit_logs.sql new file mode 100644 index 00000000..0cbd4343 --- /dev/null +++ b/db_management/019_drop_legacy_audit_logs.sql @@ -0,0 +1,16 @@ +-- 019_drop_legacy_audit_logs.sql +-- 안 쓰는 빈 표 `audit_logs` 를 지운다 (계획서 0-8 곁가지, 2026-09-07 사용자 지시). +-- +-- 왜 — 감사 기록의 **정본은 `system_audit_logs`** 다(`004_dashboard.sql`). `audit_logs` 는 +-- 최초 스키마(`001_create_schema.sql`)에만 있고 **코드가 한 곳도 쓰지 않는다** +-- (쓰기 `common_util_audit.py:61`, 읽기 `B01_Dashboard_Repository.py:455·460`, 정리 +-- `purge_expired_audit_logs` 모두 `system_audit_logs`). 표가 둘이라 「감사 기록이 비었다」는 +-- 오진이 실제로 한 번 났다(2026-09-07). +-- +-- 지우기 전 확인(2026-09-07, 공용 DB) — `audit_logs` **0행** · `system_audit_logs` 81행 · +-- 이 표를 참조하는 **외래키 0건**. 그래서 지워도 잃는 자료가 없다. +-- +-- ⚠ 최초 스키마 파일은 **고치지 않았다** — 지나간 이력이라 다시 쓰지 않는 것이 규칙이다. +-- 새로 설치하면 001 이 만들고 이 파일이 지운다. + +DROP TABLE IF EXISTS audit_logs; From f84b3d9451670e3080eadfe64accc515ea4e7137 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 19:10:07 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=E2=80=94=20R=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EB=A5=B8=20=EC=9E=90=EB=A6=AC=20=EC=98=86?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C,=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0?= =?UTF-8?q?=C2=B7=EB=8B=A4=EC=8B=9C=ED=95=98=EA=B8=B0=C2=B7=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94,=20=EC=A4=8C=C2=B7=ED=8C=AC=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 ③④⑤⑥ 과 추가 지시(되돌리기)를 한 묶음으로 처리. - **③ R 라벨** — 모달 맨 아랫줄이던 곡선 편집칸을 걷고, 고른 꺾임점 **옆에 뜨는 라벨**로 바꿈(`_Label.ts` 신설). 캔버스 밖으로 나가면 반대쪽으로 접어 넣음. 확대·이동·창 크기가 바뀌어도 고른 노드를 따라감. - **④ 노드가 손잡이보다 먼저 잡히게** — 반대였던 탓에 헤어핀처럼 곡선이 몰린 데서는 노드를 아예 못 집었음. 손잡이는 **고른 곡선에만** 그림. 머리말 안내에 조작 여섯을 적음. - **⑤ 줌·팬을 배수유역도와 동일하게**(`_Input.ts` 신설) — 휠은 **당기면 확대**(반대였음), 계수 1.15/0.87, 상한은 화면 폭 16m 기준, 하한 0.5, **팬은 가운데 버튼 전용**. ⚠ 커서 고정 계산이 좌상단 기준이라 확대할수록 지점이 밀리던 것도 중심 기준으로 고침. - **⑥ 등고선을 노선 ±300m 띠로** — 창 크기와 무관한 고정 띠라 창을 늘려도 안 깨짐. 화면 밖 걸러내기는 종전대로 `drawPreparedLayer` 가 함. - **되돌리기·다시하기·초기화**(`_History.ts` 신설) — [확인]이 무거워 되돌릴 길이 없으므로 창 안에서 물릴 수 있게 함. **끌기 한 번이 한 걸음**이고 클릭만으로는 걸음이 안 생김. [초기화]는 **이 창을 연 상태**로 (≠ [예상노선으로]). Ctrl+Z / Ctrl+Y · Ctrl+Shift+Z. - 700줄 제한 — 집기(hit test)를 `_Input.ts` 로 옮겨 본체 695줄. 실화면 검증(5174) — 라벨이 노드 오른쪽 [+18,−38]px 에 뜸 / 휠 당기니 중심에서 373.1 → 428.1px 로 확대 / 왼쪽 끌기로는 지도 안 움직임 / 가운데 버튼 끌기는 [60,40] 그대로 따라옴 / 단추 켜짐이 여섯 단계 모두 맞음. 전체 554 passed · 18 skipped, tsc 통과. 정본 안 건드림. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 360 +++++++++--------- .../B05_Profile_UI_RouteEdit_History.ts | 161 ++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 183 +++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 120 ++++++ .../B05_Profile_UI_Style_RouteEdit.css | 60 ++- 5 files changed, 684 insertions(+), 200 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_History.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Input.ts create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Label.ts diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 1d2b41fb..c54db379 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -30,14 +30,33 @@ import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +import { + bindRouteEditNavigation, + handleAtScreen, + nodeAtScreen, + segmentAtScreen, +} from "./B05_Profile_UI_RouteEdit_Input"; +import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label"; +import { + bindHistoryControls, + createRouteEditHistory, + type RouteEditHistory, + type RouteEditSnapshot, +} from "./B05_Profile_UI_RouteEdit_History"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; /** 노드 반지름(px). */ const NODE_R = 4; -/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */ -const DRAG_THRESHOLD_PX = 3; +/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ +const SEGMENT_HIT_PX = 12; +/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). + * + * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 + * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 + * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ +const CONTOUR_BAND_M = 300; /** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ const CURVE_HANDLE_PX = 5; @@ -56,28 +75,24 @@ export async function openRouteEditModal(
계획노선 편집 - 노드를 끌어 옮기고, 선을 두 번 누르면 노드가 생깁니다. 노드 오른쪽 클릭은 삭제. + 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 · + 노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
-
노선을 읽는 중… 예상노선(원본) 계획노선 + + + @@ -110,6 +125,8 @@ export async function openRouteEditModal( let curveRadius: Array = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; + /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ + let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; let sheets: PreparedLayer[] = []; let view: ViewState = { @@ -125,6 +142,7 @@ export async function openRouteEditModal( const close = (): void => { closed = true; window.removeEventListener("resize", resize); + historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -181,6 +199,34 @@ export async function openRouteEditModal( context.restore(); } + /** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이 + * 아직 없으면 null(전체를 그린다). */ + function contourBandRect(): { x: number; y: number; width: number; height: number } | null { + const line = plannedLine.length ? plannedLine : planned; + if (!meta || line.length < 2) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of line) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. + const [left, bottom] = toScreen([minX - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]); + const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; + } + function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); @@ -189,6 +235,14 @@ export async function openRouteEditModal( context.fillRect(0, 0, view.width, view.height); context.save(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = contourBandRect(); + if (band) { + context.beginPath(); + context.rect(band.x, band.y, band.width, band.height); + context.clip(); + } context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; context.lineWidth = 0.8; for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot"); @@ -239,6 +293,9 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { + // **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서 + // 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다. + if (curve.node_first !== picked) return; const on = curveOn[curve.node_first] !== false; if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. [curve.start, curve.end].forEach((point) => { @@ -257,28 +314,17 @@ export async function openRouteEditModal( }); }); context.restore(); + // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. + syncCurveBar(); } - /** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. - * - * 잡은 것을 **노드 번호**로 기억한다 — 곡선 목록은 고칠 때마다 다시 만들어지므로 목록 - * 자리(index)로 들고 있으면 끄는 도중 엉뚱한 곡선을 가리키게 된다. */ - function handleAt(px: number, py: number): { node: number; end: "start" | "end" } | null { - let best: { node: number; end: "start" | "end" } | null = null; - let bestDistance = NODE_HIT_PX + 2; - curveInfo.forEach((curve) => { - (["start", "end"] as const).forEach((which) => { - const point = which === "start" ? curve.start : curve.end; - const [x, y] = toScreen([point[0], point[1]]); - const distance = Math.hypot(x - px, y - py); - if (distance <= bestDistance) { - bestDistance = distance; - best = { node: curve.node_first, end: which }; - } - }); - }); - return best; - } + /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ + const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => + handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2); + const nodeAt = (px: number, py: number): number => + nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); + const segmentAt = (px: number, py: number): number => + segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX); /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ function dragHandleTo( @@ -294,41 +340,6 @@ export async function openRouteEditModal( return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); } - /** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ - function nodeAt(px: number, py: number): number { - let best = -1; - let bestDistance = NODE_HIT_PX; - planned.forEach((vertex, index) => { - const [x, y] = toScreen(vertex); - const distance = Math.hypot(x - px, y - py); - if (distance <= bestDistance) { - bestDistance = distance; - best = index; - } - }); - return best; - } - - /** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */ - function segmentAt(px: number, py: number): number { - let best = -1; - let bestDistance = 12; - for (let index = 0; index < planned.length - 1; index += 1) { - const [ax, ay] = toScreen(planned[index]); - const [bx, by] = toScreen(planned[index + 1]); - const dx = bx - ax; - const dy = by - ay; - const lengthSquared = dx * dx + dy * dy || 1; - const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); - const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); - if (distance < bestDistance) { - bestDistance = distance; - best = index; - } - } - return best; - } - /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 @@ -363,100 +374,101 @@ export async function openRouteEditModal( ); } - // ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ── - const curveBar = overlay.querySelector(".b05-routeedit__curve")!; - const curveLabel = curveBar.querySelector(".b05-routeedit__curve-label")!; - const curveRadiusInput = curveBar.querySelector( - ".b05-routeedit__curve-radius", - )!; - const curveInfoText = curveBar.querySelector(".b05-routeedit__curve-info")!; - const curveOffBtn = curveBar.querySelector('[data-act="curve-off"]')!; - const curveOnBtn = curveBar.querySelector('[data-act="curve-on"]')!; - const curveAutoBtn = curveBar.querySelector('[data-act="curve-auto"]')!; + // ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ── + const curveLabelBox = createCurveLabel(canvas.parentElement!, { + onRadius: (value) => { + if (picked < 0) return; + curveRadius[picked] = value; + // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); + }, + onCurveOn: (on) => { + if (picked < 0) return; + curveOn[picked] = on; + applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); + }, + onAuto: () => { + if (picked < 0) return; + curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. + applyEdit("반지름을 자동으로 되돌렸습니다."); + }, + }); - /** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */ + /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ function syncCurveBar(): void { - const editable = picked > 0 && picked < planned.length - 1; - curveBar.hidden = !editable; - if (!editable) return; - const on = curveOn[picked] !== false; - curveLabel.textContent = `${picked + 1}번째 꺾임점`; - curveOffBtn.hidden = !on; - curveOnBtn.hidden = on; - curveRadiusInput.disabled = !on; - curveAutoBtn.disabled = !on || curveRadius[picked] === null; + if (!(picked > 0 && picked < planned.length - 1)) { + curveLabelBox.hide(); + return; + } const forced = curveRadius[picked]; - const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null; - curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10); - const inner = nodeInfo[picked]?.inner_angle_deg; - curveInfoText.textContent = on - ? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + - ` · 법정 하한 ${minRadiusM}m` - : "곡선 없음 — 직선이 그대로 꺾입니다"; + curveLabelBox.show({ + seat: picked, + at: toScreen(planned[picked]), + canvas: { width: view.width, height: view.height }, + curveOn: curveOn[picked] !== false, + radiusShown: + forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null, + forced: forced !== null, + innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, + minRadiusM, + }); } - curveRadiusInput.addEventListener("change", () => { - if (picked < 0) return; - const value = Number(curveRadiusInput.value); - curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null; - // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. + /** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */ + function applyEdit(message: string, record = true): void { markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`; + status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`; draw(); + if (record) history?.commit(snapshotNow()); + historyControls.sync(); + } + + /** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */ + function snapshotNow(): RouteEditSnapshot { + return { planned, curveOn, curveRadius, picked }; + } + + const historyControls = bindHistoryControls({ + overlay, + getHistory: () => history, + restore: (snapshot, message) => { + planned = snapshot.planned; + curveOn = snapshot.curveOn; + curveRadius = snapshot.curveRadius; + picked = snapshot.picked; + applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다. + }, }); - curveOffBtn.addEventListener("click", () => { - if (picked < 0) return; - curveOn[picked] = false; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`; - draw(); - }); - - curveOnBtn.addEventListener("click", () => { - if (picked < 0) return; - curveOn[picked] = true; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`; - draw(); - }); - - curveAutoBtn.addEventListener("click", () => { - if (picked < 0) return; - curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`; - draw(); - }); - - // ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. let dragNode = -1; - /** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */ + /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ let dragHandle: { node: number; end: "start" | "end" } | null = null; - let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 + * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ + let dragMoved = false; canvas.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; const rect = canvas.getBoundingClientRect(); const px = event.clientX - rect.left; const py = event.clientY - rect.top; - // 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작). - dragHandle = handleAt(px, py); - dragNode = dragHandle ? -1 : nodeAt(px, py); - if (dragHandle) { + // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 + // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 + // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. + dragNode = nodeAt(px, py); + dragHandle = dragNode >= 0 ? null : handleAt(px, py); + dragMoved = false; + if (dragNode >= 0) { + picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + syncCurveBar(); + draw(); + } else if (dragHandle) { picked = dragHandle.node; syncCurveBar(); draw(); - } else if (dragNode >= 0) { - picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다. - syncCurveBar(); - draw(); - } else { - panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY }; } canvas.setPointerCapture(event.pointerId); }); @@ -476,6 +488,7 @@ export async function openRouteEditModal( picked = node; // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + dragMoved = true; markEdited(); syncCurveBar(); status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; @@ -484,6 +497,7 @@ export async function openRouteEditModal( return; } if (dragNode >= 0) { + dragMoved = true; planned[dragNode] = toMetric(px, py); markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 @@ -492,24 +506,20 @@ export async function openRouteEditModal( draw(); return; } - if (panFrom) { - if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return; - view = { - ...view, - offsetX: panFrom.offsetX + (px - panFrom.x), - offsetY: panFrom.offsetY + (py - panFrom.y), - }; - draw(); - return; - } - canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default"; + canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default"; }); const endDrag = (event: PointerEvent): void => { if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 + // 눌러야 한다. 놓는 순간에만 쌓는다. + if (dragMoved) { + history?.commit(snapshotNow()); + historyControls.sync(); + } dragNode = -1; dragHandle = null; - panFrom = null; + dragMoved = false; }; canvas.addEventListener("pointerup", endDrag); canvas.addEventListener("pointercancel", endDrag); @@ -525,10 +535,7 @@ export async function openRouteEditModal( curveOn.splice(segment + 1, 0, true); curveRadius.splice(segment + 1, 0, null); picked = segment + 1; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`; - draw(); + applyEdit("새 노드를 넣었습니다(직선 추가)."); }); canvas.addEventListener("contextmenu", (event) => { @@ -544,33 +551,19 @@ export async function openRouteEditModal( curveOn.splice(index, 1); curveRadius.splice(index, 1); picked = -1; - markEdited(); - syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`; - draw(); + applyEdit("노드를 지웠습니다(직선 삭제)."); }); - canvas.addEventListener( - "wheel", - (event) => { - event.preventDefault(); - const rect = canvas.getBoundingClientRect(); - const px = event.clientX - rect.left; - const py = event.clientY - rect.top; - const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2; - const nextScale = Math.max(1, Math.min(2000, view.scale * factor)); - const ratio = nextScale / view.scale; - // 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다. - view = { - ...view, - scale: nextScale, - offsetX: px - (px - view.offsetX) * ratio, - offsetY: py - (py - view.offsetY) * ratio, - }; - draw(); + // 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용. + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => { + view = next; }, - { passive: false }, - ); + getMeta: () => meta, + draw, + }); async function runHeavy(label: string, task: () => Promise): Promise { busy.hidden = false; @@ -667,6 +660,9 @@ export async function openRouteEditModal( if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat }); }); picked = -1; + // 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로. + history = createRouteEditHistory(snapshotNow()); + historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_History.ts b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts new file mode 100644 index 00000000..a460c458 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_History.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_History.ts + * 노선 편집의 **되돌리기·다시하기·초기화** (2026-09-07 사용자 지시). + * + * 왜 필요한가 — [확인]은 배수유역부터 다시 도는 무거운 작업이라 되돌릴 길이 없다. 그러니 + * **창 안에서** 실수를 물릴 수 있어야 한다. 여기서 말하는 [초기화]는 **이 창을 연 상태**로 + * 돌아가는 것이지, 예상노선으로 되돌리는 것(`[예상노선으로]`, 서버 재계산)이 아니다. + * + * 값을 통째로 사진처럼 담는다(델타 아님) — 노드·곡선 켬끔·반지름이 서로 엮여 있어 델타로 + * 쪼개면 되돌릴 때 어긋나기 쉽다. 노선 하나가 노드 수십 개라 사진 몇 벌은 가볍다. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 되돌릴 수 있는 편집 상태 한 벌. */ +export interface RouteEditSnapshot { + planned: Vertex[]; + curveOn: boolean[]; + curveRadius: Array; + picked: number; +} + +/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */ +const MAX_STEPS = 100; + +export interface RouteEditHistory { + /** 편집 한 번이 끝났다 — 지금 상태를 사진으로 쌓는다(다시하기 갈래는 버린다). */ + commit: (snapshot: RouteEditSnapshot) => void; + /** 한 걸음 뒤로. 되돌릴 것이 없으면 null. */ + undo: () => RouteEditSnapshot | null; + /** 한 걸음 앞으로. 없으면 null. */ + redo: () => RouteEditSnapshot | null; + /** 창을 연 상태로. 이미 그 상태면 null. */ + reset: () => RouteEditSnapshot | null; + canUndo: () => boolean; + canRedo: () => boolean; + /** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */ + isDirty: () => boolean; +} + +/** 사진을 깊이 복사한다 — 배열을 그대로 담으면 뒤이은 편집이 과거까지 바꾼다. */ +function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot { + return { + planned: snapshot.planned.map(([x, y]): Vertex => [x, y]), + curveOn: [...snapshot.curveOn], + curveRadius: [...snapshot.curveRadius], + picked: snapshot.picked, + }; +} + +/** 두 사진이 **같은 노선**인가 — [초기화]를 켤지 정할 때 쓴다. 고른 자리는 안 본다. */ +function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean { + if (a.planned.length !== b.planned.length) return false; + for (let index = 0; index < a.planned.length; index += 1) { + if (a.planned[index][0] !== b.planned[index][0]) return false; + if (a.planned[index][1] !== b.planned[index][1]) return false; + if (a.curveOn[index] !== b.curveOn[index]) return false; + if (a.curveRadius[index] !== b.curveRadius[index]) return false; + } + return true; +} + +/** 첫 사진(창을 연 상태)으로 이력을 연다. */ +export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHistory { + const steps: RouteEditSnapshot[] = [clone(initial)]; + let at = 0; + + return { + commit(snapshot) { + // 되돌린 뒤 새로 고치면 앞쪽 갈래는 버린다 — 흔한 되돌리기 규칙 그대로. + steps.length = at + 1; + steps.push(clone(snapshot)); + if (steps.length > MAX_STEPS) steps.shift(); + at = steps.length - 1; + }, + undo() { + if (at <= 0) return null; + at -= 1; + return clone(steps[at]); + }, + redo() { + if (at >= steps.length - 1) return null; + at += 1; + return clone(steps[at]); + }, + reset() { + // 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다. + if (sameRoute(steps[at], steps[0])) return null; + // 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다. + steps.length = at + 1; + steps.push(clone(steps[0])); + at = steps.length - 1; + return clone(steps[at]); + }, + canUndo: () => at > 0, + canRedo: () => at < steps.length - 1, + isDirty: () => !sameRoute(steps[at], steps[0]), + }; +} + +export interface HistoryControlsParams { + /** 단추가 들어 있는 칸 — `[data-act]` 로 찾는다. */ + overlay: HTMLElement; + /** 아직 노선을 못 읽었으면 null 이다(단추는 꺼진 채로 둔다). */ + getHistory: () => RouteEditHistory | null; + /** 사진 한 벌을 화면에 되살린다. */ + restore: (snapshot: RouteEditSnapshot, message: string) => void; +} + +/** [초기화]·[되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다. + * + * 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을 + * 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */ +export function bindHistoryControls(params: HistoryControlsParams): { + sync: () => void; + dispose: () => void; +} { + const { overlay, getHistory, restore } = params; + const undoBtn = overlay.querySelector('[data-act="undo"]')!; + const redoBtn = overlay.querySelector('[data-act="redo"]')!; + const resetBtn = overlay.querySelector('[data-act="history-reset"]')!; + + const sync = (): void => { + const history = getHistory(); + undoBtn.disabled = !history?.canUndo(); + redoBtn.disabled = !history?.canRedo(); + resetBtn.disabled = !history?.isDirty(); + }; + + const step = (which: "undo" | "redo" | "reset"): void => { + const history = getHistory(); + if (!history) return; + const snapshot = history[which](); + if (!snapshot) return; + restore( + snapshot, + which === "undo" + ? "되돌렸습니다." + : which === "redo" + ? "다시 했습니다." + : "창을 연 상태로 돌렸습니다.", + ); + sync(); + }; + + undoBtn.addEventListener("click", () => step("undo")); + redoBtn.addEventListener("click", () => step("redo")); + resetBtn.addEventListener("click", () => step("reset")); + + const onKey = (event: KeyboardEvent): void => { + if (!(event.ctrlKey || event.metaKey)) return; + const key = event.key.toLowerCase(); + if (key === "z" && !event.shiftKey) step("undo"); + else if (key === "y" || (key === "z" && event.shiftKey)) step("redo"); + else return; + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + + return { sync, dispose: () => window.removeEventListener("keydown", onKey) }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts new file mode 100644 index 00000000..a73dd52e --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -0,0 +1,183 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Input.ts + * 노선 편집 모달의 **지도 조작** — 휠 확대/축소와 가운데 버튼 팬. + * + * ⚠ 배수유역도(`B05_Profile_UI_Drainage_Interact.ts`)와 **같은 동작**이어야 한다 + * (2026-09-07 사용자 지시 ⑤). 한쪽만 고치면 두 지도가 서로 다르게 움직인다. + * · 휠을 **당기면 확대**(`deltaY > 0` → 확대) · 계수 1.15 / 0.87 + * · 배율 상한은 「화면 폭 16m」로 계산(`computeMaxScale`), 하한 0.5 + * · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼이 팬까지 겸하면 노드를 집으려다 지도가 + * 딸려 움직인다. 가운데 버튼이 없는 터치·펜은 그대로 끌어서 팬 한다. + * + * ⚠ 커서 고정은 **화면 중심 기준**으로 셈한다 — `affineOf` 가 `centerX*(1−scale)` 을 품고 + * 있어 좌상단 기준으로 셈하면 확대할수록 커서 아래 지점이 밀린다(옛 모달의 버그). + * ========================================================================== */ + +import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */ +const ZOOM_IN_FACTOR = 1.15; +const ZOOM_OUT_FACTOR = 0.87; +/** 배율 하한 — 배수유역도와 같다(전체보다 조금 더 뒤로 뺄 수 있게). */ +const MIN_SCALE = 0.5; +/** 배율 상한을 못 구할 때 쓸 값 — 배수유역도와 같은 자리에 같은 수. */ +const MAX_SCALE_FALLBACK = 16; + +export interface RouteEditNavigationParams { + canvas: HTMLCanvasElement; + getView: () => ViewState; + setView: (next: ViewState) => void; + getMeta: () => VWorldMeta | null; + draw: () => void; +} + +/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */ +export function bindRouteEditNavigation(params: RouteEditNavigationParams): void { + const { canvas, getView, setView, getMeta, draw } = params; + let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null; + + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const view = getView(); + const maxScale = computeMaxScale( + getMeta(), + view.mapRect.width, + view.width, + MAX_SCALE_FALLBACK, + ); + const factor = event.deltaY > 0 ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; + const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor)); + const ratio = scale / view.scale; + const rect = canvas.getBoundingClientRect(); + // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. + const cursorX = event.clientX - rect.left - rect.width / 2; + const cursorY = event.clientY - rect.top - rect.height / 2; + setView({ + ...view, + scale, + offsetX: cursorX * (1 - ratio) + view.offsetX * ratio, + offsetY: cursorY * (1 - ratio) + view.offsetY * ratio, + }); + draw(); + }, + { passive: false }, + ); + + canvas.addEventListener("pointerdown", (event) => { + // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. + if (event.button === 1) event.preventDefault(); + // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. + if (event.pointerType === "mouse" && event.button !== 1) return; + const view = getView(); + dragStart = { + x: event.clientX, + y: event.clientY, + offsetX: view.offsetX, + offsetY: view.offsetY, + }; + canvas.style.cursor = "grabbing"; + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener("pointermove", (event) => { + if (!dragStart) return; + setView({ + ...getView(), + offsetX: dragStart.offsetX + event.clientX - dragStart.x, + offsetY: dragStart.offsetY + event.clientY - dragStart.y, + }); + draw(); + }); + + const stop = (): void => { + if (!dragStart) return; + dragStart = null; + canvas.style.removeProperty("cursor"); + }; + canvas.addEventListener("pointerup", stop); + canvas.addEventListener("pointercancel", stop); +} + +/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */ + +export type ScreenOf = (point: [number, number]) => [number, number]; + +/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */ +export function nodeAtScreen( + points: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number, +): number { + let best = -1; + let bestDistance = hitPx; + points.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = index; + } + }); + return best; +} + +/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. + * + * **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라 + * **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한 + * 곡선을 가리킨다. */ +export function handleAtScreen( + curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, + picked: number, + toScreen: ScreenOf, + px: number, + py: number, + hitPx: number, +): { node: number; end: "start" | "end" } | null { + let best: { node: number; end: "start" | "end" } | null = null; + let bestDistance = hitPx; + curves.forEach((curve) => { + if (curve.node_first !== picked) return; + (["start", "end"] as const).forEach((which) => { + const point = which === "start" ? curve.start : curve.end; + const [x, y] = toScreen([point[0], point[1]]); + const distance = Math.hypot(x - px, y - py); + if (distance <= bestDistance) { + bestDistance = distance; + best = { node: curve.node_first, end: which }; + } + }); + }); + return best; +} + +/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */ +export function segmentAtScreen( + points: Array<[number, number]>, + toScreen: ScreenOf, + px: number, + py: number, + maxPx: number, +): number { + let best = -1; + let bestDistance = maxPx; + for (let index = 0; index < points.length - 1; index += 1) { + const [ax, ay] = toScreen(points[index]); + const [bx, by] = toScreen(points[index + 1]); + const dx = bx - ax; + const dy = by - ay; + const lengthSquared = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared)); + const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + } + return best; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts new file mode 100644 index 00000000..f6538512 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Label.ts + * 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다. + * + * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 + * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. + * 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다. + * + * 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을 + * 골라도 입력칸이 잘리지 않아야 한다. + * ========================================================================== */ + +/** 라벨과 노드 사이 여백(px). */ +const GAP_PX = 14; +/** 캔버스 가장자리에서 이만큼은 띄운다(px). */ +const EDGE_PX = 8; + +export interface CurveLabelState { + /** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */ + seat: number; + /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ + at: [number, number]; + /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ + canvas: { width: number; height: number }; + curveOn: boolean; + /** 지금 보일 반지름(m). 곡선이 없으면 null. */ + radiusShown: number | null; + /** 사용자가 못박은 값인지(아니면 자동). */ + forced: boolean; + innerAngleDeg: number | null; + minRadiusM: number; +} + +export interface CurveLabelHandlers { + /** R 칸을 고쳤다 — null 이면 「자동」. */ + onRadius: (value: number | null) => void; + /** 곡선을 지우거나 넣었다. */ + onCurveOn: (on: boolean) => void; + /** 반지름을 자동으로 되돌렸다. */ + onAuto: () => void; +} + +export interface CurveLabel { + show: (state: CurveLabelState) => void; + hide: () => void; +} + +/** 라벨을 만들어 `host`(캔버스를 감싼 칸, `position: relative`)에 붙인다. */ +export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers): CurveLabel { + const root = document.createElement("div"); + root.className = "b05-routeedit__label"; + root.hidden = true; + root.innerHTML = ` +
+ + +
+ + `; + host.append(root); + + const seatText = root.querySelector(".b05-routeedit__curve-label")!; + const toggle = root.querySelector('[data-act="curve-toggle"]')!; + const radius = root.querySelector(".b05-routeedit__curve-radius")!; + const auto = root.querySelector('[data-act="curve-auto"]')!; + const info = root.querySelector(".b05-routeedit__curve-info")!; + + // 라벨 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다. + root.addEventListener("pointerdown", (event) => event.stopPropagation()); + root.addEventListener("dblclick", (event) => event.stopPropagation()); + root.addEventListener("contextmenu", (event) => event.stopPropagation()); + + let curveOn = true; + radius.addEventListener("change", () => { + const value = Number(radius.value); + handlers.onRadius(Number.isFinite(value) && value > 0 ? value : null); + }); + toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + auto.addEventListener("click", () => handlers.onAuto()); + + function place(at: [number, number], canvas: { width: number; height: number }): void { + // 먼저 보여야 크기를 잴 수 있다 — 그 다음 자리를 잡는다. + const width = root.offsetWidth; + const height = root.offsetHeight; + // 기본은 오른쪽. 오른쪽이 좁으면 왼쪽으로 접는다(사용자 말대로 「우측에」가 기본). + let left = at[0] + GAP_PX; + if (left + width > canvas.width - EDGE_PX) left = at[0] - GAP_PX - width; + left = Math.max(EDGE_PX, Math.min(left, canvas.width - width - EDGE_PX)); + let top = at[1] - height / 2; + top = Math.max(EDGE_PX, Math.min(top, canvas.height - height - EDGE_PX)); + root.style.left = `${Math.round(left)}px`; + root.style.top = `${Math.round(top)}px`; + } + + return { + show(state) { + curveOn = state.curveOn; + root.hidden = false; + seatText.textContent = `${state.seat + 1}번째 꺾임점`; + toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; + radius.disabled = !state.curveOn; + auto.disabled = !state.curveOn || !state.forced; + radius.value = + state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10); + const inner = state.innerAngleDeg; + info.textContent = state.curveOn + ? `${state.forced ? "값 지정" : "자동"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` + + ` · 법정 하한 ${state.minRadiusM}m` + : "곡선 없음 — 직선이 그대로 꺾입니다"; + place(state.at, state.canvas); + }, + hide() { + root.hidden = true; + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 9933d6fb..7e6bd7d0 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -131,21 +131,47 @@ text-align: center; } -/* 곡선 편집줄 — 고른 꺾임점의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시). - 바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */ -.b05-routeedit__curve { - /* 바닥줄과 같이 **줄어들지 않게** 둔다 — 안 그러면 캔버스(flex:1)가 자리를 다 먹고 - 이 줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */ - flex: none; - position: relative; - z-index: 1; +/* R 라벨 — 고른 꺾임점 **옆에 떠 있는** 작은 상자(2026-09-07 사용자 지시 ③). + 예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다. + 자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */ +.b05-routeedit__label { + position: absolute; + z-index: 2; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 11rem; + padding: var(--spacing-8, 8px); + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-6, 6px); + background: var(--color-surface-2, #f9fafb); + box-shadow: 0 4px 14px rgb(0 0 0 / 25%); + font-size: var(--font-size-13, 13px); +} + +.b05-routeedit__label-head { display: flex; align-items: center; - gap: var(--spacing-8); - padding: var(--spacing-8) var(--spacing-12); - border-top: 1px solid var(--color-border, #e5e7eb); - background: var(--color-surface-2, #f9fafb); - font-size: var(--font-size-13, 13px); + justify-content: space-between; + gap: var(--spacing-8, 8px); +} + +.b05-routeedit__label-toggle, +.b05-routeedit__label-auto { + padding: 1px 6px; + border: 1px solid var(--color-border, #e5e7eb); + border-radius: var(--radius-4, 4px); + background: var(--color-surface, #fff); + color: inherit; + font: inherit; + font-size: var(--font-size-12, 12px); + cursor: pointer; +} + +.b05-routeedit__label-toggle:disabled, +.b05-routeedit__label-auto:disabled { + color: var(--color-text-muted, #9ca3af); + cursor: default; } .b05-routeedit__curve-label { @@ -161,7 +187,7 @@ } .b05-routeedit__curve-radius { - width: 5.5rem; + width: 4.5rem; padding: 2px 6px; border: 1px solid var(--color-border, #e5e7eb); border-radius: var(--radius-4, 4px); @@ -175,9 +201,7 @@ } .b05-routeedit__curve-info { - flex: 1 1 auto; + max-width: 16rem; color: var(--color-text-muted, #6b7280); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 1.35; } From 4300be838510f432285f13adab8bb7fd31bcbd8c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 19:28:02 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix(B05):=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EC=A0=91=EC=84=A0=EC=A0=90=EC=9D=84=20?= =?UTF-8?q?=EB=8A=98=20=EB=B3=B4=EC=9D=B4=EA=B2=8C,=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EC=9D=84=20R+=EA=B3=A1=EC=84=A0=20=EA=B8=B8=EC=9D=B4=EB=A1=9C,?= =?UTF-8?q?=20=EC=A4=91=EC=8B=AC=20=EB=B0=98=EB=8C=80=EC=AA=BD=EC=97=90=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 3건 반영. - **접선점 표기 복구** — 손잡이를 「고른 곡선만」으로 줄이면서 직선↔R 만나는 자리의 **표기까지 없앴음**. 접선점은 손잡이이기 이전에 읽을 정보라 **늘 그림**(고른 곡선은 속을 채워 도드라지게). 노드를 못 집던 문제는 집기 우선순위로 이미 풀려 겹치지 않음. - **라벨을 반지름 + 곡선 길이 두 칸으로, [자동] 단추 삭제** — 교각 Δ 는 앞뒤 직선이 정하므로 L = R·Δ 로 묶임. 길이를 받으면 R 로 바꿔 한 값만 보관. 칸을 비우면 자동. - **라벨 자리를 곡선 중심의 반대쪽으로**(상하좌우) — 중심 쪽에 두면 곡선을 가림. 중심 방향은 접선점 두 방향 단위벡터의 합(각 이등분선)으로 구함. - ⚠ 라벨 글자가 안 읽히던 것 — `--color-surface-2` 가 이 테마에 없어 밝은 기본값으로 떨어졌음. 모달 본체와 같은 토큰으로 교체(실측 배경 rgb(37,31,56)·글자 rgb(228,224,240)). - 상자 높이가 늦게 자라 자리가 11px 어긋나던 것도 다음 프레임에 다시 맞추게 함. - 700줄 유지 — 교각·중심방향은 `_Label`, 등고선 띠는 `_Input` 으로 옮겨 본체 687줄. 실화면 검증 — 접선점이 고르기 전에도 보임 / 곡선 길이 40 → 반지름 203.7m 로 따라옴 / 라벨이 중심 반대쪽(위)에 붙음 / [자동] 없음. 554 passed · 18 skipped, tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 92 +++++++------- B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 42 ++++++- B05_Profile/B05_Profile_UI_RouteEdit_Label.ts | 112 ++++++++++++++---- .../B05_Profile_UI_Style_RouteEdit.css | 56 +++++---- 4 files changed, 194 insertions(+), 108 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index c54db379..09a1f611 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -32,11 +32,16 @@ import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; import { bindRouteEditNavigation, + contourBandRect, handleAtScreen, nodeAtScreen, segmentAtScreen, } from "./B05_Profile_UI_RouteEdit_Input"; -import { createCurveLabel } from "./B05_Profile_UI_RouteEdit_Label"; +import { + centerDirectionOf, + createCurveLabel, + deflectionRad, +} from "./B05_Profile_UI_RouteEdit_Label"; import { bindHistoryControls, createRouteEditHistory, @@ -199,34 +204,6 @@ export async function openRouteEditModal( context.restore(); } - /** 등고선을 보일 화면 사각형 — 노선 경계에 `CONTOUR_BAND_M` 를 두른 것. - * - * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 노선이 - * 아직 없으면 null(전체를 그린다). */ - function contourBandRect(): { x: number; y: number; width: number; height: number } | null { - const line = plannedLine.length ? plannedLine : planned; - if (!meta || line.length < 2) return null; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const [x, y] of line) { - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. - const [left, bottom] = toScreen([minX - CONTOUR_BAND_M, minY - CONTOUR_BAND_M]); - const [right, top] = toScreen([maxX + CONTOUR_BAND_M, maxY + CONTOUR_BAND_M]); - return { - x: Math.min(left, right), - y: Math.min(top, bottom), - width: Math.abs(right - left), - height: Math.abs(bottom - top), - }; - } - function draw(): void { if (closed) return; const style = getComputedStyle(document.documentElement); @@ -237,7 +214,9 @@ export async function openRouteEditModal( context.save(); // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). - const band = contourBandRect(); + const band = meta + ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) + : null; if (band) { context.beginPath(); context.rect(band.x, band.y, band.width, band.height); @@ -293,21 +272,21 @@ export async function openRouteEditModal( // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. context.lineWidth = 2; curveInfo.forEach((curve) => { - // **고른 곡선에만** 손잡이를 낸다(2026-09-07 사용자 지적 ④) — 전부 내놓으면 헤어핀에서 - // 노드 위를 덮어 노드를 못 집는다. 먼저 노드를 눌러 고르고, 그 다음 손잡이를 끈다. - if (curve.node_first !== picked) return; + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. const on = curveOn[curve.node_first] !== false; - if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다. + if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === picked; [curve.start, curve.end].forEach((point) => { const [x, y] = toScreen([point[0], point[1]]); context.beginPath(); - context.rect( - x - CURVE_HANDLE_PX, - y - CURVE_HANDLE_PX, - CURVE_HANDLE_PX * 2, - CURVE_HANDLE_PX * 2, - ); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; context.fill(); context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; context.stroke(); @@ -320,7 +299,7 @@ export async function openRouteEditModal( /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => - handleAtScreen(curveInfo, picked, toScreen, px, py, NODE_HIT_PX + 2); + handleAtScreen(curveInfo, toScreen, px, py, NODE_HIT_PX + 2); const nodeAt = (px: number, py: number): number => nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); const segmentAt = (px: number, py: number): number => @@ -374,7 +353,7 @@ export async function openRouteEditModal( ); } - // ── R 라벨 — 고른 꺾임점 **옆에** 뜬다(2026-09-07 사용자 지시 ③). 그리기는 `_Label` 몫 ── + // ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ── const curveLabelBox = createCurveLabel(canvas.parentElement!, { onRadius: (value) => { if (picked < 0) return; @@ -382,16 +361,19 @@ export async function openRouteEditModal( // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."); }, + onArcLength: (value) => { + if (picked < 0) return; + // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). + // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); + curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null; + applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."); + }, onCurveOn: (on) => { if (picked < 0) return; curveOn[picked] = on; applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다."); }, - onAuto: () => { - if (picked < 0) return; - curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다. - applyEdit("반지름을 자동으로 되돌렸습니다."); - }, }); /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */ @@ -401,13 +383,23 @@ export async function openRouteEditModal( return; } const forced = curveRadius[picked]; + const pickedCurve = curveInfo.find((entry) => entry.node_first === picked); + const shown = forced ?? pickedCurve?.radius_m ?? null; + const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg); curveLabelBox.show({ seat: picked, at: toScreen(planned[picked]), + centerDirection: pickedCurve + ? centerDirectionOf( + toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), + toScreen(pickedCurve.start), + toScreen(pickedCurve.end), + ) + : null, canvas: { width: view.width, height: view.height }, curveOn: curveOn[picked] !== false, - radiusShown: - forced ?? curveInfo.find((entry) => entry.node_first === picked)?.radius_m ?? null, + radiusShown: shown, + arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection, forced: forced !== null, innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null, minRadiusM, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index a73dd52e..89555c43 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -126,14 +126,14 @@ export function nodeAtScreen( return best; } -/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. +/** 화면 좌표에 가장 가까운 **곡선 손잡이**(접선점). 없으면 null. * - * **고른 곡선만** 본다 — 그려 보이는 것만 잡혀야 한다. 잡은 것은 곡선 목록 자리가 아니라 - * **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 자리로 들면 끄는 도중 엉뚱한 - * 곡선을 가리킨다. */ + * 곡선 **전부**를 본다 — 접선점은 늘 그려지므로 늘 잡혀야 한다. 노드를 못 집던 문제는 + * 부르는 쪽에서 **노드를 먼저** 보는 것으로 풀었다(2026-09-07). + * 잡은 것은 곡선 목록 자리가 아니라 **노드 번호**로 돌려준다: 목록은 고칠 때마다 새로 나므로 + * 자리로 들면 끄는 도중 엉뚱한 곡선을 가리킨다. */ export function handleAtScreen( curves: Array<{ node_first: number; start: [number, number]; end: [number, number] }>, - picked: number, toScreen: ScreenOf, px: number, py: number, @@ -142,7 +142,6 @@ export function handleAtScreen( let best: { node: number; end: "start" | "end" } | null = null; let bestDistance = hitPx; curves.forEach((curve) => { - if (curve.node_first !== picked) return; (["start", "end"] as const).forEach((which) => { const point = which === "start" ? curve.start : curve.end; const [x, y] = toScreen([point[0], point[1]]); @@ -181,3 +180,34 @@ export function segmentAtScreen( } return best; } + +/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null. + * + * **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는 + * 미터로 정해 두므로 **창 크기와 무관**하다(2026-09-07 사용자 지시 ⑥). */ +export function contourBandRect( + line: Array<[number, number]>, + toScreen: ScreenOf, + bandM: number, +): { x: number; y: number; width: number; height: number } | null { + if (line.length < 2) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [x, y] of line) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + // 사업지 좌표(m)에서 띠를 두르고 화면으로 옮긴다 — y 는 화면에서 뒤집힌다. + const [left, bottom] = toScreen([minX - bandM, minY - bandM]); + const [right, top] = toScreen([maxX + bandM, maxY + bandM]); + return { + x: Math.min(left, right), + y: Math.min(top, bottom), + width: Math.abs(right - left), + height: Math.abs(bottom - top), + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index f6538512..9096e81b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -1,15 +1,47 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Label.ts - * 고른 꺾임점 **옆에 뜨는 R 라벨** — 반지름을 바꾸고, 곡선을 지우고 넣는다. + * 고른 꺾임점 **옆에 뜨는 곡선 라벨** — 반지름과 곡선 길이로 곡선을 만진다. * * 왜 옮겼나(2026-09-07 사용자 지시 ③) — 예전에는 모달 **맨 아래 줄**이었다. 지금 고른 것이 * 지도 어디인지 눈으로 이어지지 않아, 값을 바꾸면서도 어느 곡선을 만지는지 몰랐다. - * 「해당 요소를 선택하면 우측에 라벨 같은 입력이 보이게」가 사용자의 말 그대로다. * - * 캔버스 밖으로 나가지 않게 가장자리에서 **반대쪽으로 접어 넣는다** — 노선 끝의 꺾임점을 - * 골라도 입력칸이 잘리지 않아야 한다. + * **R 과 곡선 길이 두 칸이 한 쌍이다**(2026-09-07 사용자 지시) — 교각(Δ)은 앞뒤 직선이 + * 정하므로 둘은 L = R·Δ 로 묶여 있다. 한쪽을 고치면 다른 쪽이 따라온다. 「반지름 자동」 + * 단추는 없앴다 — 칸을 비우는 것이 곧 자동이다. + * + * **자리는 곡선 중심의 반대쪽**(2026-09-07 사용자 지시) — 중심 쪽에 두면 라벨이 곡선을 + * 가린다. 상하좌우 네 방향 중 중심에서 먼 쪽에 붙이고, 캔버스 밖으로 나가면 안으로 접는다. * ========================================================================== */ +/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */ +export function deflectionRad(innerAngleDeg: number | null | undefined): number { + if (innerAngleDeg === null || innerAngleDeg === undefined) return 0; + return ((180 - innerAngleDeg) * Math.PI) / 180; +} + +/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 라벨을 그 반대쪽에 붙이는 데 쓴다. + * + * 중심은 접선점 둘이 이루는 각의 이등분선 위에 있다. 접선점은 교각점에서 앞뒤 직선을 따라 + * 뻗은 자리이므로, 두 방향의 단위벡터를 더하면 그대로 중심 쪽이다. 셋이 한 점이면 null. */ +export function centerDirectionOf( + apex: [number, number], + start: [number, number], + end: [number, number], +): [number, number] | null { + const arm = (point: [number, number]): [number, number] => { + const dx = point[0] - apex[0]; + const dy = point[1] - apex[1]; + const length = Math.hypot(dx, dy); + return length <= 1e-9 ? [0, 0] : [dx / length, dy / length]; + }; + const [ux, uy] = arm(start); + const [vx, vy] = arm(end); + const sx = ux + vx; + const sy = uy + vy; + const length = Math.hypot(sx, sy); + return length <= 1e-9 ? null : [sx / length, sy / length]; +} + /** 라벨과 노드 사이 여백(px). */ const GAP_PX = 14; /** 캔버스 가장자리에서 이만큼은 띄운다(px). */ @@ -20,11 +52,16 @@ export interface CurveLabelState { seat: number; /** 그 꺾임점의 화면 좌표(캔버스 기준 px). */ at: [number, number]; + /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 라벨은 이 반대쪽에 붙는다. + * 곡선이 없으면 null — 그때는 오른쪽에 둔다. */ + centerDirection: [number, number] | null; /** 캔버스 크기(px) — 라벨을 안쪽으로 접어 넣는 데 쓴다. */ canvas: { width: number; height: number }; curveOn: boolean; /** 지금 보일 반지름(m). 곡선이 없으면 null. */ radiusShown: number | null; + /** 지금 보일 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ + arcLengthShown: number | null; /** 사용자가 못박은 값인지(아니면 자동). */ forced: boolean; innerAngleDeg: number | null; @@ -32,12 +69,12 @@ export interface CurveLabelState { } export interface CurveLabelHandlers { - /** R 칸을 고쳤다 — null 이면 「자동」. */ + /** R 칸을 고쳤다 — null 이면 「자동」(칸을 비운 것). */ onRadius: (value: number | null) => void; + /** 곡선 길이 칸을 고쳤다 — null 이면 「자동」. */ + onArcLength: (value: number | null) => void; /** 곡선을 지우거나 넣었다. */ onCurveOn: (on: boolean) => void; - /** 반지름을 자동으로 되돌렸다. */ - onAuto: () => void; } export interface CurveLabel { @@ -55,10 +92,13 @@ export function createCurveLabel(host: HTMLElement, handlers: CurveLabelHandlers
-