Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View 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>
);
}