Files
Aislo/D01_PlanRegister/D01_PlanRegister_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 bcf397ab3d feat(D 예정노선): 지형 칸 → 위치 칸 · 주소 · 장소 찾기 → 이동 → 받기 · 등록 지도 찾기 (PLAN 596-2 · 596-5 · 요구 463)
- 새 D03_PlanAnalysis_UI_Site — 지금 주소 · 찾기(createFindList · searchPlaces) · 실패 · 끊김 한 줄 + [다시 받기] · [임도망 올리기]
- 위치 = 캐시 한 걸음(Snap site · 판 5 · 되돌리기 · 떠남 물음) → [저장] 몸 site · 지점 이름 point_names
- 고름 → 지도 이동 → 둘레 받기(center) · 멂 · 다른 TM 띠 = 물음 한 번 뒤 reset · 아니오 = 위치 되돌림
- 임도망 올린 뒤 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지)
- 조용히 받기 = 기준점 10 km 안 지점 · 선만 · 칸 글 걷음(실패 · 끊김 · 기준점 없음 · 못 불러옴만)
- 등록(D01) 지도 머리 [위성] 오른쪽 찾기 = 같은 createPlaceFind · 사전 DE_Reg_Find*
- 시험 553_4 위치 칸 · 등록 찾기 부품 · 560_4 savedJson 꼴 · 584_3 올리기 자리(위치 칸)

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

189 lines
8.0 KiB
TypeScript

/* =============================================================================
* D01_PlanRegister_UI_Page.ts
* D01 예정노선 등록(요구서 345 3-1 · 389 3장 1~3 · PLAN 560-1) — B02 등록 화면 꼴 · 도면 칸 없음.
* 칸 = 사업연도(시작) · 사업지역 · 프로젝트명(B02 이름 조합기 · 「<시작 연도> <사업 지역> 노선 계획」)
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
* 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점.
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로.
* ========================================================================== */
import { API_BASE_URL, ROUTES } from "@config/config_frontend";
import { createButton, createCard, createInputField, showToast } from "@ui/ui_template_elements";
import { createFormGrid, createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { editMap, type MapType } from "@ui/ui_template_kakao_map";
import { t } from "@ui/ui_template_locale";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site";
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
const PREF_MAP_TYPE = "aislo:pref:d01.map.type";
interface Place {
address: string | null;
region: string | null;
source: string;
}
/** 기준점 고르기 — 지도 누름 · 끌기 = 한 점 · 주소(VWorld) · onRegion = 「시군구 읍면」 */
function sitePicker(onRegion: (region: string) => void): {
root: HTMLElement;
value: () => [number, number] | null;
} {
let site: [number, number] | null = null;
let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게
const where = createText(t("DE_Reg_SiteEmpty"), "muted", 14);
where.dataset.d01Site = "address";
const map = editMap({
hint: t("DE_Reg_SiteHint"),
center: () => KOREA_CENTER,
onClick: (lon, lat) => void moveTo(lon, lat),
onMove: (_key, lon, lat) => void moveTo(lon, lat),
});
const mapType = (readByKey(PREF_MAP_TYPE) === "HYBRID" ? "HYBRID" : "ROADMAP") as MapType;
map.setType(mapType);
const typePick = createSegment({
options: [
{ value: "ROADMAP", label: t("DE_Reg_MapNormal") },
{ value: "HYBRID", label: t("DE_Reg_MapSatellite") },
],
value: mapType,
ariaLabel: t("DE_Reg_MapType"),
onChange: (value) => {
map.setType(value as MapType);
writeByKey(PREF_MAP_TYPE, value);
},
});
const draw = (): void =>
map.set(
site
? [{ key: "site", name: t("DE_Reg_Site"), lon: site[0], lat: site[1], movable: true }]
: [],
[],
);
async function moveTo(lon: number, lat: number): Promise<void> {
const mine = ++seq;
site = [+lon.toFixed(DIGITS), +lat.toFixed(DIGITS)];
where.textContent = t("DE_Reg_SiteFinding");
draw();
const place = await fetch(`${API_BASE_URL}/map/address?lon=${site[0]}&lat=${site[1]}`, {
credentials: "include",
})
.then((r) => (r.ok ? (r.json() as Promise<Place>) : null))
.catch(() => null);
if (mine !== seq) return;
const text = place?.address || `${t("DE_Reg_SiteNoAddress")} (${site[1]}, ${site[0]})`;
where.textContent = `${t("DE_Reg_SiteAddress")}${text}${place ? ` · ${t("DE_Reg_SiteSource")} ${place.source}` : ""}`;
if (place?.region) onRegion(place.region);
}
draw();
// 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래
const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat));
const field = find.search.parentElement!;
field.style.flex = "1 1 12rem";
const head = document.createElement("div");
head.className = "ui-general-block__actions";
head.style.flexWrap = "wrap";
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field);
const root = document.createElement("div");
root.dataset.d01Site = "";
root.append(head, find.root, map.root, where);
return { root, value: () => site };
}
export function renderD01PlanRegister(root: HTMLElement): void {
const yearStart = createInputField({
label: t("DE_Reg_YearStart"),
type: "number",
value: String(new Date().getFullYear()),
min: 2000,
max: 2100,
});
const region = createInputField({
label: t("B02_Proj_Field_Region"),
placeholder: t("B02_Proj_Field_Region_Placeholder"),
required: true,
});
const name = createInputField({ label: t("DE_Reg_Name"), required: true });
// 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움)
const composer = createNameComposer();
const syncName = (): void => {
name.input.value = composer.update({
year: yearStart.input.value.trim(),
region: region.input.value,
type: t("DE_Reg_KindPlan"),
});
};
name.input.addEventListener("input", () => composer.edit(name.input.value));
for (const field of [yearStart, region]) field.input.addEventListener("input", syncName);
syncName();
// 사업 지역 = 주소의 「시군구 읍면」 — 비었거나 지난번 채운 그대로일 때만(손으로 고친 값은 둠)
let autoRegion = "";
const site = sitePicker((value) => {
const now = region.input.value.trim();
if (now && now !== autoRegion) return;
autoRegion = value;
region.input.value = value;
region.setError();
syncName();
});
const submit = (): void => {
const fields = [yearStart, region, name];
fields.forEach((field) => field.setError());
const y0 = Number.parseInt(yearStart.input.value, 10);
const errors: Array<[(typeof fields)[number], string | false]> = [
[yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")],
[region, !region.input.value.trim() && t("B02_Proj_Error_Required")],
[name, !name.input.value.trim() && t("B02_Proj_Error_Required")],
];
const bad = errors.filter(([, message]) => message);
bad.forEach(([field, message]) => field.setError(message as string));
if (bad.length) return;
const at = site.value();
if (!at) return showToast(t("DE_Reg_SiteRequired"), "error");
void createGroupProject(
"/d01/project",
{
project_name: name.input.value.trim(),
project_year: y0,
region: region.input.value.trim(),
site: { lon: at[0], lat: at[1] },
},
ROUTES.D03_PLAN_ANALYSIS,
);
};
const grid = createFormGrid([yearStart, region, name].map((f) => f.root));
const actions = document.createElement("div");
actions.className = "ui-general-block__actions ui-general-block__actions--end";
actions.append(
createHelpButton(),
createButton({
label: t("Common_Btn_Cancel"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.B01_ACCOUNT),
}),
createButton({ label: t("DE_Reg_Submit"), variant: "filled", onClick: submit }),
);
const layout = createGeneralLayout({
narrow: true,
title: t("DE_Reg_PlanTitle"),
subtitle: t("DE_Reg_PlanSubtitle"),
content: createCard({ body: [grid, site.root, actions], raised: true }),
});
root.append(layout.root);
}