254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Pieces.ts
|
|
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래).
|
|
* 해 줄(그 해 모두) · 그 밑 세트 줄(「2027-1」 · 길이 · 구간 수 · 덩이 수 = 마지막 서버 셈 · 같은 세트 고름) ·
|
|
* 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 세트 고름 · 지움) ·
|
|
* 구간 줄(측점 글 작게 · 세트 고름). 누름 = 고름 · 세트 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지).
|
|
* 세트 고름은 속성 판과 같은 셈(`props.assign` = `assignSet` · 638-3) · 보기만 계정 = 값만.
|
|
* ========================================================================== */
|
|
|
|
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 { setKey, type LineRange } from "./logic/D03_PlanAnalysis_Group";
|
|
import { lineNames, nameError, 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;
|
|
/** 고를 세트(속성 판과 같음) · 범위를 그 세트로(한 걸음). */
|
|
setOptions: () => Array<{ value: string; text: string }>;
|
|
assign: (ranges: LineRange[], key: string) => void;
|
|
commit: (next: PlanNetwork) => void;
|
|
/** 마지막 서버 세트 셈(덩이 수 · 고친 뒤 = null). */
|
|
sets?: () => ReadonlyArray<{ key: string; parts: number }> | null;
|
|
}
|
|
|
|
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 setSelect(
|
|
pieces: () => PlanNetwork["pieces"],
|
|
label: string,
|
|
): { root: HTMLElement; sync: () => void } {
|
|
const field = createSelectField({
|
|
options: [],
|
|
size: 24,
|
|
ariaLabel: label,
|
|
disabled: !params.editable(),
|
|
onChange: (value) =>
|
|
value &&
|
|
params.assign(
|
|
pieces().map(({ line, from_m, to_m }) => ({ line, from_m, to_m })),
|
|
value,
|
|
),
|
|
});
|
|
const sync = (): void => {
|
|
const keys = new Set(pieces().map(setKey));
|
|
const options = params.setOptions();
|
|
field.setOptions(
|
|
keys.size > 1 ? [{ value: "", text: "여러 세트" }, ...options] : options,
|
|
keys.size > 1 ? "" : String([...keys][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 parts = new Map((params.sets?.() ?? []).map((s) => [s.key, s.parts]));
|
|
// 구간 꼴 · 세트 · 덩이까지 열쇠 — 되돌리기 뒤 길이 · 세트 글이 옛 값으로 남지 않게
|
|
const key = JSON.stringify([
|
|
network.lines.map((line) => [line.id, lineLength(line.vertices).toFixed(1)]),
|
|
network.pieces.map((p) => [p.id, p.line, p.from_m.toFixed(1), p.to_m.toFixed(1), setKey(p)]),
|
|
[...picked],
|
|
scope,
|
|
editable,
|
|
params.setOptions().length,
|
|
[...parts],
|
|
]);
|
|
if (key === shown) {
|
|
syncs.forEach((sync) => sync());
|
|
return;
|
|
}
|
|
shown = key;
|
|
syncs = [];
|
|
const rows: HTMLElement[] = [];
|
|
|
|
const total = (pieces: typeof network.pieces): string =>
|
|
meters(pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0));
|
|
const group = <K>(pieces: typeof network.pieces, of: (p: (typeof pieces)[number]) => K) => {
|
|
const out = new Map<K, typeof network.pieces>();
|
|
for (const piece of pieces) out.set(of(piece), [...(out.get(of(piece)) ?? []), piece]);
|
|
return out;
|
|
};
|
|
const byYear = group(network.pieces, (p) => p.year);
|
|
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
|
|
const bySet = group(pieces, (p) => p.set);
|
|
const row = createListRow({
|
|
label: `${year}년`,
|
|
sub: `세트 ${bySet.size} · ${total(pieces)}`,
|
|
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);
|
|
for (const [, own] of [...bySet].sort(([a], [b]) => a - b)) {
|
|
const name = setKey(own[0]);
|
|
const lumps = parts.get(name);
|
|
const setRow = createListRow({
|
|
label: name,
|
|
sub: `${total(own)} · 구간 ${own.length}${lumps ? ` · 덩이 ${lumps}` : ""}`,
|
|
size: 28,
|
|
active: scope === "set" && own.every((p) => picked.has(p.id)),
|
|
onClick: () => {
|
|
edit.select([own[0].id]);
|
|
edit.setScope("set");
|
|
},
|
|
});
|
|
setRow.classList.add("d03-plan__piece-row");
|
|
setRow.dataset.set = name;
|
|
rows.push(setRow);
|
|
}
|
|
}
|
|
|
|
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 = setSelect(piecesNow, "구역 세트");
|
|
year.root.dataset.role = "line-set";
|
|
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 = setSelect(() => [pieceNow()], "구간 세트");
|
|
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 };
|
|
}
|