From e250517adf9c954daec512e499e58946894a930e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 22:42:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=EB=85=B8=EC=84=A0=20=EB=B3=80=EA=B2=BD=20=3D=20?= =?UTF-8?q?=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B8=8B=EA=B8=B0=20=C2=B7=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=A9=94=EB=89=B4=20=C2=B7=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=ED=8C=90=20=EA=B7=B8=EB=8C=80=EB=A1=9C(=ED=95=9C?= =?UTF-8?q?=20=EC=84=A0)=20=C2=B7=20=EC=9B=90=EB=B3=B8=20=EC=98=85?= =?UTF-8?q?=EC=9D=80=20=EC=A0=90=EC=84=A0=20=EC=BC=AC=EB=81=94=20(PLAN=206?= =?UTF-8?q?26-3=20=C2=B7=20=EC=9A=94=EA=B5=AC=20535-1=20=C2=B7=20536=20?= =?UTF-8?q?=C2=B7=20535-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05 「예정노선 변경」 켬끔 = D03 createPlanDraw · createPlanEdit · createPlanMenu · createPlanProps 를 한 선 network 로 얹음(같은 함수 · 단추 · 단축키 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete · [다시 긋기]) — 갈래 · 둘째 선 · 연도 · 지점은 막음/숨김 - 한 번 고침(긋기 끝 · 놓음 · 지움) → 켠 뒤 첫 고침만 확인창 → `/route/follow` → 편집기 한 걸음 · 다 지우면 새로 그을 때까지 평면 그대로 - 도구줄 「예정노선(원본)」 켬끔 — 파일입력 예정노선(안 덮음)을 옅은 점선으로(고친 것과 다를 때만) - D03 덧 인자 `oneLine`(UI_Draw · UI_DrawPanel 연도 마커 · UI_Menu · UI_Props) — 기본값 = 지금 그대로 · 긋기 칸 · 속성 판 꼴 CSS 를 `D03_PlanAnalysis_UI_DrawParts.css` 로 옮겨 그 TS 가 불러옴(D03 꼴 같음) - 옛 자체 긋기 셈 `logic/B05_Profile_ExpectedDraw.ts` 지움(D03 셈으로 갈음) · 시험 test_626_3 다시 씀(5) - 낡은 글 고침 — 설계 지형 바꿈 = 지형 칩(`/route/design-terrain`) · 「추천 [적용] 때만」 글 걷음(Router_Terrain · Router_Replan · Api_Replan · RouteEdit_Apply · 동작 그대로) Co-Authored-By: Claude Opus 5.5 --- A00_Common/map/map_toolbar.ts | 3 +- B05_Profile/B05_Profile_Api_Replan.ts | 4 +- B05_Profile/B05_Profile_Router_Replan.py | 2 +- B05_Profile/B05_Profile_Router_Terrain.py | 4 +- B05_Profile/B05_Profile_UI_ExpectedDraw.ts | 591 +++++++++--------- B05_Profile/B05_Profile_UI_RouteEdit.ts | 4 +- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 2 +- .../B05_Profile_UI_RouteEdit_Chrome.ts | 5 +- .../B05_Profile_UI_Style_RouteEdit.css | 6 + B05_Profile/logic/B05_Profile_ExpectedDraw.ts | 116 ---- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 20 + .../D03_PlanAnalysis_UI_DrawPanel.ts | 9 +- .../D03_PlanAnalysis_UI_DrawParts.css | 50 ++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 10 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 11 +- .../D03_PlanAnalysis_UI_Style.css | 48 -- resources/tester/test_626_3_expected_edit.py | 138 ++-- ui_template/ui_template_map_tools.ts | 8 +- 18 files changed, 451 insertions(+), 580 deletions(-) delete mode 100644 B05_Profile/logic/B05_Profile_ExpectedDraw.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawParts.css diff --git a/A00_Common/map/map_toolbar.ts b/A00_Common/map/map_toolbar.ts index 5e432337f..25ba4acd1 100644 --- a/A00_Common/map/map_toolbar.ts +++ b/A00_Common/map/map_toolbar.ts @@ -48,9 +48,10 @@ export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [ [ { key: "expected-draw", - does: "예정노선 그리기 판 켬끔 — 고친 예정노선 따라 평면선 다시 추적(626-3)", + does: "예정노선 변경 켬끔 — 예정노선 페이지 긋기 그대로 · 고친 예정노선 따라 평면선 다시 추적(626-3)", pages: B05, }, + { key: "expected-original", does: "예정노선(원본) 옅은 점선 켬끔(626-3)", pages: B05 }, ], [ { diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index f4cd8997e..137154716 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -6,8 +6,8 @@ * POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial) * GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도) * - * 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이 - * 그 지형을 설계 지형으로(509-5). + * 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 설계 지형 바꿈 = 평면 도구줄 지형 칩 + * (`/route/design-terrain` · 626-1 · `_Terrain`). * * 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대) * 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다. diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index a8df095cd..22c6c7230 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -573,7 +573,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any if request.balanced_profile and as_initial else None ), - request.terrain if as_initial else None, # 지형 바꿈 = 추천 [적용]만(509-5) + request.terrain if as_initial else None, # 손 [저장] = 지형 그대로 · 바꿈 = 지형 칩(626-1) (old_line, await asyncio.to_thread(_vertices_of, working_path)), as_initial, ) diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 1a5b951dd..d796d0d20 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -13,7 +13,9 @@ GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag) 분석 지형(`terrain`, PLAN 133-1) — las = 라이다 · sheet = 수치지형도 · None = 지금 설계 지형. -고르기는 추천 노선 창 안(509-5) — 설계 지형(확정)은 추천 [적용] 때만 바뀜(`/route/replan`). +고르기 = 평면 도구줄 지형 칩(626-1) — 설계 지형(확정)은 지형 칩 +→ `POST /route/design-terrain` 때만 바뀜(손 [저장] = 지형 그대로 · 옛 추천 [적용] `as_initial` 길은 +서버 · 시험만 남음). """ import asyncio diff --git a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts index 7fc96d09e..150209ca1 100644 --- a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts +++ b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts @@ -1,76 +1,80 @@ /* ============================================================================= * B05_Profile_UI_ExpectedDraw.ts - * B05 평면 예정노선 그리기(PLAN 626-3 · 요구 530-11 · 12 · 결정 D3 · D4 · D9 · D10) — 도구줄 「예정노선 그리기」 칸. - * 켬 = 떠 있는 판(새로 긋기 · 구간 다시 긋기 · 지움 + 공용 방식 칸 `map_draw_kind`) · 고치는 예정노선이 맨 위 · - * 캔버스 왼쪽 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기(Shift+누름) · - * 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠은 그대로. - * 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab(자유지정 ↔ 길이·기울기) · Enter 끝 · Esc 취소 · - * Backspace 마지막 점 빼기. 구간 다시 긋기 = 예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝(그 사이 갈음 · - * Enter = 취소). 지움 = 고친 예정노선 버리고 파일입력 예정노선으로. - * 끝 → 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → `POST /route/follow`(저장 0) → - * `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] 이 손 편집과 같은 길 + 초안 `expected`. - * 예정노선 세 벌 = 파일입력(`expected` · 안 바뀜) · 저장본(`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림). - * 셈 = `logic/B05_Profile_ExpectedDraw` · 그림 = D03 `paintPreview` · `strokeCased` · `slopeColor` 그대로. + * B05 평면 예정노선 변경(PLAN 626-3 · 요구 530-11 · 12 · 535-1 · 536 · 결정 D3 · D4 · D9) — 도구줄 「예정노선 변경」 켬끔. + * 켬 = 예정노선이 맨 위 · 예정노선 페이지(D03) 긋기 · 고르기 · 고치기를 **그대로**(같은 함수 · 단추 · 단축키): + * 긋기 칸 `createPlanDraw`(방식 칸 · 부채꼴 · +/− 색 · Shift · Tab · Enter · Esc · Backspace · 선 끝 이어 긋기) · + * 고르기 겹 `createPlanEdit`(노드 → 선 → 구역 → 전체 · 끌어 고르기 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete) · + * 우클릭 메뉴 `createPlanMenu`(여기서 긋기 시작 · 이어 긋기 · 노드 지움 · 다시 긋기) · 속성 판 `createPlanProps`([다시 긋기] · + * [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 한 선(`oneLine`) = 갈래 · 둘째 선 · 연도 · 지점 막음/숨김. + * 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 · + * 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만). + * 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → + * `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과 + * 같은 길 + 초안 `expected`(`expected_route_edit.csv`). 선이 없으면(다 지움) 평면 그대로 · 새로 그을 때까지 기다림. + * 예정노선 세 벌 = 파일입력(`expected_route.csv` · 안 덮음 · 도구줄 「예정노선(원본)」 켬끔 = 옅은 점선) · 저장본 + * (`expected_edited`) · 이번 고침(`edit` · 걸음 사진에 실림). * ========================================================================== */ import { keyIs } from "../A00_Common/key_registry"; import { listenWhileAlive } from "@ui/ui_template_window_watch"; -import { createButton, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { askConfirm } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { themeColor } from "@ui/ui_template_palette"; +import type { ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; -import { - createDrawKindFields, - DRAW_DEFAULT_LENGTH_M, - DRAW_DEFAULT_SLOPE_PCT, - type DrawConstraint, -} from "../A00_Common/map/map_draw_kind"; -import { groundSlopePct, segmentLength } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw"; -import type { Fan } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan"; -import { - paintPreview, - slopeColor, - strokeCased, -} from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint"; -import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw"; -import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; +import type { PlanNetwork } from "../D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan"; +import { createPlanDraw } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw"; +import { createPlanEdit } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit"; +import type { PlanMapLayer, ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map"; +import { createPlanMenu } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu"; +import type { PlanPoints } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Points"; +import { createPlanProps } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Props"; import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan"; -import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; +import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; +import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; +import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits"; -import { - leadBefore, - nearestOnLine, - pickNext, - respliceLine, -} from "./logic/B05_Profile_ExpectedDraw"; type Vertex = [number, number]; -/** 예정노선 위로 볼 반경(px) — D03 붙음 반경과 같음. */ -const SNAP_PX = 12; -const IDLE_HINT = "새로 긋기 · 구간 다시 긋기 · 지움 중 하나를 고릅니다."; -const KEYS_HINT = "Enter 끝 · Esc 취소 · Backspace 점 빼기 · Tab 방식 · Shift 사이 기울기"; +const HINT = + "[긋기] · 우클릭 「여기서 긋기 시작」 · 선 끝에서 이어 긋기 · 고르고 Delete · 노드 끌기 — 한 번 고칠 때마다 평면선을 다시 추적합니다."; +const EMPTY_HINT = "예정노선이 없습니다 — 빈 땅 우클릭 「여기서 긋기 시작」으로 새로 긋습니다."; /** `POST /route/follow` 응답 — GET `/route/plan` 칸 이름(626-2). */ type FollowResponse = Pick; +/** 지점 없음(B05 예정노선 = 한 선 · 메뉴의 지점 항목 숨김). */ +const NO_POINTS: PlanPoints = { + panel: document.createElement("div"), + input: { draw: () => undefined }, + pins: { draw: () => undefined }, + render: () => undefined, + pinAt: () => null, + place: () => undefined, + remove: () => undefined, + name: () => "", + full: () => true, +}; + export interface ExpectedDrawParams { projectId: string; canvas: HTMLCanvasElement; - /** 도구줄 칸 · 판을 얹을 도구 줄 칸. */ - button: HTMLButtonElement; - host: HTMLElement; + /** 평면 뼈대 — 도구줄 칸(변경 켬끔 · 원본 켬끔) · 긋기 판 자리(도구 줄) · 속성 판 · 메뉴 자리(지도 칸). */ + chrome: Pick; toScreen: (vertex: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; /** 지도 돌림 — 보이는 px ↔ 그리기 px · 글자 세울 각(만든 뒤에 서므로 함수로). */ rotation: () => Pick; pxPerMeter: () => number; + view: () => ViewState; redraw: () => void; - /** 판을 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */ + /** 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */ onOpen: () => void; - /** 다시 추적한 평면 · 고친 예정노선([] = 지움) — 편집기 한 걸음. */ + /** 다시 추적한 평면 · 고친 예정노선 — 편집기 한 걸음. */ onFollow: (plan: FlattenedPlan, expected: Vertex[]) => void; } @@ -83,209 +87,187 @@ export interface ExpectedDraw { /** 지금 보일 예정노선(같은 배열을 돌려줌 — 재기 높이 표가 배열로 낡음을 봄). */ line: () => Vertex[]; paint: (context: CanvasRenderingContext2D) => void; - /** 검증 창구 — 판 켬 · 긋는 중 · 미리보기. */ - state: () => { - open: boolean; - stroke: string | null; - points: number; - preview: DrawPreview | null; - }; } -type Stroke = - | { mode: "new"; points: Vertex[] } - | { mode: "respan"; start: { atM: number; point: Vertex } | null; through: Vertex[] }; +const sameLine = (a: ReadonlyArray, b: ReadonlyArray): boolean => + JSON.stringify(a) === JSON.stringify(b); export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { const { canvas } = params; + const { expectedDrawButton: button, expectedOriginalButton: originalButton } = params.chrome; + const viewport = params.chrome.overlay.querySelector(".b05-routeedit__canvas-wrap")!; let original: Vertex[] = []; let saved: Vertex[] = []; let edit: Vertex[] | null = null; let open = false; + let showOriginal = true; let busy = false; - let stroke: Stroke | null = null; - let preview: DrawPreview | null = null; - /** 확인창이 떠 있는 동안 보일 새 예정노선(묻는 동안 무엇이 바뀌는지 보이게). */ - let asking: Vertex[] | null = null; - let fan: Fan | null = null; - let hoverAt: { px: number; py: number; shift: boolean } | null = null; + /** 켠 뒤 확인창을 물었나 — 다시 추적 뒤 평면은 손 고침이 없음(켠 동안 계획선 안 집힘). */ + let asked = false; let grid: DesignTerrainGrid | null = null; - let kind: DrawConstraint = "both"; // 기억 안 함(B9) - let lengthM = DRAW_DEFAULT_LENGTH_M; - let slopePct = DRAW_DEFAULT_SLOPE_PCT; + /** 고치는 판(D03 꼴 · 한 선) · 긋기 시작 때 판(끝나면 바뀜을 봄). */ + let network: PlanNetwork = { lines: [], pieces: [] }; + let strokeBase: PlanNetwork | null = null; const line = (): Vertex[] => edit === null ? (saved.length > 1 ? saved : original) : edit.length > 1 ? edit : original; const z = (x: number, y: number): number => (grid ? gridHeightAt(grid.grid, x, y) : NaN); + const fromLine = (): PlanNetwork => { + const shown = line(); + const vertices = shown.map(([x, y]): Vertex => [x, y]); + return { lines: shown.length > 1 ? [{ id: "main", vertices, branch: null }] : [], pieces: [] }; + }; - // ── 판 ───────────────────────────────────────────────── - const panel = createFloatPanel({ title: "예정노선 그리기", onClose: () => setOpen(false) }); + // ── D03 긋기 · 고르기 · 메뉴 · 속성 판(같은 함수) ───────── + const draw = createPlanDraw({ + base: () => [], + network: () => network, + heightAt: () => z, + commit, + onMode, + oneLine: true, + }); + const show = (next: PlanNetwork): void => { + network = next; + params.redraw(); + }; + // `heightAt` = 625-3(놓인 노드 부호 색) 덧 칸 — 합치기 앞뒤 둘 다 맞게 칸 묶음을 따로 둠 + const editParams = { + network: () => network, + base: (): Vertex[] => [], + commit, + show, + commitPoints: (_points: Vertex[], next: PlanNetwork) => commit(next), + showPoints: (_points: Vertex[], next: PlanNetwork) => show(next), + enabled: () => open && draw.mode() === "idle", + editable: () => open && !busy, + heightAt: () => z, + onSelect: (): void => (props.render(), params.redraw()), + }; + const editor = createPlanEdit(editParams); + const props = createPlanProps({ + network: () => network, + edit: editor, + editable: () => !busy, + yearStart: () => new Date().getFullYear(), + commit, + heightAt: () => z, + epsg: () => 0, + points: () => [], + addPoint: () => undefined, + commitPoints: (_points, next) => commit(next), + respan: draw.respan, + oneLine: true, + }); + const menu = createPlanMenu({ + network: () => network, + points: () => [], + marks: NO_POINTS, + edit: editor, + draw, + editable: () => open && !busy, + respan: draw.respan, + oneLine: true, + }); + const layers: PlanMapLayer[] = [draw.layer, editor.layer, menu.layer]; + props.panel.classList.add("b05-routeedit__expected-props"); + viewport.append(props.panel); + menu.mount(viewport); + + // ── 켬끔 판 ───────────────────────────────────────────── + const panel = createFloatPanel({ title: "예정노선 변경", onClose: () => setOpen(false) }); panel.root.classList.add("b05-routeedit__expected-draw"); panel.root.hidden = true; - const hint = createText(IDLE_HINT, "muted"); - const kindFields = createDrawKindFields({ - value: { kind, lengthM, slopePct }, - onChange: (value) => { - ({ kind, lengthM, slopePct } = value); - rehover(); - }, + const hint = createText(HINT, "muted"); + panel.body.append(draw.panel, hint); + params.chrome.overlay.querySelector(".b05-routeedit__tools")!.append(panel.root); + button.addEventListener("click", () => setOpen(!open)); + const paintOriginalButton = (): void => { + originalButton.setAttribute("aria-pressed", String(showOriginal)); + originalButton.classList.toggle("is-active", showOriginal); + }; + paintOriginalButton(); + originalButton.addEventListener("click", () => { + showOriginal = !showOriginal; + paintOriginalButton(); + params.redraw(); }); - const row = document.createElement("div"); - row.className = "b05-routeedit__expected-row"; - row.append( - createButton({ label: "새로 긋기", variant: "filled", size: 28, onClick: () => begin("new") }), - createButton({ - label: "구간 다시 긋기", - variant: "ghost", - size: 28, - onClick: () => begin("respan"), - }), - createButton({ label: "지움", variant: "ghost", size: 28, onClick: () => void clear() }), - ); - panel.body.append(row, kindFields.root, hint); - params.host.append(panel.root); - params.button.addEventListener("click", () => setOpen(!open)); function setOpen(next: boolean): void { + if (draw.mode() !== "idle") draw.setMode("idle"); open = next; - stroke = null; - preview = fan = null; - params.button.setAttribute("aria-pressed", String(open)); - params.button.classList.toggle("is-active", open); + asked = false; + network = fromLine(); + editor.select([]); + draw.setEditable(open); + button.setAttribute("aria-pressed", String(open)); + button.classList.toggle("is-active", open); panel.root.hidden = !open; - hint.textContent = IDLE_HINT; - canvas.style.removeProperty("cursor"); + hint.textContent = network.lines.length ? HINT : EMPTY_HINT; if (open) { params.onOpen(); - void loadDesignTerrainGrid(params.projectId).then((loaded) => ((grid = loaded), rehover())); + void loadDesignTerrainGrid(params.projectId).then( + (loaded) => ((grid = loaded), params.redraw()), + ); } + props.render(); params.redraw(); } - function begin(mode: Stroke["mode"]): void { - if (busy) return; - if (mode === "respan" && line().length < 2) - return showToast("다시 그을 예정노선이 없습니다 — 새로 긋기를 쓰세요.", "info"); - stroke = mode === "new" ? { mode, points: [] } : { mode, start: null, through: [] }; - preview = fan = null; - hint.textContent = - (mode === "new" - ? "첫 점을 누릅니다 · " - : "예정노선 위를 눌러 시작 · 다시 예정노선 위를 누르면 끝 · ") + KEYS_HINT; - canvas.style.cursor = "crosshair"; + /** 꼴 한 걸음(D03 `commit`) — 긋는 중이면 판만 · 쉴 때(놓음 · 지움) = 다시 추적. */ + function commit(next: PlanNetwork): void { + network = next; + props.render(); params.redraw(); + if (draw.mode() === "idle") void apply(); } - function stop(message = IDLE_HINT): void { - stroke = null; - preview = fan = null; - hint.textContent = message; - canvas.style.removeProperty("cursor"); - params.redraw(); - } - - // ── 셈 ────────────────────────────────────────────────── - /** 화면 px 둘레 예정노선 위 자리 — 멀면 null. */ - function onLine(px: number, py: number): { atM: number; point: Vertex } | null { - const shown = line(); - if (shown.length < 2) return null; - const hit = nearestOnLine(shown, params.toMetric(px, py)); - const [sx, sy] = params.toScreen(hit.point); - return Math.hypot(sx - px, sy - py) <= SNAP_PX ? hit : null; - } - - /** 그은 점(구간 다시 긋기 = 시작 자리 + 속 점). */ - function strokePoints(): Vertex[] { - if (!stroke) return []; - if (stroke.mode === "new") return stroke.points; - return stroke.start ? [stroke.start.point, ...stroke.through] : []; - } - - function previewAt(px: number, py: number, shift: boolean): DrawPreview | null { - const points = strokePoints(); - const from = points[points.length - 1]; - if (!stroke || !from) return null; - const cursor = params.toMetric(px, py); - const prev = - points.length > 1 - ? points[points.length - 2] - : stroke.mode === "respan" && stroke.start - ? leadBefore(line(), stroke.start.atM) - : null; - const end = stroke.mode === "respan" ? onLine(px, py) : null; - const pick = pickNext(kind, from, cursor, lengthM, slopePct, z, prev, shift); - fan = pick.fan; - const point = end ? end.point : pick.point; - const at = point ?? cursor; - const length = segmentLength(from, at); - return { - point: at, - lengthM: length, - slopePct: groundSlopePct(from, at, z), - note: end ? "예정노선에 붙음 — 끝" : pick.note, - blocked: !point || length <= 0, - snapped: !!end, - lengthOnly: pick.lengthOnly && !end, - onLine: null, - }; - } - - function rehover(): void { - if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.shift); - params.redraw(); - } - - function press(px: number, py: number, shift: boolean): void { - if (!stroke || busy) return void (open && !stroke && showToast(IDLE_HINT, "info")); - if (stroke.mode === "new" && !stroke.points.length) { - stroke.points.push(params.toMetric(px, py)); - return rehover(); + /** 긋기 들어감 = 판 기억 · 긋기 끝 = 바뀌었으면 다시 추적. */ + function onMode(): void { + if (draw.mode() === "draw") { + strokeBase ??= network; + return params.redraw(); } - if (stroke.mode === "respan" && !stroke.start) { - stroke.start = onLine(px, py); - if (!stroke.start) showToast("예정노선 위를 눌러 시작합니다.", "info"); - return rehover(); - } - preview = previewAt(px, py, shift); - if (!preview || preview.blocked) - return showToast(preview?.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); - if (stroke.mode === "respan" && preview.snapped) { - const end = onLine(px, py)!; - const next = respliceLine(line(), stroke.start!.atM, end.atM, stroke.through); - return void follow(next); - } - if (stroke.mode === "new") stroke.points.push(preview.point); - else stroke.through.push(preview.point); - rehover(); + const base = strokeBase; + strokeBase = null; + if (base && JSON.stringify(base.lines) !== JSON.stringify(network.lines)) void apply(); + params.redraw(); } - function finish(): void { - if (!stroke) return; - if (stroke.mode === "respan") - return stop("구간 다시 긋기는 예정노선 위를 눌러 끝 — 취소했습니다."); - if (stroke.points.length < 2) return void showToast("점이 2개 이상이어야 합니다.", "info"); - void follow([...stroke.points]); + function revert(): void { + network = fromLine(); + editor.sync(); + draw.sync(); + props.render(); + params.redraw(); } - /** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */ - async function follow(next: Vertex[]): Promise { - stop(); - asking = next.length ? next : original; - params.redraw(); - const ok = await askConfirm({ - title: "평면선 다시 추적", - message: - (next.length ? "고친 예정노선" : "파일입력 예정노선") + - "을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " + - "[저장] 전까지는 되돌리기로 물릴 수 있습니다.", - okLabel: "다시 추적", - }); - asking = null; - params.redraw(); - if (!ok) return; + /** 다시 추적 — 한 선 · 점 2 이상일 때만 · 켠 뒤 처음만 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. */ + async function apply(): Promise { + if (busy || !open) return; + const drawn = network.lines.filter((entry) => entry.vertices.length > 1); + hint.textContent = drawn.length ? HINT : EMPTY_HINT; + if (!drawn.length) return; // 다 지움 — 새로 그을 때까지 평면 그대로 + if (drawn.length > 1) { + showToast("예정노선은 한 선입니다.", "info"); + return revert(); + } + const points = drawn[0].vertices.map(([x, y]): Vertex => [x, y]); + if (sameLine(points, line())) return; + if (!asked) { + const ok = await askConfirm({ + title: "평면선 다시 추적", + message: + "고친 예정노선을 따라 평면선을 통째로 다시 추적합니다 — 직접 고친 평면(노드 · 곡선)은 버립니다. " + + "예정노선(원본)은 그대로 · [저장] 전까지는 되돌리기로 물릴 수 있습니다.", + okLabel: "다시 추적", + }); + if (!ok) return revert(); + asked = true; + } busy = true; + params.redraw(); try { - const points = next.length ? next : original; const plan = await requestJson( `/projects/${params.projectId}/route/follow`, { method: "POST", body: JSON.stringify({ points, crs: "tm" }) }, @@ -293,25 +275,45 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { ); params.onFollow( flattenServerPlan(plan.nodes ?? [], plan.curves ?? [], plan.node_edits), - next, + points, ); } catch (error) { showToast( `평면선을 다시 추적하지 못했습니다.${error instanceof Error ? ` ${error.message}` : ""}`, "error", ); + revert(); } finally { busy = false; + params.redraw(); } } - async function clear(): Promise { - if (busy) return; - if (line() === original) return showToast("고친 예정노선이 없습니다.", "info"); - await follow([]); - } - - // ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ── + // ── 누름 · 키 — 켠 동안 먼저 받아 D03 겹에 넘김(계획선 · 구조물 · 재기로 안 샘) ── + const map = (): ScreenMap => { + const rotation = params.rotation(); + return { + toScreen: params.toScreen, + toMetric: params.toMetric, + pxPerMeter: params.pxPerMeter(), + view: params.view(), + stations: false, // 측점 눈금 = 계획선 몫 + slopes: markShown("slopes"), + uprightRad: rotation.uprightRad(), + upright(context, x, y, paint) { + const radians = rotation.uprightRad(); + if (!radians) return paint(); + context.save(); + context.translate(x, y); + context.rotate(radians); + context.translate(-x, -y); + paint(); + context.restore(); + }, + rerotate: rotation.rerotate, + unrotate: rotation.unrotate, + }; + }; const at = (event: MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top); @@ -325,119 +327,102 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { (event) => { if (!open || event.button !== 0) return; claim(event); - press(...at(event), event.shiftKey); + const [px, py] = at(event); + if (!busy && layers.some((layer) => layer.press?.(px, py, map(), 0, event.shiftKey))) + canvas.setPointerCapture(event.pointerId); // 끌기가 밖으로 나가도 놓음을 받음 + params.redraw(); }, true, ); canvas.addEventListener( "pointermove", (event) => { - if (!open || event.buttons) return; // 가운데 끌기 = 지도 이동 그대로 + if (!open || event.buttons & 6) return; // 오른쪽 · 가운데 끌기 = 지도 이동 그대로 event.stopImmediatePropagation(); const [px, py] = at(event); - hoverAt = { px, py, shift: event.shiftKey }; - if (stroke) rehover(); + const screen = map(); + for (const layer of layers) layer.hover?.(px, py, screen, event.shiftKey); + params.redraw(); }, true, ); - for (const type of ["pointerup", "click", "dblclick", "contextmenu"]) - canvas.addEventListener( - type, - (event) => { - if (open && (event as MouseEvent).button !== 1) claim(event); - }, - true, - ); + canvas.addEventListener( + "pointerup", + (event) => { + if (!open || event.button !== 0) return; + claim(event); + const screen = map(); + for (const layer of layers) layer.release?.(screen); + params.redraw(); + }, + true, + ); + canvas.addEventListener( + "contextmenu", + (event) => { + if (!open) return; + claim(event); + const [px, py] = at(event); + if (!busy) layers.some((layer) => layer.press?.(px, py, map(), 2, event.shiftKey)); + params.redraw(); + }, + true, + ); + for (const type of ["click", "dblclick"]) + canvas.addEventListener(type, (event) => open && claim(event), true); canvas.addEventListener("pointerleave", () => { - hoverAt = null; - if (preview) ((preview = null), params.redraw()); + for (const layer of layers) layer.leave?.(); + if (open) params.redraw(); }); const onKey = (event: KeyboardEvent): void => { if (!open || (event.target as HTMLElement | null)?.closest?.("input, select, textarea")) return; - if (!stroke) return; - if (event.key === "Shift") { - if (hoverAt) ((hoverAt.shift = event.type === "keydown"), rehover()); - return; - } - if (event.type !== "keydown") return; - if (keyIs(event, "plan.drawKind")) { - kind = kind === "both" ? "free" : "both"; // 단독 고정이면 길이·기울기로(625-1) - kindFields.set({ kind }); - rehover(); - } else if (keyIs(event, "plan.drawFinish")) finish(); - else if (event.key === "Escape") stop(); - else if (keyIs(event, "plan.drawBack")) { - const points = stroke.mode === "new" ? stroke.points : stroke.through; - if (!points.pop() && stroke.mode === "respan") stroke.start = null; - rehover(); - } else return; + if (keyIs(event, "plan.undo") || keyIs(event, "plan.redo")) return; // 되돌리기 = 페이지 사진첩 + if (!(draw.key(event) || editor.key(event))) return; claim(event); + params.redraw(); }; listenWhileAlive(window, "keydown", canvas, onKey, true); - listenWhileAlive(window, "keyup", canvas, onKey, true); // ── 그림 ──────────────────────────────────────────────── - const map = { - toScreen: params.toScreen, - pxPerMeter: 1, - upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void { - const radians = params.rotation().uprightRad(); - if (!radians) return paint(); - context.save(); - context.translate(x, y); - context.rotate(radians); - context.translate(-x, -y); - paint(); - context.restore(); - }, - }; - const path = (context: CanvasRenderingContext2D, points: ReadonlyArray): void => { - context.beginPath(); - points.forEach((point, i) => { - const [sx, sy] = params.toScreen(point); - if (i) context.lineTo(sx, sy); - else context.moveTo(sx, sy); - }); - }; - function paint(context: CanvasRenderingContext2D): void { - if (!open) return; - const colors = planColors(); - map.pxPerMeter = params.pxPerMeter(); - context.save(); - context.lineCap = "round"; - context.lineJoin = "round"; - path(context, line()); // 고치는 예정노선 = 맨 위(D9) - strokeCased(context, colors.measure, 2.5, [8, 5]); - const points = strokePoints(); - if ((asking ?? points).length > 1) { - path(context, asking ?? points); // 묻는 동안 = 새 예정노선 통째 - strokeCased(context, colors.active, 3); - } - points.forEach((point, i) => { - const [sx, sy] = params.toScreen(point); + if (showOriginal && original.length > 1 && line() !== original) { + context.save(); context.beginPath(); - context.arc(sx, sy, 3.5, 0, Math.PI * 2); - context.fillStyle = i ? slopeColor(groundSlopePct(points[i - 1], point, z)) : colors.active; - context.fill(); - }); - const from = points[points.length - 1]; - if (from && preview) paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null); - context.restore(); + original.forEach((point, i) => { + const [sx, sy] = params.toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.globalAlpha = 0.7; + context.setLineDash([2, 5]); + context.lineWidth = 2; + context.strokeStyle = themeColor("--draw-text-secondary", "#9ca3af"); // 원본 = 옅은 점선 + context.stroke(); + context.restore(); + } + if (!open) return; + const screen = map(); + for (const layer of layers) { + context.save(); + layer.draw(context, screen); // 고치는 예정노선 = 맨 위(D9) · 고름 · 미리보기 + context.restore(); + } } - const state = (): ReturnType => ({ - open, - stroke: stroke?.mode ?? null, - points: strokePoints().length, - preview, - }); - // 검증 창구 — 판 상태 · 보이는 예정노선 · 사업지 좌표 → 보이는 화면 px + // 검증 창구 — 켬 · 긋기 모드 · 고친 판 · 보이는 예정노선 · 사업지 좌표 → 보이는 화면 px Object.assign(canvas, { expectedProbe: { - state, + state: () => ({ + open, + mode: draw.mode(), + lines: network.lines.map((entry) => entry.vertices.length), + preview: draw.preview(), + selected: editor.selected()?.kind ?? null, + busy, + }), line, + original: () => original, edit: () => edit, screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)), }, @@ -447,13 +432,17 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { load(nextOriginal, nextSaved) { original = nextOriginal; saved = nextSaved; + network = fromLine(); }, edit: () => edit, setEdit(next) { edit = next; + if (draw.mode() !== "idle") return; // 긋는 중 되돌림 — 긋기가 끝나면 그 판으로 다시 추적 + network = fromLine(); + editor.sync(); + props.render(); }, line, paint, - state, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a77be295d..0fd8d47a5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -122,12 +122,12 @@ export async function mountRouteEditor( const expectedDraw = createExpectedDraw({ projectId, canvas, - button: chrome.expectedDrawButton, - host: overlay.querySelector(".b05-routeedit__tools")!, + chrome, toScreen: (vertex) => toScreen(vertex), toMetric: (px, py) => toMetric(px, py), rotation: () => rotation, pxPerMeter: () => pxPerMeter(), + view: () => view, redraw: () => draw(), onOpen: () => ((picked = -1), syncCurveBar()), onFollow: (flat, line) => diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index b9d2fc30d..1a62637c6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -94,7 +94,7 @@ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteD /** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂 * (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */ export function bindRouteApply(params: RouteApplyParams): () => void { - // 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈은 추천 [적용]만(509-5) + // 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈 = 도구줄 지형 칩(626-1 · `_Terrain`) const settings = () => ({ ...params.snap(), terrain: null }); const source: RouteSource = { pending: async () => { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index c96f2dd7a..b3366a1cd 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -21,7 +21,7 @@ import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoL * (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 * 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위). * 도구줄 차례 = 공용 표(624-3 · `A00_Common/map/map_toolbar` 「b05」) — [?] | ↶ ↷ | 3D ⟲ ⟳ | 1m 등고선 · 측점 · 기울기 · - * 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 예정노선 그리기(626-3) | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` · + * 절/성토선 · 거리 재기 | 측점 스냅 · 간격 | 예정노선 변경 · 원본(626-3) | 추천 노선 보기(626-1) | 설계 지형 칩(626-1). 아래 줄 = 공용 겹 줄(`_Layers` · * 끝 = 그래픽 카드 단추) · 범례 = 상태 글판 위. 단추는 공용 지도 도구줄(568-2 · `ui_template_map_tools`) 아이콘 — 긴 설명은 마우스 글. * * 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 · @@ -66,6 +66,8 @@ export interface RouteEditChrome { stationSnapButton: HTMLButtonElement; /** 예정노선 그리기 켬끔(626-3 · 배선 = `B05_Profile_UI_ExpectedDraw`). */ expectedDrawButton: HTMLButtonElement; + /** 예정노선(원본 · 파일입력) 옅은 점선 켬끔(626-3 · 535-1). */ + expectedOriginalButton: HTMLButtonElement; /** 설계 지형 칩(626-1) — 지형 읽은 뒤 `load`. */ terrain: RouteTerrain; /** 추천 노선 미리보기(626-1) — 그리기 안에서 `draw`. */ @@ -291,6 +293,7 @@ export function createRouteEditChrome( minorContourButton, stationSnapButton, expectedDrawButton: mapTools.button("expected-draw")!, + expectedOriginalButton: mapTools.button("expected-original")!, terrain, preview, layers, diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index c4d82b5cb..b38666ffd 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -508,3 +508,9 @@ html.is-bm-route .b05-routeedit__tools { flex-wrap: wrap; gap: var(--spacing-4); } + +/* 626-3 예정노선 변경 속성 판(D03 고름 판 그대로) — 도구 줄 아래 오른쪽 · 횡단 판 비킴 */ +.b05-routeedit .b05-routeedit__expected-props { + top: 132px; + right: calc(var(--spacing-12) + var(--b05-side-open, 0px)); +} diff --git a/B05_Profile/logic/B05_Profile_ExpectedDraw.ts b/B05_Profile/logic/B05_Profile_ExpectedDraw.ts deleted file mode 100644 index afcd50952..000000000 --- a/B05_Profile/logic/B05_Profile_ExpectedDraw.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* ============================================================================= - * B05_Profile_ExpectedDraw.ts - * B05 평면 예정노선 그리기 셈(PLAN 626-3 · 요구 530-11 · 결정 D10) — 다음 점 고르기 · 구간 다시 긋기 갈음 · 선 위 자리. - * 다음 점 = D03 긋기와 같은 방식 넷(자유지정 · 길이 고정 · 기울기 고정 · 길이·기울기 고정 부채꼴 · Shift 사이 기울기) — - * 셈은 D03 `logic/D03_PlanAnalysis_Draw` · `logic/D03_PlanAnalysis_Fan` 그대로(파이썬 짝 없음 · 화면 고르기 전용). - * 구간 다시 긋기 = 선 위 두 자리(m) 사이를 그은 점으로 갈음(거꾸로 그었으면 뒤집어 끼움). - * 서버는 고친 예정노선을 다시 추적(`POST /route/follow`) · [저장] 때 `expected_route_edit.csv`. - * DOM · fetch · sessionStorage 없음. - * ========================================================================== */ - -import { - distanceAlong, - fixedLengthPoint, - fixedSlopePoint, - lineLength, - pointAtDistance, - segmentLength, - sliceLine, - type HeightAt, - type Vertex, -} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw"; -import { - compassFan, - fanBetween, - fanPick, - type Fan, -} from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan"; - -/** 긋기 방식 — 공용 방식 칸(`A00_Common/map/map_draw_kind` `DrawConstraint`)과 같은 꼴(logic 은 화면 파일을 안 부름). */ -export type DrawConstraint = "free" | "length" | "slope" | "both"; - -/** 같은 점으로 볼 거리(m). */ -const SAME_M = 1e-6; -/** 구간 다시 긋기 첫 구간 정방향 — 시작 자리 앞 이만큼(m) 선 위 점을 앞 구간 시작으로 봄. */ -const LEAD_M = 5; - -export interface ExpectedPick { - /** 찍을 점 — 없으면 null(자리 없음). */ - point: Vertex | null; - /** 길이·기울기 고정 부채꼴(그 방식일 때만). */ - fan: Fan | null; - note: string | null; - /** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅). */ - lengthOnly: boolean; -} - -const one = (value: number): string => value.toFixed(1); - -/** 다음 점 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `shift` = 부채꼴 사이 고르기. */ -export function pickNext( - kind: DrawConstraint, - from: Vertex, - cursor: Vertex, - lengthM: number, - slopePct: number, - heightAt: HeightAt, - prev: Vertex | null, - shift = false, -): ExpectedPick { - if (kind === "length") - return { - point: fixedLengthPoint(from, cursor, lengthM), - fan: null, - note: null, - lengthOnly: false, - }; - if (kind === "slope") { - const point = fixedSlopePoint(from, cursor, slopePct, heightAt); - const note = point ? null : `이 방향엔 ±${one(Math.abs(slopePct))} % 자리 없음`; - return { point, fan: null, note, lengthOnly: false }; - } - if (kind === "both") { - const fan = compassFan(from, lengthM, slopePct, heightAt, prev, cursor); - const point = shift - ? fanBetween(fan, from, cursor, lengthM) - : fanPick(fan, from, cursor, lengthM); - const note = point - ? null - : `반지름 ${one(lengthM)} m 원 위에 ±${one(Math.abs(slopePct))} % 자리 없음`; - return { point, fan, note, lengthOnly: shift || fan.flat }; - } - return { point: cursor, fan: null, note: null, lengthOnly: false }; -} - -/** 선 위 가장 가까운 자리(누가 m · 점). */ -export function nearestOnLine( - line: ReadonlyArray, - point: Vertex, -): { atM: number; point: Vertex } { - const atM = distanceAlong(line, point); - return { atM, point: pointAtDistance(line, atM) }; -} - -/** 구간 다시 긋기 첫 구간의 앞 구간 시작 — 선 위 시작 자리 조금 앞(시점이면 null). */ -export function leadBefore(line: ReadonlyArray, atM: number): Vertex | null { - return atM > SAME_M ? pointAtDistance(line, Math.max(0, atM - LEAD_M)) : null; -} - -/** 이웃한 같은 점 뺌. */ -function dedupe(points: Vertex[]): Vertex[] { - return points.filter((point, i) => !i || segmentLength(points[i - 1], point) > SAME_M); -} - -/** 구간 다시 긋기 — 선 `fromM` ~ `toM` 사이를 `through`(그은 속 점 · 시작 · 끝 자리 뺌)로 갈음. */ -export function respliceLine( - line: ReadonlyArray, - fromM: number, - toM: number, - through: ReadonlyArray, -): Vertex[] { - const total = lineLength(line); - const forward = fromM <= toM; - const [lo, hi] = forward ? [fromM, toM] : [toM, fromM]; - const middle = forward ? [...through] : [...through].reverse(); - return dedupe([...sliceLine(line, 0, lo), ...middle, ...sliceLine(line, hi, total)]); -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 8a6828d02..8a5cd7b9a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -68,6 +68,18 @@ const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — export type DrawMode = "idle" | "draw"; +/** 한 선(626-3) 막힘 까닭 — 선 위 · 지점 = 갈래 · 선이 있는데 빈 땅 = 둘째 선 · 됨 = null. */ +export function oneLineBlock( + kind: "tip" | "point" | "line" | "empty", + network: PlanNetwork, +): string | null { + if (kind === "line" || kind === "point") + return "예정노선은 한 선입니다 — 선 끝에서 이어 긋습니다."; + if (kind === "empty" && network.lines.some((line) => line.vertices.length > 1)) + return "예정노선은 한 선입니다 — 지운 뒤 새로 긋습니다."; + return null; +} + export interface DrawPreview { point: Vertex; lengthM: number; @@ -90,6 +102,8 @@ export interface PlanDrawParams { commit: (next: PlanNetwork) => void; /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ onMode: () => void; + /** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 · 둘째 선 막음 · 연도 구역 마커 숨김. 없음 = D03 그대로. */ + oneLine?: boolean; } export interface PlanDraw { @@ -145,6 +159,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { }); const drawPanel = createDrawPanel({ kind: kindFields, + marks: !params.oneLine, onDraw: () => setMode(mode === "draw" ? "idle" : "draw"), }); const panel = drawPanel.root; @@ -366,6 +381,11 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { function begin(px: number, py: number, map: ScreenMap, free = false): boolean { const network = params.network(); const target = aim(px, py, map); + const single = params.oneLine ? oneLineBlock(target.kind, network) : null; + if (single) { + showToast(single, "info"); + return false; + } strokeStart = network; if (target.kind === "tip") { if (target.head) { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts index 5a8ed38ef..4e72da35e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -11,6 +11,7 @@ import { createButton } from "@ui/ui_template_elements"; import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map"; import type { DrawKindFields } from "../A00_Common/map/map_draw_kind"; +import "./D03_PlanAnalysis_UI_DrawParts.css"; // 방식 칸 = 공용(`A00_Common/map/map_draw_kind` · 626-3 · B05 평면 예정노선 그리기도 씀) export { @@ -30,7 +31,12 @@ export interface DrawPanel { } /** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */ -export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }): DrawPanel { +export function createDrawPanel(opts: { + kind: DrawKindFields; + onDraw: () => void; + /** 연도 구역 마커 단추(없음 = 보임 · B05 평면 예정노선 = 숨김 626-3). */ + marks?: boolean; +}): DrawPanel { const drawButton = createButton({ label: "긋기", variant: "filled", @@ -45,6 +51,7 @@ export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }); markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다"; markButton.dataset.role = "draw-year-mark"; + markButton.hidden = opts.marks === false; markButton.draggable = true; markButton.addEventListener("dragstart", (event) => { event.dataTransfer?.setData(YEAR_MARK_DRAG, "1"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawParts.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawParts.css new file mode 100644 index 000000000..4a374e2b2 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawParts.css @@ -0,0 +1,50 @@ +/* D03 긋기 칸 · 고름 속성 판 꼴(626-3) — 긋기 칸(`UI_DrawPanel`) · 속성 판(`UI_Props`)이 불러옴 · + * 예정노선(D03) · B05 평면 예정노선 변경이 같이 씀(자리만 페이지 몫). */ + +.d03-plan__row { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: flex-end; + margin-bottom: 6px; +} + +.d03-plan__row > .ui-field { + flex: 1 1 6rem; + margin: 0; +} + +.d03-plan__draw { + display: flex; + flex-direction: column; + gap: 6px; +} + +.d03-plan__props { + top: calc(var(--spacing-8) * 6); + right: var(--spacing-8); + width: 300px; + max-height: calc(100% - var(--spacing-8) * 8); +} + +.d03-plan__props[hidden] { + display: none; +} + +.d03-plan__props .ui-float-panel__body { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--spacing-8); + overflow: auto; +} + +.d03-plan__props-rows { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.d03-plan__props-rows > .ui-btn { + justify-content: flex-start; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts index da66c2a69..24064c72f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -10,7 +10,7 @@ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; -import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import { oneLineBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points"; @@ -24,6 +24,8 @@ export interface PlanMenuParams { editable: () => boolean; /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ respan: (line: string, from: number, to: number) => boolean; + /** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 시작 · 둘째 선 · 연도 구역 나눔 항목 뺌. 없음 = D03 그대로. */ + oneLine?: boolean; } export interface PlanMenu { @@ -45,12 +47,14 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { const pin = marks.pinAt(px, py, map); const at = pin === null ? edit.hit(px, py, map) : null; const start = (x: number, y: number): void => { + const aim = draw.aim(x, y, map); + if (params.oneLine && oneLineBlock(aim, params.network())) return; const label = { tip: "이어 긋기", point: "지점에서 긋기 시작", line: "갈래 시작", empty: "여기서 긋기 시작", - }[draw.aim(x, y, map)]; + }[aim]; items.push([label, () => draw.startAt(x, y, map)]); }; if (draw.mode() === "draw" && draw.stroke()) @@ -79,7 +83,7 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { } else if (at?.segment) { const seg = at.segment; start(px, py); - items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); + if (!params.oneLine) items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]); } else start(px, py); if (pin === null && !at?.node && !at?.mark && !marks.full()) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index e5d33520a..789ac1eef 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -43,6 +43,7 @@ import { import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; +import "./D03_PlanAnalysis_UI_DrawParts.css"; /** 조각 연도 고를 수 — 사업연도 시작부터. */ const YEAR_SPAN = 10; @@ -66,6 +67,8 @@ export interface PlanPropsParams { commitPoints: (points: Vertex[], next: PlanNetwork) => void; /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ respan: (line: string, from: number, to: number) => boolean; + /** 한 선만(B05 평면 예정노선 · 626-3) — 연도 · 구간 줄 · [지점으로] 뺌. 없음 = D03 그대로. */ + oneLine?: boolean; } export interface PlanProps { @@ -210,7 +213,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { const toPoint = button("지점으로", "props-to-point", () => params.addPoint([point[0], point[1]]), ); - toPoint.hidden = params.points().some((p) => samePoint(p, point)); + toPoint.hidden = !!params.oneLine || params.points().some((p) => samePoint(p, point)); return { title: `${nameOf(line)} · 노드 ${vertex + 1}`, parts, @@ -237,7 +240,8 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); } - rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); + if (!params.oneLine) + rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; if (single) actions.unshift( @@ -263,8 +267,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { { label: "길이", value: meters(length) }, { label: "노드", value: `${vertices.length}개` }, ]), - yearField(pieces), - pieceRows(network, pieces), + ...(params.oneLine ? [] : [yearField(pieces), pieceRows(network, pieces)]), ], actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index c08519723..5507a987f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -88,25 +88,6 @@ margin: 0 0 6px; } -.d03-plan__row { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - align-items: flex-end; - margin-bottom: 6px; -} - -.d03-plan__row > .ui-field { - flex: 1 1 6rem; - margin: 0; -} - -.d03-plan__draw { - display: flex; - flex-direction: column; - gap: 6px; -} - .d03-plan__pieces { display: flex; flex-direction: column; @@ -138,35 +119,6 @@ } /* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */ -.d03-plan__props { - top: calc(var(--spacing-8) * 6); - right: var(--spacing-8); - width: 300px; - max-height: calc(100% - var(--spacing-8) * 8); -} - -.d03-plan__props[hidden] { - display: none; -} - -.d03-plan__props .ui-float-panel__body { - display: flex; - flex-direction: column; - gap: 6px; - padding: var(--spacing-8); - overflow: auto; -} - -.d03-plan__props-rows { - display: flex; - flex-direction: column; - align-items: stretch; -} - -.d03-plan__props-rows > .ui-btn { - justify-content: flex-start; -} - /* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */ .d03-plan__points-head { display: flex; diff --git a/resources/tester/test_626_3_expected_edit.py b/resources/tester/test_626_3_expected_edit.py index b01e46094..fc789c266 100644 --- a/resources/tester/test_626_3_expected_edit.py +++ b/resources/tester/test_626_3_expected_edit.py @@ -1,116 +1,31 @@ -"""626-3 B05 평면 예정노선 그리기 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 결정 D3 · D4 · D9 · D10). +"""626-3 B05 평면 예정노선 변경 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 535-1 · 536 · 결정 D3 · D4 · D9). -- 셈(TS `logic/B05_Profile_ExpectedDraw.ts` · tsc 로 옮겨 Node) — 구간 다시 긋기 갈음(앞뒤 그대로 · 거꾸로 그음 뒤집음 · - 시점 · 종점 자리) · 다음 점 방식 넷(D03 셈 그대로) · 선 위 자리 · 첫 구간 정방향 앞 점. +- 예정노선 페이지(D03) 긋기 · 고르기 · 메뉴 · 속성 판 함수를 그대로 부름(`oneLine` = 한 선 · 갈래 · 연도 · 지점 막음/숨김) · + D03 덧 인자는 기본값 = 지금 그대로. - 지킴(글 대조) — 다시 추적 = `/route/follow` 만(저장 0) · `/route/replan` · `as_initial` · `expected_route.csv` 안 부름 · 결과 = 편집기 `restoreSnapshot(…, true)` 한 걸음 · [저장] = 손 편집 같은 길(`bindRouteApply`) + 초안 `expected` · - 걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 긋기 방식 칸 공용 · - 판 켜면 캔버스 누름을 먼저 받음(재기 · 계획선 고르기 막음) · RouteEdit 700 줄 안. + 걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 켠 동안 캔버스 누름을 + 먼저 받음(재기 · 계획선 고르기 막음) · 원본 켬끔 · RouteEdit 700 줄 안. ./venv/Scripts/python.exe -m pytest resources/tester/test_626_3_expected_edit.py -q """ from __future__ import annotations -import json import re -import subprocess from pathlib import Path -import pytest - ROOT = Path(__file__).resolve().parents[2] B05 = ROOT / "B05_Profile" -TS_FILES = [ - ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts", - ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts", - B05 / "logic" / "B05_Profile_ExpectedDraw.ts", -] -TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" - -_RUNNER = """ -import { writeFileSync } from "node:fs"; -import * as d from "./B05_Profile/logic/B05_Profile_ExpectedDraw.js"; -const line = [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]]; -const plane = (x, y) => 0.65 * x; -const out = {}; -out.forward = d.respliceLine(line, 12, 28, [[15, 5], [25, 5]]); -out.backward = d.respliceLine(line, 28, 12, [[25, 5], [15, 5]]); -out.head = d.respliceLine(line, 0, 20, [[10, 3]]); -out.tail = d.respliceLine(line, 30, 40, []); -out.near = d.nearestOnLine(line, [17, 4]); -out.leadStart = d.leadBefore(line, 0); -out.lead = d.leadBefore(line, 12); -out.free = d.pickNext("free", [0, 0], [3, 4], 20, 9, plane, null); -out.length = d.pickNext("length", [0, 0], [3, 4], 20, 9, plane, null); -const both = d.pickNext("both", [0, 0], [0, 50], 20, 9, plane, [0, -20]); -out.both = { point: both.point, nodes: both.fan.nodes.length, arcs: both.fan.arcs.length, lengthOnly: both.lengthOnly }; -const between = d.pickNext("both", [0, 0], [-3, 50], 20, 9, plane, [0, -20], true); -out.between = { point: between.point, lengthOnly: between.lengthOnly }; -out.none = d.pickNext("both", [0, 0], [0, 50], 20, 9, () => NaN, null); -out.slopeNone = d.pickNext("slope", [0, 0], [0, 50], 20, 9, () => 0, null); -writeFileSync(process.argv[2], JSON.stringify(out)); -""" - - -@pytest.fixture(scope="module") -def r(tmp_path_factory: pytest.TempPathFactory) -> dict: - tmp = tmp_path_factory.mktemp("expected_draw") - out = tmp / "js" - subprocess.run( # noqa: S603 — 고정 실행 파일 - ["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)] - + ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"] - + ["--ignoreConfig"], - cwd=str(ROOT), - check=True, - capture_output=True, - ) - for emitted in out.rglob("*.js"): - emitted.write_text( - re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), - encoding="utf-8", - ) - (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") - result = tmp / "out.json" - subprocess.run( # noqa: S603 - ["node", str(out / "runner.mjs"), str(result)], - cwd=str(ROOT), - check=True, - capture_output=True, - ) - return json.loads(result.read_text(encoding="utf-8")) +D03 = ROOT / "D03_PlanAnalysis" def _src(path: Path) -> str: return path.read_text(encoding="utf-8") -def test_구간_다시_긋기_앞뒤_그대로_가운데만_갈음(r): - assert r["forward"] == [[0, 0], [10, 0], [12, 0], [15, 5], [25, 5], [28, 0], [30, 0], [40, 0]] - assert r["backward"] == r["forward"] # 거꾸로 그어도 같은 선 - assert r["head"] == [[0, 0], [10, 3], [20, 0], [30, 0], [40, 0]] # 시점부터 · 같은 점 하나 - assert r["tail"] == [[0, 0], [10, 0], [20, 0], [30, 0], [40, 0]] # 속 점 없음 = 곧은 갈음 - - -def test_선_위_자리_첫_구간_앞_점(r): - assert r["near"] == {"atM": 17, "point": [17, 0]} - assert r["leadStart"] is None and r["lead"] == [7, 0] - - -def test_다음_점_방식_넷_D03_셈_그대로(r): - assert r["free"]["point"] == [3, 4] and r["free"]["fan"] is None - assert [round(v, 6) for v in r["length"]["point"]] == [12, 16] - both = r["both"] - assert both["nodes"] == 4 and both["arcs"] == 2 and not both["lengthOnly"] - assert abs((both["point"][0] ** 2 + both["point"][1] ** 2) ** 0.5 - 20) < 1e-6 - between = r["between"] - assert between["lengthOnly"] and abs(sum(v * v for v in between["point"]) ** 0.5 - 20) < 1e-6 - assert r["none"]["point"] is None and "자리 없음" in r["none"]["note"] - assert r["slopeNone"]["point"] is None and "자리 없음" in r["slopeNone"]["note"] - - def test_다시_추적_follow_만_저장_0_초기값_추천_안_건드림(): - ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") + ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts").split("*/", 1)[1] # 머리 글 뺀 몸 assert "/route/follow" in ui and "askConfirm(" in ui and "flattenServerPlan(" in ui for banned in ( "/route/replan", @@ -152,18 +67,47 @@ def test_걸음_사진_초안_같은_선에_예정노선(): assert "expected: list[tuple[float, float]] | None = None" in server -def test_방식_칸_공용_판_켜면_캔버스_누름_먼저_받음(): +def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") - assert 'from "../A00_Common/map/map_draw_kind"' in ui and "createDrawKindFields(" in ui - assert "paintPreview(" in ui and "pickNext(" in ui # D03 그림 · 셈 그대로 + for call in ("createPlanDraw({", "createPlanEdit(", "createPlanMenu({", "createPlanProps({"): + assert call in ui, call + assert ui.count("oneLine: true,") == 3 # 긋기 · 메뉴 · 속성 판 + assert "draw.key(event) || editor.key(event)" in ui # 같은 단축키 + assert ( + 'keyIs(event, "plan.undo") || keyIs(event, "plan.redo")' in ui + ) # 되돌리기 = 페이지 사진첩 assert '"pointerdown",' in ui and "event.stopImmediatePropagation()" in ui assert re.search( r"\n true,\n \);", ui ) # 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저 - assert 'keyIs(event, "plan.drawKind")' in ui and 'keyIs(event, "plan.drawFinish")' in ui - assert "createFloatPanel(" in ui and 'createElement("button")' not in ui + assert "event.buttons & 6" in ui # 오른쪽 · 가운데 끌기 = 지도 이동 그대로 + assert 'createElement("button")' not in ui + # D03 덧 인자 — 기본값 = 지금 그대로 + draw = _src(D03 / "D03_PlanAnalysis_UI_Draw.ts") + assert ( + "oneLine?: boolean;" in draw + and "params.oneLine ? oneLineBlock(target.kind, network) : null" in draw + ) + assert "marks: !params.oneLine," in draw + assert "markButton.hidden = opts.marks === false;" in _src( + D03 / "D03_PlanAnalysis_UI_DrawPanel.ts" + ) + menu = _src(D03 / "D03_PlanAnalysis_UI_Menu.ts") + assert "if (params.oneLine && oneLineBlock(aim, params.network())) return;" in menu + assert 'if (!params.oneLine) items.push(["여기서 연도 구역 나눔"' in menu + props = _src(D03 / "D03_PlanAnalysis_UI_Props.ts") + assert "!!params.oneLine ||" in props and "...(params.oneLine ? [] :" in props tools = _src(ROOT / "A00_Common" / "map" / "map_toolbar.ts") assert re.search(r'key: "expected-draw",\s+does: "[^"]+",\s+pages: B05', tools) + assert re.search(r'key: "expected-original", does: "[^"]+", pages: B05', tools) render = _src(B05 / "B05_Profile_UI_RouteEdit_Render.ts") # 맨 위 = 구조물 마커 뒤 · 회전 끝 앞 assert render.index("scene.overAll?.(context);") < render.index("context.restore(); // 회전 끝") + + +def test_원본_예정노선_안_덮음_옅은_점선_켬끔(): + ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") + assert "showOriginal && original.length > 1 && line() !== original" in ui + assert "expected_route.csv" in ui.split("*/")[0] # 머리 = 원본 안 덮음 적음 + for banned in ("write_route_csv", 'expected_route.csv"', "/route/initial"): + assert banned not in ui.split("*/", 1)[1], banned diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index ff1b8aa24..a4c24aa70 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -34,7 +34,13 @@ export const MAP_TOOLS = { "expected-draw": { icon: "pencil", title: - "예정노선 그리기 — 예정노선을 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선을 예정노선 따라 통째 다시 추적 (긋는 동안 계획선은 안 집힘)", + "예정노선 변경 — 켜면 예정노선이 맨 위 · 예정노선 페이지와 같은 긋기 · 고르기 · 고치기 → 평면선을 예정노선 따라 통째 다시 추적 (켠 동안 계획선 · 구조물은 안 집힘)", + toggle: true, + }, + "expected-original": { + icon: "lineDashed", + title: + "예정노선(원본) — 파일로 올린 예정노선을 옅은 점선으로 보이기 · 숨기기 (고친 예정노선과 다를 때만 보임)", toggle: true, }, } satisfies Record;