refactor(B05,B06): B05_wf2_Route -> B05_Profile, B06_wf3_ProfileCross -> B06_Section 동시 개명
- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수) - B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존) - 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section), 라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로 - 로직 변경 없음. typecheck·백엔드 import 검증 통과 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Edit.ts
|
||||
* 종단 계획선 편집 상태 보관과 그래프 위 숨김 버튼 오버레이.
|
||||
*
|
||||
* 편집 델타는 세션 저장소에 초안으로 남겨 새로고침해도 작업이 날아가지 않게 하고,
|
||||
* 실제 영속화는 [확정] 시점에 서버로 보낸다.
|
||||
*
|
||||
* 버튼 구성 (평시 투명, 패널 hover 시 노출):
|
||||
* - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다.
|
||||
* - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동).
|
||||
* - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다.
|
||||
*
|
||||
* 측점 버튼과 구간 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과
|
||||
* 측점 라벨에 가려 보이지 않기 때문이다. 자리가 겹칠 땐 측점 버튼을 수직선 위에 고정하고
|
||||
* 구간 버튼만 옆으로 비킨다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
AlignmentEdits,
|
||||
AlignmentSegment,
|
||||
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";
|
||||
/** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
|
||||
const BUTTON_CLEARANCE_PX = 23;
|
||||
const BUTTON_HALF_PX = 10;
|
||||
/** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */
|
||||
const HOLD_DELAY_MS = 500;
|
||||
const HOLD_INTERVAL_MS = 100;
|
||||
|
||||
/**
|
||||
* 길게 누르는 동안 같은 동작을 반복한다.
|
||||
*
|
||||
* 버튼은 편집 한 번마다 다시 그려져 DOM에서 사라지므로, 타이머와 종료 감지를
|
||||
* 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나
|
||||
* 반대로 멈추지 않는 상황이 생기지 않는다.
|
||||
*/
|
||||
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
|
||||
let delayTimer = 0;
|
||||
let repeatTimer = 0;
|
||||
|
||||
function stop(): void {
|
||||
window.clearTimeout(delayTimer);
|
||||
window.clearInterval(repeatTimer);
|
||||
delayTimer = 0;
|
||||
repeatTimer = 0;
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
window.removeEventListener("blur", stop);
|
||||
}
|
||||
|
||||
function start(action: () => void): void {
|
||||
stop();
|
||||
delayTimer = window.setTimeout(() => {
|
||||
repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS);
|
||||
}, HOLD_DELAY_MS);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
window.addEventListener("blur", stop);
|
||||
}
|
||||
|
||||
return { start, stop };
|
||||
}
|
||||
|
||||
export interface ProfileEditStore {
|
||||
edits(): AlignmentEdits;
|
||||
replace(next: AlignmentEdits): void;
|
||||
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다. */
|
||||
markSaved(): void;
|
||||
resetStation(chainageM: number): void;
|
||||
resetAll(): void;
|
||||
/** 아직 서버에 반영되지 않은 변경이 있는가. */
|
||||
dirty(): boolean;
|
||||
/** 자동 선형 대비 편집 델타가 하나라도 있는가. */
|
||||
edited(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선의 미저장 편집 초안을 읽는다(B06 공용).
|
||||
*
|
||||
* B06이 진입 시 stale 횡단 설계를 일괄 재계산할 때, B05가 확정 없이 남긴 세션 초안까지
|
||||
* 반영해야 두 화면의 계획선이 같은 값으로 계산된다 — 저장분(profile_alignment.edits)만
|
||||
* 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다.
|
||||
*/
|
||||
export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null {
|
||||
return readDraft(`${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`);
|
||||
}
|
||||
|
||||
function readDraft(storageKey: string): AlignmentEdits | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<AlignmentEdits>;
|
||||
return {
|
||||
station_offsets: parsed.station_offsets ?? {},
|
||||
curve_radii: parsed.curve_radii ?? {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집 델타 보관소.
|
||||
*
|
||||
* 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이
|
||||
* 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면
|
||||
* 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
|
||||
*/
|
||||
export function createProfileEditStore(
|
||||
routeId: number | null,
|
||||
saved: AlignmentEdits,
|
||||
onChange: () => void,
|
||||
): ProfileEditStore {
|
||||
const storageKey = `${DRAFT_KEY_PREFIX}:${routeId ?? "none"}`;
|
||||
const draft = readDraft(storageKey);
|
||||
let current = draft ?? saved;
|
||||
let unsaved = draft !== null;
|
||||
|
||||
function dropDraft(): void {
|
||||
try {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// 세션 저장소를 못 쓰는 환경에서도 편집 자체는 계속 동작해야 한다.
|
||||
}
|
||||
}
|
||||
|
||||
function commit(next: AlignmentEdits): void {
|
||||
current = next;
|
||||
unsaved = true;
|
||||
try {
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(current));
|
||||
} catch {
|
||||
// 초안 보관 실패는 편집을 막지 않는다.
|
||||
}
|
||||
onChange();
|
||||
}
|
||||
|
||||
return {
|
||||
edits: () => current,
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
unsaved = false;
|
||||
dropDraft();
|
||||
},
|
||||
resetStation(chainageM) {
|
||||
const key = chainageKey(chainageM);
|
||||
const stationOffsets = { ...current.station_offsets };
|
||||
const curveRadii = { ...current.curve_radii };
|
||||
delete stationOffsets[key];
|
||||
delete curveRadii[key];
|
||||
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
|
||||
},
|
||||
resetAll() {
|
||||
commit(emptyEdits());
|
||||
},
|
||||
dirty: () => unsaved,
|
||||
edited: () => hasEdits(current),
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditOverlayOptions {
|
||||
alignment: ProfileAlignment;
|
||||
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;
|
||||
}
|
||||
|
||||
function overlayButton(
|
||||
repeater: { start: (action: () => void) => void },
|
||||
className: string,
|
||||
glyph: string,
|
||||
title: string,
|
||||
onAction: () => void,
|
||||
): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = `b05-profile-edit__btn ${className}`;
|
||||
button.textContent = glyph;
|
||||
button.title = `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)`;
|
||||
// 포인터로 누르면 즉시 1회 반응하고, 이어지는 click은 중복이므로 삼킨다.
|
||||
let swallowClick = false;
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.stopPropagation();
|
||||
swallowClick = true;
|
||||
onAction();
|
||||
repeater.start(onAction);
|
||||
});
|
||||
button.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (swallowClick) {
|
||||
swallowClick = false;
|
||||
return;
|
||||
}
|
||||
onAction();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 계획선 샘플에서 chainage 위치의 계획고를 선형보간한다(버튼 라벨용). */
|
||||
function planElevationAtSample(alignment: ProfileAlignment, chainageM: number): number {
|
||||
const samples = alignment.samples;
|
||||
if (!samples.length) return 0;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last.elevation_m;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const current = samples[index];
|
||||
if (current.chainage_m < chainageM) continue;
|
||||
const previous = samples[index - 1];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
const ratio = span > 1e-9 ? (chainageM - previous.chainage_m) / span : 0;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
}
|
||||
return last.elevation_m;
|
||||
}
|
||||
|
||||
/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */
|
||||
export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
|
||||
const { alignment, width, x, step, onStation, onSegment, onResetStation, onResetSegment } =
|
||||
options;
|
||||
const irregular = options.irregularStations ?? [];
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "b05-profile-edit";
|
||||
layer.style.width = `${width}px`;
|
||||
|
||||
const repeater = createHoldRepeater();
|
||||
const edited = new Set(Object.keys(alignment.edits.station_offsets));
|
||||
|
||||
/**
|
||||
* 규칙·비정규 측점을 chainage 순으로 합치고, 버튼끼리 겹치지 않게 자리를 잡는다.
|
||||
* 비정규 측점(구조물)이 규칙 측점 바로 옆에 서면 ▲▼가 서로 덮였다(2026-08-04 사용자
|
||||
* 보고) — 앞 버튼과 최소 간격(BUTTON_CLEARANCE_PX)을 강제하며 오른쪽으로 밀어낸다.
|
||||
* 측점선 자체는 그대로라 버튼만 살짝 비켜선다.
|
||||
*
|
||||
* 확정을 거친 구조물 측점은 종단 정본(alignment.stations)에 **이미 병합**돼 있어
|
||||
* 사이드바 목록(irregular)과 이중으로 잡힌다 — 같은 chainage(소수 3자리)는 한 번만
|
||||
* 버튼을 단다(2026-08-04 사용자 보고: 구조물 측점 버튼이 2개씩 생김).
|
||||
*/
|
||||
const alignmentStationKeys = new Set(
|
||||
alignment.stations.map((station) => chainageKey(station.chainage_m)),
|
||||
);
|
||||
const stationPlacements = [
|
||||
...alignment.stations.map((station) => ({
|
||||
chainage: station.chainage_m,
|
||||
plan: station.plan_elevation_m,
|
||||
})),
|
||||
...irregular
|
||||
.filter((station) => !alignmentStationKeys.has(chainageKey(station.chainage_m)))
|
||||
.map((station) => ({
|
||||
chainage: station.chainage_m,
|
||||
plan: planElevationAtSample(alignment, station.chainage_m),
|
||||
})),
|
||||
]
|
||||
.sort((a, b) => a.chainage - b.chainage)
|
||||
.map(
|
||||
(() => {
|
||||
let previousX = Number.NEGATIVE_INFINITY;
|
||||
return (entry: { chainage: number; plan: number }) => {
|
||||
const placed = Math.max(x(entry.chainage), previousX + BUTTON_CLEARANCE_PX);
|
||||
previousX = placed;
|
||||
return { ...entry, left: placed };
|
||||
};
|
||||
})(),
|
||||
);
|
||||
const stationXs = stationPlacements.map((entry) => entry.left);
|
||||
|
||||
/**
|
||||
* 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다.
|
||||
* 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고
|
||||
* 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다).
|
||||
*/
|
||||
function avoidStations(center: number): number {
|
||||
let nearest = center;
|
||||
let best = Infinity;
|
||||
for (const stationX of stationXs) {
|
||||
const distance = Math.abs(stationX - center);
|
||||
if (distance < best) {
|
||||
best = distance;
|
||||
nearest = stationX;
|
||||
}
|
||||
}
|
||||
return best < BUTTON_CLEARANCE_PX ? nearest + BUTTON_CLEARANCE_PX : center;
|
||||
}
|
||||
// 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도
|
||||
// `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
|
||||
// left는 겹침 회피가 끝난 화면 x — 측점 수직선(x(chainage))과 다를 수 있다.
|
||||
function addStationButtons(chainageM: number, planElevationM: number, left: number): void {
|
||||
const isEdited = edited.has(chainageKey(chainageM));
|
||||
const label = `${chainageM.toFixed(1)}m 계획고 ${planElevationM.toFixed(2)}m`;
|
||||
|
||||
const up = overlayButton(repeater, "is-station is-up", "▲", `${label} — ${step}m 올림`, () =>
|
||||
onStation(chainageM, step),
|
||||
);
|
||||
up.style.left = `${left - BUTTON_HALF_PX}px`;
|
||||
const down = overlayButton(
|
||||
repeater,
|
||||
"is-station is-down",
|
||||
"▼",
|
||||
`${label} — ${step}m 내림`,
|
||||
() => onStation(chainageM, -step),
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user