Files
Aislo/B06_Section/B06_Section_UI_Section_View_Profile.ts
T
eomsangdonandClaude Opus 5.5 60acb5ba79 fix(463 B05 B06 한 페이지): 페이지 설정 한 칸 · 강제 받기 · 측점 만들기 뒤 상세 알림 · [저장] 상단측 차례 같은 본문
- (B) 종단도 기준(기울기 상한 · 최소고 강제 · 시작 측점) = 세션 초안 ?? 저장 노선 설정 — 두 보기가 그린 뒤 applyPageSettings 하나 · B06 이 받은 노선은 캐시에
- (C) 상세 강제 다시 받기 = 듣는 쪽 한 번(숨은 종단 보기 옛 객체 안 쥠) · [측점 만들기] 뒤 캐시 비우고 강제로 받음
- (D) [저장] 상단측 차례 = flushRouteParts 한 벌(노선 캐시 지표 조건 + 확정 지표면 id · 비정규 빈 목록) — 종단 보기를 열든 안 열든 같은 요청
- 시험 test_271_detail_notify · test_271_save_same · test_271_profile_settings · 옛 저장 시험 가짜 창구에 flushRouteParts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:51:49 +09:00

281 lines
13 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Section_View_Profile.ts
* 횡단 보기의 종단도 — 종단 보기와 **같은 노드 한 벌**(`design_shared` 의 종단도)에 횡단 보기
* 알림을 걸어 횡단 카드와 선택 · 구조물 편집 · 재계산을 잇는다(계획서 24-2c · 24-3b).
*
* 옛 위쪽 붙박이 종단(자체 그래프 · 유토곡선 없음 · 흙덮기 막기 없음)을 갈음한다.
* 도구(▲▼ · 되돌리기 · 직선화 · 옮기기 · 확대 · 표 · 유토곡선 · 범례 · 뱃지)와 막기 규칙
* (최소 흙덮기 · 기울기 상한)은 종단도 한 벌이 맡고, 여기서는 이음만 한다. 알약 · 측점선 · 흙덮기 관은
* 페이지 시설 줄 한 벌(`shared.lane` · 462)이 종단도에 씀 — 여기는 읽기만.
* ========================================================================== */
import type { DesignProfile } from "../A00_Common/design/design_profile";
import {
bindDesignView,
sharedProfile,
type DesignPageShared,
} from "../A00_Common/design/design_shared";
import {
coversChainage,
NO_SELECTION,
PIPE_MATCH_M,
readSelection,
selectDesign,
} from "../A00_Common/design/design_selection";
import { paintMapPick } from "../A00_Common/design/design_pick_profile";
import { ESCAPE_FROM } from "../A00_Common/design/design_pick_escape";
import { plantsStation } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
import {
fetchLatestRoute,
readRouteLatestCache,
writeRouteLatestCache,
} from "../B05_Profile/B05_Profile_Api_Fetch";
import { applyPageSettings } from "../B05_Profile/B05_Profile_UI_Page_Latest";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import { irregularStationId, isPipeStation } 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 = PIPE_MATCH_M;
/** 종단 우클릭의 옛 자유 텍스트 구조물 → 레지스트리 타입(종단 보기와 같은 매핑). */
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;
/** 카드가 바뀌었다 — 유토곡선 · 뱃지만 다시 싣는다(편집 초안은 그대로). */
redraw: () => void;
/** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */
showCard: (section: CrossSection | null) => void;
/** 알약 강조를 그 구조물로(null = 풀기). */
selectMark: (structureId: string | null) => void;
}
/**
* 배수유역도에 노선 평면선을 싣는다 — 종단 보기가 두고 간 최신 노선 캐시를 먼저 쓰고,
* 없으면 한 번 읽어 캐시에 둔다(페이지 설정 한 칸이 읽음 · 463).
*/
function loadRoute(projectId: string, profile: DesignProfile): void {
const cached = readRouteLatestCache(projectId);
if (cached?.route_points?.length) {
profile.setRoutePolyline(cached.route_points);
return;
}
void fetchLatestRoute(projectId)
.then((latest) => {
writeRouteLatestCache(projectId, latest);
profile.setRoutePolyline(latest.route_points ?? []);
applyPageSettings(profile, projectId, latest); // 그린 뒤 왔으면 지금 얹음
})
.catch(() => {
/* 노선을 못 읽으면 판이 「노선 필요」 안내를 그대로 보인다. */
});
}
export function createSectionProfile(
host: SectionProfileHost,
shared: DesignPageShared,
): SectionProfile {
let profile: DesignProfile | null = null;
let detail: SectionDetailResponse | null = null;
/** 알약 · 비정규 측점 = 시설 줄 한 벌(462 · 읽기만). */
const lane = shared.lane;
/** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */
let markedId: string | null = null;
/** 마지막으로 받은 관 목록 지문(자리 · 종류 · 관경) — 바뀔 때만 카드를 다시 얹음. */
let pipeSignature: string | null = null;
/** 가까운 카드 — 관 측점 · 구조물 자리에서 카드를 고를 때. */
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 = lane.stations().find((entry) => irregularStationId(entry.id) === stationId);
if (pipe) return nearestCard(pipe.chainage_m)?.station_id ?? null;
return stationId;
};
/** 배수유역도에서 고른 관 자리 → 고름 한 칸(null = 풂) — 횡단 보기 구독이 카드 · 폼 · 알약 · 표 열을
* 그 값으로 세움(종단 보기 `pickFromMap` 과 같은 몫 · 270). */
const pickFromMap = (chainageM: number | null): void =>
selectDesign(
shared.projectId,
chainageM === null ? NO_SELECTION : { chainageM, structureId: null },
{ from: "map" },
);
/** 한 벌 종단도에 횡단 보기 알림을 건다 — 횡단 보기가 보일 때만 이쪽으로 온다. */
const build = (): DesignProfile => {
bindDesignView(shared, "cross", {
profile: {
onSelectStation: (stationId) => host.selectCard(cardOf(stationId)),
callbacks: {
// 숨은 횡단 보기는 건너뜀 — 다시 보일 때 정합이 카드를 맞춤(onShow)
onCrossDesignsUpdated: (updated) => {
if (shared.view() === "cross") 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),
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270)
onPipesChanged: (pipes) => {
if (shared.view() === "cross" && shared.profile?.side)
paintMapPick(shared.profile.side, readSelection(shared.projectId));
// 관 종류 · 관경 · 채운 월류 높이가 바뀜(새 관 관경 · 빈 높이는 배수 재계산이 정함) — 카드 관 그림도 다시(273-2 · 298-1)
const signature = JSON.stringify(
pipes.map((p) => [
p.chainage_m,
p.facility,
p.options?.pipe_diameter_mm ?? null,
p.options?.ford_height_m ?? null,
]),
);
const changed = pipeSignature !== null && signature !== pipeSignature;
pipeSignature = signature;
if (changed && shared.view() === "cross") host.edit()?.pipesChanged?.();
},
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
onStructureSelect: (structureId) => {
// 알약 다시 누름 = 풀기 한 길(Esc 와 같은 값 — 카드 · 폼 · 알약 · 범위 테두리 모두 · 요구 178)
if (structureId === null) {
selectDesign(shared.projectId, NO_SELECTION, { from: ESCAPE_FROM });
return;
}
const hit = lane.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);
return;
}
// 측점 없는 관 = 카드 안 고름 · 스크롤 없음 · 그 관 폼 · 알약만(요구 178 · 307)
if (edit?.selectPipe && !plantsStation(hit, lane.types())) {
edit.selectPipe(structureAnchorM(hit));
return;
}
// 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2)
host.selectCard(null);
host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약
},
onStructureMarkMove: (structureId, toChainageM) => {
const edit = host.edit();
if (!edit) return;
moveMarkById(structureId, toChainageM, {
structures: lane.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);
}
detail = input.detail;
lane.setIntervalM(input.stationInterval);
const routeId = Number(route);
profile.render(
input.detail,
input.stationInterval,
Number.isFinite(routeId) ? routeId : undefined,
);
applyPageSettings(profile, id); // 페이지 설정 한 칸 — B05 를 안 열었어도 같은 기준(463)
if (input.conversion) {
profile.setEarthworkContext({
conversion: input.conversion,
haulLimits: input.haulLimits,
naturalSpoilMinSlope: input.naturalSpoilMinSlope,
});
}
lane.paint(); // 다시 그린 종단도에 시설 줄(추가 측점이 늦게 왔으면 다시 셈)
},
redraw() {
profile?.redraw();
},
selectMark(structureId) {
markedId = structureId;
profile?.setSelectedStructure(structureId, true); // 카드가 측점선 주인 — 선 없는 구조물 · 풂은 카드 선 지킴(177-3)
},
showCard(section) {
if (!profile) return;
if (!section) {
profile.setSelectedStation(null);
return;
}
const pipe = lane
.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
? lane.marks().find((entry) => entry.structure_id === markedId)
: undefined;
if (!pipe && marked && coversChainage(marked, section.chainage_m))
profile.setSelectedStructure(markedId, true);
},
};
}