feat(Q3): wildlife journal blueprint with API routes and template
Add wildlife_bp with sightings, stats, frequency, and CSV export endpoints; Bootstrap 5 dark journal template with live-updating summary cards, species bars, time-of-day frequency chart, and paginated/filterable sightings table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
afc15a92fe
commit
8bf7900324
49
tests/unit/test_wildlife_api.py
Normal file
49
tests/unit/test_wildlife_api.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import pytest
|
||||||
|
from vigilar.config import VigilarConfig
|
||||||
|
from vigilar.storage.queries import insert_wildlife_sighting
|
||||||
|
from vigilar.web.app import create_app
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def wildlife_app(test_db):
|
||||||
|
cfg = VigilarConfig()
|
||||||
|
app = create_app(cfg)
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
app.config["DB_ENGINE"] = test_db
|
||||||
|
for i in range(3):
|
||||||
|
insert_wildlife_sighting(test_db, species="deer", threat_level="PASSIVE",
|
||||||
|
camera_id="front", confidence=0.9)
|
||||||
|
insert_wildlife_sighting(test_db, species="bear", threat_level="PREDATOR",
|
||||||
|
camera_id="back", confidence=0.95)
|
||||||
|
return app
|
||||||
|
|
||||||
|
def test_wildlife_sightings_api(wildlife_app):
|
||||||
|
with wildlife_app.test_client() as c:
|
||||||
|
rv = c.get("/wildlife/api/sightings")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
assert len(rv.get_json()["sightings"]) == 4
|
||||||
|
|
||||||
|
def test_wildlife_sightings_filter_species(wildlife_app):
|
||||||
|
with wildlife_app.test_client() as c:
|
||||||
|
rv = c.get("/wildlife/api/sightings?species=bear")
|
||||||
|
assert len(rv.get_json()["sightings"]) == 1
|
||||||
|
|
||||||
|
def test_wildlife_stats_api(wildlife_app):
|
||||||
|
with wildlife_app.test_client() as c:
|
||||||
|
rv = c.get("/wildlife/api/stats")
|
||||||
|
data = rv.get_json()
|
||||||
|
assert data["total"] == 4
|
||||||
|
assert data["per_species"]["deer"] == 3
|
||||||
|
|
||||||
|
def test_wildlife_frequency_api(wildlife_app):
|
||||||
|
with wildlife_app.test_client() as c:
|
||||||
|
rv = c.get("/wildlife/api/frequency")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
assert len(rv.get_json()) == 6
|
||||||
|
|
||||||
|
def test_wildlife_export_csv(wildlife_app):
|
||||||
|
with wildlife_app.test_client() as c:
|
||||||
|
rv = c.get("/wildlife/api/export")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
assert "text/csv" in rv.content_type
|
||||||
|
lines = rv.data.decode().strip().split("\n")
|
||||||
|
assert len(lines) == 5 # header + 4 rows
|
||||||
@ -30,6 +30,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
|||||||
from vigilar.web.blueprints.recordings import recordings_bp
|
from vigilar.web.blueprints.recordings import recordings_bp
|
||||||
from vigilar.web.blueprints.sensors import sensors_bp
|
from vigilar.web.blueprints.sensors import sensors_bp
|
||||||
from vigilar.web.blueprints.system import system_bp
|
from vigilar.web.blueprints.system import system_bp
|
||||||
|
from vigilar.web.blueprints.wildlife import wildlife_bp
|
||||||
|
|
||||||
app.register_blueprint(cameras_bp)
|
app.register_blueprint(cameras_bp)
|
||||||
app.register_blueprint(events_bp)
|
app.register_blueprint(events_bp)
|
||||||
@ -38,6 +39,7 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
|||||||
app.register_blueprint(recordings_bp)
|
app.register_blueprint(recordings_bp)
|
||||||
app.register_blueprint(sensors_bp)
|
app.register_blueprint(sensors_bp)
|
||||||
app.register_blueprint(system_bp)
|
app.register_blueprint(system_bp)
|
||||||
|
app.register_blueprint(wildlife_bp)
|
||||||
|
|
||||||
# Root route → dashboard
|
# Root route → dashboard
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
|
|||||||
72
vigilar/web/blueprints/wildlife.py
Normal file
72
vigilar/web/blueprints/wildlife.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"""Wildlife journal blueprint — sighting log, stats, export."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, current_app, jsonify, render_template, request
|
||||||
|
|
||||||
|
wildlife_bp = Blueprint("wildlife", __name__, url_prefix="/wildlife")
|
||||||
|
|
||||||
|
|
||||||
|
def _engine():
|
||||||
|
return current_app.config.get("DB_ENGINE")
|
||||||
|
|
||||||
|
|
||||||
|
@wildlife_bp.route("/")
|
||||||
|
def journal():
|
||||||
|
return render_template("wildlife/journal.html")
|
||||||
|
|
||||||
|
|
||||||
|
@wildlife_bp.route("/api/sightings")
|
||||||
|
def sightings_api():
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"sightings": []})
|
||||||
|
from vigilar.storage.queries import get_wildlife_sightings_paginated
|
||||||
|
sightings = get_wildlife_sightings_paginated(
|
||||||
|
engine,
|
||||||
|
species=request.args.get("species"),
|
||||||
|
threat_level=request.args.get("threat_level"),
|
||||||
|
camera_id=request.args.get("camera_id"),
|
||||||
|
since_ts=request.args.get("since", type=float),
|
||||||
|
until_ts=request.args.get("until", type=float),
|
||||||
|
limit=min(request.args.get("limit", 50, type=int), 500),
|
||||||
|
offset=request.args.get("offset", 0, type=int),
|
||||||
|
)
|
||||||
|
return jsonify({"sightings": sightings})
|
||||||
|
|
||||||
|
|
||||||
|
@wildlife_bp.route("/api/stats")
|
||||||
|
def stats_api():
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({"total": 0, "species_count": 0, "per_species": {}})
|
||||||
|
from vigilar.storage.queries import get_wildlife_stats
|
||||||
|
return jsonify(get_wildlife_stats(engine))
|
||||||
|
|
||||||
|
|
||||||
|
@wildlife_bp.route("/api/frequency")
|
||||||
|
def frequency_api():
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return jsonify({})
|
||||||
|
from vigilar.storage.queries import get_wildlife_frequency
|
||||||
|
return jsonify(get_wildlife_frequency(engine))
|
||||||
|
|
||||||
|
|
||||||
|
@wildlife_bp.route("/api/export")
|
||||||
|
def export_csv():
|
||||||
|
engine = _engine()
|
||||||
|
if engine is None:
|
||||||
|
return Response("No data", mimetype="text/csv")
|
||||||
|
from vigilar.storage.queries import get_wildlife_sightings_paginated
|
||||||
|
sightings = get_wildlife_sightings_paginated(engine, limit=10000, offset=0)
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(["id", "timestamp", "species", "threat_level", "camera_id",
|
||||||
|
"confidence", "temperature_c", "conditions"])
|
||||||
|
for s in sightings:
|
||||||
|
writer.writerow([s["id"], s["ts"], s["species"], s["threat_level"],
|
||||||
|
s["camera_id"], s.get("confidence"), s.get("temperature_c"), s.get("conditions")])
|
||||||
|
return Response(output.getvalue(), mimetype="text/csv",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=wildlife_sightings.csv"})
|
||||||
343
vigilar/web/templates/wildlife/journal.html
Normal file
343
vigilar/web/templates/wildlife/journal.html
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Wildlife Journal — Vigilar{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0"><i class="bi bi-binoculars-fill me-2 text-success"></i>Wildlife Journal</h5>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="/wildlife/api/export" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-download me-1"></i>Export CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-dark border-secondary h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-3 fw-bold text-success" id="stat-total">—</div>
|
||||||
|
<div class="small text-muted">Total Sightings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-dark border-secondary h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-3 fw-bold text-info" id="stat-species">—</div>
|
||||||
|
<div class="small text-muted">Species Observed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-dark border-secondary h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-3 fw-bold text-warning" id="stat-top-species">—</div>
|
||||||
|
<div class="small text-muted">Most Frequent</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card bg-dark border-secondary h-100">
|
||||||
|
<div class="card-body text-center py-3">
|
||||||
|
<div class="fs-3 fw-bold text-danger" id="stat-predators">—</div>
|
||||||
|
<div class="small text-muted">Predator Sightings</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Species breakdown -->
|
||||||
|
<div class="card bg-dark border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold"><i class="bi bi-bar-chart-fill me-2"></i>Species Breakdown</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex flex-wrap gap-2" id="species-bars">
|
||||||
|
<span class="text-muted small">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity by time of day -->
|
||||||
|
<div class="card bg-dark border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2">
|
||||||
|
<span class="small fw-semibold"><i class="bi bi-clock-history me-2"></i>Activity by Time of Day</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="row g-2" id="frequency-grid">
|
||||||
|
<div class="col-12 text-muted small text-center py-2">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters + sighting table -->
|
||||||
|
<div class="card bg-dark border-secondary mb-3">
|
||||||
|
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
|
||||||
|
<span class="small fw-semibold"><i class="bi bi-table me-2"></i>Sighting Log</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<select class="form-select form-select-sm bg-dark text-light border-secondary" id="filter-species"
|
||||||
|
style="width:auto;">
|
||||||
|
<option value="">All species</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select form-select-sm bg-dark text-light border-secondary" id="filter-threat"
|
||||||
|
style="width:auto;">
|
||||||
|
<option value="">All threat levels</option>
|
||||||
|
<option value="PASSIVE">Passive</option>
|
||||||
|
<option value="NUISANCE">Nuisance</option>
|
||||||
|
<option value="PREDATOR">Predator</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select form-select-sm bg-dark text-light border-secondary" id="filter-camera"
|
||||||
|
style="width:auto;">
|
||||||
|
<option value="">All cameras</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-dark table-hover mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Species</th>
|
||||||
|
<th>Threat</th>
|
||||||
|
<th>Camera</th>
|
||||||
|
<th>Confidence</th>
|
||||||
|
<th>Conditions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sightings-tbody">
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top border-secondary">
|
||||||
|
<span class="small text-muted" id="page-info">—</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="btn-prev" disabled>
|
||||||
|
<i class="bi bi-chevron-left"></i> Prev
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="btn-next" disabled>
|
||||||
|
Next <i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const THREAT_BADGE = {
|
||||||
|
PASSIVE: 'bg-success',
|
||||||
|
NUISANCE: 'bg-warning text-dark',
|
||||||
|
PREDATOR: 'bg-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
let currentOffset = 0;
|
||||||
|
let totalShown = 0;
|
||||||
|
|
||||||
|
// ---- utility ------------------------------------------------------------
|
||||||
|
function fmtTs(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPct(v) {
|
||||||
|
if (v == null) return '—';
|
||||||
|
return (v * 100).toFixed(1) + '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stats --------------------------------------------------------------
|
||||||
|
function loadStats() {
|
||||||
|
fetch('/wildlife/api/stats')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('stat-total').textContent = data.total ?? '—';
|
||||||
|
document.getElementById('stat-species').textContent = data.species_count ?? '—';
|
||||||
|
|
||||||
|
const per = data.per_species || {};
|
||||||
|
const entries = Object.entries(per).sort((a, b) => b[1] - a[1]);
|
||||||
|
document.getElementById('stat-top-species').textContent =
|
||||||
|
entries.length > 0 ? entries[0][0] : '—';
|
||||||
|
|
||||||
|
// Populate species filter
|
||||||
|
const sel = document.getElementById('filter-species');
|
||||||
|
entries.forEach(([sp]) => {
|
||||||
|
if (!sel.querySelector(`option[value="${sp}"]`)) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = sp;
|
||||||
|
opt.textContent = sp.charAt(0).toUpperCase() + sp.slice(1);
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Species bars
|
||||||
|
const barsEl = document.getElementById('species-bars');
|
||||||
|
if (entries.length === 0) {
|
||||||
|
barsEl.innerHTML = '<span class="text-muted small">No sightings recorded</span>';
|
||||||
|
} else {
|
||||||
|
const maxCnt = entries[0][1];
|
||||||
|
barsEl.innerHTML = entries.map(([sp, cnt]) => {
|
||||||
|
const pct = Math.round((cnt / maxCnt) * 100);
|
||||||
|
return `<div style="min-width:12rem;flex:1 1 12rem;">
|
||||||
|
<div class="d-flex justify-content-between small mb-1">
|
||||||
|
<span class="fw-semibold">${sp}</span>
|
||||||
|
<span class="text-muted">${cnt}</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress" style="height:8px;background:rgba(255,255,255,.1);">
|
||||||
|
<div class="progress-bar bg-success" style="width:${pct}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- frequency ----------------------------------------------------------
|
||||||
|
function loadFrequency() {
|
||||||
|
fetch('/wildlife/api/frequency')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const grid = document.getElementById('frequency-grid');
|
||||||
|
const buckets = Object.entries(data);
|
||||||
|
if (buckets.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="col-12 text-muted small text-center py-2">No data</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// find max value across all buckets
|
||||||
|
let maxVal = 1;
|
||||||
|
buckets.forEach(([, counts]) => {
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
if (total > maxVal) maxVal = total;
|
||||||
|
});
|
||||||
|
grid.innerHTML = buckets.map(([label, counts]) => {
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
const pct = Math.round((total / maxVal) * 100);
|
||||||
|
const species = Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([sp, n]) => `${sp} (${n})`)
|
||||||
|
.join(', ');
|
||||||
|
return `<div class="col-6 col-md-4 col-lg-2">
|
||||||
|
<div class="text-center small text-muted mb-1">${label}</div>
|
||||||
|
<div class="progress mb-1" style="height:20px;background:rgba(255,255,255,.1);">
|
||||||
|
<div class="progress-bar bg-info" style="width:${pct}%;" title="${species}">
|
||||||
|
${total > 0 ? total : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center x-small text-muted" style="font-size:.7rem;">${species || 'none'}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('frequency-grid').innerHTML =
|
||||||
|
'<div class="col-12 text-muted small text-center py-2">Unavailable</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sightings table ----------------------------------------------------
|
||||||
|
function buildParams(offset) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const species = document.getElementById('filter-species').value;
|
||||||
|
const threat = document.getElementById('filter-threat').value;
|
||||||
|
const camera = document.getElementById('filter-camera').value;
|
||||||
|
if (species) params.set('species', species);
|
||||||
|
if (threat) params.set('threat_level', threat);
|
||||||
|
if (camera) params.set('camera_id', camera);
|
||||||
|
params.set('limit', PAGE_SIZE);
|
||||||
|
params.set('offset', offset);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSightings(offset) {
|
||||||
|
const tbody = document.getElementById('sightings-tbody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>';
|
||||||
|
|
||||||
|
fetch('/wildlife/api/sightings?' + buildParams(offset))
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
const rows = data.sightings || [];
|
||||||
|
totalShown = rows.length;
|
||||||
|
currentOffset = offset;
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No sightings found</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = rows.map(s => {
|
||||||
|
const badgeCls = THREAT_BADGE[s.threat_level] || 'bg-secondary';
|
||||||
|
return `<tr>
|
||||||
|
<td class="text-nowrap">${fmtTs(s.ts)}</td>
|
||||||
|
<td class="fw-semibold">${s.species || '—'}</td>
|
||||||
|
<td><span class="badge ${badgeCls}">${s.threat_level || '—'}</span></td>
|
||||||
|
<td>${s.camera_id || '—'}</td>
|
||||||
|
<td>${fmtPct(s.confidence)}</td>
|
||||||
|
<td class="text-muted">${s.conditions || '—'}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Populate camera filter from data
|
||||||
|
const camSel = document.getElementById('filter-camera');
|
||||||
|
rows.forEach(s => {
|
||||||
|
if (s.camera_id && !camSel.querySelector(`option[value="${s.camera_id}"]`)) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.camera_id;
|
||||||
|
opt.textContent = s.camera_id;
|
||||||
|
camSel.appendChild(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageInfo = document.getElementById('page-info');
|
||||||
|
pageInfo.textContent = rows.length === 0
|
||||||
|
? 'No results'
|
||||||
|
: `Showing ${offset + 1}–${offset + rows.length}`;
|
||||||
|
|
||||||
|
document.getElementById('btn-prev').disabled = offset === 0;
|
||||||
|
document.getElementById('btn-next').disabled = rows.length < PAGE_SIZE;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Failed to load sightings</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stats predator count -----------------------------------------------
|
||||||
|
function loadPredatorCount() {
|
||||||
|
fetch('/wildlife/api/sightings?threat_level=PREDATOR&limit=500&offset=0')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('stat-predators').textContent =
|
||||||
|
(data.sightings || []).length;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- events -------------------------------------------------------------
|
||||||
|
document.getElementById('btn-prev').addEventListener('click', () => {
|
||||||
|
loadSightings(Math.max(0, currentOffset - PAGE_SIZE));
|
||||||
|
});
|
||||||
|
document.getElementById('btn-next').addEventListener('click', () => {
|
||||||
|
loadSightings(currentOffset + PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
['filter-species', 'filter-threat', 'filter-camera'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('change', () => {
|
||||||
|
currentOffset = 0;
|
||||||
|
loadSightings(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- initial load -------------------------------------------------------
|
||||||
|
loadStats();
|
||||||
|
loadFrequency();
|
||||||
|
loadSightings(0);
|
||||||
|
loadPredatorCount();
|
||||||
|
setInterval(() => { loadStats(); loadFrequency(); loadSightings(currentOffset); }, 60000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user