/* ============================================================================= * B05_Profile_UI_Profile_Edit.ts * 종단 계획선 편집 상태 보관과 그래프 위 숨김 버튼 오버레이. * * 편집 델타는 세션 저장소에 초안으로 남겨 새로고침해도 작업이 날아가지 않게 하고, * 실제 영속화는 [확정] 시점에 서버로 보낸다. * * 버튼 구성 (평시 투명, 패널 hover 시 노출): * - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다. * * 구간 ⇧⇩(평행이동)과 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — 구간 조작은 * [직선화]+[쉬프트]가, 되돌리기는 undo/redo가 대신한다. * * 측점 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 측점 * 라벨에 가려 보이지 않기 때문이다. * ========================================================================== */ import type { AlignmentEdits, ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; import { chainageKey, emptyEdits, hasEdits } from "./B05_Profile_UI_Profile_Alignment"; import { ALIGNMENT_DRAFT_KEY_PREFIX, draftProjectId, useDesignStore, } from "../A00_Common/design/design_store"; import { clearState } from "../A00_Common/b_page_state"; /** 버튼 폭(21px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */ const BUTTON_CLEARANCE_PX = 23; const BUTTON_HALF_PX = 10; /** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */ export const HOLD_DELAY_MS = 500; export 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; /** 마지막 [저장] 시점으로 되돌린다 — 그 뒤의 편집만 버린다(2026-09-03 사용자 지시). */ restoreSaved(): void; /** 마지막 저장 시점과 지금이 다른가([초기화] 활성 조건). */ canRestoreSaved(): boolean; /** `moveTo` 를 주면 지우지 않고 그 측점으로 옮김(서버 반올림으로 측점이 조금 바뀐 때). */ resetStation(chainageM: number, moveTo?: number): void; resetAll(): void; /** 아직 서버에 반영되지 않은 변경이 있는가. */ dirty(): boolean; /** 자동 선형 대비 편집 델타가 하나라도 있는가. */ edited(): boolean; } /** 초안 읽기 · 쓰기는 설계 가게(`design_store` 의 `alignmentDraft`) 한 창구로만. */ const draftOf = (routeId: number) => useDesignStore(null, routeId); /** * 노선의 미저장 편집 초안을 읽는다(B06 공용). * * B06이 진입 시 stale 횡단 설계를 일괄 재계산할 때, B05가 확정 없이 남긴 세션 초안까지 * 반영해야 두 화면의 계획선이 같은 값으로 계산된다 — 저장분(profile_alignment.edits)만 * 쓰면 초안 편집분이 B06에서 되돌아간 것처럼 보인다. */ export function readAlignmentDraft(routeId: number | null): AlignmentEdits | null { return routeId === null ? null : draftOf(routeId).get("alignmentDraft"); } /** * 계획선 편집 초안을 모두 버린다 — [초기화]가 부른다. * * 초기화는 서버 값을 초기 스냅샷으로 되돌리는데, 이 초안이 남아 있으면 화면이 그 위에 * 옛 편집 델타를 다시 얹어 계획선이 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: * 초기화 직후에는 멀쩡하다가 새로고침 한 번에 최대 6.0m 어긋남). * 노선 번호를 모르는 옛 초안까지 쓸어야 해 여기서만 키를 훑는다. */ export function clearAlignmentDrafts(): void { shared.clear(); // 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다. clearState("alignment-draft", draftProjectId()); try { const keys: string[] = []; for (let index = 0; index < sessionStorage.length; index += 1) { const key = sessionStorage.key(index); if (key?.startsWith(`${ALIGNMENT_DRAFT_KEY_PREFIX}:`)) keys.push(key); } keys.forEach((key) => sessionStorage.removeItem(key)); } catch { // 세션 저장소를 못 쓰는 환경이면 남길 초안도 없다. } } /** * 노선마다 **한 벌**인 편집 상태 — 두 보기의 종단도(`design_profile`)가 같은 벌을 * 쓴다(계획서 24-2a). 예전에는 두 화면이 가게를 따로 만들어 같은 초안 키를 각자 고쳤다 — * 한쪽이 메모리에 쥔 옛 값으로 다시 쓰면 다른 쪽 편집이 덮였다. */ interface EditState { current: AlignmentEdits; unsaved: boolean; /** * [초기화]의 기준점 — **마지막 [저장] 시점의 편집분**이다(2026-09-03 사용자 지시). * 처음에는 서버 저장분이고, [저장]을 누를 때마다 그때 값으로 옮겨 간다. 초기 자동 * 선형으로 되돌리는 것(`resetAll`)과는 다르다 — 저장한 작업까지 지우지 않는다. */ savedBaseline: AlignmentEdits; /** 서버 저장분 — 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준. */ saved: AlignmentEdits; } const shared = new Map(); /** 서버 저장분과 남은 초안으로 상태를 세운다(초안이 있으면 그것이 이기고 미저장). */ function freshState(routeId: number | null, saved: AlignmentEdits): EditState { const draft = readAlignmentDraft(routeId); return { current: draft ?? saved, unsaved: draft !== null, savedBaseline: saved, saved }; } /** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */ export function pendingAlignment(routeId: number): AlignmentEdits | null { const state = shared.get(routeId); if (state) return state.unsaved ? state.current : null; return readAlignmentDraft(routeId); } /** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */ export function markAlignmentSaved(routeId: number): void { const state = shared.get(routeId); if (state) { state.unsaved = false; state.savedBaseline = state.current; } draftOf(routeId).set("alignmentDraft", null); } /** * 편집 델타 보관소. * * 초기값은 **서버에 저장된 편집분**이고, 세션 초안이 남아 있으면(= 확정 없이 * 새로고침한 경우) 초안을 우선 채택하고 미저장 상태로 표시한다. routeId가 바뀌면 * 초안 키도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다. * * 부를 때마다 그 노선의 한 벌을 **초안 · 저장분으로 다시 세운다**(되돌리기가 초안을 바꾼 뒤 * 다시 읽는 길). 같은 노선이면 어느 화면이 불러도 같은 한 벌이다. * * routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에 * 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다. */ export function createProfileEditStore( routeId: number | null, saved: AlignmentEdits, onChange: () => void, ): ProfileEditStore { const fresh = freshState(routeId, saved); // 이미 선 한 벌은 **그 자리에서** 다시 세운다 — 바꿔 끼우면 붙어 있던 쪽이 옛 벌을 쥔다. const state = routeId === null ? fresh : Object.assign(shared.get(routeId) ?? fresh, fresh); if (routeId !== null) shared.set(routeId, state); return handleOf(routeId, state, onChange); } function handleOf( routeId: number | null, state: EditState, onChange: () => void, ): ProfileEditStore { const draft = routeId === null ? null : draftOf(routeId); function dropDraft(): void { draft?.set("alignmentDraft", null); } function commit(next: AlignmentEdits): void { state.current = next; state.unsaved = true; draft?.set("alignmentDraft", state.current); onChange(); } return { edits: () => state.current, replace: commit, markSaved() { state.unsaved = false; state.savedBaseline = state.current; dropDraft(); }, restoreSaved() { state.current = state.savedBaseline; state.unsaved = false; dropDraft(); onChange(); }, canRestoreSaved: () => JSON.stringify(state.current) !== JSON.stringify(state.savedBaseline), resetStation(chainageM, moveTo) { const key = chainageKey(chainageM); const stationOffsets = { ...state.current.station_offsets }; const curveRadii = { ...state.current.curve_radii }; if (moveTo !== undefined) { const to = chainageKey(moveTo); if (stationOffsets[key] !== undefined) stationOffsets[to] = stationOffsets[key]; if (curveRadii[key] !== undefined) curveRadii[to] = curveRadii[key]; delete stationOffsets[key]; delete curveRadii[key]; return commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); } delete stationOffsets[key]; // 그 측점의 곡선반경은 저장분(자동 계산이 역산해 둔 값)으로 되돌린다 — 지우면 // 프론트가 기본 R로 다시 그려 계획고가 서버 정본과 어긋난다(2026-08-23). const savedRadius = state.saved.curve_radii?.[key]; if (savedRadius === undefined) delete curveRadii[key]; else curveRadii[key] = savedRadius; commit({ station_offsets: stationOffsets, curve_radii: curveRadii }); }, resetAll() { // 지우는 것은 **사용자 편집 측점**이다. 자동 계획선이 역산해 저장한 곡선반경 // (`curve_radii`)까지 비우면 호가 달라져 화면 계획고가 정본과 달라진다 — // 세월교 지점에서 0.22m 낮게 나와 최소고 경고가 잘못 떴다(2026-08-23 진단). commit({ ...emptyEdits(), curve_radii: { ...(state.saved.curve_radii ?? {}) } }); }, dirty: () => state.unsaved, edited: () => hasEdits(state.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; } /** * 두 측점 목록이 **같은 측점**을 가리키는 것으로 볼 누가거리 차(m). * * 사이드바 구조물 목록은 표기 규칙(`n+ 0.0`)대로 0.1m 로 반올림한 값을 들고 있어 종단 * 정본의 원값과 최대 0.05m 어긋난다. 실제로 이만큼 붙은 별개 측점은 없다. */ const SAME_STATION_TOLERANCE_M = 0.1; 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 } = 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)과 이중으로 잡힌다 — 겹치면 한 번만 버튼을 단다 * (2026-08-04 사용자 보고: 구조물 측점 버튼이 2개씩 생김). * * 겹침 판정은 **거리**로 한다. 두 목록의 누가거리가 소수 3자리까지 같지 않기 때문이다 — * 종단 정본은 원값(`85.594514`), 사이드바 목록은 표기 규칙대로 0.1m 로 반올림한 값 * (`85.6`)을 들고 있어 키 비교로는 서로 다른 측점으로 잡혔다. 그 결과 버튼이 두 벌 생기고, * 사이드바 쪽 버튼을 누르면 정본 측점에서 몇 mm 떨어진 **가짜 변화점**이 승격돼 그 구간 * 기울기가 수천 %로 튀었다(2026-09-03 사용자 보고 · 실측 11,858%). */ const stationPlacements = [ ...alignment.stations.map((station) => ({ chainage: station.chainage_m, plan: station.plan_elevation_m, })), ...irregular .filter( (station) => !alignment.stations.some( (merged) => Math.abs(merged.chainage_m - station.chainage_m) <= SAME_STATION_TOLERANCE_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 }; }; })(), ); // 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도 // `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); // 측점 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — undo/redo 로 대체한다. void isEdited; } stationPlacements.forEach((entry) => addStationButtons(entry.chainage, entry.plan, entry.left)); // 구간 평행이동(⬆⬇)과 구간 원복 ↺ 도 같은 지시로 삭제했다. 구간 단위 조작은 // [직선화]로 만든 직선을 [쉬프트]로 옮기는 흐름이 대신한다. return layer; }