Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Edit.ts
T
eomsangdonandClaude Opus 5.5 90c295cebf fix(472-5 덧 노선 초안 ↶): 버림 = 차례 하나 · 값 하나 — 사이 섞인 유토곡선 · 배지 없음
원인(브라우저 차례 엿보기): 관 옆 노선 교점 끌기 = 노선 초안(관은 follow 로 따라감) · ↶ 가
① 버림 차례(조용 · 초안 전 상세 되돌림)와 되돌리기 훅의 관 먹이기 차례 둘로 갈림(버림이 밀림)
② 버림 시작~되돌림 사이 종단도가 초안 때 갈아끼운 저장 상세 · 초안 종단과 섞어 그림
③ 되돌린 뒤 시설 줄이 한 박자 늦어 「낡음」 다시 셈이 옛 관 자리 계획선으로 셈(관 측점 면적 한 틀 틀림)
④ 되돌림 · 갈아끼움이 읽기 길의 얹기(조정창 · 관 옵션)를 빼고 면적부터 셈

- 되돌리기 훅: 버림 동안 관 · 구조물 안 먹임(버림 차례가 follow 로 초안 전 값까지)
- 버림 시작부터 종단도 그리기 멈춤(holdDraw) · 되돌림 바로 뒤 저장 기준 맞춤 · 묶음이 조용해지면 한 번 그림
- 종단도가 저장 상세를 받을 때 노선 초안이 이미 없으면 그 자리에서 저장 기준 · 저장 노선 셈 편집 = 그 노선 것(routeAlignment)
- 버림 동안 저장 노선 낡음 다시 셈(recross) 안 띄움(pipelineHolding)
- 상세 가게 얹기 꼬리 한 벌(withAdjustsAndAreas) — 읽기 · 갈아끼움 · 되돌림
- 시험 test_472_5c_drop_once(옛 코드 빨강) · 위키 규칙 한 줄 · 줄 번호

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:01:18 +09:00

417 lines
18 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_Profile_Edit.ts
* 종단 계획선 편집 상태 보관과 그래프 위 숨김 버튼 오버레이.
*
* 편집 델타는 세션 저장소에 초안으로 남겨 새로고침해도 작업이 날아가지 않게 하고,
* 실제 영속화는 [확정] 시점에 서버로 보낸다.
*
* 버튼 구성 (평시 투명, 패널 hover 시 노출):
* - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다.
*
* 구간 ⇧⇩(평행이동)과 원복 ↺ 는 2026-09-02 사용자 지시로 삭제했다 — 구간 조작은
* [직선화]+[쉬프트]가, 되돌리기는 undo/redo가 대신한다.
*
* 측점 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과 측점
* 라벨에 가려 보이지 않기 때문이다.
* ========================================================================== */
import type { AlignmentEdits, ProfileAlignment } from "./logic/B05_Profile_UI_Profile_Alignment";
import { chainageKey, emptyEdits, hasEdits } from "./logic/B05_Profile_UI_Profile_Alignment";
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
import { clearState } from "../A00_Common/b_page_state";
import { viewOf } from "../common_util/common_util_view_window";
/** 버튼 폭(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(node: Node): {
start: (action: () => void) => void;
stop: () => void;
} {
let delayTimer = 0;
let repeatTimer = 0;
/** 시계는 버튼 층이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 시계는 늦게 돎(230-2). */
let timerView: Window = window;
function stop(): void {
timerView.clearTimeout(delayTimer);
timerView.clearInterval(repeatTimer);
delayTimer = 0;
repeatTimer = 0;
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
window.removeEventListener("blur", stop);
}
function start(action: () => void): void {
stop();
timerView = viewOf(node);
delayTimer = timerView.setTimeout(() => {
repeatTimer = timerView.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;
/** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */
savedRadii(): Readonly<Record<string, number>>;
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;
/** ↶ ↷ 가 세션을 되쓴 뒤 — 이 창구가 마지막으로 본 편집과 다른가(다시 셈 여부). */
resync(): 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 {
// 등록부 칸(`alignment-draft`) — 노선을 안 주면 그 프로젝트의 모든 노선 칸을 쓸어 낸다.
clearState("alignment-draft", draftProjectId());
}
/**
* 노선마다 편집 값 주인 = 설계 가게 두 칸(472-5) — 두 보기의 종단도(`design_profile`)가 같은 칸을 읽는다.
* `alignmentDraft` = 미저장 편집(세션 · 되돌리기가 사진 찍음) · 있으면 미저장
* `alignmentSaved` = 서버 저장분(메모리 · 종단도가 상세를 받을 때 · [저장]이 옮김) — [초기화]의 기준점 ·
* 측점 원복 · 전체 원복이 자동 곡선반경을 되살리는 기준(2026-08-23 · 2026-09-03 사용자 지시)
* 지금 편집 = 초안 ?? 저장분 — 모듈 사본이 없어 ↶ ↷ 가 세션을 되써도 두 보기가 같은 값을 본다.
*/
const savedOf = (routeId: number | null): AlignmentEdits =>
(routeId === null ? null : draftOf(routeId).get("alignmentSaved")) ?? emptyEdits();
/** 그 노선 지금 편집(초안 ?? 저장분) — 창구 없이 읽음(노선 초안 미리보기 동안 저장 노선 셈 · 472-5 덧). */
export const routeAlignment = (routeId: number): AlignmentEdits =>
readAlignmentDraft(routeId) ?? savedOf(routeId);
/** [저장]이 보낼 계획고 초안 — 미저장 편집이 없으면 null. */
export function pendingAlignment(routeId: number): AlignmentEdits | null {
return readAlignmentDraft(routeId);
}
/** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */
export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void {
draftOf(routeId).set("alignmentDraft", edits);
}
/** 계획고가 정본에 나갔다 — 그 노선 초안을 저장분으로 옮기고 초안을 지운다. */
export function markAlignmentSaved(routeId: number): void {
const store = draftOf(routeId);
const draft = store.get("alignmentDraft");
if (draft) store.set("alignmentSaved", draft);
store.set("alignmentDraft", null);
}
/**
* 편집 창구.
*
* `saved` = 서버에 저장된 편집분(종단도가 상세를 받을 때) — 주면 그 노선 저장분 칸을 갈아 끼움 · 안 주면
* 칸 그대로. 세션 초안이 남아 있으면(= 확정 없이 새로고침한 경우) 초안이 이기고 미저장이다. routeId가
* 바뀌면 칸도 바뀌어 이전 노선의 편집이 새 노선에 잘못 얹히지 않는다.
*
* routeId가 아직 없으면 **초안을 두지 않는다**(2026-09-04). 노선 없는 키(`:none`)에
* 쌓인 초안은 어느 노선에나 되붙어 초기화로도 떨어지지 않았다.
*/
export function createProfileEditStore(
routeId: number | null,
saved: AlignmentEdits | undefined,
onChange: () => void,
): ProfileEditStore {
const cells = routeId === null ? localCells(saved) : storeCells(routeId, saved);
const editsNow = (): AlignmentEdits => cells.draft() ?? cells.saved();
/** 이 창구가 마지막으로 본 편집 — ↶ ↷ 가 세션을 되쓴 뒤 바뀌었는지(`resync`). */
let seen = JSON.stringify(editsNow());
function commit(next: AlignmentEdits): void {
seen = JSON.stringify(next);
cells.setDraft(next);
onChange();
}
return {
edits: editsNow,
savedRadii: () => cells.saved().curve_radii ?? {},
replace: commit,
markSaved() {
const draft = cells.draft();
if (draft) cells.setSaved(draft);
cells.setDraft(null);
},
restoreSaved() {
seen = JSON.stringify(cells.saved());
cells.setDraft(null);
onChange();
},
canRestoreSaved: () => JSON.stringify(editsNow()) !== JSON.stringify(cells.saved()),
resetStation(chainageM, moveTo) {
const current = editsNow();
const key = chainageKey(chainageM);
const stationOffsets = { ...current.station_offsets };
const curveRadii = { ...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 = cells.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: { ...(cells.saved().curve_radii ?? {}) } });
},
dirty: () => cells.draft() !== null,
edited: () => hasEdits(editsNow()),
resync() {
const now = JSON.stringify(editsNow());
const changed = now !== seen;
seen = now;
return changed;
},
};
}
interface EditCells {
draft(): AlignmentEdits | null;
setDraft(value: AlignmentEdits | null): void;
saved(): AlignmentEdits;
setSaved(value: AlignmentEdits): void;
}
/** 노선 칸 = 설계 가게 두 칸. */
function storeCells(routeId: number, saved: AlignmentEdits | undefined): EditCells {
const store = draftOf(routeId);
if (saved) store.set("alignmentSaved", saved);
return {
draft: () => store.get("alignmentDraft"),
setDraft: (value) => store.set("alignmentDraft", value),
saved: () => savedOf(routeId),
setSaved: (value) => store.set("alignmentSaved", value),
};
}
/** 노선이 아직 없음 — 초안을 세션에 두지 않음(창구 안 메모리만 · 2026-09-04). */
function localCells(saved: AlignmentEdits | undefined): EditCells {
let draft: AlignmentEdits | null = null;
let base = saved ?? emptyEdits();
return {
draft: () => draft,
setDraft: (value) => void (draft = value),
saved: () => base,
setSaved: (value) => void (base = value),
};
}
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(layer);
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;
}