- B06 상세 모달 표 위 CAD 미리 넣음(pipeMark)과 B07 짝(_pipe_mark) 모두 원 + 원 안 세 줄만 - 요구 169 원문 기록 · 시험 260 · 265 기대값 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Detail_Cad.ts
|
|
* 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1).
|
|
*
|
|
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
|
|
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
|
|
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
|
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
|
|
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
|
|
* 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면.
|
|
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
|
|
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
|
|
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
|
|
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
|
|
* ========================================================================== */
|
|
|
|
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
|
|
import { readState, writeState } from "../A00_Common/b_page_state";
|
|
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
|
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
|
import {
|
|
fetchStructures,
|
|
fetchStructureTypes,
|
|
readPendingStructures,
|
|
} from "../B05_Profile/B05_Profile_Api_Structures";
|
|
import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon";
|
|
import {
|
|
readGroupItems,
|
|
readSummaryItem,
|
|
summaryLabel,
|
|
type SummaryItem,
|
|
} from "../common_util/common_util_summary_item";
|
|
import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch";
|
|
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
|
|
|
|
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
|
|
|
|
/** [저장]이 patch 로 싣는 CAD 칸 — 측점(m) → 엔티티 묶음. */
|
|
export function readStructureNoteCads(
|
|
projectId: string,
|
|
routeId: number,
|
|
): Map<number, CrossNoteCad> {
|
|
const raw = readState<Record<string, CrossNoteCad>>("structure-note-cads", projectId, routeId);
|
|
return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad]));
|
|
}
|
|
|
|
function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void {
|
|
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
|
|
if (section.design) section.design.structure_note_cad = cad;
|
|
const all = readState<Record<string, CrossNoteCad>>(
|
|
"structure-note-cads",
|
|
at.projectId,
|
|
at.routeId,
|
|
);
|
|
const next = { ...all, [noteKey(section.chainage_m)]: cad };
|
|
writeState("structure-note-cads", next, at.projectId, at.routeId);
|
|
}
|
|
|
|
/** 세션 초안 → 저장분 → 없음(null). */
|
|
function noteCadOf(section: CrossSection, at: NoteCadPlace): CrossNoteCad | null {
|
|
const draft = readState<Record<string, CrossNoteCad>>(
|
|
"structure-note-cads",
|
|
at.projectId,
|
|
at.routeId,
|
|
);
|
|
return draft?.[noteKey(section.chainage_m)] ?? section.design?.structure_note_cad ?? null;
|
|
}
|
|
|
|
/* ── 엔티티(CAD 저장 꼴 · B07 `_text_entity` · `_circle_entity` 와 같은 꼴) ───────── */
|
|
|
|
const LAYER_ID = "기본";
|
|
const BLANK_LAYER = { id: LAYER_ID, name: "기본", isVisible: true, isLocked: false };
|
|
const FONT = 2.0;
|
|
const LINE = 3.0;
|
|
/** 옛 글 색 = B07 표 글(`TABLE_LABEL_COLOR`) · 미리 넣음 = 그림처럼 붉은 계열. */
|
|
const NOTE_COLOR = "#e8edf4";
|
|
const CULVERT_COLOR = "#ff7f7f";
|
|
|
|
const newId = (): string => crypto.randomUUID();
|
|
|
|
function textEntity(
|
|
label: string,
|
|
x: number,
|
|
y: number,
|
|
color: string,
|
|
align: "left" | "center" = "left",
|
|
): Record<string, unknown> {
|
|
return {
|
|
id: newId(),
|
|
type: "Text",
|
|
lineColor: color,
|
|
lineWidth: 1,
|
|
layerId: LAYER_ID,
|
|
shapeData: {
|
|
label,
|
|
basePoint: { x, y },
|
|
options: {
|
|
textDirection: { x: 1, y: 0 },
|
|
textAlign: align,
|
|
textColor: color,
|
|
fontSize: FONT,
|
|
fontFamily: "sans-serif",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 옛 글 → 줄마다 Text(B07 `cross_note_lines` 와 같은 줄 나눔 · 빈 줄은 자리만). */
|
|
export function legacyNoteEntities(note: string | undefined): Record<string, unknown>[] {
|
|
const lines = (note ?? "")
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trimEnd());
|
|
if (!lines.some(Boolean)) return [];
|
|
return lines.flatMap((line, index) =>
|
|
line ? [textEntity(line, 0, -LINE * (index + 0.5), NOTE_COLOR)] : [],
|
|
);
|
|
}
|
|
|
|
/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */
|
|
|
|
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
|
|
const wholeMetres = (value: number | null | undefined): string =>
|
|
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
|
|
|
|
/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */
|
|
export function stationNoLabel(chainageM: number, intervalM: number): string {
|
|
const safe = intervalM > 0 ? intervalM : 1;
|
|
let number = Math.floor((chainageM + 1e-6) / safe);
|
|
let rest = chainageM - number * safe;
|
|
if (rest >= safe - 0.05) {
|
|
number += 1;
|
|
rest = 0;
|
|
}
|
|
return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`;
|
|
}
|
|
|
|
/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */
|
|
export interface NoteThing {
|
|
name: string;
|
|
/** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */
|
|
pipe: boolean;
|
|
/** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */
|
|
at: number;
|
|
summary_item?: unknown;
|
|
group_items?: unknown;
|
|
/** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */
|
|
optionLines?: string[];
|
|
}
|
|
|
|
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
|
|
const PIPE_BODY_LINK = "grv6lny";
|
|
|
|
/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */
|
|
export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] =>
|
|
things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M);
|
|
|
|
/**
|
|
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
|
|
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
|
|
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1).
|
|
*/
|
|
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
|
|
return thingsAt(things, chainageM).flatMap((thing) => {
|
|
const own = readSummaryItem(thing.summary_item);
|
|
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
|
|
if (own) slots.unshift(["", own]);
|
|
const names = slots
|
|
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
|
|
.map(([, item]) => summaryLabel(item));
|
|
const combos = slots.length
|
|
? [...new Set(names)]
|
|
: thing.pipe
|
|
? []
|
|
: [`${thing.name} 조합 미지정`];
|
|
return [...combos, ...(thing.optionLines ?? [])];
|
|
});
|
|
}
|
|
|
|
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
id: newId(),
|
|
type,
|
|
lineColor: CULVERT_COLOR,
|
|
lineWidth: 1,
|
|
layerId: LAYER_ID,
|
|
shapeData,
|
|
};
|
|
}
|
|
|
|
/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) ·
|
|
* 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) · 지시선 없음(169). */
|
|
function pipeMark(section: CrossSection): Record<string, unknown>[] {
|
|
const culvert = section.culvert;
|
|
if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return [];
|
|
if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M)
|
|
return [];
|
|
const [cx, cy, radius] = [6.5, -9.0, 6.5];
|
|
const inside = [
|
|
"배수관",
|
|
`Φ${Math.round(culvert.diameter_m * 1000)}m/m`,
|
|
`L=${wholeMetres(section.design?.pipe_length_m)}`,
|
|
];
|
|
return [
|
|
shape("Circle", { center: { x: cx, y: cy }, radius }), // 아래 수직 지시선 없음(169)
|
|
...inside.map((line, index) =>
|
|
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
|
|
),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) ·
|
|
* 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록.
|
|
*/
|
|
export function noteEntities(
|
|
section: CrossSection,
|
|
station: string,
|
|
lines: readonly string[],
|
|
): Record<string, unknown>[] {
|
|
const mark = pipeMark(section);
|
|
if (!mark.length && !lines.length) return [];
|
|
const x = mark.length ? 15 : 0;
|
|
return [
|
|
...mark,
|
|
...[station, ...lines].map((line, index) =>
|
|
textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR),
|
|
),
|
|
];
|
|
}
|
|
|
|
/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) ·
|
|
* 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */
|
|
export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
|
|
try {
|
|
const [pipes, structures, types] = await Promise.all([
|
|
readPendingPipes(projectId) ??
|
|
fetchDetailPipePoints(projectId).then((res) => res.pipe_points),
|
|
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
|
|
fetchStructureTypes(true),
|
|
]);
|
|
const byId = new Map(types.map((type) => [type.type_id, type]));
|
|
const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind;
|
|
const optionLines = (type_id: string, options?: Record<string, string | number>): string[] =>
|
|
structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id));
|
|
const piped = pipes.map((point): NoteThing => {
|
|
const kind = point.facility ?? "pipe";
|
|
const { summary_item, group_items } = point;
|
|
return {
|
|
name: nameOf(kind),
|
|
pipe: kind === "pipe",
|
|
at: point.chainage_m,
|
|
summary_item,
|
|
group_items,
|
|
optionLines: optionLines(kind, point.options as Record<string, string | number>),
|
|
};
|
|
});
|
|
const placed = structures.flatMap((item): NoteThing[] => {
|
|
const at = item.chainage_m ?? item.start_m;
|
|
if (typeof at !== "number" || !Number.isFinite(at)) return [];
|
|
const { summary_item, group_items } = item;
|
|
return [
|
|
{
|
|
name: nameOf(item.type_id),
|
|
pipe: false,
|
|
at,
|
|
summary_item,
|
|
group_items,
|
|
optionLines: optionLines(item.type_id, item.options),
|
|
},
|
|
];
|
|
});
|
|
return [...piped, ...placed];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면.
|
|
* 옮기거나 넣었으면 초안에 씀. */
|
|
async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise<CrossNoteCad> {
|
|
const stored = noteCadOf(section, at);
|
|
if (stored) return stored;
|
|
let entities = legacyNoteEntities(section.design?.structure_note);
|
|
if (!entities.length) {
|
|
const lines = structureNoteLines(await at.things, section.chainage_m);
|
|
entities = noteEntities(section, at.station, lines);
|
|
}
|
|
const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] };
|
|
if (entities.length) writeNoteCad(section, cad, at);
|
|
return cad;
|
|
}
|
|
|
|
/* ── 모달 칸(빈 CAD · 바탕만) ───────────────────────────────────────────── */
|
|
|
|
export interface NoteCadPlace {
|
|
projectId: string;
|
|
routeId: number;
|
|
/** 미리 넣음 첫 줄(`stationNoLabel`). */
|
|
station: string;
|
|
/** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */
|
|
things: Promise<NoteThing[]>;
|
|
}
|
|
|
|
/**
|
|
* [상세] 모달 아래 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 ·
|
|
* 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤).
|
|
*/
|
|
export function mountNoteCad(
|
|
box: HTMLElement,
|
|
first: CrossSection,
|
|
section: () => CrossSection,
|
|
at: NoteCadPlace,
|
|
): { destroy: () => void } {
|
|
let closed = false;
|
|
const cad = createCadHost<CadHostDrawing>({
|
|
title: "표 위 CAD",
|
|
onChanged: (dirty) => {
|
|
if (!dirty || closed) return;
|
|
cad
|
|
.requestSave()
|
|
.then(({ drawing }) => {
|
|
if (!closed)
|
|
writeNoteCad(section(), { entities: drawing.entities, layers: drawing.layers }, at);
|
|
})
|
|
.catch(() => undefined);
|
|
},
|
|
});
|
|
box.append(cad.element);
|
|
cad.beginLoading();
|
|
const scope = `b06-detail-note:${at.projectId}:${at.routeId}:${noteKey(first.chainage_m)}`;
|
|
void firstNoteCad(first, at).then((drawing) => {
|
|
if (closed) return;
|
|
const layers = drawing.layers?.length ? drawing.layers : [BLANK_LAYER];
|
|
cad.load(
|
|
{ entities: drawing.entities, layers },
|
|
null,
|
|
false,
|
|
{},
|
|
{
|
|
recoveryScope: scope,
|
|
hostTitle: "표 위 CAD",
|
|
bare: true,
|
|
},
|
|
);
|
|
});
|
|
return {
|
|
destroy: () => {
|
|
closed = true;
|
|
cad.destroy();
|
|
},
|
|
};
|
|
}
|