- 범례가 응답 bands(색 · 비율)를 그대로 씀 — 화면 급경사 색 값 셋 뺌(test_551_2 그림 값 개수 24 → 21) - 지적 경계 노랑 → 분홍(등고선 · 급경사 노랑과 겹쳐 안 보임) - 겹 판 만들 때 상태 한 번 맞춤 — 지형 못 읽으면 범례 빈 칸이 남던 것 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Layers.ts
|
|
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로).
|
|
*
|
|
* 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 ·
|
|
* 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸).
|
|
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
|
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음.
|
|
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선.
|
|
* ========================================================================== */
|
|
|
|
import { createIconButton } from "@ui/ui_template_button";
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { createLegend } from "@ui/ui_template_flow_legend";
|
|
import type { IconName } from "@ui/ui_template_icons";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { createToolbar } from "@ui/ui_template_toolbar";
|
|
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
|
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
|
import {
|
|
fetchVWorldMeta,
|
|
getVWorldMapUrl,
|
|
type VWorldMeta,
|
|
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import {
|
|
createNormalizer,
|
|
drawPreparedLabels,
|
|
drawPreparedLayer,
|
|
pickLevelStep,
|
|
type GeoJsonCollection,
|
|
type PreparedLayer,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
|
import {
|
|
fetchPlanCadastral,
|
|
fetchPlanRaster,
|
|
fetchPlanSheetLayer,
|
|
type PlanRaster,
|
|
} from "./D03_PlanAnalysis_Api";
|
|
import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
|
|
|
export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope";
|
|
|
|
interface LayerDef {
|
|
key: PlanLayerKey;
|
|
icon: IconName;
|
|
title: string;
|
|
on: boolean;
|
|
}
|
|
|
|
const LAYERS: readonly LayerDef[] = [
|
|
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
|
|
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
|
|
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
|
|
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
|
|
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
|
|
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
|
|
];
|
|
const PREF_KEY = "aislo:pref:d03.layers";
|
|
/** 등고선을 받아 올 그은 선 둘레(m). */
|
|
const CONTOUR_PAD_M = 600;
|
|
|
|
interface Prepared {
|
|
contour: PreparedLayer;
|
|
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
|
stream: [PreparedLayer, PreparedLayer];
|
|
cadastral: PreparedLayer;
|
|
road: PreparedLayer;
|
|
slope: { raster: PlanRaster; image: HTMLImageElement };
|
|
}
|
|
|
|
export interface PlanLayers {
|
|
bar: HTMLElement;
|
|
legend: HTMLElement;
|
|
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
|
layers: PlanMapLayer[];
|
|
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
|
load: () => Promise<void>;
|
|
/** 검증 창구 — 켬끔 · 받은 겹. */
|
|
state: () => { on: Record<PlanLayerKey, boolean>; loaded: PlanLayerKey[] };
|
|
}
|
|
|
|
function readPref(): Record<PlanLayerKey, boolean> {
|
|
const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record<
|
|
PlanLayerKey,
|
|
boolean
|
|
>;
|
|
try {
|
|
const saved = JSON.parse(readByKey(PREF_KEY) ?? "{}") as Partial<Record<PlanLayerKey, unknown>>;
|
|
for (const def of LAYERS)
|
|
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
|
|
} catch {
|
|
// 깨진 값 = 처음 값
|
|
}
|
|
return on;
|
|
}
|
|
|
|
const loadImage = (src: string): Promise<HTMLImageElement> =>
|
|
new Promise((done, fail) => {
|
|
const image = new Image();
|
|
image.onload = () => done(image);
|
|
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
|
|
image.src = src;
|
|
});
|
|
|
|
export function createPlanLayers(opts: {
|
|
projectId: string;
|
|
map: PlanMap;
|
|
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
|
|
points: () => Vertex[];
|
|
}): PlanLayers {
|
|
const { projectId, map } = opts;
|
|
const on = readPref();
|
|
const data: Partial<Prepared> = {};
|
|
const pending = new Map<PlanLayerKey, Promise<void>>();
|
|
let meta: VWorldMeta | null = null;
|
|
let satellite: HTMLImageElement | null = null;
|
|
|
|
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
|
for (const def of LAYERS) {
|
|
const button = createIconButton({
|
|
icon: def.icon,
|
|
title: def.title,
|
|
pressed: on[def.key],
|
|
onClick: () => toggle(def.key),
|
|
});
|
|
button.dataset.layer = def.key;
|
|
buttons.set(def.key, button);
|
|
}
|
|
const bar = createToolbar({
|
|
glass: true,
|
|
size: 32,
|
|
label: "지도 겹",
|
|
items: [...buttons.values()],
|
|
});
|
|
bar.classList.add("d03-plan__layers");
|
|
const legend = document.createElement("div");
|
|
legend.className = "d03-plan__legend";
|
|
legend.title = "급경사 색 — 단계 · 받은 지형 안 넓이 비율";
|
|
|
|
function sync(): void {
|
|
for (const def of LAYERS) {
|
|
const button = buttons.get(def.key)!;
|
|
button.setAttribute("aria-pressed", String(on[def.key]));
|
|
button.disabled = !meta;
|
|
const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`;
|
|
button.title = title;
|
|
button.setAttribute("aria-label", title);
|
|
}
|
|
legend.hidden = !meta || !on.slope || !data.slope;
|
|
}
|
|
|
|
sync();
|
|
|
|
function toggle(key: PlanLayerKey): void {
|
|
on[key] = !on[key];
|
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
|
void ensure(key);
|
|
showSatellite();
|
|
sync();
|
|
map.redraw();
|
|
}
|
|
|
|
function showSatellite(): void {
|
|
map.update({ image: on.satellite ? satellite : null });
|
|
}
|
|
|
|
/** 켜진 겹의 자료를 받음(한 번). */
|
|
function ensure(key: PlanLayerKey): Promise<void> {
|
|
if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared]))
|
|
return Promise.resolve();
|
|
const running = pending.get(key);
|
|
if (running) return running;
|
|
const at = meta;
|
|
const job = fetchLayer(key, at)
|
|
.then(() => {
|
|
if (meta !== at) return;
|
|
showSatellite();
|
|
sync();
|
|
map.redraw();
|
|
})
|
|
.catch((error: unknown) => {
|
|
const text = error instanceof Error ? error.message : String(error);
|
|
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
|
|
})
|
|
.finally(() => pending.delete(key));
|
|
pending.set(key, job);
|
|
return job;
|
|
}
|
|
|
|
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
|
|
const normalizer = createNormalizer(at);
|
|
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
|
prepareLayer(collection, normalizer, labels);
|
|
if (key === "satellite") {
|
|
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
|
|
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
|
|
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
|
|
} else if (key === "contour") {
|
|
const points = opts.points();
|
|
const xs = points.map(([x]) => x);
|
|
const ys = points.map(([, y]) => y);
|
|
const bbox = [
|
|
Math.min(...xs) - CONTOUR_PAD_M,
|
|
Math.min(...ys) - CONTOUR_PAD_M,
|
|
Math.max(...xs) + CONTOUR_PAD_M,
|
|
Math.max(...ys) + CONTOUR_PAD_M,
|
|
].map((v) => v.toFixed(0));
|
|
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
|
projectId,
|
|
"도엽_등고선",
|
|
"route",
|
|
bbox.join(","),
|
|
);
|
|
data.contour = prepare(collection, ["등고수치"]);
|
|
} else if (key === "stream") {
|
|
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
|
projectId,
|
|
"도엽_하천중심선",
|
|
);
|
|
const pick = (small: boolean): GeoJsonCollection => ({
|
|
...collection,
|
|
features: (collection.features ?? []).filter(
|
|
(feature) => (feature.properties?.["구분"] === "세류") === small,
|
|
),
|
|
});
|
|
data.stream = [prepare(pick(true)), prepare(pick(false))];
|
|
} else if (key === "cadastral") {
|
|
data.cadastral = prepare(await fetchPlanCadastral(projectId));
|
|
} else if (key === "road") {
|
|
data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선"));
|
|
} else {
|
|
const raster = await fetchPlanRaster(projectId, "slope");
|
|
data.slope = { raster, image: await loadImage(raster.image) };
|
|
// 범례 = 서버 단계 색 그대로(색 한 곳)
|
|
legend.replaceChildren(
|
|
createLegend(
|
|
raster.bands.map((band) => ({
|
|
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
|
color: band.color,
|
|
})),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const stroke = (
|
|
context: CanvasRenderingContext2D,
|
|
map: ScreenMap,
|
|
layer: PreparedLayer,
|
|
color: string,
|
|
width: number,
|
|
everyM?: number,
|
|
): void => {
|
|
context.strokeStyle = color;
|
|
context.lineWidth = width;
|
|
drawPreparedLayer(context, layer, map.view, "dot", everyM);
|
|
};
|
|
|
|
const layers: PlanMapLayer[] = [
|
|
{
|
|
draw: (context, map) => {
|
|
const slope = on.slope && data.slope;
|
|
if (!slope) return;
|
|
const [x0, y0, x1, y1] = slope.raster.box_m;
|
|
const [left, top] = map.toScreen([x0, y1]);
|
|
const [right, bottom] = map.toScreen([x1, y0]);
|
|
context.globalAlpha = 0.55;
|
|
context.drawImage(slope.image, left, top, right - left, bottom - top);
|
|
},
|
|
},
|
|
{
|
|
draw: (context, map) => {
|
|
if (!on.cadastral || !data.cadastral) return;
|
|
context.globalAlpha = 0.8;
|
|
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
|
|
},
|
|
},
|
|
{
|
|
draw: (context, map) => {
|
|
const contour = on.contour && data.contour;
|
|
if (!contour) return;
|
|
const color = themeColor("--draw-map-contour", "#ffd68c");
|
|
context.globalAlpha = 0.7;
|
|
stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5));
|
|
stroke(context, map, contour, color, 1.6, 25); // 계곡선
|
|
context.globalAlpha = 1;
|
|
context.font = "10px system-ui, sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
drawPreparedLabels(
|
|
context,
|
|
contour,
|
|
map.view,
|
|
color,
|
|
25,
|
|
map.uprightRad,
|
|
"rgba(0, 0, 0, 0.6)",
|
|
);
|
|
},
|
|
},
|
|
{
|
|
draw: (context, map) => {
|
|
if (!on.road || !data.road) return;
|
|
stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2);
|
|
stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6);
|
|
},
|
|
},
|
|
{
|
|
draw: (context, map) => {
|
|
if (!on.stream || !data.stream) return;
|
|
const color = themeColor("--draw-map-stream", "#38bdf8");
|
|
stroke(context, map, data.stream[0], color, 1);
|
|
stroke(context, map, data.stream[1], color, 2.4);
|
|
},
|
|
},
|
|
];
|
|
|
|
async function load(): Promise<void> {
|
|
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
|
|
const changed =
|
|
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
|
|
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
|
meta = next;
|
|
if (changed) {
|
|
satellite = null;
|
|
for (const key of Object.keys(data)) delete data[key as keyof Prepared];
|
|
}
|
|
map.update({ meta });
|
|
showSatellite();
|
|
sync();
|
|
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
|
}
|
|
|
|
return {
|
|
bar,
|
|
legend,
|
|
layers,
|
|
load,
|
|
state: () => ({
|
|
on: { ...on },
|
|
loaded: [
|
|
...(satellite ? (["satellite"] as PlanLayerKey[]) : []),
|
|
...(Object.keys(data) as PlanLayerKey[]),
|
|
],
|
|
}),
|
|
};
|
|
}
|