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