fix(468 값 안 변함 ③): 되돌리기 빈 칸 · 접힘 파생값 · 배지 소수 한 자리
- 사진첩: 「{}」 · 「null」 · 「""」 = 칸 없음 — 빈 맵 쓰기가 걸음이 되어 ↷ 를 버리던 흠(R4) · 「[]」(관 · 구조물 모두 지움)은 값으로 둠
- 세션 맵 · 연동 플래그: 빈 맵 persist = 칸 지움(갈아끼움 이동이 「{}」 여섯 칸을 만들지 않음)
- 종단도: 본문 0 크기(판 접힘)에도 낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료를 셈 · 그리기만 건너뜀 — 떼어 낸 유토 판은 그림
- 배지: 소수 한 자리 고정(−17,570.0㎥)
- 시험: test_272_history_empty_cells · test_272_hidden_summary(옛 코드 7 실패)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,11 +45,19 @@ const isEditKey = (key: string, projectId: string): boolean =>
|
||||
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
|
||||
export type HistorySnapshot = Record<string, string>;
|
||||
|
||||
/** 빈 칸 = 칸 없음(272 · 떠남 확인 `filled` 와 한 규칙) — 빈 맵을 쓴 것은 걸음이 아님.
|
||||
* 「[]」 는 목록 초안(관 · 구조물 모두 지움 ≠ 저장분)이라 값으로 찍음. */
|
||||
const EMPTY_CELLS = new Set(["", "{}", "null"]);
|
||||
const cellOf = (key: string): string | null => {
|
||||
const value = readByKey(key);
|
||||
return value === null || EMPTY_CELLS.has(value) ? null : value;
|
||||
};
|
||||
|
||||
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */
|
||||
function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot {
|
||||
const snapshot: HistorySnapshot = {};
|
||||
for (const key of editKeys(projectId)) {
|
||||
const value = readByKey(key);
|
||||
const value = cellOf(key);
|
||||
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
|
||||
}
|
||||
return snapshot;
|
||||
@@ -255,7 +263,7 @@ export function createProfileHistory(
|
||||
stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습
|
||||
};
|
||||
seedActive = (key) => {
|
||||
const value = isEditKey(key, projectId) ? readByKey(key) : null;
|
||||
const value = isEditKey(key, projectId) ? cellOf(key) : null;
|
||||
if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value));
|
||||
};
|
||||
active = history;
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface RouteMassHaulDrawParams {
|
||||
pendingRecalc?: boolean;
|
||||
/** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */
|
||||
staleChainages?: number[];
|
||||
/** 종단 본문이 0 크기(판 접힘) — 이 판도 자리가 없으면 총괄값만(떼어 낸 창이면 그림 · 272). */
|
||||
bodyHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteMassHaulPanel {
|
||||
@@ -339,7 +341,7 @@ export function createRouteMassHaulPanel(
|
||||
syncHandlePosition();
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!open) {
|
||||
if (!open || (params.bodyHidden && overlay.clientWidth <= 0)) {
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,11 +128,101 @@ export interface ProfileRenderContext {
|
||||
setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void;
|
||||
}
|
||||
|
||||
/** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */
|
||||
/** 그림과 상관없는 파생값 — 낡음 예약 · 측점 자료(272 · 접힘 · 0 크기에도 같은 값). */
|
||||
function deriveProfile(
|
||||
ctx: ProfileRenderContext,
|
||||
detail: SectionDetailResponse,
|
||||
alignment: ProfileAlignment | null,
|
||||
irregularStations: IrregularStation[],
|
||||
) {
|
||||
const longitudinal = detail.longitudinal;
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1)
|
||||
const stale = staleKey({
|
||||
longitudinal: { design_profiles: designProfiles },
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: oneStationList(graphData.stations, injected),
|
||||
};
|
||||
return { designProfiles, stale, pending, graphData, graphLongitudinal };
|
||||
}
|
||||
type DerivedProfile = ReturnType<typeof deriveProfile>;
|
||||
|
||||
/** 유토곡선에 넘길 값 — 그림과 숨은 셈이 한 벌(가로 배치만 다름). */
|
||||
function massHaulParamsOf(
|
||||
ctx: ProfileRenderContext,
|
||||
detail: SectionDetailResponse,
|
||||
derived: DerivedProfile,
|
||||
view: { originOffset: number; fromM: number; toM: number; widthPx: number },
|
||||
stationIntervalM: number | undefined,
|
||||
bodyHidden: boolean,
|
||||
): RouteMassHaulDrawParams {
|
||||
const { longitudinal } = detail;
|
||||
const { designProfiles, stale, pending } = derived;
|
||||
return {
|
||||
stationSource: derived.graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
// 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이
|
||||
// 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
||||
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
||||
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
||||
pendingRecalc: pending,
|
||||
staleChainages: pending ? [] : staleChainages(stale),
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
|
||||
padLeft: LONG_PAD.left + view.originOffset,
|
||||
padRight: LONG_PAD.right + view.originOffset,
|
||||
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
|
||||
axisX: LONG_PAD.left,
|
||||
// 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다
|
||||
// (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음).
|
||||
padTop: 30,
|
||||
// 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도
|
||||
// 곡선이 납작하게 눌린다(2026-09-04 사용자 지시).
|
||||
viewRange: { fromM: view.fromM, toM: view.toM },
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: view.widthPx,
|
||||
selectedStationId: ctx.selectedStationId(),
|
||||
onSelectStation: ctx.selectStation,
|
||||
bodyHidden,
|
||||
};
|
||||
}
|
||||
|
||||
/** 본문을 통째로 다시 그린다. 상세가 없으면 아무것도 하지 않는다.
|
||||
* 본문이 0 크기(판 접힘 · 아직 자리 없음)면 파생값(낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료)만
|
||||
* 셈 · 그리기만 건너뜀 — 펼침 · 떼어 낸 유토 창과 같은 값(272). */
|
||||
export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
const { body, callbacks, massHaul, tableOverlay, store } = ctx;
|
||||
const detail = ctx.detail();
|
||||
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
||||
if (!detail) return;
|
||||
if (body.clientWidth <= 0 || body.clientHeight <= 0) {
|
||||
const alignment = ctx.alignment();
|
||||
const derived = deriveProfile(ctx, detail, alignment, ctx.irregularStations());
|
||||
if (alignment && derived.designProfiles[0]) {
|
||||
const maxM = maxChainageOf(detail.longitudinal);
|
||||
const view = { originOffset: 0, fromM: 0, toM: maxM, widthPx: 1 };
|
||||
const interval = ctx.stationInterval() ?? alignment.policy.station_interval_m;
|
||||
massHaul.draw(massHaulParamsOf(ctx, detail, derived, view, interval, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const alignment = ctx.alignment();
|
||||
const base = ctx.base();
|
||||
const stationInterval = ctx.stationInterval();
|
||||
@@ -252,24 +342,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
intervalM: stationIntervalM ?? 20,
|
||||
});
|
||||
});
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1)
|
||||
const stale = staleKey({
|
||||
longitudinal: { design_profiles: designProfiles },
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: oneStationList(graphData.stations, injected),
|
||||
};
|
||||
const derived = deriveProfile(ctx, detail, alignment, irregularStations);
|
||||
const { designProfiles, graphData, graphLongitudinal } = derived;
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
|
||||
const toChainage = chainageInverter(longitudinal, width, originOffset);
|
||||
@@ -441,39 +515,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
|
||||
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
|
||||
if (alignment && designProfiles[0]) {
|
||||
const massHaulParams: RouteMassHaulDrawParams = {
|
||||
stationSource: graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
// 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이
|
||||
// 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
||||
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
||||
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
||||
pendingRecalc: pending,
|
||||
staleChainages: pending ? [] : staleChainages(stale),
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
|
||||
padLeft: LONG_PAD.left + originOffset,
|
||||
padRight: LONG_PAD.right + originOffset,
|
||||
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
|
||||
axisX: LONG_PAD.left,
|
||||
// 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다
|
||||
// (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음).
|
||||
padTop: 30,
|
||||
// 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도
|
||||
// 곡선이 납작하게 눌린다(2026-09-04 사용자 지시).
|
||||
viewRange: { fromM: viewFromM, toM: viewToM },
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: width,
|
||||
selectedStationId,
|
||||
onSelectStation: ctx.selectStation,
|
||||
};
|
||||
massHaul.draw(massHaulParams);
|
||||
const view = { originOffset, fromM: viewFromM, toM: viewToM, widthPx: width };
|
||||
massHaul.draw(massHaulParamsOf(ctx, detail, derived, view, stationIntervalM, false));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user