- 새 칸 design.structure_note_cad — 스키마 · 재계산 남김 · 세션 초안 structure-note-cads · [저장] patch - 상세 모달 글 상자 → 빈 CAD(cad_host bare · 높이 고정 · 닫으면 destroy · 고칠 때마다 초안) - 처음 열 때 칸이 없으면 옛 글 줄마다 Text · 없으면 횡단배수 주인 측점만 미리 넣음 - 시험 test_260_note_cad · helper_260_note_cad Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Detail_Cad.ts
|
|
* 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21).
|
|
*
|
|
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
|
|
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
|
|
* (`_Engine_Cad_Table.cross_note_cad_entities`).
|
|
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수
|
|
* 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면.
|
|
* 좌표 = 도면 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 type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
|
|
|
|
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)] : [],
|
|
);
|
|
}
|
|
|
|
/* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */
|
|
|
|
const one = (value: number | null | undefined): string =>
|
|
typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : "";
|
|
const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : "");
|
|
const trim = (value: number): string => String(Math.round(value * 100) / 100);
|
|
/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */
|
|
const bondOf = (form: string | null | undefined): string =>
|
|
/찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? "");
|
|
const BASIN_FORMS: Record<string, string> = { I: "I형", L: "L형", U: "ㄷ형" };
|
|
|
|
/** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */
|
|
function sideLine(side: CulvertSideSpec | undefined, title: string): string | null {
|
|
if (!side?.structure) return null;
|
|
const basin = side.structure === "집수정";
|
|
const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form);
|
|
const height = basin ? side.basin_height_m : side.revet_height_m;
|
|
const length = basin ? side.basin_length_m : side.revet_length_m;
|
|
const spec = [form, `H=${one(height)}`].filter(Boolean).join(",");
|
|
return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`;
|
|
}
|
|
|
|
/** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */
|
|
function chuteLine(options: Record<string, unknown>, fallback?: string): string | null {
|
|
const picked = Object.keys(options)
|
|
.map((key) => /^chute_add_(\d+)_structure$/.exec(key))
|
|
.filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim())
|
|
.sort((a, b) => Number(a[1]) - Number(b[1]))[0];
|
|
const head = picked ? `chute_add_${picked[1]}_` : null;
|
|
const structure = head ? String(options[`${head}structure`]).trim() : fallback;
|
|
if (!structure) return null;
|
|
const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : "";
|
|
const area = head ? Number(options[`${head}area`]) : NaN;
|
|
const spec = [structure, bond].filter(Boolean).join(",");
|
|
return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`;
|
|
}
|
|
|
|
/** 측점 이름(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)}`;
|
|
}
|
|
|
|
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
id: newId(),
|
|
type,
|
|
lineColor: CULVERT_COLOR,
|
|
lineWidth: 1,
|
|
layerId: LAYER_ID,
|
|
shapeData,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관
|
|
* (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적).
|
|
* 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) ·
|
|
* 관유출부보강.
|
|
*/
|
|
export function culvertNoteEntities(
|
|
section: CrossSection,
|
|
station: string,
|
|
options: Record<string, unknown> = {},
|
|
): 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=${metres(section.design?.pipe_length_m)}`,
|
|
];
|
|
const right = [
|
|
station,
|
|
sideLine(culvert.inlet, "유입"),
|
|
sideLine(culvert.outlet, "유출"),
|
|
chuteLine(options, culvert.chute?.structure),
|
|
].filter((line): line is string => !!line);
|
|
return [
|
|
shape("Circle", { center: { x: cx, y: cy }, radius }),
|
|
shape("Line", {
|
|
startPoint: { x: cx, y: cy - radius },
|
|
endPoint: { x: cx, y: cy - radius - LINE },
|
|
}),
|
|
...inside.map((line, index) =>
|
|
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
|
|
),
|
|
...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)),
|
|
];
|
|
}
|
|
|
|
/** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */
|
|
async function pipeOptionsAt(
|
|
projectId: string,
|
|
chainageM: number,
|
|
): Promise<Record<string, unknown>> {
|
|
const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points;
|
|
const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M);
|
|
return pipe?.options ?? {};
|
|
}
|
|
|
|
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */
|
|
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 && section.culvert) {
|
|
const owner = section.culvert.chainage_m ?? section.chainage_m;
|
|
const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({}));
|
|
entities = culvertNoteEntities(section, at.station, options);
|
|
}
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* [상세] 모달 가운데 칸(`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();
|
|
},
|
|
};
|
|
}
|