Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts
T
eomsangdonandClaude Opus 5.5 b4e1b89943 feat(D03): 평면 도구줄을 공용 지도 도구줄로 — [?] 안내 · 측점 · 기울기 켬끔 더해 아홉 · 겹 단추 아이콘 (PLAN 568-3 · 569-2 · 요구 395)
- UI_Map — createMapTools · mapToolButton(되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 거리 재기) · [?] 조작 안내 여덟 줄 · ScreenMap 에 측점 · 기울기 켬 실음
- UI_Draw — 측점 눈금 · 구간 길이 · 기울기 글을 켬끔 따라 그림
- UI_Layers — 겹 단추 글자 대신 공용 아이콘(satellite · droplet · borderAll · angle)
- test_553_4 — 옛 createToolbar · createIconButton 지킴을 공용 도구줄 · 겹 단추 지킴으로 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:04:02 +09:00

347 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;
const SLOPE_STEPS = [
{ label: "15°", token: "--draw-slope-15" },
{ label: "35°", token: "--draw-slope-35" },
{ label: "40°", token: "--draw-slope-40" },
];
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 = "급경사 색 — 그 값 이상";
legend.append(
createLegend(SLOPE_STEPS.map((step) => ({ label: step.label, color: `var(${step.token})` }))),
);
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;
}
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) };
}
}
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", "#facc15"), 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[]),
],
}),
};
}