Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts
T
eomsangdonandClaude Opus 5.5 ed7f7fa674 feat(D 예정노선): 노드 판 — 방식 · 값 고침(앞 노드 기준 다시 놓음 · 뒤 노드 그대로 · 어긋남 ⚠) · [지점으로] · [다시 긋기] 이음 (PLAN 598-4 · 요구 463-11 · 464)
- logic/Select placeNode — 지점 자리 노드면 지점 같이 · 첫 노드 = 까닭 글
- UI_Props 노드 판 = 599-1 방식 칸(createDrawKindFields) · 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때
- 판 · 메뉴 [다시 긋기] = draw.respan(599-3)
- 시험 598_1 placeNode 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:02:48 +09:00

339 lines
14 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Props.ts
* D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈.
*
* 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂.
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김).
* 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘).
* 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움].
* 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨).
* 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움].
* 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만.
* 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임).
* ========================================================================== */
import { createButton, showToast } from "@ui/ui_template_elements";
import { createFloatPanel } from "@ui/ui_template_float_panel";
import { createSelectField } from "@ui/ui_template_input";
import { createKv, type KvRow } from "@ui/ui_template_kv";
import { stationLabel } from "../common_util/common_util_station";
import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw";
import {
lineNames,
samePoint,
setPiecesYear,
type PlanNetwork,
type PlanPiece,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import {
placeNode,
segmentPath,
segmentPieces,
segmentRange,
} from "./logic/D03_PlanAnalysis_Select";
import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import {
createDrawKindFields,
DRAW_DEFAULT_LENGTH_M,
DRAW_DEFAULT_SLOPE_PCT,
type DrawKindValue,
} from "./D03_PlanAnalysis_UI_DrawPanel";
import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import { selectionLength } from "./D03_PlanAnalysis_UI_Select";
/** 조각 연도 고를 수 — 사업연도 시작부터. */
const YEAR_SPAN = 10;
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
const meters = (m: number): string => `${m.toFixed(1)} m`;
const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`);
export interface PlanPropsParams {
network: () => PlanNetwork;
edit: PlanEdit;
editable: () => boolean;
yearStart: () => number;
commit: (next: PlanNetwork) => void;
heightAt: () => HeightAt;
epsg: () => number;
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
points: () => Vertex[];
addPoint: (point: Vertex) => void;
/** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
respan: (line: string, from: number, to: number) => boolean;
}
export interface PlanProps {
/** 지도 위 속성 판(지도 칸에 붙임). */
panel: HTMLElement;
/** 고름 · 꼴이 바뀜 — 판 다시. */
render: () => void;
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
yearOptions: () => Array<{ value: string; text: string }>;
}
export function createPlanProps(params: PlanPropsParams): PlanProps {
const { edit } = params;
const float = createFloatPanel({
title: "고름",
draggable: true,
onClose: () => edit.select([]),
});
float.root.classList.add("d03-plan__props");
float.root.dataset.role = "plan-props";
float.root.hidden = true;
function yearOptions(): Array<{ value: string; text: string }> {
const start = params.yearStart();
const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => start + i));
params.network().pieces.forEach((piece) => years.add(piece.year));
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
}
const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => {
const b = createButton({ label, variant: "ghost", size: 24, onClick });
b.dataset.role = role;
return b;
};
/** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */
function yearField(pieces: PlanPiece[]): HTMLElement {
const years = new Set(pieces.map((piece) => piece.year));
const options = yearOptions();
const ids = pieces.map((piece) => piece.id);
const year = createSelectField({
label: "연도",
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""),
size: 24,
ariaLabel: "고른 구간 연도",
disabled: !params.editable(),
onChange: (value) =>
value && params.commit(setPiecesYear(params.network(), ids, Number(value))),
});
year.select.dataset.role = "props-year";
return year.root;
}
/** 구간 줄 — 누름 = 그 구간만. */
function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement {
const names = lineNames(network.lines);
const rows = document.createElement("div");
rows.className = "d03-plan__props-rows";
for (const piece of pieces) {
const index = network.lines.findIndex((line) => line.id === piece.line);
const row = button(
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
`${meters(piece.to_m - piece.from_m)} · ${piece.year}년`,
"props-piece",
() => edit.select([piece.id]),
);
row.dataset.piece = piece.id;
rows.append(row);
}
return rows;
}
/** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */
function nodeKind(line: number, vertex: number): HTMLElement {
const record = params.network().lines[line].nodes?.[vertex] ?? null;
let value: DrawKindValue = {
kind: record?.kind ?? "free",
lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M,
slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT,
};
const apply = (): void => {
const network = params.network();
const next = nodeRecord(value.kind, value.lengthM, value.slopePct);
const out = placeNode(network, params.points(), line, vertex, next, params.heightAt());
if (typeof out === "string") return void showToast(out, "info");
if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines))
params.commitPoints(out.points, out.network);
};
const fields = createDrawKindFields({
value,
onChange: (next) => {
const kindChanged = next.kind !== value.kind;
value = next;
if (kindChanged) apply();
},
});
fields.root.addEventListener("change", apply);
fields.root.dataset.role = "props-node-kind";
fields.setDisabled(!params.editable());
return fields.root;
}
/** 고름 꼴마다 — 머리 글 · 몸 · 단추. */
function body(
picked: PlanSelection,
network: PlanNetwork,
): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } {
const names = lineNames(network.lines);
const nameOf = (id: string | null): string =>
names[network.lines.findIndex((line) => line.id === id)] ?? "";
const z = params.heightAt();
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
const length = selectionLength(edit.paths());
if (picked.kind === "node") {
const { line, vertex } = picked.node!;
const vertices = network.lines.find((l) => l.id === line)!.vertices;
const point = vertices[vertex];
const tm = tmParams(params.epsg());
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
const height = z(point[0], point[1]);
const rows: KvRow[] = [
{
label: "경위도",
value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null,
},
{ label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null },
{ label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) },
];
if (vertex > 0) {
const [a, b] = [vertices[vertex - 1], vertices[vertex]];
rows.push(
{ label: "앞 선 길이", value: meters(lineLength([a, b])) },
{ label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) },
);
}
const entry = network.lines.find((l) => l.id === line)!;
if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" });
const parts: HTMLElement[] = [createKv(rows)];
if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex));
const toPoint = button("지점으로", "props-to-point", () =>
params.addPoint([point[0], point[1]]),
);
toPoint.hidden = params.points().some((p) => samePoint(p, point));
return {
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
parts,
actions: [
button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)),
toPoint,
],
};
}
if (picked.kind === "segment") {
const single = picked.segments.length === 1 ? picked.segments[0] : null;
const years = [
...new Set(
segmentPieces(network, picked.segments).map(
(id) => network.pieces.find((p) => p.id === id)?.year,
),
),
].sort();
const rows: KvRow[] = [{ label: "길이", value: meters(length) }];
if (single) {
const [from, to] = segmentRange(network, single)!;
const [a, b] = segmentPath(network, single)!;
rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` });
rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) });
}
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
if (single)
actions.unshift(
button("다시 긋기", "props-respan", () =>
params.respan(single.line, single.index, single.index + 1),
),
);
return {
title: single
? `${nameOf(single.line)} · 선 ${single.index + 1}`
: `선 ${picked.segments.length}개`,
parts: [createKv(rows)],
actions,
};
}
if (picked.kind === "line") {
const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? [];
return {
title: nameOf(picked.line),
parts: [
createKv([
{ label: "길이", value: meters(length) },
{ label: "노드", value: `${vertices.length}개` },
]),
yearField(pieces),
pieceRows(network, pieces),
],
actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))],
};
}
if (picked.kind === "all") {
const byYear = new Map<number, number>();
for (const p of network.pieces)
byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m);
return {
title: "전체",
parts: [
createKv([
{ label: "길이", value: meters(length) },
{ label: "구역", value: `${network.lines.length}개` },
...[...byYear]
.sort(([a], [b]) => a - b)
.map(([year, m]) => ({ label: `${year}년`, value: meters(m) })),
]),
],
actions: [button("전체 지움", "props-drop-all", edit.dropAll)],
};
}
// 연도 구간 · 마커
const lineIds = new Set(pieces.map((piece) => piece.line));
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)];
const actions: HTMLButtonElement[] = [];
const mark = network.pieces.find((p) => p.id === picked.mark);
if (mark) {
const before = network.pieces.find(
(p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3,
);
parts.unshift(
createKv([
{ label: "측점", value: station(mark.from_m) },
{ label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` },
]),
);
actions.push(button("마커 지움", "props-drop-mark", edit.dropMark));
}
return {
title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`,
parts,
actions,
};
}
function renderPanel(): void {
const picked = edit.selected();
float.root.hidden = !picked;
if (!picked) return float.body.replaceChildren();
const { title, parts, actions } = body(picked, params.network());
const head = float.root.querySelector(".ui-float-panel__title");
if (head) head.textContent = title;
float.root.dataset.kind = picked.kind;
const row = document.createElement("div");
row.className = "d03-plan__row";
if (params.editable()) row.append(...actions);
float.body.replaceChildren(...parts, row);
}
return {
panel: float.root,
render() {
renderPanel();
},
yearOptions,
};
}