- 다른 측점 부재를 누를 때 옛 카드 선택 풀기가 폼 · 선택 칸을 비우던 것 막음(첫 누름부터 잡힘) - 컨테이너 상자 강조가 폼 다시 그리기에 지워지던 것 · 옛 상자 강조 남던 것 · 유입 · 유출도 보이게 스크롤 - 고른 구조물 기준 측점 고침 = 바로 반영 · 횡단 카드 · 알약 · 선택 칸이 따라감 - 부재키는 같은 것을 다시 적을 때만 지킴 · 풀면 지움(B05 ↔ B06 오갈 때 옛 조정창 안 열림) - 카드 고름이 일반 측점 · 이음 카드 알약을 지우던 것 → 연 구조물 알약 지킴 - B05 되살리기 = 구조물 id 가 같은 측점 관보다 먼저 · B06 다시 들어올 때 넘김값 다시 적용 - 시험: ui_checks/53-1_횡단_고르기.js(점검 116 · 흠 0) · 선택 시험 새 규칙으로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Section_View_Profile.ts
|
|
* 횡단 보기의 종단도 — 종단 보기와 **같은 노드 한 벌**(`design_shared` 의 종단도)에 횡단 보기
|
|
* 알림을 걸어 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c · 24-3b).
|
|
*
|
|
* 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다.
|
|
* 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙
|
|
* (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다.
|
|
* ========================================================================== */
|
|
|
|
import type { DesignProfile } from "../A00_Common/design/design_profile";
|
|
import {
|
|
bindDesignView,
|
|
sharedProfile,
|
|
type DesignPageShared,
|
|
} from "../A00_Common/design/design_shared";
|
|
import { readState } from "../A00_Common/b_page_state";
|
|
import { coversChainage } from "../A00_Common/design/design_selection";
|
|
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
|
|
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;
|
|
/** 알약 강조를 그 구조물로(null = 풀기). */
|
|
selectMark: (structureId: string | null) => 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,
|
|
shared: DesignPageShared,
|
|
): SectionProfile {
|
|
let profile: DesignProfile | null = null;
|
|
let detail: SectionDetailResponse | null = null;
|
|
let marks: ReadonlyArray<StructureInstance> = [];
|
|
/** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */
|
|
let markedId: string | null = null;
|
|
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 = (): DesignProfile => {
|
|
bindDesignView(shared, "cross", {
|
|
profile: {
|
|
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),
|
|
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
|
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
|
|
onStructureSelect: (structureId) => {
|
|
const hit = marks.find((entry) => entry.structure_id === structureId);
|
|
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();
|
|
if (!edit) return;
|
|
moveMarkById(structureId, toChainageM, {
|
|
structures: marks,
|
|
stations: detail?.longitudinal.stations ?? [],
|
|
edit,
|
|
});
|
|
},
|
|
},
|
|
},
|
|
});
|
|
return sharedProfile(shared);
|
|
};
|
|
|
|
return {
|
|
root: () => profile?.root ?? null,
|
|
render(input) {
|
|
const [id, route] = input.balloonScope.split(":");
|
|
if (!id || id === "-") return;
|
|
if (!profile) {
|
|
// 종단 보기가 먼저 세웠으면 노선 · 흙덮기 설정은 이미 실려 있다(판 확대를 흔들지 않음).
|
|
const fresh = !shared.profile;
|
|
profile = build();
|
|
if (fresh) 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();
|
|
},
|
|
selectMark(structureId) {
|
|
markedId = structureId;
|
|
profile?.setSelectedStructure(structureId);
|
|
},
|
|
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);
|
|
// 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1)
|
|
const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined;
|
|
if (!pipe && marked && coversChainage(marked, section.chainage_m))
|
|
profile.setSelectedStructure(markedId);
|
|
},
|
|
};
|
|
}
|