Files
Aislo/B06_Section/B06_Section_UI_Section_View_Profile.ts
T
eomsangdonandClaude Opus 5.5 90a2cb9a11 refactor(B05·B06): 한 페이지 뼈대 — 주소 둘이 같은 페이지(design_page) · 같은 페이지 안 이동은 보기만 바꿈 · 떠날 때 3D · 종단도 · 횡단 정리 · 숨긴 3D 그리기 멈춤 · 창 키 듣기 뗌 · 뷰어 나눔(771→541)
- 라우터: 여러 주소 페이지(MountedPage) 길 · 같은 페이지면 셸도 다시 안 그림 · 떠나면 dispose · 늦게 끝난 옛 렌더 버림
- design_page: 보기 약속 DesignPageView(mount 중간 단계 · mountCenter 끝 모습 + shared 한 벌 자리) · host.onShow · onHide · onDispose
- 3D 뷰어: setActive(루프 멈춤 · 창 포인터 뗌) · dispose 가 WebGL 문맥까지 놓음 · 보관 지형은 남김 · 코리도 조각을 Viewer_Corridor 로
- 종단도: setActive(창 키 듣기 뗌 · 보일 때 초안 · 판 다시 읽음)
- 겹친 알약: 같은 측점이면 누른 알약을 그대로(24-6b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
2026-09-29 21:39:31 +09:00

265 lines
10 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Section_View_Profile.ts
* 횡단 보기의 종단도 — 종단 보기와 **같은 종단도 한 벌**(`design_profile`)을 가운데
* 아래쪽에 두고, 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c).
*
* 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다.
* 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙
* (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다.
* ========================================================================== */
import { createDesignProfile, type DesignProfile } from "../A00_Common/design/design_profile";
import { readState } from "../A00_Common/b_page_state";
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
import { drainageSide, drainageSideTool } from "../B05_Profile/B05_Profile_UI_Profile_SideTool";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import {
irregularStationId,
isPipeStation,
type IrregularStation,
} from "../B05_Profile/B05_Profile_UI_IrregularStations";
import {
isPipeMark,
moveMarkById,
type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu";
/** 관 · 측점을 같은 자리로 보는 거리(m) — 계획서 24-1 `PIPE_MATCH_M` 과 같은 값. */
const MATCH_M = 0.51;
/** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */
const LEGACY_TYPE_IDS: Record<string, string> = {
기성막이: "revetment",
대피로: "refuge",
기타: "etc",
};
export interface SectionProfileHost {
/** 종단도에서 측점을 골랐다 — 그 카드를 고르고 보이게 한다(null = 해제). */
selectCard: (stationId: string | null) => void;
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭 · 끌기가 아무것도 안 함). */
edit: () => SectionStructureEdit | null;
/** 계획고 편집으로 다시 계산한 측점 카드만 갈아 끼운다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
}
export interface SectionProfile {
root: () => HTMLElement | null;
render: (input: {
detail: SectionDetailResponse;
balloonScope: string;
stationInterval: number;
conversion?: EarthworkConversion;
haulLimits?: HaulEquipmentLimit[];
naturalSpoilMinSlope?: number;
}) => void;
setMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */
redraw: () => void;
/** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */
showCard: (section: CrossSection | null) => void;
setActive: (active: boolean) => void;
dispose: () => void;
}
/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */
function pipeStations(
marks: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
intervalM: number,
): IrregularStation[] {
return marks
.filter((mark) => isPipeMark(mark) && mark.type_id !== "ford_pavement")
.map((mark) => {
const chainage = structureAnchorM(mark);
const station = Math.floor(chainage / intervalM);
return {
id: String(mark.structure_id),
station,
remainder: chainage - station * intervalM,
chainage_m: chainage,
structure: types.find((type) => type.type_id === mark.type_id)?.name ?? "배수관",
structureType: "배관",
origin: "pipe",
} as IrregularStation;
});
}
/**
* 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고,
* 없으면 한 번 읽는다(읽기만 · 계산 없음).
*/
function loadRoute(projectId: string, profile: DesignProfile): void {
// 최소 흙덮기 강제 여부 — 종단 보기를 거치지 않고 바로 들어왔으면 저장된 노선 설정값.
const enforceKnown = readState<boolean>("profile-enforce-min-cover", projectId) !== null;
const apply = (latest: RouteLatestResponse): void => {
profile.setRoutePolyline(latest.route_points ?? []);
if (!enforceKnown) {
profile.setEnforceMinCover(latest.route_params?.enforce_pipe_clearance === true);
}
};
const cached = readState<RouteLatestResponse>("latest", projectId);
if (cached?.route_points?.length) {
apply(cached);
return;
}
void fetchLatestRoute(projectId)
.then(apply)
.catch(() => {
/* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */
});
}
export function createSectionProfile(host: SectionProfileHost): SectionProfile {
let profile: DesignProfile | null = null;
let projectId: string | null = null;
let detail: SectionDetailResponse | null = null;
let marks: ReadonlyArray<StructureInstance> = [];
let types: ReadonlyArray<StructureType> = [];
let stations: IrregularStation[] = [];
let intervalM = 20;
/** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */
const nearestCard = (chainageM: number): CrossSection | null => {
let best: CrossSection | null = null;
for (const section of detail?.cross_sections ?? []) {
if (!best || Math.abs(section.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM))
best = section;
}
return best;
};
/** 종단도 측점 id → 카드 id. 관 측점(`irregular:`)은 누가거리로 카드를 찾는다. */
const cardOf = (stationId: string | null): string | null => {
if (stationId === null) return null;
const pipe = stations.find((entry) => irregularStationId(entry.id) === stationId);
if (pipe) return nearestCard(pipe.chainage_m)?.station_id ?? null;
return stationId;
};
const syncMarks = (): void => {
if (!profile) return;
profile.setStructureTypes([...types]);
profile.setStructures([...marks]);
stations = pipeStations(marks, types, intervalM);
profile.setIrregularStations(stations);
// 판(배수유역도)이 없는 보기 — 관 목록을 직접 넘겨 최소 흙덮기를 판정한다.
profile.setMinCoverPipes(
marks.filter(isPipeMark).map((mark) => ({
chainage_m: structureAnchorM(mark),
facility: mark.type_id as never,
options: mark.options,
})),
);
};
const build = (id: string): DesignProfile =>
createDesignProfile({
projectId: id,
view: "cross",
// 오른쪽 판(배수유역도) — 종단 보기와 같은 판 · 같은 켬끔 값(계획서 24-2e).
side: drainageSide,
sideTool: drainageSideTool,
onSelectStation: (stationId) => host.selectCard(cardOf(stationId)),
callbacks: {
onCrossDesignsUpdated: (updated) => host.refreshCards(updated),
onStructureMove: (from, to, station) => {
if (isPipeStation(station)) host.edit()?.movePipe(from, to);
},
onStructureRemove: (station) => {
if (isPipeStation(station)) host.edit()?.removePipe(station.chainage_m);
},
onPipeAdd: (chainage) => host.edit()?.addPipe(chainage),
onStructureAdd: (chainage, type) =>
host.edit()?.addStructureType(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId),
onIrregularSelect: (station) =>
host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null),
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
onStructureSelect: (structureId) => {
const hit = marks.find((entry) => entry.structure_id === structureId);
if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
},
onStructureMarkMove: (structureId, toChainageM) => {
const edit = host.edit();
if (!edit) return;
moveMarkById(structureId, toChainageM, {
structures: marks,
stations: detail?.longitudinal.stations ?? [],
edit,
});
},
},
});
return {
root: () => profile?.root ?? null,
render(input) {
const [id, route] = input.balloonScope.split(":");
if (!id || id === "-") return;
if (profile && projectId !== id) {
profile.dispose();
profile = null;
}
if (!profile) {
projectId = id;
profile = build(id);
loadRoute(id, profile);
syncMarks();
}
detail = input.detail;
intervalM = input.stationInterval;
const routeId = Number(route);
profile.render(
input.detail,
input.stationInterval,
Number.isFinite(routeId) ? routeId : undefined,
);
if (input.conversion) {
profile.setEarthworkContext({
conversion: input.conversion,
haulLimits: input.haulLimits,
naturalSpoilMinSlope: input.naturalSpoilMinSlope,
});
}
syncMarks();
},
setMarks(nextMarks, nextTypes) {
marks = nextMarks;
types = nextTypes;
syncMarks();
},
redraw() {
profile?.redraw();
},
showCard(section) {
if (!profile) return;
if (!section) {
profile.setSelectedStation(null);
return;
}
const pipe = stations.find(
(entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M,
);
profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id);
},
setActive: (active) => profile?.setActive(active),
dispose() {
profile?.dispose();
profile = null;
},
};
}