Merge origin/sub_laptop_6 — D02 기준 지정 · 다국어 사전 충돌 풂(528-2 대시보드 · 529-3 D02 글 모두 살림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 20:14:26 +09:00
co-authored by Claude Opus 5.5
5 changed files with 335 additions and 10 deletions
+246
View File
@@ -0,0 +1,246 @@
/* =============================================================================
* D02_PlanBase_UI_Page.ts
* D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표.
* 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로.
* 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈)
* · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장).
* ========================================================================== */
import "./D02_PlanBase_UI_Style.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { editMap, type MapPin } from "@ui/ui_template_kakao_map";
import { t } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
interface LonLat {
lon: number;
lat: number;
}
interface ServerPoint extends LonLat {
x: number;
y: number;
}
interface PointsView {
year_start: number;
year_end: number;
epsg: number | null;
years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>;
}
const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"];
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm
const cacheKey = (projectId: string): string => `d02_points_${projectId}`;
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
if (!response.ok)
throw new Error(typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`);
return data as T;
}
/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */
function boundaryPoints(view: PointsView): Array<LonLat | null> {
const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null);
return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))];
}
export async function renderD02PlanBase(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const mapHost = el("div", { className: "d02-map" });
const table = el("div", { className: "d02-points" });
const info = el("p", { className: "d02-info" });
const card = createPanelCard(t("D02_Points_Title"), false);
card.append(el("p", { className: "d02-info", text: t("D02_Points_Hint") }), table, info);
const save = createButton({
label: t("D02_Save"),
variant: "filled",
onClick: () => void store(),
});
const actions = el("div", { className: "ui-sidebar-actions", children: [save] });
const left = el("div", { className: "ui-sidebar-panel d02-panel", children: [card, actions] });
mountGroupStep(root, ROUTES.D02_PLAN_BASE, { mainContent: mapHost, leftPanel: left });
let view: PointsView | null = null;
let points: Array<LonLat | null> = [];
let active: number | null = null; // 지도 누름이 놓을 지점
const years = (): number[] => (view ? view.years.map((y) => y.year) : []);
const pointName = (i: number): string => {
const ys = years();
if (i === 0) return `${ys[0]} ${t("D02_Start")}`;
if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`;
return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`;
};
const firstEmpty = (): number | null => {
const i = points.findIndex((p) => !p);
return i < 0 ? null : i;
};
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ points }));
const map = editMap({
hint: t("D02_Map_Hint"),
center: () => KOREA_CENTER,
onMove: (key, lon, lat) => place(Number(key), { lon, lat }),
onClick: (lon, lat) => {
if (active === null) return showToast(t("D02_Pick_Point"), "info");
place(active, { lon, lat });
},
});
mapHost.append(map.root);
function place(index: number, at: LonLat): void {
points[index] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) };
if (index === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로
remember();
paint(String(index));
}
/** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 */
function drawMap(focus = ""): void {
const pins: MapPin[] = points.flatMap((p, i) =>
p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: true }] : [],
);
const lines: Array<Array<[number, number]>> = [];
const colors: string[] = [];
years().forEach((_, i) => {
const a = points[i];
const b = points[i + 1];
if (!a || !b) return;
lines.push([
[a.lat, a.lon],
[b.lat, b.lon],
]);
colors.push(YEAR_COLORS[i % YEAR_COLORS.length]);
});
map.set(pins, lines, colors, focus);
}
function drawTable(): void {
const rows = points.map((p, i) => {
const lon = el("input", { className: "d02-points__input" }) as HTMLInputElement;
const lat = el("input", { className: "d02-points__input" }) as HTMLInputElement;
lon.type = lat.type = "number";
lon.step = lat.step = "any";
lon.placeholder = t("D02_Lon");
lat.placeholder = t("D02_Lat");
lon.value = p ? String(p.lon) : "";
lat.value = p ? String(p.lat) : "";
lon.dataset.point = lat.dataset.point = String(i);
lon.dataset.axis = "lon";
lat.dataset.axis = "lat";
const typed = (): void => {
const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) };
const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45;
lon.classList.toggle("is-invalid", !ok);
lat.classList.toggle("is-invalid", !ok);
if (ok) place(i, at);
};
lon.addEventListener("change", typed);
lat.addEventListener("change", typed);
const pick = createButton({
label: "📍",
variant: "ghost",
size: "sm",
onClick: () => {
active = i;
drawTable();
},
});
pick.title = pick.ariaLabel = t("D02_Pick_Point");
const row = el("div", {
className: `d02-points__row${active === i ? " is-active" : ""}`,
children: [
el("span", { className: "d02-points__name", text: pointName(i) }),
pick,
lon,
lat,
],
});
const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length];
row.style.setProperty("--d02-year-color", color);
return row;
});
table.replaceChildren(...rows);
const saved = view?.years.find((y) => y.start);
info.textContent = view?.epsg
? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` +
(saved?.start
? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}`
: "")
: t("D02_Not_Saved");
}
function paint(focus = ""): void {
drawTable();
drawMap(focus);
}
function adopt(next: PointsView): void {
view = next;
points = boundaryPoints(next);
}
async function store(): Promise<boolean> {
if (!view) return false;
showLoadingOverlay();
try {
const body = {
years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })),
};
adopt(
await request<PointsView>(`/projects/${projectId}/plan/points`, {
method: "PUT",
body: JSON.stringify(body),
}),
);
sessionStorage.removeItem(cacheKey(projectId));
active = firstEmpty();
paint();
showToast(t("D02_Saved"), "success");
return true;
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
return false;
} finally {
hideLoadingOverlay();
}
}
// [>] = 저장 성공 뒤 D03(지형 받기 시작은 D03 몫) · 실패면 머묾
left.addEventListener("ui-step-nav", (event) => {
if ((event as CustomEvent<number>).detail !== 1) return;
event.stopPropagation();
void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS));
});
try {
adopt(await request<PointsView>(`/projects/${projectId}/plan/points`));
const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as {
points?: Array<LonLat | null>;
} | null;
if (cached?.points?.length === points.length) points = cached.points;
active = firstEmpty();
paint();
} catch (error) {
table.replaceChildren(
el("p", { className: "d02-info", text: error instanceof Error ? error.message : "" }),
);
}
}
+51
View File
@@ -0,0 +1,51 @@
/* D02 기준 지정(529-3) — 메인 지도 = 본문 높이 가득 · 좌 연도 지점 표 */
.d02-map {
height: calc(100dvh - var(--app-header-h));
}
.d02-map .ui-kakao-map,
.d02-map .ui-kakao-map__box {
height: 100%;
}
.d02-points {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.d02-points__row {
display: grid;
grid-template-columns: auto 1fr 1fr;
gap: 4px;
align-items: center;
padding: 4px 6px;
border-left: 4px solid var(--d02-year-color);
border-radius: 4px;
}
.d02-points__row.is-active {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.06));
}
.d02-points__name {
grid-column: 1 / -1;
font-size: var(--text-caption);
font-weight: 600;
}
.d02-points__input {
width: 100%;
min-width: 0;
font-size: var(--text-caption);
}
.d02-points__input.is-invalid {
outline: 1px solid var(--color-danger, #d92d20);
}
.d02-info {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
+2 -3
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* D02_PlanBase_Route.ts
* 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 빈 단계(틀 · 패널 · 단계 막대)
* 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = D02 기준 지정(529-3)
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
@@ -12,6 +12,5 @@ export const ROUTE: DraftRoute = {
workflow: true,
project: true,
footerless: true,
load: async () =>
(await import("../../A00_Common/group_workflow")).groupStepPage(ROUTES.D02_PLAN_BASE),
load: async () => (await import("../D02_PlanBase_UI_Page")).renderD02PlanBase,
};
+19 -7
View File
@@ -104,8 +104,13 @@ export interface MapPin {
export interface EditMap {
root: HTMLElement;
/** 표지 · 길 선 다시 놓고 맞춤 */
set: (pins: MapPin[], lines: Array<Array<[number, number]>>) => void;
/** 표지 · 길 선 다시 놓고 맞춤 · colors = 선마다 색 · focus = 고친 표지 key(529-3 · 없으면 지금 규칙) */
set: (
pins: MapPin[],
lines: Array<Array<[number, number]>>,
colors?: string[],
focus?: string,
) => void;
}
/** 고르는 지도 — 움직일 표지 끌기 = onMove · 지도 누름 = onClick(있을 때만) · 점 없으면 center(경도, 위도) */
@@ -122,7 +127,12 @@ export function editMap(opts: {
let drawn: KOverlay[] = [];
let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로
const set = (pins: MapPin[], lines: Array<Array<[number, number]>>): void => {
const set = (
pins: MapPin[],
lines: Array<Array<[number, number]>>,
colors?: string[],
focus?: string,
): void => {
loadKakaoMaps().then(
(maps) =>
requestAnimationFrame(() => {
@@ -146,11 +156,11 @@ export function editMap(opts: {
drawn.forEach((o) => o.setMap(null));
drawn = [
...lines.map(
(line) =>
(line, index) =>
new maps.Polyline({
path: line.map(([lat, lon]) => new maps.LatLng(lat, lon)),
strokeWeight: 6,
strokeColor: LINE_COLOR,
strokeColor: colors?.[index] ?? LINE_COLOR,
strokeOpacity: 0.9,
}),
),
@@ -185,9 +195,11 @@ export function editMap(opts: {
];
drawn.forEach((o) => o.setMap(map));
if (fitted) {
// 보기 밖 표지만 그쪽으로 옮김(줌 그대로)
// 보기 밖 표지만 그쪽으로 옮김(줌 그대로) · focus 를 주면 그 표지만 봄
const view = map.getBounds();
const out = moving.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon)));
const out = (focus === undefined ? moving : moving.filter((p) => p.key === focus)).find(
(p) => !view.contain(new maps.LatLng(p.lat, p.lon)),
);
if (out) map.panTo(new maps.LatLng(out.lat, out.lon));
} else if (points.length > 1) {
const bounds = new maps.LatLngBounds();
+17
View File
@@ -110,4 +110,21 @@ export const ui_locales_de = {
DE_Assigned_Update: ["업데이트 있음", "Update available"],
DE_Assigned_View: ["보기", "View"],
DE_Assigned_Link: ["연결", "Link"],
/* --- D02 기준 지정(529-3) --- */
D02_Points_Title: ["연도 지점", "Yearly Points"],
D02_Points_Hint: [
"📍 로 고른 지점을 지도 누름으로 놓고 · 표지를 끌거나 경도 · 위도를 넣습니다. 직전 종점 = 다음 시점.",
"Pick a point with 📍 and click the map, drag markers, or type lon · lat. Each end is the next start.",
],
D02_Start: ["시점", "start"],
D02_End: ["종점", "end"],
D02_Lon: ["경도", "Lon"],
D02_Lat: ["위도", "Lat"],
D02_Pick_Point: ["지도에 놓을 지점 고르기", "Pick the point to place on the map"],
D02_Map_Hint: ["지도를 눌러 지점을 놓습니다.", "Click the map to place a point."],
D02_Save: ["저장", "Save"],
D02_Saved: ["지점을 저장했습니다.", "Points saved."],
D02_Saved_Crs: ["저장 좌표계", "Saved CRS"],
D02_Not_Saved: ["아직 저장 안 함", "Not saved yet"],
} as const satisfies Record<string, LocaleEntry>;