wing-ops/frontend/src/common/components/map/measureLayers.ts
leedano 301df70376 feat(map): 거리·면적 측정 도구 구현
TopBar 퀵메뉴에서 거리/면적 측정 모드 토글, MapView에서 클릭으로
포인트 수집 후 deck.gl 레이어로 결과를 시각화한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:52:27 +09:00

158 lines
4.8 KiB
TypeScript

import { ScatterplotLayer, PathLayer, TextLayer, PolygonLayer } from '@deck.gl/layers';
import type { Layer as DeckLayer } from '@deck.gl/core';
import type { MeasurePoint, MeasureResult } from '../../store/mapStore';
import { formatDistance, formatArea } from '../../utils/geo';
const CYAN = [6, 182, 212, 220] as const;
const CYAN_FILL = [6, 182, 212, 60] as const;
const WHITE = [255, 255, 255, 255] as const;
function midpoint(a: MeasurePoint, b: MeasurePoint): [number, number] {
return [(a.lon + b.lon) / 2, (a.lat + b.lat) / 2];
}
export function centroid(pts: MeasurePoint[]): [number, number] {
const n = pts.length;
return [
pts.reduce((s, p) => s + p.lon, 0) / n,
pts.reduce((s, p) => s + p.lat, 0) / n,
];
}
function toPos(pt: MeasurePoint): [number, number] {
return [pt.lon, pt.lat];
}
export function midpointOf(a: MeasurePoint, b: MeasurePoint): [number, number] {
return midpoint(a, b);
}
export function buildMeasureLayers(
measureInProgress: MeasurePoint[],
measureMode: 'distance' | 'area' | null,
measurements: MeasureResult[],
): DeckLayer[] {
const layers: DeckLayer[] = [];
// 진행 중인 점들
if (measureInProgress.length > 0) {
layers.push(
new ScatterplotLayer({
id: 'measure-in-progress-points',
data: measureInProgress,
getPosition: (d: MeasurePoint) => toPos(d),
getRadius: 6,
getFillColor: [...CYAN],
getLineColor: [...WHITE],
getLineWidth: 2,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
stroked: true,
}),
);
if (measureInProgress.length >= 2) {
layers.push(
new PathLayer({
id: 'measure-in-progress-path',
data: [{ path: measureInProgress.map(toPos) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [...CYAN],
getWidth: 2,
widthUnits: 'pixels',
}),
);
}
// 면적 모드: 첫 점으로 돌아가는 점선 미리보기
if (measureMode === 'area' && measureInProgress.length >= 3) {
const first = measureInProgress[0];
const last = measureInProgress[measureInProgress.length - 1];
layers.push(
new PathLayer({
id: 'measure-area-close-preview',
data: [{ path: [toPos(last), toPos(first)] }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [6, 182, 212, 100],
getWidth: 2,
widthUnits: 'pixels',
}),
);
}
}
// 완료된 측정 결과들
for (const m of measurements) {
if (m.mode === 'distance') {
layers.push(
new PathLayer({
id: `${m.id}-line`,
data: [{ path: m.points.map(toPos) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [...CYAN],
getWidth: 3,
widthUnits: 'pixels',
}),
new ScatterplotLayer({
id: `${m.id}-points`,
data: m.points,
getPosition: (d: MeasurePoint) => toPos(d),
getRadius: 6,
getFillColor: [...CYAN],
getLineColor: [...WHITE],
getLineWidth: 2,
radiusUnits: 'pixels',
lineWidthUnits: 'pixels',
stroked: true,
}),
new TextLayer({
id: `${m.id}-label`,
data: [{ position: midpoint(m.points[0], m.points[1]), text: formatDistance(m.value) }],
getPosition: (d: { position: [number, number] }) => d.position,
getText: (d: { text: string }) => d.text,
getSize: 14,
getColor: [255, 255, 255, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Pretendard, sans-serif',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: true,
}),
);
} else {
layers.push(
new PolygonLayer({
id: `${m.id}-polygon`,
data: [{ polygon: m.points.map(toPos) }],
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
getFillColor: [...CYAN_FILL],
getLineColor: [...CYAN],
getLineWidth: 2,
lineWidthUnits: 'pixels',
stroked: true,
filled: true,
}),
new TextLayer({
id: `${m.id}-label`,
data: [{ position: centroid(m.points), text: formatArea(m.value) }],
getPosition: (d: { position: [number, number] }) => d.position,
getText: (d: { text: string }) => d.text,
getSize: 14,
getColor: [255, 255, 255, 255],
getTextAnchor: 'middle',
getAlignmentBaseline: 'center',
fontFamily: 'Pretendard, sans-serif',
fontWeight: 700,
outlineWidth: 3,
outlineColor: [0, 0, 0, 200],
billboard: true,
}),
);
}
}
return layers;
}