Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7

This commit is contained in:
2026-10-06 00:12:30 +09:00
8 changed files with 213 additions and 6 deletions
+8 -1
View File
@@ -179,6 +179,7 @@ export async function mountCrossView(
focusChainage: (chainageM) => {
if (!isWallSelecting()) focusStationAt(chainageM);
},
markStructure: (structureId) => sectionView.selectMark(structureId),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
});
@@ -316,6 +317,7 @@ export async function mountCrossView(
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
@@ -426,7 +428,12 @@ export async function mountCrossView(
handoff,
sectionDetail,
{
focusStation: (stationId) => sectionView.focusStation(stationId),
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15).
focusStation: (stationId) => {
structuresPanel.prefer(handoff?.structureId ?? null);
sectionView.focusStation(stationId);
structuresPanel.prefer(null);
},
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
@@ -145,9 +145,12 @@ export function structureIdAtStation(
if (!stationId.startsWith(prefix)) return null;
const station = stations.find((entry) => irregularStationId(entry.id) === stationId);
if (!station) return null;
const hit = structures.find(
const near = structures.filter(
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
);
// 관 측점선의 짝은 관 알약(`pipe-*`)이다 — 같은 측점 다른 구조물(포장 등)을 켜면 그 알약을
// 누를 때 해제로 읽혀 안 골라졌다(52-15).
const hit = near.find((item) => String(item.structure_id ?? "").startsWith("pipe-")) ?? near[0];
return hit?.structure_id ?? null;
}
@@ -85,6 +85,8 @@ export interface B06StructuresPanelDeps {
stationInterval: () => number;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
focusChainage: (chainageM: number) => void;
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */
markStructure?: (structureId: string | null) => void;
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
onMarks?: (structures: StructureInstance[], types: StructureType[]) => void;
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
@@ -116,6 +118,10 @@ export interface B06StructuresPanel {
releasePipe: () => void;
/** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */
selectionAt: (chainageM: number | null) => DesignSelection;
/** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */
prefer: (structureId: string | null) => void;
/** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */
selectStructure: (structureId: string) => void;
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
getStructures: () => StructureInstance[];
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
@@ -148,6 +154,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
);
let markTypes: StructureType[] = [];
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
let preferredId: string | null = null;
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
const pushMarks = (): void =>
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
@@ -164,7 +172,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
deps.onStructuresChanged?.();
},
onSelect: (structure) => {
if (structure) deps.focusChainage(structureAnchorM(structure));
if (!structure) return;
deps.markStructure?.(structure.structure_id ?? null);
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
preferredId = structure.structure_id ?? null;
deps.focusChainage(structureAnchorM(structure));
preferredId = null;
},
getInterval: deps.stationInterval,
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
@@ -241,6 +254,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
},
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
deps.markStructure?.(null); // 알약 강조는 관 측점선 짝으로(같은 측점 구조물 강조를 푼다).
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
selectDesign(
deps.projectId,
@@ -464,9 +478,13 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
const selection = { chainageM, structureId: null };
return { chainageM, structureId: section.resolveAt(selection).structureId };
},
prefer: (structureId) => {
preferredId = structureId;
},
selectStructure: (structureId) => section.selectById(structureId),
showAtChainage: (chainageM) => {
currentChainageM = null;
const selection: DesignSelection = { chainageM, structureId: null };
const selection: DesignSelection = { chainageM, structureId: preferredId };
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
const hit = resolveSelection(selection, pipeFacilities, structures);
+8 -1
View File
@@ -446,8 +446,15 @@ export function createSectionView(
refreshCards,
focusStation(stationId) {
if (selectedStationId !== stationId) selectStation(stationId, true);
else revealCard(stationId, "smooth");
else {
revealCard(stationId, "smooth");
// 같은 카드라도 알약 강조는 다시 맞춘다 — 목록에서 같은 측점 다른 항목을 고른 경우(52-15).
profile.showCard(
currentDetail?.cross_sections.find((entry) => entry.station_id === stationId) ?? null,
);
}
},
selectMark: (structureId) => profile.selectMark(structureId),
setStationSelectListener(listener) {
stationSelectListener = listener;
},
@@ -29,6 +29,8 @@ export interface SectionStructureEdit {
movePipe: (fromChainageM: number, toChainageM: number) => void;
/** 구조물(관 아님)을 다른 측점으로 옮긴다. */
moveStructure: (structureId: string, toChainageM: number) => void;
/** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */
selectStructure: (structureId: string) => void;
}
/**
@@ -74,6 +74,8 @@ export interface SectionProfile {
redraw: () => void;
/** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */
showCard: (section: CrossSection | null) => void;
/** 알약 강조를 그 구조물로(null = 풀기). */
selectMark: (structureId: string | null) => void;
}
/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */
@@ -189,9 +191,14 @@ export function createSectionProfile(
onIrregularSelect: (station) =>
host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null),
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
onStructureSelect: (structureId) => {
const hit = marks.find((entry) => entry.structure_id === structureId);
if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
if (!hit) return;
const edit = host.edit();
if (!isPipeMark(hit) && hit.structure_id && edit)
edit.selectStructure(hit.structure_id);
else host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
},
onStructureMarkMove: (structureId, toChainageM) => {
const edit = host.edit();
@@ -245,6 +252,9 @@ export function createSectionProfile(
redraw() {
profile?.redraw();
},
selectMark(structureId) {
profile?.setSelectedStructure(structureId);
},
showCard(section) {
if (!profile) return;
if (!section) {
@@ -40,6 +40,8 @@ export interface SectionViewController {
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
focusStation: (stationId: string) => void;
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */
selectMark: (structureId: string | null) => void;
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
* 올린다(2026-08-29 일원화). null = 선택 해제. */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
@@ -0,0 +1,158 @@
/* 52-15 — 같은 측점에 관 + 포장이 함께 있을 때 좌측 목록 · 알약 · 넘김값이 누른 것을 그대로 고르는가
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 로그인 상태 · 목록에 「22+0 배관 3」
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5215 = "단계";` 를 붙여
* orca eval --expression "window.__m5215='setup'; $(cat 이 파일)" → sessionStorage 사진 · 22+0 에 콘크리트 포장 [추가](저장 안 함)
* orca eval --expression "window.__m5215='check'; $(cat 이 파일)" → 지금 화면(B05 · B06) 목록 고르기 네 길
* orca eval --expression "window.__m5215='handoff'; $(cat 이 파일)" → B05 에서 포장 고른 채 B06 으로(넘김값)
* orca eval --expression "window.__m5215='pill'; $(cat 이 파일)" → B06 종단 알약으로 포장 고르기
* orca eval --expression "window.__m5215='restore'; $(cat 이 파일)" → sessionStorage 를 사진 그대로 · 뒤에 orca reload
* 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage)
* 기대 수치: 네 길 모두 누른 종류(pavement_concrete · pipe) · 포장일 때 고름 칸 sid = 포장 id ·
* handoff = pavement_concrete · pill = pavement_concrete · restore 뒤 sessionStorage 같음
*/
(async () => {
const MODE = window.__m5215 || "check";
const PIPE = /^22\+0 배관/;
const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 기준 22+0 · 길이 10 m
const OTHER = /^4\+5 배관/;
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const sec = () => document.querySelector(".b05-structure-section");
const spec = () => sec().querySelector(".b05-structure__spec");
const selects = () => sec().querySelectorAll(".b05-structure__grid select");
const fire = (el, value) => {
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
};
const specCtl = (item) =>
spec()?.querySelector(`[data-spec-item="detail.${item}"]`)?.querySelector("select, input");
const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const text = (li) => li.innerText.replace(/\s+/g, " ").trim();
const row = (re) => listItems().find((li) => re.test(text(li)));
const click = async (re) => {
const li = row(re);
if (!li) return false;
(li.querySelector("button") ?? li).click();
await w(900);
return true;
};
const pickKey = () =>
Object.keys(sessionStorage).find((k) => k.includes("structure-pick") && k.includes(PROJECT));
const PROJECT = "936be972-11bc-46c2-8bf3-b15d8de7df0d";
const picked = () => {
const raw = pickKey() ? sessionStorage.getItem(pickKey()) : null;
let sid = null;
try {
const value = JSON.parse(raw);
sid = (value?.value ?? value)?.sid ?? null;
} catch {
/* 빈 값 */
}
return { type: selects()[1]?.value ?? null, sid };
};
/** 한 화면의 네 길 — 다른 측점 → 포장(첫 눌림) · 다른 측점 → 관 · 관 → 포장 · 포장 → 관. */
const fourWays = async () => {
const out = {};
const run = async (name, first, second) => {
await click(first);
await click(second);
out[name] = picked();
};
await run("otherToPave", OTHER, PAVE);
await run("otherToPipe", OTHER, PIPE);
await click(PIPE); // 재선택 = 해제 — 관이 골라진 채로 시작
await click(PIPE);
await click(PAVE);
out.pipeToPave = picked();
await click(PIPE);
out.paveToPipe = picked();
await click(PIPE); // 해제
return out;
};
const out = { mode: MODE };
await until(() => listItems().length > 0);
if (MODE === "setup") {
window.__m5215snap = JSON.stringify(Object.entries(sessionStorage));
const [g, t] = selects();
fire(g, "G");
await w(300);
fire(t, "pavement_concrete");
await w(1500);
const anchor = () =>
sec().querySelectorAll(".b05-structure__position-row > label")[2].querySelectorAll("input");
fire(anchor()[0], "22");
await w(300);
fire(anchor()[1], "0");
await w(500);
out.anchor = [...anchor()].map((i) => i.value);
fire(specCtl("spec"), "콘크리트");
await w(200);
fire(specCtl("height"), "0.35");
await w(200);
sec().querySelector(".is-primary").click(); // 추가
await w(1000);
out.rows = listItems()
.map(text)
.filter((t) => /22\+[05]/.test(t));
} else if (MODE === "check") {
out[location.hash.includes("b06") ? "b06" : "b05"] = await fourWays();
} else if (MODE === "handoff") {
await click(OTHER);
await click(PAVE); // B05 에서 포장을 고른 채 옮김
out.b05Picked = picked();
location.hash = "#/b06-section";
await until(() => listItems().length > 0 && selects()[1]?.value, 20000);
await w(2000);
out.handoff = picked();
out.handoffOk = out.handoff.type === "pavement_concrete";
} else if (MODE === "pill") {
// 알약 — B06 종단 알약 레인의 포장을 누른다(다른 측점을 고른 뒤 · 같은 측점 관이 골라진 뒤).
await click(OTHER);
await click(PAVE);
const sid = picked().sid;
await click(PAVE); // 해제
const pill = () =>
[...document.querySelectorAll("[data-structure-id]")].find(
(node) => node.dataset.structureId === sid,
);
out.pillFound = !!pill();
for (const [name, first] of [
["otherToPill", OTHER],
["pipeToPill", PIPE],
]) {
await click(first);
pill()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await w(900);
out[name] = picked();
}
await click(PIPE); // 관 고른 채로 끝났으면 해제
out.pillOk =
out.otherToPill?.type === "pavement_concrete" && out.pipeToPill?.type === "pavement_concrete";
} else if (MODE === "restore") {
const snap = window.__m5215snap;
if (!snap) {
out.error = "사진 없음";
} else {
sessionStorage.clear();
for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v);
out.same = JSON.stringify(Object.entries(sessionStorage)) === snap;
}
}
const ways = (r) =>
!!r &&
r.otherToPave.type === "pavement_concrete" &&
!!r.otherToPave.sid &&
r.otherToPipe.type === "pipe" &&
r.pipeToPave.type === "pavement_concrete" &&
r.paveToPipe.type === "pipe";
if (out.b05) out.b05Ok = ways(out.b05);
if (out.b06) out.b06Ok = ways(out.b06);
return JSON.stringify(out);
})();