refactor(B05): 구조물 조작을 종단 그래프 측점선으로 이동

- 테이블에 얹던 구조물 세로선을 걷어냈다. 위치는 그래프 측점선이 이미 보여 주므로
  같은 것을 두 번 그리는 셈이었다. 테이블에는 우클릭 메뉴만 남는다
  (가까운 구조물이 있으면 삭제, 없으면 배관 추가).
- 드래그는 종단 그래프의 구조물 측점선에서 한다. createLongitudinalProfile에
  onDragStation을 추가하고 kind === "irregular" 측점선에만 붙였다(좌클릭 전용).
- 좌측 구조물 폼에서 "배관" 항목을 고치거나 지우면 그래프와 배수유역도가 함께
  갱신된다. DrainagePanel.setPipeChainages() 신설.
  reconcilePipes()가 목록이 같으면 null을 돌려 관↔구조물 순환을 끊는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:52:17 +09:00
co-authored by Claude Opus 5
parent 6147a9a2fd
commit c141ad4ef8
9 changed files with 137 additions and 136 deletions
@@ -45,6 +45,7 @@ import {
MIN_PANEL_WIDTH,
renderBasinRows,
pointInRing,
reconcilePipes,
WIDTH_KEY,
type DrainageLayer,
} from "./B05_wf2_Route_UI_Drainage_Parts";
@@ -71,6 +72,9 @@ export interface DrainagePanel {
pipeChainages: () => number[];
/** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */
movePipe: (fromChainage: number, toChainage: number) => void;
/** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우).
* 현재 목록과 같으면 아무 일도 하지 않는다 — 되먹임 고리를 끊는 지점이다. */
setPipeChainages: (chainages: ReadonlyArray<number>) => void;
/** 종단 테이블 우클릭으로 배관을 넣거나 지울 때. */
addPipe: (chainageM: number) => void;
removePipe: (chainageM: number) => void;
@@ -644,6 +648,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
if (index < 0) return;
pipeEditor.moveTo(index, toChainage);
},
setPipeChainages(chainages) {
const next = reconcilePipes(pipeEditor.pipes(), chainages);
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
pipeEditor.setPipes(next);
void analyze();
},
addPipe(chainageM) {
pipeEditor.addAtChainage(chainageM);
},
@@ -14,7 +14,7 @@ import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"
import { normalizeStrength, rampColor } from "../B04_wf1_Surface/B04_wf1_Surface_UI_FlowRamp";
import type { MapContextMenu } from "@ui/ui_template_context_menu";
import type { DetailBasin } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
import type { PipeEditor } from "./B05_wf2_Route_UI_Drainage_Pipes";
import type { PipeEditor, PipePoint } from "./B05_wf2_Route_UI_Drainage_Pipes";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -306,3 +306,22 @@ export function mountDrainageToggles(
L("B05_Drainage_Layer_Upstream_Tip"),
);
}
/** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다.
* 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */
export function reconcilePipes(
before: ReadonlyArray<PipePoint>,
chainages: ReadonlyArray<number>,
): PipePoint[] | null {
const next = [...chainages].map((value) => Math.round(value * 100) / 100).sort((a, b) => a - b);
const current = before
.map((pipe) => Math.round(pipe.chainage_m * 100) / 100)
.sort((a, b) => a - b);
if (next.length === current.length && next.every((value, index) => value === current[index])) {
return null;
}
return next.map((chainage) => {
const matched = before.find((pipe) => Math.abs(pipe.chainage_m - chainage) < 0.51);
return { chainage_m: chainage, reason: matched?.reason ?? "confirmed" };
});
}
+5
View File
@@ -408,6 +408,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
irregularStations = stations;
if (currentSectionDetail) renderStationLines(currentSectionDetail);
profilePanel.setIrregularStations(stations);
// 좌측 구조물 폼에서 "배관" 항목을 고쳤거나 지웠으면 배수유역도까지 따라가야 한다.
// 관 목록이 그대로면 배수유역도가 아무 일도 하지 않으므로 되먹임 고리는 여기서 끊긴다.
profilePanel.drainage.setPipeChainages(
stations.filter((entry) => entry.origin === "pipe").map((entry) => entry.chainage_m),
);
}
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
@@ -457,14 +457,12 @@ export function createRouteProfilePanel(
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
// 구조물 배치 라인 — 끌어서 옮기고 우클릭으로 배관을 넣거나 지운다.
// 구조물 우클릭 메뉴 — 배관 추가 / 구조물 삭제. 이동은 그래프 측점선에서 한다.
structureLines: {
chainageAt: chainageInverter(longitudinal, width, layout.originOffset),
maxChainageM: maxChainageOf(longitudinal),
onMove: (from, to, station) => callbacks?.onStructureMove?.(from, to, station),
onRemove: (station) => callbacks?.onStructureRemove?.(station),
onAddPipe: (chainage) => callbacks?.onPipeAdd?.(chainage),
onSelect: (station) => callbacks?.onIrregularSelect?.(station),
},
})
: null;
@@ -504,6 +502,13 @@ export function createRouteProfilePanel(
yAxis = axis;
},
stationDisplay.station,
// 구조물 측점선을 끌어 옮긴다 — 배관이면 관 지점 정본을 거쳐 세부유역까지 다시 나뉜다.
(stationId, toChainage) => {
const target = irregularStations.find(
(entry) => irregularStationId(entry.id) === stationId,
);
if (target) callbacks?.onStructureMove?.(target.chainage_m, toChainage, target);
},
),
);
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
@@ -1,21 +1,20 @@
/* =============================================================================
* 종단 테이블 구조물 배치 라인 (B05)
*
* 구조물 측점을 테이블 위에 세로선으로 얹고, 그 선을 끌어 위치를 옮긴다. 우클릭하면 그 자리에
* 배관을 넣거나 선을 지운다.
* 테이블 위 우클릭 메뉴만 담당한다. 세로선은 **그리지 않는다** — 구조물 위치는 그래프의 측점선이
* 이미 보여 주므로 테이블에까지 얹으면 같은 것을 두 번 그리는 셈이다(2026-08-01 사용자 지시).
* 위치를 옮기는 것도 그래프 측점선에서 한다.
*
* 배관 선은 배수유역도의 관 매설 지점이 투영된 것이다(`origin: "pipe"`). 그래서 여기서 옮기면
* 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도 함께 다시 나뉜다 — 값을 두 곳에 따로
* 쌓지 않기 위해서다(2026-08-01 사용자 지시).
* 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관 추가를 띄운다. 배관은 배수유역도의 관 지점이
* 투영된 것이라(`origin: "pipe"`), 지우면 관 지점 정본(`pipe_points.json`)이 바뀌고 세부유역도
* 함께 다시 나뉜다 — 값을 두 곳에 따로 쌓지 않기 위해서다.
* ========================================================================== */
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { irregularLabel, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
import type { IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
/** 선을 잡았다고 볼 좌우 여유(px). 선 자체는 얇아 그대로는 집기 어렵다. */
const GRAB_SLACK_PX = 6;
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
const DRAG_SLOP_PX = 3;
export interface StructureLineOptions {
/** 테이블에 얹을 구조물 측점 목록. */
@@ -26,103 +25,50 @@ export interface StructureLineOptions {
chainageAt: (px: number) => number;
/** 노선 총 연장(m). 이 범위를 벗어난 자리로는 옮기지 않는다. */
maxChainageM: number;
/** 선을 옮겼을 때. 옛 누가거리와 새 누가거리를 넘긴다. */
onMove: (fromChainageM: number, toChainageM: number, station: IrregularStation) => void;
/** 선을 지울 때. */
onRemove: (station: IrregularStation) => void;
/** 빈 자리에서 우클릭해 배관을 넣을 때. */
onAddPipe: (chainageM: number) => void;
/** 선을 눌렀을 때(끌지 않음) — 값 열 오버레이 선택과 맞춘다. */
onSelect?: (station: IrregularStation) => void;
}
/**
* 테이블 요소 위에 구조물 라인 레이어를 얹는다. 테이블은 매 그리기마다 새로 만들어지므로
* 이 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
* 테이블 요소에 우클릭 메뉴를 붙인다. 테이블은 매 그리기마다 새로 만들어지므로 이 함수도
* 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다.
*/
export function mountStructureLines(table: HTMLElement, options: StructureLineOptions): void {
const layer = document.createElement("div");
layer.className = "b05-profile-table__structures";
export function mountStructureMenu(table: HTMLElement, options: StructureLineOptions): void {
const menu = createMapContextMenu("b05-profile-table");
/** 끌고 있는 선. null이면 잡은 것이 없다. */
let dragging: { station: IrregularStation; element: HTMLElement; startX: number } | null = null;
let moved = false;
function clamp(chainageM: number): number {
return Math.min(Math.max(chainageM, 0), options.maxChainageM);
}
options.stations.forEach((station) => {
const line = document.createElement("div");
line.className =
"b05-profile-table__structure" + (station.origin === "pipe" ? " is-pipe" : " is-user");
line.style.left = `${options.x(station.chainage_m)}px`;
line.title = `${irregularLabel(station)} · ${station.structure || "구조물"}`;
const label = document.createElement("span");
label.className = "b05-profile-table__structure-label";
label.textContent = station.structure || "구조물";
line.append(label);
line.addEventListener("pointerdown", (event) => {
// 이동은 좌클릭 전용 — 우클릭은 메뉴다(2026-08-01 사용자 지시).
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
menu.close();
dragging = { station, element: line, startX: event.clientX };
moved = false;
line.setPointerCapture(event.pointerId);
});
line.addEventListener("pointermove", (event) => {
if (!dragging || dragging.station.id !== station.id) return;
if (Math.abs(event.clientX - dragging.startX) > DRAG_SLOP_PX) moved = true;
const rect = table.getBoundingClientRect();
line.style.left = `${options.x(clamp(options.chainageAt(event.clientX - rect.left)))}px`;
});
line.addEventListener("pointerup", (event) => {
if (!dragging || dragging.station.id !== station.id) return;
const rect = table.getBoundingClientRect();
const next = clamp(options.chainageAt(event.clientX - rect.left));
dragging = null;
if (!moved) {
// 끌지 않고 눌렀다 뗐으면 고르기다 — 원래 자리로 되돌린다.
line.style.left = `${options.x(station.chainage_m)}px`;
options.onSelect?.(station);
return;
}
options.onMove(station.chainage_m, Number(next.toFixed(2)), station);
});
line.addEventListener("contextmenu", (event) => {
event.preventDefault();
event.stopPropagation();
const rect = table.getBoundingClientRect();
menu.open(event.clientX - rect.left, event.clientY - rect.top, [
[station.origin === "pipe" ? "배관 삭제" : "구조물 삭제", () => options.onRemove(station)],
]);
});
layer.append(line);
});
// 빈 자리 우클릭 — 그 누가거리에 배관을 넣는다.
// 우클릭 — 가까운 구조물이 있으면 삭제, 없으면 그 자리에 배관을 넣는다.
table.addEventListener("contextmenu", (event) => {
if (menu.contains(event.target)) {
event.preventDefault();
return;
}
const rect = table.getBoundingClientRect();
const chainage = clamp(options.chainageAt(event.clientX - rect.left));
const localX = event.clientX - rect.left;
const chainage = clamp(options.chainageAt(localX));
// 선을 그리지 않으므로 화면 거리로 가장 가까운 구조물을 찾는다.
const near = options.stations
.map((station) => ({ station, distance: Math.abs(options.x(station.chainage_m) - localX) }))
.filter((entry) => entry.distance <= GRAB_SLACK_PX)
.sort((left, right) => left.distance - right.distance)[0];
event.preventDefault();
menu.open(event.clientX - rect.left, event.clientY - rect.top, [
["배관 추가", () => options.onAddPipe(Number(chainage.toFixed(2)))],
menu.open(localX, event.clientY - rect.top, [
near
? [
near.station.origin === "pipe" ? "배관 삭제" : "구조물 삭제",
() => options.onRemove(near.station),
]
: ["배관 추가", () => options.onAddPipe(Number(chainage.toFixed(2)))],
]);
});
table.addEventListener("pointerdown", (event) => {
if (!menu.contains(event.target)) menu.close();
});
table.append(layer, menu.element);
table.append(menu.element);
}
/** 선을 잡았다고 볼 여유 — 스타일에서 선 폭을 정할 때 함께 쓴다. */
export const STRUCTURE_GRAB_SLACK_PX = GRAB_SLACK_PX;
@@ -20,7 +20,7 @@ import type {
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { irregularStationId, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
import {
mountStructureLines,
mountStructureMenu,
type StructureLineOptions,
} from "./B05_wf2_Route_UI_Profile_Structures";
@@ -46,7 +46,7 @@ export interface ProfileTableOptions {
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
/** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */
onAdjustStation?: (chainageM: number, deltaM: number) => void;
/** 구조물 배치 라인(끌어 이동·우클릭 추가/삭제). 없으면 라인을 얹지 않는다. */
/** 구조물 우클릭 메뉴(배관 추가·구조물 삭제). 없으면 메뉴를 붙이지 않는다. */
structureLines?: Omit<StructureLineOptions, "stations" | "x">;
}
@@ -541,9 +541,9 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
),
);
}
// 구조물 배치 라인 — 값 열 오버레이 위에 얹어 언제든 잡을 수 있게 한다.
// 구조물 우클릭 메뉴 — 세로선은 그래프가 그리므로 여기서는 메뉴만 붙인다.
if (options.structureLines) {
mountStructureLines(table, {
mountStructureMenu(table, {
...options.structureLines,
stations: options.irregularStations ?? [],
x,
+1 -48
View File
@@ -685,54 +685,7 @@
/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ──────────────
세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */
/* ── 구조물 배치 라인 (끌어 이동 · 우클릭 추가/삭제) ─────────────────────── */
.b05-profile-table__structures {
position: absolute;
z-index: 5;
inset: 0;
/* 레이어 자체는 클릭을 흘려보내고, 선만 받는다 — 값 셀 조작을 막지 않는다. */
pointer-events: none;
}
.b05-profile-table__structure {
position: absolute;
top: 0;
bottom: 0;
width: 13px;
/* 선은 얇게 보이되 집는 폭은 넉넉히 — 가운데 2px만 색을 칠한다. */
margin-left: -6px;
background: linear-gradient(
to right,
transparent 5px,
var(--b05-structure-color) 5px,
var(--b05-structure-color) 8px,
transparent 8px
);
cursor: ew-resize;
pointer-events: auto;
touch-action: none;
}
.b05-profile-table__structure.is-pipe {
--b05-structure-color: var(--map-pipe-user, rgb(22 163 74));
}
.b05-profile-table__structure.is-user {
--b05-structure-color: var(--color-royal-amethyst, rgb(109 40 217));
}
.b05-profile-table__structure-label {
position: absolute;
top: 2px;
left: 10px;
padding: 0 3px;
border-radius: 2px;
background: var(--color-surface-raised);
color: var(--b05-structure-color);
font-size: 10px;
white-space: nowrap;
}
/* ── 구조물 우클릭 메뉴 (배관 추가 · 구조물 삭제) ────────────────────────── */
.b05-profile-table__context-menu {
position: absolute;
@@ -83,6 +83,53 @@ function appendCutFillBands(
flush(samples.length - 1);
}
/** 화면 x(px)를 누가거리로 되돌린다. `x()`와 같은 선형 매핑의 역함수다. */
function inverseOf(x: (chainageM: number) => number, maxChainageM: number): (px: number) => number {
const origin = x(0);
const span = x(maxChainageM) - origin;
return (px: number) => (span > 0 ? ((px - origin) / span) * maxChainageM : 0);
}
/** 구조물 측점선 하나에 끌기 동작을 붙인다. 끌지 않고 뗐으면 아무 일도 하지 않는다(선택은 click). */
function attachStationDrag(
marker: SVGElement,
svg: SVGElement,
station: { station_id: string; chainage_m: number },
x: (chainageM: number) => number,
xInverse: (px: number) => number,
onDragStation: (stationId: string, toChainageM: number) => void,
): void {
marker.classList.add("b06-chart__station--draggable");
let startX: number | null = null;
let moved = false;
marker.addEventListener("pointerdown", (event) => {
// 이동은 좌클릭 전용 — 우클릭은 메뉴다.
if (event.button !== 0) return;
event.preventDefault();
startX = event.clientX;
moved = false;
marker.setPointerCapture(event.pointerId);
});
marker.addEventListener("pointermove", (event) => {
if (startX === null) return;
if (Math.abs(event.clientX - startX) > STATION_DRAG_SLOP_PX) moved = true;
if (!moved) return;
const local = event.clientX - svg.getBoundingClientRect().left;
marker.setAttribute("transform", `translate(${local - x(station.chainage_m)}, 0)`);
});
marker.addEventListener("pointerup", (event) => {
if (startX === null) return;
startX = null;
marker.removeAttribute("transform");
if (!moved) return;
const local = event.clientX - svg.getBoundingClientRect().left;
onDragStation(station.station_id, Number(xInverse(local).toFixed(2)));
});
}
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
const STATION_DRAG_SLOP_PX = 3;
export function createLongitudinalProfile(
data: LongitudinalSection,
selectedStationId: string | null,
@@ -106,6 +153,11 @@ export function createLongitudinalProfile(
onYAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void,
/** 이어 공사 시작 측점 오프셋. 측점 라벨의 측점번호에 이만큼 더한다(B05 표시용, 기본 0). */
stationNumberOffset = 0,
/**
* 구조물(비정규) 측점선을 끌어 옮겼을 때(선택). 넘기면 그 측점선만 잡아 끌 수 있게 된다.
* B05가 구조물·배관 위치를 그래프에서 바로 조정하는 데 쓴다(2026-08-01 사용자 지시).
*/
onDragStation?: (stationId: string, toChainageM: number) => void,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
@@ -142,6 +194,7 @@ export function createLongitudinalProfile(
: Math.max(rawMax - rawMin, 1);
const x = (chainage: number) =>
LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth;
const xInverse = inverseOf(x, maxChainage);
const y = (elevation: number) =>
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
@@ -210,6 +263,10 @@ export function createLongitudinalProfile(
marker.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
});
// 구조물 측점선만 끌 수 있다 — 규칙 측점은 격자라 옮길 대상이 아니다.
if (onDragStation && station.kind === "irregular") {
attachStationDrag(marker, svg, station, x, xInverse, onDragStation);
}
marker.append(
svgElement("line", {
x1: stationX,
@@ -284,6 +284,12 @@
cursor: pointer;
}
/* 구조물 측점선은 끌어 옮길 수 있다 — 커서로 그 사실을 알린다(2026-08-01 사용자 지시). */
.b06-chart__station--draggable {
cursor: ew-resize;
touch-action: none;
}
.b06-chart__station-line {
stroke-width: 1.2;
}