248 lines
8.5 KiB
TypeScript
248 lines
8.5 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { EnvironmentOutlined, ExportOutlined } from '@ant-design/icons';
|
|
import { Button, Space, Tag, Typography } from 'antd';
|
|
import './EmbeddedMapUI.css';
|
|
|
|
const { Paragraph, Text, Title } = Typography;
|
|
|
|
export type EmbeddedMapUIProps = {
|
|
latitude: number;
|
|
longitude: number;
|
|
zoom?: number;
|
|
height?: number;
|
|
title?: string;
|
|
address?: string;
|
|
description?: string;
|
|
markerLabel?: string;
|
|
className?: string;
|
|
radiusMeters?: number;
|
|
lockViewport?: boolean;
|
|
secondaryMarker?: {
|
|
latitude: number;
|
|
longitude: number;
|
|
label?: string;
|
|
} | null;
|
|
overlay?: ReactNode;
|
|
};
|
|
|
|
type Bounds = {
|
|
minLatitude: number;
|
|
maxLatitude: number;
|
|
minLongitude: number;
|
|
maxLongitude: number;
|
|
};
|
|
|
|
function clampZoom(zoom: number) {
|
|
return Math.min(18, Math.max(8, zoom));
|
|
}
|
|
|
|
function metersToLatitudeDegrees(meters: number) {
|
|
return meters / 111320;
|
|
}
|
|
|
|
function metersToLongitudeDegrees(meters: number, latitude: number) {
|
|
const safeLatitude = Math.max(-89.9999, Math.min(89.9999, latitude));
|
|
const denominator = 111320 * Math.cos((safeLatitude * Math.PI) / 180);
|
|
return meters / Math.max(denominator, 0.000001);
|
|
}
|
|
|
|
function createViewUrl(latitude: number, longitude: number, zoom: number) {
|
|
const params = new URLSearchParams({
|
|
mlon: String(longitude),
|
|
mlat: String(latitude),
|
|
});
|
|
|
|
return `https://www.openstreetmap.org/?${params.toString()}#map=${clampZoom(zoom)}/${latitude}/${longitude}`;
|
|
}
|
|
|
|
function createBounds(
|
|
latitude: number,
|
|
longitude: number,
|
|
radiusMeters: number | undefined,
|
|
secondaryMarker: EmbeddedMapUIProps['secondaryMarker'],
|
|
): Bounds {
|
|
const coverageMeters = Math.max(radiusMeters ?? 0, secondaryMarker ? 180 : 120, 120) * 1.8;
|
|
const latitudePadding = metersToLatitudeDegrees(coverageMeters);
|
|
const longitudePadding = metersToLongitudeDegrees(coverageMeters, latitude);
|
|
|
|
let minLatitude = latitude - latitudePadding;
|
|
let maxLatitude = latitude + latitudePadding;
|
|
let minLongitude = longitude - longitudePadding;
|
|
let maxLongitude = longitude + longitudePadding;
|
|
|
|
if (secondaryMarker) {
|
|
const extraLatitudePadding = metersToLatitudeDegrees(coverageMeters * 0.35);
|
|
const extraLongitudePadding = metersToLongitudeDegrees(
|
|
coverageMeters * 0.35,
|
|
(latitude + secondaryMarker.latitude) / 2,
|
|
);
|
|
|
|
minLatitude = Math.min(minLatitude, secondaryMarker.latitude - extraLatitudePadding);
|
|
maxLatitude = Math.max(maxLatitude, secondaryMarker.latitude + extraLatitudePadding);
|
|
minLongitude = Math.min(minLongitude, secondaryMarker.longitude - extraLongitudePadding);
|
|
maxLongitude = Math.max(maxLongitude, secondaryMarker.longitude + extraLongitudePadding);
|
|
}
|
|
|
|
return {
|
|
minLatitude,
|
|
maxLatitude,
|
|
minLongitude,
|
|
maxLongitude,
|
|
};
|
|
}
|
|
|
|
function createEmbedUrl(bounds: Bounds, latitude: number, longitude: number) {
|
|
const params = new URLSearchParams({
|
|
bbox: [
|
|
bounds.minLongitude,
|
|
bounds.minLatitude,
|
|
bounds.maxLongitude,
|
|
bounds.maxLatitude,
|
|
].join(','),
|
|
layer: 'mapnik',
|
|
marker: `${latitude},${longitude}`,
|
|
});
|
|
|
|
return `https://www.openstreetmap.org/export/embed.html?${params.toString()}`;
|
|
}
|
|
|
|
function projectPoint(latitude: number, longitude: number, bounds: Bounds) {
|
|
const xRatio =
|
|
(longitude - bounds.minLongitude) / Math.max(bounds.maxLongitude - bounds.minLongitude, 0.000001);
|
|
const yRatio =
|
|
(bounds.maxLatitude - latitude) / Math.max(bounds.maxLatitude - bounds.minLatitude, 0.000001);
|
|
|
|
return {
|
|
x: Math.min(1, Math.max(0, xRatio)) * 100,
|
|
y: Math.min(1, Math.max(0, yRatio)) * 100,
|
|
};
|
|
}
|
|
|
|
function calculateCircleSizePercent(radiusMeters: number, bounds: Bounds, latitude: number) {
|
|
const diameterLongitudeDegrees = metersToLongitudeDegrees(radiusMeters * 2, latitude);
|
|
return (diameterLongitudeDegrees / Math.max(bounds.maxLongitude - bounds.minLongitude, 0.000001)) * 100;
|
|
}
|
|
|
|
export function EmbeddedMapUI({
|
|
latitude,
|
|
longitude,
|
|
zoom = 15,
|
|
height = 360,
|
|
title = '현장 위치',
|
|
address,
|
|
description = '모바일과 데스크톱에서 바로 확인할 수 있는 내장 지도입니다.',
|
|
markerLabel = '현장',
|
|
className,
|
|
radiusMeters,
|
|
lockViewport = false,
|
|
secondaryMarker,
|
|
overlay,
|
|
}: EmbeddedMapUIProps) {
|
|
const resolvedZoom = clampZoom(zoom);
|
|
const viewUrl = createViewUrl(latitude, longitude, resolvedZoom);
|
|
const bounds = createBounds(latitude, longitude, radiusMeters, secondaryMarker);
|
|
const embedUrl = createEmbedUrl(bounds, latitude, longitude);
|
|
const primaryPoint = projectPoint(latitude, longitude, bounds);
|
|
const secondaryPoint = secondaryMarker
|
|
? projectPoint(secondaryMarker.latitude, secondaryMarker.longitude, bounds)
|
|
: null;
|
|
const radiusPercent =
|
|
radiusMeters && radiusMeters > 0 ? calculateCircleSizePercent(radiusMeters, bounds, latitude) : null;
|
|
|
|
return (
|
|
<div className={['embedded-map-ui', className].filter(Boolean).join(' ')}>
|
|
<div className="embedded-map-ui__header">
|
|
<div className="embedded-map-ui__copy">
|
|
<Space size={8} wrap>
|
|
<Tag color="cyan" icon={<EnvironmentOutlined />}>
|
|
{markerLabel}
|
|
</Tag>
|
|
<Text type="secondary">{`${latitude.toFixed(5)}, ${longitude.toFixed(5)}`}</Text>
|
|
</Space>
|
|
<Title level={4}>{title}</Title>
|
|
<Paragraph>{description}</Paragraph>
|
|
{address ? <Text type="secondary">{address}</Text> : null}
|
|
</div>
|
|
<Button href={viewUrl} target="_blank" rel="noreferrer" icon={<ExportOutlined />}>
|
|
외부 지도로 열기
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="embedded-map-ui__frame" style={{ height }}>
|
|
<iframe
|
|
className={[
|
|
'embedded-map-ui__canvas',
|
|
lockViewport ? 'embedded-map-ui__canvas--locked' : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
src={embedUrl}
|
|
title={title}
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer-when-downgrade"
|
|
/>
|
|
<div className="embedded-map-ui__overlay">
|
|
<svg
|
|
className="embedded-map-ui__overlay-svg"
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
aria-hidden="true"
|
|
>
|
|
{radiusPercent ? (
|
|
<circle
|
|
className="embedded-map-ui__radius"
|
|
cx={primaryPoint.x}
|
|
cy={primaryPoint.y}
|
|
r={Math.max(2, radiusPercent / 2)}
|
|
/>
|
|
) : null}
|
|
{secondaryPoint ? (
|
|
<>
|
|
<line
|
|
className="embedded-map-ui__link"
|
|
x1={primaryPoint.x}
|
|
y1={primaryPoint.y}
|
|
x2={secondaryPoint.x}
|
|
y2={secondaryPoint.y}
|
|
/>
|
|
<circle className="embedded-map-ui__marker embedded-map-ui__marker--secondary" cx={secondaryPoint.x} cy={secondaryPoint.y} r="1.8" />
|
|
</>
|
|
) : null}
|
|
<circle className="embedded-map-ui__marker embedded-map-ui__marker--primary" cx={primaryPoint.x} cy={primaryPoint.y} r="2.2" />
|
|
</svg>
|
|
<div
|
|
className="embedded-map-ui__label embedded-map-ui__label--primary"
|
|
style={{ left: `${primaryPoint.x}%`, top: `${primaryPoint.y}%` }}
|
|
>
|
|
{markerLabel}
|
|
</div>
|
|
{secondaryPoint ? (
|
|
<div
|
|
className="embedded-map-ui__label embedded-map-ui__label--secondary"
|
|
style={{ left: `${secondaryPoint.x}%`, top: `${secondaryPoint.y}%` }}
|
|
>
|
|
{secondaryMarker?.label ?? '보조 위치'}
|
|
</div>
|
|
) : null}
|
|
{lockViewport ? (
|
|
<div className="embedded-map-ui__lock-badge">반경 오버레이 고정</div>
|
|
) : null}
|
|
{overlay ? <div className="embedded-map-ui__slot">{overlay}</div> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="embedded-map-ui__footer">
|
|
<div className="embedded-map-ui__meta">
|
|
<Tag bordered={false}>Zoom {resolvedZoom}</Tag>
|
|
<Tag bordered={false}>Radius {radiusMeters ? `${Math.round(radiusMeters)}m` : 'Off'}</Tag>
|
|
<Tag bordered={false}>{lockViewport ? 'Viewport Locked' : 'Viewport Free'}</Tag>
|
|
<Tag bordered={false}>Embed Map</Tag>
|
|
</div>
|
|
<Text type="secondary">
|
|
OpenStreetMap 임베드와 좌표 오버레이로 앱 내부에서 위치를 확인합니다. 반경 오버레이 사용 시 지도 이동은 잠금 처리합니다.
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|