Research Labs

2026-09-17 21:37 UTCopen field data · live

pages/species.php

Snapshot 2026.09.17-a2894de (current) · 435 lines · 32,793 bytes · tree · plain text

SHA-256 dfa4a48f1e0915d5696a26ccadfe694ffe92a39c066c8b45baef26a6a57393b8
Functions: sp_year_chart L32-L85 · sp_bar L86-L104 · sp_feed L105-L119 · sp_since_baseline L120-L259 · sp_timeline L260-L435
1<?php
2/* Districthive Research Labs: Species. Every species recorded by the two
3 baseline surveys of 2021 (fish in Lárvaðall; vegetation and birds at
4 Skerðingsstaðir), before anything was built, with what each report says
5 about it, a picture, general facts and the months it is likely here.
6 Data: assets/species/species.json, built from the reports by the AI
7 operations platform; installed to research/pages/species.php by its deploy
8 hook. Source: deploy/research/species.php in districthive/ai. */
9require_once is_file(__DIR__.'/layout.php') ? __DIR__.'/layout.php' : __DIR__.'/../inc/layout.php';
10
11$dataFile = __DIR__.'/../assets/species/species.json';
12$d = is_file($dataFile) ? (json_decode((string) file_get_contents($dataFile), true) ?: []) : [];
13$species = $d['species'] ?? [];
14$sources = $d['sources'] ?? [];
15$gallery = $d['gallery'] ?? [];
16$survey = $d['survey'] ?? [];
17$groups = ['bird' => 'Birds', 'fish' => 'Fish', 'plant' => 'Plants', 'invertebrate' => 'Invertebrates', 'mammal' => 'Mammals', 'other' => 'Other life'];
18$order = array_flip(array_keys($groups));
19usort($species, fn ($a, $b) => ($order[$a['group']] ?? 9) <=> ($order[$b['group']] ?? 9) ?: strcmp($a['english'], $b['english']));
20$counts = [];
21foreach ($species as $s) { $counts[$s['group']] = ($counts[$s['group']] ?? 0) + 1; }
22$monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
23
24/* ---------------------------------------------------------------- year views
25 Every species carries the months it is present and the months it breeds,
26 spawns or flowers (West Iceland calendar). Two views: counts per month for
27 each group (small multiples, one hue per group, the subset overlaid in the
28 full hue), and a calendar strip per species. Inline SVG, no script. */
29const SP_COLOURS = ['bird' => '#3987e5', 'fish' => '#d95926', 'plant' => '#199e70', 'invertebrate' => '#c98500', 'mammal' => '#9085e9', 'other' => '#d55181'];
30const SP_SUBLABEL = ['bird' => 'breeding', 'fish' => 'spawning', 'plant' => 'in flower', 'invertebrate' => '', 'mammal' => '', 'other' => ''];
31
32function sp_year_chart(array $species, array $groups, int $thisMonth, array $monthNames): string
33{
34 $present = [];
35 $subset = [];
36 foreach ($species as $sp) {
37 $g = $sp['group'];
38 for ($m = 1; $m <= 12; $m++) {
39 $present[$g][$m] = ($present[$g][$m] ?? 0) + (in_array($m, $sp['months']['present'] ?? [], true) ? 1 : 0);
40 $subset[$g][$m] = ($subset[$g][$m] ?? 0) + (in_array($m, $sp['months']['breeding'] ?? [], true) ? 1 : 0);
41 }
42 }
43 $out = '<div class="spyear">';
44 $rows = [];
45 foreach ($groups as $g => $label) {
46 if (empty($present[$g])) { continue; }
47 $max = max(1, max($present[$g]));
48 $col = SP_COLOURS[$g] ?? '#a1a1aa';
49 $sub = SP_SUBLABEL[$g] ?? '';
50 $total = count(array_filter($species, fn ($x) => $x['group'] === $g));
51 $out .= '<div class="spyear-panel"><div class="spyear-head"><h3>'.h($label).'</h3><span class="kv">'.$total.' species</span></div>';
52 $out .= '<div class="spyear-legend"><span><i style="background:'.$col.';opacity:.38"></i>present</span>'.($sub !== '' ? '<span><i style="background:'.$col.'"></i>'.h($sub).'</span>' : '').'</div>';
53 $W = 360; $H = 150; $left = 6; $right = 6; $top = 20; $base = 126; $slot = ($W - $left - $right) / 12; $bw = 20;
54 $out .= '<svg class="spyear-svg" viewBox="0 0 '.$W.' '.$H.'" role="img" aria-label="'.h($label).' by month">';
55 $nx = $left + ($thisMonth - 1) * $slot;
56 $out .= '<rect x="'.round($nx, 1).'" y="4" width="'.round($slot, 1).'" height="'.($H - 8).'" fill="#fff" fill-opacity=".06" rx="4"/>';
57 $out .= '<line x1="'.$left.'" y1="'.$base.'" x2="'.($W - $right).'" y2="'.$base.'" stroke="#fff" stroke-opacity=".18"/>';
58 for ($m = 1; $m <= 12; $m++) {
59 $pv = $present[$g][$m]; $sv = $subset[$g][$m];
60 $x = $left + ($m - 1) * $slot + ($slot - $bw) / 2;
61 $ph = ($base - $top - 6) * $pv / $max; $sh = ($base - $top - 6) * $sv / $max;
62 $tip = $monthNames[$m - 1].': '.$pv.' '.strtolower($label).' present'.($sub !== '' ? ', '.$sv.' '.$sub : '');
63 $out .= '<g><title>'.h($tip).'</title>';
64 if ($pv > 0) { $out .= sp_bar($x, $base - $ph, $bw, $ph, $col, .38); }
65 if ($sv > 0) { $out .= sp_bar($x, $base - $sh, $bw, $sh, $col, 1); }
66 $out .= '<text x="'.round($x + $bw / 2, 1).'" y="'.round($base - $ph - 4, 1).'" class="spyear-val">'.$pv.'</text>';
67 $out .= '<text x="'.round($x + $bw / 2, 1).'" y="'.($base + 14).'" class="spyear-m'.($m === $thisMonth ? ' now' : '').'">'.substr($monthNames[$m - 1], 0, 1).'</text></g>';
68 }
69 $out .= '</svg></div>';
70 $rows[] = [$label, $present[$g], $subset[$g], $sub];
71 }
72 $out .= '</div>';
73 /* table view for readers who want the numbers */
74 $out .= '<details class="spyear-table"><summary>Table view</summary><table class="data"><thead><tr><th>Group</th>';
75 foreach ($monthNames as $mn) { $out .= '<th>'.h($mn).'</th>'; }
76 $out .= '</tr></thead><tbody>';
77 foreach ($rows as [$label, $pv, $sv, $sub]) {
78 $out .= '<tr><td>'.h($label).' present</td>'; for ($m = 1; $m <= 12; $m++) { $out .= '<td>'.$pv[$m].'</td>'; } $out .= '</tr>';
79 if ($sub !== '') { $out .= '<tr><td>'.h($label).' '.h($sub).'</td>'; for ($m = 1; $m <= 12; $m++) { $out .= '<td>'.$sv[$m].'</td>'; } $out .= '</tr>'; }
80 }
81 $out .= '</tbody></table></details>';
82 return $out;
83}
84
85/** A bar with rounded top corners, anchored to the baseline. */
86function sp_bar(float $x, float $y, float $w, float $hgt, string $col, float $op): string
87{
88 $r = min(4, $hgt / 2, $w / 2);
89 $x2 = $x + $w; $y2 = $y + $hgt;
90 $d = sprintf('M%.1f %.1f L%.1f %.1f L%.1f %.1f Q%.1f %.1f %.1f %.1f L%.1f %.1f Q%.1f %.1f %.1f %.1f Z',
91 $x, $y2, $x, $y + $r, $x, $y + $r, $x, $y, $x + $r, $y, $x2 - $r, $y, $x2, $y, $x2, $y + $r) . sprintf(' M%.1f %.1f L%.1f %.1f', $x2, $y + $r, $x2, $y2);
92 // simpler: build the closed shape explicitly
93 $d = sprintf('M%.1f %.1f L%.1f %.1f Q%.1f %.1f %.1f %.1f L%.1f %.1f Q%.1f %.1f %.1f %.1f L%.1f %.1f Z',
94 $x, $y2, $x, $y + $r, $x, $y, $x + $r, $y, $x2 - $r, $y, $x2, $y, $x2, $y + $r, $x2, $y2);
95 return '<path d="'.$d.'" fill="'.$col.'" fill-opacity="'.$op.'" stroke="#1c1c1c" stroke-width="1"/>';
96}
97
98
99/* ------------------------------------------------------------ since the baseline
100 The sightings register lives on the AI operations platform; its public feed
101 carries species, date, count, place, the observer's role and a confidence,
102 nothing personal. Cached here for five minutes. */
103const SP_FEED = 'https://districthive.com/ai_discover/api/sightings.php';
104
105function sp_feed(): array
106{
107 $cache = sys_get_temp_dir().'/dh-research-sightings.json';
108 if (is_file($cache) && filemtime($cache) > time() - 300) {
109 $j = json_decode((string) file_get_contents($cache), true);
110 if (is_array($j) && isset($j['sightings'])) { return $j; }
111 }
112 $ctx = stream_context_create(['http' => ['timeout' => 4, 'header' => "User-Agent: DistricthiveResearchLabs/1.0\r\n"]]);
113 $raw = @file_get_contents(SP_FEED, false, $ctx);
114 $j = $raw !== false ? json_decode($raw, true) : null;
115 if (is_array($j) && isset($j['sightings'])) { @file_put_contents($cache, $raw); return $j; }
116 if (is_file($cache)) { $j = json_decode((string) file_get_contents($cache), true); if (is_array($j) && isset($j['sightings'])) { return $j; } }
117 return ['sightings' => [], 'unavailable' => true, 'baseline_dates' => ['2021-06-04', '2021-07-03', '2021-08-04', '2021-09-02']];
118}
119
120function sp_since_baseline(array $species, array $groups, int $thisMonth, array $monthNames): string
121{
122 $feed = sp_feed();
123 $sightings = array_values(array_filter(is_array($feed['sightings'] ?? null) ? $feed['sightings'] : [],
124 fn ($x) => is_array($x) && ($x['confidence'] ?? '') !== 'unsure' && is_string($x['date'] ?? null) && preg_match('/^\d{4}-\d\d-\d\d$/', $x['date']) && strtotime($x['date']) !== false && !empty($x['species'])));
125 $byId = []; // species id => sightings
126 $new = []; // name => sightings of species not in the baseline
127 foreach ($sightings as $x) {
128 if (!empty($x['species_id'])) { $byId[$x['species_id']][] = $x; } else { $new[mb_strtolower((string) $x['species'])][] = $x; }
129 }
130 $watch = array_values(array_filter($species, fn ($sp) => $sp['group'] !== 'plant')); // plants are checked by season, not by sighting
131 $seen = array_values(array_filter($watch, fn ($sp) => !empty($byId[$sp['id']])));
132 $notYet = array_values(array_filter($watch, fn ($sp) => empty($byId[$sp['id']])));
133 $plantsSeen = count(array_filter($species, fn ($sp) => $sp['group'] === 'plant' && !empty($byId[$sp['id']])));
134 $base = $feed['baseline_dates'] ?? [];
135 $baseLabel = $base !== [] ? date('j M Y', strtotime($base[0])).' to '.date('j M Y', strtotime(end($base))) : '2021';
136
137 $out = '<h2 class="spgroup" id="since">Since the baseline: what has checked out</h2>';
138 $out .= '<p class="lede">Two comparisons. First, the surveyors\' own count days, eleven visits between 2011 and 2021, set against the period each species is expected here: a ring marks a month in which the species was actually counted on one of those days. Second, every sighting logged since by the team on the operations dashboard, and later by the wildlife scanner, set against the '.count($watch).' birds, fish and invertebrates of the baseline. Plants are checked by season rather than by sighting. Guest reports are not logged, since they cannot be checked.</p>';
139 if (!empty($feed['unavailable'])) { $out .= '<div class="alert">The sightings register could not be read just now; the last known state is shown.</div>'; }
140
141 /* stats */
142 $out .= '<div class="spstats"><div class="spstat"><b>'.count($seen).'</b><span>of '.count($watch).' seen again</span></div>';
143 $out .= '<div class="spstat"><b>'.count($notYet).'</b><span>not seen yet</span></div>';
144 $out .= '<div class="spstat"><b>'.count($new).'</b><span>species new to this place</span></div>';
145 $out .= '<div class="spstat"><b>'.count($sightings).'</b><span>sightings logged</span></div>';
146 if ($plantsSeen > 0) { $out .= '<div class="spstat"><b>'.$plantsSeen.'</b><span>plants logged as seen</span></div>'; }
147 $out .= '</div>';
148
149 /* chart: expected and seen, by month */
150 $expected = array_fill(1, 12, 0); $seenM = array_fill(1, 12, 0);
151 foreach ($watch as $sp) {
152 $months = [];
153 foreach ($byId[$sp['id']] ?? [] as $x) { $months[(int) date('n', strtotime($x['date']))] = true; }
154 for ($m = 1; $m <= 12; $m++) {
155 if (in_array($m, $sp['months']['present'] ?? [], true)) { $expected[$m]++; }
156 if (isset($months[$m])) { $seenM[$m]++; }
157 }
158 }
159 $max = max(1, max($expected));
160 $W = 720; $H = 190; $left = 8; $right = 8; $top = 22; $baseY = 164; $slot = ($W - $left - $right) / 12; $bw = 22; $gap = 3;
161 $out .= '<div class="card spcmp"><div class="spyear-head"><h3>Expected and seen, by month</h3><span class="kv">species with any sighting in that month since the baseline</span></div>';
162 $out .= '<div class="spyear-legend"><span><i style="background:#fff;opacity:.28"></i>expected from the baseline</span><span><i style="background:#ffd701"></i>seen again</span></div>';
163 $out .= '<svg class="spyear-svg" viewBox="0 0 '.$W.' '.$H.'" role="img" aria-label="Expected and seen species by month">';
164 $nx = $left + ($thisMonth - 1) * $slot;
165 $out .= '<rect x="'.round($nx, 1).'" y="4" width="'.round($slot, 1).'" height="'.($H - 8).'" fill="#fff" fill-opacity=".06" rx="4"/>';
166 $out .= '<line x1="'.$left.'" y1="'.$baseY.'" x2="'.($W - $right).'" y2="'.$baseY.'" stroke="#fff" stroke-opacity=".18"/>';
167 for ($m = 1; $m <= 12; $m++) {
168 $x0 = $left + ($m - 1) * $slot + ($slot - 2 * $bw - $gap) / 2;
169 $eh = ($baseY - $top - 6) * $expected[$m] / $max; $sh = ($baseY - $top - 6) * $seenM[$m] / $max;
170 $out .= '<g><title>'.h($monthNames[$m - 1].': '.$expected[$m].' expected, '.$seenM[$m].' seen again').'</title>';
171 if ($expected[$m] > 0) { $out .= sp_bar($x0, $baseY - $eh, $bw, $eh, '#ffffff', .28); }
172 if ($seenM[$m] > 0) { $out .= sp_bar($x0 + $bw + $gap, $baseY - $sh, $bw, $sh, '#ffd701', 1); }
173 $out .= '<text x="'.round($x0 + $bw / 2, 1).'" y="'.round($baseY - $eh - 4, 1).'" class="spyear-val">'.$expected[$m].'</text>';
174 $out .= '<text x="'.round($x0 + $bw + $gap + $bw / 2, 1).'" y="'.round($baseY - $sh - 4, 1).'" class="spyear-val" fill="#ffd701">'.$seenM[$m].'</text>';
175 $out .= '<text x="'.round($left + ($m - 1) * $slot + $slot / 2, 1).'" y="'.($baseY + 16).'" class="spyear-m'.($m === $thisMonth ? ' now' : '').'">'.h($monthNames[$m - 1]).'</text></g>';
176 }
177 $out .= '</svg></div>';
178
179 /* timeline: sightings per month since the baseline */
180 $start = $base !== [] ? strtotime(substr($base[0], 0, 7).'-01') : strtotime('2021-06-01');
181 $months = []; $t = $start; $now = time();
182 while ($t <= $now && count($months) < 200) { $months[] = date('Y-m', $t); $t = strtotime('+1 month', $t); }
183 $perMonth = array_fill_keys($months, 0); $spMonth = array_fill_keys($months, []);
184 foreach ($sightings as $x) { $k = substr((string) $x['date'], 0, 7); if (isset($perMonth[$k])) { $perMonth[$k]++; $spMonth[$k][$x['species']] = true; } }
185 $n = count($months); $max2 = max(1, max($perMonth));
186 $W2 = 720; $H2 = 150; $l2 = 8; $r2 = 8; $t2 = 18; $b2 = 122; $cw = ($W2 - $l2 - $r2) / max(1, $n);
187 $out .= '<div class="card spcmp"><div class="spyear-head"><h3>Sightings over time</h3><span class="kv">logged per month since the baseline surveys; the survey days are marked</span></div>';
188 $out .= '<svg class="spyear-svg" viewBox="0 0 '.$W2.' '.$H2.'" role="img" aria-label="Sightings per month since the baseline">';
189 $out .= '<line x1="'.$l2.'" y1="'.$b2.'" x2="'.($W2 - $r2).'" y2="'.$b2.'" stroke="#fff" stroke-opacity=".18"/>';
190 foreach ($base as $bd) {
191 $bi = array_search(substr($bd, 0, 7), $months, true);
192 if ($bi !== false) { $bx = $l2 + $bi * $cw + $cw * ((int) date('j', strtotime($bd)) / 31); $out .= '<line x1="'.round($bx, 1).'" y1="'.($t2 - 2).'" x2="'.round($bx, 1).'" y2="'.$b2.'" stroke="#3987e5" stroke-width="1.5" stroke-dasharray="3 3"><title>Baseline survey '.h(date('j M Y', strtotime($bd))).'</title></line>'; }
193 }
194 foreach ($months as $i => $ym) {
195 $x = $l2 + $i * $cw; $v = $perMonth[$ym]; $hgt = ($b2 - $t2 - 4) * $v / $max2;
196 if ($v > 0) { $out .= '<g><title>'.h(date('M Y', strtotime($ym.'-01')).': '.$v.' sighting'.($v === 1 ? '' : 's').', '.count($spMonth[$ym]).' species').'</title>'.sp_bar($x + 1, $b2 - $hgt, max(2, $cw - 2), $hgt, '#ffd701', 1).'<text x="'.round($x + $cw / 2, 1).'" y="'.round($b2 - $hgt - 3, 1).'" class="spyear-val" fill="#ffd701">'.$v.'</text></g>'; }
197 if (substr($ym, 5) === '01' || $i === 0) { $out .= '<text x="'.round($x + 2, 1).'" y="'.($b2 + 14).'" class="spyear-m" style="text-anchor:start">'.substr($ym, 0, 4).'</text>'; }
198 }
199 $out .= '</svg>';
200 if ($sightings === []) { $out .= '<p class="kv" style="margin:6px 0 0">No sightings logged yet. The register opens on the operations dashboard; the baseline stands alone until the first one.</p>'; }
201 $out .= '<div class="spyear-legend"><span><i style="background:#3987e5"></i>baseline survey day</span><span><i style="background:#ffd701"></i>sightings logged</span></div></div>';
202
203 /* the survey days themselves */
204 $days = $GLOBALS['d']['survey_days'] ?? [];
205 if ($days !== []) {
206 $perDay = [];
207 foreach ($species as $sp) { foreach ($sp['survey_counts'] ?? [] as $c) { $perDay[$c['date']][$sp['id']] = true; } }
208 $out .= '<div class="card spcmp"><div class="spyear-head"><h3>The survey days</h3><span class="kv">what was done on each visit and how many species were recorded</span></div><div class="spdays">';
209 foreach ($days as $dy) {
210 $n = count($perDay[$dy['date']] ?? []);
211 $out .= '<div class="spday"><b>'.h(date('j M Y', strtotime($dy['date']))).'</b><span>'.h($dy['what']).'</span><em>'.$n.' species</em></div>';
212 }
213 $out .= '</div><p class="kv">Counts on the lagoon were made by telescope from the main road; the 2021 visits also mapped territories on the point, fished the streams and walked the vegetation. Sampling on eleven days cannot show every month, so the rings below are evidence of presence, never of absence.</p></div>';
214 }
215
216 /* per species: the baseline calendar with the sightings on it */
217 $out .= '<div class="card spcmp"><div class="spyear-head"><h3>Species by species</h3><span class="kv">expected period in colour; a ring where the surveyors counted it on a survey day; a dot where the register has a sighting</span></div>';
218 $rows = $seen;
219 usort($rows, function ($a, $b) use ($byId) { $la = max(array_column($byId[$a['id']], 'date')); $lb = max(array_column($byId[$b['id']], 'date')); return strcmp($lb, $la) ?: strcmp($a['english'], $b['english']); });
220 $ny = $notYet; usort($ny, fn ($a, $b) => strcmp($a['group'], $b['group']) ?: strcmp($a['english'], $b['english']));
221 $out .= '<div class="sptl spcmp-tl"><div class="sptl-row sptl-head"><span></span>';
222 for ($m = 1; $m <= 12; $m++) { $out .= '<span class="'.($m === $thisMonth ? 'now' : '').'">'.substr($monthNames[$m - 1], 0, 1).'</span>'; }
223 $out .= '<span class="sptl-status">status</span></div>';
224 foreach (array_merge($rows, $ny) as $sp) {
225 $col = SP_COLOURS[$sp['group']] ?? '#a1a1aa';
226 $pres = $sp['months']['present'] ?? []; $br = $sp['months']['breeding'] ?? [];
227 $dots = []; $rings = [];
228 foreach ($byId[$sp['id']] ?? [] as $x) { $mm = (int) date('n', strtotime($x['date'])); $dots[$mm] = ($dots[$mm] ?? 0) + 1; }
229 foreach ($sp['survey_counts'] ?? [] as $c) { $mm = (int) date('n', strtotime($c['date'])); $rings[$mm][] = date('j M Y', strtotime($c['date'])).': '.$c['count'].' ('.$c['kind'].')'; }
230 $out .= '<div class="sptl-row" style="--c:'.$col.'"><a href="#'.h($sp['id']).'">'.h($sp['english']).'</a>';
231 for ($m = 1; $m <= 12; $m++) {
232 $cls = in_array($m, $br, true) ? 'b' : (in_array($m, $pres, true) ? 'p' : '');
233 $d = $dots[$m] ?? 0; $r = $rings[$m] ?? [];
234 $tip = $sp['english'].', '.$monthNames[$m - 1].': '.($cls === 'b' ? 'breeding expected' : ($cls === 'p' ? 'expected' : 'not expected')).($r !== [] ? '. Survey: '.implode('; ', $r) : '').($d ? '. Register: '.$d.' sighting'.($d === 1 ? '' : 's') : '');
235 $out .= '<span class="'.$cls.($m === $thisMonth ? ' now' : '').($d ? ' seen' : '').($r !== [] ? ' ring' : '').'" title="'.h($tip).'">'.($r !== [] ? '<u></u>' : '').($d ? '<i></i>' : '').'</span>';
236 }
237 $sd = count(array_unique(array_column($sp['survey_counts'] ?? [], 'date')));
238 if (!empty($byId[$sp['id']])) {
239 $last = max(array_column($byId[$sp['id']], 'date') ?: ['']);
240 $out .= '<span class="sptl-status ok">seen again · '.h($last !== '' ? date('j M Y', strtotime($last)) : '').'</span>';
241 } else {
242 $out .= '<span class="sptl-status">'.($sd ? $sd.' survey day'.($sd === 1 ? '' : 's').' · not since' : 'not seen').'</span>';
243 }
244 $out .= '</div>';
245 }
246 $out .= '</div>';
247 if ($new !== []) {
248 $out .= '<h4 class="splabel" style="margin-top:16px">New to this place, not in the 2021 baseline</h4><ul class="spfacts">';
249 foreach ($new as $name => $xs) {
250 $last = max(array_column($xs, 'date')); $first = $xs[0]['species'];
251 $out .= '<li><b>'.h(ucfirst($first)).'</b>: '.count($xs).' sighting'.(count($xs) === 1 ? '' : 's').', last on '.h(date('j M Y', strtotime($last))).(!empty($xs[0]['place']) ? ', '.h($xs[0]['place']) : '').'</li>';
252 }
253 $out .= '</ul>';
254 }
255 $out .= '<div class="spyear-legend" style="margin-top:8px"><span><i style="background:#fff;opacity:.38"></i>expected</span><span><i style="background:#fff"></i>breeding, spawning</span><span><i style="border:2px solid #3987e5;background:none"></i>counted on a survey day</span><span><i style="background:#fff;border-radius:50%"></i>register sighting</span></div>';
256 $out .= '<p class="note">Sightings are logged on the operations dashboard by the team, and later by the wildlife scanner and the acoustic monitors; each carries a date, a count where given, a place and a confidence, never a person. Guest reports are not logged.</p></div>';
257 return $out;
258}
259
260function sp_timeline(array $rows, string $group, int $thisMonth, array $monthNames): string
261{
262 $col = SP_COLOURS[$group] ?? '#a1a1aa';
263 $sub = SP_SUBLABEL[$group] ?? '';
264 usort($rows, function ($a, $b) {
265 $pa = $a['months']['present'] ?? []; $pb = $b['months']['present'] ?? [];
266 return (min($pa ?: [13]) <=> min($pb ?: [13])) ?: (count($pb) <=> count($pa)) ?: strcmp($a['english'], $b['english']);
267 });
268 $out = '<div class="sptl" style="--c:'.$col.'"><div class="sptl-row sptl-head"><span></span>';
269 for ($m = 1; $m <= 12; $m++) { $out .= '<span class="'.($m === $thisMonth ? 'now' : '').'">'.substr($monthNames[$m - 1], 0, 1).'</span>'; }
270 $out .= '</div>';
271 foreach ($rows as $sp) {
272 $pres = $sp['months']['present'] ?? []; $br = $sp['months']['breeding'] ?? [];
273 $out .= '<div class="sptl-row"><a href="#'.h($sp['id']).'">'.h($sp['english']).'</a>';
274 for ($m = 1; $m <= 12; $m++) {
275 $cls = in_array($m, $br, true) ? 'b' : (in_array($m, $pres, true) ? 'p' : '');
276 $tip = $sp['english'].', '.$monthNames[$m - 1].': '.($cls === 'b' ? $sub : ($cls === 'p' ? 'present' : 'not expected'));
277 $out .= '<span class="'.$cls.($m === $thisMonth ? ' now' : '').'" title="'.h($tip).'"></span>';
278 }
279 $out .= '</div>';
280 }
281 $out .= '<div class="spyear-legend"><span><i style="background:'.$col.';opacity:.38"></i>present</span>'.($sub !== '' ? '<span><i style="background:'.$col.'"></i>'.h($sub).'</span>' : '').'</div></div>';
282 return $out;
283}
284
285$thisMonth = (int) gmdate('n');
286$mParam = (int) ($_GET['m'] ?? $thisMonth);
287if ($mParam < 1 || $mParam > 12) { $mParam = $thisMonth; }
288$hereNow = array_values(array_filter($species, fn ($s) => in_array($mParam, $s['months']['present'] ?? [], true) && $s['group'] !== 'plant'));
289$plantsNow = array_values(array_filter($species, fn ($s) => in_array($mParam, $s['months']['present'] ?? [], true) && $s['group'] === 'plant'));
290
291page_header('Species', 'species');
292echo '<h1>Species of Skerðingsstaðir and Lárvaðall</h1>';
293echo '<p class="lede">Before anything was built, two independent field surveys in 2021 recorded what lives on this land and in the lagoon beside it: the birds that breed on the point, the fish that run up from the fjord, the plants that make up each habitat. This page is that baseline, species by species, with what the surveyors found here, what is known about each one, and the months you are likely to meet it. The Outpost, Districthive\'s Human Recharging Station, now stands on the point; later counts will be compared against these.</p>';
294
295if ($species === []) {
296 echo '<div class="card"><h3>Species</h3><div class="kv">The species data has not been installed yet.</div></div>';
297 page_footer();
298 return;
299}
300
301/* summary strip */
302echo '<div class="spstats">';
303foreach ($groups as $g => $label) {
304 if (empty($counts[$g])) { continue; }
305 echo '<a class="spstat" href="#'.h($g).'"><b>'.(int) $counts[$g].'</b><span>'.h($label).'</span></a>';
306}
307echo '<div class="spstat"><b>'.count($species).'</b><span>species recorded</span></div>';
308echo '</div>';
309
310/* this month */
311echo '<section class="card spnow"><h3>Who is here in '.h($monthNames[$mParam - 1]).'</h3>';
312echo '<form method="get" class="spmonths"><input type="hidden" name="p" value="species">';
313for ($i = 1; $i <= 12; $i++) {
314 echo '<button name="m" value="'.$i.'" class="'.($i === $mParam ? 'on' : '').($i === $thisMonth ? ' now' : '').'" type="submit">'.$monthNames[$i - 1].'</button>';
315}
316echo '</form>';
317if ($hereNow === []) {
318 echo '<p class="kv">No bird or fish record for this month; the surveys were made in summer and the winter picture is inferred from what is known of each species.</p>';
319} else {
320 echo '<div class="spchips">';
321 foreach ($hereNow as $s) {
322 $b = in_array($mParam, $s['months']['breeding'] ?? [], true);
323 echo '<a class="spchip'.($b ? ' breeding' : '').'" href="#'.h($s['id']).'">'.($s['image']['file'] ?? '' ? '<img src="assets/species/img/'.h($s['image']['file']).'" alt="" loading="lazy">' : '').'<span>'.h($s['english']).'</span>'.($b ? '<em>breeding</em>' : '').'</a>';
324 }
325 echo '</div>';
326}
327if ($plantsNow !== []) {
328 echo '<p class="kv" style="margin-top:8px">'.count($plantsNow).' of the recorded plants are in leaf or flower this month.</p>';
329}
330echo '<p class="note">Months are drawn from each species\' known Icelandic calendar (breeding, passage, wintering, growing season), not from counts made here every month. The survey dates themselves are given on every card.</p>';
331echo '</section>';
332
333/* the year, month by month */
334echo '<h2 class="spgroup">The year, month by month</h2>';
335echo '<p class="lede">How many of the recorded species you can expect in each month, and how many are breeding, spawning or in flower. The pale bar is every species present; the solid bar is the subset in its season. The current month is shaded.</p>';
336echo sp_year_chart($species, $groups, $thisMonth, $monthNames);
337echo '<div class="grid cols2 sptl-grid">';
338$byGroup = [];
339foreach ($species as $sp) { $byGroup[$sp['group']][] = $sp; }
340if (!empty($byGroup['bird'])) { echo '<div class="card"><h3>Bird calendar</h3><p class="kv">Residents first, then arrivals in order. Solid cells are the breeding months.</p>'.sp_timeline($byGroup['bird'], 'bird', $thisMonth, $monthNames).'</div>'; }
341echo '<div>';
342if (!empty($byGroup['fish'])) { echo '<div class="card"><h3>Fish calendar</h3><p class="kv">Solid cells are the spawning months.</p>'.sp_timeline($byGroup['fish'], 'fish', $thisMonth, $monthNames).'</div>'; }
343if (!empty($byGroup['invertebrate'])) { echo '<div class="card" style="margin-top:16px"><h3>Invertebrates</h3>'.sp_timeline($byGroup['invertebrate'], 'invertebrate', $thisMonth, $monthNames).'</div>'; }
344echo '</div></div>';
345if (!empty($byGroup['plant'])) {
346 echo '<details class="card sptl-plants"><summary><b>Plant calendar</b> <span class="kv">'.count($byGroup['plant']).' plants: growing season and flowering months</span></summary>'.sp_timeline($byGroup['plant'], 'plant', $thisMonth, $monthNames).'</details>';
347}
348
349/* since the baseline */
350echo sp_since_baseline($species, $groups, $thisMonth, $monthNames);
351
352/* filter */
353echo '<div class="spfilter" role="tablist">';
354echo '<button class="on" data-g="all">All</button>';
355foreach ($groups as $g => $label) { if (!empty($counts[$g])) { echo '<button data-g="'.h($g).'">'.h($label).' <span>'.(int) $counts[$g].'</span></button>'; } }
356echo '<input type="search" id="spq" placeholder="Search a name…" aria-label="Search species">';
357echo '</div>';
358
359/* cards by group */
360$srcTitle = [];
361foreach ($sources as $src) { $srcTitle[$src['key']] = $src['short'] ?? $src['title']; }
362foreach ($groups as $g => $label) {
363 if (empty($counts[$g])) { continue; }
364 echo '<h2 id="'.h($g).'" class="spgroup" data-g="'.h($g).'">'.h($label).' <span class="kv">'.(int) $counts[$g].'</span></h2>';
365 echo '<div class="grid spgrid" data-g="'.h($g).'">';
366 foreach ($species as $s) {
367 if ($s['group'] !== $g) { continue; }
368 $img = $s['image']['file'] ?? '';
369 echo '<article class="card spcard" id="'.h($s['id']).'" data-g="'.h($g).'" data-q="'.h(strtolower($s['english'].' '.$s['icelandic'].' '.$s['scientific'])).'">';
370 if ($img !== '') {
371 echo '<figure class="spimg"><img src="assets/species/img/'.h($img).'" alt="'.h($s['english']).'" loading="lazy">';
372 if (!empty($s['image']['credit'])) { echo '<figcaption>'.h($s['image']['credit']).(!empty($s['image']['license']) ? ' · '.h($s['image']['license']) : '').'</figcaption>'; }
373 echo '</figure>';
374 } else {
375 echo '<div class="spimg spimg-none"><span>'.h(mb_substr($s['english'], 0, 1)).'</span></div>';
376 }
377 echo '<div class="spbody"><h3>'.h($s['english']).'</h3>';
378 echo '<div class="spnames">'.h($s['icelandic']).($s['scientific'] !== '' ? ' · <i>'.h($s['scientific']).'</i>' : '').'</div>';
379 if (!empty($s['status'])) { echo '<div class="spstatus"><span class="badge badge-live">'.h($s['status']).'</span></div>'; }
380 if (!empty($s['site_facts'])) {
381 echo '<div class="splabel">In the survey</div><ul class="spfacts">';
382 foreach ($s['site_facts'] as $f) { echo '<li>'.h($f['text']).(isset($f['source']) ? ' <span class="spref">'.h($srcTitle[$f['source']] ?? $f['source']).(isset($f['page']) ? ', p. '.(int) $f['page'] : '').'</span>' : '').'</li>'; }
383 echo '</ul>';
384 }
385 if (!empty($s['about'])) {
386 echo '<div class="splabel">About</div><ul class="spfacts spabout">';
387 foreach ($s['about'] as $f) { echo '<li>'.h($f).'</li>'; }
388 echo '</ul>';
389 }
390 $present = $s['months']['present'] ?? [];
391 $breed = $s['months']['breeding'] ?? [];
392 if ($present !== []) {
393 echo '<div class="splabel">'.h($s['months']['label'] ?? 'When').'</div><div class="spstrip" aria-label="Months present">';
394 for ($i = 1; $i <= 12; $i++) {
395 $cls = in_array($i, $breed, true) ? 'b' : (in_array($i, $present, true) ? 'p' : '');
396 echo '<span class="'.$cls.'" title="'.$monthNames[$i - 1].'">'.substr($monthNames[$i - 1], 0, 1).'</span>';
397 }
398 echo '</div>';
399 if (!empty($s['months']['note'])) { echo '<div class="kv">'.h($s['months']['note']).'</div>'; }
400 }
401 if (!empty($s['conservation'])) { echo '<div class="kv spcons">'.h($s['conservation']).'</div>'; }
402 if (!empty($s['wiki'])) { echo '<div class="kv"><a href="'.h($s['wiki']).'" target="_blank" rel="noopener">read more</a></div>'; }
403 echo '</div></article>';
404 }
405 echo '</div>';
406}
407
408/* gallery from the reports */
409if ($gallery !== []) {
410 echo '<h2 class="spgroup">From the surveys</h2><div class="grid cols2 spgallery">';
411 foreach ($gallery as $gph) {
412 echo '<figure class="card spshot"><img src="assets/species/img/'.h($gph['file']).'" alt="'.h($gph['caption']).'" loading="lazy"><figcaption>'.h($gph['caption']).'<span class="kv">'.h($gph['credit']).'</span></figcaption></figure>';
413 }
414 echo '</div>';
415}
416
417/* survey facts and sources */
418if ($survey !== []) {
419 echo '<h2 class="spgroup">How the surveys were made</h2><div class="grid cols2">';
420 foreach ($survey as $blk) {
421 echo '<div class="card"><h3>'.h($blk['title']).'</h3><ul class="spfacts">';
422 foreach ($blk['facts'] as $f) { echo '<li>'.h($f).'</li>'; }
423 echo '</ul></div>';
424 }
425 echo '</div>';
426}
427if ($sources !== []) {
428 echo '<h2 class="spgroup">Sources</h2><div class="card"><ul class="spfacts">';
429 foreach ($sources as $src) { echo '<li><b>'.h($src['title']).'</b>. '.h($src['authors']).'. '.h($src['org']).', '.h($src['date']).'.'.(!empty($src['note']) ? ' '.h($src['note']) : '').'</li>'; }
430 echo '</ul><p class="note">Species pictures are from Wikimedia Commons under their stated licences, or from the survey reports where credited. General facts summarise the published literature on each species; the site facts are the surveyors\' own findings. Corrections are welcome at '.h(CONTACT_EMAIL).'.</p></div>';
431}
432
433echo '<script>(function(){var bs=document.querySelectorAll(".spfilter button"),q=document.getElementById("spq");function apply(){var g=document.querySelector(".spfilter button.on").dataset.g,t=(q.value||"").toLowerCase().trim();document.querySelectorAll(".spcard").forEach(function(c){var ok=(g==="all"||c.dataset.g===g)&&(!t||c.dataset.q.indexOf(t)>-1);c.style.display=ok?"":"none";});document.querySelectorAll(".spgroup[data-g]").forEach(function(h){var grid=h.nextElementSibling,any=grid&&Array.prototype.some.call(grid.children,function(c){return c.style.display!=="none";});h.style.display=any?"":"none";if(grid)grid.style.display=any?"":"none";});}bs.forEach(function(b){b.addEventListener("click",function(){bs.forEach(function(x){x.classList.remove("on")});b.classList.add("on");apply();});});q.addEventListener("input",apply);})();</script>';
434page_footer();
435

Lines can be cited as pages/species.php L120-L140, platform code 2026.09.17-a2894de; add #L120-L140 to this page's address to highlight them. Source shown for scrutiny of the research platform. All rights reserved. Not offered as installable software.