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:
2026-08-18 19:38:52 +09:00
co-authored by Claude Opus 5
parent 4b504f2e2c
commit 99c94777af
14 changed files with 216 additions and 102 deletions
+2
View File
@@ -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;
+8 -13
View File
@@ -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,
+20 -24
View File
@@ -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"),
);
+5 -1
View File
@@ -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: [
+7 -3
View File
@@ -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);
// 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은
+14 -11
View File
@@ -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");
+11 -13
View File
@@ -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);
+30
View File
@@ -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;
}
+47 -11
View File
@@ -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`;
+3 -3
View File
@@ -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: [
"추천은 유량(유효직경) 근거만 반영합니다 — 계곡 횡단경사·하천 차수는 현장 확인.",