pages/species.php
Snapshot 2026.09.17-a2894de (current) · 435 lines · 32,793 bytes · tree · plain text
SHA-256
dfa4a48f1e0915d5696a26ccadfe694ffe92a39c066c8b45baef26a6a57393b8Functions: 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. */ |
| 9 | require_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)); |
| 19 | usort($species, fn ($a, $b) => ($order[$a['group']] ?? 9) <=> ($order[$b['group']] ?? 9) ?: strcmp($a['english'], $b['english'])); |
| 20 | $counts = []; |
| 21 | foreach ($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. */ |
| 29 | const SP_COLOURS = ['bird' => '#3987e5', 'fish' => '#d95926', 'plant' => '#199e70', 'invertebrate' => '#c98500', 'mammal' => '#9085e9', 'other' => '#d55181']; |
| 30 | const SP_SUBLABEL = ['bird' => 'breeding', 'fish' => 'spawning', 'plant' => 'in flower', 'invertebrate' => '', 'mammal' => '', 'other' => '']; |
| 31 | |
| 32 | function 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. */ |
| 86 | function 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. */ |
| 103 | const SP_FEED = 'https://districthive.com/ai_discover/api/sightings.php'; |
| 104 | |
| 105 | function 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 | |
| 120 | function 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 | |
| 260 | function 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); |
| 287 | if ($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 | |
| 291 | page_header('Species', 'species'); |
| 292 | echo '<h1>Species of Skerðingsstaðir and Lárvaðall</h1>'; |
| 293 | echo '<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 | |
| 295 | if ($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 */ |
| 302 | echo '<div class="spstats">'; |
| 303 | foreach ($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 | } |
| 307 | echo '<div class="spstat"><b>'.count($species).'</b><span>species recorded</span></div>'; |
| 308 | echo '</div>'; |
| 309 | |
| 310 | /* this month */ |
| 311 | echo '<section class="card spnow"><h3>Who is here in '.h($monthNames[$mParam - 1]).'</h3>'; |
| 312 | echo '<form method="get" class="spmonths"><input type="hidden" name="p" value="species">'; |
| 313 | for ($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 | } |
| 316 | echo '</form>'; |
| 317 | if ($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 | } |
| 327 | if ($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 | } |
| 330 | echo '<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>'; |
| 331 | echo '</section>'; |
| 332 | |
| 333 | /* the year, month by month */ |
| 334 | echo '<h2 class="spgroup">The year, month by month</h2>'; |
| 335 | echo '<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>'; |
| 336 | echo sp_year_chart($species, $groups, $thisMonth, $monthNames); |
| 337 | echo '<div class="grid cols2 sptl-grid">'; |
| 338 | $byGroup = []; |
| 339 | foreach ($species as $sp) { $byGroup[$sp['group']][] = $sp; } |
| 340 | if (!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>'; } |
| 341 | echo '<div>'; |
| 342 | if (!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>'; } |
| 343 | if (!empty($byGroup['invertebrate'])) { echo '<div class="card" style="margin-top:16px"><h3>Invertebrates</h3>'.sp_timeline($byGroup['invertebrate'], 'invertebrate', $thisMonth, $monthNames).'</div>'; } |
| 344 | echo '</div></div>'; |
| 345 | if (!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 */ |
| 350 | echo sp_since_baseline($species, $groups, $thisMonth, $monthNames); |
| 351 | |
| 352 | /* filter */ |
| 353 | echo '<div class="spfilter" role="tablist">'; |
| 354 | echo '<button class="on" data-g="all">All</button>'; |
| 355 | foreach ($groups as $g => $label) { if (!empty($counts[$g])) { echo '<button data-g="'.h($g).'">'.h($label).' <span>'.(int) $counts[$g].'</span></button>'; } } |
| 356 | echo '<input type="search" id="spq" placeholder="Search a name…" aria-label="Search species">'; |
| 357 | echo '</div>'; |
| 358 | |
| 359 | /* cards by group */ |
| 360 | $srcTitle = []; |
| 361 | foreach ($sources as $src) { $srcTitle[$src['key']] = $src['short'] ?? $src['title']; } |
| 362 | foreach ($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 */ |
| 409 | if ($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 */ |
| 418 | if ($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 | } |
| 427 | if ($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 | |
| 433 | echo '<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>'; |
| 434 | page_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.