feat(B05): 종단 [자유지정] — 범위 고름 · 클릭 점 측점선 붙음 · 실시간 점선 · 기울기 빨강 · 꺾인 점 종단곡선 · 계획고 편집 캐시(215-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-10 15:32:27 +09:00
co-authored by Claude Opus 5.5
parent ad30e1ac12
commit b628df3e93
10 changed files with 1029 additions and 11 deletions
@@ -26,6 +26,8 @@ import {
tiltStraightRun,
} from "./B05_Profile_UI_Profile_Straighten";
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { createFreeDraw } from "../A00_Common/design/design_profile_free";
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
/** 방향키 한 번에 움직이는 양(m) — 계획고·누가거리 모두 같다(사용자 확정). */
const KEY_STEP_M = 0.1;
@@ -111,6 +113,18 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
pause: () => (syncPipes(), inner.pause()),
};
// [자유지정] — 적용은 직선화 결과와 같은 길(applyEdits · 이력 · 막기 규칙)(215-1).
const free = createFreeDraw({
base: ctx.base,
alignment: ctx.alignment,
edits: ctx.edits,
apply: (next) => ctx.applyEdits(next),
close: () => tools.clear(),
changed: ctx.refresh,
});
const freeView = createFreeView(free);
let lastGeom: FreeChartGeom | null = null;
const tools = createProfileTools({
onStraighten: (fromM, toM) => {
const base = ctx.base();
@@ -143,12 +157,19 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
return alignment ? hasStraightRuns(alignment) : false;
},
onChanged: ctx.refresh,
onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"),
freeHint: () => free.hint(),
mountOverlay: (geom) => {
lastGeom = geom;
freeView.mount(geom, tools.mode() === "free");
},
});
/** 그래프에서 누른 자리를 도구가 먹는다. */
function handleToolPick(chainageM: number | null): boolean {
const mode = tools.mode();
if (mode === "none") return false;
if (mode === "free") return true; // 자유지정 클릭은 오버레이가 먼저 먹는다.
if (chainageM === null) return tools.handleRunPick(null);
const alignment = ctx.alignment();
if (mode === "shift") {
@@ -283,6 +304,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
// 입력칸 안에서는 방향키가 값 조작이므로 손대지 않는다.
if (target && target.closest("input, select, textarea")) return;
const key = event.key;
// [자유지정] 중 Enter 끝 · Esc 취소 · Backspace 점 빼기 — 단추 초점의 Enter 누름도 막는다.
if (tools.mode() === "free" && free.key(key)) {
event.preventDefault();
freeView.redraw();
return;
}
if ((event.ctrlKey || event.metaKey) && (key === "z" || key === "Z")) {
event.preventDefault();
flushHold();
+14 -4
View File
@@ -34,7 +34,7 @@ import {
Y_AXIS_WINDOW_CLASS,
type ElevationWindowResult,
} from "@util/common_util_chart_ywindow";
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
import type { ProfileTools } from "./B05_Profile_UI_Profile_Tools";
import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover";
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
import type { ProfileZoomState } from "./B05_Profile_UI_Profile_Zoom";
@@ -106,8 +106,8 @@ export interface ProfileRenderContext {
) => { min: number; max: number } | undefined;
/** 그래프 x → chainage 역변환이 필요한 도구 판정용 — 클릭 지점의 누가거리. */
toolActive: () => boolean;
/** [쉬프트]로 고른 직선 구간 — 그래프에 빨갛게 강조한다(2026-09-03). */
selectedRuns: () => StraightRun[];
/** [쉬프트]로 고른 직선 구간(빨간 강조) · [자유지정] 오버레이(215-1). */
tools: Pick<ProfileTools, "selectedRuns" | "mountOverlay">;
stationIdAtStructure: (structureId: string | null) => string | null;
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
redraw: () => void;
@@ -368,7 +368,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
if (alignment) {
const highlight = createRunHighlight({
alignment,
runs: ctx.selectedRuns(),
runs: ctx.tools.selectedRuns(),
x,
axis: yAxis,
widthPx: width,
@@ -403,6 +403,16 @@ export function renderProfile(ctx: ProfileRenderContext): void {
canvas.append(chartWrap, structureLane);
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
// [자유지정] — 범위 강조 · 점선 미리 보기 · 클릭 점(215-1). 화면에 붙인 뒤라야 자리를 잰다.
ctx.tools.mountOverlay({
chart: chartWrap,
scroller: body,
x,
toChainage,
widthPx: width,
heightPx: chartHeight,
stations: graphLongitudinal.stations.map((station) => station.chainage_m),
});
// 테이블은 바닥 고정 오버레이에 담는다 — 폭은 종단 캔버스와 같아 세로선이 맞물린다.
if (table) table.style.width = `${width}px`;
tableOverlay.setTable(table);
@@ -46,7 +46,7 @@ function pviChainages(alignment: ProfileAlignment): number[] {
}
/** 자동 선형(base_pvi) 위에서의 표고 — 편집 델타의 기준값. */
function baseElevationAt(base: AlignmentBase, chainageM: number): number {
export 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;
@@ -181,7 +181,7 @@ export function straightenBetween(
}
/** 정책에서 라운드 기본 길이 L(m)을 읽는다 — 옛 저장분은 R 기준으로 되돌아간다. */
function defaultCurveLength(base: AlignmentBase, deltaGrade: number): number {
export 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);
+28 -4
View File
@@ -11,12 +11,14 @@
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
* [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1).
* ========================================================================== */
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
import { sameRun } from "./B05_Profile_UI_Profile_Straighten";
import type { FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
export type ProfileToolMode = "none" | "straighten" | "shift";
export type ProfileToolMode = "none" | "straighten" | "shift" | "free";
export interface ProfileToolsCallbacks {
/** 두 측점을 직선으로 잇는다. */
@@ -36,6 +38,12 @@ export interface ProfileToolsCallbacks {
hasStraightRuns: () => boolean;
/** 선택 표시를 갱신해야 할 때(모드·선택 변화) 호출된다. */
onChanged: () => void;
/** 모드가 바뀜 — [자유지정] 오버레이를 붙이고 뗀다. */
onModeChange: (mode: ProfileToolMode) => void;
/** [자유지정] 단계 안내. */
freeHint: () => string;
/** 그래프를 새로 그림 — [자유지정] 오버레이를 다시 붙인다. */
mountOverlay: (geom: FreeChartGeom) => void;
}
export interface ProfileTools {
@@ -54,6 +62,8 @@ export interface ProfileTools {
nudge: (delta: number) => boolean;
/** 모드·선택을 모두 끈다. */
clear: () => void;
/** 그래프를 새로 그릴 때마다 — [자유지정] 오버레이 자리. */
mountOverlay: (geom: FreeChartGeom) => void;
}
function toolButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
@@ -78,6 +88,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
mode = "none";
pending = null;
runs = [];
callbacks.onModeChange(mode);
}
function setMode(next: ProfileToolMode): void {
@@ -87,6 +98,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
mode = next;
pending = null;
runs = [];
callbacks.onModeChange(mode);
}
callbacks.onChanged();
}
@@ -106,6 +118,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
nudge: step,
selectedRuns: () => runs,
pendingStation: () => pending,
mountOverlay: callbacks.mountOverlay,
clear() {
if (mode === "none" && pending === null && !runs.length) return;
reset();
@@ -174,15 +187,26 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
() => setMode("shift"),
);
shift.classList.toggle("is-active", mode === "shift");
const free = toolButton(
"자유지정",
"범위를 고른 뒤 클릭으로 계획선을 잇습니다 (Enter 끝 · Backspace 점 빼기 · Esc 취소)",
() => setMode("free"),
);
free.classList.toggle("is-active", mode === "free");
const up = toolButton("▲", "고른 직선을 0.1m 올림", () => void step(0.1));
const down = toolButton("▼", "고른 직선을 0.1m 내림", () => void step(-0.1));
const idle = mode === "none" || (!runs.length && mode === "shift");
const idle = mode === "none" || mode === "free" || (!runs.length && mode === "shift");
up.disabled = idle || (mode === "straighten" && runs.length !== 1);
down.disabled = up.disabled;
wrap.append(reset, undo, redo, straighten, shift, up, down);
if (mode === "straighten" && pending !== null) {
wrap.append(reset, undo, redo, straighten, free, shift, up, down);
if (mode === "free") {
const hint = document.createElement("em");
hint.className = "b05-route-profile__tool-hint";
hint.textContent = callbacks.freeHint();
wrap.append(hint);
} else if (mode === "straighten" && pending !== null) {
const hint = document.createElement("em");
hint.className = "b05-route-profile__tool-hint";
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
@@ -334,6 +334,56 @@
opacity: 0.5;
}
/* [자유지정] 오버레이 — 범위 띠 · 지나온 점선 · 마우스까지 미리 보기 · 기울기 글(215-1).
기준 기울기를 넘는 구간은 빨강. 클릭은 그래프 래퍼가 받으므로 오버레이는 마우스를 통과시킨다. */
.b05-profile-freemark {
position: absolute;
z-index: 5;
inset: 0;
pointer-events: none;
}
.b05-profile-freemark__band {
fill: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.b05-profile-freemark__edge {
stroke: var(--color-primary);
stroke-width: 1.5;
}
.b05-profile-freemark__line {
stroke: var(--color-primary);
stroke-width: 2;
stroke-dasharray: 6 4;
}
.b05-profile-freemark__line.is-preview {
opacity: 0.7;
}
.b05-profile-freemark__line.is-over {
stroke: var(--color-danger, #d64545);
}
.b05-profile-freemark__dot {
fill: var(--color-surface);
stroke: var(--color-primary);
stroke-width: 1.5;
}
.b05-profile-freemark__grade {
fill: var(--color-text);
font-size: var(--text-caption);
paint-order: stroke;
stroke: var(--color-surface);
stroke-width: 3px;
}
.b05-profile-freemark__grade.is-over {
fill: var(--color-danger, #d64545);
}
.b05-profile-edit {
position: absolute;
z-index: 4;