diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index eeef0e76d..d048aa3dc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -6,8 +6,8 @@ * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** * 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다. * - * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 · - * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. + * 그림 = B06 횡단 카드 읽기 전용(393-2 · `_Cross_Card` — 구조물 · 포장 · 면적 · 축) · 상세가 아직 없으면 + * 원지반 · 계획 횡단선 · 성토사면 길이만(`_Cross_Draw`). * * 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계 * (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 · @@ -44,6 +44,11 @@ import { readByKey, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { sidePanelsOf } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { FootprintRecord } from "./logic/B05_Profile_PlanFootprint"; +import { + routeCrossCard, + routeCrossSource, + type RouteCrossSource, +} from "./B05_Profile_UI_RouteEdit_Cross_Card"; export interface CrossPreviewParams { projectId: string; @@ -90,8 +95,12 @@ export interface CrossPreviewWindow { interface CrossPane { root: HTMLElement; - /** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */ - show: (preview: CrossPreviewResponse | null, intervalM: number) => void; + /** 셈해 온 횡단을 그린다(단면 사본이 있으면 B06 카드 · 393-2). `null` 이면 빈 화면으로 되돌린다. */ + show: ( + preview: CrossPreviewResponse | null, + intervalM: number, + source?: RouteCrossSource | null, + ) => void; /** 기다리는 중임을 알린다. */ wait: (text: string) => void; } @@ -133,15 +142,40 @@ function createPane(title: string, empty: string): CrossPane { let drawn: CrossPreviewResponse | null = null; let size = { width: 0, height: 0 }; let zoom: ZoomPanState | undefined; + /** B06 카드 재료(393-2) · 카드 머리 · 발 높이(그림 높이 = 판 − 이것) · 측점 간격. */ + let source: RouteCrossSource | null = null; + let chrome = 0; + let interval = 20; + function drawCard(preview: CrossPreviewResponse, from: RouteCrossSource): void { + const plotHeight = (): number => Math.max(120, (size.height || 150) - chrome); + const build = (): HTMLElement => { + const card = routeCrossCard( + from, + interval, + preview.cross_half_width_m, + size.width || 240, + plotHeight(), + ); + box.replaceChildren(card); + return card; + }; + const card = build(); + const extra = card.offsetHeight - plotHeight(); + if (size.height && extra > 0 && extra !== chrome) { + chrome = extra; + build(); + } + } function draw(preview: CrossPreviewResponse): void { // 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고 // 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고, // 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다. const rect = box.getBoundingClientRect(); size = { width: Math.round(rect.width), height: Math.round(rect.height) }; + observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. + if (source) return drawCard(preview, source); const plot = buildCrossSvg(preview, size.width || 240, size.height || 150); box.replaceChildren(plot.svg); - observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다. // 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③). // 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다). attachSvgZoomPan({ @@ -166,9 +200,11 @@ function createPane(title: string, empty: string): CrossPane { }); return { root, - show(preview, intervalM) { + show(preview, intervalM, from = null) { box.replaceChildren(); drawn = preview; + source = preview ? from : null; + interval = intervalM; zoom = undefined; if (!preview) { station.textContent = ""; @@ -184,6 +220,7 @@ function createPane(title: string, empty: string): CrossPane { wait(text) { box.replaceChildren(); drawn = null; + source = null; foot.textContent = text; }, }; @@ -226,6 +263,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; + let shownSource: RouteCrossSource | null = null; let drawnRoute: unknown = null; const stationsNow = (): PipelineStation[] => pipe?.output("ground")?.stations ?? []; @@ -245,11 +283,23 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind function redraw(): void { if (watching === null || watching.structure) return; const route = pipe?.input("route") ?? null; - if (route !== drawnRoute && shown) previous.show(shown, interval()); + if (route !== drawnRoute && shown) + previous.show( + shown, + interval(), + shownSource && { + ...shownSource, + section: { + ...shownSource.section, + station_id: shownSource.section.station_id.replace("b05-cur-", "b05-prev-"), + }, + }, + ); drawnRoute = route; const at = located()?.chainageM ?? watching.chainageM; shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); - if (shown) current.show(shown, interval()); + shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur"); + if (shown) current.show(shown, interval(), shownSource); else current.wait("계산 중…"); params.onShown?.(); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts new file mode 100644 index 000000000..fa9ba89c3 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts @@ -0,0 +1,131 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross_Card.ts + * 평면 「횡단」 · 「이전 횡단」 판 그림 = **B06 횡단 카드 읽기 전용**(393-2) — 구조물 · 포장 · 면적 · 축 · + * 종단 계획고 · 횡단 설계가 B06 카드와 같은 그림. + * + * · 자료 = 새 노선 상세(계산 묶음) · 없으면 받아 둔 저장 상세 · 노선 초안 중 새 상세가 아직 없으면 없음 + * (판은 옛 선 그림 `_Cross_Draw` 로 · 종단 임시 횡단 창도 그것 그대로) + * · 단면 사본(station_id 앞머리 · design 사본) — B06 고름 · 줌 기억 · 면적 원본을 안 건드림 + * · 구조물 자리 = 정본만 읽는 제어기(`Structure_Layouts` · 세월교 · 박스 조정값) · 누름 막음(줌 단추만) + * ========================================================================== */ + +import "../B06_Section/B06_Section_UI_Style_Cross_Controls.css"; +import { pipelineDetail } from "../A00_Common/design/design_pipeline_page"; +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { nearestWithin } from "../A00_Common/design/design_selection"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { peekSectionDetail } from "../B06_Section/B06_Section_Section_Store"; +import { + extraWalls, + inletStructure, + revetOffset, +} from "../B06_Section/B06_Section_Structure_Layouts"; +import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box_Geom"; +import type { BoxControl } from "../B06_Section/B06_Section_UI_Cross_Box_Panel"; +import { culvertLinkFor, type CulvertLink } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire"; +import { fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford_Geom"; +import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel"; +import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View"; +import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common"; + +/** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */ +export interface RouteCrossSource { + section: CrossSection; + designZ: number | undefined; + link: CulvertLink | undefined; +} + +const none = (): void => undefined; + +/** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */ +export function routeCrossSource( + projectId: string, + pipe: DesignPipeline | null, + chainageM: number, + pane: string, +): RouteCrossSource | null { + const detail = + pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId)); + const sections = detail?.cross_sections ?? []; + const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m); + if (!detail || !found) return null; + const profiles = detail.longitudinal.design_profiles; + const planZ = designElevationAt(profiles, found.chainage_m); + return { + section: { + ...found, + station_id: `b05-${pane}-${found.station_id}`, + design: found.design && { ...found.design }, + }, + designZ: planZ === undefined ? undefined : planZ - (found.design?.surface_drop_m ?? 0), + link: found.culvert + ? undefined + : culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null), + }; +} + +/** B06 횡단 카드 읽기 전용 — 판 폭 · 그림 높이(px)로. */ +export function routeCrossCard( + source: RouteCrossSource, + intervalM: number, + halfWidthM: number | undefined, + widthPx: number, + plotHeightPx: number, +): HTMLElement { + const { section } = source; + const design = section.design; + const ford: FordControl = { + adjustFor: () => fordAdjustOf(design?.ford_adjust), + update: none, + reset: none, + setPipe: none, + setWing: none, + selectedFor: () => null, + select: none, + }; + const box: BoxControl = { + adjustFor: () => ({ + left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design?.box_adjust?.left ?? {}) }, + right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design?.box_adjust?.right ?? {}) }, + }), + update: none, + reset: none, + setBody: none, + setWing: none, + selectedFor: () => null, + select: none, + }; + const card = createCrossSectionCard( + section, + false, + 1, + none, + intervalM, + halfWidthM, + widthPx, + plotHeightPx, + source.designZ, + undefined, + undefined, + null, + undefined, + undefined, + revetOffset, + inletStructure, + extraWalls, + source.link, + undefined, + undefined, + ford, + box, + ); + // 읽기 전용 — 벽 · 선 · 면적 고름을 막음(줌 단추만 통과) + card.addEventListener( + "click", + (event) => { + if (!(event.target as Element).closest("button")) event.stopPropagation(); + }, + true, + ); + return card; +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 9a436bcc2..2a8d4d5c5 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -254,6 +254,14 @@ background: var(--color-surface); } +/* B06 횡단 카드 읽기 전용(393-2) — 판 상자가 테두리 · 고름 강조 · 손가락 없음. */ +.b05-routeedit__cross-box > .b06-cross-card { + border: 0; + border-radius: 0; + box-shadow: none; + cursor: default; +} + .b05-routeedit__cross-svg { display: block; width: 100%; diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index a5ac2ca76..ea766a1d2 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -258,6 +258,17 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo } } +/** 받아 둔 상세 중 노선 번호가 가장 큰 것(최신) — 노선 번호를 모르는 쪽(B05 평면 횡단 판 · 393-2). */ +export function peekSectionDetail(projectId: string): SectionDetailResponse | null { + const prefix = `${projectId}:`; + let best: { routeId: number; detail: SectionDetailResponse } | null = null; + for (const [key, detail] of cache) { + const routeId = Number(key.slice(prefix.length)); + if (key.startsWith(prefix) && (!best || routeId > best.routeId)) best = { routeId, detail }; + } + return best?.detail ?? null; +} + /** * 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름). * diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index 0f0f47ef2..037a325b2 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -42,8 +42,8 @@ function storedWallAdjust(section: CrossSection, role: string): WallAdjust { return stored ? { ...ZERO_ADJUST, ...(stored as Partial) } : { ...ZERO_ADJUST }; } -/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다. */ -const revetOffset: RevetOffsetControl = { +/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다(B05 평면 횡단 판도 씀 · 393-2). */ +export const revetOffset: RevetOffsetControl = { adjustFor: (section, role) => storedWallAdjust(section, role), storedAdjustFor: (section, role) => section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null, @@ -55,7 +55,7 @@ const revetOffset: RevetOffsetControl = { reset: () => undefined, }; -const extraWalls: ExtraWallControl = { +export const extraWalls: ExtraWallControl = { countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0, setCount: () => undefined, equalize: () => undefined, @@ -63,7 +63,7 @@ const extraWalls: ExtraWallControl = { syncCount: () => undefined, }; -const inletStructure: InletStructureControl = { +export const inletStructure: InletStructureControl = { valueFor: (section) => section.design?.inlet_structure ?? "auto", adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }), set: () => undefined, diff --git a/resources/tester/helper_365_page_pipeline.cjs b/resources/tester/helper_365_page_pipeline.cjs index e49bd662a..13a052f20 100644 --- a/resources/tester/helper_365_page_pipeline.cjs +++ b/resources/tester/helper_365_page_pipeline.cjs @@ -181,6 +181,11 @@ const store = load("A00_Common", "design", "design_store.ts"); STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined }; STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" }; STUBS["B05_Profile_UI_RouteEdit_Chrome"] = { sidePanelsOf: new WeakMap() }; // 판 양식(382-1) — 화면 밖 +// B06 카드 그림(393-2) — 화면 밖 · 판 값 고르기는 진짜 +STUBS["B05_Profile_UI_RouteEdit_Cross_Card"] = { + routeCrossSource: () => null, + routeCrossCard: () => null, +}; const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"); const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null); diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs index b4e176a3e..b3e7ce55c 100644 --- a/resources/tester/helper_b05_routeedit_cross_width.cjs +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -67,7 +67,14 @@ let stations = [ let footprint = null; let route = { v: 1 }; // 접힌 판 펴기 — 몸(side)에 묶인 패널 묶음 흉내 -const panels = { now: null, current: () => panels.now, open: (id) => (panels.now = id) }; +// 393-1 — 측점 눈금이 연 판은 기억 안 함(remember 거짓) +const panels = { + now: null, + current: () => panels.now, + open: (id, remember = true) => ((panels.now = id), (panels.remember = remember)), +}; +// 393-2 — 판 그림 = B06 카드(가짜) · 그린 단면 id 를 적음 +const cards = []; const side = { append() {} }; const STUBS = { @@ -79,6 +86,12 @@ const STUBS = { summarizeCross: () => "", }, "./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new WeakMap([[side, panels]]) }, + "./B05_Profile_UI_RouteEdit_Cross_Card": { + routeCrossSource: (projectId, pipe, chainageM, pane) => ({ + section: { station_id: `b05-${pane}-s${chainageM}` }, + }), + routeCrossCard: (source) => (cards.push(source.section.station_id), fakeEl()), + }, "../A00_Common/design/design_pipeline_page": { pagePipeline: () => ({ output: (name) => (name === "ground" ? { stations } : null), @@ -171,8 +184,10 @@ async function run() { ]; footprint = { records: [record("grid:5", 102, 9, -4, false, true)] }; route = { v: 2 }; + cards.length = 0; await cross.refresh(); out.afterEdit = cross.watched(); + out.cardsAfterEdit = cards.slice(); // 평면 눈금 클릭 = 공용 고름에 적음 · 판이 접혀 있으면 폄 const heard = []; @@ -185,6 +200,7 @@ async function run() { selection: pick.readSelection("p-1"), heard: heard.slice(), panel: panels.now, + remember: panels.remember, watched: cross.watched(), }; diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py index 37aee859c..8a99b6879 100644 --- a/resources/tester/test_b05_routeedit_cross_width.py +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -92,3 +92,13 @@ def test_관_구조물_고름은_마름모만(results): def test_고름을_풀면_법선도_없다(results): assert results["cleared"] == {"watched": None, "redrawn": True} + + +def test_393_판_그림은_B06_카드_사본_노선_고치면_전후(results): + # 노선 고친 차례 — 아래 판 = 고치기 전 측점(이전 사본 id) · 위 판 = 새 측점 · B06 카드 id 와 안 겹침 + assert results["cardsAfterEdit"] == ["b05-prev-s100", "b05-cur-s102"] + + +def test_393_1_눈금이_연_판은_기억_안_함(results): + assert results["planClick"]["panel"] == "cross" + assert results["planClick"]["remember"] is False