feat(B05): 종단 계획선 전체 측점 폴리라인 전환 + 직선화·쉬프트·undo/redo 신설

2026-09-02 사용자 지시 9건 반영. 화면 실조작 검증은 다음 세션 몫 (사용자 지시로
코딩까지만 진행).

1. 초기 계획선 = 전체 측점 폴리라인 — `design_ground_following_profile()` 신설.
   모든 측점을 변화점으로 잡고 계획고 = 원지반고, 라운드는 R을 지정한 자리에만
   (`build_curves(only_explicit=)`·`build_alignment(only_explicit_curves=)`).
   basis `ground_polyline`. 관 정착 선형은 폴백으로 내림.
2. 구간 쉬프트(⬆⬇) 삭제.
3. [직선화] 신설 — `B05_Profile_UI_Profile_Straighten.ts`. 두 측점의 라운드에 탄젠트한
   직선으로 대체하고 사이 라운드 삭제. 직선 틸팅 시 가운데 라운드 + 양측 탄젠트 재구성.
4. [쉬프트] 신설 — 직선 구간을 기하에서 되읽어(`detectStraightRun`) 복수 선택,
   최외곽 라운드 중심 기준 상·하 평행이동.
5. 방향키 조작 — 상하 0.1m 계획고, 좌우 0.1m 누가거리(구조물·비정규 측점 한정).
6. undo/redo 신설 — B05·B06 조작 세션 키 묶음 스냅샷(`_Profile_History.ts`).
   버튼은 요약줄 맨 앞(최대 기울기 좌측), 21x17px. Ctrl+Z / Ctrl+Shift+Z.
7. 종단 요약줄의 횡단배수 최소고 표시 삭제(산식·편집 차단은 유지).
8. [편집 되돌리기] 버튼 삭제 — undo/redo로 대체.
9. 지형 구분 기본값 특수지형 — 패널 셀렉트와 백엔드 기본값(스키마·체인 폴백) 일치.

700줄 제한 — 패널을 `_Profile_Panel_Tools` · `_Profile_Preview` 로 분리하고 측점↔구조물
짝짓기를 `_Profile_Structures` 로 이관(842줄 → 696줄).

검증: tsc --noEmit 오류 0, ruff format/check 통과,
pytest tmp/tests/ -q → 366 passed / 14 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 19:30:16 +09:00
co-authored by Claude Opus 5
parent 2e57d29156
commit 3651ad9875
20 changed files with 1133 additions and 245 deletions
+1 -1
View File
@@ -382,7 +382,7 @@ async def run_redesign_chain(
long_sample_interval_m=params.get("long_sample_interval_m"),
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
paved=bool(options.get("paved", False)),
terrain_type=str(options.get("terrain_type") or "normal"),
terrain_type=str(options.get("terrain_type") or "special"),
main_direction=str(options.get("main_direction") or "auto"),
min_curve_radius_m=options.get("min_curve_radius_m"),
max_uphill_grade=options.get("max_uphill_grade"),
+3 -1
View File
@@ -55,7 +55,9 @@ class GradeDesignOptions:
min_tangent_length_m: float
vertical_curve_skip_delta_pct: float
design_speed_kph: int
terrain_type: str = "normal"
# 기본 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이다.
# 잘못된 값이 들어오면 아래 판정 함수들이 "normal" 로 되눌러 법정 상한을 낮게 잡는다.
terrain_type: str = "special"
paved: bool = False
main_direction: str = "auto"
balance_segment_length_m: float | None = None
@@ -187,6 +187,8 @@ def build_curves(
pvi_z: np.ndarray,
policy: AlignmentPolicy,
curve_radii: dict[str, float] | None = None,
*,
only_explicit: bool = False,
) -> tuple[list[dict[str, Any]], list[str]]:
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
@@ -195,6 +197,10 @@ def build_curves(
곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해
좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다).
`only_explicit=True` 면 **사용자가 R을 지정한 변화점에만** 곡선을 넣는다. 전체 측점
폴리라인(2026-09-02 사용자 확정)은 모든 측점이 변화점이라 기본 곡선을 다 넣으면
계획고가 지반고에서 떠 버린다 — 라운드는 [직선화]·틸팅으로 사용자가 만들 때만 생긴다.
"""
overrides = curve_radii or {}
spans = pvi_s[1:] - pvi_s[:-1]
@@ -209,6 +215,8 @@ def build_curves(
key = chainage_key(chainage)
if abs(delta) < 1e-9:
continue
if only_explicit and overrides.get(key) is None:
continue
# 법정 다-(3)-(다)는 "종단곡선을 두지 않을 수 있다"는 허용 조항이다.
# 실무 도면은 대수차가 작아도 변화점을 원곡선으로 처리하므로, 기본은 곡선을
# 삽입하고 생략 가능 구간이라는 표시만 남긴다(config로 실제 생략 전환 가능).
@@ -370,8 +378,13 @@ def build_alignment(
stations: list[dict[str, Any]],
policy: AlignmentPolicy,
edits: dict[str, Any] | None = None,
only_explicit_curves: bool = False,
) -> dict[str, Any]:
"""자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다."""
"""자동 변화점 + 사용자 편집으로 계획선 선형 전체를 파생한다.
`only_explicit_curves=True` 는 전체 측점 폴리라인용 — 사용자가 R을 지정한 변화점에만
종단곡선을 넣는다(`build_curves(only_explicit=...)` 와 같은 뜻).
"""
edits = edits or {}
station_offsets = {
str(key): float(value)
@@ -385,7 +398,9 @@ def build_alignment(
}
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii)
curves, warnings = build_curves(
pvi_s, pvi_z, policy, curve_radii, only_explicit=only_explicit_curves
)
segments = _segments(pvi_s, pvi_z)
# 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 합쳐서 평가한다. 격자만 쓰면
# 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 모서리를
@@ -452,6 +467,9 @@ def build_alignment(
return {
"schema_version": ALIGNMENT_SCHEMA_VERSION,
"policy": policy.as_dict(),
# 화면 사본(`B05_Profile_UI_Profile_Alignment.ts`)이 같은 규칙으로 다시 그리려면
# 이 값이 저장본에 남아 있어야 한다(2026-09-02 전체 측점 폴리라인).
"only_explicit_curves": bool(only_explicit_curves),
"base_pvi": [
{"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
for s, z in zip(base_s, base_z)
@@ -5,8 +5,12 @@
[[B05_Profile_Engine_Grade_Alignment]] 의 기하 파생을 묶어
`design_profiles` 배열에 넣을 계획선 한 벌을 만든다.
진입점이 있다.
- `design_pipe_anchored_profile()` : **1차(기본)**. 배수유역도가 산출한 배관 배치 측점을
진입점이 있다.
- `design_ground_following_profile()` : **1차(기본, 2026-09-02 사용자 확정)**. 모든 측점을
변화점으로 삼아 계획고를 **원지반고 그대로** 두는 폴리라인이다. 종단곡선은 사용자가
[직선화]·틸팅으로 만들 때만 생긴다. 관 측점만 정착하던 옛 방식은 관 사이가 길면 골을
성토로, 마루를 절토로 메워 최대 성토 +9.36m(용화 실측)가 남았다.
- `design_pipe_anchored_profile()` : 관 정착 선형(옛 1차). 배수유역도가 산출한 배관 배치 측점을
변화점으로 삼아, 계획선이 각 배관 자리에서 지면선과 만나도록(계획고 = 지반고) 시작점 →
배관1 → 배관2 → … → 종점을 직선으로 잇고 기본 R을 얹는다(2026-08-03 사용자 확정).
배관(암거)은 계곡 유하부라 계획선이 그 지점에 붙어야 복토·유입 조건이 성립한다.
@@ -49,6 +53,8 @@ ALIGNMENT_PROFILE_ID = "design_grade_line"
# 폴백으로 떨어진 것을 저장본에서 확인하지 못했다(2026-09-02).
PIPE_ANCHORED_BASIS = "pipe_anchored"
ALIGNMENT_BASIS = "station_alignment"
# 전체 측점 폴리라인(2026-09-02 사용자 확정) — 계획고 = 지반고.
GROUND_POLYLINE_BASIS = "ground_polyline"
def infer_station_interval(stations: list[dict[str, Any]]) -> float:
@@ -138,6 +144,83 @@ def _clearance_at(
return best
def design_ground_following_profile(
longitudinal: dict[str, Any],
options: GradeDesignOptions,
*,
station_interval_m: float | None = None,
edits: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""전체 측점을 변화점으로 삼고 계획고를 원지반고에 맞추는 1차 계획선.
규칙(2026-09-02 사용자 확정):
- **모든 측점이 변화점**이고 그 표고는 그 자리 지반고 그대로다. 절·성토가 거의 0이다.
- **종단곡선(라운드)은 자동으로 넣지 않는다.** 사용자가 [직선화]로 만든 직선을
틸팅할 때 그 변화점에만 R이 지정되고, 그때 곡선이 생긴다
(`build_alignment(only_explicit_curves=True)`).
- 기울기는 지형 그대로라 법정 상한을 넘길 수 있다 — 막지 않고 경고만 남긴다
(기존 정책과 같다).
시·종점 오프셋(`start/end_elevation_offset_m`)은 그대로 반영한다. 기본값 0이라
평소에는 양끝도 지반고다.
"""
options.validate()
chainage, ground = ground_profile(longitudinal)
total = float(chainage[-1])
if total <= 0:
raise ValueError("종단 연장이 0이어서 계획선을 만들 수 없습니다.")
stations = list(longitudinal.get("stations") or [])
interval = float(station_interval_m or 0) or infer_station_interval(stations)
policy = AlignmentPolicy.from_config(
station_interval_m=interval,
max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved,
)
warnings = list(options.warnings)
# 측점 목록이 곧 변화점 목록이다. 측점이 비었거나 양끝이 빠져 있으면 종단 격자를 쓴다.
node_s = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None]
node_s = sorted({round(value, 3) for value in node_s if 0.0 <= value <= total})
if len(node_s) < 2:
node_s = [round(float(value), 3) for value in chainage]
if node_s[0] > 0.0:
node_s.insert(0, 0.0)
if node_s[-1] < total:
node_s.append(round(total, 3))
base_s = np.array(node_s, dtype=np.float64)
base_z = np.interp(base_s, chainage, ground)
base_z[0] += options.start_elevation_offset_m
base_z[-1] += options.end_elevation_offset_m
rise = float(base_z[-1] - base_z[0])
direction, note = (
detect_main_direction(ground, rise)
if options.main_direction == "auto"
else (options.main_direction, None)
)
if note:
warnings.append(note)
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
chainage=chainage,
ground=ground,
stations=stations,
policy=policy,
edits=dict(edits or {}),
only_explicit_curves=True,
)
warnings.extend(alignment["warnings"])
balanced = bool(alignment["balance"]["within_tolerance"])
return alignment, _profile_entry(
alignment, options, direction, balanced, warnings, GROUND_POLYLINE_BASIS
)
def design_pipe_anchored_profile(
longitudinal: dict[str, Any],
options: GradeDesignOptions,
@@ -365,6 +448,11 @@ def rebuild_alignment_profile(
base_z = np.array([float(item["elevation_m"]) for item in base], dtype=np.float64)
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
previous = (longitudinal.get("design_profiles") or [{}])[0]
# 전체 측점 폴리라인은 곡선을 자동으로 넣지 않는다 — 재구성도 같은 규칙이어야
# 편집 델타를 지웠을 때 초기 폴리라인으로 정확히 돌아간다(2026-09-02).
only_explicit = str(previous.get("basis") or "") == GROUND_POLYLINE_BASIS
alignment = build_alignment(
base_s=base_s,
base_z=base_z,
@@ -373,8 +461,8 @@ def rebuild_alignment_profile(
stations=list(longitudinal.get("stations") or []),
policy=policy,
edits=edits,
only_explicit_curves=only_explicit,
)
previous = (longitudinal.get("design_profiles") or [{}])[0]
criteria = previous.get("criteria") or {}
options = GradeDesignOptions(
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
+13 -6
View File
@@ -15,6 +15,7 @@ from typing import Any
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, design_grade_line
from B05_Profile.B05_Profile_Engine_Grade_Profile import (
design_alignment_profile,
design_ground_following_profile,
design_pipe_anchored_profile,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import (
@@ -214,18 +215,24 @@ def _append_design_profiles(
) -> dict[str, Any] | None:
"""종단 계획선을 산출해 longitudinal에 붙이고 요약을 반환한다.
1순위는 **배관 정착 선형** 배수유역도의 배관 배치 측점마다 계획선이 지면선과
만나도록 직선으로 기본 R을 는다(2026-08-03 사용자 확정). 배관이 없거나
산출이 불가하면 2순위 기존 지반 추종 직선 분할 선형(`design_alignment_profile`),
그마저 실패하면 균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도
종횡단 생성 자체는 유지한다.
1순위는 **전체 측점 폴리라인**(`design_ground_following_profile`) 모든 측점의
계획고를 원지반고에 맞추 종단곡선은 넣지 는다(2026-09-02 사용자 확정).
실패하면 2순위 배관 정착 선형(`design_pipe_anchored_profile`, 1),
3순위 지반 추종 직선 분할 선형(`design_alignment_profile`), 그마저 실패하면
균형 최적화 계획선으로 폴백하고(편집 불가), 모두 실패해도 종횡단 생성 자체는 유지한다.
"""
longitudinal.setdefault("design_profiles", [])
if grade_options is None:
return None
alignment = None
profile = None
if pipe_chainages:
try:
alignment, profile = design_ground_following_profile(
longitudinal, grade_options, station_interval_m=station_interval_m
)
except (ValueError, KeyError, ArithmeticError):
logger.exception("B05 전체 측점 폴리라인 산출 실패 — 배관 정착 선형으로 대체")
if profile is None and pipe_chainages:
try:
alignment, profile = design_pipe_anchored_profile(
longitudinal,
+2 -1
View File
@@ -91,7 +91,8 @@ class RouteSolveRequest(BaseModel):
long_sample_interval_m: float | None = Field(default=None, gt=0)
# 종단 계획선(계획고) 설계 옵션. 빈 값은 null로 두어 config 기본값을 쓴다.
terrain_type: str = Field(default="normal", description="지형 구분 (normal/special)")
# 기본 특수지형(2026-09-02 사용자 지시) — B05 좌측 패널 기본 선택과 같은 값이다.
terrain_type: str = Field(default="special", description="지형 구분 (normal/special)")
# 설계속도(km/h) — 법정 종단 기준을 정하는 축이다. 임도는 속도를 낼 수 없어 20이
# 기본이며, 간선·산불진화만 30·40을 고를 수 있다(2026-08-19 사용자 확정).
# 미지정(None)이면 종류별 기본 설계속도(grade_to_design_speed)를 쓴다.
+3
View File
@@ -398,6 +398,9 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
],
});
const terrainType = terrainField.select;
// 기본값 특수지형(2026-09-02 사용자 지시) — 임도 대상지는 대개 특수지형이라
// 매번 바꿔야 했다. 저장분이 있으면 `apply()`가 그 값으로 덮는다.
terrainType.value = "special";
// 역기울기(5%) 상한 방향은 서버가 지반 형상에서 자동 판정(main_direction="auto")하므로
// 수동 선택 UI는 두지 않는다. 노선 균형 구역 길이도 자동 산출 기본값(전체 1구역)에 맡긴다.
// 횡단배수 최소고 강제(2026-09-01 사용자 지시) — 기본 해제. 켜면 자동 계획선이 배관
@@ -112,6 +112,9 @@ export interface AlignmentEdits {
export interface ProfileAlignment {
schema_version: number;
policy: AlignmentPolicy;
/** true R
* (2026-09-02 , `build_alignment(only_explicit_curves=)` ). */
only_explicit_curves?: boolean;
base_pvi: AlignmentNode[];
edits: AlignmentEdits;
pvi: AlignmentPvi[];
@@ -127,6 +130,8 @@ export interface ProfileAlignment {
/** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */
export interface AlignmentBase {
policy: AlignmentPolicy;
/** 라운드를 사용자가 지정한 변화점에만 넣을지 — 저장본에서 그대로 물려받는다. */
onlyExplicitCurves: boolean;
basePvi: AlignmentNode[];
chainage: number[];
ground: number[];
@@ -162,6 +167,7 @@ function interpolate(xs: number[], ys: number[], value: number): number {
export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase {
return {
policy: alignment.policy,
onlyExplicitCurves: alignment.only_explicit_curves === true,
basePvi: alignment.base_pvi.map((node) => ({ ...node })),
chainage: alignment.samples.map((sample) => sample.chainage_m),
ground: alignment.samples.map((sample) => sample.ground_elevation_m),
@@ -207,6 +213,7 @@ function buildCurves(
policy: AlignmentPolicy,
curveRadii: Record<string, number>,
warnings: string[],
onlyExplicit = false,
): WorkingCurve[] {
const curves: WorkingCurve[] = [];
const skipDelta = policy.curve_skip_delta_pct / 100;
@@ -225,6 +232,8 @@ function buildCurves(
// 기본 L이 없는 옛 저장분만 R 기준으로 되돌아간다.
const requested = curveRadii[key];
const hasRequested = Number.isFinite(requested) && requested > 0;
// 전체 측점 폴리라인 — 사용자가 R을 지정한 변화점에만 라운드가 생긴다.
if (onlyExplicit && !hasRequested) continue;
const fallbackLength = Number.isFinite(policy.default_curve_length_m)
? (policy.default_curve_length_m as number)
: policy.default_curve_radius_m * Math.abs(delta);
@@ -309,7 +318,13 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof
const nodes = resolvePvi(base, edits.station_offsets);
const pviS = nodes.map((node) => node.chainage_m);
const pviZ = nodes.map((node) => node.elevation_m);
const curves = buildCurves(nodes, base.policy, edits.curve_radii, warnings);
const curves = buildCurves(
nodes,
base.policy,
edits.curve_radii,
warnings,
base.onlyExplicitCurves === true,
);
const segments: AlignmentSegment[] = [];
for (let index = 0; index < nodes.length - 1; index += 1) {
@@ -411,6 +426,7 @@ export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): Prof
return {
schema_version: 1,
policy: base.policy,
only_explicit_curves: base.onlyExplicitCurves === true,
base_pvi: base.basePvi,
edits,
pvi: pviRows,
+9 -32
View File
@@ -15,7 +15,7 @@
* ========================================================================== */
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { minCoverWarningText, type MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
import type { MinCoverViolation } from "./B05_Profile_UI_Profile_MinCover";
export interface BalanceBarParams {
/** 표시줄 컨테이너. 그릴 때마다 통째로 갈아 끼운다. */
@@ -30,6 +30,8 @@ export interface BalanceBarParams {
hasIrregularStations: boolean;
/** 저장되지 않은 편집이 있는지. */
dirty: boolean;
/** 요약줄 맨 앞에 놓는 도구줄(되돌리기·직선화·쉬프트·틸팅) — 2026-09-02. */
tools?: HTMLElement;
/** 횡단배수 최소 계획고 위반(2026-08-23) — 배수관·BOX암거 토피 미확보 경고. */
minCoverViolations?: MinCoverViolation[];
/** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */
@@ -38,6 +40,8 @@ export interface BalanceBarParams {
export function renderBalanceBar(params: BalanceBarParams): void {
params.balanceBar.replaceChildren();
// 도구줄은 계획선이 없을 때도 둔다 — 되돌리기는 계획선 밖 조작(구조물 등)도 되돌린다.
if (params.tools) params.balanceBar.append(params.tools);
if (!params.alignment) {
if (params.legacyAlignment) {
const note = document.createElement("span");
@@ -74,10 +78,8 @@ export function renderBalanceBar(params: BalanceBarParams): void {
];
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded}`, "over"]);
// 횡단배수 최소 계획고(2026-08-23 사용자 지시) — 관경·구체높이 + 토피 0.5m를
// 밑도는 측점이 있으면 경고한다. 계획선을 대신 올려 주지는 않는다(사용자 판단).
const coverWarning = minCoverWarningText(params.minCoverViolations ?? []);
if (coverWarning) entries.push(["횡단배수 최소고", coverWarning, "over"]);
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
// `B05_Profile_UI_Profile_Render.ts` 에 그대로 남아 있고 기본 해제다.
const editedCount = Object.keys(alignment.edits.station_offsets).length;
if (editedCount) entries.push(["편집 측점", `${editedCount}`, "edited"]);
entries.forEach(([label, value, tone]) => {
@@ -88,14 +90,6 @@ export function renderBalanceBar(params: BalanceBarParams): void {
item.append(caption, document.createTextNode(value));
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
if (label === "횡단배수 최소고" && params.minCoverViolations?.length) {
item.title = params.minCoverViolations
.map(
(entry) =>
`${entry.chainage_m.toFixed(1)}m ${entry.label}: 계획고 ${entry.planned_m.toFixed(2)} < 최소 ${entry.required_m.toFixed(2)} (부족 ${entry.shortfall_m.toFixed(2)}m)`,
)
.join("\n");
}
if (label === "최대 기울기" && violations.length) {
item.title = violations
.map(
@@ -106,25 +100,8 @@ export function renderBalanceBar(params: BalanceBarParams): void {
}
params.balanceBar.append(item);
});
if (params.edited || params.hasIrregularStations) {
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b05-route-profile__balance-reset";
// 이 버튼이 지우는 것은 **화면에 쌓인 편집 델타**다(계획선 변화점 이동·비정규
// 측점). [저장]을 거치면 그 편집이 정본에 반영되므로 결과적으로 "마지막 저장
// 상태"가 되는 것뿐, 저장 지점으로 되돌아가는 기능이 아니다. 최초 자동 계산
// 상태로의 복귀는 좌측 하단 [초기화](B05·B06 재계산)가 맡는다(2026-08-19 정정).
// 자리도 값 칩들 뒤 — 편집 상태(미저장 배지) 옆이 맥락에 맞다.
reset.textContent = "편집 되돌리기";
reset.title =
"화면에서 수정한 계획선 변화점과 추가한 비정규 측점을 지웁니다.\n" +
"이미 저장한 내용은 정본에 반영돼 있어 그대로 남습니다.\n" +
"최초 자동 계산 상태로 되돌리려면 좌측 하단 [초기화]를 쓰세요.";
reset.addEventListener("click", () => {
params.onResetAll();
});
params.balanceBar.append(reset);
}
// [편집 되돌리기] 버튼은 2026-09-02 사용자 지시로 삭제했다 — 한 단계씩 되돌리는
// undo/redo가 대신하며, 최초 자동 계산 상태 복귀는 좌측 하단 [초기화]가 맡는다.
if (params.dirty) {
const badge = document.createElement("span");
badge.className = "b05-route-profile__balance-item is-unsaved";
+12 -94
View File
@@ -7,19 +7,15 @@
*
* ( , hover ):
* - / : ±step .
* - / : ( , ).
* - : .
*
* ** · ** . X축
* .
* .
* () 2026-09-02
* []+[], undo/redo가 .
*
* ** · ** . X축
* .
* ========================================================================== */
import type {
AlignmentEdits,
AlignmentSegment,
ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment";
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
@@ -173,15 +169,9 @@ export interface EditOverlayOptions {
width: number;
x: (chainageM: number) => number;
step: number;
/** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼(+원복) 버튼을 단다. */
/** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼ 버튼을 단다. */
irregularStations?: Array<{ chainage_m: number }>;
onStation: (chainageM: number, delta: number) => void;
onSegment: (segment: AlignmentSegment, delta: number) => void;
onResetStation: (chainageM: number) => void;
/** 구간 평행이동 편집을 자동 선형으로 원복(양 끝 오프셋 삭제). */
onResetSegment: (segment: AlignmentSegment) => void;
/** 쉬프트 가능 구간 판정 — false면 그 구간의 ⬆⬇를 만들지 않는다(힌지 부족). */
canShift?: (segment: AlignmentSegment) => boolean;
}
function overlayButton(
@@ -235,8 +225,7 @@ function planElevationAtSample(alignment: ProfileAlignment, chainageM: number):
/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */
export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const { alignment, width, x, step, onStation, onSegment, onResetStation, onResetSegment } =
options;
const { alignment, width, x, step, onStation } = options;
const irregular = options.irregularStations ?? [];
const layer = document.createElement("div");
layer.className = "b05-profile-edit";
@@ -281,31 +270,6 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
};
})(),
);
const stationXs = stationPlacements.map((entry) => entry.left);
/** 이미 자리를 잡은 구간 버튼 x — 구간끼리도 겹치지 않게 기억해 둔다. */
const segmentXs: number[] = [];
/**
* , .
* ** **
* ( ).
*
* ·
* (2026-08-17 ). .
*/
function avoidStations(center: number): number {
let placed = center;
for (let guard = 0; guard < 40; guard += 1) {
const blocking = [...stationXs, ...segmentXs]
.filter((px) => Math.abs(px - placed) < BUTTON_CLEARANCE_PX)
.sort((left, right) => right - left)[0];
if (blocking === undefined) break;
placed = blocking + BUTTON_CLEARANCE_PX;
}
segmentXs.push(placed);
return placed;
}
// 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도
// `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
// left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다.
@@ -327,60 +291,14 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
down.style.left = `${left - BUTTON_HALF_PX}px`;
layer.append(up, down);
if (!isEdited) return;
const offset = alignment.edits.station_offsets[chainageKey(chainageM)];
const reset = overlayButton(
repeater,
"is-reset",
"↺",
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
() => onResetStation(chainageM),
);
reset.style.left = `${left - BUTTON_HALF_PX}px`;
layer.append(reset);
// 측점 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — undo/redo 로 대체한다.
void isEdited;
}
stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left));
alignment.segments.forEach((segment) => {
const left = x(segment.from_m);
const right = x(segment.to_m);
if (right - left < 36) return;
// 힌지(안쪽 미틸트 측점 2개)를 못 만드는 구간은 쉬프트 대상이 아니다(2026-08-23).
if (options.canShift && !options.canShift(segment)) return;
const center = avoidStations((left + right) / 2);
const label =
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
// 구간 이동 화살표는 속이 찬 글리프(⬆⬇)를 쓴다(2026-08-04 사용자 지시 — ⇧⇩는 윤곽선뿐이라
// 흐릿했다). ︎(텍스트 표기 선택자)로 이모지 컬러 렌더링을 막아 방향색이 살게 한다.
const up = overlayButton(repeater, "is-segment is-up", "⬆︎", `${label}${step}m 올림`, () =>
onSegment(segment, step),
);
up.style.left = `${center - BUTTON_HALF_PX}px`;
const down = overlayButton(
repeater,
"is-segment is-down",
"⬇︎",
`${label}${step}m 내림`,
() => onSegment(segment, -step),
);
down.style.left = `${center - BUTTON_HALF_PX}px`;
layer.append(up, down);
// 구간 양 끝 중 하나라도 편집됐으면 원복 ↺ 노출(측점 ↺와 동일 스타일·연산).
if (edited.has(chainageKey(segment.from_m)) || edited.has(chainageKey(segment.to_m))) {
const reset = overlayButton(
repeater,
"is-segment is-reset",
"↺",
`${label} — 자동 선형으로 원복`,
() => onResetSegment(segment),
);
reset.style.left = `${center - BUTTON_HALF_PX}px`;
layer.append(reset);
}
});
// 구간 평행이동(⬆⬇)과 구간 원복 ↺ 도 같은 지시로 삭제했다. 구간 단위 조작은
// [직선화]로 만든 직선을 [쉬프트]로 옮기는 흐름이 대신한다.
return layer;
}
@@ -0,0 +1,124 @@
/* =============================================================================
* B05_Profile_UI_Profile_History.ts
* B05 (undo)·(redo) .
*
* (2026-09-02 ): B05의
* ** ** ( · ·
* · ).
* , .
* **B05 **, .
*
* : 패널 ·· ** **.
* (`isLayoutKey`).
* ========================================================================== */
/** 조작값 스냅샷 대상 세션 키 접두어. B05·B06은 한 페이지라 함께 뜬다(CLAUDE.md 5장). */
const DATA_KEY_PREFIXES = ["b05:", "b05-", "b06:", "b06-"];
/** 조작이 아니라 보기 설정인 키 — 스냅샷에서 뺀다. */
function isLayoutKey(key: string): boolean {
return (
key.includes("collapsed") ||
key.includes("width") ||
key.includes("height") ||
key.includes("open") ||
key.includes("visible")
);
}
function isDataKey(key: string): boolean {
return DATA_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)) && !isLayoutKey(key);
}
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
export type HistorySnapshot = Record<string, string>;
function takeSnapshot(): HistorySnapshot {
const snapshot: HistorySnapshot = {};
try {
for (let index = 0; index < sessionStorage.length; index += 1) {
const key = sessionStorage.key(index);
if (!key || !isDataKey(key)) continue;
const value = sessionStorage.getItem(key);
if (value !== null) snapshot[key] = value;
}
} catch {
// 세션 저장소를 못 쓰는 환경에서는 되돌리기만 비활성이 된다.
}
return snapshot;
}
function sameSnapshot(left: HistorySnapshot, right: HistorySnapshot): boolean {
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key) => left[key] === right[key]);
}
/** 스냅샷을 세션에 되쓴다 — 스냅샷에 없던 조작 키는 지운다. */
function restoreSnapshot(snapshot: HistorySnapshot): void {
try {
const existing: string[] = [];
for (let index = 0; index < sessionStorage.length; index += 1) {
const key = sessionStorage.key(index);
if (key && isDataKey(key)) existing.push(key);
}
existing.forEach((key) => {
if (!(key in snapshot)) sessionStorage.removeItem(key);
});
Object.entries(snapshot).forEach(([key, value]) => sessionStorage.setItem(key, value));
} catch {
// 되쓰기 실패 시 화면 상태는 그대로 둔다 — 잘못 섞인 복원보다 낫다.
}
}
export interface ProfileHistory {
/** 조작이 끝난 뒤 현재 상태를 이력에 쌓는다(직전과 같으면 무시). */
record(): void;
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
}
/**
* .
*
* `onRestore`
* .
*/
export function createProfileHistory(onRestore: () => void, limit = 50): ProfileHistory {
const stack: HistorySnapshot[] = [takeSnapshot()];
let cursor = 0;
/** 복원 중에 들어오는 record()를 무시한다 — 복원이 새 이력을 만들면 redo가 사라진다. */
let restoring = false;
function apply(index: number): boolean {
if (index < 0 || index >= stack.length) return false;
cursor = index;
restoring = true;
restoreSnapshot(stack[cursor]);
try {
onRestore();
} finally {
restoring = false;
}
return true;
}
return {
record() {
if (restoring) return;
const snapshot = takeSnapshot();
if (sameSnapshot(snapshot, stack[cursor])) return;
stack.splice(cursor + 1);
stack.push(snapshot);
if (stack.length > limit) stack.shift();
cursor = stack.length - 1;
},
undo: () => apply(cursor - 1),
redo: () => apply(cursor + 1),
canUndo: () => cursor > 0,
canRedo: () => cursor < stack.length - 1,
};
}
+54 -80
View File
@@ -15,11 +15,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "./B05_Profile_Api_Structures";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import {
createProfileTableOverlay,
TABLE_OVERLAY_MIN_HEIGHT,
@@ -28,7 +24,6 @@ import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
import {
findMinCoverViolations,
@@ -58,6 +53,12 @@ import { renderBalanceBar } from "./B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "./B05_Profile_UI_Profile_Heights";
import { renderProfile } from "./B05_Profile_UI_Profile_Render";
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { createCrossPreview } from "./B05_Profile_UI_Profile_Preview";
import { createPanelTools } from "./B05_Profile_UI_Profile_Panel_Tools";
import {
stationIdAtStructure as stationIdAtStructureOf,
structureIdAtStation as structureIdAtStationOf,
} from "./B05_Profile_UI_Profile_Structures";
import "../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
@@ -300,11 +301,10 @@ export function createRouteProfilePanel(
let base: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
let savedEdits: AlignmentEdits = emptyEdits();
let resizeTimer = 0;
let redrawPending = false;
/** 횡단 설계 프리뷰 디바운스 타이머와 최신 요청 번호(늦게 온 응답 버리기용). */
let crossPreviewTimer = 0;
let crossPreviewSeq = 0;
let lastWidth = 0;
let lastHeight = 0;
@@ -312,33 +312,11 @@ export function createRouteProfilePanel(
* (2026-08-04 ).
* (·) . null로 Page에 .
*/
/** 같은 자리로 볼 여유(m) — 측점선 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */
const SAME_CHAINAGE_M = 0.51;
/** 측점선 id → 알약(구조물) id. 같은 누가거리의 구조물이 없으면 null. */
function structureIdAtStation(stationId: string | null): string | null {
if (stationId === null) return null;
const prefix = irregularStationId("");
if (!stationId.startsWith(prefix)) return null;
const station = irregularStations.find((entry) => irregularStationId(entry.id) === stationId);
if (!station) return null;
const hit = structures.find(
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
);
return hit?.structure_id ?? null;
}
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
function stationIdAtStructure(structureId: string | null): string | null {
if (structureId === null) return null;
const structure = structures.find((item) => item.structure_id === structureId);
if (!structure) return null;
const anchor = structureAnchorM(structure);
const station = irregularStations.find(
(entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M,
);
return station ? irregularStationId(station.id) : null;
}
/** 측점선 알약(구조물) 짝짓기는 `_Profile_Structures` 로 옮겼다(700줄 한계). */
const structureIdAtStation = (stationId: string | null): string | null =>
structureIdAtStationOf(stationId, irregularStations, structures);
const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures);
/**
* (2026-08-04 ).
@@ -377,6 +355,7 @@ export function createRouteProfilePanel(
renderBalanceBar({
minCoverViolations,
balanceBar,
tools: tools.render(),
alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
edited: store.edited(),
@@ -389,6 +368,26 @@ export function createRouteProfilePanel(
});
}
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 로 뺐다(700줄 한계). */
const { tools, history, handleToolPick } = createPanelTools({
root,
base: () => base,
alignment: () => alignment,
edits: () => store.edits(),
applyEdits: (next) => applyEdits(next),
selectedStationId: () => selectedStationId,
irregularStations: () => irregularStations,
stationIdOf: (station) => irregularStationId(station.id),
moveStation: (station, toChainageM) =>
callbacks?.onStructureMove?.(station.chainage_m, toChainageM, station),
restore: () => {
// 세션이 정본이므로 편집 초안을 다시 읽어 그린다.
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
rebuild();
},
refresh: () => renderBalance(),
});
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
function applyEdits(next: AlignmentEdits): void {
if (!base || !alignment) return;
@@ -404,6 +403,7 @@ export function createRouteProfilePanel(
return;
}
store.replace(next);
history.record();
}
/**
@@ -424,48 +424,19 @@ export function createRouteProfilePanel(
});
}
/**
* .
* (seq ).
*/
function scheduleCrossPreview(): void {
if (!detail || routeId === null) return;
window.clearTimeout(crossPreviewTimer);
crossPreviewTimer = window.setTimeout(() => {
if (!detail || routeId === null) return;
const seq = (crossPreviewSeq += 1);
const targetRouteId = routeId;
// full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한
// 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값 없으면
// DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다.
void previewCrossDesigns(projectId, targetRouteId, store.edits(), undefined, {
fullDesigns: true,
})
.then((next) => {
if (seq !== crossPreviewSeq || !detail || routeId !== targetRouteId) return;
// 공유 캐시가 들고 있는 **같은 객체**를 제자리 갱신한다 — B06이 이 객체를 그대로
// 보므로 페이지를 넘어가도 다시 받을 필요가 없다. 지반선 샘플은 건드리지 않는다.
const designByChainage = new Map(
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of detail.cross_sections) {
const full = designByChainage.get(section.chainage_m.toFixed(3));
if (!full || !section.design) continue;
// 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존.
section.design = {
...(full as NonNullable<typeof section.design>),
inlet_structure: section.design.inlet_structure,
basin_adjust: section.design.basin_adjust,
};
}
draw();
callbacks?.onCrossDesignsUpdated?.();
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
});
}, CROSS_PREVIEW_DEBOUNCE_MS);
}
/** 횡단 설계 프리뷰는 `_Profile_Preview` 로 뺐다(700줄 한계). */
const crossPreview = createCrossPreview({
projectId,
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
debounceMs: CROSS_PREVIEW_DEBOUNCE_MS,
onApplied: () => {
draw();
callbacks?.onCrossDesignsUpdated?.();
},
});
const scheduleCrossPreview = (): void => crossPreview.schedule();
/**
* (2026-08-05 ):
@@ -527,6 +498,8 @@ export function createRouteProfilePanel(
},
selectStation,
applyEdits,
handleToolPick,
toolActive: () => tools.mode() !== "none",
stationIdAtStructure,
redraw: draw,
});
@@ -588,7 +561,8 @@ export function createRouteProfilePanel(
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (routeChanged || !store.dirty()) {
routeId = nextRouteId ?? routeId;
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
}
base = stored ? toAlignmentBase(stored) : null;
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
@@ -712,7 +686,7 @@ export function createRouteProfilePanel(
},
dispose() {
window.clearTimeout(resizeTimer);
window.clearTimeout(crossPreviewTimer);
crossPreview.dispose();
resizeObserver.disconnect();
heightResizer.dispose();
window.removeEventListener("pointerup", clearDragFlags);
@@ -0,0 +1,159 @@
/* =============================================================================
* B05_Profile_UI_Profile_Panel_Tools.ts
* []·[]·· (2026-09-02 ).
*
* (`B05_Profile_UI_Profile_Panel`) 700 .
* ** ** ·
* .
*
* B05 `B05_Profile_UI_Profile_History` .
* ========================================================================== */
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import { adjustStation } from "./B05_Profile_UI_Profile_Alignment";
import { createProfileHistory, type ProfileHistory } from "./B05_Profile_UI_Profile_History";
import { createProfileTools, type ProfileTools } from "./B05_Profile_UI_Profile_Tools";
import {
detectStraightRun,
shiftStraightRuns,
straightenBetween,
tiltStraightRun,
} from "./B05_Profile_UI_Profile_Straighten";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
const KEY_STEP_M = 0.1;
export interface PanelToolsContext {
/** 패널 루트 — 키보드 조작이 이 안에서 일어났을 때만 반응한다. */
root: HTMLElement;
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
applyEdits: (next: AlignmentEdits) => void;
/** 고른 측점 id(없으면 null). */
selectedStationId: () => string | null;
/** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */
irregularStations: () => IrregularStation[];
/** 측점 id 만들기 — 목록 id와 그래프 id를 맞춘다. */
stationIdOf: (station: IrregularStation) => string;
/** 구조물·비정규 측점을 다른 누가거리로 옮긴다(그래프 끌기와 같은 경로). */
moveStation: (station: IrregularStation, toChainageM: number) => void;
/** 세션 복원 후 화면을 다시 세운다(편집 초안 재적재 포함). */
restore: () => void;
/** 도구 상태가 바뀌어 요약줄을 다시 그려야 할 때. */
refresh: () => void;
}
export interface PanelTools {
tools: ProfileTools;
history: ProfileHistory;
/** 그래프 클릭을 도구가 먹는지 — 먹었으면 기본 선택 동작을 건너뛴다. */
handleToolPick: (chainageM: number | null) => boolean;
}
export function createPanelTools(ctx: PanelToolsContext): PanelTools {
const history = createProfileHistory(ctx.restore);
const tools = createProfileTools({
onStraighten: (fromM, toM) => {
const base = ctx.base();
if (!base) return;
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM));
tools.clear();
},
onShift: (runs, delta) => {
const base = ctx.base();
if (!base) return;
ctx.applyEdits(shiftStraightRuns(base, ctx.edits(), runs, delta));
},
onTilt: (run, delta) => {
const base = ctx.base();
if (!base) return;
ctx.applyEdits(tiltStraightRun(base, ctx.edits(), run, delta));
},
onUndo: () => history.undo(),
onRedo: () => history.redo(),
canUndo: () => history.canUndo(),
canRedo: () => history.canRedo(),
onChanged: ctx.refresh,
});
/** 그래프에서 누른 자리를 도구가 먹는다. */
function handleToolPick(chainageM: number | null): boolean {
const mode = tools.mode();
if (mode === "none") return false;
if (chainageM === null) return tools.handleRunPick(null);
const alignment = ctx.alignment();
if (mode === "shift") {
return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null);
}
// 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라
// 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다.
if (tools.pendingStation() === null && alignment) {
const run = detectStraightRun(alignment, chainageM);
if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) {
return tools.handleRunPick(run);
}
}
return tools.handleStationPick(chainageM);
}
function selectedIrregular(): IrregularStation | null {
const stationId = ctx.selectedStationId();
if (stationId === null) return null;
return ctx.irregularStations().find((entry) => ctx.stationIdOf(entry) === stationId) ?? null;
}
/** 고른 측점의 누가거리 — 비정규 측점이면 목록에서, 규칙 측점이면 선형에서 찾는다. */
function selectedChainage(): number | null {
const irregular = selectedIrregular();
if (irregular) return irregular.chainage_m;
const stationId = ctx.selectedStationId();
const row = ctx.alignment()?.stations.find((station) => station.station_id === stationId);
return row ? row.chainage_m : null;
}
/* · ( ), · .
* · . 20m ·
* (2026-09-02 ). */
ctx.root.addEventListener("keydown", (event) => {
const target = event.target as HTMLElement | null;
// 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다.
if (target && target.closest("input, select, textarea")) return;
const key = event.key;
if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) {
event.preventDefault();
if (event.shiftKey) history.redo();
else history.undo();
return;
}
if (key !== "ArrowUp" && key !== "ArrowDown" && key !== "ArrowLeft" && key !== "ArrowRight") {
return;
}
const chainage = selectedChainage();
const base = ctx.base();
if (chainage === null || !base) return;
if (key === "ArrowUp" || key === "ArrowDown") {
event.preventDefault();
const delta = key === "ArrowUp" ? KEY_STEP_M : -KEY_STEP_M;
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
return;
}
const station = selectedIrregular();
if (!station) return; // 규칙 측점은 좌우 이동 대상이 아니다 — 조용히 무시한다.
event.preventDefault();
const next = Number(
(station.chainage_m + (key === "ArrowRight" ? KEY_STEP_M : -KEY_STEP_M)).toFixed(3),
);
if (next < 0) return;
ctx.moveStation(station, next);
history.record();
});
return { tools, history, handleToolPick };
}
@@ -0,0 +1,79 @@
/* =============================================================================
* B05_Profile_UI_Profile_Preview.ts
* ( , 2026-09-02 · 700 ).
*
*
* ** ** .
* 3D (2026-08-03·08-23 ).
*
* , seq .
* ========================================================================== */
import type { AlignmentEdits } from "./B05_Profile_UI_Profile_Alignment";
import { previewCrossDesigns } from "../B06_Section/B06_Section_Api_Fetch";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
export interface CrossPreviewContext {
projectId: string;
detail: () => SectionDetailResponse | null;
routeId: () => number | null;
edits: () => AlignmentEdits;
debounceMs: number;
/** 반영이 끝난 뒤 다시 그린다. */
onApplied: () => void;
}
export interface CrossPreview {
/** 편집이 있을 때마다 부른다 — 마지막 값만 서버로 나간다. */
schedule: () => void;
/** 패널을 걷을 때 대기 중인 요청 타이머를 끈다. */
dispose: () => void;
}
export function createCrossPreview(ctx: CrossPreviewContext): CrossPreview {
let timer = 0;
let seq = 0;
return {
schedule() {
if (!ctx.detail() || ctx.routeId() === null) return;
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const detail = ctx.detail();
const routeId = ctx.routeId();
if (!detail || routeId === null) return;
const current = (seq += 1);
// full_designs — 설계선 좌표까지 통째로 받아야 3D 코리도가 편집 즉시 정확한
// 형상으로 재빌드된다(2026-08-23 사용자 지시). 암 경계는 백엔드가 세션값이 없으면
// DB 저장 echo를 폴백으로 쓰므로 그대로 유지된다.
void previewCrossDesigns(ctx.projectId, routeId, ctx.edits(), undefined, {
fullDesigns: true,
})
.then((next) => {
const live = ctx.detail();
if (current !== seq || !live || ctx.routeId() !== routeId) return;
const designByChainage = new Map(
next.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
for (const section of live.cross_sections) {
const full = designByChainage.get(section.chainage_m.toFixed(3));
if (!full || !section.design) continue;
// 전체 교체(설계선 포함) — B06 reconcile과 같은 패턴으로 사용자 부속값은 보존.
section.design = {
...(full as NonNullable<typeof section.design>),
inlet_structure: section.design.inlet_structure,
basin_adjust: section.design.basin_adjust,
};
}
ctx.onApplied();
})
.catch(() => {
/* 프리뷰 실패는 무시 — 화면의 계획선은 그대로 두고 다음 편집에서 다시 시도한다. */
});
}, ctx.debounceMs);
},
dispose() {
window.clearTimeout(timer);
},
};
}
+21 -16
View File
@@ -21,8 +21,6 @@ import {
buildAlignment,
controlElevationAt,
setCurveRadius,
shiftMovingPoints,
shiftSegment,
type AlignmentBase,
type AlignmentEdits,
type ProfileAlignment,
@@ -85,6 +83,10 @@ export interface ProfileRenderContext {
clearMainDragCooldown: () => void;
selectStation: (stationId: string | null) => void;
applyEdits: (next: AlignmentEdits) => void;
/** [직선화]·[쉬프트] 도구가 그래프 클릭을 먼저 먹는지(먹었으면 기본 선택을 건너뛴다). */
handleToolPick: (chainageM: number | null) => boolean;
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
toolActive: () => boolean;
stationIdAtStructure: (structureId: string | null) => string | null;
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
redraw: () => void;
@@ -171,6 +173,23 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 측점선이 아닌 빈 곳을 누르면 선택 해제(2026-08-04 사용자 지시).
// 측점 마커·편집 버튼 클릭은 각자 처리하므로 여기까지 안 온다(closest·stopPropagation).
chartWrap.addEventListener("click", (event) => {
// [직선화]·[쉬프트] 모드에서는 그래프 클릭이 도구 선택으로 간다 — 측점선을 눌렀으면
// 그 측점, 빈 곳이면 그 x의 누가거리로 직선 구간을 고른다(2026-09-02).
if (ctx.toolActive()) {
const marker = (event.target as HTMLElement).closest(".b06-chart__station");
const raw = marker?.getAttribute("data-chainage");
if (raw !== null && raw !== undefined) {
if (ctx.handleToolPick(Number(raw))) return;
} else {
const rect = chartWrap.getBoundingClientRect();
const chainage = chainageInverter(
longitudinal,
width,
layout.originOffset,
)(event.clientX - rect.left + chartWrap.scrollLeft);
if (ctx.handleToolPick(Number.isFinite(chainage) ? chainage : null)) return;
}
}
if ((event.target as HTMLElement).closest(".b06-chart__station")) return;
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
// 구조물 알약 선택도 함께 푼다 — 빈 공간 클릭 시 사이드 폼(구조물군·종류)까지
@@ -338,20 +357,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
if (blocksMinCover(next)) return;
ctx.applyEdits(next);
},
onSegment: (segment, delta) => {
if (!base) return;
const next = shiftSegment(base, store.edits(), segment, delta);
if (next === store.edits() || blocksMinCover(next)) return;
ctx.applyEdits(next);
},
// 쉬프트 가능 구간 판정 — 안쪽 미틸트 측점 2개(힌지) 확보 못 하면 버튼 숨김.
canShift: (segment) => shiftMovingPoints(alignment, segment) !== null,
onResetStation: (chainage) => store.resetStation(chainage),
// 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2).
onResetSegment: (segment) => {
store.resetStation(segment.from_m);
store.resetStation(segment.to_m);
},
}),
);
}
@@ -0,0 +1,263 @@
/* =============================================================================
* B05_Profile_UI_Profile_Straighten.ts
* []·[] (2026-09-02 ).
*
* ** **( = ) ,
* .
*
* [] 2 ** ** .
* .
* .
* [] ( ) · .
* = ** **
* , .
*
* ** **(`detectStraightRun`)
* (`station_offsets`·`curve_radii`) .
* ========================================================================== */
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "./B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
controlElevationAt,
} from "./B05_Profile_UI_Profile_Alignment";
/** 같은 직선 위에 있다고 볼 기울기 차이(무차원). 0.1m 편집 단위의 반올림 오차보다 크게 둔다. */
const COLLINEAR_EPSILON = 1e-6;
/** 두 chainage를 같은 측점으로 볼 허용 오차(m). */
const SAME_STATION_M = 1e-6;
/** 직선화된 한 구간 — 양 끝 변화점과 그 사이 측점들. */
export interface StraightRun {
fromM: number;
toM: number;
/** 양 끝을 포함한 구간 안 변화점 chainage (오름차순). */
nodes: number[];
}
/** 현재 선형의 변화점 chainage 목록 (오름차순). */
function pviChainages(alignment: ProfileAlignment): number[] {
return alignment.pvi.map((node) => node.chainage_m);
}
/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */
function baseElevationAt(base: AlignmentBase, chainageM: number): number {
const nodes = base.basePvi;
if (!nodes.length) return 0;
if (chainageM <= nodes[0].chainage_m) return nodes[0].elevation_m;
const last = nodes[nodes.length - 1];
if (chainageM >= last.chainage_m) return last.elevation_m;
for (let index = 1; index < nodes.length; index += 1) {
if (nodes[index].chainage_m < chainageM) continue;
const previous = nodes[index - 1];
const current = nodes[index];
const span = current.chainage_m - previous.chainage_m;
const ratio = span > 1e-12 ? (chainageM - previous.chainage_m) / span : 0;
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
}
return last.elevation_m;
}
/** 변화점 사이 기울기 (index 번째 구간). */
function gradeAt(alignment: ProfileAlignment, index: number): number {
const segment = alignment.segments[index];
if (!segment || segment.length_m <= 1e-12) return 0;
return segment.height_m / segment.length_m;
}
/**
* ** ** .
*
* (= ) .
* ,
* .
*/
export function detectStraightRun(
alignment: ProfileAlignment,
chainageM: number,
): StraightRun | null {
const nodes = pviChainages(alignment);
if (nodes.length < 2) return null;
// 클릭 지점을 포함하는 구간 index
let index = alignment.segments.findIndex(
(segment) =>
chainageM >= segment.from_m - SAME_STATION_M && chainageM <= segment.to_m + SAME_STATION_M,
);
if (index < 0) return null;
const grade = gradeAt(alignment, index);
let first = index;
let last = index;
while (first > 0 && Math.abs(gradeAt(alignment, first - 1) - grade) < COLLINEAR_EPSILON) {
first -= 1;
}
while (
last < alignment.segments.length - 1 &&
Math.abs(gradeAt(alignment, last + 1) - grade) < COLLINEAR_EPSILON
) {
last += 1;
}
// 한 구간(측점 두 개)뿐이면 직선화된 라인이 아니라 그냥 폴리라인 한 마디다.
if (last === first) return null;
const fromM = alignment.segments[first].from_m;
const toM = alignment.segments[last].to_m;
return {
fromM,
toM,
nodes: nodes.filter(
(value) => value >= fromM - SAME_STATION_M && value <= toM + SAME_STATION_M,
),
};
}
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
return pviChainages(alignment).filter(
(value) => value > fromM + SAME_STATION_M && value < toM - SAME_STATION_M,
);
}
/** 편집 델타에 측점 오프셋을 써 넣는다(기존 객체는 건드리지 않는다). */
function withOffsets(
edits: AlignmentEdits,
updates: Array<[string, number]>,
dropRadiusKeys: string[] = [],
): AlignmentEdits {
const stationOffsets = { ...edits.station_offsets };
const curveRadii = { ...edits.curve_radii };
updates.forEach(([key, value]) => {
stationOffsets[key] = Number(value.toFixed(6));
});
dropRadiusKeys.forEach((key) => delete curveRadii[key]);
return { station_offsets: stationOffsets, curve_radii: curveRadii };
}
/**
* .
*
* .
* ( z는 ).
*/
export function straightenBetween(
base: AlignmentBase,
edits: AlignmentEdits,
fromChainageM: number,
toChainageM: number,
): AlignmentEdits {
const fromM = Math.min(fromChainageM, toChainageM);
const toM = Math.max(fromChainageM, toChainageM);
const span = toM - fromM;
if (span <= SAME_STATION_M) return edits;
const current = buildAlignment(base, edits);
const startZ = controlElevationAt(current, fromM);
const endZ = controlElevationAt(current, toM);
const inner = interiorNodes(current, fromM, toM);
if (!inner.length) return edits;
const updates: Array<[string, number]> = inner.map((chainage) => {
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(edits, updates, inner.map(chainageKey));
}
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
const length = base.policy.default_curve_length_m;
if (typeof length === "number" && Number.isFinite(length) && length > 0) return length;
return base.policy.default_curve_radius_m * Math.abs(deltaGrade);
}
/**
* · ** **.
*
* delta만큼 , .
* ( R을 ),
* R은 L에서 R = L / || .
*/
export function tiltStraightRun(
base: AlignmentBase,
edits: AlignmentEdits,
run: StraightRun,
delta: number,
): AlignmentEdits {
const current = buildAlignment(base, edits);
const inner = interiorNodes(current, run.fromM, run.toM);
if (!inner.length) return edits;
const center = (run.fromM + run.toM) / 2;
const pivot = inner.reduce((best, chainage) =>
Math.abs(chainage - center) < Math.abs(best - center) ? chainage : best,
);
const startZ = controlElevationAt(current, run.fromM);
const endZ = controlElevationAt(current, run.toM);
const span = run.toM - run.fromM;
const pivotZ =
startZ + ((endZ - startZ) * (pivot - run.fromM)) / (span > 1e-12 ? span : 1) + delta;
const updates: Array<[string, number]> = [
[chainageKey(pivot), pivotZ - baseElevationAt(base, pivot)],
];
const place = (chainage: number, aM: number, aZ: number, bM: number, bZ: number): void => {
const width = bM - aM;
if (width <= 1e-12) return;
const target = aZ + ((bZ - aZ) * (chainage - aM)) / width;
updates.push([chainageKey(chainage), target - baseElevationAt(base, chainage)]);
};
inner.forEach((chainage) => {
if (Math.abs(chainage - pivot) < SAME_STATION_M) return;
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
else place(chainage, pivot, pivotZ, run.toM, endZ);
});
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
const deltaGrade = Math.abs(gradeOut - gradeIn);
const next = withOffsets(
edits,
updates,
inner.filter((chainage) => Math.abs(chainage - pivot) >= SAME_STATION_M).map(chainageKey),
);
if (deltaGrade < 1e-9) return next;
return {
...next,
curve_radii: {
...next.curve_radii,
[chainageKey(pivot)]: Number((defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6)),
},
};
}
/**
* · .
*
*
* ** **( ).
* (BP·EP· ) .
*/
export function shiftStraightRuns(
base: AlignmentBase,
edits: AlignmentEdits,
runs: StraightRun[],
delta: number,
): AlignmentEdits {
if (!runs.length) return edits;
const current = buildAlignment(base, edits);
const moving = new Set<number>();
runs.forEach((run) => run.nodes.forEach((chainage) => moving.add(chainage)));
const updates: Array<[string, number]> = [...moving].map((chainage) => {
const target = controlElevationAt(current, chainage) + delta;
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
});
return withOffsets(edits, updates);
}
/** 같은 구간인지 — 선택 목록에서 중복을 걸러낼 때 쓴다. */
export function sameRun(left: StraightRun, right: StraightRun): boolean {
return (
Math.abs(left.fromM - right.fromM) < SAME_STATION_M &&
Math.abs(left.toM - right.toM) < SAME_STATION_M
);
}
@@ -10,7 +10,12 @@
* ========================================================================== */
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { isPipeStation, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
import {
irregularStationId,
isPipeStation,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel";
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
@@ -117,3 +122,42 @@ export function mountStructureMenu(host: HTMLElement, options: StructureLineOpti
host.append(menu.element);
}
/** 같은 자리로 볼 여유(m) — 측점선과 알약은 같은 누가거리를 쓰지만 소수점이 갈린다. */
export const SAME_CHAINAGE_M = 0.51;
/**
* id () id .
*
* (2026-08-17 ).
* 700 (2026-09-02).
*/
export function structureIdAtStation(
stationId: string | null,
stations: IrregularStation[],
structures: StructureInstance[],
): string | null {
if (stationId === null) return null;
const prefix = irregularStationId("");
if (!stationId.startsWith(prefix)) return null;
const station = stations.find((entry) => irregularStationId(entry.id) === stationId);
if (!station) return null;
const hit = structures.find(
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
);
return hit?.structure_id ?? null;
}
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
export function stationIdAtStructure(
structureId: string | null,
stations: IrregularStation[],
structures: StructureInstance[],
): string | null {
if (structureId === null) return null;
const structure = structures.find((item) => item.structure_id === structureId);
if (!structure) return null;
const anchor = structureAnchorM(structure);
const station = stations.find((entry) => Math.abs(entry.chainage_m - anchor) < SAME_CHAINAGE_M);
return station ? irregularStationId(station.id) : null;
}
+180
View File
@@ -0,0 +1,180 @@
/* =============================================================================
* B05_Profile_UI_Profile_Tools.ts
* [][] · [] · [] · [][] (2026-09-02 ).
*
* · ** **( ),
* (`b05-profile-edit__btn` CSS에서 ).
*
* :
* [] 2 .
* [] ( ) [][] .
* [] [][]
* .
* ========================================================================== */
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
import { sameRun } from "./B05_Profile_UI_Profile_Straighten";
export type ProfileToolMode = "none" | "straighten" | "shift";
export interface ProfileToolsCallbacks {
/** 두 측점을 직선으로 잇는다. */
onStraighten: (fromChainageM: number, toChainageM: number) => void;
/** 고른 직선 구간(들)을 위·아래로 옮긴다. */
onShift: (runs: StraightRun[], delta: number) => void;
/** 고른 직선 구간을 꺾는다 — 가운데 라운드 + 양측 탄젠트. */
onTilt: (run: StraightRun, delta: number) => void;
onUndo: () => void;
onRedo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
/** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */
onChanged: () => void;
}
export interface ProfileTools {
/** 요약줄 맨 앞에 넣을 도구 묶음. 그릴 때마다 새로 만든다. */
render: () => HTMLElement;
mode: () => ProfileToolMode;
/** 그래프에서 측점을 눌렀을 때 — 도구가 삼켰으면 true. */
handleStationPick: (chainageM: number) => boolean;
/** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */
handleRunPick: (run: StraightRun | null) => boolean;
/** 선택 중인 직선 구간들(강조 표시용). */
selectedRuns: () => StraightRun[];
/** 직선화 대기 중 첫 측점(강조 표시용). */
pendingStation: () => number | null;
/** 모드·선택을 모두 끈다. */
clear: () => void;
}
function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-route-profile__tool";
button.textContent = label;
button.title = title;
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick();
});
return button;
}
export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileTools {
let mode: ProfileToolMode = "none";
let pending: number | null = null;
let runs: StraightRun[] = [];
function reset(): void {
mode = "none";
pending = null;
runs = [];
}
function setMode(next: ProfileToolMode): void {
// 같은 버튼을 다시 누르면 모드를 끈다 — 선택도 함께 비운다.
if (mode === next) reset();
else {
mode = next;
pending = null;
runs = [];
}
callbacks.onChanged();
}
function step(delta: number): void {
if (mode === "shift" && runs.length) {
callbacks.onShift(runs, delta);
return;
}
if (runs.length === 1) callbacks.onTilt(runs[0], delta);
}
return {
mode: () => mode,
selectedRuns: () => runs,
pendingStation: () => pending,
clear() {
if (mode === "none" && pending === null && !runs.length) return;
reset();
callbacks.onChanged();
},
handleStationPick(chainageM) {
if (mode !== "straighten") return false;
if (pending === null) {
pending = chainageM;
callbacks.onChanged();
return true;
}
const from = pending;
pending = null;
if (Math.abs(from - chainageM) < 1e-6) {
callbacks.onChanged();
return true;
}
callbacks.onStraighten(from, chainageM);
return true;
},
handleRunPick(run) {
if (mode === "none") return false;
if (!run) {
// 빈 곳을 누르면 선택만 비우고 모드는 유지한다 — 연속 조작을 끊지 않는다.
if (runs.length || pending !== null) {
runs = [];
pending = null;
callbacks.onChanged();
}
return true;
}
const already = runs.findIndex((entry) => sameRun(entry, run));
if (already >= 0) runs.splice(already, 1);
else if (mode === "shift") runs.push(run);
else runs = [run];
callbacks.onChanged();
return true;
},
render() {
const wrap = document.createElement("span");
wrap.className = "b05-route-profile__tools";
const undo = toolButton("↶", "되돌리기 (Ctrl+Z)", callbacks.onUndo);
undo.disabled = !callbacks.canUndo();
const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo);
redo.disabled = !callbacks.canRedo();
const straighten = toolButton(
"직선화",
"측점 2개를 골라 그 사이를 직선으로 만듭니다 (사이 라운드는 지워집니다)",
() => setMode("straighten"),
);
straighten.classList.toggle("is-active", mode === "straighten");
const shift = toolButton(
"쉬프트",
"직선화된 라인을 골라(여러 개 가능) 위·아래로 옮깁니다",
() => setMode("shift"),
);
shift.classList.toggle("is-active", mode === "shift");
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => step(0.1));
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => step(-0.1));
const idle = mode === "none" || (!runs.length && mode === "shift");
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
down.disabled = up.disabled;
wrap.append(undo, redo, straighten, shift, up, down);
if (mode === "straighten" && pending !== null) {
const hint = document.createElement("em");
hint.className = "b05-route-profile__tool-hint";
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요`;
wrap.append(hint);
} else if (mode === "shift" && !runs.length) {
const hint = document.createElement("em");
hint.className = "b05-route-profile__tool-hint";
hint.textContent = "직선화된 라인을 고르세요";
wrap.append(hint);
}
return wrap;
},
};
}
+35 -7
View File
@@ -544,17 +544,45 @@
text-overflow: ellipsis;
}
.b05-route-profile__balance-reset {
/* 도구줄 요약줄 (최대 기울기 왼쪽). 버튼 크기는 그래프 틸팅 버튼과 같다
(21×17px, 2026-09-02 사용자 지시). */
.b05-route-profile__tools {
display: inline-flex;
flex: 0 0 auto;
padding: 1px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
align-items: center;
gap: 2px;
margin-right: var(--spacing-8);
}
.b05-route-profile__tool {
height: 17px;
min-width: 21px;
padding: 0 3px;
border: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent);
border-radius: 3px;
background: color-mix(in srgb, var(--color-surface-raised) 80%, transparent);
color: var(--color-text);
font-size: 9px;
line-height: 1;
cursor: pointer;
}
.b05-route-profile__tool:disabled {
opacity: 0.35;
cursor: default;
}
.b05-route-profile__tool.is-active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.b05-route-profile__tool-hint {
color: var(--color-text-muted);
font-size: var(--text-caption);
font-style: normal;
}
/* 도면 테이블 (구배 ~ 곡선 9행)
셀은 종단면도와 같은 X 매핑으로 절대 배치되어 측점 수직선과 맞물린다.
이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */
@@ -284,6 +284,8 @@ export function createLongitudinalProfile(
const selected = station.station_id === selectedStationId;
const marker = svgElement("g", {
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
// B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02).
"data-chainage": station.chainage_m.toFixed(3),
tabindex: "0",
role: "button",
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`,