85 lines
8.2 KiB
Plaintext
85 lines
8.2 KiB
Plaintext
---
|
||
import Layout from "../../layouts/Layout.astro";
|
||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||
import TackleGlyph from "../../components/TackleGlyph.astro";
|
||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||
import CatchList from "../../components/CatchList.astro";
|
||
import DataPassport from "../../components/DataPassport.astro";
|
||
import { activityLevel, api, ApiError, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
||
const { id } = Astro.params;
|
||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||
let timeline: { start: string; end: string; count: number }[] = [];
|
||
try {
|
||
const readable = id?.match(/^(.+)-(-?\d+)x(-?\d+)$/);
|
||
const spotResult = readable
|
||
? await api<Spot>(`/api/v1/spots/resolve?waterbody=${encodeURIComponent(readable[1])}&x=${readable[2]}&y=${readable[3]}`)
|
||
: await api<Spot>(`/api/v1/spots/${id}`);
|
||
if (!readable) return Astro.redirect(`/spots/${spotResult.waterbody_slug}-${spotResult.x}x${spotResult.y}`, 301);
|
||
const [catchResult, activityPaginated] = await Promise.all([api<Catch[]>(`/api/v1/spots/${spotResult.id}/catches`), api<PaginatedActivity>(`/api/v1/activity?hours=24&waterbody=${encodeURIComponent(spotResult.waterbody_slug)}&limit=100`)]);
|
||
spot = spotResult; catches = catchResult; activity = activityPaginated.items.find(item => item.spot_id === spotResult.id) ?? null;
|
||
timeline = await api<typeof timeline>(`/api/v1/spots/${spotResult.id}/timeline`);
|
||
} catch (error) {
|
||
unavailable = true;
|
||
Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503;
|
||
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
|
||
}
|
||
const level = activity ? activityLevel(activity.activity_score) : null;
|
||
const coordinatePrecision = { exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as const;
|
||
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
|
||
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
|
||
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
|
||
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
|
||
] } : null;
|
||
---
|
||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
|
||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecision[spot.coordinate_precision as keyof typeof coordinatePrecision] ?? "не указаны"}</strong></p><div class="spot-hero__actions"><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><button class="plan-save" data-action="inverse" type="button" data-plan-save data-plan-key={Astro.url.pathname} data-plan-waterbody={spot.waterbody} data-plan-coordinates={`${spot.x}:${spot.y}`} data-plan-baits={spot.top_baits.join("|")} data-plan-freshness={activity?.last_confirmed_at ?? ""} data-plan-confidence={activity?.confidence_score ?? ""} aria-pressed="false">Сохранить в план</button><small class="copy-status" aria-live="polite"></small><small class="plan-status" aria-live="polite"></small></div></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||
<ActivityTimeline buckets={timeline}/>
|
||
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
||
<section class="detail-grid">
|
||
<div>
|
||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
|
||
</div>
|
||
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} sourceConflicts={activity.source_conflicts} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
|
||
</section>
|
||
</>}
|
||
</Layout>
|
||
<script>
|
||
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
const value = button.dataset.copyCoordinates || "";
|
||
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
|
||
try { await navigator.clipboard.writeText(value); if (status) status.textContent = "Скопировано"; }
|
||
catch { if (status) status.textContent = value; }
|
||
});
|
||
});
|
||
const planStorageKey = "rf4spotter:fishing-plan";
|
||
const readPlan = (): Array<Record<string, string>> => {
|
||
try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; }
|
||
catch { return []; }
|
||
};
|
||
document.querySelectorAll<HTMLButtonElement>("[data-plan-save]").forEach((button) => {
|
||
const key = button.dataset.planKey || "";
|
||
const status = button.parentElement?.querySelector<HTMLElement>(".plan-status");
|
||
const sync = () => {
|
||
const saved = readPlan().some(item => item.key === key);
|
||
button.textContent = saved ? "В плане" : "Сохранить в план";
|
||
button.setAttribute("aria-pressed", String(saved));
|
||
button.dataset.saved = String(saved);
|
||
};
|
||
sync();
|
||
button.addEventListener("click", () => {
|
||
const plan = readPlan();
|
||
const index = plan.findIndex(item => item.key === key);
|
||
if (index >= 0) { plan.splice(index, 1); if (status) status.textContent = "Удалено из плана"; }
|
||
else { plan.unshift({ key, waterbody: button.dataset.planWaterbody || "", coordinates: button.dataset.planCoordinates || "", baits: button.dataset.planBaits || "", freshness: button.dataset.planFreshness || "", confidence: button.dataset.planConfidence || "" }); if (status) status.textContent = "Добавлено в план"; }
|
||
localStorage.setItem(planStorageKey, JSON.stringify(plan.slice(0, 5)));
|
||
sync();
|
||
});
|
||
});
|
||
</script>
|