diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 2dc688cd..dcbb38f7 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -88,14 +88,21 @@ export interface DrainagePanel { savePipes: () => Promise; /** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */ selectBasinByChainage: (chainageM: number | null) => void; + /** 측점 선택 마킹 — 계획선 위 해당 누가거리에 표식을 그린다(null이면 지움). */ + markStation: (chainageM: number | null) => void; dispose: () => void; } export interface DrainagePanelCallbacks { - /** 관 목록이 바뀔 때마다 누가거리 목록을 넘긴다 — 종단 테이블 구조물 라인 동기화용. */ - onPipesChanged?: (chainages: number[]) => void; + /** 관 목록이 바뀔 때마다 누가거리 + 담당 유역의 배수 유효직경(mm)을 넘긴다 — + * 종단 테이블 구조물 라인 동기화 및 관경 자동 지정(D800 기본)용. */ + onPipesChanged?: ( + pipes: Array<{ chainage_m: number; effective_diameter_mm: number | null }>, + ) => void; /** 유역을 고르거나 풀 때 그 관의 누가거리(없으면 null)를 넘긴다 — 그래프·사이드 패널 동기화용. */ onBasinSelected?: (chainageM: number | null) => void; + /** 배수유역도 우클릭으로 배관 외 구조물을 넣을 때(누가거리는 계획선 투영값). */ + onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; } export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): DrainagePanel { @@ -186,6 +193,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra let normalizer: Normalizer | null = null; let basins: DetailBasin[] = []; let selectedBasin: number | null = null; + // 측점 선택 마킹(계획선 위 누가거리). 유역이 없는 구조물 측점도 위치를 보여 준다. + let markedChainage: number | null = null; // 배관 편집기 — 마커 선택/추가/이동/삭제 시 재그리기와 버튼 상태만 갱신한다. const pipeEditor = createPipeEditor( () => { @@ -310,6 +319,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra showHotspots, pipeEditor, pipeColor, + markedChainage, }); updateImageTransform(); } @@ -386,7 +396,15 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra renderBasinList(); syncPipeSelection(); summaryText(summary, pipeEditor.pipes().length, basins.length, zSource); - callbacks.onPipesChanged?.(pipeEditor.chainages()); + callbacks.onPipesChanged?.( + // 관마다 담당 유역의 배수 유효직경을 붙인다(±0.5m 매칭). 유역 없는 관은 null. + pipeEditor.chainages().map((chainage) => ({ + chainage_m: chainage, + effective_diameter_mm: + basins.find((basin) => Math.abs(basin.chainage_m - chainage) < 0.51)?.pipe_diameter_mm ?? + null, + })), + ); } /** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */ @@ -519,7 +537,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }, { passive: false }, ); - bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView); + bindPipeContextMenu(viewport, contextMenu, pipeEditor, currentView, (chainage, type) => + callbacks.onStructureAdd?.(chainage, type), + ); viewport.addEventListener("pointerdown", (event) => { if (contextMenu.contains(event.target)) return; @@ -659,6 +679,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra pipeEditor.setPipes(next); void analyze(); }, + markStation(chainageM) { + if (markedChainage === chainageM) return; + markedChainage = chainageM; + scheduleDraw(); + }, addPipe(chainageM) { pipeEditor.addAtChainage(chainageM); }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts index ed990963..211b7eb9 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts @@ -185,6 +185,7 @@ export function bindPipeContextMenu( menu: MapContextMenu, editor: PipeEditor, viewOf: () => ViewState, + onAddStructure?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void, ): void { viewport.addEventListener("contextmenu", (event) => { if (menu.contains(event.target)) { @@ -212,7 +213,17 @@ export function bindPipeContextMenu( // 계획선에서 먼 자리는 브라우저 기본 메뉴를 그대로 둔다. if (!editor.canAddAt(view, x, y)) return; event.preventDefault(); - menu.open(x, y, [[L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)]]); + // 배관 + 배관 외 구조물(기성막이/대피로/기타) 추가(2026-08-05 사용자 지시). + const chainage = editor.chainageAt(view, x, y); + menu.open(x, y, [ + [L("B05_Drainage_Menu_Add"), () => void editor.addAt(view, x, y)], + ...(onAddStructure && chainage !== null + ? (["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ + `${type === "기타" ? "기타 구조물" : type} 추가`, + () => onAddStructure(chainage, type), + ]) + : []), + ]); }); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index 519bf82c..e6080127 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -38,6 +38,8 @@ export interface PipeEditor { addAtChainage(chainageM: number): void; /** 그 자리에 배관을 넣을 수 있는지(계획선에 충분히 가까운지)만 본다. */ canAddAt(view: ViewState, screenX: number, screenY: number): boolean; + /** 화면 좌표를 계획선 위 누가거리로 투영한다. 계획선에서 멀면 null(구조물 추가 메뉴용). */ + chainageAt(view: ViewState, screenX: number, screenY: number): number | null; /** 계획선 위 그 자리에 배관을 넣는다. 노선에서 멀면 false. */ addAt(view: ViewState, screenX: number, screenY: number): boolean; /** 편집 상호작용. 처리했으면 true(패널은 지도 팬을 생략한다). */ @@ -209,6 +211,13 @@ export function createPipeEditor( const nearest = nearestChainage(metric.x, metric.y); return !!nearest && nearest.distance * pxPerMeter(view) <= ADD_SNAP_PX; }, + chainageAt(view, screenX, screenY) { + const metric = screenToMetric(view, screenX, screenY); + if (!metric) return null; + const nearest = nearestChainage(metric.x, metric.y); + if (!nearest || nearest.distance * pxPerMeter(view) > ADD_SNAP_PX) return null; + return Number(nearest.chainage.toFixed(2)); + }, addAt(view, screenX, screenY) { const metric = screenToMetric(view, screenX, screenY); if (!metric) return false; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts index 6aa2b8fe..3231a8b3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Render.ts @@ -62,6 +62,8 @@ export interface DrainageScene { showHotspots: boolean; pipeEditor: PipeEditor; pipeColor: (chainage: number, position: number) => string; + /** 선택된 측점의 누가거리(m). 계획선 위 그 자리에 선택 표식을 그린다(null=없음). */ + markedChainage: number | null; } export function drawDrainageScene( @@ -158,6 +160,28 @@ export function drawDrainageScene( } // 배관(관 매설) 마커 — 계획선 위. scene.pipeEditor.draw(context, view, scene.pipeColor); + // 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의 + // 배수유역도 쪽 표현. 유역이 없는 구조물 측점도 위치가 보여야 한다). + if (scene.markedChainage !== null && scene.strengthSamples.length > 0) { + const index = Math.min( + scene.strengthSamples.length - 1, + Math.max(0, Math.round(scene.markedChainage)), + ); + const sample = scene.strengthSamples[index]; + const [x, y] = projector.toScreen(sample.x, sample.y); + context.save(); + context.translate(x, y); + context.rotate(Math.PI / 4); + const half = 7; + context.beginPath(); + context.rect(-half, -half, half * 2, half * 2); + context.fillStyle = "rgba(255, 196, 0, 0.35)"; + context.fill(); + context.lineWidth = 2.5; + context.strokeStyle = "#ff9800"; + context.stroke(); + context.restore(); + } // 유역 번호 — 무엇에도 가리지 않게 맨 마지막. drawBadges(context, badges); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index 773228f1..76fb15cf 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -10,6 +10,20 @@ * chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다. * ========================================================================== */ +import { + ESCAPE_ROUTE_DEFAULT_WIDTH_M, + ESCAPE_ROUTE_WIDTHS_M, + PIPE_DEFAULT_DIAMETER_MM, + PIPE_DEFAULT_TYPE, + PIPE_DIAMETERS_MM, + PIPE_TYPES, + STRUCTURE_DEFAULT_TYPE, + STRUCTURE_ETC_DEFAULT_NAME, + STRUCTURE_TYPES, + pickPipeDiameter, + type StructureType, +} from "@config/config_frontend"; + export interface IrregularStation { id: string; /** 측점번호 X. */ @@ -18,20 +32,62 @@ export interface IrregularStation { remainder: number; /** = station × 측점간격 + remainder. 그래프·테이블·3D의 X축 기준. */ chainage_m: number; - /** 구조물 설명 (당분간 자유 텍스트). */ + /** 표시용 구조물 라벨 — structureLabel()이 만든다 (예: "파형강관 D800", "대피로 2.0m"). */ structure: string; + /** 구조물 종류 드롭다운 값 (2026-08-05 사용자 확정). 구버전 항목은 undefined → "기타" 취급. */ + structureType?: StructureType; + /** 배관 하위 옵션 — 관종·직경. */ + pipeType?: string; + diameterMm?: number; + /** 사용자가 관종·직경을 손으로 바꿨는가. true면 배수유역 재계산 때 자동 지정으로 덮지 않는다. */ + userSized?: boolean; + /** 대피로 하위 옵션 — 폭(m). */ + escapeWidthM?: number; + /** 기타 하위 옵션 — 이름 자유 텍스트. */ + customName?: string; /** 어디서 온 항목인가. `pipe`는 배수유역도의 관 매설 지점이 투영된 것이라 손으로 * 지우거나 옮겨도 정본(`pipe_points.json`)을 거쳐야 한다(2026-08-01 사용자 지시). */ origin?: "user" | "pipe"; } -/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 이름. 향후 드롭다운으로 바꾼다. */ +/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 이름. */ export const PIPE_STRUCTURE_NAME = "배관"; -/** 배관으로 볼 항목인가. 손으로 이름을 "배관"이라 적은 것도 관 지점 정본을 따르게 한다 — - * 그렇지 않으면 [초기화]로 지워지지 않고 배수유역도와도 어긋난 채 남는다(2026-08-02 사용자 보고). */ -export function isPipeStation(station: { origin?: "user" | "pipe"; structure: string }): boolean { - return station.origin === "pipe" || station.structure.trim() === PIPE_STRUCTURE_NAME; +/** 배관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"이라 적은 것도 + * 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와 어긋난다. */ +export function isPipeStation(station: { + origin?: "user" | "pipe"; + structure: string; + structureType?: StructureType; +}): boolean { + return ( + station.origin === "pipe" || + station.structureType === PIPE_STRUCTURE_NAME || + station.structure.trim() === PIPE_STRUCTURE_NAME + ); +} + +/** 옵션 값들로 표시 라벨을 만든다 — 목록·종단 그래프·3D 마커가 같은 문자열을 쓴다. */ +export function structureLabel(entry: { + structureType?: StructureType; + pipeType?: string; + diameterMm?: number; + escapeWidthM?: number; + customName?: string; + structure?: string; +}): string { + switch (entry.structureType) { + case "배관": + return `${entry.pipeType ?? PIPE_DEFAULT_TYPE} D${entry.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM}`; + case "기성막이": + return "기성막이"; + case "대피로": + return `대피로 ${(entry.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1)}m`; + case "기타": + return entry.customName?.trim() || STRUCTURE_ETC_DEFAULT_NAME; + default: + return entry.structure ?? ""; + } } export interface IrregularStationsSection { @@ -41,8 +97,13 @@ export interface IrregularStationsSection { selectByChainage: (chainageM: number | null) => void; /** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */ setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void; - /** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. */ - setPipeStations: (chainages: ReadonlyArray) => void; + /** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다. + * effective_diameter_mm(배수 유효직경)를 주면 그 이상 첫 규격을 자동 지정한다(기본 D800). */ + setPipeStations: ( + pipes: ReadonlyArray, + ) => void; + /** 우클릭 메뉴에서 배관 외 구조물을 기본 옵션으로 추가한다(종단·배수유역도 공용). */ + addStructure: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void; /** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */ removeByChainage: (chainageM: number) => IrregularStation | null; /** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */ @@ -69,7 +130,7 @@ export function irregularLabel(station: IrregularStation): string { return `${station.station}+${station.remainder.toFixed(1)}`; } -function field(labelText: string, input: HTMLInputElement): HTMLLabelElement { +function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; const caption = document.createElement("span"); @@ -86,6 +147,12 @@ function numberInput(step: string, min: string): HTMLInputElement { return input; } +function selectInput(options: ReadonlyArray): HTMLSelectElement { + const select = document.createElement("select"); + select.replaceChildren(...options.map((value) => new Option(value, value))); + return select; +} + export function createIrregularStationsSection( callbacks: IrregularStationsCallbacks, ): IrregularStationsSection { @@ -102,9 +169,50 @@ export function createIrregularStationsSection( stationField.placeholder = "측점번호"; const remainderField = numberInput("0.1", "0"); remainderField.placeholder = "잔여거리"; - const structureField = document.createElement("input"); - structureField.type = "text"; - structureField.placeholder = "구조물 (예: 배수구조물, 옹벽)"; + + // 구조물 종류 + 종류별 하위 옵션 (2026-08-05 사용자 확정. 목록은 config_frontend가 정본). + const typeSelect = selectInput(STRUCTURE_TYPES); + const pipeTypeSelect = selectInput(PIPE_TYPES); + const diameterSelect = document.createElement("select"); + const widthSelect = selectInput(ESCAPE_ROUTE_WIDTHS_M.map((width) => width.toFixed(1))); + const nameField = document.createElement("input"); + nameField.type = "text"; + nameField.placeholder = STRUCTURE_ETC_DEFAULT_NAME; + + const pipeTypeField = field("관종", pipeTypeSelect); + const diameterField = field("직경", diameterSelect); + const widthField = field("폭 (m)", widthSelect); + const nameFieldWrap = field("이름", nameField); + const subRow = document.createElement("div"); + subRow.className = "b05-route__irregular-row"; + subRow.append(pipeTypeField, diameterField, widthField, nameFieldWrap); + + /** 관종에 맞는 직경 목록으로 갈아 끼운다. 가능하면 현재 선택을 유지한다. */ + function syncDiameterOptions(keep?: number): void { + const sizes = PIPE_DIAMETERS_MM[pipeTypeSelect.value] ?? PIPE_DIAMETERS_MM[PIPE_DEFAULT_TYPE]; + const wanted = keep ?? (Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM); + diameterSelect.replaceChildren( + ...sizes.map((size) => new Option(`D${size}`, String(size), false, size === wanted)), + ); + if (!sizes.includes(wanted)) + diameterSelect.value = String(pickPipeDiameter(pipeTypeSelect.value, wanted)); + } + + /** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름. */ + function syncSubOptions(): void { + const type = typeSelect.value as StructureType; + pipeTypeField.hidden = diameterField.hidden = type !== "배관"; + widthField.hidden = type !== "대피로"; + nameFieldWrap.hidden = type !== "기타"; + subRow.hidden = type === "기성막이"; // 기성막이 하위 옵션 미정(추가반영 예정) + } + typeSelect.addEventListener("change", syncSubOptions); + pipeTypeSelect.addEventListener("change", () => syncDiameterOptions()); + typeSelect.value = STRUCTURE_DEFAULT_TYPE; + pipeTypeSelect.value = PIPE_DEFAULT_TYPE; + widthSelect.value = ESCAPE_ROUTE_DEFAULT_WIDTH_M.toFixed(1); + syncDiameterOptions(PIPE_DEFAULT_DIAMETER_MM); + syncSubOptions(); const stationRow = document.createElement("div"); stationRow.className = "b05-route__irregular-row"; @@ -132,8 +240,8 @@ export function createIrregularStationsSection( help.textContent = "구조물을 설치할 위치를 측점번호+잔여거리로 추가합니다. 목록에서 고르면 수정·삭제할 수 있습니다."; - // 측점번호/잔여거리를 최상단에 둔다(위치가 먼저, 그다음 구조물). - body.append(stationRow, field("구조물", structureField), actions, list, help); + // 측점번호/잔여거리를 최상단에 둔다(위치가 먼저, 그다음 구조물 종류·하위 옵션). + body.append(stationRow, field("구조물", typeSelect), subRow, actions, list, help); const stations: IrregularStation[] = []; let editingId: string | null = null; @@ -163,7 +271,16 @@ export function createIrregularStationsSection( editingId = target?.id ?? null; stationField.value = target ? String(target.station) : ""; remainderField.value = target ? String(target.remainder) : ""; - structureField.value = target?.structure ?? ""; + // 구버전 항목(structureType 없음)은 "기타"로 열고 이름칸에 기존 텍스트를 옮긴다. + const type = target ? (target.structureType ?? "기타") : STRUCTURE_DEFAULT_TYPE; + typeSelect.value = type; + pipeTypeSelect.value = target?.pipeType ?? PIPE_DEFAULT_TYPE; + syncDiameterOptions(target?.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM); + widthSelect.value = (target?.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1); + nameField.value = target + ? (target.customName ?? (target.structureType ? "" : target.structure)) + : ""; + syncSubOptions(); syncButtons(); // 선택 강조(is-selected)가 클릭 즉시 따라오도록 목록을 다시 그린다(토글 해제 포함). renderList(); @@ -216,7 +333,18 @@ export function createIrregularStationsSection( const safeRemainder = Number.isFinite(remainder) ? Math.min(Math.max(remainder, 0), intervalMax()) : 0; - const structure = structureField.value.trim(); + // 드롭다운 선택값 → 옵션 필드 + 표시 라벨. 종류에 안 쓰는 필드는 넣지 않는다. + const structureType = typeSelect.value as StructureType; + const options: Partial = { structureType }; + if (structureType === "배관") { + options.pipeType = pipeTypeSelect.value; + options.diameterMm = Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM; + } else if (structureType === "대피로") { + options.escapeWidthM = Number(widthSelect.value) || ESCAPE_ROUTE_DEFAULT_WIDTH_M; + } else if (structureType === "기타") { + options.customName = nameField.value.trim() || STRUCTURE_ETC_DEFAULT_NAME; + } + const structure = structureLabel(options); const chainage_m = chainageOf(station, safeRemainder); if (editingId) { // 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어 @@ -224,13 +352,22 @@ export function createIrregularStationsSection( // 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다. const index = stations.findIndex((entry) => entry.id === editingId); if (index >= 0) { + const previous = stations[index]; + // 배관 관종·직경을 손으로 바꿨으면 자동 지정이 다시 덮지 않게 표시한다. + const userSized = + structureType === "배관" && + (previous.userSized || + previous.pipeType !== options.pipeType || + previous.diameterMm !== options.diameterMm); stations[index] = { - ...stations[index], + ...previous, id: editingId, station, remainder: safeRemainder, chainage_m, structure, + ...options, + userSized, }; } } else { @@ -240,6 +377,7 @@ export function createIrregularStationsSection( remainder: safeRemainder, chainage_m, structure, + ...options, origin: "user", }); } @@ -298,26 +436,59 @@ export function createIrregularStationsSection( renderList(); callbacks.onChange([...stations]); }, - setPipeStations(chainages) { + setPipeStations(pipes) { // 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다. + // 단, 사용자가 관종·직경을 손으로 바꾼 항목(userSized)은 같은 자리에 다시 올 때 유지한다. + const previousPipes = stations.filter(isPipeStation); for (let index = stations.length - 1; index >= 0; index -= 1) { if (isPipeStation(stations[index])) stations.splice(index, 1); } - chainages.forEach((chainage) => { + pipes.forEach((pipe) => { + const chainage = typeof pipe === "number" ? pipe : pipe.chainage_m; + const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null); const { station, remainder } = splitChainage(chainage); - stations.push({ + const kept = previousPipes.find( + (entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < 0.51, + ); + const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE; + const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective); + const entry: IrregularStation = { id: String(nextId++), station, remainder, chainage_m: chainage, - structure: PIPE_STRUCTURE_NAME, + structure: "", + structureType: PIPE_STRUCTURE_NAME, + pipeType, + diameterMm, + userSized: kept?.userSized ?? false, origin: "pipe", - }); + }; + entry.structure = structureLabel(entry); + stations.push(entry); }); if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null); renderList(); callbacks.onChange([...stations]); }, + addStructure(chainageM, structureType) { + const { station, remainder } = splitChainage(chainageM); + const entry: IrregularStation = { + id: String(nextId++), + station, + remainder, + chainage_m: chainageM, + structure: "", + structureType, + origin: "user", + }; + if (structureType === "대피로") entry.escapeWidthM = ESCAPE_ROUTE_DEFAULT_WIDTH_M; + if (structureType === "기타") entry.customName = STRUCTURE_ETC_DEFAULT_NAME; + entry.structure = structureLabel(entry); + stations.push(entry); + renderList(); + callbacks.onChange([...stations]); + }, removeByChainage(chainageM) { const index = stations.findIndex((entry) => Math.abs(entry.chainage_m - chainageM) < 0.51); if (index < 0) return null; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index a7036a98..ce48042c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -320,8 +320,12 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void { disposeGroup(stationGroup); disposeGroup(stationLabelGroup); + stationCenters.clear(); const bounds = getBounds(); - if (!bounds || halfWidth <= 0) return; + if (!bounds || halfWidth <= 0) { + syncSelectionPin(); + return; + } // 측점간격은 최빈 간격으로 되짚는다 — 비정규 측점이 섞여 있어도 규칙 간격이 나온다. const stationIntervalM = inferStationInterval( stations @@ -332,6 +336,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou if (station.center_z === null) return; const [leftX, leftY] = station.frame.left_xy; const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.45 }; + stationCenters.set(station.station_id, modelToScene(center, bounds)); const points = [ modelToScene( { x: center.x + leftX * halfWidth, y: center.y + leftY * halfWidth, z: center.z }, @@ -373,6 +378,36 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou stationGroup.add(lamp); }); }); + // 재렌더로 좌표가 갱신됐으니 선택 핀도 그 자리로 다시 놓는다. + syncSelectionPin(); + } + + /* ── 선택 측점 수직 핀 마커 ────────────────────────────────────────────── + * 원형 표기는 지형에 묻혀 잘 안 보인다(2026-08-05 사용자 지시로 지양). 대신 지형에서 + * 수직으로 솟는 기둥 + 아래를 가리키는 역원뿔 헤드를 세운다. depthTest를 꺼서 능선 + * 뒤에 있어도 항상 보인다. 측점 좌표는 renderStationLines가 갱신하는 맵에서 찾는다. */ + const stationCenters = new Map(); + const PIN_HEIGHT = 14; + const selectionPin = (() => { + const group = new THREE.Group(); + const material = new THREE.MeshBasicMaterial({ color: 0xff3b30, depthTest: false }); + const beam = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.28, PIN_HEIGHT, 10), material); + beam.position.y = PIN_HEIGHT / 2; + const head = new THREE.Mesh(new THREE.ConeGeometry(1.8, 3.4, 14), material); + head.rotation.x = Math.PI; // 꼭짓점이 아래(측점)를 가리키게 뒤집는다. + head.position.y = PIN_HEIGHT + 1.7; + group.add(beam, head); + group.renderOrder = 9; + group.visible = false; + interactionGroup.add(group); + return group; + })(); + + /** 선택 상태·측점 좌표에 맞춰 핀을 놓는다(재렌더·선택 변경 공용). */ + function syncSelectionPin(): void { + const center = selectedStationId ? stationCenters.get(selectedStationId) : undefined; + selectionPin.visible = !!center; + if (center) selectionPin.position.copy(center); } function selectStation(stationId: string | null): void { @@ -384,6 +419,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou material.color.set(selected ? 0xef4444 : 0xfacc15); material.linewidth = selected ? 3 : 1; }); + syncSelectionPin(); stationSelectionListener?.(selectedStationId); } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 907dd9b4..8d7214b8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -198,7 +198,7 @@ export async function renderB05Route(root: HTMLElement): Promise { { // 관 매설 목록 ↔ 구조물 목록의 "배관" 항목을 한 방향으로 맞춘다. // 정본은 배수유역도의 관 지점이며, 구조물 목록은 그것을 실체화한 것이다. - onPipesChanged: (chainages) => panel.irregularStations.setPipeStations(chainages), + onPipesChanged: (pipes) => panel.irregularStations.setPipeStations(pipes), onStructureMove: (from, to, station) => { if (isPipeStation(station)) { // 배관은 관 지점 정본을 거쳐야 세부유역까지 함께 다시 나뉜다. @@ -215,6 +215,8 @@ export async function renderB05Route(root: HTMLElement): Promise { panel.irregularStations.removeByChainage(station.chainage_m); }, onPipeAdd: (chainage) => profilePanel.drainage.addPipe(chainage), + // 우클릭 메뉴로 배관 외 구조물 추가(종단·배수유역도 공용) — 기본 옵션으로 넣는다. + onStructureAdd: (chainage, type) => panel.irregularStations.addStructure(chainage, type), onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)), // 배수유역도에서 유역을 고르면 그 관의 구조물 측점을 그래프·3D·사이드 패널에서도 고른다. onBasinSelected: (chainageM) => selectStationOfPipe(chainageM), @@ -266,6 +268,7 @@ export async function renderB05Route(root: HTMLElement): Promise { selectGraph: (id) => profilePanel.setSelectedStation(id), selectSidebar: (chainageM) => panel.irregularStations.selectByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), + markStation: (chainageM) => profilePanel.drainage.markStation(chainageM), }); function persistUphillOverrides(): void { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index e84cc6a2..f5c3136b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -23,6 +23,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { createDrainagePanel } from "./B05_wf2_Route_UI_Drainage_Panel"; import { mountStructureMenu } from "./B05_wf2_Route_UI_Profile_Structures"; +import { createProfileTableOverlay } from "./B05_wf2_Route_UI_Profile_TableOverlay"; import { hasLegacyAlignment, normalizedLongitudinal, @@ -74,11 +75,8 @@ import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross_Areas.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; /** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ const HEIGHT_KEY = "b05-route-profile-height"; -/** 처음 잡힌 도면 테이블 높이(px). 패널 높이와 같은 수명(세션)으로 함께 남긴다 — - * 페이지를 다시 들어와도 테이블 높이가 달라지지 않아야 한다. */ -const TABLE_HEIGHT_KEY = "b05-route-profile-table-height"; -/** 정보 라인을 뺀 본문 세로를 그래프 40% : 테이블 60%로 나눈다(4:6, 6이 테이블). */ -const CHART_HEIGHT_RATIO = 0.4; +/* 테이블 높이는 오버레이 서브패널(--b05-table-height 리사이저)이 관리한다 — + 예전 4:6 고정 분할(TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO)은 폐지(2026-08-05). */ const MIN_CHART_HEIGHT = 100; /** 접힌 유토곡선 손잡이(24px)가 앉을 테이블 아래 전용 띠 — 곡선 행 클릭을 가리지 않게. */ const MASSHAUL_HANDLE_GUTTER_PX = 26; @@ -204,14 +202,19 @@ function computeProfileLayout( /** 종단 테이블 구조물 라인·배수유역도가 Page로 올려 보내는 알림. */ export interface RouteProfilePanelCallbacks { - /** 관 매설 목록이 바뀜 — 구조물 목록의 "배관" 항목을 이 누가거리로 맞춘다. */ - onPipesChanged?: (chainages: number[]) => void; + /** 관 매설 목록이 바뀜 — 구조물 목록의 "배관" 항목을 이 누가거리로 맞춘다. + * 유효직경(mm)을 함께 올려 관경 자동 지정에 쓴다. */ + onPipesChanged?: ( + pipes: Array<{ chainage_m: number; effective_diameter_mm: number | null }>, + ) => void; /** 테이블에서 구조물 라인을 끌어 옮김. */ onStructureMove?: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void; /** 테이블 우클릭으로 구조물(배관 포함)을 지움. */ onStructureRemove?: (station: IrregularStation) => void; /** 테이블 빈 자리 우클릭으로 배관을 넣음. */ onPipeAdd?: (chainageM: number) => void; + /** 종단·배수유역도 우클릭으로 배관 외 구조물(기성막이/대피로/기타)을 넣음. */ + onStructureAdd?: (chainageM: number, type: "기성막이" | "대피로" | "기타") => void; /** 구조물 라인을 눌러 고름. */ onIrregularSelect?: (station: IrregularStation) => void; /** 배수유역도에서 유역을 고름 — 그 관의 누가거리(해제면 null). 그래프·사이드 패널을 맞춘다. */ @@ -255,12 +258,21 @@ export function createRouteProfilePanel( bodyWrap.append(massHaul.overlay, massHaul.handle); // 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지. massHaul.attachScrollSync(body); + // 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시). + const tableOverlay = createProfileTableOverlay(() => draw()); + bodyWrap.append(tableOverlay.overlay, tableOverlay.handle); + tableOverlay.attachScrollSync(body); + // 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시). + // 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다. + body.addEventListener("contextmenu", (event) => event.preventDefault()); + massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault()); const content = document.createElement("div"); content.className = "b05-route-profile__content"; // 관 목록이 바뀌면 종단 테이블의 "배관" 구조물 라인도 같이 맞춘다(정본은 관 지점 파일). const drainagePanel = createDrainagePanel({ - onPipesChanged: (chainages) => callbacks?.onPipesChanged?.(chainages), + onPipesChanged: (pipes) => callbacks?.onPipesChanged?.(pipes), onBasinSelected: (chainageM) => callbacks?.onBasinSelected?.(chainageM), + onStructureAdd: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), }); content.append(bodyWrap, drainagePanel.root); // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 @@ -294,8 +306,6 @@ export function createRouteProfilePanel( let crossPreviewSeq = 0; let lastWidth = 0; let lastHeight = 0; - /** 처음 그릴 때 잡힌 도면 테이블 높이(px). 패널을 끌어도 이 값을 지킨다. */ - let fixedTableHeight = Number(sessionStorage.getItem(TABLE_HEIGHT_KEY)) || 0; /** * 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시). @@ -474,47 +484,42 @@ export function createRouteProfilePanel( // 그래프 40% : 테이블 60% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다). // 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다. const available = Math.max(120, body.clientHeight); - // 첫 화면에서만 4:6으로 나누고, 그때 잡힌 테이블 높이를 이후로도 그대로 쓴다. - // 패널을 끌어 늘리거나 줄이면 그 차이는 전부 그래프가 흡수한다(사용자 지시). - if (alignment && fixedTableHeight <= 0) { - fixedTableHeight = Math.round(available * (1 - CHART_HEIGHT_RATIO)); - sessionStorage.setItem(TABLE_HEIGHT_KEY, String(fixedTableHeight)); - } - // 종단도·테이블 배치는 유토곡선과 무관하게 항상 같다(2026-08-04 사용자 확정). - // 유토곡선은 별도 오버레이 서브패널이 그 위를 덮는다. - const chartHeight = alignment - ? Math.max(MIN_CHART_HEIGHT, available - fixedTableHeight) - : available; - // 테이블 아래에 접힌 유토곡선 손잡이(24px)의 전용 띠를 남긴다 — 띠 없이는 손잡이가 - // 곡선 L·R 행 위에 떠서 입력 클릭을 가로챈다(2026-08-04 사용자 보고: 곡선 수정 불가). - const tableHeight = available - chartHeight - MASSHAUL_HANDLE_GUTTER_PX; + // 테이블은 유토곡선과 같은 바닥 고정 오버레이 서브패널로 옮겼다(2026-08-05 사용자 지시). + // 종단도는 본문 전체 높이를 쓰고, 바닥에 접힌 손잡이 띠(24px)만 남긴다 — 띠 없이는 + // 손잡이가 그래프 편집 버튼 위에 떠서 입력 클릭을 가로챈다(2026-08-04 사용자 보고). + const chartHeight = Math.max(MIN_CHART_HEIGHT, available - MASSHAUL_HANDLE_GUTTER_PX); + // 오버레이 순서(위→아래): 종단도 → 테이블 → 유토곡선. 유토곡선이 열려 있으면 그 높이만큼 + // 테이블 오버레이를 위로 밀어 쌓는다. + tableOverlay.setBottomOffset(massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight); + const tableHeight = tableOverlay.contentHeight(); - const table = alignment - ? createProfileTable({ - alignment, - stationInterval: stationInterval ?? alignment.policy.station_interval_m, - width, - height: tableHeight, - // 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다). - cellWidth: layout.cellWidth, - // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. - labelWidth: LONG_PAD.left, - rowCount: TABLE_ROW_COUNT, - x, - // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. - irregularStations: irregularStations.filter( - (entry) => - entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, - ), - selectedStationId, - stationDisplay, - onCurveRadiusChange: (curve, radius) => - applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), - // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. - onAdjustStation: (chainage, delta) => - base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), - }) - : null; + const table = + alignment && tableOverlay.isOpen() && tableHeight > 0 + ? createProfileTable({ + alignment, + stationInterval: stationInterval ?? alignment.policy.station_interval_m, + width, + height: tableHeight, + // 셀 폭은 실제 측점 간격에 맞춰 함께 늘어난다(폭맞춤 시 값이 넓게 퍼진다). + cellWidth: layout.cellWidth, + // 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다. + labelWidth: LONG_PAD.left, + rowCount: TABLE_ROW_COUNT, + x, + // 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다. + irregularStations: irregularStations.filter( + (entry) => + entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, + ), + selectedStationId, + stationDisplay, + onCurveRadiusChange: (curve, radius) => + applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)), + // 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인. + onAdjustStation: (chainage, delta) => + base && applyEdits(adjustStation(base, store.edits(), chainage, delta)), + }) + : null; const chartWrap = document.createElement("div"); chartWrap.className = "b05-profile__chart"; @@ -578,6 +583,7 @@ export function createRouteProfilePanel( maxChainageM: maxChainageOf(longitudinal), onRemove: (station) => callbacks?.onStructureRemove?.(station), onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage), + onAddStructure: (chainage, type) => callbacks?.onStructureAdd?.(chainage, type), }); // 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단). // 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트 @@ -610,9 +616,11 @@ export function createRouteProfilePanel( } canvas.style.height = `${available}px`; canvas.append(chartWrap); - if (table) canvas.append(table); body.replaceChildren(canvas); body.scrollLeft = scrollLeft; + // 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다. + if (table) table.style.width = `${width}px`; + tableOverlay.setTable(table); // 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정). // X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다. diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts index e53223ad..7a42e955 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts @@ -28,6 +28,8 @@ export interface StructureLineOptions { onRemove: (station: IrregularStation) => void; /** 빈 자리에서 우클릭해 배관을 넣을 때. */ onAddPipe: (chainageM: number) => void; + /** 빈 자리에서 우클릭해 배관 외 구조물(기성막이/대피로/기타)을 넣을 때. */ + onAddStructure?: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void; } /** @@ -56,13 +58,24 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti .filter((entry) => entry.distance <= GRAB_SLACK_PX) .sort((left, right) => left.distance - right.distance)[0]; event.preventDefault(); + const at = Number(chainage.toFixed(2)); + // 빈 자리 — 구조물 종류별 추가 항목(2026-08-05 사용자 지시. 배관은 관 지점 정본 경유). + const addItems: Array<[string, () => void]> = [ + ["배관 추가", () => options.onAddPipe(at)], + ...(["기성막이", "대피로", "기타"] as const).map((type): [string, () => void] => [ + `${type === "기타" ? "기타 구조물" : type} 추가`, + () => options.onAddStructure?.(at, type), + ]), + ]; menu.open(localX, event.clientY - rect.top, [ - near + ...(near ? [ - isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제", - () => options.onRemove(near.station), + [ + isPipeStation(near.station) ? "배관 삭제" : "구조물 삭제", + () => options.onRemove(near.station), + ] as [string, () => void], ] - : ["배관 추가", () => options.onAddPipe(Number(chainage.toFixed(2)))], + : addItems), ]); }); host.addEventListener("pointerdown", (event) => { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts new file mode 100644 index 00000000..c3a1510b --- /dev/null +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_TableOverlay.ts @@ -0,0 +1,149 @@ +/* ============================================================================= + * B05_wf2_Route_UI_Profile_TableOverlay.ts + * 하단 종단 패널의 12행 도면식 테이블을 **유토곡선과 같은 형태**의 바닥 고정 + * 오버레이 서브패널로 감싼다(2026-08-05 사용자 지시). + * + * - 접힘/펼침 표준 삼각형 손잡이 + 위 경계 리사이저 + 세션 보존. + * - 유토곡선 오버레이가 함께 열리면 그 **위**에 쌓인다 — 위→아래 순서가 + * 종단도 → 테이블 → 유토곡선이 되도록 setBottomOffset으로 바닥 간격을 받는다. + * - 접혔을 때 손잡이는 유토곡선 손잡이와 가로로 나란히(일렬) 놓인다(CSS에서 + * 가로 오프셋). 테이블 내용물은 Profile_Panel의 draw()가 만들어 setTable로 넣는다. + * ========================================================================== */ + +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createPanelResizer } from "@ui/ui_template_resizer"; + +const OPEN_KEY = "b05:profile:table:open"; +const HEIGHT_KEY = "b05:profile:table:height"; +const OVERLAY_MIN_HEIGHT = 140; +const OVERLAY_MAX_RATIO = 0.75; + +export interface RouteProfileTableOverlay { + /** 접힘 손잡이(패널 바닥 띠에 얹는다). */ + handle: HTMLElement; + /** 테이블을 담는 바닥 고정 오버레이. */ + overlay: HTMLElement; + isOpen: () => boolean; + /** 오버레이 내용 높이(px) — draw()가 테이블 행 높이를 정하는 기준. */ + contentHeight: () => number; + /** draw()가 만든 테이블 요소를 넣는다(null이면 비움). */ + setTable: (table: HTMLElement | null) => void; + /** 종단 그래프 스크롤러와 가로 스크롤 양방향 동기화. */ + attachScrollSync: (main: HTMLElement) => void; + /** 유토곡선 오버레이가 차지한 바닥 높이(px). 그 위로 쌓인다. */ + setBottomOffset: (px: number) => void; +} + +export function createProfileTableOverlay(onChanged: () => void): RouteProfileTableOverlay { + const handleControl = createWorkflowPanelHandle("bottom", "down", "테이블"); + const handle = document.createElement("div"); + handle.className = "b05-profile__table-handle"; + handle.append(handleControl.root); + handleControl.root.setAttribute("aria-label", "테이블 패널"); + + const overlay = document.createElement("div"); + overlay.className = "b05-profile__table-overlay"; + const scroll = document.createElement("div"); + scroll.className = "b05-profile__table-scroll"; + + // 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다 + // (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인). + let open = sessionStorage.getItem(OPEN_KEY) !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지) + let bottomOffset = 0; + let resizeRedrawPending = false; + const resizer = createPanelResizer({ + axis: "vertical", + target: overlay, + cssVar: "--b05-table-height", + direction: -1, + min: OVERLAY_MIN_HEIGHT, + max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO, + storageKey: HEIGHT_KEY, + onResize: () => { + syncHandlePosition(); + if (resizeRedrawPending) return; + resizeRedrawPending = true; + requestAnimationFrame(() => { + resizeRedrawPending = false; + onChanged(); + }); + }, + }); + overlay.append(resizer.root, scroll); + + // 테이블 영역 우클릭은 브라우저 기본 메뉴를 막는다(2026-08-05 사용자 지시). + scroll.addEventListener("contextmenu", (event) => event.preventDefault()); + + // 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다). + scroll.addEventListener( + "wheel", + (event) => { + if (event.shiftKey || event.deltaY === 0) return; + const limit = scroll.scrollWidth - scroll.clientWidth; + if (limit <= 0) return; + const delta = event.deltaY; + if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit)) + return; + scroll.scrollLeft += delta; + event.preventDefault(); + }, + { passive: false }, + ); + + /** 손잡이는 오버레이 위 경계를, 접히면 바닥(+유토곡선 간격)을 따라간다. */ + function syncHandlePosition(): void { + handle.style.bottom = open ? `${bottomOffset + overlay.offsetHeight}px` : "0px"; + overlay.style.bottom = `${bottomOffset}px`; + } + + function applyOpen(next: boolean): void { + open = next; + sessionStorage.setItem(OPEN_KEY, String(next)); + handleControl.setOpen(next); + handleControl.root.title = next ? "테이블 접기" : "테이블 펼치기"; + handle.classList.toggle("is-open", next); + overlay.hidden = !next; + syncHandlePosition(); + onChanged(); + } + handleControl.setOpen(open); + handleControl.root.title = open ? "테이블 접기" : "테이블 펼치기"; + handle.classList.toggle("is-open", open); + overlay.hidden = !open; + handleControl.root.addEventListener("click", () => applyOpen(!open)); + + // 종단 스크롤러와의 양방향 동기화 — 재진입 루프는 플래그로 끊는다(유토곡선과 동일). + let syncing = false; + function mirror(from: HTMLElement, to: HTMLElement): void { + from.addEventListener("scroll", () => { + if (syncing) return; + syncing = true; + to.scrollLeft = from.scrollLeft; + syncing = false; + }); + } + let syncedMain: HTMLElement | null = null; + + return { + handle, + overlay, + isOpen: () => open, + contentHeight: () => Math.max(0, overlay.offsetHeight - 6), + setTable(table) { + const keepScroll = scroll.scrollLeft; + scroll.replaceChildren(...(table ? [table] : [])); + scroll.scrollLeft = keepScroll; + syncHandlePosition(); + }, + attachScrollSync(main) { + if (syncedMain === main) return; + syncedMain = main; + mirror(main, scroll); + mirror(scroll, main); + }, + setBottomOffset(px) { + bottomOffset = px; + syncHandlePosition(); + }, + }; +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts b/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts index d6c721ab..b08089bf 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Selection.ts @@ -28,6 +28,8 @@ export interface SelectionSyncPorts { selectSidebar: (chainageM: number | null) => void; /** 배수유역도 세부유역 강조(누가거리 기준). */ selectBasin: (chainageM: number | null) => void; + /** 배수유역도 계획선 위 측점 마킹(누가거리 기준). 유역 없는 구조물도 위치를 보여 준다. */ + markStation?: (chainageM: number | null) => void; } export interface SelectionSync { @@ -55,6 +57,8 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { function syncBasinHighlight(stationId: string | null): void { const station = irregularOf(stationId); ports.selectBasin(station && isPipeStation(station) ? station.chainage_m : null); + // 배수유역도 측점 마킹 — 유역이 없는 구조물(대피로 등)도 위치는 표시한다(2026-08-05). + ports.markStation?.(station ? station.chainage_m : null); } return { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 744004df..edecd06c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -546,10 +546,13 @@ left: 0; display: inline-flex; align-items: center; + /* 행제목이 좌측 편집 버튼 층에 걸린다 — 글자를 우측으로 맞추고 좌측 여백을 더 민다 + (2026-08-05 사용자 지시). */ + justify-content: flex-end; box-sizing: border-box; width: var(--b05-table-label-width, 60px); height: 100%; - padding-inline: var(--spacing-4); + padding-inline: var(--spacing-16) var(--spacing-4); border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet)); background: var(--color-surface-raised); color: var(--color-text); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css index c118133d..70d7d038 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style_MassHaul.css @@ -36,7 +36,8 @@ position: absolute; right: 6px; color: var(--color-text-secondary); - font-size: 10px; + /* 10px는 작아 안 읽힌다 — 종단도·유토곡선 공통으로 키운다(2026-08-05 사용자 지시). */ + font-size: 13px; white-space: nowrap; transform: translateY(-50%); } @@ -118,11 +119,13 @@ } /* 2차 슬라이드 손잡이 — 다른 패널들과 **같은 표준 삼각형 손잡이**를 그대로 쓴다 - (2026-08-04 사용자 지시). 패널 본문 바닥 중앙에 붙고, 오버레이(z-index 5) 위에 뜬다. */ + (2026-08-04 사용자 지시). 패널 본문 바닥에 붙고, 오버레이(z-index 5) 위에 뜬다. + 접혔을 때 테이블 손잡이와 **일렬**로 놓이도록 중앙에서 우측으로 비켜 세운다 + (2026-08-05 사용자 지시 — 좌: 테이블, 우: 유토곡선). */ .b05-profile__masshaul-handle { position: absolute; bottom: 0; - left: 50%; + left: calc(50% + 44px); z-index: 6; transform: translateX(-50%); } @@ -134,3 +137,48 @@ transform: none; margin: 0; } + +/* ── 테이블 오버레이 서브패널 (2026-08-05 사용자 지시) ────────────────────── + 유토곡선과 같은 뼈대. 유토곡선이 열려 있으면 그 위에 쌓인다(bottom은 JS가 조절). + 위→아래 순서: 종단도 → 테이블 → 유토곡선. */ +.b05-profile__table-overlay { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 5; + display: flex; + flex-direction: column; + height: var(--b05-table-height, 300px); + border-top: 1px solid var(--color-border); + background: var(--color-surface-raised); + box-shadow: 0 -4px 12px rgb(0 0 0 / 18%); +} + +.b05-profile__table-overlay > .ui-resizer--vertical { + top: -3px; +} + +/* 테이블 가로 스크롤러 — 종단 스크롤러와 scrollLeft 동기화(JS). */ +.b05-profile__table-scroll { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow-x: scroll; + overflow-y: hidden; +} + +/* 테이블 손잡이 — 유토곡선 손잡이 왼쪽에 일렬 배치. */ +.b05-profile__table-handle { + position: absolute; + bottom: 0; + left: calc(50% - 44px); + z-index: 6; + transform: translateX(-50%); +} + +.b05-profile__table-handle .ui-workflow-overlay__handle { + position: static; + transform: none; + margin: 0; +} diff --git a/config/config_frontend.ts b/config/config_frontend.ts index e6fefe39..48de7323 100644 --- a/config/config_frontend.ts +++ b/config/config_frontend.ts @@ -123,3 +123,37 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [ * -------------------------------------------------------------------------- */ export const THEME_STORAGE_KEY = "frd_theme"; export const LANG_STORAGE_KEY = "frd_lang"; + +/* ----------------------------------------------------------------------------- + * 6. B05 구조물 옵션 (드롭다운 목록·기본값) + * 정본은 config/config_system.py의 STRUCTURE_* / PIPE_* 상수 — 값을 바꿀 때는 + * 양쪽을 함께 고친다(2026-08-05 사용자 확정. 계산은 백엔드, 표시는 프론트). + * -------------------------------------------------------------------------- */ +export const STRUCTURE_TYPES = ["배관", "기성막이", "대피로", "기타"] as const; +export type StructureType = (typeof STRUCTURE_TYPES)[number]; +export const STRUCTURE_DEFAULT_TYPE: StructureType = "배관"; + +/** 관종별 직경(mm) 목록. */ +export const PIPE_DIAMETERS_MM: Record = { + 이중벽관: [150, 200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500], + 삼중벽관: [200, 250, 300, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1500], + 파형강관: [ + 150, 200, 250, 300, 350, 400, 450, 500, 600, 700, 800, 900, 1000, 1200, 1350, 1500, 1650, 1800, + 2000, + ], +} as const; +export const PIPE_TYPES = Object.keys(PIPE_DIAMETERS_MM); +export const PIPE_DEFAULT_TYPE = "파형강관"; +/** 자동 지정 기본 관경 — 별표2 (나) 예외 하한 800mm. 유효직경이 더 크면 바로 위 규격. */ +export const PIPE_DEFAULT_DIAMETER_MM = 800; + +export const ESCAPE_ROUTE_WIDTHS_M = [1.5, 2.0, 2.5, 3.0] as const; +export const ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0; +export const STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물"; + +/** 유효직경(mm) 이상인 가장 작은 규격을 고른다. 목록을 넘으면 최대 규격. */ +export function pickPipeDiameter(pipeType: string, effectiveDiameterMm: number | null): number { + const sizes = PIPE_DIAMETERS_MM[pipeType] ?? PIPE_DIAMETERS_MM[PIPE_DEFAULT_TYPE]; + const need = Math.max(PIPE_DEFAULT_DIAMETER_MM, effectiveDiameterMm ?? 0); + return sizes.find((size) => size >= need) ?? sizes[sizes.length - 1]; +}