Merge remote-tracking branch 'origin/dev' into sub_laptop_5

충돌 = UI_Page.ts 메뉴 블록 옆 주석(596-4) — 둘 다 살림
This commit is contained in:
2026-10-30 14:58:32 +09:00
29 changed files with 1251 additions and 317 deletions
+41 -44
View File
@@ -19,8 +19,9 @@
import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { pickFiles } from "@ui/ui_template_drop";
import { askConfirm } from "@ui/ui_template_modal";
import { createProgressBar } from "@ui/ui_template_progress";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import {
createButton,
@@ -71,10 +72,8 @@ import {
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
const POLL_MS = 1000;
/** 지도 보기 가운데(보기의 반)가 받은 범위 밖이면 이어 받음 — 그 상자가 이보다 넓으면(멀리 줄여 봄) 안 받음(m). */
const VIEW_FETCH_MAX_M = 2000;
/** 지도 보기 바뀐 뒤 이만큼 멈추면 범위를 봄(ms). */
const VIEW_SETTLE_MS = 800;
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000;
/** 되돌리기 쌓는 수. */
@@ -110,9 +109,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let redoStack: Snap[] = [];
let surface: PlanSurface | null = null;
let have: [number, number, number, number] | null = null;
let viewCore: Vertex[] = []; // 지도 보기 가운데 상자 두 귀(넓으면 빈 것)
let viewMid: Vertex | null = null; // 지도 보기 가운데(지점 [추가] 자리)
let viewTimer = 0;
let lastTried = ""; // 같은 받기를 거듭하지 않음
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
let busy = false;
@@ -160,7 +157,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }),
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(() => z);
const measure = createPlanMeasure(
() => z,
() => network,
);
const marks = createPlanPoints({
points: () => points,
network: () => network,
@@ -184,30 +184,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
draw,
editable: () => editable,
});
// 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄)
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
const viewWatch: PlanMapLayer = {
draw(context, screen) {
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
const corners = [
[w / 4, h / 4],
[(3 * w) / 4, h / 4],
[(3 * w) / 4, (3 * h) / 4],
[w / 4, (3 * h) / 4],
].map(([x, y]) => screen.toMetric(...screen.unrotate(x, y)));
const xs = corners.map((c) => c[0]);
const ys = corners.map((c) => c[1]);
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
const box: Vertex[] = [
[Math.min(...xs), Math.min(...ys)],
[Math.max(...xs), Math.max(...ys)],
];
const wide =
box[1][0] - box[0][0] > VIEW_FETCH_MAX_M || box[1][1] - box[0][1] > VIEW_FETCH_MAX_M;
const next = wide || !w ? [] : box;
if (JSON.stringify(next) === JSON.stringify(viewCore)) return;
viewCore = next;
clearTimeout(viewTimer);
viewTimer = window.setTimeout(() => void ensureCovered(), VIEW_SETTLE_MS);
},
};
const map = createPlanMap({
@@ -220,16 +201,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
projectId,
map,
points: () => [...points, ...allVertices()],
have: () => have,
});
map.root.append(backdrop.bar, backdrop.legend, props.panel);
// 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3)
const circle = createProgressCircle({ size: 40, corner: true });
circle.root.hidden = true;
map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root);
const main = document.createElement("div");
main.className = "d03-plan";
main.append(map.root);
const terrainText = createText("", "muted");
terrainText.classList.add("d03-plan__hint");
const bar = createProgressBar();
bar.root.hidden = true;
const retryButton = createButton({
label: "다시 받기",
variant: "filled",
@@ -240,7 +223,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
},
});
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar.root, retryButton);
const roadButton = createButton({
label: "임도망 올리기",
variant: "ghost",
size: 28,
onClick: () =>
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
void backdrop.uploadRoads(files);
}),
});
roadButton.title =
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
terrainCard.append(terrainText, retryButton, roadButton);
const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel);
const drawCard = createPanelCard("그리기", false);
@@ -341,13 +335,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
/** 조용히 받기(요구 453) — 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
* 단추 · 막는 창 없음 · 실패 = [다시 받기] · 같은 요청은 한 번만. */
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
const need = [...points, ...allVertices()];
if (inside(need) && inside(viewCore)) return; // 받을 때 서버가 여유를 더함
const extra = [...need, ...viewCore];
const extra = [...points, ...allVertices()];
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
const key = JSON.stringify([have, extra]);
if (key === lastTried) return;
lastTried = key;
@@ -369,6 +362,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
pieces.render();
marks.render();
retryButton.hidden = !editable || busy || !failed;
roadButton.hidden = !editable;
if (!busy)
terrainText.textContent = failed
? `지형 받기 실패 — ${failed}`
@@ -407,29 +401,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
async function watch(): Promise<PlanJob | null> {
busy = true;
bar.root.hidden = false;
circle.set(null, "지형");
circle.root.hidden = false;
refresh();
try {
for (;;) {
if (!root.isConnected) return null;
const job = (await fetchPlanProgress(projectId)).terrain;
bar.set(job.percent / 100);
terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
if (!job.running) {
const cut = !job.error && (job.interrupted || job.percent < 100);
if (job.error) showToast(job.error, "error");
return job;
return cut ? { ...job, error: INTERRUPTED } : job;
}
await sleep(POLL_MS);
}
} catch (error) {
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return null;
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
busy = false;
bar.root.hidden = true;
circle.root.hidden = true;
refresh();
}
}