From ae6eace1ad0821a3b93c1f42ffff17a1f2735e30 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 11:42:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor(635-1):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=82=A4=ED=8A=B8=20=C2=B7=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=A7=80=EB=8F=84=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=95=9C=20=ED=95=A8=EC=88=98=20=E2=80=94=20oneLine=20=C2=B7?= =?UTF-8?q?=20=EA=B0=80=EC=A7=9C=20=EA=B0=92=20=EA=B1=B7=EC=9D=8C(?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 D03_PlanAnalysis_UI_Features.ts — 노선 그리기 기능 표(요구서 546 4장 · pages d03 · b05 = use · view · hide · 까닭) · featureUse · featureOn · featureShown - 새 D03_PlanAnalysis_UI_Kit.ts — createPlanKit(부품 엮기 · 왼쪽 칸 위치 · 지점 · 그리기 · 구역·연도 · 겹 차례 · 키 · sync) · bindPlanInput(누름 · 맴돎 · 놓음 · 우클릭 · 연도 마커 끌어 놓기 · capture = B05 켠 동안 먼저 받음) - 예정노선 UI_Page 680 → 620 줄 · 키트로 엮음(화면 · 저장 · 되돌리기 그대로) · UI_Map 지도 입력 줄 → map.input - UI_Draw · UI_Menu · UI_Props · UI_DrawPanel — oneLine 플래그 → 기능 표 page 읽기 · 지점 · 경위도(lonLat) · 연도 시작 = 없으면 숨김(빈 지점 · epsg 0 가짜 값 걷음) · oneLineBlock → lineLimitBlock - B05 ExpectedDraw — page: "b05" 넘김 · NO_POINTS · 가짜 값 걷음 · 지도 입력 = bindPlanInput capture(동작 그대로) · 속성 판 경위도 빈칸 → 칸 숨김 - 시험 고침: test_626_3(oneLine 글 대조 → 기능 표 · 키트 capture 대조) · test_475(위치 칸 서클 = 키트 site.title) Co-Authored-By: Claude Opus 5.5 --- B05_Profile/B05_Profile_UI_ExpectedDraw.ts | 95 +----- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 23 +- .../D03_PlanAnalysis_UI_DrawPanel.ts | 7 +- .../D03_PlanAnalysis_UI_Features.ts | 109 +++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts | 293 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 81 ++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 31 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 196 ++++-------- D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 51 +-- .../tester/test_475_plan_area_refresh.py | 4 +- resources/tester/test_626_3_expected_edit.py | 40 +-- 11 files changed, 591 insertions(+), 339 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts diff --git a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts index 150209ca1..56bc22eb4 100644 --- a/B05_Profile/B05_Profile_UI_ExpectedDraw.ts +++ b/B05_Profile/B05_Profile_UI_ExpectedDraw.ts @@ -5,8 +5,8 @@ * 긋기 칸 `createPlanDraw`(방식 칸 · 부채꼴 · +/− 색 · Shift · Tab · Enter · Esc · Backspace · 선 끝 이어 긋기) · * 고르기 겹 `createPlanEdit`(노드 → 선 → 구역 → 전체 · 끌어 고르기 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete) · * 우클릭 메뉴 `createPlanMenu`(여기서 긋기 시작 · 이어 긋기 · 노드 지움 · 다시 긋기) · 속성 판 `createPlanProps`([다시 긋기] · - * [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 한 선(`oneLine`) = 갈래 · 둘째 선 · 연도 · 지점 막음/숨김. - * 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음 = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 · + * [노드 지움] · [선 지움] · [구역 지움] · [전체 지움]) — 숨김 = 기능 표 「b05」(`D03_PlanAnalysis_UI_Features` · 갈래 · 둘째 선 · 연도 · 지점). + * 캔버스 누름 · 맴돎 · 두 번 클릭 · 오른쪽 클릭을 먼저 받음(키트 `bindPlanInput` capture) = 계획선 · 구조물 · 등고선 고르기 · 재기 · 노드 넣기 막음 · * 가운데 · 오른쪽 끌기(지도 이동) · 휠은 그대로. 끔 = 지금 B05 평면 편집 그대로(예정노선은 보기만). * 한 번 고침(긋기 끝 · 놓음 · 지움) → (켠 뒤 처음만) 확인창(직접 고친 평면 버림 · 예정노선 따라 통째 다시 추적) → * `POST /route/follow`(저장 0) → `onFollow(평면, 예정노선)` = 편집기 한 걸음(되돌리기 됨) → 페이지 [저장] = 손 편집과 @@ -27,9 +27,9 @@ import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sam 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 { bindPlanInput } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit"; 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 { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid"; @@ -47,19 +47,6 @@ 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; @@ -120,12 +107,11 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { // ── D03 긋기 · 고르기 · 메뉴 · 속성 판(같은 함수) ───────── const draw = createPlanDraw({ - base: () => [], + page: "b05", network: () => network, heightAt: () => z, commit, onMode, - oneLine: true, }); const show = (next: PlanNetwork): void => { network = next; @@ -146,28 +132,22 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { }; const editor = createPlanEdit(editParams); const props = createPlanProps({ + page: "b05", 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({ + page: "b05", 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"); @@ -314,65 +294,20 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw { 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); - }; const claim = (event: Event): void => { event.stopImmediatePropagation(); event.preventDefault(); }; - canvas.addEventListener( - "pointerdown", - (event) => { - if (!open || event.button !== 0) return; - claim(event); - const [px, py] = at(event); - if (!busy && layers.some((layer) => layer.press?.(px, py, map(), 0, event.shiftKey))) - canvas.setPointerCapture(event.pointerId); // 끌기가 밖으로 나가도 놓음을 받음 - params.redraw(); + bindPlanInput({ + canvas, + at: (event) => { + const rect = canvas.getBoundingClientRect(); + return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top); }, - true, - ); - canvas.addEventListener( - "pointermove", - (event) => { - if (!open || event.buttons & 6) return; // 오른쪽 · 가운데 끌기 = 지도 이동 그대로 - event.stopImmediatePropagation(); - const [px, py] = at(event); - const screen = map(); - for (const layer of layers) layer.hover?.(px, py, screen, event.shiftKey); - params.redraw(); - }, - 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", () => { - for (const layer of layers) layer.leave?.(); - if (open) params.redraw(); + screen: map, + layers: () => layers, + redraw: params.redraw, + capture: { active: () => open, busy: () => busy }, }); const onKey = (event: KeyboardEvent): void => { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 8a5cd7b9a..79067e987 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -62,14 +62,15 @@ import { DRAW_DEFAULT_SLOPE_PCT, type DrawConstraint, } from "./D03_PlanAnalysis_UI_DrawPanel"; +import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」."; export type DrawMode = "idle" | "draw"; -/** 한 선(626-3) 막힘 까닭 — 선 위 · 지점 = 갈래 · 선이 있는데 빈 땅 = 둘째 선 · 됨 = null. */ -export function oneLineBlock( +/** 갈래 숨긴 페이지(기능 표 「branch」 · B05 = 한 선) 막힘 까닭 — 선 위 · 지점 = 갈래 · 선이 있는데 빈 땅 = 둘째 선 · 됨 = null. */ +export function lineLimitBlock( kind: "tip" | "point" | "line" | "empty", network: PlanNetwork, ): string | null { @@ -95,15 +96,15 @@ export interface DrawPreview { } export interface PlanDrawParams { - /** 지점(긋기에 붙는 점 · 591-2). */ - base: () => Vertex[]; + /** 기능 표 페이지(`D03_PlanAnalysis_UI_Features` — 갈래 · 연도 마커 숨김). */ + page: PlanPage; + /** 지점(긋기에 붙는 점 · 591-2) — 없음 = 지점 없는 페이지. */ + base?: () => Vertex[]; network: () => PlanNetwork; heightAt: () => (x: number, y: number) => number; commit: (next: PlanNetwork) => void; /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ onMode: () => void; - /** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 · 둘째 선 막음 · 연도 구역 마커 숨김. 없음 = D03 그대로. */ - oneLine?: boolean; } export interface PlanDraw { @@ -149,6 +150,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { through: Vertex[]; records: NodeRecord[]; } | null = null; + const basePoints = (): Vertex[] => params.base?.() ?? []; const spanLine = (): number => span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1; @@ -159,7 +161,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { }); const drawPanel = createDrawPanel({ kind: kindFields, - marks: !params.oneLine, + page: params.page, onDraw: () => setMode(mode === "draw" ? "idle" : "draw"), }); const panel = drawPanel.root; @@ -186,8 +188,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { ): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null { const network = params.network(); const head = active === null ? undefined : network.lines[active]?.vertices[0]; - const offers = params - .base() + const offers = basePoints() .filter((point) => !head || !samePoint(point, head)) .map((point) => ({ point, note: "지점에 붙음" })); network.lines.forEach((line, i) => { @@ -351,7 +352,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { | { kind: "line"; hit: NonNullable> } | { kind: "empty"; point: Vertex } { const network = params.network(); - const points = params.base(); + const points = basePoints(); const gap = (point: Vertex): number => screenGap(map, point, px, py); const nearest = (items: T[]): T | undefined => items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0]; @@ -381,7 +382,7 @@ 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; + const single = featureOn(params.page, "branch") ? null : lineLimitBlock(target.kind, network); if (single) { showToast(single, "info"); return false; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts index 4e72da35e..27963ca4f 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 { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; import "./D03_PlanAnalysis_UI_DrawParts.css"; // 방식 칸 = 공용(`A00_Common/map/map_draw_kind` · 626-3 · B05 평면 예정노선 그리기도 씀) @@ -34,8 +35,8 @@ export interface DrawPanel { export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void; - /** 연도 구역 마커 단추(없음 = 보임 · B05 평면 예정노선 = 숨김 626-3). */ - marks?: boolean; + /** 기능 표 페이지 — 연도 구역 마커 단추 = 「years」 사용일 때만. */ + page: PlanPage; }): DrawPanel { const drawButton = createButton({ label: "긋기", @@ -51,7 +52,7 @@ export function createDrawPanel(opts: { }); markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다"; markButton.dataset.role = "draw-year-mark"; - markButton.hidden = opts.marks === false; + markButton.hidden = !featureOn(opts.page, "years"); markButton.draggable = true; markButton.addEventListener("dragstart", (event) => { event.dataTransfer?.setData(YEAR_MARK_DRAG, "1"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts new file mode 100644 index 000000000..1a91999f8 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Features.ts @@ -0,0 +1,109 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Features.ts + * 노선 그리기 기능 표(PLAN 635-1 · 요구서 546 4장) — 예정노선(d03) · B05 평면이 같은 그리기 부품을 쓰고 이 표로만 가름. + * 한 줄 = 기능 열쇠 · 이름 · 페이지마다 쓰임(use 사용 · view 보기만 · hide 숨김) · 까닭. + * 숨김을 바꿀 때 = 이 표만 고침(부품 · 키트가 `featureUse` 로 읽음) · 꼴 = 지도 도구줄 표(`map_toolbar`). + * ========================================================================== */ + +import type { MapPage } from "../A00_Common/map/map_layer_list"; + +export type PlanPage = MapPage; +export type FeatureUse = "use" | "view" | "hide"; +export type PlanFeature = + | "site" + | "draw" + | "extend" + | "select" + | "props" + | "history" + | "terrain" + | "road-upload" + | "branch" + | "years" + | "points" + | "plan-work"; + +export interface PlanFeatureRow { + key: PlanFeature; + name: string; + pages: Record; + why?: string; +} + +export const PLAN_FEATURES: readonly PlanFeatureRow[] = [ + { key: "site", name: "주소 · 장소 찾기(B05 = 지도 이동만)", pages: { d03: "use", b05: "use" } }, + { + key: "draw", + name: "긋기 네 방식 · 길이 · 기울기 · Tab · Shift · 부채꼴 · ± 색", + pages: { d03: "use", b05: "use" }, + }, + { + key: "extend", + name: "이어 긋기 · 다시 긋기 · 빈 땅 새 선(선 없을 때)", + pages: { d03: "use", b05: "use" }, + }, + { + key: "select", + name: "고름 노드 → 선 → 전체 · 끌어 고르기 · 노드 끌기 · 꺾임점 · 지움", + pages: { d03: "use", b05: "use" }, + }, + { + key: "props", + name: "속성 판 지반고 · 측점 · 길이 · 기울기 · 경위도(B05 = 서버 변환)", + pages: { d03: "use", b05: "use" }, + }, + { + key: "history", + name: "되돌리기 · 다시하기 · 길이측정 · 3D(B05 = B05 것)", + pages: { d03: "use", b05: "use" }, + }, + { + key: "terrain", + name: "기준점 · 지형 받기 · 다시 받기 · 10 km 알림", + pages: { d03: "use", b05: "hide" }, + why: "B 지형 = 전처리 확정", + }, + { + key: "road-upload", + name: "임도(업로드) 올리기 · 묶음 목록", + pages: { d03: "use", b05: "hide" }, + why: "서버 B 400 · 받은 자료로 보임", + }, + { + key: "branch", + name: "갈래 · 둘째 선 · 다른 선과 잇기 · 가운데 토막 지움(끝 토막 지움은 사용)", + pages: { d03: "use", b05: "hide" }, + why: "B 계획선 한 줄", + }, + { + key: "years", + name: "연도 구역 마커 · 구역 · 연도 고침 · 선 이름", + pages: { d03: "use", b05: "view" }, + why: "받은 몫 고정", + }, + { + key: "points", + name: "지점 칸 · 핀 · 지점에서 긋기", + pages: { d03: "use", b05: "hide" }, + why: "B 저장 자리 없음", + }, + { + key: "plan-work", + name: "등록 기준점 · 확정 · 업체 지정 · 배포 · 산림청 [저장]", + pages: { d03: "use", b05: "hide" }, + why: "예정노선 업무", + }, +]; + +/** 그 페이지 쓰임 — 표에 없는 열쇠 = 숨김. */ +export function featureUse(page: PlanPage, key: PlanFeature): FeatureUse { + return PLAN_FEATURES.find((row) => row.key === key)?.pages[page] ?? "hide"; +} + +/** 고칠 수 있게 씀(보기만 · 숨김 = 거짓). */ +export const featureOn = (page: PlanPage, key: PlanFeature): boolean => + featureUse(page, key) === "use"; + +/** 보임(사용 · 보기만). */ +export const featureShown = (page: PlanPage, key: PlanFeature): boolean => + featureUse(page, key) !== "hide"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts new file mode 100644 index 000000000..fe9d3c113 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Kit.ts @@ -0,0 +1,293 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Kit.ts + * 노선 그리기 키트(PLAN 635-1 · 요구서 546 3장 「노선 그리기」) — 예정노선(d03) · B05 평면이 같은 한 벌을 엮음. + * + * 부품 = 긋기 `createPlanDraw` · 고르기 `createPlanEdit` · 속성 판 `createPlanProps` · 구역·연도 `createPlanPieces` · + * 지점 `createPlanPoints` · 우클릭 메뉴 `createPlanMenu` — 숨김 = 기능 표(`D03_PlanAnalysis_UI_Features`)만 읽음. + * 내놓음 = 왼쪽 칸(위치 · 지점 · 그리기 · 구역·연도 중 표가 켠 것) · 겹 차례(고정) · 키 · `sync()`. + * 저장 · 초기화 · 되돌리기 = 페이지 몫(`commit` 한 번 = 한 걸음 · 캐시 = 페이지). + * 지도 입력 나눠 주기 `bindPlanInput` 하나 — 예정노선 지도(겹 몫 그대로) · B05 평면(`capture` = 켠 동안 먼저 받음). + * ========================================================================== */ + +import { perfTime } from "@ui/ui_template_gl_map"; +import { createPanelCard } from "@ui/ui_template_collapsible"; +import type { HeightAt } from "./logic/D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { createPlanDraw, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import { createPlanEdit, type PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; +import { featureOn, featureShown, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; +import { YEAR_MARK_DRAG, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import { createPlanMenu, type PlanMenu } from "./D03_PlanAnalysis_UI_Menu"; +import { createPlanPieces, type PlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; +import { createPlanPoints, type PlanPoints } from "./D03_PlanAnalysis_UI_Points"; +import { createPlanProps, type PlanProps } from "./D03_PlanAnalysis_UI_Props"; + +/** 지점 통로(표 「points」 켠 페이지) — 지점 · 이름 · 좌표계 · 보기 가운데 · 한 걸음 · 보여 주기만. */ +export interface PlanKitPoints { + list: () => Vertex[]; + names: () => string[]; + epsg: () => number; + center: () => Vertex | null; + commit: (points: Vertex[], next: PlanNetwork, names?: string[]) => void; + show: (points: Vertex[], next: PlanNetwork) => void; +} + +export interface PlanKitParams { + page: PlanPage; + network: () => PlanNetwork; + /** 한 걸음(되돌리기 · 캐시 = 페이지). */ + commit: (next: PlanNetwork) => void; + /** 끄는 중 보여 주기만(걸음 아님). */ + show: (next: PlanNetwork) => void; + heightAt: () => HeightAt; + /** 작업 좌표 → 경위도(없음 = 속성 판 경위도 칸 숨김). */ + lonLat?: (x: number, y: number) => [number, number] | null; + editable: () => boolean; + /** 고르기 · 핀이 누름을 받을 때(긋기 쉼 말고 더 — 예정노선 = 재기 끔). */ + idle?: () => boolean; + onMode: () => void; + onSelect: () => void; + /** 사업연도 시작(연도 고름). */ + yearStart?: () => number; + points?: PlanKitPoints; + /** 페이지 키(되돌리기 · 다시하기 · 재기 끔) — 긋기 · 고르기보다 먼저. */ + onKey?: (event: KeyboardEvent) => boolean; + cards: { + /** 위치 칸 몸 · 제목 곁(진행 서클) — 찾기 → 이동은 페이지 몫. */ + site?: { parts: HTMLElement[]; title?: HTMLElement }; + }; +} + +export interface PlanKit { + /** 왼쪽 칸 — 표가 켠 차례(위치 · 지점 · 그리기 · 구역·연도). */ + cards: HTMLElement[]; + /** 겹 차례 고정 — 페이지 아래 겹 · 긋기 · 핀 누름 · 고르기 · 페이지 위 겹 · 핀 그림 · 우클릭 메뉴(맨 위). */ + layers: (under: PlanMapLayer[], over: PlanMapLayer[]) => PlanMapLayer[]; + draw: PlanDraw; + edit: PlanEdit; + props: PlanProps; + menu: PlanMenu; + marks: PlanPoints | null; + pieces: PlanPieces | null; + /** 키 — 받았으면 참(글 칸 · 보기만 = 안 받음). */ + key: (event: KeyboardEvent) => boolean; + /** 꼴 · 고름이 바뀜 — 긋기 · 고르기 · 판 · 칸 다시. */ + sync: () => void; +} + +export function createPlanKit(params: PlanKitParams): PlanKit { + const { page, network, commit, heightAt, editable } = params; + const points = featureOn(page, "points") ? params.points : undefined; + const idle = (): boolean => draw.mode() === "idle" && (params.idle?.() ?? true); + const commitPoints = (list: Vertex[], next: PlanNetwork): void => + points ? points.commit(list, next) : commit(next); + + const draw = createPlanDraw({ + page, + base: points?.list, + network, + heightAt, + commit, + onMode: params.onMode, + }); + const edit = createPlanEdit({ + network, + base: () => points?.list() ?? [], + commit, + heightAt, + show: params.show, + commitPoints, + showPoints: (list, next) => (points ? points.show(list, next) : params.show(next)), + enabled: idle, + editable, + onSelect: params.onSelect, + }); + const props = createPlanProps({ + page, + network, + edit, + editable, + yearStart: params.yearStart, + commit, + heightAt, + lonLat: params.lonLat, + points: points?.list, + addPoint: points + ? (at) => points.commit([...points.list(), at], network(), [...points.names(), ""]) + : undefined, + commitPoints, + respan: draw.respan, + }); + const pieces = featureShown(page, "years") + ? createPlanPieces({ network, edit, editable, yearOptions: props.yearOptions, commit }) + : null; + const marks = points + ? createPlanPoints({ + points: points.list, + network, + names: points.names, + epsg: points.epsg, + commit: (list, names, next) => points.commit(list, next, names), + center: points.center, + show: points.show, + idle, + editable, + }) + : null; + const menu = createPlanMenu({ + page, + network, + points: points?.list, + marks: marks ?? undefined, + edit, + draw, + editable, + respan: draw.respan, + }); + + const card = (title: string, ...body: HTMLElement[]): HTMLElement => { + const box = createPanelCard(title, false); + box.append(...body); + return box; + }; + const cards: HTMLElement[] = []; + const site = params.cards.site; + if (site && featureShown(page, "site")) { + const siteCard = card("위치", ...site.parts); + if (site.title) siteCard.firstElementChild!.append(site.title); + cards.push(siteCard); + } + if (marks) cards.push(card("지점", marks.panel)); + cards.push(card("그리기", draw.panel)); + if (pieces) cards.push(card("구역 · 연도", pieces.list)); + + return { + cards, + layers: (under, over) => [ + ...under, + draw.layer, + ...(marks ? [marks.input] : []), + edit.layer, + ...over, + ...(marks ? [marks.pins] : []), + menu.layer, + ], + draw, + edit, + props, + menu, + marks, + pieces, + key(event) { + const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select"); + if (typing || !editable()) return false; + return !!params.onKey?.(event) || draw.key(event) || edit.key(event); + }, + sync() { + draw.sync(); + edit.sync(); + props.render(); + pieces?.render(); + marks?.render(); + }, + }; +} + +/** 지도 입력 — 그림 px · 겹 · 그릴 틀(없음 = 아직 못 그림) · 다시 그림. */ +export interface PlanInput { + canvas: HTMLCanvasElement; + /** 누른 자리 → 그림 px(돌리기 전). */ + at: (event: MouseEvent) => [number, number]; + screen: () => ScreenMap | null; + layers: () => PlanMapLayer[]; + redraw: () => void; + /** 남의 지도 위에 얹음(B05 평면) — 켠 동안 잡기 단계에서 먼저 받고 막음 · 오른쪽 · 가운데 끌기 · 휠은 그대로. */ + capture?: { active: () => boolean; busy: () => boolean }; +} + +/** 누름 · 맴돎 · 놓음 · 우클릭 · 연도 마커 끌어 놓기를 겹에 나눠 줌(한 함수 · 두 페이지). */ +export function bindPlanInput(input: PlanInput): void { + const { canvas, at, layers, redraw, capture } = input; + const on = ( + type: K, + run: (event: HTMLElementEventMap[K]) => void, + ): void => canvas.addEventListener(type, run, !!capture); + const claim = (event: Event): void => { + event.stopImmediatePropagation(); + event.preventDefault(); + }; + /** 지금 받을 틀 — 얹은 지도는 켠 동안만. */ + const live = (): ScreenMap | null => (capture && !capture.active() ? null : input.screen()); + const free = (): boolean => !capture?.busy(); + + on("pointerdown", (event) => { + if (event.button !== 0) return; + const map = live(); + if (!map) return; + if (capture) claim(event); + const [px, py] = at(event); + if (free() && layers().some((layer) => layer.press?.(px, py, map, 0, event.shiftKey))) { + canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음 + redraw(); + } else if (capture) redraw(); + }); + on("pointerup", (event) => { + if (event.button !== 0) return; + const map = live(); + if (!map) return; + if (capture) claim(event); + for (const layer of layers()) layer.release?.(map); + redraw(); + }); + on("pointermove", (event) => { + const map = live(); + if (!map || event.buttons & (capture ? 6 : 4)) return; // 가운데(얹음 = 오른쪽도) 끌기 = 지도 이동 + if (capture) event.stopImmediatePropagation(); + perfTime("plan:pointermove", () => { + const [px, py] = at(event); + for (const layer of layers()) layer.hover?.(px, py, map, event.shiftKey); + redraw(); + }); + }); + canvas.addEventListener("pointerleave", () => { + for (const layer of layers()) layer.leave?.(); + if (!capture || capture.active()) redraw(); + }); + on("contextmenu", (event) => { + if (capture ? capture.active() : true) event.preventDefault(); + const map = live(); + if (!map) return; + if (capture) claim(event); + const [px, py] = at(event); + const took = free() && layers().some((layer) => layer.press?.(px, py, map, 2, event.shiftKey)); + if (took || capture) redraw(); + }); + if (capture) + for (const type of ["click", "dblclick"] as const) + on(type, (event) => capture.active() && claim(event)); + const markDrag = (event: DragEvent): ScreenMap | null => + event.dataTransfer?.types.includes(YEAR_MARK_DRAG) ? live() : null; + on("dragover", (event) => { + const map = markDrag(event); + if (!map) return; + const [px, py] = at(event); + if (layers().some((layer) => layer.dragOver?.(px, py, map))) { + event.preventDefault(); + event.dataTransfer!.dropEffect = "copy"; + } + redraw(); + }); + on("drop", (event) => { + const map = markDrag(event); + if (!map) return; + event.preventDefault(); + const [px, py] = at(event); + layers().some((layer) => layer.drop?.(px, py, map)); + redraw(); + }); + canvas.addEventListener("dragleave", () => { + if (capture && !capture.active()) return; + for (const layer of layers()) layer.leave?.(); + redraw(); + }); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index df044fd04..b0a4b1f47 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -38,7 +38,7 @@ import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm"; -/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4). */ +/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4 · 받음 = 키트 `bindPlanInput`). */ export const YEAR_MARK_DRAG = "application/x-aislo-year-mark"; /** 측점 간격(m) — B 규칙 측점과 같음. */ @@ -155,6 +155,14 @@ export interface PlanMap { screenOfLonLat: (lon: number, lat: number) => [number, number] | null; /** 검증 창구 — 지금 돌린 각(도). */ rotationDeg: () => number; + /** 지도 입력 — 누름 · 맴돎 · 놓음 · 우클릭을 겹에 나눠 줄 재료(`bindPlanInput`). */ + input: { + canvas: HTMLCanvasElement; + at: (event: MouseEvent) => [number, number]; + screen: () => ScreenMap | null; + layers: () => PlanMapLayer[]; + redraw: () => void; + }; dispose: () => void; } @@ -443,71 +451,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { flush(); } - /** 누른 화면 자리 → 그림 px(돌리기 전). */ - const at = (event: PointerEvent | MouseEvent): [number, number] => { + /** 누른 화면 자리 → 그림 px(돌리기 전) · 나눠 주기 = `bindPlanInput`(키트 · 페이지가 묶음). */ + const at = (event: MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); }; - - canvas.addEventListener("pointerdown", (event) => { - if (!meta || event.button !== 0) return; - const [px, py] = at(event); - const map = screenMap(); - if (layers.some((layer) => layer.press?.(px, py, map, 0, event.shiftKey))) { - canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음 - drawTop(); - } - }); - canvas.addEventListener("pointerup", (event) => { - if (event.button !== 0 || !meta) return; - const map = screenMap(); - for (const layer of layers) layer.release?.(map); - drawTop(); - }); - canvas.addEventListener("pointermove", (event) => { - if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 - perfTime("plan:pointermove", () => { - const [px, py] = at(event); - const map = screenMap(); - for (const layer of layers) layer.hover?.(px, py, map, event.shiftKey); - drawTop(); - }); - }); - canvas.addEventListener("pointerleave", () => { - for (const layer of layers) layer.leave?.(); - drawTop(); - }); - const markDrag = (event: DragEvent): boolean => - !!meta && !!event.dataTransfer?.types.includes(YEAR_MARK_DRAG); - canvas.addEventListener("dragover", (event) => { - if (!markDrag(event)) return; - const [px, py] = at(event); - const map = screenMap(); - if (layers.some((layer) => layer.dragOver?.(px, py, map))) { - event.preventDefault(); - event.dataTransfer!.dropEffect = "copy"; - } - drawTop(); - }); - canvas.addEventListener("drop", (event) => { - if (!markDrag(event)) return; - event.preventDefault(); - const [px, py] = at(event); - const map = screenMap(); - layers.some((layer) => layer.drop?.(px, py, map)); - drawTop(); - }); - canvas.addEventListener("dragleave", () => { - for (const layer of layers) layer.leave?.(); - drawTop(); - }); - canvas.addEventListener("contextmenu", (event) => { - event.preventDefault(); - if (!meta) return; - const [px, py] = at(event); - const map = screenMap(); - if (layers.some((layer) => layer.press?.(px, py, map, 2, event.shiftKey))) drawTop(); - }); bindRouteEditNavigation({ canvas, getView: () => view, @@ -570,6 +518,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { screenOfLonLat: (lon, lat) => scene.meta ? rotation.rerotate(...lonLat(scene.meta).project(lon, lat)) : null, rotationDeg: () => (rotation.radians() * 180) / Math.PI, + input: { + canvas, + at, + screen: () => (meta ? screenMap() : null), + layers: () => layers, + redraw: drawTop, + }, dispose: () => { gl?.enable(null); observer.disconnect(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts index 24064c72f..3a0a716b0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -10,22 +10,24 @@ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; -import { oneLineBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import { lineLimitBlock, type PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; +import { featureOn, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points"; export interface PlanMenuParams { + /** 기능 표 페이지 — 갈래 · 연도 구역 나눔 · 지점 항목. */ + page: PlanPage; network: () => PlanNetwork; - points: () => Vertex[]; - marks: PlanPoints; + /** 지점 · 핀(표 「points」 켠 페이지만). */ + points?: () => Vertex[]; + marks?: PlanPoints; edit: PlanEdit; draw: PlanDraw; editable: () => boolean; /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ respan: (line: string, from: number, to: number) => boolean; - /** 한 선만(B05 평면 예정노선 · 626-3) — 갈래 시작 · 둘째 선 · 연도 구역 나눔 항목 뺌. 없음 = D03 그대로. */ - oneLine?: boolean; } export interface PlanMenu { @@ -35,7 +37,9 @@ export interface PlanMenu { } export function createPlanMenu(params: PlanMenuParams): PlanMenu { - const { marks, edit, draw } = params; + const { edit, draw, page } = params; + const marks = featureOn(page, "points") ? params.marks : undefined; + const points = (): Vertex[] => params.points?.() ?? []; const menu = createMapContextMenu("d03-plan"); let menuAt: [number, number] = [0, 0]; @@ -44,11 +48,11 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { press(px, py, map, button) { if (button !== 2 || !params.editable()) return false; const items: MapContextMenuItem[] = []; - const pin = marks.pinAt(px, py, map); + const pin = marks?.pinAt(px, py, map) ?? null; 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; + if (!featureOn(page, "branch") && lineLimitBlock(aim, params.network())) return; const label = { tip: "이어 긋기", point: "지점에서 긋기 시작", @@ -59,8 +63,8 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { }; if (draw.mode() === "draw" && draw.stroke()) items.push(["긋기 끝", () => draw.setMode("idle")]); - else if (pin !== null) { - const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 + else if (marks && pin !== null) { + const [sx, sy] = map.toScreen(points()[pin]); // 핀 머리를 눌러도 지점 자리에서 items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]); } else if (at?.mark) { @@ -78,15 +82,16 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { const [sx, sy] = map.toScreen(point); start(sx, sy); items.push("-", ["노드 지움", () => edit.dropNode(node)]); - if (!marks.full() && !params.points().some((p) => samePoint(p, point))) + if (marks && !marks.full() && !points().some((p) => samePoint(p, point))) items.push(["지점으로", () => marks.place(sx, sy, map)]); } else if (at?.segment) { const seg = at.segment; start(px, py); - if (!params.oneLine) items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); + if (featureOn(page, "years")) + 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()) + if (marks && pin === null && !at?.node && !at?.mark && !marks.full()) items.push("-", ["지점 놓기", () => marks.place(px, py, map)]); menu.open(menuAt[0], menuAt[1], items); return true; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 77b3e017a..91bc5c79f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -21,7 +21,7 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { ROUTES } from "@config/config_frontend"; -import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { askConfirm } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; import { @@ -50,8 +50,7 @@ import { type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; -import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; -import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit"; +import { bindPlanInput, createPlanKit } from "./D03_PlanAnalysis_UI_Kit"; import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; import { createPlanTerrain } from "./D03_PlanAnalysis_UI_LidarUpload"; import { @@ -60,10 +59,6 @@ import { type PlanMapLayer, } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; -import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu"; -import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; -import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; -import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; import { aroundSite, @@ -84,6 +79,7 @@ import { type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; +import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; @@ -130,83 +126,75 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const heights = terrain.surface(surface); // 지형 칩 원천(624-4) return heights ? gridHeightAt(heights.grid, x, y) : NaN; }; - const draw = createPlanDraw({ - base: () => points, - network: () => network, - heightAt: () => z, - commit, - onMode: () => { - if (draw.mode() !== "idle") setMeasure(false); - refresh(); - }, - }); const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void => step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로 - const edit = createPlanEdit({ + const showPoints = (nextPoints: Vertex[], next: PlanNetwork): void => { + [points, network] = [nextPoints, next]; + map.redraw(); + }; + // 긋기 · 고르기 · 속성 판 · 지점 · 구역 · 메뉴 · 왼쪽 칸 · 키 = 노선 그리기 키트(635-1 · 숨김 = 기능 표 「d03」) + const circle = createProgressCircle({ size: 16, inline: true }); // 받기 진행 = 위치 칸 제목 오른쪽 서클(604-3) + const siteBox = createPlanSite({ + onPick: (hit) => void pickSite(hit), + onRetry: () => { + [failed, lastTried, ready] = [null, "", true]; + void ensureCovered(true); + }, + onUpload: (files) => uploadRoads(files), + roads: { + projectId, + onPick: (pick, bounds) => { + backdrop.pickRoad(pick); + if (pick) fitTo(boxOfLonLat(epsg, bounds)); + }, + onDelete: (bundle) => backdrop.deleteRoad(bundle), + }, + }); + const kit = createPlanKit({ + page: "d03", network: () => network, - base: () => points, commit, - heightAt: () => z, show: (next) => { network = normalizePieces(next, yearStart); map.redraw(); }, - commitPoints: stepPoints, - showPoints: (nextPoints, next) => { - [points, network] = [nextPoints, next]; - map.redraw(); - }, - enabled: () => draw.mode() === "idle" && !measuring, - editable: () => editable, - onSelect: () => refresh(), - }); - const props = createPlanProps({ - network: () => network, - edit, - editable: () => editable, - yearStart: () => yearStart, - commit, heightAt: () => z, - epsg: () => epsg, - points: () => points, - addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]), - commitPoints: stepPoints, - respan: draw.respan, - }); - const pieces = createPlanPieces({ - network: () => network, - edit, + lonLat: (x, y) => { + const tm = tmParams(epsg); + return tm ? tmToLonLat(tm, x, y) : null; + }, editable: () => editable, - yearOptions: props.yearOptions, - commit, + idle: () => !measuring, + onMode: () => { + if (draw.mode() !== "idle") setMeasure(false); + refresh(); + }, + onSelect: () => refresh(), + yearStart: () => yearStart, + points: { + list: () => points, + names: () => pointNames, + epsg: () => epsg, + center: () => viewMid, + commit: stepPoints, + show: showPoints, + }, + onKey: (event) => + keyIs(event, "plan.undo") + ? (undo(), true) + : keyIs(event, "plan.redo") + ? (redo(), true) + : measuring && event.key === "Escape" + ? (setMeasure(false), true) + : false, + cards: { site: { parts: siteBox.parts, title: circle.root } }, }); + const { draw, edit, props } = kit; + const marks = kit.marks!; const measure = createPlanMeasure( () => z, () => network, ); - const marks = createPlanPoints({ - points: () => points, - network: () => network, - names: () => pointNames, - epsg: () => epsg, - commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames), - center: () => viewMid, - show: (nextPoints, next) => { - [points, network] = [nextPoints, next]; - map.redraw(); - }, - idle: () => draw.mode() === "idle" && !measuring, - editable: () => editable, - }); - const menu = createPlanMenu({ - network: () => network, - points: () => points, - marks, - edit, - draw, - editable: () => editable, - respan: draw.respan, - }); // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { draw(context, screen) { @@ -216,8 +204,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬 }, }; - // 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3) - const circle = createProgressCircle({ size: 16, inline: true }); const work = createWorkCircle(circle); const terrain = createPlanTerrain({ projectId, @@ -237,31 +223,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root); - const siteBox = createPlanSite({ - onPick: (hit) => void pickSite(hit), - onRetry: () => { - [failed, lastTried, ready] = [null, "", true]; - void ensureCovered(true); - }, - onUpload: (files) => uploadRoads(files), - roads: { - projectId, - onPick: (pick, bounds) => { - backdrop.pickRoad(pick); - if (pick) fitTo(boxOfLonLat(epsg, bounds)); - }, - onDelete: (bundle) => backdrop.deleteRoad(bundle), - }, - }); - const siteCard = createPanelCard("위치", false); - siteCard.firstElementChild!.append(circle.root); - siteCard.append(...siteBox.parts); - const pointCard = createPanelCard("지점", false); - pointCard.append(marks.panel); - const drawCard = createPanelCard("그리기", false); - drawCard.append(draw.panel); - const pieceCard = createPanelCard("구역 · 연도", false); - pieceCard.append(pieces.list); const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); const resetButton = createButton({ label: "초기화", @@ -273,7 +234,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(siteCard, pointCard, drawCard, pieceCard, actions); + panel.append(...kit.cards, actions); attachCollapsible(panel); // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { @@ -351,17 +312,9 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); } - /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */ - const layerList = (): PlanMapLayer[] => [ - ...backdrop.layers, - draw.layer, - marks.input, - edit.layer, - ...(measuring ? [measure.layer] : []), - viewWatch, - marks.pins, - menu.layer, - ]; + /** 겹 차례 = 키트(바탕 겹 아래 · 재기 · 보기 가운데 = 고르기 위 · 핀 그림 · 우클릭 메뉴 맨 위). */ + const layerList = (): PlanMapLayer[] => + kit.layers(backdrop.layers, [...(measuring ? [measure.layer] : []), viewWatch]); /** 점들이 받은 범위 안인가. */ const inside = (list: Vertex[]): boolean => @@ -381,8 +334,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } function refresh(): void { - draw.sync(); - edit.sync(); + kit.sync(); map.setTools({ undo: editable && undoStack.length > 0, redo: editable && redoStack.length > 0, @@ -390,9 +342,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { measure: measuring, }); map.redraw(); - props.render(); - pieces.render(); - marks.render(); const far = epsg ? farFromSite(epsg, site, [...points, ...allVertices()]) : 0; const farNote = far ? `기준점 10 km 밖 점 ${far}곳 — 지형 안 받음 · 위치를 옮기세요` : ""; siteBox.render({ @@ -620,26 +569,17 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { layers: backdrop.state, terrain: terrain.state, }; + // 키 = 등록부(588-3 · 한글 자판도 자판 자리로) · 되돌리기 · 다시하기 · 재기 끔 = 키트 `onKey` 먼저 const onKey = (event: KeyboardEvent): void => { if (!root.isConnected) return window.removeEventListener("keydown", onKey); - const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select"); - if (typing || !editable) return; - // 키 = 등록부(588-3 · 한글 자판도 자판 자리로) - const handled = keyIs(event, "plan.undo") - ? (undo(), true) - : keyIs(event, "plan.redo") - ? (redo(), true) - : measuring && event.key === "Escape" - ? (setMeasure(false), true) - : draw.key(event) || edit.key(event); - if (handled) { - event.preventDefault(); - map.redraw(); - } + if (!kit.key(event)) return; + event.preventDefault(); + map.redraw(); }; window.addEventListener("keydown", onKey); map.setLayers(layerList()); - menu.mount(map.root); + kit.menu.mount(map.root); + bindPlanInput(map.input); try { editable = !!(await fetchSessionUser())?.is_forest_service; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 789ac1eef..62bdca065 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -33,7 +33,6 @@ import { segmentRange, } from "./logic/D03_PlanAnalysis_Select"; import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; -import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; import { createDrawKindFields, DRAW_DEFAULT_LENGTH_M, @@ -41,6 +40,7 @@ import { type DrawKindValue, } from "./D03_PlanAnalysis_UI_DrawPanel"; import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; +import { featureOn, featureShown, type PlanPage } from "./D03_PlanAnalysis_UI_Features"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; import "./D03_PlanAnalysis_UI_DrawParts.css"; @@ -53,22 +53,24 @@ const meters = (m: number): string => `${m.toFixed(1)} m`; const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`); export interface PlanPropsParams { + /** 기능 표 페이지 — 연도 · 구간 줄 · [지점으로]. */ + page: PlanPage; network: () => PlanNetwork; edit: PlanEdit; editable: () => boolean; - yearStart: () => number; + /** 사업연도 시작(연도 고름 · 표 「years」 사용 페이지). */ + yearStart?: () => number; commit: (next: PlanNetwork) => void; heightAt: () => HeightAt; - epsg: () => number; - /** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */ - points: () => Vertex[]; - addPoint: (point: Vertex) => void; + /** 작업 좌표 → 경위도(없음 = 경위도 칸 숨김). */ + lonLat?: (x: number, y: number) => [number, number] | null; + /** 지점(노드 판 [지점으로] — 이미 있으면 숨김 · 표 「points」 켠 페이지만). */ + points?: () => Vertex[]; + addPoint?: (point: Vertex) => void; /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ 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 { @@ -81,7 +83,11 @@ export interface PlanProps { } export function createPlanProps(params: PlanPropsParams): PlanProps { - const { edit } = params; + const { edit, page } = params; + const points = (): Vertex[] => params.points?.() ?? []; + /** 연도 줄 보임 — 표 「years」 사용 · 보기만 + 조각 있음. */ + const yearsShown = (network: PlanNetwork): boolean => + featureShown(page, "years") && network.pieces.length > 0; const float = createFloatPanel({ title: "고름", draggable: true, @@ -92,7 +98,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { float.root.hidden = true; function yearOptions(): Array<{ value: string; text: string }> { - const start = params.yearStart(); + const start = params.yearStart?.() ?? new Date().getFullYear(); const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => start + i)); params.network().pieces.forEach((piece) => years.add(piece.year)); return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); @@ -115,7 +121,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""), size: 24, ariaLabel: "고른 구간 연도", - disabled: !params.editable(), + disabled: !params.editable() || !featureOn(page, "years"), onChange: (value) => value && params.commit(setPiecesYear(params.network(), ids, Number(value))), }); @@ -153,7 +159,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { const apply = (): void => { const network = params.network(); const next = nodeRecord(value.kind, value.lengthM, value.slopePct); - const out = placeNode(network, params.points(), line, vertex, next, params.heightAt()); + const out = placeNode(network, points(), line, vertex, next, params.heightAt()); if (typeof out === "string") return void showToast(out, "info"); if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines)) params.commitPoints(out.points, out.network); @@ -188,17 +194,17 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { const { line, vertex } = picked.node!; const vertices = network.lines.find((l) => l.id === line)!.vertices; const point = vertices[vertex]; - const tm = tmParams(params.epsg()); - const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + const lonLat = params.lonLat?.(point[0], point[1]) ?? null; const height = z(point[0], point[1]); const rows: KvRow[] = [ - { - label: "경위도", - value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null, - }, { label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null }, { label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) }, ]; + if (params.lonLat) + rows.unshift({ + label: "경위도", + value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null, + }); if (vertex > 0) { const [a, b] = [vertices[vertex - 1], vertices[vertex]]; rows.push( @@ -211,9 +217,10 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { const parts: HTMLElement[] = [createKv(rows)]; if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex)); const toPoint = button("지점으로", "props-to-point", () => - params.addPoint([point[0], point[1]]), + params.addPoint?.([point[0], point[1]]), ); - toPoint.hidden = !!params.oneLine || params.points().some((p) => samePoint(p, point)); + toPoint.hidden = + !featureOn(page, "points") || !params.addPoint || points().some((p) => samePoint(p, point)); return { title: `${nameOf(line)} · 노드 ${vertex + 1}`, parts, @@ -240,7 +247,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); } - if (!params.oneLine) + if (yearsShown(network)) rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; if (single) @@ -267,7 +274,7 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { { label: "길이", value: meters(length) }, { label: "노드", value: `${vertices.length}개` }, ]), - ...(params.oneLine ? [] : [yearField(pieces), pieceRows(network, pieces)]), + ...(yearsShown(network) ? [yearField(pieces), pieceRows(network, pieces)] : []), ], actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], }; diff --git a/resources/tester/test_475_plan_area_refresh.py b/resources/tester/test_475_plan_area_refresh.py index 7033eeadb..8b5a38a8c 100644 --- a/resources/tester/test_475_plan_area_refresh.py +++ b/resources/tester/test_475_plan_area_refresh.py @@ -107,7 +107,9 @@ def test_layers_turn_drops_old_answers() -> None: def test_one_circle_in_site_title() -> None: page = _src("D03_PlanAnalysis_UI_Page.ts") assert "createProgressCircle({ size: 16, inline: true })" in page - assert "siteCard.firstElementChild!.append(circle.root);" in page + assert "cards: { site: { parts: siteBox.parts, title: circle.root } }" in page # 635-1 키트 + kit = _src("D03_PlanAnalysis_UI_Kit.ts") + assert "if (site.title) siteCard.firstElementChild!.append(site.title);" in kit assert ( "corner: true" not in page and "circle.root);\n" not in page.split("map.root.append(")[1][:80] diff --git a/resources/tester/test_626_3_expected_edit.py b/resources/tester/test_626_3_expected_edit.py index fc789c266..727ac1931 100644 --- a/resources/tester/test_626_3_expected_edit.py +++ b/resources/tester/test_626_3_expected_edit.py @@ -1,7 +1,7 @@ """626-3 B05 평면 예정노선 변경 → 다시 추적 → 편집기 한 걸음 → [저장](요구 530 · 531 · 535-1 · 536 · 결정 D3 · D4 · D9). -- 예정노선 페이지(D03) 긋기 · 고르기 · 메뉴 · 속성 판 함수를 그대로 부름(`oneLine` = 한 선 · 갈래 · 연도 · 지점 막음/숨김) · - D03 덧 인자는 기본값 = 지금 그대로. +- 예정노선 페이지(D03) 긋기 · 고르기 · 메뉴 · 속성 판 함수를 그대로 부름(숨김 = 기능 표 「b05」 — 한 선 · 갈래 · 연도 · + 지점 · 635-1) · 지도 입력 = 키트 `bindPlanInput` capture. - 지킴(글 대조) — 다시 추적 = `/route/follow` 만(저장 0) · `/route/replan` · `as_initial` · `expected_route.csv` 안 부름 · 결과 = 편집기 `restoreSnapshot(…, true)` 한 걸음 · [저장] = 손 편집 같은 길(`bindRouteApply`) + 초안 `expected` · 걸음 사진 · 노선 초안에 고친 예정노선 실림(되돌리기 · 세션 되살림) · 같은 선 판정에 예정노선 · 켠 동안 캔버스 누름을 @@ -71,32 +71,36 @@ def test_D03_긋기_고르기_메뉴_속성판_그대로_한_선(): ui = _src(B05 / "B05_Profile_UI_ExpectedDraw.ts") for call in ("createPlanDraw({", "createPlanEdit(", "createPlanMenu({", "createPlanProps({"): assert call in ui, call - assert ui.count("oneLine: true,") == 3 # 긋기 · 메뉴 · 속성 판 + assert ui.count('page: "b05",') == 3 # 긋기 · 메뉴 · 속성 판 = 기능 표(635-1) + for fake in ("NO_POINTS", "epsg: () => 0", "addPoint: () => undefined", "oneLine"): + assert fake not in ui, fake 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 "event.buttons & 6" in ui # 오른쪽 · 가운데 끌기 = 지도 이동 그대로 + # 지도 입력 = 키트 한 함수 · 잡기 단계(capture) — 재기 · 계획선 고르기보다 먼저 + assert "capture: { active: () => open, busy: () => busy }," in ui + kit = _src(D03 / "D03_PlanAnalysis_UI_Kit.ts") + assert "canvas.addEventListener(type, run, !!capture)" in kit + assert "event.stopImmediatePropagation()" in kit + assert "event.buttons & (capture ? 6 : 4)" in kit # 오른쪽 · 가운데 끌기 = 지도 이동 그대로 assert 'createElement("button")' not in ui - # D03 덧 인자 — 기본값 = 지금 그대로 + # 숨김 = 기능 표 읽기(635-1 · 플래그 없음) 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( + assert 'featureOn(params.page, "branch") ? null : lineLimitBlock(target.kind, network)' in draw + assert 'markButton.hidden = !featureOn(opts.page, "years");' 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 + assert '!featureOn(page, "branch") && lineLimitBlock(aim, params.network())' in menu + assert 'if (featureOn(page, "years"))' in menu props = _src(D03 / "D03_PlanAnalysis_UI_Props.ts") - assert "!!params.oneLine ||" in props and "...(params.oneLine ? [] :" in props + assert '!featureOn(page, "points") ||' in props and "yearsShown(network)" in props + for src in (draw, menu, props): + assert "oneLine" not in src + table = _src(D03 / "D03_PlanAnalysis_UI_Features.ts") + for key, b05 in (("branch", "hide"), ("years", "view"), ("points", "hide"), ("draw", "use")): + assert re.search(rf'key: "{key}",[^}}]*b05: "{b05}"', table, re.S), key 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)