/* ============================================================================= * 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 { 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) : 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); }