202 lines
7.6 KiB
TypeScript
202 lines
7.6 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_SheetTiles.ts
|
|
* D03 수치지형도 선 겹 = 도엽 조각(PLAN 606 · 요구 477) — 위성처럼 지도 보기에 보이는 도엽만.
|
|
* 보기 멈춘 뒤(`VIEW_IDLE_MS`) 보이는 도엽 목록(서버 · 가운데부터) → 켠 겹의 없는 조각만 하나씩 받음
|
|
* (등고 먼저 · 보조 겹 뒤) → 받을 때마다 `onChange`(겹이 합친 자료를 다시 얹음).
|
|
* 보이는 도엽 12 장 이하 = 다 · 13~30 = 계곡선만(등고선 자리에 · 그 밖 선 겹 빔) · 넘음 = 선 겹 숨김.
|
|
* 받은 조각은 기억(최근 도엽 `KEEP_SHEETS` 장) — 다시 오면 즉시 · 받는 동안 서클(`track`).
|
|
* 지형 받기와 따로 — 보기만 옮겨도 조각은 받음 · 지형(받은 범위)은 안 받음(596-4 그대로).
|
|
* ========================================================================== */
|
|
|
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import { fetchPlanSheetTile, fetchPlanSheetTiles } from "./D03_PlanAnalysis_Api";
|
|
import { WATER_KEYS, type PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
|
|
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
|
|
/** 겹 → 도엽 조각 이름(서버 `TILE_NAMES`). */
|
|
export const SHEET_TILE_NAMES: Partial<Record<PlanLayerKey, string[]>> = {
|
|
contour: ["등고선"],
|
|
stream: ["하천중심선"],
|
|
road: ["도로중심선"],
|
|
forestRoad: ["도로중심선"],
|
|
water: WATER_KEYS,
|
|
building: ["건물"],
|
|
place: ["지명"],
|
|
};
|
|
export const FULL_MAX = 12;
|
|
export const INDEX_MAX = 30;
|
|
const VIEW_IDLE_MS = 800;
|
|
const KEEP_SHEETS = 24;
|
|
const CHANGE_MS = 150;
|
|
|
|
export type SheetMode = "all" | "index" | "hidden";
|
|
|
|
export interface SheetTiles {
|
|
/** 바탕 그릴 때마다 — 보기가 멈추면 보이는 도엽을 다시 셈. */
|
|
view: (m: ScreenMap, meta: VWorldMeta) => void;
|
|
/** 겹 조각 이름의 보이는 도엽 합(모드대로 · 없는 조각은 받기 시작). */
|
|
collection: (name: string) => GeoJsonCollection;
|
|
state: () => {
|
|
mode: SheetMode;
|
|
count: number;
|
|
sheets: string[];
|
|
cached: number;
|
|
loading: boolean;
|
|
/** 마지막 보기 경위도 상자. */
|
|
bbox: string;
|
|
failed: string[];
|
|
/** 조각마다 받은 시간(ms · 마지막 20). */
|
|
ms: Array<[string, number]>;
|
|
};
|
|
}
|
|
|
|
export function createSheetTiles(opts: {
|
|
projectId: string;
|
|
track: <T>(job: Promise<T>) => Promise<T>;
|
|
/** 지금 켠 겹의 조각 이름. */
|
|
wanted: () => string[];
|
|
onChange: (names: Set<string>) => void;
|
|
}): SheetTiles {
|
|
const { projectId, track } = opts;
|
|
const cache = new Map<string, GeoJsonCollection>();
|
|
const failed = new Set<string>();
|
|
const ms: Array<[string, number]> = [];
|
|
let recent: string[] = [];
|
|
let sheets: string[] = [];
|
|
let count = 0;
|
|
let mode: SheetMode = "all";
|
|
let lastBox = "";
|
|
let timer = 0;
|
|
let turn = 0;
|
|
let loading = false;
|
|
|
|
/** 바뀐 조각 이름 — 잠깐 모아 한 번에 알림(겹마다 합친 자료를 다시 얹는 일 줄임). */
|
|
const dirty = new Set<string>();
|
|
let flush = 0;
|
|
function changed(names: string[]): void {
|
|
for (const name of names) dirty.add(name);
|
|
window.clearTimeout(flush);
|
|
flush = window.setTimeout(() => {
|
|
const names = new Set(dirty);
|
|
dirty.clear();
|
|
opts.onChange(names);
|
|
}, CHANGE_MS);
|
|
}
|
|
|
|
/** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음. */
|
|
const source = (name: string): string | null =>
|
|
mode === "hidden" ? null : mode === "all" ? name : name === "등고선" ? "계곡선" : null;
|
|
|
|
function nextJob(): [string, string] | null {
|
|
const names = [...new Set(opts.wanted())].map(source).filter((n): n is string => !!n);
|
|
// 등고 먼저 · 보조 겹(zip) 뒤
|
|
names.sort(
|
|
(a, b) =>
|
|
Number(!a.endsWith("고선") && a !== "계곡선") -
|
|
Number(!b.endsWith("고선") && b !== "계곡선"),
|
|
);
|
|
for (const name of names)
|
|
for (const sheet of sheets) {
|
|
const key = `${sheet}/${name}`;
|
|
if (!cache.has(key) && !failed.has(key)) return [sheet, name];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 없는 조각을 하나씩 — 목록이 바뀌면(차례) 남은 것은 새 목록으로. */
|
|
async function pull(): Promise<void> {
|
|
if (loading) return;
|
|
loading = true;
|
|
try {
|
|
for (let job = nextJob(); job; job = nextJob()) {
|
|
const [sheet, name] = job;
|
|
const key = `${sheet}/${name}`;
|
|
const started = performance.now();
|
|
try {
|
|
cache.set(key, await fetchPlanSheetTile(projectId, sheet, name));
|
|
} catch {
|
|
failed.add(key); // 이번 화면에선 다시 안 받음(다음 목록 때 다시)
|
|
}
|
|
ms.push([key, Math.round(performance.now() - started)]);
|
|
if (ms.length > 20) ms.shift();
|
|
if (sheets.includes(sheet)) changed([name === "계곡선" ? "등고선" : name]);
|
|
}
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
const kick = (): void => void (nextJob() && !loading && track(pull()));
|
|
|
|
async function list(bbox: [number, number, number, number]): Promise<void> {
|
|
const mine = ++turn;
|
|
const got = await fetchPlanSheetTiles(projectId, bbox).catch(() => null);
|
|
if (!got || mine !== turn) return;
|
|
const before = `${mode}|${sheets.join()}`;
|
|
count = got.count;
|
|
sheets = got.sheets;
|
|
mode = count <= FULL_MAX ? "all" : count <= INDEX_MAX ? "index" : "hidden";
|
|
failed.clear();
|
|
recent = [...new Set([...sheets, ...recent])].slice(0, Math.max(KEEP_SHEETS, sheets.length));
|
|
for (const key of cache.keys()) if (!recent.includes(key.split("/")[0])) cache.delete(key);
|
|
if (`${mode}|${sheets.join()}` !== before) changed(Object.values(SHEET_TILE_NAMES).flat());
|
|
kick();
|
|
}
|
|
|
|
return {
|
|
view: (m, meta) => {
|
|
const { width, height } = m.view;
|
|
if (!width || !height) return;
|
|
// 그림 px 네 귀 → 작업 좌표 → 경위도(위성 틀 1차) · 돌린 지도 = 대각선만큼 넓힘
|
|
const grow = m.uprightRad ? Math.SQRT2 : 1;
|
|
const corners = [
|
|
[0, 0],
|
|
[width, 0],
|
|
[0, height],
|
|
[width, height],
|
|
].map(([px, py]) =>
|
|
m.toMetric(width / 2 + (px - width / 2) * grow, height / 2 + (py - height / 2) * grow),
|
|
);
|
|
const lon = (x: number) =>
|
|
meta.lon_min +
|
|
((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min);
|
|
const lat = (y: number) =>
|
|
meta.lat_min +
|
|
((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min);
|
|
const lons = corners.map(([x]) => lon(x));
|
|
const lats = corners.map(([, y]) => lat(y));
|
|
const bbox: [number, number, number, number] = [
|
|
Math.min(...lons),
|
|
Math.min(...lats),
|
|
Math.max(...lons),
|
|
Math.max(...lats),
|
|
];
|
|
const tag = bbox.map((v) => v.toFixed(4)).join();
|
|
if (tag === lastBox) return;
|
|
lastBox = tag;
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(() => void list(bbox), VIEW_IDLE_MS);
|
|
},
|
|
collection: (name) => {
|
|
const src = source(name);
|
|
if (src) kick();
|
|
return {
|
|
type: "FeatureCollection",
|
|
features: src
|
|
? sheets.flatMap((sheet) => cache.get(`${sheet}/${src}`)?.features ?? [])
|
|
: [],
|
|
} as GeoJsonCollection;
|
|
},
|
|
state: () => ({
|
|
mode,
|
|
count,
|
|
sheets: [...sheets],
|
|
cached: cache.size,
|
|
loading,
|
|
bbox: lastBox,
|
|
failed: [...failed],
|
|
ms: [...ms],
|
|
}),
|
|
};
|
|
}
|