feat(B05): 구조물 드롭다운·관경 자동지정·우클릭/마킹·하단 패널 개편 (계획서 Phase 3~5)

- 구조물 배치 폼: 종류 드롭다운(배관/기성막이/대피로/기타) + 관종·직경/폭/이름
  하위 옵션. 목록·기본값은 config_frontend(정본 config_system.py 미러) 관리
- 관경 자동 지정: 기본 D800, 배수 유효직경 초과 시 바로 위 규격.
  수동 변경(userSized)은 재계산에도 유지. 소구경 리스트 유지
- 종단·배수유역도 우클릭 메뉴에 기성막이/대피로/기타 추가 항목
- 유토곡선·테이블 영역 브라우저 기본 우클릭 메뉴 차단
- 측점 선택 시 배수유역도 계획선 위 다이아몬드 마킹(유역 없는 구조물 포함)
- 3D 선택 마킹: 수직 핀(기둥+역원뿔, depthTest off) — 원형 표기 지양
- 테이블을 유토곡선과 같은 바닥 고정 오버레이 서브패널로 개편.
  접힘 손잡이 일렬(좌 테이블/우 유토곡선), 순서 종단도-테이블-유토곡선
- 테이블 행제목 우측 정렬+좌측 여백, 종단·유토곡선 Y축 눈금 10-13px

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 18:41:55 +09:00
co-authored by Claude Fable 5
parent fa6953ce6d
commit 3d5e8988c0
14 changed files with 626 additions and 88 deletions
@@ -88,14 +88,21 @@ export interface DrainagePanel {
savePipes: () => Promise<number>;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
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);
},
@@ -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),
])
: []),
]);
});
}
@@ -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;
@@ -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);
}
@@ -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<number>) => void;
/** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다.
* effective_diameter_mm(배수 유효직경)를 주면 그 이상 첫 규격을 자동 지정한다(기본 D800). */
setPipeStations: (
pipes: ReadonlyArray<number | { chainage_m: number; effective_diameter_mm?: number | null }>,
) => 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<string>): 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<IrregularStation> = { 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;
+37 -1
View File
@@ -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<string, THREE.Vector3>();
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);
}
+4 -1
View File
@@ -198,7 +198,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
{
// 관 매설 목록 ↔ 구조물 목록의 "배관" 항목을 한 방향으로 맞춘다.
// 정본은 배수유역도의 관 지점이며, 구조물 목록은 그것을 실체화한 것이다.
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<void> {
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<void> {
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 {
+59 -51
View File
@@ -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 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
@@ -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) => {
@@ -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();
},
};
}
@@ -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 {
+4 -1
View File
@@ -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);
@@ -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;
}
+34
View File
@@ -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<string, readonly number[]> = {
: [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];
}