fix(D 예정노선): 첫 열림 지점 칸 [추가] 켬 — 지도 가운데가 처음 생길 때 칸 다시 그림 · Page 줄 수 698 → 647 (받기 지켜보기 · 둘레 상자 → UI_Terrain) (PLAN 597 · 요구 463-7)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 16:27:06 +09:00
co-authored by Claude Sonnet 5.5
parent 8e8812c56d
commit 5f90f6c0bb
2 changed files with 117 additions and 74 deletions
+23 -74
View File
@@ -41,7 +41,6 @@ import {
savePlanNetwork,
startTerrain,
type PlaceHit,
type PlanJob,
type PlanNetworkView,
type PlanSurface,
type TerrainAim,
@@ -62,6 +61,14 @@ import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
import {
aroundSite,
boxOfLonLat,
errorText,
nearSite,
watchTerrain,
type Site,
} from "./D03_PlanAnalysis_UI_Terrain";
import {
emptyNetwork,
fromServer,
@@ -71,27 +78,12 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
const POLL_MS = 1000;
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000;
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
const COVER_MAX_M = 10000;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 100;
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */
const DRAFT_VERSION = 5;
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
interface Site {
lon: number;
lat: number;
address: string | null;
}
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
interface Snap {
network: PlanNetwork;
@@ -104,10 +96,6 @@ interface Draft extends Snap {
version: number;
}
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = currentProjectId();
let editable = false;
@@ -213,7 +201,9 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const viewWatch: PlanMapLayer = {
draw(context, screen) {
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
const had = !!viewMid;
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬
},
};
const map = createPlanMap({
@@ -281,16 +271,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const moved = (): boolean => !!site && JSON.stringify(site) !== JSON.stringify(savedSite);
/** 받기 · 범위에 늘 지금 기준점(캐시)을 실음 — 받은 범위에서 멀면 서버가 물음(저장된 자리로 되돌아올 때도). */
const center = (): TerrainAim["center"] => (site ? { lon: site.lon, lat: site.lat } : null);
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
const around = (at: Site | null): Vertex[] => {
const tm = tmParams(epsg);
if (!at || !tm) return [];
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
return [
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
];
};
const around = (at: Site | null): Vertex[] => aroundSite(epsg, at);
const fitTo = (box: Vertex[]): void => {
if (!box.length) return;
map.update({ fit: box });
@@ -369,10 +350,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(again = false): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
const [lo, hi] = around(site);
const near = ([x, y]: Vertex): boolean =>
!lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M;
const extra = [...points, ...allVertices()].filter(near);
const extra = nearSite(epsg, site, [...points, ...allVertices()]);
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
const key = JSON.stringify([have, extra]);
if (key === lastTried) return;
@@ -388,7 +366,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
redo: editable && redoStack.length > 0,
view3d: !!surface && network.lines.some((line) => line.vertices.length > 1),
measure: measuring,
yearMark: editable && network.lines.some((line) => line.vertices.length > 1),
});
map.redraw();
props.render();
@@ -466,16 +443,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
async function uploadRoads(files: File[]): Promise<void> {
const info = await backdrop.uploadRoads(files);
const tm = tmParams(epsg);
if (!info) return;
const roads: Vertex[] =
tm && info.bounds
? [
lonLatToTm(tm, info.bounds[0], info.bounds[1]),
lonLatToTm(tm, info.bounds[2], info.bounds[3]),
]
: [];
fitTo([...around(site), ...roads]);
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
@@ -488,35 +456,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
async function watch(): Promise<PlanJob | null> {
busy = true;
circle.set(null, "지형");
circle.root.hidden = false;
refresh();
try {
for (;;) {
if (!root.isConnected) return null;
const job = (await fetchPlanProgress(projectId)).terrain;
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 cut ? { ...job, error: INTERRUPTED } : job;
}
await sleep(POLL_MS);
}
} catch (error) {
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
busy = false;
circle.root.hidden = true;
refresh();
}
}
const watch = () =>
watchTerrain({
projectId,
alive: () => root.isConnected,
circle,
setBusy: (on) => {
busy = on;
refresh();
},
});
async function loadSurface(): Promise<void> {
try {
@@ -0,0 +1,94 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Terrain.ts
* D 예정노선 받기 이음(PLAN 597 · 요구 463-7) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · 받기 지켜보기(서클).
* 상태는 페이지가 쥐고 여기는 인자로만 받음(화면 요소 만들기 없음 · 서클은 부품 그대로).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { createProgressCircle } from "@ui/ui_template_progress";
import { fetchPlanProgress, type PlanJob } from "./D03_PlanAnalysis_Api";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
const POLL_MS = 1000;
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000;
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
const COVER_MAX_M = 10000;
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
export interface Site {
lon: number;
lat: number;
address: string | null;
}
export const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
export const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
export function aroundSite(epsg: number, at: Site | null): Vertex[] {
const tm = tmParams(epsg);
if (!at || !tm) return [];
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
return [
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
];
}
/** 경위도 상자 [경도1, 위도1, 경도2, 위도2] → 작업 좌표 두 점(없음 · 띠 모름 = 빈 목록). */
export function boxOfLonLat(epsg: number, bounds: number[] | null | undefined): Vertex[] {
const tm = tmParams(epsg);
if (!tm || !bounds) return [];
return [lonLatToTm(tm, bounds[0], bounds[1]), lonLatToTm(tm, bounds[2], bounds[3])];
}
/** 기준점 둘레 상자 가운데에서 `COVER_MAX_M` 안인 점만 남김(기준점 없음 = 모두). */
export function nearSite(epsg: number, at: Site | null, list: Vertex[]): Vertex[] {
const [lo, hi] = aroundSite(epsg, at);
if (!lo) return list;
return list.filter(
([x, y]) => Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M,
);
}
export interface WatchParams {
projectId: string;
alive: () => boolean;
circle: ReturnType<typeof createProgressCircle>;
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
setBusy: (on: boolean) => void;
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
const { projectId, alive, circle, setBusy } = params;
setBusy(true);
circle.set(null, "지형");
circle.root.hidden = false;
try {
for (;;) {
if (!alive()) return null;
const job = (await fetchPlanProgress(projectId)).terrain;
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 cut ? { ...job, error: INTERRUPTED } : job;
}
await sleep(POLL_MS);
}
} catch (error) {
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
circle.root.hidden = true;
setBusy(false);
}
}