405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
/**
|
||
* 视口感知工具:遮挡检测 + 条件性平移。
|
||
*
|
||
* 真机 Google Maps 行为:
|
||
* - 选中地点后底部弹出 Sheet,仅当 Sheet 会遮挡标记时才移动视口
|
||
* - 移动目标:将标记居中于"剩余可见区域"(视口 − Sheet 覆盖区域)
|
||
*/
|
||
|
||
const MARGIN_RATIO = 0.04;
|
||
|
||
function getMapDiv(map: google.maps.Map): HTMLElement | null {
|
||
try {
|
||
return map.getDiv() ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getVisibleCenterYInMapCoords(map: google.maps.Map, sheetRatio: number) {
|
||
const mapH = getMapDiv(map)?.clientHeight ?? 0;
|
||
return mapH * (1 - sheetRatio) / 2;
|
||
}
|
||
|
||
function getVisibleCenterXInMapCoords(map: google.maps.Map) {
|
||
const mapW = getMapDiv(map)?.clientWidth ?? 0;
|
||
return mapW / 2;
|
||
}
|
||
|
||
function panLatLngToMapDivPoint(
|
||
map: google.maps.Map,
|
||
latLng: google.maps.LatLngLiteral,
|
||
targetX: number,
|
||
targetY: number,
|
||
): google.maps.LatLngLiteral {
|
||
const projection = map.getProjection();
|
||
const zoom = map.getZoom();
|
||
const div = getMapDiv(map);
|
||
if (!div) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
const mapW = div.clientWidth;
|
||
const mapH = div.clientHeight;
|
||
if (
|
||
!projection ||
|
||
zoom == null ||
|
||
mapW <= 0 ||
|
||
mapH <= 0 ||
|
||
targetX < 0 ||
|
||
targetY < 0
|
||
) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
const targetPoint = projection.fromLatLngToPoint(new google.maps.LatLng(latLng));
|
||
if (!targetPoint) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
const clampedTargetX = Math.max(0, Math.min(mapW, targetX));
|
||
const clampedTargetY = Math.max(0, Math.min(mapH, targetY));
|
||
const worldDeltaX = (mapW / 2 - clampedTargetX) / (2 ** zoom);
|
||
const worldDeltaY = (mapH / 2 - clampedTargetY) / (2 ** zoom);
|
||
|
||
const nextCenterPoint = new google.maps.Point(
|
||
targetPoint.x + worldDeltaX,
|
||
targetPoint.y + worldDeltaY,
|
||
);
|
||
const nextCenter = projection.fromPointToLatLng(nextCenterPoint);
|
||
if (!nextCenter) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
const center = { lat: nextCenter.lat(), lng: nextCenter.lng() };
|
||
map.panTo(center);
|
||
return center;
|
||
}
|
||
|
||
/**
|
||
* 用 LatLng + map bounds 近似判断标记是否在 Sheet 遮挡区域。
|
||
* 仅在无法获取 DOM clientY 时使用(如 Marker 组件 onClick 不暴露事件对象)。
|
||
* 在城市级别缩放下 lat→screenY 近似线性,精度足够做遮挡判断。
|
||
*/
|
||
export function isLatLngOccludedBySheet(
|
||
map: google.maps.Map,
|
||
latLng: { lat: number; lng: number },
|
||
sheetRatio: number,
|
||
): boolean {
|
||
const bounds = map.getBounds();
|
||
if (!bounds) return false;
|
||
const ne = bounds.getNorthEast();
|
||
const sw = bounds.getSouthWest();
|
||
const latRange = ne.lat() - sw.lat();
|
||
if (latRange <= 0) return false;
|
||
const sheetTopLat = sw.lat() + sheetRatio * latRange;
|
||
const margin = latRange * MARGIN_RATIO;
|
||
return latLng.lat < sheetTopLat + margin;
|
||
}
|
||
|
||
/**
|
||
* 用 DOM 事件的 clientY 判断点击位置是否会被底部 Sheet 遮挡。
|
||
* clientY 是浏览器原生屏幕坐标,不受任何 CSS zoom 影响。
|
||
*/
|
||
export function isClickOccludedBySheet(
|
||
clickClientY: number,
|
||
containerRect: DOMRect,
|
||
sheetRatio: number,
|
||
): boolean {
|
||
if (containerRect.height <= 0) return false;
|
||
const relY = clickClientY - containerRect.top;
|
||
const sheetTopScreen = containerRect.height * (1 - sheetRatio);
|
||
const margin = containerRect.height * MARGIN_RATIO;
|
||
return relY > sheetTopScreen - margin;
|
||
}
|
||
|
||
/**
|
||
* 将标记从当前位置(地图中心)平移到"剩余可见区域"的中心。
|
||
* 前提:调用前标记已在地图中心(通过 panTo 或 Google Maps 默认行为)。
|
||
*
|
||
* map.panBy() 使用的是地图 div 的内部 CSS 像素坐标系。
|
||
* App 外层可能被 OS 用 CSS zoom 缩放,但这里全程使用 map div 的
|
||
* clientWidth/clientHeight,因此不需要额外按全局视口比例换算。
|
||
*/
|
||
export function panMarkerToVisibleCenter(
|
||
map: google.maps.Map,
|
||
sheetRatio: number,
|
||
) {
|
||
const div = getMapDiv(map);
|
||
if (!div) return;
|
||
const mapH = div.clientHeight;
|
||
const mapW = div.clientWidth;
|
||
if (mapH <= 0 || mapW <= 0 || sheetRatio <= 0) return;
|
||
const visibleCenterXInMapCoords = getVisibleCenterXInMapCoords(map);
|
||
const visibleCenterInMapCoords = getVisibleCenterYInMapCoords(map, sheetRatio);
|
||
const currentMarkerXInMapCoords = mapW / 2;
|
||
const currentMarkerYInMapCoords = mapH / 2;
|
||
const deltaX = currentMarkerXInMapCoords - visibleCenterXInMapCoords;
|
||
const deltaY = currentMarkerYInMapCoords - visibleCenterInMapCoords;
|
||
map.panBy(deltaX, deltaY);
|
||
}
|
||
|
||
/**
|
||
* 已知点击点在屏幕上的坐标时,直接一次性把该点移动到剩余可见区域中心。
|
||
* 适用于地图 POI 点击、长按落点等“有原始 domEvent/clientX/clientY”的入口。
|
||
*/
|
||
export function panScreenPointToVisibleCenter(
|
||
map: google.maps.Map,
|
||
pointClientX: number,
|
||
pointClientY: number,
|
||
containerRect: DOMRect,
|
||
sheetRatio: number,
|
||
) {
|
||
const div = getMapDiv(map);
|
||
const rect = div?.getBoundingClientRect() ?? containerRect;
|
||
const mapW = div?.clientWidth ?? containerRect.width;
|
||
const mapH = div?.clientHeight ?? containerRect.height;
|
||
const screenToMapX = rect.width > 0 ? mapW / rect.width : 1;
|
||
const screenToMapY = rect.height > 0 ? mapH / rect.height : 1;
|
||
const relX = (pointClientX - rect.left) * screenToMapX;
|
||
const relY = (pointClientY - rect.top) * screenToMapY;
|
||
const targetX = mapW / 2;
|
||
const targetY = mapH * (1 - sheetRatio) / 2;
|
||
const deltaX = relX - targetX;
|
||
const deltaY = relY - targetY;
|
||
map.panBy(deltaX, deltaY);
|
||
}
|
||
|
||
export function clientPointToLatLng(
|
||
map: google.maps.Map,
|
||
pointClientX: number,
|
||
pointClientY: number,
|
||
): google.maps.LatLngLiteral | null {
|
||
const projection = map.getProjection();
|
||
const zoom = map.getZoom();
|
||
const center = map.getCenter();
|
||
const div = getMapDiv(map);
|
||
if (!projection || zoom == null || !center || !div) return null;
|
||
|
||
const rect = div.getBoundingClientRect();
|
||
const mapW = div.clientWidth;
|
||
const mapH = div.clientHeight;
|
||
if (rect.width <= 0 || rect.height <= 0 || mapW <= 0 || mapH <= 0) return null;
|
||
|
||
const screenToMapX = mapW / rect.width;
|
||
const screenToMapY = mapH / rect.height;
|
||
const relX = (pointClientX - rect.left) * screenToMapX;
|
||
const relY = (pointClientY - rect.top) * screenToMapY;
|
||
const offsetX = relX - mapW / 2;
|
||
const offsetY = relY - mapH / 2;
|
||
|
||
const centerPoint = projection.fromLatLngToPoint(center);
|
||
if (!centerPoint) return null;
|
||
|
||
const scale = 2 ** zoom;
|
||
const point = new google.maps.Point(
|
||
centerPoint.x + offsetX / scale,
|
||
centerPoint.y + offsetY / scale,
|
||
);
|
||
const latLng = projection.fromPointToLatLng(point);
|
||
return latLng ? { lat: latLng.lat(), lng: latLng.lng() } : null;
|
||
}
|
||
|
||
/**
|
||
* 已知目标地点的 LatLng 时,直接计算最终 center 并一次 panTo 到位。
|
||
* 适用于搜索结果点击、程序化选点、没有原始点击坐标的 marker 点击等入口。
|
||
*/
|
||
export function panLatLngToVisibleCenter(
|
||
map: google.maps.Map,
|
||
latLng: google.maps.LatLngLiteral,
|
||
sheetRatio: number,
|
||
): google.maps.LatLngLiteral {
|
||
const div = getMapDiv(map);
|
||
if (!div) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
const mapW = div.clientWidth;
|
||
const mapH = div.clientHeight;
|
||
if (mapW <= 0 || mapH <= 0 || sheetRatio <= 0) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
return panLatLngToMapDivPoint(
|
||
map,
|
||
latLng,
|
||
getVisibleCenterXInMapCoords(map),
|
||
getVisibleCenterYInMapCoords(map, sheetRatio),
|
||
);
|
||
}
|
||
|
||
export function panLatLngToVisibleRectCenter(
|
||
map: google.maps.Map,
|
||
latLng: google.maps.LatLngLiteral,
|
||
visibleRect: Pick<DOMRectReadOnly, 'left' | 'right' | 'top' | 'bottom'>,
|
||
): google.maps.LatLngLiteral {
|
||
const div = getMapDiv(map);
|
||
if (!div) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
const mapRect = div.getBoundingClientRect();
|
||
const mapW = div.clientWidth;
|
||
const mapH = div.clientHeight;
|
||
if (mapRect.width <= 0 || mapRect.height <= 0 || mapW <= 0 || mapH <= 0) {
|
||
map.panTo(latLng);
|
||
return latLng;
|
||
}
|
||
|
||
const targetClientX = (visibleRect.left + visibleRect.right) / 2;
|
||
const targetClientY = (visibleRect.top + visibleRect.bottom) / 2;
|
||
const targetX = (targetClientX - mapRect.left) * (mapW / mapRect.width);
|
||
const targetY = (targetClientY - mapRect.top) * (mapH / mapRect.height);
|
||
return panLatLngToMapDivPoint(map, latLng, targetX, targetY);
|
||
}
|
||
|
||
export function getMapVisibleRectAboveBottomSheet(
|
||
map: google.maps.Map,
|
||
sheetEl: HTMLElement | null,
|
||
fallbackBottomInset = 0,
|
||
): Pick<DOMRectReadOnly, 'left' | 'right' | 'top' | 'bottom'> | null {
|
||
const div = getMapDiv(map);
|
||
if (!div) return null;
|
||
|
||
const mapRect = div.getBoundingClientRect();
|
||
if (mapRect.width <= 0 || mapRect.height <= 0) return null;
|
||
|
||
let bottom = mapRect.bottom;
|
||
const sheetRect = sheetEl?.getBoundingClientRect();
|
||
const sheetOverlapsMap =
|
||
sheetRect &&
|
||
sheetRect.width > 0 &&
|
||
sheetRect.height > 0 &&
|
||
sheetRect.right > mapRect.left &&
|
||
sheetRect.left < mapRect.right &&
|
||
sheetRect.top < mapRect.bottom &&
|
||
sheetRect.bottom > mapRect.top;
|
||
|
||
if (sheetOverlapsMap) {
|
||
bottom = Math.min(bottom, Math.max(mapRect.top, sheetRect.top));
|
||
}
|
||
|
||
if (fallbackBottomInset > 0) {
|
||
const rawSheetScale =
|
||
sheetEl && sheetRect && sheetEl.offsetWidth > 0
|
||
? sheetRect.width / sheetEl.offsetWidth
|
||
: mapRect.width / div.clientWidth;
|
||
const sheetScale = Number.isFinite(rawSheetScale) && rawSheetScale > 0 ? rawSheetScale : 1;
|
||
const expectedBottom = sheetRect?.bottom ?? mapRect.bottom;
|
||
bottom = Math.min(
|
||
bottom,
|
||
Math.max(mapRect.top, expectedBottom - fallbackBottomInset * sheetScale),
|
||
);
|
||
}
|
||
|
||
if (bottom <= mapRect.top) return null;
|
||
return {
|
||
left: mapRect.left,
|
||
right: mapRect.right,
|
||
top: mapRect.top,
|
||
bottom,
|
||
};
|
||
}
|
||
|
||
interface ApplySearchResultsViewportOptions {
|
||
map: google.maps.Map;
|
||
biasCenter?: google.maps.LatLng | null;
|
||
locations: Array<google.maps.LatLng | google.maps.LatLngLiteral>;
|
||
sheetRatio?: number;
|
||
topPadding?: number;
|
||
edgePadding?: number;
|
||
maxZoom?: number;
|
||
fitCount?: number;
|
||
}
|
||
|
||
/**
|
||
* 搜索结果列表打开时的统一视口策略:
|
||
* 1. 先用前几个结果 + bias center 做 fitBounds 保留真机缩放感
|
||
* 2. 再把第一个结果校正到「去掉底部 sheet 后」的可见视口中心
|
||
*/
|
||
export function applySearchResultsViewport({
|
||
map,
|
||
biasCenter,
|
||
locations,
|
||
sheetRatio = 0.7,
|
||
topPadding = 80,
|
||
edgePadding = 20,
|
||
maxZoom = 16,
|
||
fitCount = 5,
|
||
}: ApplySearchResultsViewportOptions) {
|
||
const validLocations = locations.filter((loc) => {
|
||
const lat = typeof (loc as google.maps.LatLng).lat === 'function'
|
||
? (loc as google.maps.LatLng).lat()
|
||
: (loc as google.maps.LatLngLiteral).lat;
|
||
const lng = typeof (loc as google.maps.LatLng).lng === 'function'
|
||
? (loc as google.maps.LatLng).lng()
|
||
: (loc as google.maps.LatLngLiteral).lng;
|
||
return Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0);
|
||
});
|
||
if (!validLocations.length) return;
|
||
|
||
const bounds = new google.maps.LatLngBounds();
|
||
if (biasCenter) {
|
||
bounds.extend(biasCenter);
|
||
}
|
||
validLocations.slice(0, fitCount).forEach((loc) => {
|
||
bounds.extend(loc);
|
||
});
|
||
|
||
const containerDiv = getMapDiv(map);
|
||
if (!containerDiv) return;
|
||
const containerH = containerDiv.clientHeight;
|
||
const sheetBottomPadding = Math.round(containerH * sheetRatio);
|
||
map.fitBounds(bounds, {
|
||
top: topPadding,
|
||
bottom: sheetBottomPadding,
|
||
left: edgePadding,
|
||
right: edgePadding,
|
||
});
|
||
|
||
const firstLocation = validLocations[0];
|
||
google.maps.event.addListenerOnce(map, 'idle', () => {
|
||
const alignFirstResult = () => {
|
||
const latLng = typeof (firstLocation as google.maps.LatLng).lat === 'function'
|
||
? {
|
||
lat: (firstLocation as google.maps.LatLng).lat(),
|
||
lng: (firstLocation as google.maps.LatLng).lng(),
|
||
}
|
||
: (firstLocation as google.maps.LatLngLiteral);
|
||
panLatLngToVisibleCenter(map, latLng, sheetRatio);
|
||
};
|
||
|
||
const zoom = map.getZoom();
|
||
if (zoom != null && zoom > maxZoom) {
|
||
google.maps.event.addListenerOnce(map, 'idle', alignFirstResult);
|
||
map.setZoom(maxZoom);
|
||
return;
|
||
}
|
||
|
||
alignFirstResult();
|
||
});
|
||
}
|
||
|
||
export interface SavedViewport {
|
||
center: google.maps.LatLngLiteral;
|
||
zoom: number;
|
||
}
|
||
|
||
export function captureViewport(map: google.maps.Map): SavedViewport | null {
|
||
const center = map.getCenter();
|
||
const zoom = map.getZoom();
|
||
if (!center || zoom == null) return null;
|
||
return { center: { lat: center.lat(), lng: center.lng() }, zoom };
|
||
}
|
||
|
||
export function restoreViewport(map: google.maps.Map, saved: SavedViewport) {
|
||
map.panTo(saved.center);
|
||
map.setZoom(saved.zoom);
|
||
}
|