Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -53,7 +53,7 @@ import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile
|
||||
import {
|
||||
structureGroupMenuItems,
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
structureIdAtStation as structureIdAtStationOf,
|
||||
structureIdKeeping,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
@@ -261,9 +261,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
|
||||
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
stationId !== null && stationIdAtStructure(selectedStructureId) === stationId
|
||||
? selectedStructureId
|
||||
: structureIdAtStationOf(stationId, irregularStations, structures);
|
||||
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
|
||||
@@ -626,8 +624,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
setSelectedStructure(structureId: string | null) {
|
||||
// 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2)
|
||||
selectedStructureId = structureId;
|
||||
selectedStationId = stationIdAtStructure(structureId);
|
||||
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
||||
selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId);
|
||||
draw();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
restoreStructurePick,
|
||||
wireStructurePick,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
bindDesignView,
|
||||
@@ -123,6 +123,15 @@ export async function mountProfileView(
|
||||
profilePanel.setMinCoverPipes(pipes);
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
};
|
||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */
|
||||
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
|
||||
const bare =
|
||||
id === null && chainageM !== null
|
||||
? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M)
|
||||
: undefined;
|
||||
if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`);
|
||||
else profilePanel.setSelectedStation(id);
|
||||
};
|
||||
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
|
||||
const profilePanel = createRouteProfilePanel(
|
||||
shared,
|
||||
@@ -205,7 +214,7 @@ export async function mountProfileView(
|
||||
selectionSyncing = value;
|
||||
},
|
||||
selectMarker: (id) => viewer.markers.selectStation(id),
|
||||
selectGraph: (id) => profilePanel.setSelectedStation(id),
|
||||
selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null),
|
||||
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
|
||||
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
|
||||
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
|
||||
@@ -299,10 +308,11 @@ export async function mountProfileView(
|
||||
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
|
||||
const id = station ? irregularStationId(station.id) : null;
|
||||
viewer.markers.selectStation(id);
|
||||
profilePanel.setSelectedStation(id);
|
||||
selectGraphAt(id, chainage);
|
||||
syncBasinHighlight(id);
|
||||
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
|
||||
profilePanel.drainage.selectPipeAtChainage(chainage);
|
||||
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
}
|
||||
@@ -315,6 +325,7 @@ export async function mountProfileView(
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
||||
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
}
|
||||
|
||||
@@ -154,6 +154,30 @@ export function structureIdAtStation(
|
||||
return hit?.structure_id ?? null;
|
||||
}
|
||||
|
||||
/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점
|
||||
* 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며
|
||||
* 고른 구조물 알약이 꺼졌다(53-2). */
|
||||
export function structureIdKeeping(
|
||||
stationId: string | null,
|
||||
selectedId: string | null,
|
||||
stations: IrregularStation[],
|
||||
structures: StructureInstance[],
|
||||
detail: {
|
||||
longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> };
|
||||
} | null,
|
||||
): string | null {
|
||||
const selected = structures.find((item) => item.structure_id === selectedId);
|
||||
const at = detail?.longitudinal.stations.find(
|
||||
(entry) => entry.station_id === stationId,
|
||||
)?.chainage_m;
|
||||
const keeps =
|
||||
stationId !== null &&
|
||||
!!selected &&
|
||||
(stationIdAtStructure(selectedId, stations, structures) === stationId ||
|
||||
(at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M));
|
||||
return keeps ? selectedId : structureIdAtStation(stationId, stations, structures);
|
||||
}
|
||||
|
||||
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
|
||||
export function stationIdAtStructure(
|
||||
structureId: string | null,
|
||||
|
||||
@@ -23,8 +23,8 @@ export interface SelectionSyncPorts {
|
||||
setSyncing: (value: boolean) => void;
|
||||
/** 3D 뷰어 마커 선택. */
|
||||
selectMarker: (stationId: string | null) => void;
|
||||
/** 종단 그래프 세로선 선택. */
|
||||
selectGraph: (stationId: string | null) => void;
|
||||
/** 종단 그래프 세로선 선택 — 누가거리는 세로선 없는 시설(물넘이포장)의 알약을 켜는 데 씀. */
|
||||
selectGraph: (stationId: string | null, chainageM?: number | null) => void;
|
||||
/** 좌측 구조물 폼 선택(누가거리 기준). */
|
||||
selectSidebar: (chainageM: number | null) => void;
|
||||
/** 배수유역도 세부유역 강조(누가거리 기준). */
|
||||
@@ -98,7 +98,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
ports.setSyncing(true);
|
||||
try {
|
||||
ports.selectMarker(id);
|
||||
ports.selectGraph(id);
|
||||
ports.selectGraph(id, chainageM);
|
||||
// 투영 측점이 아직 없어도(방금 넣은 임시 관 — 재계산 전) 누가거리는 그대로
|
||||
// 넘긴다. null을 넘기면 사이드가 "해제"로 받아 임시 배치를 스스로 취소한다
|
||||
// (2026-08-18 임시 배치 자기 삭제 버그).
|
||||
|
||||
@@ -56,8 +56,8 @@ export function numberDuplicateNames(names: ReadonlyArray<string>): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름). */
|
||||
function etcName(structure: StructureInstance): string | null {
|
||||
/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */
|
||||
export function etcName(structure: StructureInstance): string | null {
|
||||
const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : "";
|
||||
return name || null;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
|
||||
import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
|
||||
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
|
||||
@@ -133,7 +133,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
|
||||
(left, right) => structureAnchorM(left) - structureAnchorM(right),
|
||||
);
|
||||
const numberedNames = numberDuplicateNames(
|
||||
anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id),
|
||||
anchorSorted.map(
|
||||
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
|
||||
),
|
||||
);
|
||||
const displayNames = new Map(
|
||||
anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]),
|
||||
@@ -143,7 +145,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
|
||||
const id = structure.structure_id;
|
||||
if (!id) return;
|
||||
const type = typeMap.get(structure.type_id);
|
||||
const rawName = type?.name ?? structure.type_id;
|
||||
const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게)
|
||||
const displayName = displayNames.get(id) ?? rawName;
|
||||
// "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지).
|
||||
const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : "";
|
||||
|
||||
@@ -556,11 +556,10 @@ export function createStructuresSection(
|
||||
// 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인).
|
||||
structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry })));
|
||||
// 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다.
|
||||
if (applyingLive) renderList();
|
||||
else {
|
||||
loadForm(null);
|
||||
renderList();
|
||||
}
|
||||
// [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2).
|
||||
const kept = structures.find((entry) => entry.structure_id === editingId) ?? null;
|
||||
if (!applyingLive) loadForm(editingId === null ? null : kept);
|
||||
renderList();
|
||||
},
|
||||
getStructures: () => [...structures],
|
||||
setPipeFacilities(pipes) {
|
||||
|
||||
@@ -395,6 +395,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-route__irregular-item:hover {
|
||||
border-color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
}
|
||||
|
||||
.b05-route__irregular-item.is-selected {
|
||||
border-color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent);
|
||||
|
||||
@@ -172,12 +172,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
deps.onStructuresChanged?.();
|
||||
},
|
||||
onSelect: (structure) => {
|
||||
if (!structure) return;
|
||||
deps.markStructure?.(structure.structure_id ?? null);
|
||||
if (!structure) {
|
||||
deps.markStructure?.(null); // 풂 = 알약도 끔(53-2)
|
||||
return;
|
||||
}
|
||||
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
|
||||
preferredId = structure.structure_id ?? null;
|
||||
deps.focusChainage(structureAnchorM(structure));
|
||||
preferredId = null;
|
||||
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
|
||||
deps.markStructure?.(structure.structure_id ?? null);
|
||||
},
|
||||
getInterval: deps.stationInterval,
|
||||
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
|
||||
@@ -254,7 +258,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
},
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
deps.markStructure?.(null); // 알약 강조는 관 측점선 짝으로(같은 측점 구조물 강조를 푼다).
|
||||
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
|
||||
selectDesign(
|
||||
deps.projectId,
|
||||
@@ -262,6 +265,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
{ from: "b06" },
|
||||
);
|
||||
if (chainageM !== null) deps.focusChainage(chainageM);
|
||||
// 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2)
|
||||
deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -491,6 +496,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
if (hit.pipeChainageM !== null) {
|
||||
currentChainageM = hit.pipeChainageM;
|
||||
refreshSpecDefaults(hit.pipeChainageM);
|
||||
deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2)
|
||||
}
|
||||
section.selectAt(selection);
|
||||
return { chainageM, structureId: hit.structureId };
|
||||
|
||||
@@ -196,9 +196,14 @@ export function createSectionProfile(
|
||||
const hit = marks.find((entry) => entry.structure_id === structureId);
|
||||
if (!hit) return;
|
||||
const edit = host.edit();
|
||||
if (!isPipeMark(hit) && hit.structure_id && edit)
|
||||
if (!isPipeMark(hit) && hit.structure_id && edit) {
|
||||
edit.selectStructure(hit.structure_id);
|
||||
else host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
|
||||
return;
|
||||
}
|
||||
// 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2)
|
||||
host.selectCard(null);
|
||||
host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
|
||||
profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약
|
||||
},
|
||||
onStructureMarkMove: (structureId, toChainageM) => {
|
||||
const edit = host.edit();
|
||||
|
||||
@@ -103,6 +103,10 @@ def collect_items(
|
||||
definition = types.get(item.type_id)
|
||||
if definition is not None and definition.managed_by:
|
||||
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
|
||||
options = _with_defaults(definition, dict(item.options or {}))
|
||||
label = etc_label(item.type_id, item.options)
|
||||
if label: # 표 머리(이름마다 펼침)도 손 이름 · 비면 「기타」(53-2)
|
||||
options["name"] = label["그룹"]
|
||||
items.append(
|
||||
{
|
||||
"type_id": item.type_id,
|
||||
@@ -110,7 +114,7 @@ def collect_items(
|
||||
"chainage_m": float(item.chainage_m),
|
||||
"start_m": item.start_m,
|
||||
"end_m": item.end_m,
|
||||
"options": _with_defaults(definition, dict(item.options or {})),
|
||||
"options": options,
|
||||
"summary_item": item.summary_item,
|
||||
"복사본": item.structure_id or f"#{index}",
|
||||
"group_items": item.group_items,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""기타 집계표 머리(이름마다 펼침) = 목록 이름 — 손 이름 · 비면 「기타」 — PLAN 53-2.
|
||||
|
||||
화면은 ORCA `ui_checks/53-2_목록_강조.js`.
|
||||
"""
|
||||
|
||||
from M02_MasterTemplete import M02_Table_Fill as fill
|
||||
from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물
|
||||
_save,
|
||||
world,
|
||||
)
|
||||
|
||||
SOURCE = {"종류": "etc", "펼침": ["name"]}
|
||||
|
||||
|
||||
def test_기타_표_머리는_손_이름_비면_기타(world): # noqa: F811 — 고정물
|
||||
base = {"type_id": "etc", "placement": "point"}
|
||||
named = {**base, "structure_id": "e1", "chainage_m": 20.0, "options": {"name": " 배수판 "}}
|
||||
same = {**base, "structure_id": "e2", "chainage_m": 40.0, "options": {"name": "배수판"}}
|
||||
space = {**base, "structure_id": "e3", "chainage_m": 60.0, "options": {"name": " "}}
|
||||
none = {**base, "structure_id": "e4", "chainage_m": 80.0, "options": {}}
|
||||
_save(world["root"], [named, same, space, none], [])
|
||||
items = {i["복사본"]: i for i in fill.collect_items(world["root"])}
|
||||
heads = {key: fill._expansion(SOURCE, items[key]) for key in ("e1", "e2", "e3", "e4")}
|
||||
assert heads == {"e1": ("배수판",), "e2": ("배수판",), "e3": ("기타",), "e4": ("기타",)}
|
||||
assert [items[key]["그룹"] for key in ("e1", "e3", "e4")] == ["배수판", "기타", "기타"]
|
||||
@@ -77,7 +77,11 @@ def test_창_키_듣기를_뗌():
|
||||
|
||||
def test_겹친_알약은_누른_것을_그대로():
|
||||
profile = _read("A00_Common/design/design_profile.ts")
|
||||
assert "stationIdAtStructure(selectedStructureId) === stationId" in profile
|
||||
assert "structureIdKeeping(stationId, selectedStructureId" in profile
|
||||
# 판정은 `_Profile_Structures` 로 옮김 — 비정규 측점 짝 · 규칙 측점(B06 카드) 기준점(53-2)
|
||||
rule = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts")
|
||||
assert "stationIdAtStructure(selectedId, stations, structures) === stationId" in rule
|
||||
assert "Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M" in rule
|
||||
|
||||
|
||||
def test_진행_단계_두_칸이_보기를_따라감():
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/* 53-2 — B05 · B06 좌측 하단 구조물 목록 · 하이라이트 전수 점검
|
||||
* 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계 · 검증 프로젝트(sub4_검증) · 로그인 · 종단 도크 펼침
|
||||
* B05 3D 는 [3D 업데이트] 뒤(코리도 부재 신원표가 있어야 함)
|
||||
* 자료: 구조물 정본에 표 20종 + 숨긴 종단배수(측구) · 옛 계류사방(골막이 · 기슭막이) · 옛 대피소 · 기타 손 이름 ·
|
||||
* 같은 측점 여럿(4+5 = 배관 · 메쌓기 기준점 · 경계석 / 9+0 = 반사경 · 번호판)
|
||||
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m532 = "단계";`
|
||||
* names → 목록 이름 = 정본 · 레지스트리로 지은 이름(기준점 차례 · 같은 이름 번호) · 측점 글
|
||||
* list → 줄마다 누르기: 그 줄만 강조 · 폼 종류 · 알약 하나(그 구조물) · B05 3D(그 측점 부재) · B06 카드(그 측점) · 다시 누르면 다 풀림
|
||||
* pill → 알약마다 누르기: 그 줄 강조 · 목록 칸 안에 보임 · 알약 그대로 켜짐
|
||||
* card → (B06) 구조물 기준점 측점 카드 누르기: 그 측점 줄 강조 · 알약 켜짐
|
||||
* three → (B05) 3D 부재 누르기(화면 좌표 · 실제 포인터 이벤트): 그 측점 줄 강조 · 목록 칸 안
|
||||
* misc → 마우스 올림 규칙(목록 줄 · 알약) · 목록 스크롤(칸보다 김 · 끝 줄 알약 → 보임)
|
||||
* edit → (B05) 반사경 2+10 [추가] → 차례 · 번호 · 강조 풀림 → 그 줄 [삭제] → 처음 목록 → 옹벽 고르고 [저장]
|
||||
* → 차례 그대로 · 강조 그대로 (서버 저장 · 검증 프로젝트)
|
||||
* reopen → (orca reload 뒤 · orca screenshot 한 번 — 안 그려진 탭은 rAF 이 멈춰 「보이게 스크롤」이 안 돎)
|
||||
* 옹벽 강조 · 알약 되살아남 · 목록 칸 안
|
||||
* (B05 · B06 모두 돌려 names · list 결과가 같은지 대조)
|
||||
* 긴 단계(list · pill · card · three)는 연결이 끊길 수 있음 — `void` 로 띄우고
|
||||
* `sessionStorage.getItem("__532_out_{단계}_{b05|b06}")` 로 읽음
|
||||
* 쓰는 것: 화면 조작만(sessionStorage 선택 키) — 끝나면 고른 것 풀어 둠
|
||||
* ORCA 탭이 안 그려지는 동안 requestAnimationFrame 이 멈춤(목록 「보이게 스크롤」이 그 다음 프레임에 돎) —
|
||||
* 단계 동안만 setTimeout 으로 대신 돌리고 끝에 되돌림
|
||||
* 기대 수치: 모든 단계 fails 0
|
||||
*/
|
||||
(async () => {
|
||||
const MODE = window.__m532 || "names";
|
||||
const raf = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16);
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const pid = localStorage.getItem("frd_current_project_id");
|
||||
const api = async (u) => (await fetch(u, { credentials: "include" })).json();
|
||||
const vis = (el) => !!el && el.getClientRects().length > 0;
|
||||
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
|
||||
const isB06 = location.hash.includes("b06");
|
||||
const list = () => document.querySelector(".b05-route__irregular-list");
|
||||
const items = () => [...list().querySelectorAll("li.b05-route__irregular-item")];
|
||||
const selRows = () => items().filter((li) => li.classList.contains("is-selected"));
|
||||
const typeSel = () =>
|
||||
document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
|
||||
const marks = () => [...document.querySelectorAll(".b05-structure__mark")].filter(vis);
|
||||
const litPills = () =>
|
||||
marks()
|
||||
.filter((m) => m.classList.contains("is-selected"))
|
||||
.map((m) => m.dataset.structureId);
|
||||
const cardSel = () =>
|
||||
[...document.querySelectorAll(".b06-cross-card--selected")]
|
||||
.filter(vis)
|
||||
.map((c) => Number(txt(c).split(" ")[1]?.replace("m", "")));
|
||||
const tags = () => {
|
||||
const out = [];
|
||||
window.__corridorScene?.scene.traverse((o) => {
|
||||
if (!o.isMesh || typeof o.userData?.chainageM !== "number") return;
|
||||
const ms = Array.isArray(o.material) ? o.material : [o.material];
|
||||
out.push({ o, c: o.userData.chainageM, lit: ms.some((m) => m.emissive?.getHex()) });
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const lit3D = () => [
|
||||
...new Set(
|
||||
tags()
|
||||
.filter((t) => t.lit)
|
||||
.map((t) => t.c.toFixed(2)),
|
||||
),
|
||||
];
|
||||
const inView = (li) => {
|
||||
const a = li.getBoundingClientRect();
|
||||
const b = list().getBoundingClientRect();
|
||||
return a.top >= b.top - 1 && a.bottom <= b.bottom + 1;
|
||||
};
|
||||
|
||||
// 기대 목록 — 정본 둘(구조물 · 관 지점)을 기준점 오름차순으로(목록과 같은 규칙)
|
||||
const types = (await api("/api/projects/structure-types?include_disabled=true")).types;
|
||||
const typeName = (id) => types.find((t) => t.type_id === id)?.name ?? id;
|
||||
const interval = 20;
|
||||
const sta = (m) => {
|
||||
let n = Math.floor((m + 1e-6) / interval);
|
||||
let r = m - n * interval;
|
||||
if (Math.abs(r) < 0.05) r = 0;
|
||||
if (r >= interval - 0.05) ((n += 1), (r = 0));
|
||||
return `${n}+${Math.round(r)}`;
|
||||
};
|
||||
const structures = (await api(`/api/projects/${pid}/route/structures`)).structures;
|
||||
const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points;
|
||||
const rows = [
|
||||
...structures.map((s) => ({
|
||||
anchor: s.chainage_m ?? s.start_m,
|
||||
type: s.type_id === "refuge" ? "evac_road" : s.type_id, // 옛 대피소는 대피로 폼으로 엶(52-7)
|
||||
pill: s.structure_id,
|
||||
name: (s.type_id === "etc" && String(s.options?.name ?? "").trim()) || typeName(s.type_id),
|
||||
station: s.placement === "interval" ? `${sta(s.start_m)}~${sta(s.end_m)}` : sta(s.chainage_m),
|
||||
})),
|
||||
...pipes.map((p) => ({
|
||||
anchor: p.chainage_m,
|
||||
type: p.facility ?? "pipe",
|
||||
pill: `pipe-${p.chainage_m.toFixed(2)}`,
|
||||
name: typeName(p.facility ?? "pipe"),
|
||||
station: sta(p.chainage_m),
|
||||
})),
|
||||
].sort((a, b) => a.anchor - b.anchor);
|
||||
const total = {};
|
||||
rows.forEach((r) => (total[r.name] = (total[r.name] ?? 0) + 1));
|
||||
const seen = {};
|
||||
rows.forEach((r) => {
|
||||
if (total[r.name] > 1) r.name = `${r.name} ${(seen[r.name] = (seen[r.name] ?? 0) + 1)}`;
|
||||
r.text = `${r.station} ${r.name}`;
|
||||
});
|
||||
|
||||
const clearPick = async () => {
|
||||
for (let k = 0; k < 2 && selRows().length; k++) {
|
||||
selRows()[0].click();
|
||||
await w(900);
|
||||
}
|
||||
};
|
||||
const out = { mode: MODE, view: isB06 ? "b06" : "b05", fails: [] };
|
||||
const fail = (what) => out.fails.push(what);
|
||||
if (MODE === "reopen") {
|
||||
// 다시 연 화면 — 저장 전에 고른 것이 되살아났는가(세션 고름)
|
||||
const sel = selRows();
|
||||
out.restored = sel.map(txt);
|
||||
if (sel.length !== 1 || !/옹벽$/.test(txt(sel[0]))) fail(`되살린 강조 ${out.restored}`);
|
||||
else if (!inView(sel[0])) fail("되살린 줄이 목록 칸 밖");
|
||||
const pills = litPills();
|
||||
if (pills.length !== 1 || pills[0] !== rows.find((r) => /옹벽$/.test(r.text))?.pill)
|
||||
fail(`되살린 알약 ${pills}`);
|
||||
}
|
||||
await clearPick();
|
||||
|
||||
if (MODE === "names") {
|
||||
const got = items().map(txt);
|
||||
out.rows = got.length;
|
||||
out.expected = rows.length;
|
||||
rows.forEach((r, i) => got[i] !== r.text && fail(`${i}: ${got[i]} ≠ ${r.text}`));
|
||||
out.names = got;
|
||||
} else if (MODE === "list") {
|
||||
const tagCs = new Set(tags().map((t) => t.c.toFixed(2)));
|
||||
for (const [i, r] of rows.entries()) {
|
||||
items()[i].click();
|
||||
await w(isB06 ? 1500 : 900);
|
||||
const sel = selRows();
|
||||
if (sel.length !== 1 || sel[0] !== items()[i]) fail(`${r.text}: 줄 강조 ${sel.map(txt)}`);
|
||||
if (typeSel()?.value !== r.type) fail(`${r.text}: 폼 종류 ${typeSel()?.value}`);
|
||||
const pills = litPills();
|
||||
if (pills.length !== 1 || pills[0] !== r.pill) fail(`${r.text}: 알약 ${pills}`);
|
||||
if (!isB06) {
|
||||
const want = [...tagCs].filter((c) => Math.abs(c - r.anchor) < 0.01);
|
||||
const got = lit3D();
|
||||
if (want.join() !== got.join()) fail(`${r.text}: 3D ${got} ≠ ${want}`);
|
||||
} else {
|
||||
// 카드 = 기준점에 가장 가까운 측점 하나(카드 글 「측점 누가거리m …」)
|
||||
const card = cardSel();
|
||||
if (card.length !== 1 || Math.abs(card[0] - r.anchor) > interval / 2)
|
||||
fail(`${r.text}: 카드 ${card}`);
|
||||
}
|
||||
items()[i].click(); // 다시 누름 = 풂
|
||||
await w(900);
|
||||
if (selRows().length || litPills().length || (!isB06 && lit3D().length))
|
||||
fail(`${r.text}: 풂 뒤 남음 ${selRows().length}/${litPills()}/${lit3D()}`);
|
||||
}
|
||||
out.checked = rows.length;
|
||||
} else if (MODE === "pill") {
|
||||
for (const r of rows) {
|
||||
const pill = marks().find((m) => m.dataset.structureId === r.pill);
|
||||
if (!pill) {
|
||||
fail(`${r.text}: 알약 없음`);
|
||||
continue;
|
||||
}
|
||||
pill.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await w(isB06 ? 1500 : 900);
|
||||
const sel = selRows();
|
||||
if (sel.length !== 1 || txt(sel[0]) !== r.text) fail(`${r.text}: 줄 ${sel.map(txt)}`);
|
||||
else if (!inView(sel[0])) fail(`${r.text}: 목록 칸 밖`);
|
||||
if (litPills().join() !== r.pill) fail(`${r.text}: 알약 ${litPills()}`);
|
||||
await clearPick();
|
||||
}
|
||||
out.checked = rows.length;
|
||||
} else if (MODE === "card") {
|
||||
// 측점마다 카드 하나 — 기준점이 그 측점인 줄 중 하나가 켜져야 함(같은 측점 여럿은 52-15 규칙)
|
||||
const cards = [...document.querySelectorAll(".b06-cross-card")];
|
||||
let n = 0;
|
||||
for (const card of cards) {
|
||||
const at = Number(txt(card).split(" ")[1]?.replace("m", ""));
|
||||
const here = rows.filter((r) => Math.abs(r.anchor - at) < 0.51);
|
||||
if (!here.length) continue;
|
||||
// 관 시설 범위 안 카드는 관이 먼저(계곡 통과 시설 우선 · resolveSelection)
|
||||
here.push(...rows.filter((r) => r.pill.startsWith("pipe-") && Math.abs(r.anchor - at) < 5));
|
||||
n += 1;
|
||||
card.scrollIntoView({ block: "nearest" });
|
||||
await w(300);
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await w(1500);
|
||||
const sel = selRows();
|
||||
const hit = here.find((r) => sel.length === 1 && txt(sel[0]) === r.text);
|
||||
if (!hit) fail(`${at}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`);
|
||||
else if (litPills().join() !== hit.pill) fail(`${at}: 알약 ${litPills()} ≠ ${hit.pill}`);
|
||||
if (sel.length && !inView(sel[0])) fail(`${at}: 목록 칸 밖`);
|
||||
await clearPick();
|
||||
}
|
||||
out.checked = n;
|
||||
} else if (MODE === "three") {
|
||||
const S = window.__corridorScene;
|
||||
const canvas = [...document.querySelectorAll("canvas")]
|
||||
.filter(vis)
|
||||
.sort((a, b) => b.width * b.height - a.width * a.height)[0];
|
||||
const done = new Set();
|
||||
let n = 0;
|
||||
// 종단 도크가 캔버스 아래를 덮음 — 단계 동안 접고 끝에 다시 폄
|
||||
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
|
||||
handle?.click();
|
||||
await w(1500);
|
||||
const o = S.toScene(0, 0, 0); // 장면 → 모델: x = x − o.x · y = −(z − o.z) · z = y − o.y
|
||||
for (const t of tags()) {
|
||||
const key = t.c.toFixed(2);
|
||||
if (done.has(key)) continue;
|
||||
const box = new S.THREE.Box3().setFromObject(t.o);
|
||||
const c = box.getCenter(new S.THREE.Vector3());
|
||||
S.lookAt(c.x - o.x, -(c.z - o.z), c.y - o.y, 25, "top"); // 그 부재 위에서 내려다봄
|
||||
S.camera.updateMatrixWorld();
|
||||
const p = c.clone().project(S.camera);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = rect.left + ((p.x + 1) / 2) * rect.width;
|
||||
const y = rect.top + ((1 - p.y) / 2) * rect.height;
|
||||
const top = document.elementFromPoint(x, y);
|
||||
if (top !== canvas) continue; // 패널에 가린 부재는 건너뜀
|
||||
const ray = new S.THREE.Raycaster();
|
||||
ray.setFromCamera(new S.THREE.Vector2(p.x, p.y), S.camera);
|
||||
const first = ray.intersectObject(S.scene, true).find((h) => h.object.isMesh);
|
||||
if (first?.object.userData?.chainageM !== t.c) continue; // 앞에 다른 물체
|
||||
done.add(key);
|
||||
n += 1;
|
||||
const ev = (type) =>
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
button: 0,
|
||||
pointerId: 7,
|
||||
isPrimary: true,
|
||||
});
|
||||
window.dispatchEvent(ev("pointerdown"));
|
||||
canvas.dispatchEvent(ev("pointerdown"));
|
||||
canvas.dispatchEvent(ev("pointerup"));
|
||||
await w(1200);
|
||||
const sel = selRows();
|
||||
const here = rows.filter((r) => Math.abs(r.anchor - t.c) < 0.51);
|
||||
if (!here.some((r) => sel.length === 1 && txt(sel[0]) === r.text))
|
||||
fail(`3D ${key}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`);
|
||||
else if (!inView(sel[0])) fail(`3D ${key}: 목록 칸 밖`);
|
||||
if (!lit3D().includes(key)) fail(`3D ${key}: 부재 강조 ${lit3D()}`);
|
||||
await clearPick();
|
||||
}
|
||||
handle?.click();
|
||||
await w(1500);
|
||||
out.tagged = new Set(tags().map((t) => t.c.toFixed(2))).size;
|
||||
out.checked = n;
|
||||
} else if (MODE === "misc") {
|
||||
const rules = [...document.styleSheets].flatMap((s) => {
|
||||
try {
|
||||
return [...s.cssRules].map((r) => r.selectorText ?? "");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
if (!rules.includes(".b05-route__irregular-item:hover")) fail("목록 줄 마우스 올림 규칙 없음");
|
||||
if (!rules.includes(".b05-structure__mark:hover")) fail("알약 마우스 올림 규칙 없음");
|
||||
const l = list();
|
||||
out.scroll = { scrollH: l.scrollHeight, clientH: l.clientHeight };
|
||||
if (l.scrollHeight <= l.clientHeight) fail("목록이 칸보다 짧음 — 스크롤 시험 안 됨");
|
||||
for (const r of [rows[rows.length - 1], rows[0], rows[Math.floor(rows.length / 2)]]) {
|
||||
marks()
|
||||
.find((m) => m.dataset.structureId === r.pill)
|
||||
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await w(isB06 ? 1500 : 900);
|
||||
const sel = selRows()[0];
|
||||
if (!sel || !inView(sel)) fail(`${r.text}: 스크롤 뒤 안 보임`);
|
||||
await clearPick();
|
||||
}
|
||||
out.checked = 5;
|
||||
} else if (MODE === "edit") {
|
||||
// 추가(반사경 2+10 · 폼) → 차례 · 강조 풀림 → 그 줄 고르고 삭제 → 처음과 같음 → 옹벽 고르고 [저장]
|
||||
const before = items().map(txt);
|
||||
const sec = document.querySelector(".b05-structure-section");
|
||||
const fire = (el, v) => {
|
||||
el.value = v;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
const [g, t] = sec.querySelectorAll(".b05-structure__grid select");
|
||||
fire(g, "E");
|
||||
await w(300);
|
||||
fire(t, "mirror");
|
||||
await w(800);
|
||||
const anchor = sec
|
||||
.querySelectorAll(".b05-structure__position-row > label")[2]
|
||||
.querySelectorAll("input");
|
||||
fire(anchor[0], "2");
|
||||
fire(anchor[1], "10");
|
||||
await w(300);
|
||||
sec.querySelector(".is-primary").click(); // 추가
|
||||
await w(1500);
|
||||
const added = items().map(txt);
|
||||
out.added = added.slice(0, 3);
|
||||
if (added.length !== before.length + 1) fail(`추가 뒤 줄 수 ${added.length}`);
|
||||
if (added[1] !== "2+10 반사경 1" || !added.includes("9+0 반사경 2"))
|
||||
fail(`추가 차례 · 번호 ${added.slice(0, 3)} · 9+0 ${added.find((x) => x.startsWith("9+0"))}`);
|
||||
if (selRows().length || litPills().length) fail("추가 뒤 강조 남음");
|
||||
items()[1].click();
|
||||
await w(900);
|
||||
if (txt(selRows()[0] ?? list()) !== "2+10 반사경 1") fail("새 줄 고르기");
|
||||
if (litPills().length !== 1) fail(`새 줄 알약 ${litPills()}`);
|
||||
sec.querySelector(".is-danger").click(); // 삭제
|
||||
await w(1500);
|
||||
const removed = items().map(txt);
|
||||
if (removed.join("|") !== before.join("|")) fail("삭제 뒤 목록이 처음과 다름");
|
||||
if (selRows().length || litPills().length) fail("삭제 뒤 강조 남음");
|
||||
// 옹벽 고른 채 [저장] — 저장 뒤 차례 그대로 · 강조 그대로
|
||||
items()
|
||||
.find((li) => /옹벽$/.test(txt(li)))
|
||||
.click();
|
||||
await w(900);
|
||||
[...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click();
|
||||
for (let k = 0; k < 120 && !document.querySelector(".ui-toast, [class*='toast']"); k++)
|
||||
await w(500);
|
||||
await w(3000);
|
||||
out.toast = document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null;
|
||||
const saved = items().map(txt);
|
||||
if (saved.join("|") !== before.join("|")) fail("저장 뒤 목록 차례가 다름");
|
||||
out.afterSave = selRows().map(txt);
|
||||
if (out.afterSave.length !== 1 || !/옹벽$/.test(out.afterSave[0])) fail("저장 뒤 강조 풀림");
|
||||
out.checked = 9;
|
||||
}
|
||||
window.requestAnimationFrame = raf;
|
||||
out.failCount = out.fails.length;
|
||||
sessionStorage.setItem(`__532_out_${MODE}_${out.view}`, JSON.stringify(out)); // 긴 단계는 여기서 읽음
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user