216 lines
6.3 KiB
TypeScript
216 lines
6.3 KiB
TypeScript
import type { Race } from "../api";
|
||
|
||
export type RaceVisualVariant = "short" | "half" | "marathon" | "trail" | "night";
|
||
export type RaceVisualFit = "cover" | "contain";
|
||
|
||
interface RaceVisual {
|
||
variant: RaceVisualVariant;
|
||
imageSrc: string;
|
||
fallbackSrc: string;
|
||
imageFit: RaceVisualFit;
|
||
label: string;
|
||
}
|
||
|
||
interface OfficialRaceVisual {
|
||
keywords: string[];
|
||
imageSrc: string;
|
||
imageFit?: RaceVisualFit;
|
||
label: string;
|
||
}
|
||
|
||
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
|
||
short: {
|
||
variant: "short",
|
||
imageSrc: "/images/race-short.jpg",
|
||
fallbackSrc: "/images/race-short.jpg",
|
||
imageFit: "cover",
|
||
label: "Городской темп",
|
||
},
|
||
half: {
|
||
variant: "half",
|
||
imageSrc: "/images/race-half.jpg",
|
||
fallbackSrc: "/images/race-half.jpg",
|
||
imageFit: "cover",
|
||
label: "Полумарафон",
|
||
},
|
||
marathon: {
|
||
variant: "marathon",
|
||
imageSrc: "/images/race-marathon.jpg",
|
||
fallbackSrc: "/images/race-marathon.jpg",
|
||
imageFit: "cover",
|
||
label: "Марафон",
|
||
},
|
||
trail: {
|
||
variant: "trail",
|
||
imageSrc: "/images/race-trail.jpg",
|
||
fallbackSrc: "/images/race-trail.jpg",
|
||
imageFit: "cover",
|
||
label: "Трейл",
|
||
},
|
||
night: {
|
||
variant: "night",
|
||
imageSrc: "/images/race-night.jpg",
|
||
fallbackSrc: "/images/race-night.jpg",
|
||
imageFit: "cover",
|
||
label: "Ночной старт",
|
||
},
|
||
};
|
||
|
||
const OFFICIAL_VISUALS: OfficialRaceVisual[] = [
|
||
{
|
||
keywords: ["забег апрель"],
|
||
imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg",
|
||
label: "Забег Апрель",
|
||
},
|
||
{
|
||
keywords: ["быстрый пес"],
|
||
imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg",
|
||
label: "Кросс",
|
||
},
|
||
{
|
||
keywords: ["лисья гора"],
|
||
imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg",
|
||
label: "Кросс",
|
||
},
|
||
{
|
||
keywords: ["казанский марафон"],
|
||
imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png",
|
||
imageFit: "contain",
|
||
label: "Казанский марафон",
|
||
},
|
||
{
|
||
keywords: ["мышкинский полумарафон", "по шести холмам"],
|
||
imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg",
|
||
label: "Золотое кольцо",
|
||
},
|
||
{
|
||
keywords: ["забег.рф", "забег рф"],
|
||
imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg",
|
||
label: "ЗаБег.РФ",
|
||
},
|
||
{
|
||
keywords: ["переславский марафон", "александровские версты"],
|
||
imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg",
|
||
label: "Золотое кольцо",
|
||
},
|
||
{
|
||
keywords: ["красочный забег"],
|
||
imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg",
|
||
label: "Красочный забег",
|
||
},
|
||
{
|
||
keywords: ["здорово кострома", "здорово, кострома"],
|
||
imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg",
|
||
label: "Золотое кольцо",
|
||
},
|
||
{
|
||
keywords: ["ночной забег москва"],
|
||
imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg",
|
||
label: "Ночной забег",
|
||
},
|
||
{
|
||
keywords: ["белые ночи"],
|
||
imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg",
|
||
label: "Белые ночи",
|
||
},
|
||
{
|
||
keywords: ["сергиевым путем", "сергиевым путём"],
|
||
imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg",
|
||
label: "Золотое кольцо",
|
||
},
|
||
{
|
||
keywords: ["ночной забег нижний новгород"],
|
||
imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png",
|
||
imageFit: "contain",
|
||
label: "Ночной забег",
|
||
},
|
||
{
|
||
keywords: ["suvorov extreme"],
|
||
imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg",
|
||
label: "Трейл",
|
||
},
|
||
{
|
||
keywords: ["рыбинский полумарафон", "великий хлебный путь"],
|
||
imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg",
|
||
label: "Золотое кольцо",
|
||
},
|
||
{
|
||
keywords: ["ярославский полумарафон", "золотое кольцо"],
|
||
imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg",
|
||
label: "Золотое кольцо",
|
||
},
|
||
{
|
||
keywords: ["моя столица"],
|
||
imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg",
|
||
imageFit: "contain",
|
||
label: "Моя столица",
|
||
},
|
||
];
|
||
|
||
function normalizeTitle(value: string): string {
|
||
return value
|
||
.toLowerCase()
|
||
.replaceAll("ё", "е")
|
||
.replace(/[«»|]/g, " ")
|
||
.replace(/[^\p{L}\p{N}.&]+/gu, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function getFallbackRaceVisual(race: Race): RaceVisual {
|
||
const title = normalizeTitle(race.title);
|
||
|
||
if (title.includes("ночной")) {
|
||
return FALLBACK_VISUALS.night;
|
||
}
|
||
|
||
if (
|
||
title.includes("trail") ||
|
||
title.includes("extreme") ||
|
||
title.includes("suvorov") ||
|
||
title.includes("трейл") ||
|
||
title.includes("экстрим")
|
||
) {
|
||
return FALLBACK_VISUALS.trail;
|
||
}
|
||
|
||
if (Math.abs(race.distanceKm - 42.2) < 0.8) {
|
||
return FALLBACK_VISUALS.marathon;
|
||
}
|
||
|
||
if (Math.abs(race.distanceKm - 21.1) < 0.4) {
|
||
return FALLBACK_VISUALS.half;
|
||
}
|
||
|
||
return FALLBACK_VISUALS.short;
|
||
}
|
||
|
||
export function getRaceVisual(race: Race): RaceVisual {
|
||
const fallback = getFallbackRaceVisual(race);
|
||
|
||
if (race.coverImageUrl) {
|
||
return {
|
||
...fallback,
|
||
imageSrc: race.coverImageUrl,
|
||
fallbackSrc: fallback.imageSrc,
|
||
};
|
||
}
|
||
|
||
const title = normalizeTitle(race.title);
|
||
const official = OFFICIAL_VISUALS.find((visual) =>
|
||
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),
|
||
);
|
||
|
||
if (!official) {
|
||
return fallback;
|
||
}
|
||
|
||
return {
|
||
...fallback,
|
||
imageSrc: official.imageSrc,
|
||
fallbackSrc: fallback.imageSrc,
|
||
imageFit: official.imageFit ?? fallback.imageFit,
|
||
label: official.label,
|
||
};
|
||
}
|