diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts new file mode 100644 index 00000000..6fa286f5 --- /dev/null +++ b/A00_Common/design/design_profile.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * A00_Common/design/design_profile.ts + * 종단도 한 벌(계획서 24-1 · 24-2c) — 종단 보기 · 횡단 보기가 **같은 종단도**를 가운데 + * 아래쪽에 둔다. 도구(▲▼ 고치기 · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · + * 뱃지)와 막기 규칙(최소 흙덮기 · 기울기 상한)은 보기와 무관하게 하나. + * + * 판 자리 둘 — 종단도는 자리만 내주고 판은 밖에서 만들어 끼운다(나중에 떼기 쉽게). + * · side 종단도 오른쪽 판(배수유역도 · sub7). `DesignSideFactory` 로 받는다. + * · overlay 종단도 위 겹침 판(유토곡선 · 표). 종단도가 직접 만든다. + * ========================================================================== */ + +import type { + DrainagePanel, + DrainagePanelCallbacks, +} from "../../B05_Profile/B05_Profile_UI_Drainage_Panel_Types"; + +/** 가운데 보기 — 종단 보기(3D + 종단도) · 횡단 보기(종단도 + 횡단 카드). */ +export type DesignView = "profile" | "cross"; + +/** + * 오른쪽 판을 만들 때 종단도가 넘겨 주는 것 — 판이 알릴 일(관 목록 · 유역 · 관 고름 · + * 우클릭 메뉴 항목)과 처음 보기. 판은 `onPipesChanged` 를 **반드시** 불러야 종단도가 + * 최소 흙덮기를 판정한다. + */ +export interface DesignSideHost extends DrainagePanelCallbacks { + projectId: string; + view: DesignView; +} + +/** + * 오른쪽 판(배수유역도). 종단도는 만든 직후 `load(projectId)` · 노선이 오면 `setRoute` · + * 구조물이 바뀌면 `setIntervalSpans` · 되돌리기에 `pipeChainages`/`setPipeChainages` · + * 보기가 바뀌면 `setView` · 걷을 때 `dispose` 를 부른다. 뿌리(`root`)는 종단 열 오른쪽에 붙는다. + */ +export type DesignSideBoard = DrainagePanel & { + /** 보기가 바뀌었다 — 횡단 보기에서도 켜고 끈다(판이 정함). */ + setView?: (view: DesignView) => void; +}; + +/** 오른쪽 판을 끼우는 함수 모양 — 종단도를 만들 때 한 번 부른다. */ +export type DesignSideFactory = (host: DesignSideHost) => DesignSideBoard; + +/** 위 겹침 판(유토곡선 · 표) — 종단 스크롤러와 가로 스크롤을 맞추는 떼었다 붙이는 판. */ +export interface DesignOverlayBoard { + overlay: HTMLElement; + handle: HTMLElement; + attachScrollSync: (scroller: HTMLElement) => void; +} + +/** 종단도를 만들 때 넘기는 것. */ +export interface DesignProfileContext { + projectId: string; + /** 처음 보기. */ + view: DesignView; + /** 오른쪽 판 — 없으면 판 없이 종단 열만. */ + side?: DesignSideFactory; +} + +/** 종단도 한 벌. */ +export interface DesignProfile { + root: HTMLElement; + /** 보기가 바뀌었다 — 종단도는 그대로 두고 판에만 알린다(다시 만들지 않음). */ + setView: (view: DesignView) => void; + dispose: () => void; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index e48a0979..82883c9a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -32,6 +32,7 @@ import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes"; import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft"; +import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report"; import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; @@ -355,31 +356,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra summaryText(summary, pipeEditor.pipes().length, basins.length, zSource); // 목록 통지가 선택 통지보다 **먼저** 가야 한다 — 사이드 폼은 이 목록에서 관을 // 찾아 편집 화면을 채우므로, 순서가 뒤집히면 새로 넣은 관의 폼이 열리지 않는다. - callbacks.onPipesChanged?.( - // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. - // 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, - // 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조). - // 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이 - // 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). - pipeEditor.pipes().map((pipe) => { - const chainage = Math.round(pipe.chainage_m * 100) / 100; - const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); - const attributes = facilityStore.get(chainage); - return { - chainage_m: chainage, - effective_diameter_mm: - basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, - // 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다. - design_flow_m3s: basin?.design_flow_m3s ?? null, - facility: attributes?.facility ?? "pipe", - start_m: attributes?.start_m, - end_m: attributes?.end_m, - source: (pipe.reason || "user") as PipeSource, - options: attributes?.options, - summary_item: attributes?.summary_item, - }; - }), - ); + callbacks.onPipesChanged?.(buildPipeReports(pipeEditor.pipes(), basins, facilityStore)); syncPipeSelection(); } @@ -405,18 +382,19 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra } } + /** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */ + const pipeRequest = () => + facilityStore.attach( + pipeEditor.pipes().map((pipe) => ({ + chainage_m: pipe.chainage_m, + source: (pipe.reason || "user") as PipeSource, + })), + ); + /** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */ function rememberPipes(): void { if (!projectId) return; - writePendingPipes( - projectId, - facilityStore.attach( - pipeEditor.pipes().map((pipe) => ({ - chainage_m: pipe.chainage_m, - source: (pipe.reason || "user") as PipeSource, - })), - ), - ); + writePendingPipes(projectId, pipeRequest()); } /** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이 @@ -436,17 +414,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra /** 세부유역을 다시 나눈다. 격자 해석은 하지 않으므로 즉시 끝난다. * 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */ const analyze = (): Promise => - run(() => - computeDetailBasins( - projectId as string, - facilityStore.attach( - pipeEditor.pipes().map((pipe) => ({ - chainage_m: pipe.chainage_m, - source: (pipe.reason || "user") as PipeSource, - })), - ), - ), - ); + run(() => computeDetailBasins(projectId as string, pipeRequest())); analyzeButton.addEventListener("click", () => void analyze()); deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected()); @@ -664,16 +632,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }, async savePipes() { if (!projectId) return 0; - const response = await saveDetailPipePoints( - projectId, - // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). - facilityStore.attach( - pipeEditor.pipes().map((pipe) => ({ - chainage_m: pipe.chainage_m, - source: (pipe.reason || "user") as PipeSource, - })), - ), - ); + // 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임). + const response = await saveDetailPipePoints(projectId, pipeRequest()); apply(response); scheduleDraw(); return response.pipe_count; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index ea89c715..9b8f1003 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -52,24 +52,25 @@ export interface DrainagePanel { dispose: () => void; } +/** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */ +export interface DrainagePipeReport { + chainage_m: number; + effective_diameter_mm: number | null; + /** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */ + design_flow_m3s?: number | null; + facility: PipeFacility; + start_m?: number; + end_m?: number; + source?: PipeSource; + options?: Record; + /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ + summary_item?: SummaryItem | null; +} + export interface DrainagePanelCallbacks { /** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류를 * 넘긴다 — 종단 구조물 라인·통합 목록 동기화 및 관경 자동 지정(D800 기본)용. */ - onPipesChanged?: ( - pipes: Array<{ - chainage_m: number; - effective_diameter_mm: number | null; - /** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */ - design_flow_m3s?: number | null; - facility: PipeFacility; - start_m?: number; - end_m?: number; - source?: PipeSource; - options?: Record; - /** 이 관이 뜻하는 구조물 집계표 항목(21-1) — 없으면 미지정. */ - summary_item?: SummaryItem | null; - }>, - ) => void; + onPipesChanged?: (pipes: DrainagePipeReport[]) => void; /** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */ onBasinSelected?: (chainageM: number | null) => void; /** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts new file mode 100644 index 00000000..a329c75b --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Report.ts + * 배수유역 패널이 관 목록이 바뀔 때 밖에 알리는 **관 보고**(`DrainagePipeReport`)를 만든다. + * `B05_Profile_UI_Drainage_Panel` 에서 떼어냄(700줄 제한) — 셈은 그대로, 상태를 안 든다. + * ========================================================================== */ + +import type { DetailBasin, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility"; +import type { DrainagePipeReport } from "./B05_Profile_UI_Drainage_Panel_Types"; + +/** 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. + * 세월교 검토 유역(bridge_required)도 null — 관 규격을 최대치로 올려 봐야 무의미하고, + * 그 지점은 세월교·물넘이로 별도 설계한다(임도설치규정 제12조). + * 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이 + * 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */ +export function buildPipeReports( + pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>, + basins: ReadonlyArray, + facilityStore: ReturnType, +): DrainagePipeReport[] { + return pipes.map((pipe) => { + const chainage = Math.round(pipe.chainage_m * 100) / 100; + const basin = basins.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.51); + const attributes = facilityStore.get(chainage); + return { + chainage_m: chainage, + effective_diameter_mm: + basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, + // 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다. + design_flow_m3s: basin?.design_flow_m3s ?? null, + facility: attributes?.facility ?? "pipe", + start_m: attributes?.start_m, + end_m: attributes?.end_m, + source: (pipe.reason || "user") as PipeSource, + options: attributes?.options, + summary_item: attributes?.summary_item, + }; + }); +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Side.ts b/B05_Profile/B05_Profile_UI_Drainage_Side.ts new file mode 100644 index 00000000..2ff20f4f --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Side.ts @@ -0,0 +1,92 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Side.ts + * 배수유역도를 종단도의 **side 판**으로 끼우는 홀로 선 창구(계획서 24-2e). + * + * 판(`createDrainagePanel`)은 종단 패널을 모른다 — 종단쪽 일은 전부 `DrainageSideHost` + * (= `DrainagePanelCallbacks`, `_Panel_Types` 약속)로 받고, 종단쪽에서 들어오는 일은 + * `panel` 의 창구(`setRoute` · `setIntervalSpans` · `pipeChainages` · `setPipeChainages` · + * `selectPipeAtChainage` …)로만 한다. + * · 관 목록이 바뀜(최소 흙덮기 경고 · 구조물 라인 · 통합 목록) = `host.onPipesChanged` + * · 관 자리 읽기 · 쓰기(되돌리기) = `panel.pipeChainages()` · `panel.setPipeChainages()` + * · 우클릭 메뉴의 구조물 종류 = `host.structureMenuItems` + * · 구간 띠 · 노선 = `panel.setIntervalSpans()` · `panel.setRoute()` + * 두 보기(종단 · 횡단)에서 같은 판을 끼우고 **켜고 끈다** — 끄면 자리를 비우되 관 · 유역 + * 상태는 그대로 살아 있다(다시 켜면 이어서 보임). 켬 · 끔을 어디에 적을지는 끼우는 쪽 몫이다. + * ========================================================================== */ + +import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel"; +import type { DrainagePanel, DrainagePanelCallbacks } from "./B05_Profile_UI_Drainage_Panel_Types"; + +/** 종단쪽이 넘기는 이음 — 판 바깥 약속(`DrainagePanelCallbacks`) 그대로. */ +export type DrainageSideHost = DrainagePanelCallbacks; + +export interface DrainageSide { + /** 종단도 side 자리에 붙일 뿌리. */ + root: HTMLElement; + /** 판의 창구 — 종단쪽이 노선 · 구간 띠 · 관 자리 · 선택을 밀어 넣는다. */ + panel: DrainagePanel; + /** 켜고 끈다. 켜면 숨겼던 사이 바뀐 크기 · 그림을 판이 스스로 다시 맞춘다. */ + setVisible: (visible: boolean) => void; + isVisible: () => boolean; + /** 켬 · 끔이 바뀔 때마다 부른다(단추 표시 · 저장). 해제 함수를 돌려준다. */ + onVisibleChange: (listener: (visible: boolean) => void) => () => void; + dispose: () => void; +} + +export interface DrainageSideOptions { + /** 처음 켤지(기본 켬). */ + visible?: boolean; +} + +export function createDrainageSide( + host: DrainageSideHost = {}, + options: DrainageSideOptions = {}, +): DrainageSide { + const panel = createDrainagePanel(host); + const listeners = new Set<(visible: boolean) => void>(); + let visible = true; + + function setVisible(next: boolean): void { + if (next === visible) return; + visible = next; + panel.root.hidden = !visible; + listeners.forEach((listener) => listener(visible)); + } + // 처음 상태는 알림 없이 맞춘다. + if (options.visible === false) { + visible = false; + panel.root.hidden = true; + } + + return { + root: panel.root, + panel, + setVisible, + isVisible: () => visible, + onVisibleChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + listeners.clear(); + panel.dispose(); + }, + }; +} + +/** 켜고 끄는 단추 — 어느 보기에서든 같은 모양 · 같은 글. 누르면 `side` 를 뒤집는다. */ +export function createDrainageToggleButton(side: DrainageSide): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-btn ui-btn--ghost b05-drainage__toggle"; + button.textContent = "배수유역도"; + button.title = "배수유역도 판을 켜고 끕니다"; + const sync = (visible: boolean): void => { + button.setAttribute("aria-pressed", String(visible)); + button.classList.toggle("is-active", visible); + }; + sync(side.isVisible()); + side.onVisibleChange(sync); + button.addEventListener("click", () => side.setVisible(!side.isVisible())); + return button; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index cbeeee06..57946413 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -12,7 +12,7 @@ /** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 * 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */ -const HALF_TURN_ICON = { +export const HALF_TURN_ICON = { ccw: ``, @@ -42,9 +42,9 @@ export function createRouteEditChrome(): RouteEditChrome { 계획노선 편집 - + diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 0badb0dd..d784e742 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -34,6 +34,8 @@ export interface RouteEditNavigationParams { /** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다. * 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */ unrotateDelta?: (dx: number, dy: number) => [number, number]; + /** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */ + leftPan?: boolean; draw: () => void; } @@ -78,7 +80,12 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void // 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다. if (event.button === 1) event.preventDefault(); // 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫. - if (event.pointerType === "mouse" && event.button !== 1) return; + if ( + event.pointerType === "mouse" && + event.button !== 1 && + !(params.leftPan && event.button === 0) + ) + return; const view = getView(); dragStart = { x: event.clientX, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index f5ad8341..bb43b44a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -23,7 +23,6 @@ import { criteriaLine, CRITERIA_KEYS, LOGIC_LABEL, - mapPaths, metricsLine, rebalanceWeights, relaxedLine, @@ -36,6 +35,7 @@ import { type MapLayer, type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; +import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map"; import "./B05_Profile_UI_Style_RouteRecommend.css"; type Vertex = [number, number]; @@ -61,34 +61,31 @@ const CRITERIA_FIELD: Record< { label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null } > = { max_grade_pct: { - label: "기울기", + label: "종단기울기 상한", unit: "%", title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)", of: (c) => c.maxGradePct, }, min_radius_m: { - label: "최소R", + label: "최소 반지름", unit: "m", title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값", of: (c) => c.minRadiusM, }, stream_offset_m: { - label: "계류", + label: "계류 이격", unit: "m", title: "계류 이격(m) — 비우면 기본값 100m", of: (c) => c.streamOffsetM, }, grade_smooth_m: { - label: "지반", + label: "지반 고름 폭", unit: "m", title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m", of: (c) => c.gradeSmoothM, }, }; -const MAP_W = 460; -const MAP_H = 340; - export interface RecommendParams { /** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */ criteria: RouteInitialCriteria | null; @@ -120,13 +117,17 @@ export function openRecommendModal(params: RecommendParams): void { let seq = 0; let bodyEl: HTMLElement | null = null; + let map: ReturnType | null = null; openModal({ title: "노선 추천", closeLabel: "✕", dialogClass: "b05-routerec", + // 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다. + backdropClass: "b05-routerec-backdrop", // ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다. beforeClose: () => { closed = true; + map?.dispose(); return true; }, mount: (body, close) => { @@ -141,21 +142,28 @@ export function openRecommendModal(params: RecommendParams): void {
- +
예상노선(원본) 창을 연 노선 미리 보기
+
휠 = 확대 · 축소   끌기 = 이동   ↺ ↻ = 회전
@@ -181,7 +189,7 @@ export function openRecommendModal(params: RecommendParams): void {
`; const q = (sel: string): T => body.querySelector(sel)!; - const svg = body.querySelector(".b05-routerec__map")!; + map = createRecommendMap(q(".b05-routerec__mapwrap")); const pills = [...body.querySelectorAll("[data-logic]")]; const weightsBox = q(".b05-routerec__weights"); const rerun = q('[data-act="rerun"]'); @@ -213,9 +221,7 @@ export function openRecommendModal(params: RecommendParams): void { { kind: "current", points: params.current }, ]; if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned }); - svg.innerHTML = mapPaths(layers, MAP_W, MAP_H) - .map((path) => ``) - .join(""); + map?.setLayers(layers); } function render(): void { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts new file mode 100644 index 00000000..9408a1c1 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts @@ -0,0 +1,183 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Recommend_Map.ts + * [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기). + * + * 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` · + * 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기 + * `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는 + * 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다. + * ========================================================================== */ + +import { themeColor } from "@ui/ui_template_palette"; +import { + computeMapRect, + computeRouteView, + metricToScreen, + ROUTE_VIEW_MARGIN_M, + routeLineColor, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome"; +import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; +import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model"; +import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; +import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View"; + +/** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */ +const FIT_MARGIN_M = 30; + +/** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */ +export const RECOMMEND_MAP_HTML = ` + +
+ + +
`; + +export interface RecommendMap { + /** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */ + setLayers: (layers: MapLayer[]) => void; + dispose: () => void; +} + +/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */ +export function createRecommendMap(box: HTMLElement): RecommendMap { + const canvas = box.querySelector("canvas")!; + const context = canvas.getContext("2d")!; + let layers: MapLayer[] = []; + let meta: VWorldMeta | null = null; + let fitted = false; + let view: ViewState = { + width: 0, + height: 0, + scale: 1, + offsetX: 0, + offsetY: 0, + mapRect: computeMapRect(null, 0, 0), + }; + const rotation = createMapRotation({ overlay: box, size: () => view, onChange: () => draw() }); + + function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null { + const all = layers.flatMap((layer) => layer.points); + if (!all.length) return null; + const xs = all.map(([x]) => x); + const ys = all.map(([, y]) => y); + return { + x_min: Math.min(...xs), + x_max: Math.max(...xs), + y_min: Math.min(...ys), + y_max: Math.max(...ys), + }; + } + + /** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */ + function makeMeta(range: NonNullable>): VWorldMeta { + const x_min = range.x_min - ROUTE_VIEW_MARGIN_M; + const y_min = range.y_min - ROUTE_VIEW_MARGIN_M; + const x_max = range.x_max + ROUTE_VIEW_MARGIN_M; + const y_max = range.y_max + ROUTE_VIEW_MARGIN_M; + return { + x_min, + x_max, + y_min, + y_max, + width_meters: x_max - x_min, + height_meters: y_max - y_min, + center_x: (x_min + x_max) / 2, + center_y: (y_min + y_max) / 2, + lon_min: 0, + lon_max: 1, + lat_min: 0, + lat_max: 1, + }; + } + + function refit(): void { + const b = bounds(); + if (!b || !view.width) return; + meta = makeMeta(b); + view = { + ...view, + mapRect: computeMapRect(meta, view.width, view.height), + ...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M), + }; + fitted = true; + } + + function draw(): void { + context.clearRect(0, 0, view.width, view.height); + if (!meta) return; + context.save(); + context.translate(view.width / 2, view.height / 2); + context.rotate(rotation.radians()); + context.translate(-view.width / 2, -view.height / 2); + context.lineCap = "round"; + context.lineJoin = "round"; + const styles: Record void> = { + expected: () => { + context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af"); + context.lineWidth = 1.5; + context.setLineDash([5, 4]); + }, + current: () => { + context.strokeStyle = routeLineColor(); + context.globalAlpha = 0.55; + context.lineWidth = 2; + context.setLineDash([]); + }, + preview: () => { + context.strokeStyle = themeColor("--color-primary", "#6d28d9"); + context.lineWidth = 3; + context.setLineDash([]); + }, + }; + for (const layer of layers) { + if (layer.points.length < 2) continue; + context.save(); + styles[layer.kind](); + context.beginPath(); + layer.points.forEach(([x, y], index) => { + const [sx, sy] = metricToScreen(meta!, view, x, y); + if (index) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + context.restore(); + } + context.restore(); + } + + function resize(): void { + const { width, height } = fitCanvas(canvas, context); + if (!width || !height) return; + view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; + // 처음 한 번만 맞춘다 — 그 뒤 창 크기가 바뀌어도 사용자가 만진 배율 · 자리를 지킨다. + if (!fitted) refit(); + draw(); + } + + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => (view = next), + getMeta: () => meta, + unrotateDelta: rotation.unrotateDelta, + leftPan: true, + draw, + }); + // 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다. + const observer = new ResizeObserver(() => resize()); + observer.observe(canvas.parentElement!); + + return { + setLayers(next) { + layers = next; + if (!fitted) refit(); + draw(); + }, + dispose: () => observer.disconnect(), + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Drainage.css b/B05_Profile/B05_Profile_UI_Style_Drainage.css index 3bfa5c43..0581d21e 100644 --- a/B05_Profile/B05_Profile_UI_Style_Drainage.css +++ b/B05_Profile/B05_Profile_UI_Style_Drainage.css @@ -288,3 +288,8 @@ } /* 구조물(배관) 배치로 갈라진 구배 블록 — 평소엔 값 없이 윤곽만, 선택하면 강조와 함께 값 표시. */ + +/* 종단도 판 자리에서 끈 배수유역도(`_Drainage_Side` · 계획서 24-2e) — 자리를 비운다. */ +.b05-drainage[hidden] { + display: none; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index fe59d8ca..0eef5e46 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -9,13 +9,20 @@ white-space: nowrap; } +/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */ +.ui-modal__backdrop.b05-routerec-backdrop { + z-index: calc(var(--z-modal, 1000) + 2); +} + .ui-modal.b05-routerec { - width: min(900px, 94vw); + width: min(960px, 94vw); max-height: 92vh; + font-size: var(--text-body-sm); } .b05-routerec__body { gap: var(--spacing-12); + font-size: var(--text-body-sm); } /* 알약 — 하나만 고른 상태(라디오 묶음). */ @@ -26,12 +33,12 @@ } .b05-routerec__pill { - padding: 6px var(--spacing-16); + padding: 8px var(--spacing-16); border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-surface); color: var(--color-text-body); - font-size: var(--text-body-sm); + font-size: var(--text-body); cursor: pointer; } @@ -53,7 +60,7 @@ .b05-routerec__main { display: grid; - grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: var(--spacing-12); align-items: start; } @@ -64,36 +71,27 @@ gap: var(--spacing-4, 4px); } -.b05-routerec__map { - display: block; - width: 100%; - height: auto; +/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */ +.b05-routerec__mapwrap { + position: relative; + height: 380px; + overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-8, 6px); background: var(--color-surface); } -.b05-routerec__map path { - fill: none; - stroke-linecap: round; - stroke-linejoin: round; +.b05-routerec__map { + display: block; + width: 100%; + height: 100%; + touch-action: none; + cursor: grab; } -.b05-routerec__map path.is-expected { - stroke: var(--color-text-secondary, #9ca3af); - stroke-dasharray: 5 4; - stroke-width: 1.5; -} - -.b05-routerec__map path.is-current { - stroke: var(--map-route, #f97316); - stroke-width: 2; - opacity: 0.55; -} - -.b05-routerec__map path.is-preview { - stroke: var(--color-primary); - stroke-width: 2.5; +.b05-routerec__maphint { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); } .b05-routerec__legend { @@ -101,7 +99,7 @@ flex-wrap: wrap; gap: var(--spacing-12); color: var(--color-text-secondary); - font-size: var(--text-caption); + font-size: var(--text-body-sm); } .b05-routerec__legend i { @@ -128,53 +126,81 @@ display: flex; flex-direction: column; gap: var(--spacing-12); - color: var(--color-text-secondary); - font-size: var(--text-caption); + color: var(--color-text-body); + font-size: var(--text-body-sm); } +/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */ .b05-routerec__weights { - display: grid; - gap: 4px; + display: flex; + flex-direction: column; + gap: var(--spacing-4, 4px); } -.b05-routerec__weights label { - display: grid; - grid-template-columns: 3.5em 1fr 2.5em; - align-items: center; +.b05-routerec__weights[hidden] { + display: none; +} + +.b05-routerec__bars { + display: flex; gap: var(--spacing-8); } -.b05-routerec__weights output { +.b05-routerec__bar { + display: flex; + flex: 1 1 0; + flex-direction: column; + align-items: center; + gap: var(--spacing-4, 4px); +} + +.b05-routerec__bar input[type="range"] { + width: 28px; + height: 130px; + margin: 0; + writing-mode: vertical-lr; + direction: rtl; /* 위로 올릴수록 큰 값 */ + accent-color: var(--color-primary); +} + +.b05-routerec__bar output { color: var(--color-text-body); - text-align: right; + font-size: var(--text-body); + font-weight: 600; +} + +.b05-routerec__bar span { + color: var(--color-text-secondary); } .b05-routerec__sum { - text-align: right; + text-align: center; + font-weight: 600; } -/* 공통 제약 네 칸 — 비우면 placeholder 가 기본값. */ +/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */ .b05-routerec__criteria { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8) var(--spacing-12); + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--spacing-8); } .b05-routerec__criteria label { - display: inline-flex; + display: grid; + grid-template-columns: minmax(0, 1fr) 5.5em 1.5em; align-items: center; - gap: 4px; - white-space: nowrap; + gap: var(--spacing-8); + color: var(--color-text-body); } .b05-routerec__criteria input { - width: 4em; - padding: 2px 4px; + width: 100%; + padding: 4px 8px; border: 1px solid var(--color-border); border-radius: var(--radius-4, 4px); background: var(--color-surface); color: var(--color-text-body); - font-size: var(--text-caption); + font-size: var(--text-body); text-align: right; } @@ -191,8 +217,8 @@ flex-direction: column; gap: 2px; color: var(--color-text-secondary); - font-size: var(--text-caption); - line-height: 1.45; + font-size: var(--text-body-sm); + line-height: 1.5; } /* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */ @@ -212,7 +238,7 @@ .b05-routerec__state { flex: 1 1 12em; color: var(--color-text-body); - font-size: var(--text-caption); + font-size: var(--text-body-sm); } /* 좁은 폭 — 지도 아래로 조작 칸. */ @@ -220,4 +246,8 @@ .b05-routerec__main { grid-template-columns: minmax(0, 1fr); } + + .b05-routerec__mapwrap { + height: 300px; + } } diff --git a/resources/tester/helper_b05_drainage_report.cjs b/resources/tester/helper_b05_drainage_report.cjs new file mode 100644 index 00000000..402b32bb --- /dev/null +++ b/resources/tester/helper_b05_drainage_report.cjs @@ -0,0 +1,31 @@ +/* 배수유역 관 보고(`B05_Profile_UI_Drainage_Report.ts`) 셈 단위검증 헬퍼 — TS 를 트랜스파일해 Node 로 돈다. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "B05_Profile", "B05_Profile_UI_Drainage_Report.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const { buildPipeReports } = box.exports; + +const store = { + get: (chainage) => + chainage === 100.5 ? { facility: "box_culvert", start_m: 95, end_m: 106, options: { h: 2 } } : null, +}; +const basins = [ + { chainage_m: 40, pipe_diameter_mm: 800, design_flow_m3s: 1.2, bridge_required: false }, + { chainage_m: 100.5, pipe_diameter_mm: 1000, design_flow_m3s: 3.4, bridge_required: true }, +]; +const out = buildPipeReports( + [ + { chainage_m: 40.004, reason: "auto" }, + { chainage_m: 100.499 }, + { chainage_m: 200 }, + ], + basins, + store, +); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_b05_drainage_side.py b/resources/tester/test_b05_drainage_side.py new file mode 100644 index 00000000..71e302ae --- /dev/null +++ b/resources/tester/test_b05_drainage_side.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""배수유역도 판이 종단 패널을 모르고 홀로 선다(계획서 24-2e). + +- 배수유역 파일(`B05_Profile_UI_Drainage_*`)은 종단 패널(`_Profile_*`)이나 B06 을 가져오지 않는다 — + 종단쪽 일은 `_Panel_Types` 약속(콜백 · 창구)으로만 오간다. +- 파일마다 700줄 이하. +- 관 보고 만들기(`_Report`)는 상태 없이 같은 결과: ±0.5m 유역 매칭 · 세월교 유역은 관경 null · + 시설 정보 · 출처 기본값 user. +- `createDrainageSide` 가 켜고 끄는 창구를 낸다(두 보기에서 같은 판). +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _drainage_files() -> list[Path]: + return sorted(B05.glob("B05_Profile_UI_Drainage_*.ts")) + + +def test_배수유역_파일은_종단_패널과_B06을_안_가져옴(): + files = _drainage_files() + assert len(files) >= 10 + for path in files: + source = path.read_text(encoding="utf-8") + for line in source.splitlines(): + if not line.lstrip().startswith(("import ", "} from ", "export {", "export type {")): + continue + assert "UI_Profile_" not in line, (path.name, line) + assert "B06_Section" not in line, (path.name, line) + + +def test_배수유역_파일은_700줄_이하(): + for path in _drainage_files(): + lines = len(path.read_text(encoding="utf-8").splitlines()) + assert lines <= 700, (path.name, lines) + + +def test_켜고_끄는_창구(): + side = (B05 / "B05_Profile_UI_Drainage_Side.ts").read_text(encoding="utf-8") + for name in ( + "createDrainageSide", + "createDrainageToggleButton", + "setVisible", + "onVisibleChange", + ): + assert name in side + assert "root.hidden" in side or "panel.root.hidden" in side + types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8") + assert "export interface DrainagePipeReport" in types + assert "onPipesChanged?: (pipes: DrainagePipeReport[]) => void;" in types + + +@pytest.fixture(scope="module") +def reports(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_drainage_report.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_관_보고_유역_매칭과_기본값(reports): + first, box, plain = reports + # 누가거리는 소수 둘째 자리로 · 담당 유역의 관경 · 설계유량이 붙는다. + assert first["chainage_m"] == 40 and first["effective_diameter_mm"] == 800 + assert first["design_flow_m3s"] == 1.2 and first["facility"] == "pipe" + assert first["source"] == "auto" + # 세월교 검토 유역은 관경만 null · 설계유량은 남김 · 시설 정보를 데려옴. + assert box["chainage_m"] == 100.5 and box["effective_diameter_mm"] is None + assert box["design_flow_m3s"] == 3.4 + assert box["facility"] == "box_culvert" and (box["start_m"], box["end_m"]) == (95, 106) + assert box["options"] == {"h": 2} and box["source"] == "user" + # 유역 없는 관 — 모두 null · 맨 배관. + assert plain["effective_diameter_mm"] is None and plain["design_flow_m3s"] is None + assert plain["facility"] == "pipe" diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index 43a2adbd..fb6068d8 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -192,3 +192,24 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results) {"logic": "earthwork"}, None, ] + + +def test_노선_추천_다듬기_23_3(): + """구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위.""" + page = os.path.join(HERE, "..", "..", "B05_Profile") + + def read(name): + return open(os.path.join(page, name), encoding="utf-8").read() + + chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts") + rec = chrome.index('data-act="recommend"') + divider = chrome.index("b05-routeedit__divider", rec) + assert rec < divider < chrome.index('data-act="contour-minor"') + assert chrome.count("b05-routeedit__divider") == 2 + shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts") + for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"): + assert f"{name}(" in shared + css = read("B05_Profile_UI_Style_RouteRecommend.css") + assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위 + assert "writing-mode: vertical-lr" in css + assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")