项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

405 行
12 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/**
* 视口感知工具:遮挡检测 + 条件性平移。
*
* 真机 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);
}