Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts
T
eomsangdonandClaude Opus 5.5 4415f422f4 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 — 598 합치기 풂(PLAN 598 · 요구 463)
- UI_Points: 우클릭 메뉴 = UI_Menu 그대로 · sub4 597-2 칸 몫([추가] · 펼침 이름 · 경위도 · nameError · 칸 설명 글 걷음) 살림 · 핀 이름 그림(pointLayer names) · dev 쪽 openMenu · mount 걷음(지점 놓기 노드 붙음 = UI_Menu place)
- UI_Pieces: 구역 지움 = edit.dropRegion(line.id)(598 이 dropLine 을 dropRegion 으로 바꿈)
- UI_Page: 지점 한 걸음 stepPoints — 위치(site) 같이 실음(596-2 Snap) · 700 줄
- UI_Edit: 고름 강조 선 = strokeCased 검은 테(600-1 · 밝은 바탕 대비)

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

234 lines
8.9 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Pieces.ts
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래).
* 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) ·
* 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지).
* 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { askConfirm } from "@ui/ui_template_modal";
import { routeColor } from "@ui/ui_template_palette";
import { stationLabel } from "../common_util/common_util_station";
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
import {
lineNames,
nameError,
setLineYear,
setPiecesYear,
type PlanNetwork,
} from "./logic/D03_PlanAnalysis_Plan";
import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit";
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
const meters = (m: number): string => `${m.toFixed(1)} m`;
export interface PlanPiecesParams {
network: () => PlanNetwork;
edit: PlanEdit;
editable: () => boolean;
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
yearOptions: () => Array<{ value: string; text: string }>;
commit: (next: PlanNetwork) => void;
}
export interface PlanPieces {
/** 좌측 「구역 · 연도」 목록 몸. */
list: HTMLElement;
render: () => void;
}
export function createPlanPieces(params: PlanPiecesParams): PlanPieces {
const { edit } = params;
const list = document.createElement("div");
list.className = "d03-plan__pieces";
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
let syncs: Array<() => void> = [];
/** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */
const isControl = (event: Event): boolean =>
!!(event.target as Element).closest("input, select, button, .ui-list-row__trailing");
/** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */
const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => {
row.tabIndex = 0;
row.addEventListener("click", (event) => isControl(event) || onPick());
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) onPick();
});
return row;
};
/** 연도 고름 칸 — 값이 섞이면 「여러 해」. */
function yearSelect(
years: () => number[],
label: string,
pick: (year: number) => void,
): { root: HTMLElement; sync: () => void } {
const field = createSelectField({
options: [],
size: 24,
ariaLabel: label,
disabled: !params.editable(),
onChange: (value) => value && pick(Number(value)),
});
const sync = (): void => {
const set = new Set(years());
const options = params.yearOptions();
field.setOptions(
set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
set.size > 1 ? "" : String([...set][0]),
);
};
sync();
return { root: field.root, sync };
}
function render(): void {
const network = params.network();
const names = lineNames(network.lines);
const picked = new Set(edit.selected()?.pieces ?? []);
const scope = edit.selected()?.scope;
const editable = params.editable();
const key = JSON.stringify([
network.lines.map((line) => line.id),
network.pieces.map((piece) => [piece.id, piece.line]),
[...picked],
scope,
editable,
params.yearOptions().length,
]);
if (key === shown) {
syncs.forEach((sync) => sync());
return;
}
shown = key;
syncs = [];
const rows: HTMLElement[] = [];
const byYear = new Map<number, typeof network.pieces>();
for (const piece of network.pieces)
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
const row = createListRow({
label: `${year}년`,
sub: `구간 ${pieces.length} · ${meters(length)}`,
size: 28,
active: scope === "year" && pieces.every((p) => picked.has(p.id)),
onClick: () => {
edit.select([pieces[0].id]);
edit.setScope("year");
},
});
row.dataset.year = String(year);
rows.push(row);
}
network.lines.forEach((line, index) => {
const pieces = network.pieces.filter((piece) => piece.line === line.id);
if (!pieces.length) return;
const lineNow = (): (typeof network.lines)[number] =>
params.network().lines.find((entry) => entry.id === line.id) ?? line;
const piecesNow = (): typeof pieces =>
params.network().pieces.filter((piece) => piece.line === line.id);
const nameField = createInputField({ size: 24, placeholder: names[index] });
nameField.input.value = line.name ?? "";
nameField.input.setAttribute("aria-label", "구역 이름");
nameField.input.dataset.role = "line-name";
nameField.input.disabled = !editable;
nameField.input.addEventListener("input", () =>
nameField.setError(nameError(nameField.input.value) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const error = nameError(nameField.input.value);
nameField.setError(error ?? undefined);
const text = nameField.input.value.trim();
if (!error && text !== (lineNow().name ?? ""))
params.commit(setLineName(params.network(), line.id, text));
});
const year = yearSelect(
() => piecesNow().map((piece) => piece.year),
"구역 연도",
(value) => params.commit(setLineYear(params.network(), line.id, value)),
);
year.root.dataset.role = "line-year";
const drop = createIconButton({
icon: "trash",
title: "구역 지움",
size: 24,
onClick: async () => {
const ok = await askConfirm({
message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`,
okLabel: "지움",
danger: true,
});
if (!ok) return;
edit.dropRegion(line.id);
},
});
drop.dataset.role = "line-drop";
drop.hidden = !editable;
const tail = document.createElement("span");
tail.className = "d03-plan__line-tail";
tail.append(year.root, drop);
const head = createListRow({
label: "",
sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`,
stripe: routeColor(index),
size: 28,
trailing: tail,
active: scope === "line" && pieces.every((p) => picked.has(p.id)),
});
head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root);
head.dataset.line = line.id;
rows.push(
pickable(head, () => {
edit.select([pieces[0].id]);
edit.setScope("line");
}),
);
syncs.push(() => {
year.sync();
nameField.input.placeholder = lineNames(params.network().lines)[index] ?? "";
if (document.activeElement !== nameField.input)
nameField.input.value = lineNow().name ?? "";
const sub = head.querySelector(".ui-list-row__sub");
if (sub)
sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`;
});
for (const piece of pieces) {
const pieceNow = (): (typeof pieces)[number] =>
params.network().pieces.find((entry) => entry.id === piece.id) ?? piece;
const pieceYear = yearSelect(
() => [pieceNow().year],
"구간 연도",
(value) => params.commit(setPiecesYear(params.network(), [piece.id], value)),
);
const row = createListRow({
label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`,
sub: meters(piece.to_m - piece.from_m),
size: 28,
trailing: pieceYear.root,
active: picked.has(piece.id),
});
row.classList.add("d03-plan__piece-row");
row.dataset.piece = piece.id;
rows.push(pickable(row, () => edit.select([piece.id])));
syncs.push(pieceYear.sync);
}
});
list.replaceChildren(...rows);
}
return { list, render };
}