Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1

This commit is contained in:
2026-09-08 04:42:16 +09:00
4 changed files with 92 additions and 64 deletions
@@ -33,6 +33,13 @@ export interface StructureOptionField {
phase?: "b05" | "detail"; phase?: "b05" | "detail";
} }
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
* 치수까지** 받는다(2026-08-17 사용자 확정). 부르는 쪽이 정한다. */
export interface StructuresSectionOptions {
/** 참이면 `phase: "detail"` 옵션(뒷길이·돌규격·형식 …)도 폼에 그린다. */
includeDetail?: boolean;
}
/** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */ /** B05 배치 폼에 그릴 옵션인가 — 상세(detail)는 B06/B07 몫이라 숨긴다. */
export function isB05Option(option: StructureOptionField): boolean { export function isB05Option(option: StructureOptionField): boolean {
return (option.phase ?? "b05") !== "detail"; return (option.phase ?? "b05") !== "detail";
@@ -956,6 +956,15 @@
"required": true, "required": true,
"phase": "detail" "phase": "detail"
}, },
{
"key": "bond",
"label": "쌓기 방식",
"input": "select",
"choices": ["메쌓기", "찰쌓기"],
"default": null,
"required": true,
"phase": "detail"
},
{ {
"key": "side", "key": "side",
"label": "설치 측", "label": "설치 측",
+14 -5
View File
@@ -18,6 +18,7 @@ import {
isB05Option, isB05Option,
structureAnchorM, structureAnchorM,
type StructureInstance, type StructureInstance,
type StructuresSectionOptions,
type StructurePlacement, type StructurePlacement,
type StructureType, type StructureType,
} from "./B05_Profile_Api_Structures"; } from "./B05_Profile_Api_Structures";
@@ -49,7 +50,10 @@ import type {
StructuresSection, StructuresSection,
} from "./B05_Profile_UI_Structures_Panel_Types"; } from "./B05_Profile_UI_Structures_Panel_Types";
export function createStructuresSection(callbacks: StructuresCallbacks): StructuresSection { export function createStructuresSection(
callbacks: StructuresCallbacks,
sectionOptions: StructuresSectionOptions = {},
): StructuresSection {
// 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만. // 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만.
const form = buildStructuresForm({ const form = buildStructuresForm({
getInterval: () => callbacks.getInterval(), getInterval: () => callbacks.getInterval(),
@@ -294,8 +298,8 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
syncRangeDisplay(); syncRangeDisplay();
} }
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 상세(detail) 옵션은 B06/B07 /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다. 상세(detail)는 `includeDetail` 인 화면
* 몫이라 그리지 않는다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). */ * (B06/B07)에서만 — 안 받으면 뒷길이·돌규격·형식이 비어 **수량이 갈래를 못 고른다**. */
function renderOptionFields(values: Record<string, string | number> = {}): void { function renderOptionFields(values: Record<string, string | number> = {}): void {
const type = currentType(); const type = currentType();
// 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로 // 여기도 배열을 갈아끼우지 않는다 — 조각들이 참조로 받아 두므로 새 배열로
@@ -303,7 +307,9 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
optionInputs.length = 0; optionInputs.length = 0;
optionRow.replaceChildren(); optionRow.replaceChildren();
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
const visible = type && !facilityFormKind(type) ? type.options.filter(isB05Option) : []; const all = sectionOptions.includeDetail === true;
const visible =
type && !facilityFormKind(type) ? type.options.filter((o) => all || isB05Option(o)) : [];
if (!visible.length) { if (!visible.length) {
optionRow.hidden = true; optionRow.hidden = true;
syncOptionLock(); syncOptionLock();
@@ -317,8 +323,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은 // 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1). // 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]); const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
if (mustPick) choices.unshift(["", "— 선택 —"]);
input = select(choices); input = select(choices);
input.value = String(preset || (option.choices[0] ?? "")); input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") { } else if (option.input === "number") {
input = numberInput("0.1", "0"); input = numberInput("0.1", "0");
input.value = String(preset ?? ""); input.value = String(preset ?? "");
@@ -234,67 +234,70 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
const pushMarks = (): void => const pushMarks = (): void =>
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
const section = createStructuresSection({ const section = createStructuresSection(
onChange: (next) => { {
structures = withLocalIds(next); onChange: (next) => {
section.setStructures(structures); structures = withLocalIds(next);
pushMarks(); section.setStructures(structures);
if (deps.projectId) writePendingStructures(deps.projectId, structures); pushMarks();
deps.onStructuresChanged?.(); if (deps.projectId) writePendingStructures(deps.projectId, structures);
}, deps.onStructuresChanged?.();
onSelect: (structure) => { },
if (structure) deps.focusChainage(structureAnchorM(structure)); onSelect: (structure) => {
}, if (structure) deps.focusChainage(structureAnchorM(structure));
getInterval: deps.stationInterval, },
onReveal: () => deps.reveal?.(), getInterval: deps.stationInterval,
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"), onReveal: () => deps.reveal?.(),
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 → onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다. // 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
onPipeUpdate: (fromChainageM, toChainageM, attributes) => { // [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는 onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다 // 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다). // 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M; // (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
if (moved) { const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
if (deps.movePipe) { if (moved) {
deps.movePipe(fromChainageM, toChainageM); if (deps.movePipe) {
const hit = pipeFacilities.find( deps.movePipe(fromChainageM, toChainageM);
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, const hit = pipeFacilities.find(
); (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
if (hit) hit.chainage_m = toChainageM; );
currentChainageM = toChainageM; if (hit) hit.chainage_m = toChainageM;
section.setPipeFacilities(pipeFacilities); currentChainageM = toChainageM;
pushMarks(); section.setPipeFacilities(pipeFacilities);
showToast(PIPE_MOVE_NOTICE, "success"); pushMarks();
} else { showToast(PIPE_MOVE_NOTICE, "success");
showToast(PIPE_MOVE_GUIDE, "error"); } else {
showToast(PIPE_MOVE_GUIDE, "error");
}
} }
} const patch = attributes.options;
const patch = attributes.options; if (!patch || !Object.keys(patch).length) return;
if (!patch || !Object.keys(patch).length) return; if (!deps.queuePipeOptions) {
if (!deps.queuePipeOptions) { showToast(PIPE_ADD_GUIDE, "error");
showToast(PIPE_ADD_GUIDE, "error"); return;
return; }
} // 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야
// 단 수는 옵션이자 **조작 채널**이다 — 조정창에서 세우던 그 자리로 보내야 // 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게).
// 횡단도에 단이 선다(2026-08-30 사용자 지시 3: 배수관 측점과 동일하게). // 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다.
// 옵션은 **옮기기 전 자리**로 예약한다 — 저장 시점의 관 목록이 그 자리 기준이다. deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>);
deps.queuePipeOptions(fromChainageM, patch as Record<string, number | string>); deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>);
deps.applyPipeOptions?.(fromChainageM, patch as Record<string, number | string>); // 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다.
// 화면 캐시(목록·폼 재로드용)도 같이 맞춘다 — 저장 전에도 값이 유지된다. const hit = pipeFacilities.find(
const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, );
); if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
if (hit) hit.options = { ...(hit.options ?? {}), ...patch }; section.setPipeFacilities(pipeFacilities);
section.setPipeFacilities(pipeFacilities); },
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
if (chainageM !== null) deps.focusChainage(chainageM);
},
}, },
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"), { includeDetail: true },
onPipeSelect: (chainageM) => { );
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
if (chainageM !== null) deps.focusChainage(chainageM);
},
});
// 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이 // 고른 것 없이 좌측 폼을 만지면 값이 아무 데도 가지 않는다(패널 실시간 반영이
// 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: // 선택된 항목에만 걸린다) — 조용히 무시되던 자리라 이유를 알린다(2026-08-30 사용자: