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