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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user