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:
@@ -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%;
|
||||
|
||||
@@ -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 사용자 확정 흐름).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user