- 서버: lt_c_fsdifrsts 받은 범위로 잘라 토양형 코드 · 이름 · 넓이 · 토양형별 합 · 덮으면 다시 안 받음 · 실패 = 502 - 화면: 단추 하나(잎 아이콘 · 부품 상자에 더함) · 토양형 색 --draw-map-soil-1~6 · 범례 = 토양형 이름 · ha · 바탕 캔버스 - 산사태위험지도(생활안전지도 = 공공누리 4유형 · 데이터 준비 중) · 단층 · 지질구조선(이용 조건 문서 없음)은 넣지 않음(브레인 결정 A) - test_553_4: 겹 목록에 soils 더함(새 겹 · 기대 목록 갱신) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
646 lines
23 KiB
TypeScript
646 lines
23 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_Layers.ts
|
|
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 요구 395-2 · 440-8 · 442) — 지도 왼쪽 위 아이콘 한 줄
|
|
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
|
|
*
|
|
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
|
|
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) ·
|
|
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
|
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
|
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
|
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
|
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선.
|
|
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
|
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래).
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend";
|
|
import { createIconButton } from "@ui/ui_template_button";
|
|
import { createMapTools } from "@ui/ui_template_map_tools";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
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,
|
|
lonLatToScreen,
|
|
pickLevelStep,
|
|
type GeoJsonCollection,
|
|
type PreparedLayer,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import {
|
|
indexLayerLod,
|
|
prepareLayer,
|
|
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
|
import {
|
|
fetchPlanBands,
|
|
fetchPlanCadastral,
|
|
fetchPlanRaster,
|
|
fetchPlanSheetLayer,
|
|
fetchPlanSoils,
|
|
fetchPlanZones,
|
|
type PlanBand,
|
|
type PlanRaster,
|
|
type PlanSoils,
|
|
type PlanZones,
|
|
} from "./D03_PlanAnalysis_Api";
|
|
import {
|
|
FOREST_ROAD_KINDS,
|
|
GROUPS,
|
|
LAYERS,
|
|
SOIL_COLORS,
|
|
WATER_KEYS,
|
|
ZONE_COLORS,
|
|
fillLayer,
|
|
hectare,
|
|
type PlanLayerKey,
|
|
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
|
import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
|
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
|
|
|
const PREF_KEY = "aislo:pref:d03.layers";
|
|
/** 등고선을 받아 올 그은 선 둘레(m). */
|
|
const CONTOUR_PAD_M = 600;
|
|
interface Prepared {
|
|
contour: PreparedLayer;
|
|
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
|
|
stream: [PreparedLayer, PreparedLayer];
|
|
cadastral: PreparedLayer;
|
|
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
|
|
road: { all: PreparedLayer; small: PreparedLayer };
|
|
slope: { raster: PlanRaster; image: HTMLImageElement };
|
|
hillshade: { raster: PlanRaster; image: HTMLImageElement };
|
|
water: PreparedLayer;
|
|
zones: { kinds: Map<string, PreparedLayer>; summary: PlanZones["summary"] };
|
|
/** 토양형(코드 차례) → 지물 · 색 값. */
|
|
soils: { kinds: Array<{ layer: PreparedLayer; color: string }>; summary: PlanSoils["summary"] };
|
|
bands: { stream: PreparedLayer; road: PreparedLayer; info: { stream: PlanBand; road: PlanBand } };
|
|
building: PreparedLayer;
|
|
place: Array<{ lon: number; lat: number; name: string }>;
|
|
}
|
|
type DataKey = keyof Prepared;
|
|
|
|
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
|
const dataKey = (key: Exclude<PlanLayerKey, "satellite">): DataKey =>
|
|
key === "forestRoad" ? "road" : key;
|
|
|
|
export interface PlanLayers {
|
|
bar: HTMLElement;
|
|
legend: HTMLElement;
|
|
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
|
layers: PlanMapLayer[];
|
|
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
|
|
load: () => Promise<void>;
|
|
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
|
state: () => {
|
|
on: Record<PlanLayerKey, boolean>;
|
|
loaded: PlanLayerKey[];
|
|
counts: Record<string, number>;
|
|
};
|
|
}
|
|
|
|
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<DataKey | "satellite", Promise<void>>();
|
|
let meta: VWorldMeta | null = null;
|
|
let satellite: HTMLImageElement | null = null;
|
|
|
|
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
|
|
const groups = GROUPS.map((group) =>
|
|
group.map((def) => {
|
|
const button = createIconButton({
|
|
icon: def.icon,
|
|
title: def.title,
|
|
size: 32,
|
|
pressed: on[def.key],
|
|
onClick: () => toggle(def.key),
|
|
});
|
|
button.dataset.layer = def.key;
|
|
buttons.set(def.key, button);
|
|
return button;
|
|
}),
|
|
);
|
|
const tools = createMapTools({ label: "지도 겹", groups });
|
|
const bar = tools.root;
|
|
bar.classList.add("d03-plan__layers");
|
|
// 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`)
|
|
new ResizeObserver(() =>
|
|
map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`),
|
|
).observe(tools.bar);
|
|
const legend = document.createElement("div");
|
|
legend.className = "d03-plan__legend";
|
|
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
|
|
|
|
const loadedOf = (key: PlanLayerKey): boolean =>
|
|
key === "satellite" ? !!satellite : !!data[dataKey(key)];
|
|
|
|
function legendRows(): LegendItem[][] {
|
|
const rows: LegendItem[][] = [];
|
|
if (on.slope && data.slope)
|
|
rows.push(
|
|
data.slope.raster.bands.map((band) => ({
|
|
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
|
color: band.color,
|
|
})),
|
|
);
|
|
if (on.zones && data.zones) {
|
|
const items = Object.entries(data.zones.summary)
|
|
.filter(([, kind]) => kind.count || kind.failed)
|
|
.map(([key, kind]) => ({
|
|
label: kind.failed
|
|
? `${kind.label} 받지 못함`
|
|
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
|
|
color: `var(${ZONE_COLORS[key]})`,
|
|
off: !!kind.failed,
|
|
}));
|
|
rows.push(
|
|
items.length
|
|
? items
|
|
: [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }],
|
|
);
|
|
}
|
|
if (on.soils && data.soils) {
|
|
const { kinds, summary } = data.soils;
|
|
rows.push(
|
|
summary.length
|
|
? summary.map((soil, i) => ({
|
|
label: `${soil.name} ${hectare(soil.area_m2)}`,
|
|
color: `var(${kinds[i].color})`,
|
|
}))
|
|
: [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }],
|
|
);
|
|
}
|
|
if (on.bands && data.bands) {
|
|
const { stream, road } = data.bands.info;
|
|
rows.push([
|
|
{
|
|
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
|
|
color: "var(--draw-map-band-stream)",
|
|
},
|
|
{
|
|
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
|
|
color: "var(--draw-map-band-road)",
|
|
},
|
|
]);
|
|
}
|
|
const tail: LegendItem[] = [];
|
|
if (on.water && data.water)
|
|
tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" });
|
|
if (on.building && data.building)
|
|
tail.push({
|
|
label: `건물 ${data.building.features.length.toLocaleString()}동`,
|
|
color: "var(--draw-map-building)",
|
|
});
|
|
if (on.forestRoad && data.road)
|
|
tail.push({
|
|
label: `임도 짐작 ${data.road.small.features.length}가닥`,
|
|
color: "var(--draw-map-forest-road)",
|
|
kind: "dash",
|
|
});
|
|
if (tail.length) rows.push(tail);
|
|
return rows;
|
|
}
|
|
|
|
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);
|
|
}
|
|
const rows = meta ? legendRows() : [];
|
|
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
|
legend.hidden = !rows.length;
|
|
}
|
|
|
|
sync();
|
|
|
|
function toggle(key: PlanLayerKey): void {
|
|
on[key] = !on[key];
|
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
|
void ensure(key);
|
|
showSatellite();
|
|
sync();
|
|
map.redrawBase();
|
|
}
|
|
|
|
function showSatellite(): void {
|
|
map.update({ image: on.satellite ? satellite : null });
|
|
}
|
|
|
|
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
|
|
function ensure(key: PlanLayerKey): Promise<void> {
|
|
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
|
|
const slot = key === "satellite" ? key : dataKey(key);
|
|
const running = pending.get(slot);
|
|
if (running) return running;
|
|
const at = meta;
|
|
const job = fetchLayer(key, at)
|
|
.then(() => {
|
|
if (meta !== at) return;
|
|
showSatellite();
|
|
sync();
|
|
map.redrawBase();
|
|
})
|
|
.catch((error: unknown) => {
|
|
const text = error instanceof Error ? error.message : String(error);
|
|
for (const def of LAYERS)
|
|
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
|
|
on[def.key] = false;
|
|
sync();
|
|
map.redraw();
|
|
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
|
|
})
|
|
.finally(() => pending.delete(slot));
|
|
pending.set(slot, 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);
|
|
const pick = (
|
|
collection: GeoJsonCollection,
|
|
test: (props: Record<string, unknown>) => boolean,
|
|
): GeoJsonCollection => ({
|
|
...collection,
|
|
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
|
});
|
|
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 = indexLayerLod(prepare(collection, ["등고수치"]));
|
|
} else if (key === "stream") {
|
|
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
|
projectId,
|
|
"도엽_하천중심선",
|
|
);
|
|
data.stream = [
|
|
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
|
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
|
];
|
|
} else if (key === "cadastral") {
|
|
data.cadastral = prepare(await fetchPlanCadastral(projectId));
|
|
} else if (key === "road" || key === "forestRoad") {
|
|
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
|
|
data.road = {
|
|
all: prepare(collection),
|
|
small: prepare(
|
|
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
|
|
),
|
|
};
|
|
} else if (key === "slope" || key === "hillshade") {
|
|
const raster = await fetchPlanRaster(projectId, key);
|
|
data[key] = { raster, image: await loadImage(raster.image) };
|
|
} else if (key === "water") {
|
|
const parts = await Promise.all(
|
|
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
|
|
);
|
|
data.water = prepare({ features: parts.flatMap((part) => part.features ?? []) });
|
|
} else if (key === "building") {
|
|
data.building = prepare(await fetchPlanSheetLayer(projectId, "건물"));
|
|
} else if (key === "zones") {
|
|
const zones = await fetchPlanZones(projectId);
|
|
const kinds = new Map<string, PreparedLayer>();
|
|
for (const kind of Object.keys(zones.summary))
|
|
kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind)));
|
|
data.zones = { kinds, summary: zones.summary };
|
|
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
|
|
if (failed.length)
|
|
showToast(
|
|
`보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`,
|
|
"error",
|
|
);
|
|
} else if (key === "soils") {
|
|
const soils = await fetchPlanSoils(projectId);
|
|
data.soils = {
|
|
kinds: soils.summary.map((soil, i) => ({
|
|
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
|
|
color: SOIL_COLORS[i % SOIL_COLORS.length],
|
|
})),
|
|
summary: soils.summary,
|
|
};
|
|
} else if (key === "bands") {
|
|
const info = await fetchPlanBands(projectId);
|
|
data.bands = {
|
|
stream: prepare(info.stream.collection),
|
|
road: prepare(info.road.collection),
|
|
info,
|
|
};
|
|
} else {
|
|
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
|
data.place = (collection.features ?? []).flatMap((feature) => {
|
|
const geometry = feature.geometry;
|
|
const name = feature.properties?.["명칭"];
|
|
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
|
|
const [lon, lat] = geometry.coordinates as number[];
|
|
return [{ lon, lat, name }];
|
|
});
|
|
}
|
|
}
|
|
|
|
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 drawRaster = (
|
|
context: CanvasRenderingContext2D,
|
|
map: ScreenMap,
|
|
raster: { raster: PlanRaster; image: HTMLImageElement },
|
|
): void => {
|
|
const [x0, y0, x1, y1] = raster.raster.box_m;
|
|
const [left, top] = map.toScreen([x0, y1]);
|
|
const [right, bottom] = map.toScreen([x1, y0]);
|
|
context.drawImage(raster.image, left, top, right - left, bottom - top);
|
|
};
|
|
|
|
/** 등고 글 폭 기억(글꼴 하나 · 582). */
|
|
const labelWidths = new Map<string, number>();
|
|
const layers: PlanMapLayer[] = [
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.hillshade || !data.hillshade) return;
|
|
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
|
|
if (on.satellite && satellite) {
|
|
context.globalCompositeOperation = "multiply";
|
|
context.globalAlpha = 0.75;
|
|
}
|
|
drawRaster(context, map, data.hillshade);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.slope || !data.slope) return;
|
|
context.globalAlpha = 0.55;
|
|
drawRaster(context, map, data.slope);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.soils || !data.soils) return;
|
|
for (const { layer, color } of data.soils.kinds) {
|
|
const fill = themeColor(color, "#facc15");
|
|
fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 });
|
|
}
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.zones || !data.zones) return;
|
|
for (const [kind, layer] of data.zones.kinds) {
|
|
const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308");
|
|
fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 });
|
|
}
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.bands || !data.bands) return;
|
|
const road = themeColor("--draw-map-band-road", "#facc15");
|
|
const stream = themeColor("--draw-map-band-stream", "#22d3ee");
|
|
fillLayer(context, data.bands.road, map.view, road, 0.16, {
|
|
color: road,
|
|
width: 1.2,
|
|
dash: [6, 4],
|
|
});
|
|
fillLayer(context, data.bands.stream, map.view, stream, 0.18, {
|
|
color: stream,
|
|
width: 1.2,
|
|
dash: [6, 4],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
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);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.water || !data.water) return;
|
|
const color = themeColor("--draw-map-water", "#0ea5e9");
|
|
fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 });
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.building || !data.building) return;
|
|
const color = themeColor("--draw-map-building", "#fb7185");
|
|
fillLayer(context, data.building, map.view, color, 0.7, {
|
|
color: "rgba(0, 0, 0, 0.55)",
|
|
width: 0.6,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
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)",
|
|
labelWidths,
|
|
);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.road || !data.road) return;
|
|
stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2);
|
|
stroke(context, map, data.road.all, themeColor("--draw-map-road", "#f8fafc"), 1.6);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.forestRoad || !data.road) return;
|
|
context.setLineDash([7, 4]);
|
|
stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6);
|
|
stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2);
|
|
context.setLineDash([]);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
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);
|
|
},
|
|
},
|
|
{
|
|
base: true,
|
|
draw: (context, map) => {
|
|
if (!on.place || !data.place || !meta) return;
|
|
const normalizer = createNormalizer(meta);
|
|
context.font = "600 11px system-ui, sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "bottom";
|
|
context.lineWidth = 3;
|
|
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
|
|
context.fillStyle = themeColor("--draw-map-place", "#f8fafc");
|
|
for (const place of data.place) {
|
|
const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat);
|
|
if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue;
|
|
map.upright(context, x, y, () => {
|
|
context.beginPath();
|
|
context.arc(x, y, 2.5, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.strokeText(place.name, x, y - 4);
|
|
context.fillText(place.name, x, y - 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 DataKey];
|
|
}
|
|
map.update({ meta });
|
|
showSatellite();
|
|
sync();
|
|
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
|
}
|
|
|
|
const counts = (): Record<string, number> => ({
|
|
...(data.building && { building: data.building.features.length }),
|
|
...(data.water && { water: data.water.features.length }),
|
|
...(data.road && {
|
|
road: data.road.all.features.length,
|
|
forestRoad: data.road.small.features.length,
|
|
}),
|
|
...(data.place && { place: data.place.length }),
|
|
...(data.zones &&
|
|
Object.fromEntries(
|
|
Object.entries(data.zones.summary).flatMap(([kind, value]) => [
|
|
[`zone_${kind}`, value.count],
|
|
[`zone_${kind}_m2`, value.area_m2],
|
|
]),
|
|
)),
|
|
...(data.soils &&
|
|
Object.fromEntries(
|
|
data.soils.summary.flatMap((soil) => [
|
|
[`soil_${soil.code}`, soil.count],
|
|
[`soil_${soil.code}_m2`, soil.area_m2],
|
|
]),
|
|
)),
|
|
...(data.bands && {
|
|
band_stream_m2: data.bands.info.stream.area_m2,
|
|
band_road_m2: data.bands.info.road.area_m2,
|
|
}),
|
|
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
|
|
});
|
|
|
|
return {
|
|
bar,
|
|
legend,
|
|
layers,
|
|
load,
|
|
state: () => ({
|
|
on: { ...on },
|
|
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
|
|
counts: counts(),
|
|
}),
|
|
};
|
|
}
|