Files
rf4-spotter/apps/web/src/pages/spots/[id].astro
T

111 lines
11 KiB
Plaintext
Raw 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 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, coordinatePrecisionLabel, 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");
Astro.response.headers.set("Cache-Control", "no-store");
}
}
const level = activity ? activityLevel(activity.activity_score) : null;
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
const method = catches.map(item => item.fishing_method).find(Boolean) ?? "";
const retrieve = catches.map(item => item.retrieve_method).find(Boolean) ?? "";
const confirmedMethods = [...new Set(catches.map(item => item.fishing_method).filter((value): value is string => Boolean(value)))].map(value => methodLabels[value] ?? value);
const confirmedRetrieves = [...new Set(catches.map(item => item.retrieve_method).filter((value): value is string => Boolean(value)))];
const risk = activity ? activity.catches < 3 ? "Малая выборка" : activity.confidence_score < 50 ? "Низкая уверенность" : "Подтверждено" : "Нет оценки";
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 || unavailable} 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" role="alert"><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>{coordinatePrecisionLabel(spot.coordinate_precision)}</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-method={methodLabels[method] ?? method} data-plan-retrieve={retrieve} data-plan-risk={risk} 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} coordinateSources={activity.coordinate_sources} 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>}<section class="confirmed-approach" aria-label="Подтверждённый метод и проводка"><h3>Подтверждённый подход</h3>{confirmedMethods.length || confirmedRetrieves.length ? <dl>{confirmedMethods.length > 0 && <div><dt>Метод</dt><dd>{confirmedMethods.join(", ")}</dd></div>}{confirmedRetrieves.length > 0 && <div><dt>Проводка</dt><dd>{confirmedRetrieves.join(", ")}</dd></div>}</dl> : <p>Метод и проводка в уловах не указаны.</p>}<small>По одобренным уловам этой точки.</small></section><p class="note">Учитываются только одобренные наблюдения.</p></aside>
</section>
</>}
</Layout>
<script>
const copyText = async (value: string) => {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; }
const field = document.createElement("textarea");
field.value = value; field.setAttribute("readonly", ""); field.style.position = "fixed"; field.style.opacity = "0";
document.body.append(field); field.select();
const copied = document.execCommand("copy");
field.remove();
if (!copied) throw new Error("Clipboard is unavailable");
};
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 copyText(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 || "", method: button.dataset.planMethod || "", retrieve: button.dataset.planRetrieve || "", risk: button.dataset.planRisk || "", freshness: button.dataset.planFreshness || "", confidence: button.dataset.planConfidence || "" }); if (status) status.textContent = "Добавлено в план"; }
localStorage.setItem(planStorageKey, JSON.stringify(plan.slice(0, 5)));
sync();
});
});
</script>
<style>
.confirmed-approach { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
.confirmed-approach h3 { margin: 0 0 12px; }
.confirmed-approach dl { display: grid; gap: 8px; margin: 0 0 8px; }
.confirmed-approach dl div { display: grid; grid-template-columns: 92px 1fr; gap: 8px; }
.confirmed-approach dt { color: var(--text-subtle); font-size: 11px; font-weight: 750; text-transform: uppercase; }
.confirmed-approach dd { margin: 0; color: var(--text-secondary); }
.confirmed-approach p, .confirmed-approach small { color: var(--text-muted); }
</style>