fix(B06): 선택해도 카드를 다시 만들지 않게, RR 밴드 폴백 불일치 해결

- 선택 반영이 아직 카드 재생성이라 그 카드의 viewBox(휠 줌·팬)가 초기화됐다. 카드 요소에
  applySelection() 핸들을 달아 클래스와 강조 상태만 갈아 끼운다. 재생성을 없애기 위해 면적
  밴드는 선택 여부와 무관하게 항상 깔아 둔다(평소 투명이라 화면 변화는 없다).
- RR 절토를 눌러도 단면이 안 켜지던 원인은 구 데이터 폴백 불일치였다. 표는 분리 필드가 없으면
  절토 전량을 측점 지반유형으로 돌려 RR에 값을 넣는데, 밴드 쪽은 cut_rock_kind가 없으면 곧장
  null을 반환해 암반 밴드를 만들지 않았다. rockKindOf()로 표·밴드·강조 매핑의 폴백 규칙을
  하나로 통일한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 15:11:06 +09:00
co-authored by Claude Opus 5
parent 8a7c88e700
commit 2dc75f7449
3 changed files with 75 additions and 49 deletions
@@ -133,9 +133,21 @@ function bandPolygons(
return polygons;
}
/** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 경계선 값이 없는 암 측점은 0(전량 암). */
/**
* 그 측점 절토의 암종. 분리 필드가 없는 **구 데이터**는 지반유형을 그대로 쓴다 —
* 표(`cutBreakdown`)는 그 규칙으로 RR/BR에 값을 넣는데 여기서만 null로 보면
* 암반 밴드가 아예 안 만들어져 **표에는 값이 있는데 그림이 안 켜진다**(2026-08-02 사용자 지적).
*/
function rockKindOf(design: CrossDesign): CrossDesign["cut_rock_kind"] {
if (design.cut_rock_kind) return design.cut_rock_kind;
return design.ground_type === "soil" ? null : design.ground_type;
}
/** 토사층 두께(m). 토사 측점은 암반 경계선이 없어 null, 분리 근거가 없는 암 측점은 0(전량 암). */
function soilDepth(design: CrossDesign): number | null {
if (!design.cut_rock_kind) return null;
if (!rockKindOf(design)) return null;
// 구 데이터(분리 면적 없음)는 경계선을 신뢰할 수 없으므로 전량 암으로 본다 — 표와 같은 규칙.
if (design.cut_soil_area_m2 === undefined || design.cut_rock_area_m2 === undefined) return 0;
return Math.abs(design.rock_boundary_offset_m ?? 0);
}
@@ -200,7 +212,7 @@ export function appendCrossAreaBands(
}
// 표에서 암반 칸을 눌렀을 때 이 측점의 암종과 맞는 쪽만 밴드를 켠다.
const rockCell: CrossAreaKey = design.cut_rock_kind === "blasting_rock" ? "cut_br" : "cut_rr";
const rockCell: CrossAreaKey = rockKindOf(design) === "blasting_rock" ? "cut_br" : "cut_rr";
const groups = new Map<BandKey, SVGGElement>();
for (const band of bands) {
let pieces = bandPolygons(xs, band.top, band.bottom, x, toDisplayY);
@@ -249,10 +261,11 @@ function cutBreakdown(design: CrossDesign): {
};
}
const rock = design.cut_rock_area_m2;
const kind = rockKindOf(design);
return {
ea: design.cut_soil_area_m2,
rr: design.cut_rock_kind === "ripping_rock" ? rock : 0,
br: design.cut_rock_kind === "blasting_rock" ? rock : 0,
rr: kind === "ripping_rock" ? rock : 0,
br: kind === "blasting_rock" ? rock : 0,
total,
};
}
@@ -36,6 +36,14 @@ import {
validElevation,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
/**
* 횡단 카드 요소. 선택 표시와 면적 강조를 **카드를 다시 만들지 않고** 갈아 끼우는 핸들을 단다
* — 카드를 새로 만들면 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화되기 때문이다.
*/
export interface CrossCardElement extends HTMLElement {
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
}
// 절·성토 값 오버레이(그래프 상단 고정)가 계획고 선을 가리는 드문 경우를 대비해, Y 플롯
// 최대값에 더하는 상단 여유(px). 오버레이·라벨은 손대지 않고 데이터를 그만큼 아래로 내린다.
// 오버레이가 칩 한 줄에서 표(머리글 + 절토 + 성토 3행)로 바뀌어 그만큼 키웠다.
@@ -238,8 +246,8 @@ export function createCrossSectionCard(
initialAreaKey?: CrossAreaKey | null,
/** 면적 값을 눌렀을 때. 선택되지 않은 카드에서도 눌릴 수 있어 측점 id를 함께 넘긴다. */
onAreaSelect?: (stationId: string, key: CrossAreaKey | null) => void,
): HTMLElement {
const card = document.createElement("article");
): CrossCardElement {
const card: CrossCardElement = document.createElement("article");
card.id = `cross-${section.station_id}`;
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
card.tabIndex = 0;
@@ -248,6 +256,31 @@ export function createCrossSectionCard(
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
});
// 선택 표시·면적 강조는 **DOM만 갈아 끼운다**. 카드를 새로 만들면 휠 줌·팬(viewBox)이
// 초기화돼 사용자가 맞춰 둔 배율이 날아간다(2026-08-02 사용자 지적).
let isSelected = selected;
let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null;
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
card.applySelection = (nextSelected, areaKey) => {
isSelected = nextSelected;
card.classList.toggle("b06-cross-card--selected", nextSelected);
activeArea = nextSelected ? (areaKey ?? null) : null;
setBandActive(activeArea);
setChipActive(activeArea);
};
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
onAreaSelect?.(section.station_id, key);
return;
}
activeArea = activeArea === key ? null : key;
setBandActive(activeArea);
setChipActive(activeArea);
onAreaSelect?.(section.station_id, activeArea);
};
// 제목행 1행 구조(E-2/E-3): 측점 위치표기 → 단면유형 pill → 지반유형 → 구조물 → 측점정보.
const header = document.createElement("header");
const title = document.createElement("div");
@@ -393,37 +426,20 @@ export function createCrossSectionCard(
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
);
// 면적 밴드는 선택된 카드에만 깐다 — 다른 측점을 고르면 카드가 다시 그려지며 해제된다.
// 다만 **값 칸은 어느 카드에서든 눌린다**: 선택 안 된 카드에서 누르면 부모가 그 카드를
// 고르면서 누른 면적까지 한 번에 켠다(2026-08-02 사용자 지시).
let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null;
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
const toggleArea = (key: CrossAreaKey): void => {
if (!selected) {
onAreaSelect?.(section.station_id, key);
return;
}
activeArea = activeArea === key ? null : key;
setBandActive(activeArea);
setChipActive(activeArea);
onAreaSelect?.(section.station_id, activeArea);
};
if (section.design) {
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
if (selected) {
setBandActive = appendCrossAreaBands(
svg,
section.design,
sourceSamples,
x,
toDisplayY,
toggleArea,
);
}
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
setBandActive = appendCrossAreaBands(
svg,
section.design,
sourceSamples,
x,
toDisplayY,
toggleArea,
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(svg, section.design, x, toDisplayY);
appendCrossDesignOverlay(svg, section.design, x, toDisplayY, sourceSamples);
@@ -25,6 +25,7 @@ import type { CrossAreaKey } from "./B06_wf3_ProfileCross_UI_Cross_Areas";
import {
createCrossSectionCard,
crossCardNaturalHeight,
type CrossCardElement,
} from "./B06_wf3_ProfileCross_UI_Cross_View";
import {
createLongitudinalProfile,
@@ -320,26 +321,22 @@ export function createSectionView(
document.getElementById(`cross-${stationId}`)?.scrollIntoView({ behavior, block: "nearest" });
};
/** 측점 id로 카드 하나만 새로 만들어 교체한다(행 높이는 draw가 정해 둔 값을 그대로 쓴다). */
const rebuildCard = (stationId: string | null): void => {
if (!stationId || !currentDetail) return;
const section = currentDetail.cross_sections.find(
(candidate) => candidate.station_id === stationId,
);
const existing = section && document.getElementById(`cross-${stationId}`);
if (section && existing) {
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(stationId)));
}
/** 카드 하나의 선택 표시를 갱신한다 — **다시 만들지 않는다**(줌·팬이 살아남아야 한다). */
const markCard = (stationId: string | null): void => {
if (!stationId) return;
const card = document.getElementById(`cross-${stationId}`) as CrossCardElement | null;
card?.applySelection?.(stationId === selectedStationId, activeAreaKey);
};
/**
* 선택이 바뀐 흔적만 화면에 반영한다 — **전체 재렌더 금지**.
* `draw()`로 모든 카드를 다시 만들면 사용자가 카드마다 맞춰 둔 휠 줌·팬(viewBox)이 전부
* 초기화된다(2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이다.
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다
* (2026-08-02 사용자 지적). 선택 표시가 바뀌는 카드는 이전·현재 둘뿐이고, 그 둘도 클래스와
* 강조 상태만 갈아 끼운다.
*/
const applySelection = (previousStationId: string | null): void => {
rebuildCard(previousStationId);
if (selectedStationId !== previousStationId) rebuildCard(selectedStationId);
markCard(previousStationId);
if (selectedStationId !== previousStationId) markCard(selectedStationId);
drawPanel();
};