Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Edit.ts
T
2026-07-23 19:31:52 +09:00

253 lines
8.5 KiB
TypeScript

/* =============================================================================
* B05_wf2_Route_UI_Profile_Edit.ts
* 종단 계획선 편집 상태 보관과 그래프 위 숨김 버튼 오버레이.
*
* 편집 델타는 세션 저장소에 초안으로 남겨 새로고침해도 작업이 날아가지 않게 하고,
* 실제 영속화는 [확정] 시점에 서버로 보낸다.
*
* 버튼 구성 (평시 투명, 패널 hover 시 노출):
* - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다.
* - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동).
* - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다.
* ========================================================================== */
import type {
AlignmentEdits,
AlignmentSegment,
ProfileAlignment,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { chainageKey, emptyEdits, hasEdits } from "./B05_wf2_Route_UI_Profile_Alignment";
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 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;
}
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;
onStation: (chainageM: number, delta: number) => void;
onSegment: (segment: AlignmentSegment, delta: number) => void;
onResetStation: (chainageM: number) => 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;
}
/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */
export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const { alignment, width, x, step, onStation, onSegment, onResetStation } = options;
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));
alignment.stations.forEach((station) => {
const left = x(station.chainage_m);
const isEdited = edited.has(chainageKey(station.chainage_m));
const label = `${station.chainage_m.toFixed(1)}m 계획고 ${station.plan_elevation_m.toFixed(2)}m`;
const up = overlayButton(repeater, "is-station is-up", "▲", `${label} — ${step}m 올림`, () =>
onStation(station.chainage_m, step),
);
up.style.left = `${left - 9}px`;
const down = overlayButton(
repeater,
"is-station is-down",
"▼",
`${label} — ${step}m 내림`,
() => onStation(station.chainage_m, -step),
);
down.style.left = `${left - 9}px`;
layer.append(up, down);
if (!isEdited) return;
const offset = alignment.edits.station_offsets[chainageKey(station.chainage_m)];
const reset = overlayButton(
repeater,
"is-reset",
"↺",
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
() => onResetStation(station.chainage_m),
);
reset.style.left = `${left - 9}px`;
layer.append(reset);
});
alignment.segments.forEach((segment) => {
const left = x(segment.from_m);
const right = x(segment.to_m);
if (right - left < 36) return;
const center = (left + right) / 2;
const label =
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
const up = overlayButton(repeater, "is-segment is-up", "⇧", `${label} — ${step}m 올림`, () =>
onSegment(segment, step),
);
up.style.left = `${center - 9}px`;
const down = overlayButton(
repeater,
"is-segment is-down",
"⇩",
`${label} — ${step}m 내림`,
() => onSegment(segment, -step),
);
down.style.left = `${center - 9}px`;
layer.append(up, down);
});
return layer;
}