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