feat(D 예정노선): 고름 층 노드 → 선 → 구역 → 전체 · 판 하나 재활용 · 우클릭 메뉴 · 지점 ↔ 노드 붙음 (PLAN 598-1~3 · 요구 463)
- 새 logic/D03_PlanAnalysis_Select.ts — 토막 판정 · 끌어 고르기 토막 · 토막 지움(가운데 = 구역 둘 · 뒤쪽 새 구역) · 전체 지움 · 지점 ↔ 노드 같은 자리 - 새 UI_Select.ts — 고름 상태(다시 누름 = 한 단 넓힘 · 같은 해 = 왼쪽 해 줄로만) - UI_Edit.ts — 우클릭 즉시 지움 걷음 · 노드 끌면 지점 같이 · 지점 12 px 안 붙음 · 지움 창구(dropNode · dropRegion · dropAll · splitAt) - UI_Props.ts — 판 = 노드 · 선 · 구역 · 전체 · 구간 · 마커 몸 · [방향 바꾸기] · [갈래 시점 지정] 걷음 - 새 UI_Menu.ts — 우클릭 메뉴(Points 에서 옮김 · 노드 지움 · 지점으로 · 연도 구역 나눔) - UI_Points.ts — 핀 놓을 때 노드 12 px 안 붙음(nodeNear) - key_registry plan 줄 · 시험 585_2 갈래 시점 지정 줄 지움(요구 463-15 걷음) · 553_4 메뉴 자리 · 새 시험 598_1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,30 +1,43 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Props.ts
|
||||
* D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴.
|
||||
* D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈.
|
||||
*
|
||||
* 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 —
|
||||
* 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 →
|
||||
* 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) ·
|
||||
* [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
|
||||
* 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정]
|
||||
* (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음.
|
||||
* 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂.
|
||||
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김).
|
||||
* 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘).
|
||||
* 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움].
|
||||
* 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨).
|
||||
* 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움].
|
||||
* 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만.
|
||||
* 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createFloatPanel } from "@ui/ui_template_float_panel";
|
||||
import { createSelectField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createKv, type KvRow } from "@ui/ui_template_kv";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit";
|
||||
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 { segmentPath, segmentPieces, segmentRange } from "./logic/D03_PlanAnalysis_Select";
|
||||
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
||||
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;
|
||||
@@ -32,6 +45,13 @@ export interface PlanPropsParams {
|
||||
editable: () => boolean;
|
||||
yearStart: () => number;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
heightAt: () => HeightAt;
|
||||
epsg: () => number;
|
||||
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
|
||||
points: () => Vertex[];
|
||||
addPoint: (point: Vertex) => void;
|
||||
/** [다시 긋기](599 · 없으면 단추 없음) — 구역 id · 앞 노드 · 뒤 노드. */
|
||||
respan?: (line: string, from: number, to: number) => boolean;
|
||||
}
|
||||
|
||||
export interface PlanProps {
|
||||
@@ -46,7 +66,7 @@ export interface PlanProps {
|
||||
export function createPlanProps(params: PlanPropsParams): PlanProps {
|
||||
const { edit } = params;
|
||||
const float = createFloatPanel({
|
||||
title: "고른 구간",
|
||||
title: "고름",
|
||||
draggable: true,
|
||||
onClose: () => edit.select([]),
|
||||
});
|
||||
@@ -61,104 +81,203 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
|
||||
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
|
||||
}
|
||||
|
||||
function renderPanel(): void {
|
||||
const picked = edit.selected();
|
||||
float.root.hidden = !picked;
|
||||
if (!picked) return float.body.replaceChildren();
|
||||
const network = params.network();
|
||||
const names = lineNames(network.lines);
|
||||
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
|
||||
const lineIds = new Set(pieces.map((piece) => piece.line));
|
||||
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
|
||||
const title = float.root.querySelector(".ui-float-panel__title");
|
||||
if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`;
|
||||
const editable = params.editable();
|
||||
|
||||
const scope = createSegment({
|
||||
options: [
|
||||
{ value: "piece", label: "구간" },
|
||||
{ value: "line", label: "선" },
|
||||
{ value: "year", label: "같은 해" },
|
||||
],
|
||||
value: picked.scope,
|
||||
size: 24,
|
||||
ariaLabel: "고름 넓이",
|
||||
onChange: (value) => edit.setScope(value as PlanScope),
|
||||
});
|
||||
scope.root.dataset.role = "props-scope";
|
||||
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: !editable,
|
||||
disabled: !params.editable(),
|
||||
onChange: (value) =>
|
||||
value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(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 = createButton({
|
||||
label:
|
||||
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
|
||||
const row = button(
|
||||
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
|
||||
`${meters(piece.to_m - piece.from_m)} · ${piece.year}년`,
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => edit.select([piece.id]),
|
||||
});
|
||||
"props-piece",
|
||||
() => edit.select([piece.id]),
|
||||
);
|
||||
row.dataset.piece = piece.id;
|
||||
rows.append(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "d03-plan__row";
|
||||
const index = edit.lineIndex();
|
||||
if (editable && index >= 0) {
|
||||
const flip = createButton({
|
||||
label: "방향 바꾸기",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.flipLine,
|
||||
});
|
||||
flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
|
||||
flip.disabled = !!network.lines[index].branch;
|
||||
flip.dataset.role = "props-flip";
|
||||
const drop = createButton({
|
||||
label: "선 지움",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.dropLine,
|
||||
});
|
||||
drop.dataset.role = "props-drop";
|
||||
const branch = createButton({
|
||||
label: "갈래 시점 지정",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.branchLine,
|
||||
});
|
||||
branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다.";
|
||||
branch.hidden = !!network.lines[index].branch;
|
||||
branch.dataset.role = "props-branch";
|
||||
actions.append(flip, branch, drop);
|
||||
/** 고름 꼴마다 — 머리 글 · 몸 · 단추. */
|
||||
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 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: [createKv(rows)],
|
||||
actions: [
|
||||
button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)),
|
||||
toPoint,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (editable && picked.mark) {
|
||||
const dropMark = createButton({
|
||||
label: "마크 지움",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.dropMark,
|
||||
});
|
||||
dropMark.dataset.role = "props-drop-mark";
|
||||
actions.append(dropMark);
|
||||
|
||||
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 && params.respan)
|
||||
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,
|
||||
};
|
||||
}
|
||||
float.body.replaceChildren(scope.root, year.root, 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 {
|
||||
|
||||
Reference in New Issue
Block a user