Files
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

459 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
pickFormattedPhoneNumber,
pickPlaceWebsite,
type GooglePlaceContactFields,
type MapPoiSnapshot,
type ShoppingItem,
} from '../types';
import type { Locale } from '@/os/locale';
import { strings } from '../res/strings';
import { stringsEn } from '../res/strings.en';
import { fromTimestamp } from '../../../os/TimeService';
export type MapStrings = Record<keyof typeof strings, string>;
export function getMapStrings(locale: Locale = 'zh-Hans'): MapStrings {
return locale === 'en'
? ({ ...strings, ...stringsEn } as MapStrings)
: (strings as MapStrings);
}
export const stripHtml = (html: string) =>
String(html || '')
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
export const parseDistanceTextToMeters = (distance: string): number | null => {
const value = String(distance || '').trim();
if (!value) return null;
const matched = value.match(/([\d.]+)\s*(公里|千米|km|米|m|mi|miles?)/i);
if (!matched) return null;
const amount = Number(matched[1]);
if (!Number.isFinite(amount)) return null;
const unit = matched[2].toLowerCase();
if (unit === '公里' || unit === '千米' || unit === 'km') return amount * 1000;
if (unit === 'mi' || unit === 'mile' || unit === 'miles') return amount * 1609.344;
return amount;
};
/** 列表/POI 等展示:≥1000m 为「x.x 公里」;1000m 以下为按 50m 粒度四舍五入;舍入到 1000m 时显示「1.0 公里」而非「1000.0 米」;结果不足 50m 时显示 50.0 米;无效/≤0 返回空串 */
export const MIN_DISPLAY_DISTANCE_METERS = 50;
export const MIN_ROUTE_DISTANCE_METERS = 10;
export function formatDistanceLabelMeters(
meters: number | undefined | null,
locale: Locale = 'zh-Hans',
): string {
const t = getMapStrings(locale);
if (meters === undefined || meters === null || !Number.isFinite(meters) || meters <= 0) {
return '';
}
if (meters >= 1000) {
return `${(meters / 1000).toFixed(1)} ${t.unit_km}`;
}
const step = MIN_DISPLAY_DISTANCE_METERS;
let rounded = Math.round(meters / step) * step;
if (rounded < step) {
rounded = step;
}
if (rounded >= 1000) {
return `${(rounded / 1000).toFixed(1)} ${t.unit_km}`;
}
return `${rounded.toFixed(1)} ${t.unit_m}`;
}
/** 路线详情/导航距离:<1000m 显示按 10m 粒度取整的整数米数;否则显示 1 位小数公里 */
export function formatRouteDistanceMeters(
meters: number | undefined | null,
locale: Locale = 'zh-Hans',
): string {
const t = getMapStrings(locale);
if (meters === undefined || meters === null || !Number.isFinite(meters) || meters <= 0) {
return '';
}
if (meters < 1000) {
const rounded = Math.max(
MIN_ROUTE_DISTANCE_METERS,
Math.round(meters / MIN_ROUTE_DISTANCE_METERS) * MIN_ROUTE_DISTANCE_METERS,
);
if (rounded < 1000) {
return `${rounded} ${t.unit_m}`;
}
return `${(rounded / 1000).toFixed(1)} ${t.unit_km}`;
}
return `${(meters / 1000).toFixed(1)} ${t.unit_km}`;
}
export const toSearchResultSnapshot = (item: ShoppingItem): Record<string, any> => ({
id: item.id,
place_id: item.id,
name: item.name,
rating: item.rating,
user_ratings_total: item.ratingCount,
distance: formatDistanceLabelMeters(item.distance),
distance_meters: item.distance,
address: item.address,
formatted_address: item.address,
category: item.category,
types: item.types,
primary_type: item.primaryType,
status: item.status,
lat: item.lat,
lng: item.lng,
});
export const toPoiSnapshot = (
place: (google.maps.places.PlaceResult & GooglePlaceContactFields) | null,
distanceText: string,
): MapPoiSnapshot | null => {
if (!place) return null;
return {
place_id: place.place_id,
name: place.name || '',
formatted_address: place.formatted_address || place.vicinity || '',
address: place.formatted_address || place.vicinity || '',
formatted_phone_number: pickFormattedPhoneNumber(place),
website: pickPlaceWebsite(place),
rating: place.rating,
user_ratings_total: place.user_ratings_total,
business_status: place.business_status,
types: place.types || [],
distance: distanceText || '',
distance_meters: parseDistanceTextToMeters(distanceText),
};
};
export const toJudgeTravelMode = (mode: 'driving' | 'transit' | 'walking' | 'cycling'): string => {
if (mode === 'walking') return 'WALKING';
if (mode === 'cycling') return 'BICYCLING';
if (mode === 'transit') return 'TRANSIT';
return 'DRIVING';
};
export const PLACE_ABOUT_FIELDS: string[] = [
'displayName',
'formattedAddress',
'location',
'rating',
'userRatingCount',
'businessStatus',
'types',
'primaryType',
'primaryTypeDisplayName',
'internationalPhoneNumber',
'nationalPhoneNumber',
'websiteURI',
'regularOpeningHours',
'currentOpeningHours',
'editorialSummary',
'plusCode',
'accessibilityOptions',
'paymentOptions',
'parkingOptions',
'hasDineIn',
'hasTakeout',
'hasDelivery',
'isReservable',
'servesBreakfast',
'servesLunch',
'servesDinner',
'servesBrunch',
'servesBeer',
'servesWine',
'servesCocktails',
'servesCoffee',
'servesDessert',
'servesVegetarianFood',
'hasRestroom',
'allowsDogs',
'hasOutdoorSeating',
'hasLiveMusic',
'isGoodForWatchingSports',
'isGoodForChildren',
'isGoodForGroups',
'hasMenuForChildren',
];
export type AboutItem = { label: string; value: boolean };
export type AboutSection = { title: string; items: AboutItem[] };
export function extractPlaceAboutData(p: any): Record<string, any> {
const acc = p.accessibilityOptions;
const pay = p.paymentOptions;
const park = p.parkingOptions;
return {
hasDineIn: p.hasDineIn ?? null,
hasTakeout: p.hasTakeout ?? null,
hasDelivery: p.hasDelivery ?? null,
isReservable: p.isReservable ?? null,
servesBreakfast: p.servesBreakfast ?? null,
servesLunch: p.servesLunch ?? null,
servesDinner: p.servesDinner ?? null,
servesBeer: p.servesBeer ?? null,
servesWine: p.servesWine ?? null,
servesBrunch: p.servesBrunch ?? null,
servesCocktails: p.servesCocktails ?? null,
servesCoffee: p.servesCoffee ?? null,
servesDessert: p.servesDessert ?? null,
servesVegetarianFood: p.servesVegetarianFood ?? null,
accessibility: acc
? {
wheelchairEntrance: acc.hasWheelchairAccessibleEntrance ?? acc.wheelchairAccessibleEntrance ?? null,
wheelchairParking: acc.hasWheelchairAccessibleParking ?? acc.wheelchairAccessibleParking ?? null,
wheelchairRestroom: acc.hasWheelchairAccessibleRestroom ?? acc.wheelchairAccessibleRestroom ?? null,
wheelchairSeating: acc.hasWheelchairAccessibleSeating ?? acc.wheelchairAccessibleSeating ?? null,
}
: undefined,
payment: pay
? {
creditCards: pay.acceptsCreditCards ?? null,
debitCards: pay.acceptsDebitCards ?? null,
cashOnly: pay.acceptsCashOnly ?? null,
nfc: pay.acceptsNFC ?? pay.acceptsNfc ?? null,
}
: undefined,
parking: park
? {
freeParking: park.hasFreeParkingLot ?? park.freeParkingLot ?? null,
paidParking: park.hasPaidParkingLot ?? park.paidParkingLot ?? null,
streetParking: park.hasFreeStreetParking ?? park.freeStreetParking ?? park.hasPaidStreetParking ?? park.paidStreetParking ?? null,
valetParking: park.hasValetParking ?? park.valetParking ?? null,
garageParking: park.hasFreeGarageParking ?? park.freeGarageParking ?? park.hasPaidGarageParking ?? park.paidGarageParking ?? null,
}
: undefined,
hasLiveMusic: p.hasLiveMusic ?? null,
isGoodForWatchingSports: p.isGoodForWatchingSports ?? null,
hasOutdoorSeating: p.hasOutdoorSeating ?? null,
hasRestroom: p.hasRestroom ?? null,
allowsDogs: p.allowsDogs ?? null,
isGoodForChildren: p.isGoodForChildren ?? null,
hasMenuForChildren: p.hasMenuForChildren ?? null,
isGoodForGroups: p.isGoodForGroups ?? null,
};
}
export function buildAboutSections(
about: Record<string, any> | undefined,
locale: Locale = 'zh-Hans',
): AboutSection[] {
if (!about) return [];
const t = getMapStrings(locale);
const sections: AboutSection[] = [];
const svc: AboutItem[] = [];
if (about.hasDineIn === true) svc.push({ label: t.about_dine_in, value: true });
if (about.hasTakeout === true) svc.push({ label: t.about_takeout, value: true });
if (about.hasDelivery === true) svc.push({ label: t.about_delivery, value: true });
if (about.isReservable === true) svc.push({ label: t.about_reservable, value: true });
if (about.servesBreakfast === true) svc.push({ label: t.about_serves_breakfast, value: true });
if (about.servesLunch === true) svc.push({ label: t.about_serves_lunch, value: true });
if (about.servesDinner === true) svc.push({ label: t.about_serves_dinner, value: true });
if (about.servesBrunch === true) svc.push({ label: t.about_serves_brunch, value: true });
if (about.servesCoffee === true) svc.push({ label: t.about_serves_coffee, value: true });
if (about.servesDessert === true) svc.push({ label: t.about_serves_dessert, value: true });
if (about.servesBeer === true) svc.push({ label: t.about_serves_beer, value: true });
if (about.servesWine === true) svc.push({ label: t.about_serves_wine, value: true });
if (about.servesCocktails === true) svc.push({ label: t.about_serves_cocktails, value: true });
if (about.servesVegetarianFood === true) svc.push({ label: t.about_serves_vegetarian, value: true });
if (svc.length) sections.push({ title: t.about_service_options, items: svc });
const acc = about.accessibility;
if (acc) {
const items: AboutItem[] = [];
if (acc.wheelchairEntrance === true) items.push({ label: t.about_wheelchair_entrance, value: true });
if (acc.wheelchairParking === true) items.push({ label: t.about_wheelchair_parking, value: true });
if (acc.wheelchairRestroom === true) items.push({ label: t.about_wheelchair_restroom, value: true });
if (acc.wheelchairSeating === true) items.push({ label: t.about_wheelchair_seating, value: true });
if (items.length) sections.push({ title: t.about_accessibility, items });
}
const activities: AboutItem[] = [];
if (about.hasOutdoorSeating === true) activities.push({ label: t.about_outdoor_seating, value: true });
if (about.hasLiveMusic === true) activities.push({ label: t.about_live_music, value: true });
if (about.isGoodForWatchingSports === true) activities.push({ label: t.about_good_for_sports, value: true });
if (activities.length) sections.push({ title: t.about_activities, items: activities });
const facilities: AboutItem[] = [];
if (about.hasRestroom === true) facilities.push({ label: t.about_restroom, value: true });
if (about.isGoodForGroups === true) facilities.push({ label: t.about_good_for_groups, value: true });
if (facilities.length) sections.push({ title: t.about_amenities, items: facilities });
const park = about.parking;
if (park) {
const items: AboutItem[] = [];
if (park.freeParking === true) items.push({ label: t.about_free_parking, value: true });
if (park.paidParking === true) items.push({ label: t.about_paid_parking, value: true });
if (park.streetParking === true) items.push({ label: t.about_street_parking, value: true });
if (park.garageParking === true) items.push({ label: t.about_garage_parking, value: true });
if (park.valetParking === true) items.push({ label: t.about_valet_parking, value: true });
if (items.length) sections.push({ title: t.about_parking, items });
}
const pay = about.payment;
if (pay) {
const items: AboutItem[] = [];
if (pay.debitCards === true) items.push({ label: t.about_debit_cards, value: true });
if (pay.creditCards === true) items.push({ label: t.about_credit_cards, value: true });
if (pay.nfc === true) items.push({ label: t.about_nfc_payments, value: true });
if (pay.cashOnly === true) items.push({ label: t.about_cash_only, value: true });
if (items.length) sections.push({ title: t.about_payments, items });
}
const children: AboutItem[] = [];
if (about.isGoodForChildren === true) children.push({ label: t.about_good_for_children, value: true });
if (about.hasMenuForChildren === true) children.push({ label: t.about_kids_menu, value: true });
if (children.length) sections.push({ title: t.about_children, items: children });
const pets: AboutItem[] = [];
if (about.allowsDogs === true) pets.push({ label: t.about_dogs_allowed, value: true });
if (pets.length) sections.push({ title: t.about_pets, items: pets });
return sections;
}
export const DAY_NAMES_ZH = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
export const DAY_NAMES_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
export function formatCnAmpmTime(hour: number, minute: number): string {
const period = hour >= 12 ? '下午' : '上午';
const h12 = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return `${period}${h12}:${String(minute).padStart(2, '0')}`;
}
/** 24 小时制 HH:mm(与系统「24 小时制」展示一致) */
export function format24hTime(hour: number, minute: number): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
export function getNextOpeningDate(now: Date, open: { day: number; hour: number; minute: number }): Date {
const target = fromTimestamp(now.getTime());
const currentDay = now.getDay();
const addDays = (open.day - currentDay + 7) % 7;
target.setDate(now.getDate() + addDays);
target.setHours(open.hour, open.minute, 0, 0);
if (target.getTime() <= now.getTime()) {
target.setDate(target.getDate() + 7);
}
return target;
}
export function findNextOpeningLabel(
periods: any[],
now: Date,
locale: Locale = 'zh-Hans',
): string | null {
if (!periods?.length) return null;
let best: Date | null = null;
for (const pr of periods) {
if (!pr?.open) continue;
const next = getNextOpeningDate(now, pr.open);
if (!best || next.getTime() < best.getTime()) best = next;
}
if (!best) return null;
const dayName = locale === 'en' ? DAY_NAMES_EN[best.getDay()] : DAY_NAMES_ZH[best.getDay()];
return `${dayName}${format24hTime(best.getHours(), best.getMinutes())}`;
}
/** 与 Google Maps 一致:营业中/已结束营业、结束时间、下次开始时间 */
export function computeOpeningStatusFromPeriods(
hrs: { periods: any[] },
now: Date,
locale: Locale = 'zh-Hans',
): { isOpen: boolean; closesAt: string | null; opensNextLabel: string | null } {
const periods = hrs?.periods;
if (!periods?.length) {
return { isOpen: false, closesAt: null, opensNextLabel: null };
}
const day = now.getDay();
const hhmm = now.getHours() * 100 + now.getMinutes();
let closesAt: string | null = null;
let isOpen = false;
for (const pr of periods) {
if (pr.open?.day !== day) continue;
const o = pr.open.hour * 100 + pr.open.minute;
const c = pr.close ? pr.close.hour * 100 + pr.close.minute : 2400;
if (hhmm >= o && hhmm < c) {
isOpen = true;
if (pr.close) {
closesAt = format24hTime(pr.close.hour, pr.close.minute);
}
break;
}
}
const opensNextLabel = isOpen ? null : findNextOpeningLabel(periods, now, locale);
return { isOpen, closesAt, opensNextLabel };
}
/** 系统 locale → Google Maps API languageCode */
export function googleLangCode(locale: 'zh-Hans' | 'en' = 'zh-Hans'): string {
return locale === 'en' ? 'en' : 'zh-CN';
}
/** Place 构造器用 requestedLanguage 参数 */
export function placeRequestedLanguage(locale: 'zh-Hans' | 'en' = 'zh-Hans'): { requestedLanguage: string } {
return { requestedLanguage: googleLangCode(locale) };
}
/** weekdayDescriptions 为 API 本地化字符串;仅按首处冒号分列排版 */
export function splitWeekdayDescriptionLine(desc: string): { left: string; right: string | null } {
const m = String(desc).match(/^([^:]+)[:]\s*(.+)$/);
if (m) return { left: m[1].trim(), right: m[2].trim() };
return { left: desc, right: null };
}
/**
* formattedAddress 有时会把短 Plus Code 嵌进正文(如 …圆明园275X+67P 邮政编码…)。
* compoundCode 单独展示在展开区;主行应去掉嵌入片段,避免重复。
*/
export function stripEmbeddedPlusCodeFromAddress(address: string, compoundCode: string | null | undefined): string {
if (!address) return '';
let out = address.trim();
if (compoundCode) {
const localPart = compoundCode.split(/\s+/)[0]?.trim();
if (localPart && localPart.includes('+')) {
const escaped = localPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
out = out.replace(new RegExp(escaped, 'gi'), '');
}
}
// 兜底:Open Location Code 短码(约 4+2/3 位),与 compoundCode 首段可能不一致时仍剥离
const olcShort = /\s*[23456789CFGHJKLMNPQRSTVWXY]{4,8}\+[23456789CFGHJKLMNPQRSTVWXY]{2,3}\s*/gi;
out = out.replace(olcShort, ' ');
return out.replace(/\s{2,}/g, ' ').trim();
}
/**
* 将完整格式化地址裁成更接近 Google 地图卡片风格的短地址。
* 优先保留道路 / 门牌 / POI 附近信息,去掉国家、省市区等行政前缀。
*/
export function formatShortDisplayAddress(address: string | null | undefined): string {
const original = String(address || '').trim();
if (!original) return '';
let out = stripEmbeddedPlusCodeFromAddress(original, null)
.replace(/^中国/, '')
.replace(/[,]\s*中国$/, '')
.replace(/\s*邮政编码[:]?\s*\d{6}\s*$/u, '')
.trim();
const administrativePrefixes = [
/^(北京市|上海市|天津市|重庆市|香港特别行政区|澳门特别行政区)/u,
/^[^省]+省/u,
/^[^自治区]+自治区/u,
/^[^特别行政区]+特别行政区/u,
/^[^市]+市/u,
/^[^区县旗]+区/u,
/^[^区县旗]+县/u,
/^[^旗]+旗/u,
];
for (const pattern of administrativePrefixes) {
const shortened = out.replace(pattern, '').trim();
if (shortened && shortened.length >= 4) {
out = shortened;
}
}
return out || original;
}