feat(393-2): B05 우측 「횡단」 · 「이전 횡단」 판 = B06 횡단 카드 읽기 전용 — 구조물 · 포장 · 면적 · 축 · 단면 사본(B06 고름 · 줌 기억 안 건드림) · 정본만 읽는 제어기 export

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-17 17:30:03 +09:00
co-authored by Claude Opus 5.5
parent dec208f11a
commit a3dd0a1cf1
8 changed files with 244 additions and 13 deletions
+58 -8
View File
@@ -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?.();
}
@@ -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;
}
@@ -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%;
+11
View File
@@ -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 사용자 확정 흐름).
*
+4 -4
View File
@@ -42,8 +42,8 @@ function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...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,
@@ -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);
@@ -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(),
};
@@ -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