From 2dc75f7449cb1c4959072e15ed271f76adb356be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 2 Aug 2026 15:11:06 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EC=84=A0=ED=83=9D=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EC=A7=80=20=EC=95=8A=EA=B2=8C,=20RR=20?= =?UTF-8?q?=EB=B0=B4=EB=93=9C=20=ED=8F=B4=EB=B0=B1=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선택 반영이 아직 카드 재생성이라 그 카드의 viewBox(휠 줌·팬)가 초기화됐다. 카드 요소에 applySelection() 핸들을 달아 클래스와 강조 상태만 갈아 끼운다. 재생성을 없애기 위해 면적 밴드는 선택 여부와 무관하게 항상 깔아 둔다(평소 투명이라 화면 변화는 없다). - RR 절토를 눌러도 단면이 안 켜지던 원인은 구 데이터 폴백 불일치였다. 표는 분리 필드가 없으면 절토 전량을 측점 지반유형으로 돌려 RR에 값을 넣는데, 밴드 쪽은 cut_rock_kind가 없으면 곧장 null을 반환해 암반 밴드를 만들지 않았다. rockKindOf()로 표·밴드·강조 매핑의 폴백 규칙을 하나로 통일한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../B06_wf3_ProfileCross_UI_Cross_Areas.ts | 23 ++++-- .../B06_wf3_ProfileCross_UI_Cross_View.ts | 74 +++++++++++-------- .../B06_wf3_ProfileCross_UI_Section_View.ts | 27 +++---- 3 files changed, 75 insertions(+), 49 deletions(-) diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts index 8b630558..3b947449 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_Areas.ts @@ -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(); 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, }; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts index d0b9292b..e8e7dde2 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Cross_View.ts @@ -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); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index b127532d..a6125c34 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -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(); };