feat(B06,B05): 기슭막이 연동·경사를 재정의하고 링크 카드까지 강조한다

사용자 확정(2026-08-30) 세 가지를 반영한다.

① 링크 강조 — 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다. 선택
(조정창)과 강조를 분리해 `highlightFor`/`applyRevetHighlight`로 카드 재생성 없이
클래스만 갈아 끼운다(줌·팬 보존). 다단은 단 번호까지 같은 키로 짝짓는다.

② 연동 = 분리가 아니다 — 길이·전/후로 이미 하나의 기슭막이다. 연동을 푼 측점은
그 자리에 서므로 3D에서 벽이 노선과 평행이 아니라 자기 경로선을 따라간다. 링마다
폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 해제 시 단 수가
0이 되던 것(벽 4→2)도 고쳤다 — 소유 측점 4축에서 출발해 손댄 벽만 갈아 끼운다.

③ 경사 = 수평(Z 일원화) — 3D 링만 눕히던 것을 횡단도까지 넓혔다. 링크 카드 벽
상단 표고를 소유 측점과 맞추고(d = 소유 d + 1.2×dz) 차이는 노견 확장이 흡수한다.
그래서 수평 중에는 연동 해제 불가 — 버튼을 잠그고, 해제해 둔 값은 지운다.

곁다리: 3D 재빌드 해시에 extra_spans·revet_link_detached 추가(안 넣으면 저장
코리도가 안 만료된다), 캐시 저장 payload에 extra_spans 누락 보완, 3D 머리 주석을
"정본만 읽는다" → "캐시를 읽는다"로 정정.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:01:22 +09:00
co-authored by Claude Opus 5
parent 08c0dc3228
commit 0d4e4c758d
8 changed files with 502 additions and 115 deletions
+1
View File
@@ -105,6 +105,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
put("ford_adjust", design.ford_adjust);
put("box_adjust", design.box_adjust);
put("extra_wall_counts", design.extra_wall_counts);
put("extra_spans", design.extra_spans);
put("revet_link_detached", design.revet_link_detached);
put("revet_follow_grade", design.revet_follow_grade);
if (touched) patches.push(patch);
+103 -11
View File
@@ -10,6 +10,7 @@ import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Sectio
import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types";
import {
basinSpanOfSpec,
FILL_SLOPE_RATIO_MIN,
materialLabel,
materialLimit,
revetSpanOfSpec,
@@ -29,8 +30,16 @@ import {
*/
export interface RevetOffsetControl {
adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust;
/** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */
storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null;
selectedFor: (section: CrossSection) => RevetKey | null;
/** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다
* (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다).
* 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */
highlightFor: (section: CrossSection) => RevetKey | null;
select: (chainageM: number, key: RevetKey | null) => void;
/** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */
onSelectionChange?: () => void;
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void;
/** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */
@@ -154,7 +163,7 @@ export function tierSpanOf(section: CrossSection, key: string): StructureSpan {
}
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */
function culvertReach(section: CrossSection): StructureSpan | null {
export function culvertReach(section: CrossSection): StructureSpan | null {
const culvert = section.culvert;
if (!culvert) return null;
let beforeM = 0;
@@ -205,6 +214,26 @@ export function culvertOwnerFor(
return best;
}
/**
* 그 벽(기준벽·다단)이 **이 측점 카드에도 서는가** — 연동 하이라이트 짝짓기용
* (2026-08-30 사용자). 판정은 그리기(`trimLinkedLayout`)와 같은 구간값을 쓴다:
* 기준벽은 자기 길이·전/후, 다단은 단별 구간값(`tierSpanOf`).
*/
export function wallStandsAt(owner: CrossSection, section: CrossSection, key: string): boolean {
const deltaM = section.chainage_m - owner.chainage_m;
if (Math.abs(deltaM) < 0.01) return true;
const spec = owner.culvert;
if (!spec) return false;
if (key.startsWith("extra") || key.startsWith("bextra")) {
return spanCovers(tierSpanOf(owner, key), deltaM);
}
const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null;
if (!side) return false;
// 집수정은 소유 측점 하나에만 선다(그리기와 같은 규칙) — 링크 카드에는 없다.
if (side.structure === "집수정" && !spec.hidden_pipe) return false;
return spanCovers(revetSpanOfSpec(side), deltaM);
}
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
@@ -352,23 +381,41 @@ export function computeCardCulvert(
// 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다.
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
const ownerAdjusts = detached
? adjustsInput(section, revetOffset, extraWalls)
: (computeCulvertLayout(
link.source,
link.source.samples,
adjustsInput(link.source, revetOffset, extraWalls),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls));
const ownerInput = adjustsInput(link.source, revetOffset, extraWalls);
const ownerApplied =
computeCulvertLayout(
link.source,
link.source.samples,
ownerInput,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? ownerInput;
// 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄
// 벽만 자기 값으로 선다(2026-08-30 사용자).
const ownerAdjusts = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
// 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽
// 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용"
// 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점
// 노견에서 그대로 출발한다(2026-08-24 "성토선이 노견에서 떨어진다" 지적 회피).
const followGrade =
revetLink?.followGradeFor(link.source) ?? link.source.design?.revet_follow_grade !== false;
const layout = computeCulvertLayout(
hosted,
sourceSamples,
ownerAdjusts,
followGrade ? ownerAdjusts : levelToOwner(ownerAdjusts, link.dz),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(detached ? section : link.source),
);
if (!layout) return null;
// 노견 위로는 못 올라간다(기하가 d를 0에서 자른다) — 조작 중인 벽만 이유를 알린다.
if (!followGrade && (activeRevet === "inlet" || activeRevet === "outlet")) {
const requested = levelToOwner(ownerAdjusts, link.dz);
const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet;
const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet;
if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) {
showToast(L("B06_Cross_Revet_Limit_Up"), "info");
}
}
// 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나
// 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서
// 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적).
@@ -492,6 +539,51 @@ export function computeCardCulvert(
return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout;
}
/**
* **수평(Z 일원화) 보정**(2026-08-30 사용자 확정) — 경사를 풀면 기슭막이는 종단을 타지
* 않고 눕는다. 배관 벽 자리 규칙이 `상단 = 노견 − d/1.2`(`placePipeWall`)이므로 이 측점
* d를 `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로가 된다. 계획고 차 dz는
* **노견 확장 구간**이 흡수하고 성토선 각도(1:1.2)는 그대로다.
*
* 다단은 손대지 않는다 — 시작점이 기준벽 하단이라 기준벽을 따라 통째로 딸려 온다.
* d가 음수로 내려가면(벽이 이 측점 노견보다 위) 기하가 0에서 자른다.
*/
function levelToOwner(adjusts: WallAdjustSet, dz: number): WallAdjustSet {
if (!adjusts || Math.abs(dz) < 1e-9) return adjusts;
const shift = (adjust: WallAdjust): WallAdjust => ({
...adjust,
d: (adjust.d ?? 0) + FILL_SLOPE_RATIO_MIN * dz,
});
return { ...adjusts, inlet: shift(adjusts.inlet), outlet: shift(adjusts.outlet) };
}
/** 한 측점의 4축 조작값 한 벌(기준벽 2 + 다단). */
type WallAdjustSet =
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
| undefined;
/**
* **연동을 푼 측점**의 4축(2026-08-30 사용자 확정: "연동 해제는 분리가 아니다").
* 단 수·형식은 소유 측점 것 그대로이고, 이 측점이 **손댄 벽만** 자기 값으로 바뀐다.
* 종전에는 이 측점 값(대개 빈 값)으로 통째로 다시 풀어 다단이 통째로 사라졌다
* (2026-08-30 화면 실측: 벽 4 → 2).
*/
function detachedAdjusts(
section: CrossSection,
base: WallAdjustSet,
revetOffset?: RevetOffsetControl,
): WallAdjustSet {
if (!base) return base;
const pick = (role: string, fallback: WallAdjust): WallAdjust =>
revetOffset?.storedAdjustFor(section, role as RevetKey) ?? fallback;
return {
inlet: pick("inlet", base.inlet),
outlet: pick("outlet", base.outlet),
extras: base.extras.map((adjust, i) => pick(`extra${i}`, adjust)),
basinExtras: base.basinExtras.map((adjust, i) => pick(`bextra${i}`, adjust)),
};
}
function adjustsInput(
section: CrossSection,
revetOffset?: RevetOffsetControl,
@@ -408,11 +408,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
optionParts.controls.append(button);
return { button, set };
};
const linkToggle = makeToggle(
"연동",
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.",
(on) => deps.setLinked(on),
);
const LINK_TITLE =
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
const LINK_LOCKED_TITLE =
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
const gradeToggle = makeToggle(
"경사",
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
@@ -575,8 +576,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
const linkState = deps.linkState();
linkToggle.button.classList.toggle("is-hidden", linkState === null);
linkToggle.set(linkState?.linked ?? true);
// 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다.
const levelled = !deps.followGrade();
linkToggle.button.disabled = levelled;
linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE;
gradeToggle.button.classList.toggle("is-hidden", currentSpan === null);
gradeToggle.set(deps.followGrade());
gradeToggle.set(!levelled);
optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null);
const adjust = deps.adjustFor(key);
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
+28 -7
View File
@@ -70,6 +70,9 @@ export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics";
*/
export interface CrossCardElement extends HTMLElement {
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
/** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자).
* 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */
applyRevetHighlight?: (key: RevetKey | null) => void;
}
/**
@@ -181,6 +184,9 @@ export function createCrossSectionCard(
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
/** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 —
* 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */
let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet;
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
@@ -222,9 +228,11 @@ export function createCrossSectionCard(
const toggleRevet = (key: RevetKey): void => {
const wasSelected = isSelected;
activeRevet = activeRevet === key ? null : key;
setRevetActive(activeRevet);
showRevetControl(activeRevet !== null);
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
revetOffset?.select(section.chainage_m, activeRevet);
card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet);
if (activeRevet !== null && activeArea !== null) {
activeArea = null;
setBandActive(null);
@@ -247,13 +255,20 @@ export function createCrossSectionCard(
setChipActive(activeArea);
// 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨).
if ((activeArea !== null || !nextSelected) && activeRevet !== null) {
// 전역 선택이 **아직 이 카드 것일 때만** 비운다 — 옆 카드에서 방금 고른 벽까지
// 지우면 그리로 옮겨 간 조정창이 곧바로 닫힌다(2026-08-30 링크 강조).
const owns = revetOffset?.selectedFor(section) === activeRevet;
activeRevet = null;
setRevetActive(null);
showRevetControl(false);
revetOffset?.select(section.chainage_m, null);
if (owns) revetOffset?.select(section.chainage_m, null);
else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null);
}
if (activeArea !== null || !nextSelected) bodies.clearSelection();
};
card.applyRevetHighlight = (key) => {
highlightRevet = key;
setRevetActive(key);
};
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
@@ -479,7 +494,7 @@ export function createCrossSectionCard(
baseSetActive(key);
setOwnActive(key === "own");
};
if (activeRevet === "own") setOwnActive(true);
if (highlightRevet === "own") setOwnActive(true);
}
}
appendCrossDesignOverlay(
@@ -548,7 +563,7 @@ export function createCrossSectionCard(
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
);
if (activeRevet) setRevetActive(activeRevet);
if (highlightRevet) setRevetActive(highlightRevet);
}
}
@@ -662,8 +677,14 @@ export function createCrossSectionCard(
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
? culvertLink.source
: section;
const adjustOf = (key: RevetKey): WallAdjust =>
revetOffset?.adjustFor(adjustTarget(), key) ?? { x: 0, d: null, h: null, m: null };
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
const adjustOf = (key: RevetKey): WallAdjust => {
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
if (stored) return stored;
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
};
const panel = buildStructurePanel(
structurePanelDeps({
section,
@@ -24,7 +24,12 @@ import type {
StructureSpanControl,
} from "./B06_Section_UI_Cross_View";
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
import { culvertOwnerFor, tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import {
culvertOwnerFor,
culvertReach,
tierSpanOf,
wallStandsAt,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session";
@@ -184,13 +189,15 @@ export function createStationControls(deps: StationControlDeps): StationControls
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
const revetShifts = new Map<string, WallAdjust>();
const revetSelected = new Map<string, RevetKey>();
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
let revetSelection: { at: string; key: RevetKey } | null = null;
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
function loadRevetShifts(): void {
revetShifts.clear();
revetSelected.clear();
revetSelection = null;
const key = revetSessionKey();
if (!key) return;
try {
@@ -255,17 +262,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
deps.refreshCard(chainageM);
const sections = deps.detail()?.cross_sections ?? [];
const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01);
const spec = owner?.culvert;
if (!spec) return;
const reach = Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => {
const span =
side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM];
}),
);
if (!owner) return;
// 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다.
const span = owner.culvert ? culvertReach(owner) : null;
if (!span) return;
const reach = Math.max(span.beforeM, span.afterM);
for (const other of sections) {
if (other === owner) continue;
if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9)
@@ -281,10 +282,29 @@ export function createStationControls(deps: StationControlDeps): StationControls
(section.design?.revet_adjust?.[role]
? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] }
: { ...ZERO_ADJUST }),
selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null,
storedAdjustFor: (section, role) => {
const session = revetShifts.get(revetKey(section.chainage_m, role));
if (session) return session;
const stored = section.design?.revet_adjust?.[role];
return stored ? { ...ZERO_ADJUST, ...stored } : null;
},
selectedFor: (section) =>
revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null,
// 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의
// 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자).
highlightFor: (section) => {
if (!revetSelection) return null;
if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key;
const picked = sectionAt(Number(revetSelection.at));
if (!picked) return null;
const owner = picked.culvert ? picked : ownerOf(picked);
const here = section.culvert ? section : ownerOf(section);
if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null;
return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null;
},
select: (chainageM, key) => {
if (key) revetSelected.set(chainageM.toFixed(2), key);
else revetSelected.delete(chainageM.toFixed(2));
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
revetOffsetControl.onSelectionChange?.();
},
syncApplied: (chainageM, role, applied) => {
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
@@ -650,8 +670,36 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
};
/** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */
const followGradeOf = (section: CrossSection): boolean => {
const session = followGrades.get(chainageKey(section.chainage_m));
if (session !== undefined) return session;
return section.design?.revet_follow_grade !== false;
};
/**
* 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때
* 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은
* 이 구조물 것이 아니라 남긴다.
*/
const dropLinkedAdjusts = (chainageM: number): void => {
const prefix = `${chainageM.toFixed(2)}:`;
for (const key of [...revetShifts.keys()]) {
if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue;
revetShifts.delete(key);
}
persistRevetShifts();
const design = sectionAt(chainageM)?.design;
if (!design?.revet_adjust) return;
const own = design.revet_adjust.own;
design.revet_adjust = own ? { own } : {};
};
const revetLinkControl: RevetLinkControl = {
linkedFor: (section) => {
// 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를
// 하나로 묶을 수 없다(2026-08-30 사용자).
if (!followGradeOf(ownerOf(section) ?? section)) return true;
const session = linkDetached.get(chainageKey(section.chainage_m));
if (session !== undefined) return !session;
return section.design?.revet_link_detached !== true;
@@ -662,34 +710,26 @@ export function createStationControls(deps: StationControlDeps): StationControls
patchCachedDesign(chainageM, { revet_link_detached: !linked });
deps.refreshCard(chainageM);
},
followGradeFor: (section) => {
const session = followGrades.get(chainageKey(section.chainage_m));
if (session !== undefined) return session;
return section.design?.revet_follow_grade !== false;
},
followGradeFor: followGradeOf,
setFollowGrade: (section, follow) => {
followGrades.set(chainageKey(section.chainage_m), follow);
persistLinkFlags();
patchCachedDesign(section.chainage_m, { revet_follow_grade: follow });
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
// 연장 안의 측점만 다시 그린다.
const spec = section.culvert;
const reach = spec
? Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => {
const span =
side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM];
}),
)
: 0;
const span = section.culvert ? culvertReach(section) : null;
const reach = span ? Math.max(span.beforeM, span.afterM) : 0;
for (const other of deps.detail()?.cross_sections ?? []) {
if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) {
deps.refreshCard(other.chainage_m);
if (Math.abs(other.chainage_m - section.chainage_m) > reach + 1e-9) continue;
// 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다
// (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다).
if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) {
linkDetached.set(chainageKey(other.chainage_m), false);
patchCachedDesign(other.chainage_m, { revet_link_detached: false });
dropLinkedAdjusts(other.chainage_m);
}
deps.refreshCard(other.chainage_m);
}
persistLinkFlags();
},
};
@@ -331,6 +331,22 @@ export function createSectionView(
card?.applySelection?.(stationId === selectedStationId, activeAreaKey);
};
/**
* 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는
* 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다.
* 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존.
*/
const syncRevetHighlights = (): void => {
if (!currentDetail || !revetOffset) return;
for (const section of currentDetail.cross_sections) {
const card = document.getElementById(
`cross-${section.station_id}`,
) as CrossCardElement | null;
card?.applyRevetHighlight?.(revetOffset.highlightFor(section));
}
};
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
/**
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다