Files
Aislo/B04_PreProcess/B04_PreProcess_UI_MapRender.ts
eomsangdonandClaude Opus 5 4e4bfa2354 feat(B02/B03/B04/B05): 계획노선 사용 범위 · 절단 여유 3m · 배수유역도 줌·측점 표기
- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도
  변경. projects.route_start_m·route_end_m 신설(015_route_range.sql).
  load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버
  양쪽에서 차단. 비우면 전 구간으로 종전과 같음.
- 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M).
- B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지).
  4배를 넘으면 배경 그림 흐림 보간 해제.
- 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고,
  되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 19:08:11 +09:00

620 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
// 2D 지도 벡터 레이어 렌더 엔진.
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
// 매 프레임에는 뷰포트·scale·offset을 합친 어파인 변환만 적용한다.
// 정규화 좌표라 뷰포트 리사이즈 시에도 재투영이 필요 없다. 원본 GeoJSON은 변형하지 않는다.
export type GeoJsonGeometry = {
type: string;
coordinates: unknown;
};
export type GeoJsonFeature = {
geometry?: GeoJsonGeometry | null;
properties?: Record<string, unknown> | null;
};
export type GeoJsonCollection = {
features?: GeoJsonFeature[];
};
export type MarkerKind = "dot" | "x";
/** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */
/** 배경 위에서 선·글자가 묻히지 않게 뒤에 까는 흰 테두리. */
export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)");
/**
* 계획선(노선) 표기 색 — B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다.
* 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다.
* 색 값 자체는 `ui_template_theme.css`의 `--map-route`가 유일한 정의처다.
*/
export const routeLineColor = (): string => themeColor("--map-route", "#f97316");
/** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */
export const ROUTE_LINE_WIDTH = 2.4;
/**
* 사전 투영된 하나의 파트(선/링/점 묶음). 좌표는 정규화 맵 좌표(0~1) x,y 교차 배열.
* weights: Douglas-Peucker 가중치(정점 제거 시 발생하는 최대 오차, 종횡비 보정 좌표계).
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
minY: number;
maxX: number;
maxY: number;
/** 등고 라벨 앵커(정규화 좌표). 라벨 대상이 아니면 labelText가 null. */
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
};
export type PreparedLayer = {
features: PreparedFeature[];
};
/** lon/lat → 정규화 맵 좌표 변환 계수. meta에만 의존한다. aspect는 지도 종횡비(w/h). */
export type Normalizer = {
lonMin: number;
latMin: number;
lonRange: number;
latRange: number;
aspect: number;
};
/** 뷰포트 안에서 지도 이미지가 차지하는 사각형(기존 getMapRect와 동일 계산). */
export type MapRect = {
x: number;
y: number;
width: number;
height: number;
};
/** 프레임 단위 뷰 상태. */
export type ViewState = {
width: number;
height: number;
scale: number;
offsetX: number;
offsetY: number;
mapRect: MapRect;
};
export function createNormalizer(meta: VWorldMeta): Normalizer {
return {
lonMin: meta.lon_min,
latMin: meta.lat_min,
lonRange: meta.lon_max - meta.lon_min || 1,
latRange: meta.lat_max - meta.lat_min || 1,
aspect: meta.width_meters / Math.max(meta.height_meters, 1),
};
}
export function computeMapRect(meta: VWorldMeta | null, width: number, height: number): MapRect {
if (!meta) return { x: 0, y: 0, width, height };
const mapRatio = meta.width_meters / Math.max(meta.height_meters, 1);
const viewportRatio = width / Math.max(height, 1);
const mapWidth = mapRatio > viewportRatio ? width : height * mapRatio;
const mapHeight = mapRatio > viewportRatio ? width / mapRatio : height;
return {
x: (width - mapWidth) / 2,
y: (height - mapHeight) / 2,
width: mapWidth,
height: mapHeight,
};
}
/** 계획도로 주변으로 보여줄 여유 거리(m). B04 하단 지도와 B05 배수유역도가 같은 값을 쓴다
* (2026-08-01 사용자 지시: 도로 중심을 화면 중앙에, 도로 전체 + 200m까지). */
export const ROUTE_VIEW_MARGIN_M = 200;
/**
* 사업지 미터 좌표를 화면 좌표로 옮기는 변환기 (B04 지도·B05 배수유역도 공용).
*
* 배수유역도에서 쓰던 것을 여기로 옮겼다 — 두 화면이 같은 자리에 측점 눈금을 찍어야 한다
* (2026-09-04). `pxPerMeter` 는 라벨 솎기·축척 계산에 쓴다.
*/
export function createMetricProjector(
meta: VWorldMeta,
view: ViewState,
): { toScreen: (x: number, y: number) => [number, number]; pxPerMeter: number } {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
const ax = view.mapRect.width * view.scale;
const bx = view.mapRect.x * view.scale + (view.width / 2) * (1 - view.scale) + view.offsetX;
const ay = view.mapRect.height * view.scale;
const by = view.mapRect.y * view.scale + (view.height / 2) * (1 - view.scale) + view.offsetY;
return {
toScreen: (x, y) => [
((x - meta.x_min) / spanX) * ax + bx,
(1 - (y - meta.y_min) / spanY) * ay + by,
],
pxPerMeter: ax / spanX,
};
}
/** 규칙 측점 간격(m) — 종단 패널과 같은 20m 고정. 지도·배수유역도 눈금 표기 기준(2026-09-04). */
export const MAP_STATION_INTERVAL_M = 20;
/** 최대 확대에서 화면 폭에 들어올 실거리(m) — 규칙 측점 20m 기준 1~2측점
* (2026-09-04 사용자 지시). 고정 배율(8배·16배)로는 도엽 크기마다 체감이 달라진다. */
export const MAX_ZOOM_VIEW_WIDTH_M = 20;
/** 배율 상한의 안전장치 — 도엽 메타가 이상해도 여기서 멈춘다. */
export const ZOOM_SCALE_HARD_CAP = 2000;
/**
* 「화면 폭이 `MAX_ZOOM_VIEW_WIDTH_M` 가 될 때까지」에 해당하는 배율 상한을 구한다.
*
* 배율 1에서 도엽 실폭(`meta.width_meters`)이 지도 사각형 폭(px)을 채우므로,
* 화면 폭(px)에 들어오는 실거리 = width_meters × viewportWidth / (mapRect.width × scale) 이다.
* 이것을 20m 로 놓고 scale 을 푼다. 메타가 없으면 종전 고정값으로 되돌아간다.
*/
export function computeMaxScale(
meta: VWorldMeta | null,
mapRectWidth: number,
viewportWidth: number,
fallback: number,
): number {
if (!meta || mapRectWidth <= 0 || viewportWidth <= 0) return fallback;
const scale = (meta.width_meters * viewportWidth) / (mapRectWidth * MAX_ZOOM_VIEW_WIDTH_M);
return Math.min(ZOOM_SCALE_HARD_CAP, Math.max(fallback, scale));
}
/** 평면 좌표(m) 범위. */
export interface PlanBounds {
x_min: number;
x_max: number;
y_min: number;
y_max: number;
}
/**
* 도로 전체 + 여유 거리가 화면에 들어오도록 배율·이동량을 구한다(도로 중심이 화면 중앙).
*
* 지도 초기 화면의 유일한 정의처 — B04 하단 지도와 B05 배수유역도가 함께 쓴다.
* 배경 지도보다 넓은 범위를 요구하면 배경 크기에 맞춰 멈춘다(빈 여백을 만들지 않는다).
*/
export function computeRouteView(
meta: VWorldMeta | null,
route: PlanBounds | null,
viewportWidth: number,
viewportHeight: number,
marginM: number = ROUTE_VIEW_MARGIN_M,
): { scale: number; offsetX: number; offsetY: number } {
if (!meta || !route) return { scale: 1, offsetX: 0, offsetY: 0 };
const mapRect = computeMapRect(meta, viewportWidth, viewportHeight);
const wantWidth = Math.max(route.x_max - route.x_min, 1) + marginM * 2;
const wantHeight = Math.max(route.y_max - route.y_min, 1) + marginM * 2;
const scale = Math.max(
Math.min(meta.width_meters / wantWidth, meta.height_meters / wantHeight),
1,
);
const centerX = (route.x_min + route.x_max) / 2;
const centerY = (route.y_min + route.y_max) / 2;
const baseX = mapRect.x + ((centerX - meta.x_min) / meta.width_meters) * mapRect.width;
const baseY = mapRect.y + (1 - (centerY - meta.y_min) / meta.height_meters) * mapRect.height;
return {
scale,
offsetX: -(baseX - viewportWidth / 2) * scale,
offsetY: -(baseY - viewportHeight / 2) * scale,
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
}
/**
* 프레임당 1회 계산하는 어파인 계수.
* base = mapRect.x + norm * mapRect.width
* screen = center + (base - center) * scale + offset
* = norm * (mapRect.width * scale) + (mapRect.x * scale + center * (1 - scale) + offset)
*/
type Affine = { ax: number; bx: number; ay: number; by: number };
function affineOf(view: ViewState): Affine {
const centerX = view.width / 2;
const centerY = view.height / 2;
return {
ax: view.mapRect.width * view.scale,
bx: view.mapRect.x * view.scale + centerX * (1 - view.scale) + view.offsetX,
ay: view.mapRect.height * view.scale,
by: view.mapRect.y * view.scale + centerY * (1 - view.scale) + view.offsetY,
};
}
/** 정규화 좌표(0~1) → 화면 px. 프레임마다 여러 번 부를 것이라면 `affineOf`를 한 번 잡아 쓴다. */
export function normalizedToScreen(
view: ViewState,
nx: number,
ny: number,
): [x: number, y: number] {
const affine = affineOf(view);
return [nx * affine.ax + affine.bx, ny * affine.ay + affine.by];
}
/** lon/lat → 화면 px. 마커를 찍거나 외곽선을 그릴 때 쓴다. */
export function lonLatToScreen(
normalizer: Normalizer,
view: ViewState,
lon: number,
lat: number,
): [x: number, y: number] {
return normalizedToScreen(
view,
(lon - normalizer.lonMin) / normalizer.lonRange,
1 - (lat - normalizer.latMin) / normalizer.latRange,
);
}
/**
* 사업지 좌표계(m) → 화면 px. `prepareMetricPolyline`과 **같은 가정**을 쓴다 —
* meta의 x/y 범위와 lon/lat 범위가 같은 사각형을 가리킨다는 것.
*/
export function metricToScreen(
meta: VWorldMeta,
view: ViewState,
x: number,
y: number,
): [x: number, y: number] {
const spanX = meta.width_meters || 1;
const spanY = meta.height_meters || 1;
return normalizedToScreen(view, (x - meta.x_min) / spanX, 1 - (y - meta.y_min) / spanY);
}
/** 시각적 무손실 LOD 허용 오차(화면 px). 이보다 작은 오차의 정점만 생략된다. */
const LOD_PX = 0.75;
function drawLineParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
): void {
// affine.ax = 정규화 1.0당 화면 px — 종횡비 보정 좌표계의 거리를 px로 바꾸는 계수.
const tolerance = LOD_PX / affine.ax;
for (const part of feature.parts) {
const coords = part.coords;
if (coords.length < 4) continue;
const weights = part.weights;
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
context.beginPath();
let started = false;
for (let i = 0; i < coords.length; i += 2) {
if (weights && weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (started) context.lineTo(x, y);
else {
context.moveTo(x, y);
started = true;
}
}
if (!started) continue;
if (part.closed) context.closePath();
context.stroke();
}
}
function drawPointParts(
context: CanvasRenderingContext2D,
feature: PreparedFeature,
affine: Affine,
marker: MarkerKind,
): void {
for (const part of feature.parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (marker === "x") {
// 표고점: 조금 굵고 큰 X 마커
const arm = 4;
const prevWidth = context.lineWidth;
context.lineWidth = 2;
context.beginPath();
context.moveTo(x - arm, y - arm);
context.lineTo(x + arm, y + arm);
context.moveTo(x - arm, y + arm);
context.lineTo(x + arm, y - arm);
context.stroke();
context.lineWidth = prevWidth;
continue;
}
context.beginPath();
context.arc(x, y, 2, 0, Math.PI * 2);
context.fillStyle = context.strokeStyle;
context.fill();
}
}
}
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
const minX = feature.minX * affine.ax + affine.bx;
const maxX = feature.maxX * affine.ax + affine.bx;
const minY = feature.minY * affine.ay + affine.by;
const maxY = feature.maxY * affine.ay + affine.by;
return !(
maxX < -margin ||
minX > view.width + margin ||
maxY < -margin ||
minY > view.height + margin
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
): void {
const affine = affineOf(view);
for (const feature of layer.features) {
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
for (const feature of layer.features) {
if (feature.labelText === null) continue;
const x = feature.labelAnchorX * affine.ax + affine.bx;
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
context.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
}
}
/** 채움 폴리곤 오버레이(배수유역 등). 좌표는 lon/lat 링 1개. */