From c141ad4ef8f2999d53a5420fc27b0a2103e3b380 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 23:52:17 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=EC=9E=91=EC=9D=84=20=EC=A2=85=EB=8B=A8=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=B8=A1=EC=A0=90=EC=84=A0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블에 얹던 구조물 세로선을 걷어냈다. 위치는 그래프 측점선이 이미 보여 주므로 같은 것을 두 번 그리는 셈이었다. 테이블에는 우클릭 메뉴만 남는다 (가까운 구조물이 있으면 삭제, 없으면 배관 추가). - 드래그는 종단 그래프의 구조물 측점선에서 한다. createLongitudinalProfile에 onDragStation을 추가하고 kind === "irregular" 측점선에만 붙였다(좌클릭 전용). - 좌측 구조물 폼에서 "배관" 항목을 고치거나 지우면 그래프와 배수유역도가 함께 갱신된다. DrainagePanel.setPipeChainages() 신설. reconcilePipes()가 목록이 같으면 null을 돌려 관↔구조물 순환을 끊는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B05_wf2_Route_UI_Drainage_Panel.ts | 10 ++ .../B05_wf2_Route_UI_Drainage_Parts.ts | 21 +++- B05_wf2_Route/B05_wf2_Route_UI_Page.ts | 5 + .../B05_wf2_Route_UI_Profile_Panel.ts | 11 +- .../B05_wf2_Route_UI_Profile_Structures.ts | 106 +++++------------- .../B05_wf2_Route_UI_Profile_Table.ts | 8 +- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 49 +------- .../B06_wf3_ProfileCross_UI_Longitudinal.ts | 57 ++++++++++ .../B06_wf3_ProfileCross_UI_Style_Cross.css | 6 + 9 files changed, 137 insertions(+), 136 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 9ab7353a..5a688386 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -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) => 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); }, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts index 0694adfa..b4e2982d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts @@ -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, + chainages: ReadonlyArray, +): 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" }; + }); +} diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index a4ee582a..c4e122e2 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -408,6 +408,11 @@ export async function renderB05Route(root: HTMLElement): Promise { 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 { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index dc62e210..ef659648 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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와 같은 눈금·불투명 배경으로 값 누출 차단). diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts index e0336a28..b12261d5 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Structures.ts @@ -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; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index cec12c87..9e0f5e2c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -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; } @@ -541,9 +541,9 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { ), ); } - // 구조물 배치 라인 — 값 열 오버레이 위에 얹어 언제든 잡을 수 있게 한다. + // 구조물 우클릭 메뉴 — 세로선은 그래프가 그리므로 여기서는 메뉴만 붙인다. if (options.structureLines) { - mountStructureLines(table, { + mountStructureMenu(table, { ...options.structureLines, stations: options.irregularStations ?? [], x, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 8d3fa43a..e0db0888 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts index c3dbacf8..696706ef 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts @@ -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, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css index 2eb55512..f0d4b01c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style_Cross.css @@ -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; }