Files
Aislo/A00_Common/design/design_selection.ts
T
eomsangdonandClaude Opus 5.5 cb4bab7bf2 feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침
- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:06:13 +09:00

191 lines
8.0 KiB
TypeScript

/* =============================================================================
* A00_Common/design/design_selection.ts
* 측점 · 구조물 고름 **한 곳** (PLAN 24-2d).
*
* 24-1 약속의 `design_store` 「selection({chainageM, structureId})」 키 자리다. 그 모듈이
* 서면 이 파일의 `readSelection` · `selectDesign` · `subscribeSelection` 세 함수를 그쪽
* `get` · `set` · `subscribe("selection")` 으로 잇는다 — 부르는 쪽 코드는 바뀌지 않는다.
*
* 값은 등록표 키 `structure-pick`(② 설계 초안 · 페이지를 오가도 남음)에 실린다.
* 어느 화면(3D · 종단 알약 · 목록 · 횡단 카드)에서 골라도 같은 값이 적히고, 다른 쪽은
* 이 값을 읽어 따라간다. 관 · 구조물을 찾는 거리 `PIPE_MATCH_M` 도 여기 한 값뿐이다.
* ========================================================================== */
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
export interface DesignSelection {
chainageM: number | null;
structureId: string | null;
}
export const NO_SELECTION: DesignSelection = { chainageM: null, structureId: null };
/** 두 누가거리가 같은 자리인가. */
export function isNear(a: number, b: number): boolean {
return Math.abs(a - b) < PIPE_MATCH_M;
}
/** 그 누가거리에서 찾는 거리 안에 있는 항목 중 **가장 가까운 것** — 이웃한 항목이 둘 걸려도
* 자리가 맞는 쪽을 고른다. */
export function nearestWithin<T>(
items: ReadonlyArray<T>,
chainageM: number,
chainageOf: (item: T) => number,
): T | undefined {
let best: T | undefined;
let bestGap = PIPE_MATCH_M;
for (const item of items) {
const gap = Math.abs(chainageOf(item) - chainageM);
if (gap < bestGap) {
best = item;
bestGap = gap;
}
}
return best;
}
/** 선택을 풀 때 필요한 구조물의 최소 모양 — B05 `StructureInstance` 가 그대로 맞는다. */
export interface SelectableStructure {
structure_id?: string | null;
chainage_m?: number | null;
start_m?: number | null;
end_m?: number | null;
}
/** 풀린 선택 — 관이면 그 관의 누가거리, 아니면 구조물 id. 둘 다 없으면 고를 항목이 없다. */
export interface ResolvedSelection {
pipeChainageM: number | null;
structureId: string | null;
}
/** 이 구조물이 그 측점을 덮는가 — 점형은 기준점 근처, 구간형은 시작~종료 안(양끝 여유 포함). */
export function coversChainage(structure: SelectableStructure, chainageM: number): boolean {
const anchor = structure.chainage_m ?? structure.start_m;
const start = structure.start_m ?? anchor;
const end = structure.end_m ?? anchor;
if (start == null || end == null) return false;
return (
chainageM >= Math.min(start, end) - PIPE_MATCH_M &&
chainageM <= Math.max(start, end) + PIPE_MATCH_M
);
}
/**
* 선택 값을 폼이 열 항목으로 푼다 — 어느 화면에서 골랐든 **같은 순서 · 같은 거리**다.
* ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 —
* 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물.
*/
export function resolveSelection<T extends SelectableStructure>(
selection: DesignSelection,
pipes: ReadonlyArray<{ chainage_m: number }>,
structures: ReadonlyArray<T>,
/** ③ 덮는 구조물로 열어도 되나 — B06 은 노면공을 뺌(152-5 · ① id 길은 그대로). */
coverable: (entry: T) => boolean = () => true,
): ResolvedSelection {
if (selection.structureId) {
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
if (byId) return { pipeChainageM: null, structureId: byId.structure_id ?? null };
}
const chainageM = selection.chainageM;
if (chainageM === null) return { pipeChainageM: null, structureId: null };
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
const hit = structures.find((entry) => coverable(entry) && coversChainage(entry, chainageM));
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
}
/** 측점 하나 고름(횡단 카드 · 종단 측점선)이 덮어 열어도 되는 군 — 노면공(G군)은 뺌(152-5 · B05 = B06 216-1). */
export const coversAtStation = (group: string | undefined): boolean => group !== "G";
/* ── 세션 값 + 알림 ─────────────────────────────────────────────────────── */
interface Stored {
at?: unknown;
key?: unknown;
sid?: unknown;
}
/** 세션에 남은 고름 — 없으면 null. `key` 는 횡단도가 부재를 부르는 이름(조정창 여는 데 씀). */
export function readSelection(
projectId: string | null,
): (DesignSelection & { partKey?: string }) | null {
if (!projectId) return null;
try {
const value = readState<Stored>("structure-pick", projectId);
if (!value || typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
return {
chainageM: value.at,
structureId: typeof value.sid === "string" ? value.sid : null,
partKey: typeof value.key === "string" ? value.key : undefined,
};
} catch {
return null;
}
}
export type SelectionListener = (selection: DesignSelection, from: string | undefined) => void;
const listeners = new Map<string, Set<SelectionListener>>();
let dispatching = false;
/** 고름이 바뀔 때 부른다. 돌려주는 함수로 구독을 끊는다. */
export function subscribeSelection(projectId: string | null, fn: SelectionListener): () => void {
if (!projectId) return () => undefined;
let set = listeners.get(projectId);
if (!set) listeners.set(projectId, (set = new Set()));
set.add(fn);
return () => set.delete(fn);
}
/**
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 **같은 것**을 다시 적을 때만 부재키를 지킨다 —
* 3D 로 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
* 다른 구조물로 옮기면 버리고, 빈 값("")을 주면 지운다(53-1).
* `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다.
* 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지).
*/
export function selectDesign(
projectId: string | null,
selection: DesignSelection,
options: { partKey?: string; from?: string } = {},
): void {
if (!projectId) return;
if (selection.chainageM === null) {
writeState("structure-pick", null, projectId);
} else {
const before = readSelection(projectId);
const same =
before !== null &&
before.chainageM === selection.chainageM &&
before.structureId === selection.structureId &&
(options.partKey === undefined || options.partKey === before.partKey);
if (same) return;
// 부재키는 같은 것을 다시 적을 때만 지킴 — 다른 구조물로 옮기면 옛 부재(조정창)가 따라오지 않음(53-1)
const sameTarget =
before !== null &&
before.chainageM !== null &&
isNear(before.chainageM, selection.chainageM) &&
before.structureId === selection.structureId;
// 빈 부재키("") = 부재를 풂
const key =
options.partKey === ""
? undefined
: (options.partKey ?? (sameTarget ? before?.partKey : undefined));
writeState(
"structure-pick",
{ at: selection.chainageM, key, sid: selection.structureId ?? undefined },
projectId,
);
}
if (dispatching) return;
dispatching = true;
try {
for (const fn of [...(listeners.get(projectId) ?? [])]) fn(selection, options.from);
} finally {
dispatching = false;
}
}