From 679726124bc91e4c0f63d69fc249bae88e138919 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 08:20:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EC=A2=85=EB=8B=A8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20B05=20?= =?UTF-8?q?=EC=99=80=20=ED=86=B5=EC=9D=BC=20=E2=80=94=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EC=95=8C=EC=95=BD=20=EB=A0=88=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 2-5 (2026-09-07 사용자 지시 4 「구조물 표시 통일」). B06 종단에는 구조물 마크가 아예 없었고 배수관·세월교만 다른 계통으로 그려졌음. - B05 가 쓰는 **같은 부품**(`buildStructureLane`)을 B06 종단 그래프 아래에 붙임. 이름·중복 번호·툴팁·약호가 전부 B05 규칙 그대로 따라옴. - 종단 차트가 `toX`(누가거리 → px)를 내보내 레인이 그래프와 같은 자리를 씀. - 좌측 「구조물 배치」가 목록을 받을 때마다 `setStructureMarks` 로 넘김(구조물 정본 + 관 정본). 관을 가상 구조물로 바꾸는 `pipesToStructureMarks` 는 B05 안에 있던 것을 공용 자리(`B05_Profile_Api_Structures`)로 올려 **한 벌만** 둠. - 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고름(좌측 목록 클릭과 같은 규칙). 끌기는 B05 몫이라 여기서는 자리만 보임(배수유역 재분할이 걸림). - 검증 훅 `window.__b06Marks` 추가(`__corridorBuild` 등과 같은 용도). 자체검증 — 실화면 B06 종단에 레인 1줄·알약 10개(「배수관 1~8 · 세월교 1~2」)가 B05 와 같은 표기로 뜸. 훅 값 `{structures:10, types:36}`. tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_Api_Structures.ts | 27 +++++++++ B06_Section/B06_Section_UI_Page.ts | 12 ++++ .../B06_Section_UI_Page_Structures_Panel.ts | 13 ++++ B06_Section/B06_Section_UI_Section_View.ts | 60 +++++++++++++++++++ .../B06_Section_UI_Section_View_Chart.ts | 6 +- 5 files changed, 117 insertions(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index b5ceb147..d892eea8 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -186,6 +186,33 @@ export async function migrateLegacyStations( /** 종단도 마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다(2026-08-17 사용자 * 확정: 기준점에 마킹 + 시작·종료 측점). 기준점이 없는 기존 저장분은 시점으로 본다. */ +/** 관 지점을 **알약 레인용 가상 구조물**로 만든다 — 정본은 `pipe_points.json` 이라 + * 저장하지 않는다. B05 종단과 B06 종단이 같은 표기를 쓰도록 한 벌만 둔다 + * (2026-09-07 사용자 지시 4 「구조물 표시 통일」). */ +export function pipesToStructureMarks( + pipes: ReadonlyArray<{ + chainage_m: number; + facility: string; + options?: Record | null; + }>, +): StructureInstance[] { + return pipes.map((pipe) => ({ + structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`, + type_id: pipe.facility, + placement: "point", + chainage_m: pipe.chainage_m, + start_m: null, + end_m: null, + options: (pipe.options ?? {}) as Record, + memo: "", + placement_source: "automatic", + status: "draft", + revision: 0, + geometry: null, + })) as StructureInstance[]; +} + + export function structureAnchorM(structure: StructureInstance): number { return structure.chainage_m ?? structure.start_m ?? 0; } diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 286b4e32..3f2f28b7 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,6 +4,10 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { stateKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import type { + StructureInstance, + StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -139,10 +143,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. + let structureMarksSink: + | ((structures: StructureInstance[], types: StructureType[]) => void) + | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). reveal: () => layout.setOptionsOpen(true), + // 종단 알약 레인에 같은 목록을 넘긴다 — B05 와 같은 표기(2026-09-07 사용자 지시 4). + // 뷰는 이 패널보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. + onMarks: (structures, types) => structureMarksSink?.(structures, types), // 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 캐시를 버리고 다시 // 받아 그려야 면적·유토곡선이 따라온다(2026-09-06 사용자 확정). onStructuresChanged: () => void refreshDetailForStructures(), @@ -439,6 +449,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { stationControls.ford, stationControls.box, ); + // 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일). + structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { detail: () => sectionDetail, diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 90d570f0..a0152ed3 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -18,9 +18,11 @@ import { fetchStructures, fetchStructureTypes, readPendingStructures, + pipesToStructureMarks, structureAnchorM, writePendingStructures, type StructureInstance, + type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -62,6 +64,8 @@ export interface B06StructuresPanelDeps { focusChainage: (chainageM: number) => void; /** 바깥(횡단도·3D 넘김값)에서 고른 것이 폼에 실릴 때 — 접힌 좌측 패널을 펼친다. */ reveal?: () => void; + /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ + onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 폼 [수정]으로 바뀐 관 옵션을 캐시에 예약한다 — [저장]·[확정]이 정본에 쓴다 * (2026-08-29: 조정창 구간값과 같은 경로). 없으면 안내만 한다. */ queuePipeOptions?: (chainageM: number, patch: Record) => void; @@ -226,10 +230,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc item.structure_id ? item : { ...item, structure_id: crypto.randomUUID().replace(/-/g, "") }, ); + let markTypes: StructureType[] = []; + /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ + const pushMarks = (): void => + deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); const section = createStructuresSection({ onChange: (next) => { structures = withLocalIds(next); section.setStructures(structures); + pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); }, @@ -255,6 +264,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit) hit.chainage_m = toChainageM; currentChainageM = toChainageM; section.setPipeFacilities(pipeFacilities); + pushMarks(); showToast(PIPE_MOVE_NOTICE, "success"); } else { showToast(PIPE_MOVE_GUIDE, "error"); @@ -321,6 +331,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc fetchDetailPipePoints(projectId), ]); section.setTypes(types); + markTypes = types; // 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(B05와 같은 규칙). structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); @@ -348,6 +359,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc )?.design_flow_m3s ?? null, })); section.setPipeFacilities(pipeFacilities); + // 종단 알약 레인 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합쳐 B05 와 같은 표기로. + pushMarks(); // 목록이 늦게 도착하면 그 사이에 고른 시설은 강조될 자리가 없었다 — 다시 세운다 // (2026-09-04 사용자 보고: B06 좌측 목록만 하이라이트가 안 붙음). 폼에 아직 아무것도 // 없으면 세션에 남은 선택(카드형 — 부재키 없는 측점)도 같은 규칙으로 세운다. diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index cadbcb1b..78cd7557 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -44,6 +44,13 @@ import { effectiveCardHalfWidth, } from "./B06_Section_UI_Cross_View_Metrics"; import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common"; +import { LONG_PAD } from "./B06_Section_UI_Section_Common"; +import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks"; +import { + structureAnchorM, + type StructureInstance, + type StructureType, +} from "../B05_Profile/B05_Profile_Api_Structures"; import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal"; @@ -114,6 +121,12 @@ export interface SectionViewController { * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; clear: () => void; + /** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자 + * 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */ + setStructureMarks: ( + structures: ReadonlyArray, + types: ReadonlyArray, + ) => void; dispose: () => void; } @@ -142,6 +155,8 @@ export function createSectionView( let currentStationInterval: number | undefined; let currentConversion: EarthworkConversion | undefined; let currentNaturalSpoilSlope: number | undefined; + let markStructures: ReadonlyArray = []; + let markTypes: ReadonlyArray = []; let renderWidth = 0; let resizeTimer = 0; let panelResizeTimer = 0; @@ -493,6 +508,39 @@ export function createSectionView( }); const longAxis = chart.axis; const nodes: Element[] = [chart.node]; + // 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다. + // 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일). + if (markStructures.length && markTypes.length) { + nodes.push( + buildStructureLane({ + structures: markStructures, + types: markTypes, + x: chart.toX, + chainageAt: chart.toChainage, + maxChainageM: chart.maxChainageM, + widthPx: chartWidth, + axisWidthPx: LONG_PAD.left, + stationIntervalM: cachedStationInterval, + selectedId: null, + // B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + onSelect: (structureId) => { + if (!structureId) return; + const hit = markStructures.find((entry) => entry.structure_id === structureId); + if (!hit) return; + const at = structureAnchorM(hit); + // 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙. + let best: { id: string; gap: number } | null = null; + for (const section of currentDetail?.cross_sections ?? []) { + const gap = Math.abs(section.chainage_m - at); + if (!best || gap < best.gap) best = { id: section.station_id, gap }; + } + if (best) selectStation(best.id, true); + }, + // 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다. + onMove: () => undefined, + }), + ); + } chartWrap.replaceChildren(...nodes); // 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이 @@ -694,6 +742,18 @@ export function createSectionView( setStationSelectListener(listener) { stationSelectListener = listener; }, + setStructureMarks(structures, types) { + markStructures = structures; + markTypes = types; + // 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다 + // (`__corridorBuild` 등과 같은 용도). + (window as unknown as { __b06Marks?: unknown }).__b06Marks = { + structures: structures.length, + types: types.length, + drawn: !!currentDetail, + }; + if (currentDetail) drawPanel(); + }, clear() { currentDetail = null; selectedStationId = null; diff --git a/B06_Section/B06_Section_UI_Section_View_Chart.ts b/B06_Section/B06_Section_UI_Section_View_Chart.ts index 468efe3c..7ab41541 100644 --- a/B06_Section/B06_Section_UI_Section_View_Chart.ts +++ b/B06_Section/B06_Section_UI_Section_View_Chart.ts @@ -37,6 +37,8 @@ export interface LongitudinalChartResult { axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null; /** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */ toChainage: (px: number) => number; + /** 누가거리(m) → 화면 x(px). 구조물 알약 레인이 그래프와 같은 자리를 쓰게 한다. */ + toX: (chainageM: number) => number; maxChainageM: number; viewFromM: number; viewToM: number; @@ -78,6 +80,8 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi const maxChainageM = longitudinalMaxChainage(detail.longitudinal); const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right); const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM; + const toX = (chainageM: number): number => + LONG_PAD.left + (maxChainageM > 0 ? (chainageM / maxChainageM) * plotWidth : 0); const { fromM, toM } = visibleChainageRange( toChainage, maxChainageM, @@ -112,5 +116,5 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi visibleElevationRange(detail, fromM, toM) ?? undefined, ), ); - return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM }; + return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM }; }