You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
129 lines
3.5 KiB
129 lines
3.5 KiB
import type { MothershipAddressCandidate } from "@/modules/address/types";
|
|
|
|
export type StreetDirection = "N" | "S" | "E" | "W";
|
|
|
|
const DIRECTION_FULL: Record<StreetDirection, string> = {
|
|
N: "North",
|
|
S: "South",
|
|
E: "East",
|
|
W: "West",
|
|
};
|
|
|
|
/** 解析门牌后方位(单字母或 North/East 全称) */
|
|
export function parseStreetDirection(street: string): StreetDirection | null {
|
|
const trimmed = street.trim();
|
|
const letter = trimmed.match(/^\d+\s+([NSEW])\b/i);
|
|
if (letter) {
|
|
return letter[1]!.toUpperCase() as StreetDirection;
|
|
}
|
|
for (const dir of Object.keys(DIRECTION_FULL) as StreetDirection[]) {
|
|
if (new RegExp(`\\b${DIRECTION_FULL[dir]}\\b`, "i").test(trimmed)) {
|
|
return dir;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 将「600 W 7th St」展开为 MotherShip 更常识别的「600 West 7th Street」。
|
|
* 仅处理门牌后单字母方位;无法识别时返回 null。
|
|
*/
|
|
export function expandDirectionalStreet(street: string): string | null {
|
|
const trimmed = street.trim();
|
|
const match = trimmed.match(/^(\d+)\s+([NSEW])\s+(.+)$/i);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const dir = match[2]!.toUpperCase() as StreetDirection;
|
|
const fullDir = DIRECTION_FULL[dir];
|
|
if (!fullDir) {
|
|
return null;
|
|
}
|
|
let rest = match[3]!.trim();
|
|
rest = rest.replace(/\bSt\.?$/i, "Street");
|
|
return `${match[1]} ${fullDir} ${rest}`.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function labelHasDirection(
|
|
label: string,
|
|
dir: StreetDirection,
|
|
): boolean {
|
|
const lower = label.toLowerCase();
|
|
const full = DIRECTION_FULL[dir].toLowerCase();
|
|
if (lower.includes(full)) {
|
|
return true;
|
|
}
|
|
return new RegExp(`\\b\\d+\\s+${dir}\\b`, "i").test(label);
|
|
}
|
|
|
|
/** 按用户草稿对 MotherShip 候选打分(方位一致优先,相反方位重罚) */
|
|
export function scoreMothershipCandidateForDraft(
|
|
candidate: MothershipAddressCandidate,
|
|
draft: { street: string; city: string; state: string },
|
|
): number {
|
|
const label = `${candidate.display_label} ${candidate.street}`.toLowerCase();
|
|
let score = 0;
|
|
|
|
const streetNum = draft.street.match(/^\d+/)?.[0];
|
|
if (streetNum && label.includes(streetNum)) {
|
|
score += 12;
|
|
}
|
|
const city = draft.city.trim().toLowerCase();
|
|
if (city && label.includes(city)) {
|
|
score += 6;
|
|
}
|
|
const state = draft.state.trim().toLowerCase();
|
|
if (state && label.includes(state)) {
|
|
score += 3;
|
|
}
|
|
|
|
const draftDir = parseStreetDirection(draft.street);
|
|
if (draftDir) {
|
|
const opposite: Record<StreetDirection, StreetDirection> = {
|
|
N: "S",
|
|
S: "N",
|
|
E: "W",
|
|
W: "E",
|
|
};
|
|
if (labelHasDirection(label, draftDir)) {
|
|
score += 18;
|
|
}
|
|
if (labelHasDirection(label, opposite[draftDir])) {
|
|
score -= 28;
|
|
}
|
|
|
|
const expanded = expandDirectionalStreet(draft.street);
|
|
if (expanded && label.includes(expanded.toLowerCase())) {
|
|
score += 14;
|
|
}
|
|
if (
|
|
expanded &&
|
|
/\b\d+\s+[nsew]\s+/i.test(draft.street) &&
|
|
label.includes(expanded.toLowerCase()) === false &&
|
|
labelHasDirection(label, draftDir)
|
|
) {
|
|
score -= 8;
|
|
}
|
|
}
|
|
|
|
if (/\bstreet\b/i.test(label)) {
|
|
score += 4;
|
|
}
|
|
if (/\bbridge\b/.test(label) && !/\bbridge\b/i.test(draft.street)) {
|
|
score -= 20;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
export function sortMothershipCandidatesForDraft(
|
|
candidates: MothershipAddressCandidate[],
|
|
draft: { street: string; city: string; state: string },
|
|
): MothershipAddressCandidate[] {
|
|
return [...candidates].sort(
|
|
(a, b) =>
|
|
scoreMothershipCandidateForDraft(b, draft) -
|
|
scoreMothershipCandidateForDraft(a, draft),
|
|
);
|
|
}
|