Initial import
This commit is contained in:
247
src/components/embeddedMap/EmbeddedMapUI.tsx
Executable file
247
src/components/embeddedMap/EmbeddedMapUI.tsx
Executable file
@@ -0,0 +1,247 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user