From 99c94777afdfed7a4c7d796053ceec7b9a2b6917 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 18 Aug 2026 19:38:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=202=EC=B0=A8=20=E2=80=94=20=EC=9A=B0?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4=20=EC=9D=BC=EC=9B=90?= =?UTF-8?q?=ED=99=94=C2=B7=EB=B2=8C=EB=A3=AC=C2=B7=EB=AC=B8=EA=B5=AC=C2=B7?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=ED=91=9C=EC=8B=9C=20(PLAN=203-1~3-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3-1 우클릭 메뉴 일원화: 종단그래프·배수유역도 빈 자리 메뉴 = 사이드 「구조물 배치」와 같은 구조물군(A~G·기타) → 종류 2단 아코디언(공용 컨텍스트 메뉴에 하위 메뉴 지원 추가). 선택 = 즉시 추가가 아니라 폼 자동 지정(addAt, A군은 임시 배치까지). 구 [배관 추가]·평면 나열·기성막이/대피로 항목 흡수. - 3-2 유역 리스트 문구: 세월교 검토→세월교 제안, BOX암거 후보→BOX암거 제안, 추천 Ø{d} 배관→Ø{d} 배관 제안. - 3-3 구조물 알약 벌룬: 아래(테이블에 가림) 대신 위로 — 1행 좌상, 2행 우상, 솔로 위 중앙. - 3-4 사이드: 스크롤 영역 상단 여유 + 페이지 설정 샘플 간격 2열(종단 먼저). - 3-5 배수유역도 위성사진 기본 꺼짐. - 3-6 B04 초기화 버튼 톤 다운 — 붉은 테두리·글자만, 채움 없음. - 3-7 그래프 빈 공간 클릭 = 완전 해제 — 알약 해제 신호가 아예 없던 것 수정 + 외부 해제 시 구조물군·종류까지 리셋(선택 없을 땐 유지). 검증: tsc·vite + Playwright 헤드 실화면 V1~V8 전부 PASS (PLAN.md 3-8). Co-Authored-By: Claude Opus 5 (1M context) --- B04_PreProcess/B04_PreProcess_UI_Page.ts | 2 + B04_PreProcess/B04_PreProcess_UI_Style.css | 13 +++++ B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 21 +++---- B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 44 +++++++------- B05_Profile/B05_Profile_UI_Panel.ts | 6 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 10 +++- B05_Profile/B05_Profile_UI_Profile_Render.ts | 25 ++++---- .../B05_Profile_UI_Profile_Structures.ts | 55 ++++++++++++------ .../B05_Profile_UI_Structures_Marks.ts | 12 ++-- .../B05_Profile_UI_Structures_Panel.ts | 24 ++++---- B05_Profile/B05_Profile_UI_Style.css | 30 ++++++++++ .../B05_Profile_UI_Style_Structures.css | 12 ++++ ui_template/ui_template_context_menu.ts | 58 +++++++++++++++---- ui_template/ui_template_locale_b2.ts | 6 +- 14 files changed, 216 insertions(+), 102 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_UI_Page.ts b/B04_PreProcess/B04_PreProcess_UI_Page.ts index bcd24d9e..12e90d60 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Page.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Page.ts @@ -169,6 +169,8 @@ export async function renderB04Surface(root: HTMLElement): Promise { variant: "ghost", onClick: () => void onB04_Surface_Reset_Click(), }); + // 가시성 톤 다운(2026-08-18 사용자 지시) — 붉은 테두리·글자만, 채움 없음(ghost 배경). + resetButton.classList.add("b04-surface__reset"); const inputGroup = document.createElement("section"); inputGroup.className = "b04-surface__group ui-sidebar-section"; diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index 41a2cdf6..6d979b58 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -15,6 +15,19 @@ padding: var(--spacing-16); } +/* 초기화 버튼 톤 다운 — 붉은 테두리·글자만 남기고 채움은 두지 않는다(ghost 배경 + 그대로 = 임시저장류 버튼과 동일). 꽉 찬 붉은 채움이 과하게 눈에 띈다는 보고 + (2026-08-18 사용자 지시). */ +.b04-surface__reset.ui-btn { + border: 1px solid var(--color-danger); + color: var(--color-danger); + background: none; +} + +.b04-surface__reset.ui-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-danger) 10%, transparent); +} + .b04-surface__group { display: flex; flex-direction: column; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index a8a41a1b..f7ebcc26 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -1,4 +1,5 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; import { computeDetailBasins, fetchDetailPipePoints, @@ -116,11 +117,9 @@ export interface DrainagePanelCallbacks { /** 관 마커 선택이 바뀔 때(클릭·해제) 누가거리를 알린다 — 유역이 없는 관도 전 화면 * (그래프·3D·리스트)이 같은 것을 가리키게 한다(2026-08-17 전역 선택 동기화). */ onPipeSelected?: (chainageM: number | null) => void; - /** 배수유역도 우클릭으로 배관 외 구조물을 넣을 때(누가거리는 계획선 투영값). */ - onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; - /** 배수유역도 우클릭 [배관 추가] — 즉시 추가 대신 사이드 폼에 측점을 실어 준다 - * (2026-08-18 사용자 지시). */ - onPipeAddRequest?: (chainageM: number) => void; + /** 배수유역도 우클릭 빈 자리 메뉴 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 + * 2단 항목(2026-08-18 일원화). 선택 = 폼 자동 지정(addAt 경유). */ + structureMenuItems?: (chainageM: number) => MapContextMenuItem[]; } export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { @@ -219,8 +218,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let canvasHeight = 0; let canvasDpr = 0; + // 위성사진은 기본 꺼짐(2026-08-18 사용자 지시) — 어두운 사진이 유역 채움색을 묻는다. + backgroundImage.hidden = true; mountDrainageToggles(layerButtons, { initial: { + satellite: false, arrows: showArrows, strength: showStrength, hotspots: showHotspots, @@ -533,14 +535,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra } } - bindPipeContextMenu( - viewport, - contextMenu, - pipeEditor, - currentView, - (chainage, type) => callbacks.onStructureAdd?.(chainage, type), - callbacks.onPipeAddRequest ? (chainage) => callbacks.onPipeAddRequest?.(chainage) : undefined, - ); + bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, callbacks.structureMenuItems); bindDrainageInteractions({ viewport, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 0d1d7dd5..189e8713 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -17,7 +17,7 @@ import { type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp"; -import type { MapContextMenu } from "@ui/ui_template_context_menu"; +import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; @@ -199,10 +199,9 @@ export function bindPipeContextMenu( menu: MapContextMenu, editor: PipeEditor, viewOf: () => ViewState, - onAddStructure?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void, - /** [배관 추가] 위임 — 즉시 추가 대신 사이드 폼에 측점을 실어 준다(2026-08-18). - * 없으면 옛 동작(즉시 추가)으로 돌아간다. */ - onAddPipe?: (chainageM: number) => void, + /** 빈 자리 메뉴 항목 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 목록 + * (2026-08-18 일원화, 종단그래프 메뉴와 동일). 없으면 옛 [배관 추가]만 띄운다. */ + structureMenuItems?: (chainageM: number) => MapContextMenuItem[], ): void { viewport.addEventListener("contextmenu", (event) => { if (menu.contains(event.target)) { @@ -230,24 +229,14 @@ export function bindPipeContextMenu( // 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다. if (!editor.canAddAt(view, x, y)) return; event.preventDefault(); - // 배관 + 배관 외 구조물(기성막이/대피로/기타) 추가(2026-08-05 사용자 지시). + // 빈 자리 — 종단그래프와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 일원화). + // 선택 = 폼 자동 지정(사이드 addAt 경로, A군은 임시 배치까지). const chainage = editor.chainageAt(view, x, y); - menu.open(x, y, [ - [ - L("B05_Drainage_Menu_Add"), - () => { - // 즉시 추가 폐지(2026-08-18) — 사이드 폼 경유. 위임이 없을 때만 옛 동작. - if (onAddPipe && chainage !== null) onAddPipe(chainage); - else void editor.addAt(view, x, y); - }, - ], - ...(onAddStructure && chainage !== null - ? (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ - `${type === "기타" ? "기타 구조물" : type} 추가`, - () => onAddStructure(chainage, type), - ]) - : []), - ]); + if (structureMenuItems && chainage !== null) { + menu.open(x, y, structureMenuItems(chainage)); + return; + } + menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]); }); } @@ -294,7 +283,13 @@ export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: export function mountDrainageToggles( container: HTMLElement, handlers: { - initial: { arrows: boolean; strength: boolean; hotspots: boolean; upstream: boolean }; + initial: { + satellite: boolean; + arrows: boolean; + strength: boolean; + hotspots: boolean; + upstream: boolean; + }; onSatellite: (next: boolean) => void; onSheetLayer: (layer: DrainageLayer, next: boolean) => void; onArrows: (next: boolean) => void; @@ -305,11 +300,12 @@ export function mountDrainageToggles( ): void { // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. + // 기본 꺼짐(2026-08-18 사용자 지시) — 초기값은 패널이 정한다. addLayerToggle( container, L("B05_Drainage_Layer_Satellite"), satelliteToggleColor(), - true, + handlers.initial.satellite, handlers.onSatellite, L("B05_Drainage_Layer_Satellite_Tip"), ); diff --git a/B05_Profile/B05_Profile_UI_Panel.ts b/B05_Profile/B05_Profile_UI_Panel.ts index 3a35226a..300c7033 100644 --- a/B05_Profile/B05_Profile_UI_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Panel.ts @@ -294,7 +294,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) { sectionOptions.root.classList.add("is-collapsed"); const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample")); const longSampleInterval = numberField(L("B05_Route_Field_LongSample")); - sectionOptions.body.append(crossSampleInterval.wrapper, longSampleInterval.wrapper); + // 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시). + const sampleRow = document.createElement("div"); + sampleRow.className = "b05-route__field-row"; + sampleRow.append(longSampleInterval.wrapper, crossSampleInterval.wrapper); + sectionOptions.body.append(sampleRow); const terrainField = createSelectField({ label: "지형 구분", options: [ diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index bd17c99b..2beb99af 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -41,6 +41,7 @@ import { toAlignmentBase, } from "./B05_Profile_UI_Profile_Alignment"; import { createProfileEditStore } from "./B05_Profile_UI_Profile_Edit"; +import { structureGroupMenuItems } from "./B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { createRouteMassHaulPanel, @@ -181,9 +182,12 @@ export function createRouteProfilePanel( onPipesChanged: (pipes) => callbacks?.onPipesChanged?.(pipes), onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM), onPipeSelected: (chainageM) => callbacks?.onPipeSelected?.(chainageM), - onStructureAdd: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), - // 배수유역도 [배관 추가]도 사이드 폼 경유(2026-08-18) — 테이블 우클릭과 같은 경로. - onPipeAddRequest: (chainage) => callbacks?.onPipeAdd?.(chainage), + // 배수유역도 우클릭 빈 자리 메뉴 = 종단그래프와 같은 구조물군 → 종류 2단 목록 + // (2026-08-18 일원화). 선택하면 사이드 폼 자동 지정(addAt 경로). + structureMenuItems: (chainage) => + structureGroupMenuItems(structureTypes, (typeId) => + callbacks?.onStructureTypeAdd?.(chainage, typeId), + ), }); content.append(bodyWrap, drainagePanel.root); // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 5fa6a103..5ee08c3e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -164,6 +164,13 @@ export function renderProfile(ctx: ProfileRenderContext): void { chartWrap.addEventListener("click", (event) => { if ((event.target as HTMLElement).closest(".b06-chart__station")) return; if (ctx.selectedStationId() !== null) ctx.selectStation(null); + // 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지 + // 리셋되는 해제 신호가 여기서만 나갈 수 있다(2026-08-18 사용자 보고). + if (ctx.selectedStructureId() !== null) { + ctx.setSelectedStructureId(null); + callbacks?.onStructureSelect?.(null); + ctx.redraw(); + } }); const designProfiles = alignment ? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])] @@ -219,17 +226,13 @@ export function renderProfile(ctx: ProfileRenderContext): void { onRemove: (station) => callbacks?.onStructureRemove?.(station), onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), - // 우클릭 한 번으로 끝나는 타입만 메뉴에 올린다 — 계곡 통과 시설(managed_by)은 - // 관 지점 정본 소관이라 위의 [배관 추가]가 따로 맡는다. 상세(detail) 필수는 - // B06/B07 몫이라 막지 않고, B05 단계(b05) 필수만 사이드 폼으로 보낸다 - // (2026-08-17 phase 분리 — B05는 유무·종류·위치 단계). - structureTypes: structureTypes - .filter( - (type) => - !type.managed_by && - !type.options.some((option) => option.required && (option.phase ?? "b05") !== "detail"), - ) - .map((type) => ({ type_id: type.type_id, group: type.group, name: type.name })), + // 사이드 「구조물 배치」와 같은 전체 목록(A군 포함) — 선택 = 즉시 추가가 아니라 + // 폼 자동 지정이므로 필수 옵션·관리 주체로 거를 이유가 없어졌다(2026-08-18 일원화). + structureTypes: structureTypes.map((type) => ({ + type_id: type.type_id, + group: type.group, + name: type.name, + })), onAddStructureType: (chainage, typeId) => callbacks?.onStructureTypeAdd?.(chainage, typeId), }); // 구조물 알약 레인 — 그래프 아래 별도 줄(2026-08-17 사용자 지시 1·3). 자동 배수관도 diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 0a949c1f..b23b6a21 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -9,8 +9,32 @@ * 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다. * ========================================================================== */ -import { createMapContextMenu } from "@ui/ui_template_context_menu"; +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { isPipeStation, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; +import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel"; + +/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */ +export interface StructureMenuType { + type_id: string; + group: string; + name: string; +} + +/** 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴 항목을 만든다 + * (2026-08-18 우클릭 메뉴 일원화 — 종단그래프·배수유역도 공용). 항목 선택 = + * 즉시 추가가 아니라 폼 자동 지정(addAt 경로). */ +export function structureGroupMenuItems( + types: ReadonlyArray, + onPick: (typeId: string) => void, +): MapContextMenuItem[] { + const groups = [...new Set(types.map((type) => type.group))]; + return groups.map((group) => ({ + label: GROUP_LABELS[group] ?? group, + children: types + .filter((type) => type.group === group) + .map((type): [string, () => void] => [type.name, () => onPick(type.type_id)]), + })); +} /** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */ const GRAB_SLACK_PX = 6; @@ -36,9 +60,6 @@ export interface StructureLineOptions { onAddStructureType?: (chainageM: number, typeId: string) => void; } -/** 우클릭 메뉴에 한 번에 늘어놓을 타입 수. 넘으면 메뉴가 화면을 넘긴다 — 나머지는 사이드에서. */ -const MENU_TYPE_LIMIT = 12; - /** * 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 매 그리기마다 새로 만들어지므로 이 함수도 * 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다. @@ -66,23 +87,19 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti .sort((left, right) => left.distance - right.distance)[0]; event.preventDefault(); const at = Number(chainage.toFixed(2)); - // 빈 자리 — 구조물 종류별 추가 항목(2026-08-05 사용자 지시. 배관은 관 지점 정본 경유). - // 레지스트리 타입이 오면 그 목록을 쓰고(구조물군 B~G), 아직 못 받았으면 구 항목을 쓴다. - const registryItems = (options.structureTypes ?? []) - .slice(0, MENU_TYPE_LIMIT) - .map((type): [string, () => void] => [ - `${type.name} 추가`, - () => options.onAddStructureType?.(at, type.type_id), - ]); - const addItems: Array<[string, () => void]> = [ - ["배관 추가", () => options.onAddPipe(at)], - ...(registryItems.length - ? registryItems - : (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ + // 빈 자리 — 사이드 「구조물 배치」와 같은 구조물군 → 종류 2단 메뉴(2026-08-18 + // 일원화). 선택 = 폼 자동 지정(addAt 경로, A군은 임시 배치까지). 레지스트리를 + // 아직 못 받았으면 구 항목(배관·기성막이 등)으로 대신한다. + const types = options.structureTypes ?? []; + const addItems: MapContextMenuItem[] = types.length + ? structureGroupMenuItems(types, (typeId) => options.onAddStructureType?.(at, typeId)) + : [ + ["배관 추가", () => options.onAddPipe(at)], + ...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ `${type === "기타" ? "기타 구조물" : type} 추가`, () => options.onAddStructure?.(at, type), - ])), - ]; + ]), + ]; menu.open(localX, event.clientY - rect.top, [ ...(near ? [ diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 85e7a988..b2e29e9f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -172,7 +172,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement lane.append(mark); if (selected) - lane.append(buildBalloon(structure, type, anchorPx, top + height, options.stationIntervalM)); + lane.append(buildBalloon(structure, type, anchorPx, row, options.stationIntervalM)); }); // Y축 띠 — 그래프 축을 레인까지 이어 붙인다. sticky라 가로 스크롤에도 왼쪽에 @@ -191,18 +191,22 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement return lane; } -/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. */ +/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. + * 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18 + * 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로 + * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */ function buildBalloon( structure: StructureInstance, type: StructureType | undefined, anchorPx: number, - bottomPx: number, + row: number | null, stationIntervalM: number, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; + balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; - balloon.style.top = `${bottomPx + ROW_GAP_PX}px`; + balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; const title = document.createElement("strong"); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index a83113d7..d026640f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -29,8 +29,9 @@ import { import { field, numberInput, select, stationFields } from "./B05_Profile_UI_Structures_Fields"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. */ -const GROUP_LABELS: Record = { +/** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. + * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ +export const GROUP_LABELS: Record = { A: "A 횡단배수", B: "B 종단배수", C: "C 사면안정", @@ -751,16 +752,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }, selectPipeByChainage(chainageM) { if (chainageM === null) { - // 임시 배치 중 외부 해제(다른 곳 클릭) = 취소 — 추가 직전 상태로(2026-08-18). - if (tempPipeChainage !== null) { - resetForm(); - return; - } - if (selectedPipeChainage === null) return; - selectedPipeChainage = null; - facilityOptions.setFacility(null); - syncButtons(); - renderList(); + // 외부 해제(빈 공간 클릭 등) — 임시 배치는 취소, 선택 중이던 시설도 구조물군· + // 종류까지 완전 리셋(2026-08-18 사용자 지시). 아무것도 안 골랐으면 무시. + if (tempPipeChainage !== null || selectedPipeChainage !== null) resetForm(); return; } // 그래프·3D·배수유역도에서 고른 관도 목록 강조 + 폼 로드까지 — 어느 경로든 @@ -771,7 +765,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu }, selectById(structureId) { if (structureId === null) { - loadForm(null); + // 그래프·3D 빈 공간 클릭 등 외부 해제 — 선택 중이었으면 구조물군·종류까지 + // 완전 리셋(2026-08-18 사용자 지시). 선택 없는 신규 작성 중에는 두지 않는다. + if (editingId !== null || selectedPipeChainage !== null || tempPipeChainage !== null) { + resetForm(); + } return; } loadForm(structures.find((entry) => entry.structure_id === structureId) ?? null); diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index e34ada3b..cef484d2 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -214,6 +214,12 @@ width: 100%; min-width: 0; gap: var(--spacing-16); +} + +/* 스크롤 영역 상단 여유 — 첫 컨테이너(구조물 배치)가 패널 상단 경계에 붙어 + 답답하다는 보고(2026-08-18 사용자 지시). */ +.b05-route__panel .ui-sidebar-scroll { + padding-top: var(--spacing-8); /* 하단 여유 패딩은 두지 않는다 — 액션 dock이 최하단에 붙는 지금은 버튼 아래 빈 띠로만 남는다(2026-08-18). 공용 body 패딩(16px)은 dock의 sticky bottom:-16px가 덮는다. */ @@ -744,6 +750,30 @@ background: var(--color-surface-sunken); } +/* 구조물군 하위 메뉴(아코디언, 2026-08-18 메뉴 일원화) — 머리글 굵게, 종류는 들여쓰기. + 종류가 많은 군이 펼쳐지면 메뉴가 화면을 넘을 수 있어 최대 높이에서 안쪽 스크롤. */ +.b05-profile-chart__context-menu, +.b05-drainage__context-menu { + max-height: 320px; + overflow-y: auto; +} + +.b05-profile-chart__context-menu-item.is-group, +.b05-drainage__context-menu-item.is-group { + font-weight: var(--font-weight-medium); +} + +.b05-profile-chart__context-menu-sub, +.b05-drainage__context-menu-sub { + display: flex; + flex-direction: column; +} + +.b05-profile-chart__context-menu-item.is-sub, +.b05-drainage__context-menu-item.is-sub { + padding-left: var(--spacing-24); +} + .b05-profile-table__irregular-col { position: absolute; /* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도 diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 72e9c2af..e85be5c8 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -94,8 +94,11 @@ } /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ +/* 벌룬은 알약 위로 뜬다(2026-08-18) — 아래는 도면 테이블에 가린다. 전개 방향: + 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). */ .b05-structure__balloon { position: absolute; + z-index: 6; transform: translateX(-50%); display: flex; flex-direction: column; @@ -112,6 +115,15 @@ z-index: 4; } +.b05-structure__balloon.is-left { + /* 오른쪽 끝을 알약 기준점에 붙이고 왼쪽 위로 편다(+10px는 알약과 살짝 겹침 방지). */ + transform: translateX(calc(-100% + 10px)); +} + +.b05-structure__balloon.is-right { + transform: translateX(-10px); +} + .b05-structure__balloon strong { font-size: 11.5px; } diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index cda675d8..3d4d42e3 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -10,11 +10,17 @@ * 지도 쪽 pointerdown/pointerup/contextmenu는 이 판정으로 먼저 걸러야 한다. * ========================================================================== */ +/** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 + * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */ +export type MapContextMenuItem = + | [label: string, action: () => void] + | { label: string; children: Array<[label: string, action: () => void]> }; + export interface MapContextMenu { /** 뷰포트에 append할 요소. */ element: HTMLElement; - /** 항목은 [문구, 눌렀을 때 할 일] 짝. 위치는 뷰포트 기준 px. */ - open: (x: number, y: number, items: Array<[label: string, action: () => void]>) => void; + /** 위치는 뷰포트 기준 px. */ + open: (x: number, y: number, items: MapContextMenuItem[]) => void; close: () => void; /** 이벤트가 메뉴 안에서 났는지. 지도 조작에서 걸러 내는 데 쓴다. */ contains: (target: EventTarget | null) => boolean; @@ -32,20 +38,50 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.replaceChildren(); } + function buildLeaf(label: string, action: () => void, sub = false): HTMLButtonElement { + const entry = document.createElement("button"); + entry.type = "button"; + entry.className = `${blockClass}__context-menu-item`; + if (sub) entry.classList.add("is-sub"); + entry.textContent = label; + entry.addEventListener("click", () => { + close(); + action(); + }); + return entry; + } + return { element, open(x, y, items) { element.replaceChildren(); - items.forEach(([label, action]) => { - const entry = document.createElement("button"); - entry.type = "button"; - entry.className = `${blockClass}__context-menu-item`; - entry.textContent = label; - entry.addEventListener("click", () => { - close(); - action(); + items.forEach((item) => { + if (Array.isArray(item)) { + element.append(buildLeaf(item[0], item[1])); + return; + } + // 하위 메뉴(아코디언) — 머리글을 누르면 종류 목록이 바로 아래로 펼쳐진다. + // 한 번에 한 군만 연다(다른 군을 열면 앞의 군은 접힘). + const head = document.createElement("button"); + head.type = "button"; + head.className = `${blockClass}__context-menu-item is-group`; + head.textContent = `${item.label} ▸`; + const body = document.createElement("div"); + body.className = `${blockClass}__context-menu-sub`; + body.hidden = true; + item.children.forEach(([label, action]) => body.append(buildLeaf(label, action, true))); + head.addEventListener("click", () => { + const willOpen = body.hidden; + element + .querySelectorAll(`.${blockClass}__context-menu-sub`) + .forEach((other) => (other.hidden = true)); + element + .querySelectorAll(`.${blockClass}__context-menu-item.is-group`) + .forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸"))); + body.hidden = !willOpen; + head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`; }); - element.append(entry); + element.append(head, body); }); element.style.left = `${x}px`; element.style.top = `${y}px`; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 8775fc13..d70a2b94 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -78,7 +78,7 @@ export const ui_locales_b2 = { B05_Drainage_Status_LoadFailed: ["배경도를 불러오지 못했습니다.", "Failed to load the basemap."], B05_Drainage_Basin_Undecided: ["미정", "TBD"], /* 관 최대 규격 초과 계류 유역 — 관이 아니라 세월교 대상. 유효직경은 앞머리가 적는다 */ - B05_Drainage_Basin_Bridge: ["세월교 검토", "Ford crossing req."], + B05_Drainage_Basin_Bridge: ["세월교 제안", "Ford bridge proposal"], /* {area}=배수 유효면적, {relief}=표고차, {flow}=유하장, {pipe}=배수 유효직경 */ B05_Drainage_Basin_Metrics: [ "유효면적 {area} · 표고 {relief}m · 유하 {flow}m · 유효직경 {pipe}", @@ -92,9 +92,9 @@ export const ui_locales_b2 = { /* {chainage}=측점 누가거리(m) */ B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"], /* {d}=규격 스냅 관경(mm). 유효직경 이상인 가장 작은 레지스트리 선택지 */ - B05_Drainage_Basin_RecPipe: ["추천 Ø{d} 배관", "Rec. Ø{d} pipe"], + B05_Drainage_Basin_RecPipe: ["Ø{d} 배관 제안", "Ø{d} pipe proposal"], /* 유효직경 Ø1,500 초과 — 교본 BOX암거 전환 유량 조건 */ - B05_Drainage_Basin_RecBox: ["BOX암거 후보", "Box culvert candidate"], + B05_Drainage_Basin_RecBox: ["BOX암거 제안", "Box culvert proposal"], /* 추천은 유량 근거만 — 횡단경사·차수 조건은 화면이 판정하지 않는다 */ B05_Drainage_Basin_RecNote: [ "추천은 유량(유효직경) 근거만 반영합니다 — 계곡 횡단경사·하천 차수는 현장 확인.",