Compare commits
11
Commits
08155e0100
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71860b9dd9 | ||
|
|
47818fba33 | ||
|
|
24cdd9c2f0 | ||
|
|
30bf9102ea | ||
|
|
7c604ae2be | ||
|
|
ff8a9950f4 | ||
|
|
df3ecdae35 | ||
|
|
dd0ce5b44c | ||
|
|
db537411fe | ||
|
|
96078f3584 | ||
|
|
15a64501a9 |
@@ -30,3 +30,7 @@ dist/
|
|||||||
*.ps1
|
*.ps1
|
||||||
*.py
|
*.py
|
||||||
secrets.local.php
|
secrets.local.php
|
||||||
|
/data
|
||||||
|
/.fraxai
|
||||||
|
/data
|
||||||
|
data/tracking.json
|
||||||
|
|||||||
+16
-4
@@ -56,7 +56,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ab"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ab)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ab"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ab)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -72,7 +75,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -89,6 +99,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>About SunScope</h1>
|
<h1>About SunScope</h1>
|
||||||
@@ -147,7 +158,7 @@
|
|||||||
<p class="profile-name"><a href="./columns.html">📊 Column reference →</a></p>
|
<p class="profile-name"><a href="./columns.html">📊 Column reference →</a></p>
|
||||||
<p>
|
<p>
|
||||||
Every column in the forecast table explained in plain language, from Air and Shade through
|
Every column in the forecast table explained in plain language, from Air and Shade through
|
||||||
to Lightning Potential, air quality, pollen, and SunSoak.
|
to lightning warnings, air quality, pollen, and SunSoak.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,7 +206,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
// POST { id } -> deletes an entry the caller owns.
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
require_method('POST');
|
||||||
|
|
||||||
|
$user = require_user();
|
||||||
|
$body = json_body();
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($id <= 0) json_error('Invalid id.');
|
||||||
|
|
||||||
|
$stmt = db()->prepare('DELETE FROM journal_entries WHERE id = ? AND user_id = ?');
|
||||||
|
$stmt->execute([$id, $user['id']]);
|
||||||
|
if ($stmt->rowCount() === 0) {
|
||||||
|
json_error('Entry not found.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out(['ok' => true]);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
// GET -> { ok:true, entries: [{ id, date, endDate, note, profile, solarEnv, reading, locationName, lat, lon, createdAt }, ...] }, newest first.
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
|
||||||
|
$user = require_user();
|
||||||
|
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT id, entry_date, end_date, note, profile, solar_env, reading, location_name, lat, lon, created_at
|
||||||
|
FROM journal_entries
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY entry_date DESC, id DESC'
|
||||||
|
);
|
||||||
|
$stmt->execute([$user['id']]);
|
||||||
|
|
||||||
|
$entries = array_map(fn($r) => [
|
||||||
|
'id' => (int)$r['id'],
|
||||||
|
'date' => $r['entry_date'],
|
||||||
|
'endDate' => $r['end_date'],
|
||||||
|
'note' => $r['note'],
|
||||||
|
'profile' => $r['profile'],
|
||||||
|
'solarEnv' => $r['solar_env'],
|
||||||
|
'reading' => $r['reading'] !== null ? (float)$r['reading'] : null,
|
||||||
|
'locationName' => $r['location_name'],
|
||||||
|
'lat' => $r['lat'] !== null ? (float)$r['lat'] : null,
|
||||||
|
'lon' => $r['lon'] !== null ? (float)$r['lon'] : null,
|
||||||
|
'createdAt' => $r['created_at'],
|
||||||
|
], $stmt->fetchAll());
|
||||||
|
|
||||||
|
json_out(['ok' => true, 'entries' => $entries]);
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
// POST { date, endDate?, note, id?, profile?, solarEnv?, reading?, locationName?, lat?, lon? }
|
||||||
|
// -> creates a new entry, or updates one the caller owns when id is given.
|
||||||
|
// { ok:true, id }.
|
||||||
|
//
|
||||||
|
// endDate is optional - a note can span a period (e.g. a holiday week)
|
||||||
|
// rather than a single day. Omitted/equal-to-date means a single-day entry.
|
||||||
|
//
|
||||||
|
// profile/solarEnv/reading/locationName/lat/lon are an optional attached
|
||||||
|
// "reading" - a computed felt-temperature number the browser derives
|
||||||
|
// client-side from the live 14-day forecast (same compute.js the main app
|
||||||
|
// uses, same peak-of-day rule the day tabs use, taken across the whole
|
||||||
|
// range for a multi-day entry). The server never touches the physics; it
|
||||||
|
// just stores whatever number the browser already computed, the same way
|
||||||
|
// it stores the note text. profile AND solarEnv are both stored because
|
||||||
|
// both change the number - the dashboard filters its entry list and graph
|
||||||
|
// down to whichever combination is currently selected, so a Desert reading
|
||||||
|
// never turns up mixed into a Beach trend.
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
require_method('POST');
|
||||||
|
|
||||||
|
$user = require_user();
|
||||||
|
$body = json_body();
|
||||||
|
|
||||||
|
$date = (string)($body['date'] ?? '');
|
||||||
|
$note = trim((string)($body['note'] ?? ''));
|
||||||
|
$id = isset($body['id']) ? (int)$body['id'] : null;
|
||||||
|
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
json_error('Invalid date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$endDate = null;
|
||||||
|
if (isset($body['endDate']) && $body['endDate'] !== null && $body['endDate'] !== '') {
|
||||||
|
$endDate = (string)$body['endDate'];
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
|
||||||
|
json_error('Invalid end date.');
|
||||||
|
}
|
||||||
|
if ($endDate < $date) json_error('End date must be on or after the start date.');
|
||||||
|
if ($endDate === $date) $endDate = null; // same day - store as a single-day entry
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($note === '') {
|
||||||
|
json_error('Note cannot be empty.');
|
||||||
|
}
|
||||||
|
if (strlen($note) > 4000) {
|
||||||
|
json_error('Note is too long.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = null;
|
||||||
|
$solarEnv = null;
|
||||||
|
$reading = null;
|
||||||
|
$locationName = null;
|
||||||
|
$lat = null;
|
||||||
|
$lon = null;
|
||||||
|
|
||||||
|
if (isset($body['reading']) && $body['reading'] !== null && $body['reading'] !== '') {
|
||||||
|
if (!is_numeric($body['reading'])) json_error('Invalid reading.');
|
||||||
|
$reading = round((float)$body['reading'], 1);
|
||||||
|
if ($reading < -100 || $reading > 150) json_error('Reading out of range.');
|
||||||
|
|
||||||
|
$profile = (string)($body['profile'] ?? '');
|
||||||
|
if (!in_array($profile, ['basic', 'home', 'vehicle', 'pets'], true)) {
|
||||||
|
json_error('Invalid profile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$solarEnv = (string)($body['solarEnv'] ?? '');
|
||||||
|
if (!in_array($solarEnv, ['open', 'urban', 'beach', 'river', 'forest', 'openwater', 'alpine', 'desert'], true)) {
|
||||||
|
json_error('Invalid solar model.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$locationName = trim((string)($body['locationName'] ?? ''));
|
||||||
|
if ($locationName === '' || strlen($locationName) > 255) json_error('Invalid location.');
|
||||||
|
|
||||||
|
if (!is_numeric($body['lat'] ?? null) || !is_numeric($body['lon'] ?? null)) {
|
||||||
|
json_error('Invalid coordinates.');
|
||||||
|
}
|
||||||
|
$lat = round((float)$body['lat'], 5);
|
||||||
|
$lon = round((float)$body['lon'], 5);
|
||||||
|
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) json_error('Invalid coordinates.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
// Ownership enforced in the WHERE clause, not a prior SELECT.
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'UPDATE journal_entries
|
||||||
|
SET entry_date = ?, end_date = ?, note = ?, profile = ?, solar_env = ?, reading = ?, location_name = ?, lat = ?, lon = ?
|
||||||
|
WHERE id = ? AND user_id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$date, $endDate, $note, $profile, $solarEnv, $reading, $locationName, $lat, $lon, $id, $user['id']]);
|
||||||
|
if ($stmt->rowCount() === 0) {
|
||||||
|
json_error('Entry not found.', 404);
|
||||||
|
}
|
||||||
|
json_out(['ok' => true, 'id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'INSERT INTO journal_entries (user_id, entry_date, end_date, note, profile, solar_env, reading, location_name, lat, lon)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$user['id'], $date, $endDate, $note, $profile, $solarEnv, $reading, $locationName, $lat, $lon]);
|
||||||
|
json_out(['ok' => true, 'id' => (int)db()->lastInsertId()]);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
// POST { email, password } -> signs the user in.
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
require_method('POST');
|
||||||
|
|
||||||
|
$body = json_body();
|
||||||
|
$email = strtolower(trim($body['email'] ?? ''));
|
||||||
|
$password = (string)($body['password'] ?? '');
|
||||||
|
|
||||||
|
$stmt = db()->prepare('SELECT id, password_hash FROM users WHERE email = ?');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
// Same message whether the email is unknown or the password is wrong -
|
||||||
|
// don't let a login form confirm which emails have accounts.
|
||||||
|
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||||
|
json_error('Incorrect email or password.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
issue_session((int)$user['id']);
|
||||||
|
json_out(['ok' => true, 'email' => $email]);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
require_method('POST');
|
||||||
|
|
||||||
|
clear_session();
|
||||||
|
json_out(['ok' => true]);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
// GET -> { ok:true, user: { email } } if signed in, else { ok:true, user:null }.
|
||||||
|
// Always 200 - this is a status check, not an auth gate.
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
|
||||||
|
$user = current_user();
|
||||||
|
json_out(['ok' => true, 'user' => $user ? ['email' => $user['email']] : null]);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
// POST { email, password } -> creates an account, signs the user in.
|
||||||
|
require __DIR__ . '/../lib/bootstrap.php';
|
||||||
|
require_method('POST');
|
||||||
|
|
||||||
|
$body = json_body();
|
||||||
|
$email = strtolower(trim($body['email'] ?? ''));
|
||||||
|
$password = (string)($body['password'] ?? '');
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
json_error('Enter a valid email address.');
|
||||||
|
}
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
json_error('Password must be at least 8 characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = db()->prepare('SELECT id FROM users WHERE email = ?');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
json_error('An account with that email already exists.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'INSERT INTO users (public_id, email, password_hash) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([new_public_id(), $email, $hash]);
|
||||||
|
|
||||||
|
issue_session((int)db()->lastInsertId());
|
||||||
|
json_out(['ok' => true, 'email' => $email]);
|
||||||
+118
-17
@@ -118,6 +118,23 @@ header a.active { color: #c8922a; }
|
|||||||
}
|
}
|
||||||
.nav-dropdown-menu a.active { color: #c8922a; }
|
.nav-dropdown-menu a.active { color: #c8922a; }
|
||||||
|
|
||||||
|
/* The Account dropdown sits at the far right of the nav row, so a menu
|
||||||
|
centred under it (like About's) would spill past the viewport edge on
|
||||||
|
most screen widths. Right-align it to the toggle instead, growing left,
|
||||||
|
with its labels right-aligned to match the edge it hangs from. */
|
||||||
|
.nav-dropdown--right .nav-dropdown-menu {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
.nav-dropdown--right.nav-dropdown:hover .nav-dropdown-menu,
|
||||||
|
.nav-dropdown--right.nav-dropdown:focus-within .nav-dropdown-menu {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.nav-dropdown--right .nav-dropdown-menu a {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Burger button ──────────────────────────────────────────────────── */
|
/* ── Burger button ──────────────────────────────────────────────────── */
|
||||||
.utci-burger {
|
.utci-burger {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -148,10 +165,106 @@ header a.active { color: #c8922a; }
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Site identity — mirrors .nav-logo in css/base.css so the static pages and
|
||||||
|
the forecast app carry the same wordmark, icon and tagline. The header a
|
||||||
|
rules above dress every nav link, so the uppercase/brass treatment and the
|
||||||
|
hover underline are cancelled here. */
|
||||||
|
.nav-logo {
|
||||||
|
margin-right: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
header a.nav-logo:hover { border-bottom-color: transparent; }
|
||||||
|
|
||||||
|
.nav-logo-wordmark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
.nav-logo-sun,
|
||||||
|
.nav-logo-scope {
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-weight: 250;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-style: normal;
|
||||||
|
color: #1e1208;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.nav-logo-icon {
|
||||||
|
width: 1.26rem; /* same 0.79 ratio to the wordmark as the app's */
|
||||||
|
height: 1.26rem;
|
||||||
|
color: #1e1208;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-logo-tag {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7a5c2a;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile nav drawer ──────────────────────────────────────────────── */
|
/* ── Mobile nav drawer ──────────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
/* Same pinned 72px bar as the app: wordmark centred, links behind the
|
||||||
|
burger. body pads itself to clear the fixed header. */
|
||||||
header {
|
header {
|
||||||
padding: 14px 20px;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 10px 20px;
|
||||||
|
height: 72px;
|
||||||
|
background: #f5edd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 0;
|
||||||
|
pointer-events: none; /* the full-width block must not swallow burger taps */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-wordmark,
|
||||||
|
.nav-logo-tag {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-logo-wordmark {
|
||||||
|
display: block;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-logo-sun,
|
||||||
|
.nav-logo-scope {
|
||||||
|
font-size: 2.1rem;
|
||||||
|
}
|
||||||
|
.nav-logo-icon {
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-burger {
|
.utci-burger {
|
||||||
@@ -207,6 +320,9 @@ header a.active { color: #c8922a; }
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.nav-dropdown-menu a { padding: 0; font-size: 14px; }
|
.nav-dropdown-menu a { padding: 0; font-size: 14px; }
|
||||||
|
/* Right-aligned text is a desktop-popup touch - on mobile everything is
|
||||||
|
one left-indented list, so match About's list instead of standing out. */
|
||||||
|
.nav-dropdown--right .nav-dropdown-menu a { text-align: left; }
|
||||||
|
|
||||||
/* Burger → X animation */
|
/* Burger → X animation */
|
||||||
header.nav-open .utci-burger span:nth-child(1) {
|
header.nav-open .utci-burger span:nth-child(1) {
|
||||||
@@ -238,21 +354,6 @@ header a.active { color: #c8922a; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin-right: auto;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-weight: 250;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
color: #1e1208;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
text-transform: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.05em;
|
|
||||||
}
|
|
||||||
.logo-sun, .logo-scope { color: #1e1208; font-weight: 250; font-style: normal; }
|
|
||||||
.logo-icon { width: 0.82em; height: 0.82em; flex-shrink: 0; }
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
max-width: 780px;
|
max-width: 780px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -434,5 +535,5 @@ footer {
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
h1 { font-size: 1.6rem; }
|
h1 { font-size: 1.6rem; }
|
||||||
main { padding: 2rem 1rem 4rem; }
|
main { padding: 2rem 1rem 4rem; }
|
||||||
header { padding: 14px 16px; }
|
header { padding: 10px 16px; }
|
||||||
}
|
}
|
||||||
|
|||||||
+252
-49
@@ -65,7 +65,14 @@ body {
|
|||||||
color: #1e1208;
|
color: #1e1208;
|
||||||
background: #f5edd6;
|
background: #f5edd6;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 52px 36px 32px;
|
/* The side padding, as a variable: the header and the event note both
|
||||||
|
full-bleed by cancelling it, and would otherwise each need the number
|
||||||
|
restating at every breakpoint that changes it. */
|
||||||
|
--app-pad: 36px;
|
||||||
|
/* No top padding: .utci-topnav is a real header at the very top of the flow
|
||||||
|
now, and supplies its own. Was 52px, reserved for the nav when it was
|
||||||
|
absolutely positioned over this space. */
|
||||||
|
padding: 0 var(--app-pad) 32px;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* overflow-x: clip (not hidden) — `clip` clips horizontal overflow
|
/* overflow-x: clip (not hidden) — `clip` clips horizontal overflow
|
||||||
WITHOUT making this a scroll container, so `position: sticky` on
|
WITHOUT making this a scroll container, so `position: sticky` on
|
||||||
@@ -83,19 +90,27 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── 3. TOP NAVIGATION (Home / About links in the corner) ──────────── */
|
/* ── 3. SITE HEADER (wordmark left, nav links right) ───────────────── */
|
||||||
|
|
||||||
|
/* Rendered as <header id="site-nav"> — the same element the static pages
|
||||||
|
(about.html et al) use — and it now carries the wordmark and tagline that
|
||||||
|
used to be repeated inside .utci-header. It sits FIRST in the flow, at the
|
||||||
|
very top of the page, with the event banner beneath it.
|
||||||
|
Full-bleed: .utci-app pads its content in by --app-pad a side, so the header
|
||||||
|
cancels that with a matching negative margin and pads itself instead — the
|
||||||
|
bar's rule reaches both page edges while its contents still line up with
|
||||||
|
the content below. */
|
||||||
.utci-topnav {
|
.utci-topnav {
|
||||||
position: absolute;
|
position: relative; /* keeps z-index working without leaving the flow */
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
margin: 0 calc(var(--app-pad) * -1) 18px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end; /* .nav-logo's margin-right:auto splits the row */
|
||||||
padding: 0px 32px;
|
padding: 18px var(--app-pad);
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
|
background: #f5edd6;
|
||||||
|
border-bottom: 1px solid #e0d0a8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-topnav a {
|
.utci-topnav a {
|
||||||
@@ -116,9 +131,9 @@ body {
|
|||||||
border-bottom-color: #1e1208;
|
border-bottom-color: #1e1208;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Desktop: nav-links renders inline as part of nav flex row */
|
/* Desktop: nav-links renders inline as part of the header's flex row */
|
||||||
.utci-topnav .nav-links {
|
.utci-topnav .nav-links {
|
||||||
display: contents; /* participates in nav's flex layout transparently */
|
display: contents; /* participates in the header's flex layout transparently */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── About dropdown ─────────────────────────────────────────────────── */
|
/* ── About dropdown ─────────────────────────────────────────────────── */
|
||||||
@@ -171,6 +186,22 @@ body {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
|
/* The Account dropdown sits at the far right of the nav row, so a menu
|
||||||
|
centred under it (like About's) would spill past the viewport edge on
|
||||||
|
most screen widths. Right-align it to the toggle instead, growing left. */
|
||||||
|
.utci-topnav .nav-dropdown--right .nav-dropdown-menu {
|
||||||
|
left: auto;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
.utci-topnav .nav-dropdown--right.nav-dropdown:hover .nav-dropdown-menu,
|
||||||
|
.utci-topnav .nav-dropdown--right.nav-dropdown:focus-within .nav-dropdown-menu {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
/* Labels align to the same right edge the menu itself hangs from. */
|
||||||
|
.utci-topnav .nav-dropdown--right .nav-dropdown-menu a {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.utci-topnav .nav-dropdown-menu a {
|
.utci-topnav .nav-dropdown-menu a {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
@@ -190,15 +221,15 @@ body {
|
|||||||
|
|
||||||
/* ── Event note ─────────────────────────────────────────────────────── */
|
/* ── Event note ─────────────────────────────────────────────────────── */
|
||||||
/* Active cosmic/weather events shown as a strip across the content width,
|
/* Active cosmic/weather events shown as a strip across the content width,
|
||||||
above the nav. It is IN FLOW — it pushes the page down instead of
|
directly under the site header. It is IN FLOW — it pushes the page down
|
||||||
covering anything, which is the whole point of it not being a flyout.
|
instead of covering anything, which is the whole point of it not being a
|
||||||
One event at a time with its full message, cycling with a fade.
|
flyout. One event at a time with its full message, cycling with a fade.
|
||||||
|
|
||||||
The nav is normally position:absolute and .utci-app reserves 52px of top
|
It used to render ABOVE the nav, which meant repositioning the nav out of
|
||||||
padding for it. When a note is present the nav joins the flow instead so
|
its absolute placement whenever a note was present. The header owns the top
|
||||||
the note can sit above it, and that padding is no longer needed. On true
|
of the page now, so the note is simply the first thing under it — on desktop
|
||||||
mobile (≤640px) the nav is position:fixed and stays that way — there the
|
and on mobile (where the header is fixed and .utci-app pads clear of it)
|
||||||
note simply becomes the first thing under it, so no override applies. */
|
alike, with no override either way. */
|
||||||
.event-note {
|
.event-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -222,11 +253,20 @@ body {
|
|||||||
same device as .almanac-entry-accent. */
|
same device as .almanac-entry-accent. */
|
||||||
.event-note-accent {
|
.event-note-accent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
inset: 0;
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 3px;
|
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
|
pointer-events: none;
|
||||||
|
/* One element, drawn as an L: a 1px strip down the left that turns and runs
|
||||||
|
along the bottom. The note's own overflow: hidden rounds the corner off
|
||||||
|
with the rest of the box. */
|
||||||
|
clip-path: polygon(
|
||||||
|
0 0,
|
||||||
|
1px 0,
|
||||||
|
1px calc(100% - 1px),
|
||||||
|
100% calc(100% - 1px),
|
||||||
|
100% 100%,
|
||||||
|
0 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis.
|
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis.
|
||||||
@@ -272,15 +312,27 @@ body {
|
|||||||
.event-note-slide {
|
.event-note-slide {
|
||||||
grid-area: 1 / 1; /* stack all slides on top of each other */
|
grid-area: 1 / 1; /* stack all slides on top of each other */
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
gap: 2px;
|
||||||
gap: 4px 10px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
opacity: 0; /* only .is-active is visible */
|
opacity: 0; /* only .is-active is visible */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: none; /* keeps hidden copy out of a drag-selection */
|
user-select: none; /* keeps hidden copy out of a drag-selection */
|
||||||
transition: opacity 0.35s ease;
|
transition: opacity 0.35s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-note-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-note-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.event-note-slide.is-active {
|
.event-note-slide.is-active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -351,21 +403,84 @@ body {
|
|||||||
transform: scale(1.3);
|
transform: scale(1.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Above true-mobile: the nav drops into the flow beneath the note. Its own
|
/* Close control — a small brass "Close ×" pill in the top-right corner. It
|
||||||
32px side padding is dropped so the links line up with the note and the
|
sits at the end of the strip's flex row rather than being absolutely
|
||||||
content rather than sitting 32px inside them. */
|
positioned, so the dates line can never run underneath it. */
|
||||||
@media (min-width: 641px) {
|
.event-note-close {
|
||||||
.utci-app.has-event-note {
|
display: inline-flex;
|
||||||
padding-top: 18px;
|
align-items: center;
|
||||||
}
|
gap: 4px;
|
||||||
/* `relative`, not `static` — the nav keeps its z-index:100 that way, so
|
flex-shrink: 0;
|
||||||
the About dropdown still paints over .utci-shell (which is z-index:1). */
|
align-self: flex-start;
|
||||||
.utci-app.has-event-note .utci-topnav {
|
margin: -2px -6px 0 2px; /* pull into the padding so it hugs the corner */
|
||||||
position: relative;
|
padding: 3px 7px;
|
||||||
padding-left: 0;
|
border: 1px solid transparent;
|
||||||
padding-right: 0;
|
border-radius: 999px;
|
||||||
}
|
background: transparent;
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #9a7d5a;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
.event-note-close:hover {
|
||||||
|
background: rgba(200, 146, 42, 0.14);
|
||||||
|
border-color: rgba(200, 146, 42, 0.45);
|
||||||
|
color: #7a5a1e;
|
||||||
|
}
|
||||||
|
.event-note-close:focus-visible {
|
||||||
|
outline: 2px solid #c8922a;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.event-note.is-priority .event-note-close {
|
||||||
|
color: #a06e18;
|
||||||
|
}
|
||||||
|
.event-note.is-tinted .event-note-close {
|
||||||
|
color: #56452c;
|
||||||
|
}
|
||||||
|
.event-note.is-tinted .event-note-close:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.09);
|
||||||
|
border-color: rgba(0, 0, 0, 0.22);
|
||||||
|
color: #2a1a08;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Closing: the slot's height runs down to zero (measured and driven inline by
|
||||||
|
noteClose in app.js) and the note is anchored to its BOTTOM edge, so the
|
||||||
|
banner rides up behind the header's seam while everything below it rises by
|
||||||
|
the same amount at the same rate. One animated property for both movements —
|
||||||
|
a transform on the note would slide it independently of the page, and a
|
||||||
|
transform on the content below would break the fixed-position controls
|
||||||
|
living inside it. */
|
||||||
|
.event-note-slot {
|
||||||
|
/* The gap under the banner lives here rather than on the note, and is
|
||||||
|
deliberately equal to what the header gets back when .has-event-note is
|
||||||
|
dropped at the end of the close: the header's own margin-bottom (18px,
|
||||||
|
zeroed while a note is attached) plus, on a phone, the 8px .utci-app
|
||||||
|
stops padding once the note is gone. Without that, the space swapped in
|
||||||
|
at unmount showed up as a jump. */
|
||||||
|
margin-bottom: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr; /* the row fills the slot, whatever its height */
|
||||||
|
align-items: end; /* ...and the note hangs from its bottom edge */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.event-note-slot > .event-note {
|
||||||
|
min-height: 0; /* grid items floor at min-content without this */
|
||||||
|
}
|
||||||
|
/* The entry animation is fill-mode: both, so its final transform would stay
|
||||||
|
applied — cleared so it cannot offset the note against the slot. */
|
||||||
|
.event-note.is-closing {
|
||||||
|
animation: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The note follows the header in the flow now, so it needs no special
|
||||||
|
positioning — only the gap the header's own margin-bottom would otherwise
|
||||||
|
have given the content it sits in front of. */
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.event-note {
|
.event-note {
|
||||||
@@ -376,28 +491,100 @@ body {
|
|||||||
.event-note-dates {
|
.event-note-dates {
|
||||||
margin-left: 0; /* no room to right-align on a phone */
|
margin-left: 0; /* no room to right-align on a phone */
|
||||||
}
|
}
|
||||||
|
/* Phone: the cross alone, pushed to the right-hand end of the row and
|
||||||
|
padded out to a ~26px touch target. */
|
||||||
|
.event-note-close {
|
||||||
|
margin: -4px -6px 0 auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.event-note-close-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav logo — hidden on desktop, shown on mobile */
|
/* ── Note attached to the header ─────────────────────────────────────
|
||||||
|
When a note is present it hangs off the header's bottom edge rather than
|
||||||
|
floating below it as a separate panel: the header drops the gap it would
|
||||||
|
otherwise leave, and the note keeps a square top with no top border, inset
|
||||||
|
to the content edge so there is a gap either side. The header's own bottom
|
||||||
|
rule becomes the seam the strip hangs from.
|
||||||
|
|
||||||
|
Placed after the ≤640px block above deliberately — these override the
|
||||||
|
note's base padding and border at every width, mobile included. */
|
||||||
|
.has-event-note .utci-topnav {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.has-event-note .event-note {
|
||||||
|
/* Inset to the content edge rather than full-bleed, so the strip keeps a
|
||||||
|
gap either side of it. The gap below is the slot's — see .event-note-slot. */
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 15px;
|
||||||
|
/* No top border and square top corners: the header's own bottom rule is the
|
||||||
|
seam it hangs from. A tinted note's inline borderColor still tints what is
|
||||||
|
left, since that sets the colour only and this sets style and width. */
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* Matches the two-class specificity above so the phone's tighter padding
|
||||||
|
survives the override. */
|
||||||
|
.has-event-note .event-note {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 18px + the 8px of .utci-app padding-top below — see .event-note-slot. */
|
||||||
|
.event-note-slot {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The bar is position:fixed and 72px tall on a phone, and .utci-app pads 80px
|
||||||
|
clear of it — the spare 8px would show as a gap above the note. Two classes
|
||||||
|
so it outranks the single-class padding-top in ui.css whatever the order. */
|
||||||
|
.utci-app.has-event-note {
|
||||||
|
padding-top: 72px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The site identity, in the header on every viewport. Was display:none here
|
||||||
|
and unhidden only on mobile, back when the big .utci-title wordmark carried
|
||||||
|
the desktop identity instead. */
|
||||||
.nav-logo {
|
.nav-logo {
|
||||||
display: none;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Same device about.css uses on .nav-logo: one auto margin pushes everything
|
||||||
|
after it to the right-hand end, which works with .nav-links staying
|
||||||
|
display:contents. */
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
.nav-logo-wordmark {
|
.nav-logo-wordmark {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.05em;
|
gap: 0.05em;
|
||||||
|
/* It is the page's <h1> now, so the browser's own h1 margin and 2em size are
|
||||||
|
cleared here — the wordmark's size and weight come from .nav-logo-sun and
|
||||||
|
.nav-logo-scope, and the icon is sized in rem. */
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: inherit;
|
||||||
}
|
}
|
||||||
.nav-logo-sun,
|
.nav-logo-sun,
|
||||||
.nav-logo-scope {
|
.nav-logo-scope {
|
||||||
font-family: Manrope, sans-serif;
|
font-family: Manrope, sans-serif;
|
||||||
font-weight: 250;
|
font-weight: 250;
|
||||||
font-size: 2.1rem;
|
/* 1.6rem matches .nav-logo-sun in about.css, so the wordmark is the same size on the
|
||||||
|
forecast and on every static page. The phone bar scales it back up (see the
|
||||||
|
≤640px block) where it is the centred focal point rather than a corner mark. */
|
||||||
|
font-size: 1.6rem;
|
||||||
color: #1e1208;
|
color: #1e1208;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.nav-logo-icon {
|
.nav-logo-icon {
|
||||||
width: 1.65rem;
|
width: 1.26rem; /* same 0.79 ratio to the wordmark as .nav-logo-icon */
|
||||||
height: 1.65rem;
|
height: 1.26rem;
|
||||||
color: #1e1208;
|
color: #1e1208;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -406,7 +593,7 @@ body {
|
|||||||
font-family: Manrope, sans-serif;
|
font-family: Manrope, sans-serif;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 8px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #7a5c2a;
|
color: #7a5c2a;
|
||||||
@@ -470,14 +657,26 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
.nav-logo-sun,
|
||||||
.utci-title,
|
.nav-logo-scope {
|
||||||
.utci-tagline {
|
font-size: 2.1rem;
|
||||||
display: none;
|
}
|
||||||
|
.nav-logo-icon {
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phone keeps the bar pinned to the viewport, with the wordmark centred in
|
||||||
|
it and the links behind the burger. The offsets are set here rather than
|
||||||
|
inherited: the desktop rule is in-flow now and carries no top/left/right,
|
||||||
|
and its full-bleed negative margin has nothing to cancel once the bar is
|
||||||
|
fixed and already edge-to-edge. */
|
||||||
.utci-topnav {
|
.utci-topnav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
background: #f5edd6;
|
background: #f5edd6;
|
||||||
@@ -538,6 +737,10 @@ body {
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.utci-topnav .nav-dropdown-menu a { padding: 0; font-size: 14px; }
|
.utci-topnav .nav-dropdown-menu a { padding: 0; font-size: 14px; }
|
||||||
|
/* Right-aligned text is a desktop-popup touch (see the --right rule
|
||||||
|
above) - on mobile everything is one left-indented list, so match
|
||||||
|
About's list instead of standing out. */
|
||||||
|
.utci-topnav .nav-dropdown--right .nav-dropdown-menu a { text-align: left; }
|
||||||
|
|
||||||
/* Burger → X animation */
|
/* Burger → X animation */
|
||||||
.utci-topnav.nav-open .utci-burger span:nth-child(1) {
|
.utci-topnav.nav-open .utci-burger span:nth-child(1) {
|
||||||
|
|||||||
+48
-75
@@ -18,18 +18,22 @@
|
|||||||
.scope-col { grid-area: dial; }
|
.scope-col { grid-area: dial; }
|
||||||
.header-right { grid-area: hright; }
|
.header-right { grid-area: hright; }
|
||||||
|
|
||||||
/* Left column — wordmark, tagline, search and location stacked together */
|
/* Left column — the location line and its search overlay. The wordmark and
|
||||||
|
tagline that used to head this stack now live in the site header, so what is
|
||||||
|
left hangs from the top of the row, level with the top of the dial. */
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wraps the location line + the reveal-on-demand search overlay so the
|
/* Wraps the location line + the reveal-on-demand search overlay so the
|
||||||
search can position itself directly over the location to save space. */
|
search can position itself directly over the location to save space. */
|
||||||
.utci-loc-search {
|
.utci-loc-search {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 18px;
|
/* Was 18px, clearing the tagline above it. It leads the column now. */
|
||||||
|
margin-top: 0;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -40,45 +44,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: flex-start; /* level with the top of the dial */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The big "SunScope" wordmark */
|
/* The wordmark and tagline that used to head this column now live in the
|
||||||
.utci-title {
|
site header (.nav-logo in base.css) — see the header there. */
|
||||||
font-family: Manrope, sans-serif;
|
|
||||||
font-weight: 250;
|
|
||||||
font-size: clamp(48px, 6vw, 80px);
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
color: #1e1208;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.utci-title .title-sun,
|
|
||||||
.utci-title .title-scope {
|
|
||||||
color: #1e1208;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-icon {
|
|
||||||
width: 0.82em;
|
|
||||||
height: 0.82em;
|
|
||||||
color: #1e1208;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "See the world the way..." */
|
|
||||||
.utci-tagline {
|
|
||||||
font-family: Manrope, sans-serif;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 11px;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #7a5c2a;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "↳ Location, Somewhere" + the magnifier that reveals the search */
|
/* "↳ Location, Somewhere" + the magnifier that reveals the search */
|
||||||
.utci-current-loc {
|
.utci-current-loc {
|
||||||
@@ -184,6 +154,7 @@
|
|||||||
background: #fffcf2;
|
background: #fffcf2;
|
||||||
color: #9a7a3a;
|
color: #9a7a3a;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +184,39 @@
|
|||||||
.utci-loc-action.is-busy svg { animation: none; opacity: 0.55; }
|
.utci-loc-action.is-busy svg { animation: none; opacity: 0.55; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tool row under the location line — the magnifier, locate, share and journal
|
||||||
|
buttons sit on their own row now that the header has the space, each with a
|
||||||
|
small caption so the icons don't have to carry the meaning alone. */
|
||||||
|
.utci-loc-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.utci-loc-tool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.utci-loc-tool-label {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #a08c62;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light the caption up with its button so the pair reads as one control */
|
||||||
|
.utci-loc-tool:hover .utci-loc-tool-label { color: #c8922a; }
|
||||||
|
|
||||||
/* Transient note under the location row: "Link copied", permission errors */
|
/* Transient note under the location row: "Link copied", permission errors */
|
||||||
.utci-loc-note {
|
.utci-loc-note {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -240,19 +244,6 @@
|
|||||||
|
|
||||||
.utci-loc-note-x:hover { opacity: 1; }
|
.utci-loc-note-x:hover { opacity: 1; }
|
||||||
|
|
||||||
/* The lat/lon below the location */
|
|
||||||
.utci-loc-coords {
|
|
||||||
font-family: JetBrains Mono, monospace;
|
|
||||||
font-style: normal;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #b09870;
|
|
||||||
display: block;
|
|
||||||
flex-basis: 100%; /* drop onto its own line below the name + magnifier */
|
|
||||||
margin-top: 4px;
|
|
||||||
margin-left: 0;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The big UTCI dial in the middle of the header.
|
/* The big UTCI dial in the middle of the header.
|
||||||
Change width/height to resize it. */
|
Change width/height to resize it. */
|
||||||
.scope-ring {
|
.scope-ring {
|
||||||
@@ -301,7 +292,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
/* Top-anchored so the dial defines the row's top edge and the location and
|
||||||
|
Solar model columns beside it start on the same line. */
|
||||||
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,12 +587,6 @@ body.tl-scrubbing * { transition: none !important; }
|
|||||||
box-shadow: 0 6px 20px #64461426; /* lift it off the location underneath */
|
box-shadow: 0 6px 20px #64461426; /* lift it off the location underneath */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The 300px wrapper would let .utci-fetch-time's default right-align drift to
|
|
||||||
the far edge; keep it left-aligned beneath the coords as before. */
|
|
||||||
.utci-loc-search .utci-fetch-time {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.utci-search-label {
|
.utci-search-label {
|
||||||
font-family: Manrope, sans-serif;
|
font-family: Manrope, sans-serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -716,16 +703,6 @@ body.tl-scrubbing * { transition: none !important; }
|
|||||||
align-items: center; /* centre the "Why it feels like" panel under the dial */
|
align-items: center; /* centre the "Why it feels like" panel under the dial */
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-title {
|
|
||||||
font-size: clamp(40px, 12vw, 60px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.utci-title,
|
|
||||||
.utci-tagline {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
@@ -753,12 +730,8 @@ body.tl-scrubbing * { transition: none !important; }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-loc-coords {
|
.utci-loc-tools {
|
||||||
text-align: center;
|
justify-content: center; /* centre the tool row on mobile */
|
||||||
}
|
|
||||||
|
|
||||||
.utci-loc-search .utci-fetch-time {
|
|
||||||
text-align: center; /* match the desktop override's specificity */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-search-wrap {
|
.utci-search-wrap {
|
||||||
|
|||||||
+327
-96
@@ -270,6 +270,34 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The sky glyph and anything composited onto it. Anchors .utci-day-wind and
|
||||||
|
is inline-block so it hugs the 30px icon rather than the full tab width. */
|
||||||
|
.utci-day-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gust lines overlaid on the sky glyph (Near Gale and above, where the wind
|
||||||
|
isn't already the main icon). Absolutely positioned, so it costs the tab no
|
||||||
|
layout width — at the 90px min-width there is no room for a second glyph
|
||||||
|
beside the sky icon, and the tab's rows stay aligned with the legend column.
|
||||||
|
|
||||||
|
Concentric with the sky glyph rather than tucked into a corner: hanging it
|
||||||
|
off one side made the pair read as two separate stickers, one of them
|
||||||
|
falling off the edge. Centred, the gusts read as blowing THROUGH the cloud —
|
||||||
|
one combined symbol. The -44% (rather than -50%) vertical offset drops it a
|
||||||
|
couple of pixels below true centre, so the lines cross the base of the cloud
|
||||||
|
instead of cutting through its middle. */
|
||||||
|
.utci-day-wind {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -44%);
|
||||||
|
line-height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Danger-band days breathe a slow dark-red inner glow */
|
/* Danger-band days breathe a slow dark-red inner glow */
|
||||||
.utci-day-tab.danger {
|
.utci-day-tab.danger {
|
||||||
animation: dayTabDangerPulse 2.4s ease-in-out infinite;
|
animation: dayTabDangerPulse 2.4s ease-in-out infinite;
|
||||||
@@ -609,6 +637,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 14px;
|
right: 14px;
|
||||||
|
/* .profile-tab carries z-index:1, and a flex item honours z-index without
|
||||||
|
needing position — so without this the tabs paint straight over the
|
||||||
|
close button wherever the tab strip reaches the panel's right edge. */
|
||||||
|
z-index: 6;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -632,8 +664,15 @@
|
|||||||
bottom: auto;
|
bottom: auto;
|
||||||
max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
|
max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
min-width: 380px;
|
/* The panel's width is pinned by the hidden Activities sizer (see
|
||||||
max-width: min(880px, calc(100vw - 32px));
|
.profile-scroll-sizer / ConfigPanel.js): 9 x 100px cards + gaps +
|
||||||
|
padding, ~1000px all-in. The old 880px cap clipped the last card or
|
||||||
|
two, so give it room — and a min-width past that natural size so the
|
||||||
|
config strip underneath has enough room to pack into fewer rows.
|
||||||
|
Both are capped to the viewport, so narrow desktops and tablets
|
||||||
|
still shrink normally. */
|
||||||
|
min-width: min(1080px, calc(100vw - 32px));
|
||||||
|
max-width: min(1120px, calc(100vw - 32px));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
border: 1.5px solid #c9b08a;
|
border: 1.5px solid #c9b08a;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -696,7 +735,11 @@
|
|||||||
}
|
}
|
||||||
.utci-thead-sticky {
|
.utci-thead-sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 28px !important; /* Corrected to match the height of the active-profile-row + margins */
|
/* Clears the profile row pinned above it. DayTabs measures that row and
|
||||||
|
publishes its height (+ its 4px margin) as --profile-row-h — it is one
|
||||||
|
line or two depending on the labels, and the old fixed 28px left the
|
||||||
|
table header tucked under it. */
|
||||||
|
top: var(--profile-row-h, 52px) !important;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,11 +808,13 @@
|
|||||||
.active-profile-row.grp-wind { border-color: rgba(106,34,16,0.30); }
|
.active-profile-row.grp-wind { border-color: rgba(106,34,16,0.30); }
|
||||||
.active-profile-row.grp-ambient { border-color: rgba(60,58,53,0.30); }
|
.active-profile-row.grp-ambient { border-color: rgba(60,58,53,0.30); }
|
||||||
.active-profile-row.grp-surface { border-color: rgba(11,62,114,0.30); }
|
.active-profile-row.grp-surface { border-color: rgba(11,62,114,0.30); }
|
||||||
|
.active-profile-row.grp-pets { border-color: rgba(10,81,88,0.30); }
|
||||||
|
|
||||||
.active-profile-row.grp-felt .active-profile-row-value { color: #075c3a; }
|
.active-profile-row.grp-felt .active-profile-row-value { color: #075c3a; }
|
||||||
.active-profile-row.grp-wind .active-profile-row-value { color: #6a2210; }
|
.active-profile-row.grp-wind .active-profile-row-value { color: #6a2210; }
|
||||||
.active-profile-row.grp-ambient .active-profile-row-value { color: #3c3a35; }
|
.active-profile-row.grp-ambient .active-profile-row-value { color: #3c3a35; }
|
||||||
.active-profile-row.grp-surface .active-profile-row-value { color: #0b3e72; }
|
.active-profile-row.grp-surface .active-profile-row-value { color: #0b3e72; }
|
||||||
|
.active-profile-row.grp-pets .active-profile-row-value { color: #0a5158; }
|
||||||
|
|
||||||
/* Vertical rule separating general-use profiles from technical ones */
|
/* Vertical rule separating general-use profiles from technical ones */
|
||||||
.profile-divider {
|
.profile-divider {
|
||||||
@@ -1085,6 +1130,7 @@
|
|||||||
|
|
||||||
.col-toggle.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); }
|
.col-toggle.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); }
|
||||||
.col-toggle.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); }
|
.col-toggle.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); }
|
||||||
|
.col-toggle.grp-pets { background: rgba(214,238,240,0.50); color: #0a5158; border-color: rgba(10,81,88,0.30); }
|
||||||
.col-toggle.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); }
|
.col-toggle.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); }
|
||||||
.col-toggle.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); }
|
.col-toggle.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); }
|
||||||
.col-toggle.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); }
|
.col-toggle.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); }
|
||||||
@@ -1094,6 +1140,7 @@
|
|||||||
|
|
||||||
.col-toggle.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); }
|
.col-toggle.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); }
|
||||||
.col-toggle.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); }
|
.col-toggle.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); }
|
||||||
|
.col-toggle.grp-pets:hover { background: rgba(204,228,230,0.80); color: #0a5158; border-color: rgba(10,81,88,0.50); }
|
||||||
.col-toggle.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); }
|
.col-toggle.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); }
|
||||||
.col-toggle.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); }
|
.col-toggle.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); }
|
||||||
.col-toggle.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); }
|
.col-toggle.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); }
|
||||||
@@ -1103,6 +1150,7 @@
|
|||||||
|
|
||||||
.col-toggle.grp-felt.on { background: #0f9e63; border-color: #0f9e63; color: #fff; }
|
.col-toggle.grp-felt.on { background: #0f9e63; border-color: #0f9e63; color: #fff; }
|
||||||
.col-toggle.grp-surface.on { background: #2672c8; border-color: #2672c8; color: #fff; }
|
.col-toggle.grp-surface.on { background: #2672c8; border-color: #2672c8; color: #fff; }
|
||||||
|
.col-toggle.grp-pets.on { background: #17868f; border-color: #17868f; color: #fff; }
|
||||||
.col-toggle.grp-ambient.on { background: #7a7870; border-color: #7a7870; color: #fff; }
|
.col-toggle.grp-ambient.on { background: #7a7870; border-color: #7a7870; color: #fff; }
|
||||||
.col-toggle.grp-precip.on { background: #b03060; border-color: #b03060; color: #fff; }
|
.col-toggle.grp-precip.on { background: #b03060; border-color: #b03060; color: #fff; }
|
||||||
.col-toggle.grp-sky.on { background: #6058c8; border-color: #6058c8; color: #fff; }
|
.col-toggle.grp-sky.on { background: #6058c8; border-color: #6058c8; color: #fff; }
|
||||||
@@ -1112,6 +1160,7 @@
|
|||||||
|
|
||||||
.col-toggle.grp-felt.on:hover { background: #0c7d4e; border-color: #0c7d4e; color: #fff; }
|
.col-toggle.grp-felt.on:hover { background: #0c7d4e; border-color: #0c7d4e; color: #fff; }
|
||||||
.col-toggle.grp-surface.on:hover { background: #1e5aa0; border-color: #1e5aa0; color: #fff; }
|
.col-toggle.grp-surface.on:hover { background: #1e5aa0; border-color: #1e5aa0; color: #fff; }
|
||||||
|
.col-toggle.grp-pets.on:hover { background: #116b73; border-color: #116b73; color: #fff; }
|
||||||
.col-toggle.grp-ambient.on:hover { background: #5e5c56; border-color: #5e5c56; color: #fff; }
|
.col-toggle.grp-ambient.on:hover { background: #5e5c56; border-color: #5e5c56; color: #fff; }
|
||||||
.col-toggle.grp-precip.on:hover { background: #8c264c; border-color: #8c264c; color: #fff; }
|
.col-toggle.grp-precip.on:hover { background: #8c264c; border-color: #8c264c; color: #fff; }
|
||||||
.col-toggle.grp-sky.on:hover { background: #4c48a8; border-color: #4c48a8; color: #fff; }
|
.col-toggle.grp-sky.on:hover { background: #4c48a8; border-color: #4c48a8; color: #fff; }
|
||||||
@@ -1251,6 +1300,48 @@
|
|||||||
.profile-dropdown-body {
|
.profile-dropdown-body {
|
||||||
max-height: calc(100vh - 16px);
|
max-height: calc(100vh - 16px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
/* Room for the close bar below — the tab strip goes full width on
|
||||||
|
phones, so the close button can no longer share that top line. */
|
||||||
|
padding-top: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give the sheet its own header strip with a proper thumb-sized close
|
||||||
|
target. Previously the × sat on top of the full-width tab row, where
|
||||||
|
it was both invisible and unhittable — the sheet covers most of the
|
||||||
|
viewport on a phone, so there was almost no backdrop left to tap
|
||||||
|
either, and no way back to the table. The button is a child of the
|
||||||
|
fixed panel (not the scrolling body), so it stays put while the
|
||||||
|
config list scrolls under it. */
|
||||||
|
.profile-dropdown-close {
|
||||||
|
top: 6px;
|
||||||
|
right: 10px;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #8a6a3a;
|
||||||
|
background: #fffcf2;
|
||||||
|
border: 1.5px solid #c9b08a;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom-sheet grab handle — the usual "drag/tap me away" cue, and a
|
||||||
|
second visual hint that the top strip is chrome, not content. */
|
||||||
|
.profile-dropdown::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 42px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #d8c7a4;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.floating-profile-val { max-width: 90px; }
|
.floating-profile-val { max-width: 90px; }
|
||||||
.floating-profile-sub { max-width: 90px; }
|
.floating-profile-sub { max-width: 90px; }
|
||||||
@@ -1816,6 +1907,7 @@
|
|||||||
|
|
||||||
.utci-table th.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; }
|
.utci-table th.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; }
|
||||||
.utci-table th.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; }
|
.utci-table th.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; }
|
||||||
|
.utci-table th.grp-pets { background: rgba(214,238,240,0.50); color: #0a5158; }
|
||||||
.utci-table th.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; }
|
.utci-table th.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; }
|
||||||
.utci-table th.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; }
|
.utci-table th.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; }
|
||||||
.utci-table th.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; }
|
.utci-table th.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; }
|
||||||
@@ -1825,6 +1917,7 @@
|
|||||||
|
|
||||||
.utci-table th.grp-felt.col-info-th:hover { background: rgba(213,233,226,0.80); }
|
.utci-table th.grp-felt.col-info-th:hover { background: rgba(213,233,226,0.80); }
|
||||||
.utci-table th.grp-surface.col-info-th:hover { background: rgba(211,224,238,0.80); }
|
.utci-table th.grp-surface.col-info-th:hover { background: rgba(211,224,238,0.80); }
|
||||||
|
.utci-table th.grp-pets.col-info-th:hover { background: rgba(204,228,230,0.80); }
|
||||||
.utci-table th.grp-ambient.col-info-th:hover { background: rgba(226,224,217,0.80); }
|
.utci-table th.grp-ambient.col-info-th:hover { background: rgba(226,224,217,0.80); }
|
||||||
.utci-table th.grp-precip.col-info-th:hover { background: rgba(237,218,228,0.80); }
|
.utci-table th.grp-precip.col-info-th:hover { background: rgba(237,218,228,0.80); }
|
||||||
.utci-table th.grp-sky.col-info-th:hover { background: rgba(224,222,242,0.80); }
|
.utci-table th.grp-sky.col-info-th:hover { background: rgba(224,222,242,0.80); }
|
||||||
@@ -1834,6 +1927,7 @@
|
|||||||
|
|
||||||
.utci-table th.grp-felt .col-unit { color: #0f9e63; }
|
.utci-table th.grp-felt .col-unit { color: #0f9e63; }
|
||||||
.utci-table th.grp-surface .col-unit { color: #2672c8; }
|
.utci-table th.grp-surface .col-unit { color: #2672c8; }
|
||||||
|
.utci-table th.grp-pets .col-unit { color: #17868f; }
|
||||||
.utci-table th.grp-ambient .col-unit { color: #7a7870; }
|
.utci-table th.grp-ambient .col-unit { color: #7a7870; }
|
||||||
.utci-table th.grp-precip .col-unit { color: #b03060; }
|
.utci-table th.grp-precip .col-unit { color: #b03060; }
|
||||||
.utci-table th.grp-sky .col-unit { color: #6058c8; }
|
.utci-table th.grp-sky .col-unit { color: #6058c8; }
|
||||||
@@ -1859,6 +1953,7 @@
|
|||||||
|
|
||||||
.utci-table .grp-label-row th.grp-felt { background: rgba(223,243,236,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
.utci-table .grp-label-row th.grp-felt { background: rgba(223,243,236,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
||||||
.utci-table .grp-label-row th.grp-surface { background: rgba(221,234,248,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
.utci-table .grp-label-row th.grp-surface { background: rgba(221,234,248,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
||||||
|
.utci-table .grp-label-row th.grp-pets { background: rgba(214,238,240,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
||||||
.utci-table .grp-label-row th.grp-ambient { background: rgba(236,234,227,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
.utci-table .grp-label-row th.grp-ambient { background: rgba(236,234,227,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
||||||
.utci-table .grp-label-row th.grp-precip { background: rgba(247,228,238,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
.utci-table .grp-label-row th.grp-precip { background: rgba(247,228,238,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
||||||
.utci-table .grp-label-row th.grp-sky { background: rgba(234,232,252,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
.utci-table .grp-label-row th.grp-sky { background: rgba(234,232,252,0.50); border-left: 2px solid rgba(74,50,24,0.18); border-bottom: 1px solid rgba(74,50,24,0.18);}
|
||||||
@@ -1965,6 +2060,23 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ...except the plain single-picker cards (SunSoak, Fur, Burn, Pollen),
|
||||||
|
which pair up two per row. Six full-width cards turned the sheet into
|
||||||
|
a scrolling wall that buried the table; pairing halves that. Cards
|
||||||
|
holding a fused control group (Vehicle, Indoors) keep a full row —
|
||||||
|
they already wrap internally and have no width to spare — and so does
|
||||||
|
the main item, which carries the badge and the gold emphasis.
|
||||||
|
:has() gates the whole thing: a browser without it just drops these
|
||||||
|
rules and keeps the old one-per-row stack. */
|
||||||
|
.col-toggles-config-item:not(.col-toggles-config-item--main):not(:has(.col-toggle-group)) {
|
||||||
|
flex: 1 1 calc((100% - 8px) / 2);
|
||||||
|
}
|
||||||
|
.col-toggles-config-item:not(.col-toggles-config-item--main):not(:has(.col-toggle-group)) .cs-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Re-affirm the gold emphasis for the "main" item so it still stands
|
/* Re-affirm the gold emphasis for the "main" item so it still stands
|
||||||
out after the generic card border above. */
|
out after the generic card border above. */
|
||||||
.col-toggles-config-item--main {
|
.col-toggles-config-item--main {
|
||||||
@@ -2201,8 +2313,14 @@
|
|||||||
.fvt-btn:first-child {
|
.fvt-btn:first-child {
|
||||||
border-radius: 3px 0 0 3px;
|
border-radius: 3px 0 0 3px;
|
||||||
}
|
}
|
||||||
.fvt-btn:last-child {
|
/* Only the outer corners are rounded; every segment after the first drops
|
||||||
|
its left border so neighbours share one 1.5px rule rather than stacking
|
||||||
|
two. Keyed off the sibling rather than :last-child so it still holds with
|
||||||
|
three segments (Quick / Detailed / Table). */
|
||||||
|
.fvt-btn + .fvt-btn {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
|
}
|
||||||
|
.fvt-btn:last-child {
|
||||||
border-radius: 0 3px 3px 0;
|
border-radius: 0 3px 3px 0;
|
||||||
}
|
}
|
||||||
.fvt-btn:hover {
|
.fvt-btn:hover {
|
||||||
@@ -2261,7 +2379,9 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
row-gap: 6px;
|
row-gap: 6px;
|
||||||
padding: 6px 0 10px;
|
/* No bottom padding: the tabs have to touch the col-toggles border
|
||||||
|
below them, or they read as floating above the box. */
|
||||||
|
padding: 6px 0 0;
|
||||||
}
|
}
|
||||||
.fvt-toolbar-left {
|
.fvt-toolbar-left {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
@@ -2269,6 +2389,12 @@
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
/* Quick view needs the row flush so its thermal tabs attach to the box
|
||||||
|
below. Detailed and Table have no tabs, so the View buttons would
|
||||||
|
otherwise sit right on the table — give those a gap. */
|
||||||
|
.table-main:not(.table-main--simple) .table-toolbar-row {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
.fvt-thermal-tabs {
|
.fvt-thermal-tabs {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
@@ -2344,7 +2470,16 @@
|
|||||||
.table-main--simple .forecast-simple-wrap {
|
.table-main--simple .forecast-simple-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 420px;
|
/* Sized by its contents — cards, then the curve at its own fixed height,
|
||||||
|
then the button. It used to be a fixed 420px with the curve set to flex: 1
|
||||||
|
to absorb whatever was left over, which made the curve's height a
|
||||||
|
subtraction: panel minus cards minus button. That left it one card-height
|
||||||
|
change away from falling under .fsc-temp-curve's min-height, at which point
|
||||||
|
the column overflowed .forecast-simple-scroll (overflow-y: hidden) and the
|
||||||
|
BOTTOM of the curve — where the cold end of the scale lives — was clipped
|
||||||
|
off. Content-sized panel + fixed-height curve: no leftover space to gap,
|
||||||
|
none to run out of. */
|
||||||
|
height: auto;
|
||||||
border: 1.5px solid #d4c0a0;
|
border: 1.5px solid #d4c0a0;
|
||||||
background: #fdf8ee;
|
background: #fdf8ee;
|
||||||
}
|
}
|
||||||
@@ -2352,6 +2487,56 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── SCROLL INDICATORS (quick view) ──────────────────────────────────────
|
||||||
|
Same fade + chevron pair the detailed table carries, driven by the
|
||||||
|
scroll-fade-left / scroll-fade-right classes app.js toggles from the
|
||||||
|
card strip's scroll position. The gradients fade from the panel's own
|
||||||
|
cream so they read as the content running under the edge. */
|
||||||
|
.table-main--simple .forecast-simple-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.forecast-simple-wrap::before,
|
||||||
|
.forecast-simple-wrap::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 48px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 6;
|
||||||
|
}
|
||||||
|
.forecast-simple-wrap::before {
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(to right, rgba(253,248,238,0.95) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
.forecast-simple-wrap::after {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, rgba(253,248,238,0.95) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
.forecast-simple-wrap.scroll-fade-left::before { opacity: 1; }
|
||||||
|
.forecast-simple-wrap.scroll-fade-right::after { opacity: 1; }
|
||||||
|
|
||||||
|
.fsc-scroll-chevron {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 7;
|
||||||
|
pointer-events: none;
|
||||||
|
color: #8a6a3a;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.fsc-scroll-chevron.left { left: 6px; }
|
||||||
|
.fsc-scroll-chevron.right { right: 6px; }
|
||||||
|
|
||||||
|
.forecast-simple-wrap.scroll-fade-left .fsc-scroll-chevron.left { opacity: 1; }
|
||||||
|
.forecast-simple-wrap.scroll-fade-right .fsc-scroll-chevron.right { opacity: 1; }
|
||||||
|
|
||||||
/* ── SIMPLE VIEW CARDS ───────────────────────────────────────────────────── */
|
/* ── SIMPLE VIEW CARDS ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.forecast-simple-scroll {
|
.forecast-simple-scroll {
|
||||||
@@ -2394,15 +2579,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fsc-card {
|
.fsc-card {
|
||||||
|
/* One fixed width at every hour interval — a 4h card is not a wider card,
|
||||||
|
just a card with more space either side of it. It spans 2*bucket grid
|
||||||
|
tracks (see fscPlot in app.js) and is centred within that span, so it
|
||||||
|
stays over its landing hour and keeps lining up with the curve below
|
||||||
|
however wide the span gets. Must match FSC_CARD_W in app.js. */
|
||||||
|
width: 92px;
|
||||||
|
/* Kept alongside the fixed width: .fsc-feel is nowrap, so a long band label
|
||||||
|
("Very Strong Heat Stress") gives the card a ~146px min-content. Left to
|
||||||
|
the automatic minimum that can push the tracks it spans wider than their
|
||||||
|
neighbours', and the grid stops being uniform — which is the one thing
|
||||||
|
the connector-line maths (hourAt in app.js) assumes. */
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
justify-self: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 10px 8px 12px;
|
padding: 10px 8px 12px;
|
||||||
/* 2px each side = 4px between neighbouring cards. Done with margin rather
|
|
||||||
than a grid gap so the cards stay centred on their landing hour and keep
|
|
||||||
lining up with the curve below. */
|
|
||||||
margin: 0 2px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2417,11 +2610,14 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same display serif as the hour cell in the Detailed and Table views
|
||||||
|
(.utci-time) — the time reads as the same thing in all three. Now the
|
||||||
|
cards are a fixed 92px this can carry the full 16px too. */
|
||||||
.fsc-time {
|
.fsc-time {
|
||||||
font-family: JetBrains Mono, monospace;
|
font-family: Fraunces, serif;
|
||||||
font-size: 12px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
color: #4a3218;
|
color: #1e1208;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.fsc-card--now .fsc-time {
|
.fsc-card--now .fsc-time {
|
||||||
@@ -2466,13 +2662,21 @@
|
|||||||
}
|
}
|
||||||
.fsc-meta-row {
|
.fsc-meta-row {
|
||||||
font-family: Manrope, sans-serif;
|
font-family: Manrope, sans-serif;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.25;
|
||||||
color: #6b5230;
|
color: #6b5230;
|
||||||
opacity: 0.8;
|
/* Was 0.8 at 10px, which read as grey-on-cream mush. The wider cards have
|
||||||
|
the room, and these three lines are the only numbers on the card besides
|
||||||
|
the temperature — they should be legible, not decorative. */
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.fsc-card--now .fsc-meta-row { opacity: 1; color: #4a3218; }
|
.fsc-card--now .fsc-meta-row { opacity: 1; color: #4a3218; }
|
||||||
|
/* Speed and direction read as one fact ("21mph E"), so they share a line and
|
||||||
|
must never break across two. The direction sits a shade back from the
|
||||||
|
number — it qualifies the speed rather than competing with it. */
|
||||||
|
.fsc-meta-wind { white-space: nowrap; }
|
||||||
|
.fsc-meta-dir { opacity: 0.75; letter-spacing: 0.02em; }
|
||||||
.fsc-meta-rain { color: #2a6a90; }
|
.fsc-meta-rain { color: #2a6a90; }
|
||||||
.fsc-card--now .fsc-meta-rain { color: #1a4a70; }
|
.fsc-card--now .fsc-meta-rain { color: #1a4a70; }
|
||||||
|
|
||||||
@@ -2484,11 +2688,17 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The curve plots a FIXED −15…45°C scale across its full height (see fscMin /
|
||||||
|
fscMax in app.js) with preserveAspectRatio="none", so this height is the
|
||||||
|
whole scale — every degree from freezing to danger is somewhere in these
|
||||||
|
88px. It must not be flexible: the connector lines are drawn from the SVG's
|
||||||
|
top edge and have to stay flush under the cards, so any height the box does
|
||||||
|
not get is height the cold end loses off the bottom. */
|
||||||
.fsc-temp-curve {
|
.fsc-temp-curve {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-height: 60px;
|
height: 88px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fsc-table-btn-wrap {
|
.fsc-table-btn-wrap {
|
||||||
@@ -2522,7 +2732,9 @@
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.fsc-card {
|
.fsc-card {
|
||||||
min-width: 55px;
|
/* Width holds at 92px on mobile too — the row scrolls horizontally, so
|
||||||
|
narrowing the cards buys nothing and costs legibility. Only the inner
|
||||||
|
padding tightens. */
|
||||||
padding: 8px 4px 10px;
|
padding: 8px 4px 10px;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
@@ -2534,7 +2746,7 @@
|
|||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
.fsc-temp-curve {
|
.fsc-temp-curve {
|
||||||
min-height: 48px;
|
height: 72px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2542,16 +2754,14 @@
|
|||||||
ROTATED TABLE — hours along the top, metrics down the side
|
ROTATED TABLE — hours along the top, metrics down the side
|
||||||
─────────────────────────────────────────────────────────────────────
|
─────────────────────────────────────────────────────────────────────
|
||||||
Toggled by the rotate button in the table's top-left cell (state lives
|
Toggled by the rotate button in the table's top-left cell (state lives
|
||||||
in useAppState as tableRotated). Unlike the normal orientation — which
|
in useAppState as tableRotated). Built the same way as the normal
|
||||||
is two tables kept in step by useTableScroll — this is ONE table in a
|
orientation: head and body are separate tables sharing useTableScroll's
|
||||||
single scroll pane, so both sticky edges are pure CSS.
|
width sync, so the hour row can stick to the viewport as the page
|
||||||
|
scrolls. (A <thead> inside .utci-tbody-scroll can only ever stick to
|
||||||
A single table in the shared .utci-tbody-scroll horizontal scroller.
|
that scroller, which never scrolls vertically — so it would just
|
||||||
The normal orientation splits head from body so its header can stick
|
travel away with the table.) The metric column pins with plain CSS
|
||||||
to the viewport; here the hour row scrolls away with the table by
|
sticky inside the body scroller, and the corner is held to the left
|
||||||
design, so that machinery is not needed. The metric column pins with
|
edge by the head track's counter-translate.
|
||||||
plain CSS sticky, which works fine inside the scroller. The table
|
|
||||||
keeps its natural height and the page scrolls normally.
|
|
||||||
══════════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* Fallback only, for the first paint before the measurement lands.
|
/* Fallback only, for the first paint before the measurement lands.
|
||||||
@@ -2559,13 +2769,14 @@
|
|||||||
inline — it can't be fixed here, because auto layout widens that
|
inline — it can't be fixed here, because auto layout widens that
|
||||||
column to fit the longest metric name on show. */
|
column to fit the longest metric name on show. */
|
||||||
.utci-table-wrap.is-rotated {
|
.utci-table-wrap.is-rotated {
|
||||||
--hour-col-w: 136px;
|
--hour-col-w: 104px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-table.utci-table-rotated {
|
.utci-table.utci-table-rotated {
|
||||||
/* Auto layout: with a single table there are no header widths to match,
|
/* Fixed layout, like the normal orientation: head and body are two tables
|
||||||
so each hour column can simply size to its own content. */
|
kept in step by useTableScroll's width sync, and only fixed layout
|
||||||
table-layout: auto;
|
honours the widths it writes onto the first row of each. */
|
||||||
|
table-layout: fixed;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
/* The separate border model is required, not cosmetic. Under
|
/* The separate border model is required, not cosmetic. Under
|
||||||
@@ -2650,6 +2861,7 @@
|
|||||||
two different backgrounds is what makes them drift apart. */
|
two different backgrounds is what makes them drift apart. */
|
||||||
.utci-table-rotated .grp-felt { --grp-tint: rgba(223,243,236,0.50); }
|
.utci-table-rotated .grp-felt { --grp-tint: rgba(223,243,236,0.50); }
|
||||||
.utci-table-rotated .grp-surface { --grp-tint: rgba(221,234,248,0.50); }
|
.utci-table-rotated .grp-surface { --grp-tint: rgba(221,234,248,0.50); }
|
||||||
|
.utci-table-rotated .grp-pets { --grp-tint: rgba(214,238,240,0.50); }
|
||||||
.utci-table-rotated .grp-ambient { --grp-tint: rgba(236,234,227,0.50); }
|
.utci-table-rotated .grp-ambient { --grp-tint: rgba(236,234,227,0.50); }
|
||||||
.utci-table-rotated .grp-precip { --grp-tint: rgba(247,228,238,0.50); }
|
.utci-table-rotated .grp-precip { --grp-tint: rgba(247,228,238,0.50); }
|
||||||
.utci-table-rotated .grp-sky { --grp-tint: rgba(234,232,252,0.50); }
|
.utci-table-rotated .grp-sky { --grp-tint: rgba(234,232,252,0.50); }
|
||||||
@@ -2670,9 +2882,13 @@
|
|||||||
/* The group rules carry a 2px left border that marks where a group STARTS
|
/* The group rules carry a 2px left border that marks where a group STARTS
|
||||||
along the columns. Rotated, groups are stacked rows and have no left
|
along the columns. Rotated, groups are stacked rows and have no left
|
||||||
edge to mark, so it shows up as a stray line — and its width offsets
|
edge to mark, so it shows up as a stray line — and its width offsets
|
||||||
the label 2px against the metric names below it. */
|
the label 2px against the metric names below it.
|
||||||
|
The top rule matches .grp-label-fill's so the group boundary runs unbroken
|
||||||
|
from the pinned label across every hour column. */
|
||||||
.utci-table.utci-table-rotated tbody .grp-label-row--rotated th {
|
.utci-table.utci-table-rotated tbody .grp-label-row--rotated th {
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
|
border-top: 2px solid rgba(74, 50, 24, 0.18);
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The group heading pins with the metric names it labels. */
|
/* The group heading pins with the metric names it labels. */
|
||||||
@@ -2686,12 +2902,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* The per-hour cells filling out a divider row. They carry the group tint
|
/* The per-hour cells filling out a divider row. They carry the group tint
|
||||||
and, in the current hour's column, its share of the brass bracket. */
|
and, in the current hour's column, its share of the brass bracket.
|
||||||
|
|
||||||
|
Composited over the SAME opaque cream base as the sticky label at the row's
|
||||||
|
left end, for the same reason that label needs one: --grp-tint is only 50%
|
||||||
|
opaque, so painting it straight onto the cell resolved it against the table
|
||||||
|
background while the label resolved it against #f0e4c4. The divider then read
|
||||||
|
as a dark cap at the left fading to a much paler band across the hours — one
|
||||||
|
tint, two backgrounds, two colours. One base, and the row is a single shade
|
||||||
|
end to end. The brass now-column bracket is unaffected: it is a border on
|
||||||
|
these cells, not a background, so it still paints over the top. */
|
||||||
.utci-table.utci-table-rotated tbody td.grp-label-fill {
|
.utci-table.utci-table-rotated tbody td.grp-label-fill {
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
background: var(--grp-tint, transparent);
|
background-color: #f0e4c4;
|
||||||
border-bottom: 1px solid rgba(74, 50, 24, 0.18);
|
background-image: linear-gradient(var(--grp-tint, transparent), var(--grp-tint, transparent));
|
||||||
|
/* One rule, along the very top of the category block — the same 2px
|
||||||
|
rgba(74,50,24,0.18) the unrotated table draws down the left of each new
|
||||||
|
column group (.col-group-start), so a group boundary looks the same
|
||||||
|
whichever way the table is turned. Nothing along the bottom: the divider
|
||||||
|
and the metrics under it are one block, and a second line there just read
|
||||||
|
as a double rule around the label. Every cell in the row carries the edge
|
||||||
|
— under border-collapse:separate a <tr> border never paints, so the row's
|
||||||
|
rule has to be drawn cell by cell (see the row-separator rule above). */
|
||||||
|
border-top: 2px solid rgba(74, 50, 24, 0.18);
|
||||||
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-table.utci-table-rotated tbody th.rot-metric-label {
|
.utci-table.utci-table-rotated tbody th.rot-metric-label {
|
||||||
@@ -2699,7 +2934,10 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
min-width: 136px;
|
/* Was 136px, sized around the old long date in the corner. The names and
|
||||||
|
their unit lines need far less than that, and every pixel here is taken
|
||||||
|
straight off the hours. */
|
||||||
|
min-width: 104px;
|
||||||
/* box-shadow rather than border-right so the divider sits outside the
|
/* box-shadow rather than border-right so the divider sits outside the
|
||||||
cell box and cannot add to the column's measured width. */
|
cell box and cannot add to the column's measured width. */
|
||||||
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
|
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
|
||||||
@@ -2712,12 +2950,14 @@
|
|||||||
rule, which sets position:relative for the JS transform this view
|
rule, which sets position:relative for the JS transform this view
|
||||||
doesn't use. */
|
doesn't use. */
|
||||||
.utci-table.utci-table-rotated thead th.rot-corner {
|
.utci-table.utci-table-rotated thead th.rot-corner {
|
||||||
position: sticky;
|
/* Left-pinned by the head-track counter-translate useTableScroll applies to
|
||||||
left: 0;
|
thead th:first-child — the same device the normal orientation's Hour
|
||||||
|
header uses. CSS sticky would do nothing here: the head table sits in a
|
||||||
|
translated track, not in the scroller. */
|
||||||
|
position: relative;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
background: #f0e4c4;
|
background: #f0e4c4;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transform: none;
|
|
||||||
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
|
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
|
||||||
}
|
}
|
||||||
.utci-table-rotated .rot-corner-date {
|
.utci-table-rotated .rot-corner-date {
|
||||||
@@ -2727,18 +2967,40 @@
|
|||||||
color: #a08040;
|
color: #a08040;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* This cell is what widens the pinned metric-name column, so the date is
|
||||||
|
allowed to wrap rather than forcing the column past the names' width. */
|
||||||
|
.utci-table.utci-table-rotated thead th.rot-corner {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
.utci-table-rotated .rot-corner-controls {
|
.utci-table-rotated .rot-corner-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The pinned column carries text, not figures with icons, so it does not
|
||||||
|
need the 16px side padding the data cells use — and it is the one column
|
||||||
|
that costs the hours their width. */
|
||||||
|
.utci-table.utci-table-rotated thead th.rot-corner,
|
||||||
|
.utci-table.utci-table-rotated tbody th.rot-metric-label,
|
||||||
|
.utci-table.utci-table-rotated tbody th.rot-group-label {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the interval stepper inside that width; it would otherwise set the
|
||||||
|
column on its own. */
|
||||||
|
.utci-table-rotated .rot-corner .row-stepper { gap: 2px; }
|
||||||
|
.utci-table-rotated .rot-corner .row-stepper-btn { min-width: 26px; }
|
||||||
|
.utci-table-rotated .rot-corner .row-stepper-value { min-width: 20px; }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
/* Icon-only on narrow screens — the toolbar is already tight. */
|
/* Tighter segments on narrow screens — the toolbar is already tight,
|
||||||
.table-rotate-btn {
|
and there are three of them now. */
|
||||||
font-size: 0;
|
.fvt-btn {
|
||||||
gap: 0;
|
padding: 6px 6px;
|
||||||
padding: 6px 7px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2769,58 +3031,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── TOOLBAR BUTTON HEIGHTS ───────────────────────────────────────────
|
/* ── TOOLBAR BUTTON HEIGHTS ───────────────────────────────────────────
|
||||||
Quick/Detailed, Rotate and Edit columns sit on one row and have to
|
The view segments and Edit columns sit on one row and have to agree on
|
||||||
agree on height. They didn't: the first two set line-height:1 while
|
height. They didn't: the segments set line-height:1 while Edit columns
|
||||||
Edit columns leaves it at normal, so the same 6px padding produced a
|
leaves it at normal, so the same 6px padding produced a box ~3px
|
||||||
box ~3px taller. Pinning one line-height and one min-height across all
|
taller. Pinning one line-height and one min-height across both settles
|
||||||
three settles it regardless of font-size differences. */
|
it regardless of font-size differences. */
|
||||||
.fvt-btn,
|
.fvt-btn,
|
||||||
.table-rotate-btn,
|
|
||||||
.col-toggles-edit-btn {
|
.col-toggles-edit-btn {
|
||||||
min-height: 29px;
|
min-height: 29px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── ROTATE BUTTON ────────────────────────────────────────────────────
|
|
||||||
Sits beside the Quick/Detailed view toggle and only while the detailed
|
|
||||||
table is showing. Deliberately mirrors .fvt-btn — same type, padding,
|
|
||||||
border and states — so it reads as part of that control group rather
|
|
||||||
than a stray button. Standalone, so it keeps a radius on all corners
|
|
||||||
where .fvt-btn splits its radius across the segmented pair. */
|
|
||||||
.table-rotate-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-family: Manrope, sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 6px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1.5px solid #c9b08a;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: #f5edd6;
|
|
||||||
color: #6b4228;
|
|
||||||
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
|
||||||
}
|
|
||||||
.table-rotate-btn:hover {
|
|
||||||
border-color: #c8922a;
|
|
||||||
background: #fef3dc;
|
|
||||||
}
|
|
||||||
.table-rotate-btn.on {
|
|
||||||
background: #c8922a;
|
|
||||||
border-color: #c8922a;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
/* The icon turns with the state it describes — flat when hours run down
|
|
||||||
the side, quarter-turned when they run along the top. */
|
|
||||||
.table-rotate-btn .fvt-btn-icon {
|
|
||||||
transition: transform 0.18s ease;
|
|
||||||
}
|
|
||||||
.table-rotate-btn.on .fvt-btn-icon {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── MOBILE ───────────────────────────────────────────────────────── */
|
/* ── MOBILE ───────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.utci-rotated-scroll {
|
.utci-rotated-scroll {
|
||||||
@@ -2836,9 +3057,19 @@
|
|||||||
min-width: 68px;
|
min-width: 68px;
|
||||||
}
|
}
|
||||||
.utci-table-rotated .rot-metric-label {
|
.utci-table-rotated .rot-metric-label {
|
||||||
min-width: 112px;
|
min-width: 96px;
|
||||||
|
}
|
||||||
|
.utci-table.utci-table-rotated thead th.rot-corner,
|
||||||
|
.utci-table.utci-table-rotated tbody th.rot-metric-label,
|
||||||
|
.utci-table.utci-table-rotated tbody th.rot-group-label {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.utci-table-rotated .rot-corner-date {
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.utci-table-wrap.is-rotated {
|
.utci-table-wrap.is-rotated {
|
||||||
--hour-col-w: 112px;
|
--hour-col-w: 96px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-18
@@ -234,6 +234,7 @@
|
|||||||
|
|
||||||
.cs-btn.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); }
|
.cs-btn.grp-felt { background: rgba(223,243,236,0.50); color: #075c3a; border-color: rgba(7,92,58,0.35); }
|
||||||
.cs-btn.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); }
|
.cs-btn.grp-surface { background: rgba(221,234,248,0.50); color: #0b3e72; border-color: rgba(11,62,114,0.30); }
|
||||||
|
.cs-btn.grp-pets { background: rgba(214,238,240,0.50); color: #0a5158; border-color: rgba(10,81,88,0.30); }
|
||||||
.cs-btn.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); }
|
.cs-btn.grp-ambient { background: rgba(236,234,227,0.50); color: #3c3a35; border-color: rgba(60,58,53,0.30); }
|
||||||
.cs-btn.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); }
|
.cs-btn.grp-precip { background: rgba(247,228,238,0.50); color: #6b1e3a; border-color: rgba(107,30,58,0.30); }
|
||||||
.cs-btn.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); }
|
.cs-btn.grp-sky { background: rgba(234,232,252,0.50); color: #352e7a; border-color: rgba(53,46,122,0.30); }
|
||||||
@@ -243,6 +244,7 @@
|
|||||||
|
|
||||||
.cs-btn.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); }
|
.cs-btn.grp-felt:hover { background: rgba(213,233,226,0.80); color: #075c3a; border-color: rgba(7,92,58,0.55); }
|
||||||
.cs-btn.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); }
|
.cs-btn.grp-surface:hover { background: rgba(211,224,238,0.80); color: #0b3e72; border-color: rgba(11,62,114,0.50); }
|
||||||
|
.cs-btn.grp-pets:hover { background: rgba(204,228,230,0.80); color: #0a5158; border-color: rgba(10,81,88,0.50); }
|
||||||
.cs-btn.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); }
|
.cs-btn.grp-ambient:hover { background: rgba(226,224,217,0.80); color: #3c3a35; border-color: rgba(60,58,53,0.50); }
|
||||||
.cs-btn.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); }
|
.cs-btn.grp-precip:hover { background: rgba(237,218,228,0.80); color: #6b1e3a; border-color: rgba(107,30,58,0.50); }
|
||||||
.cs-btn.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); }
|
.cs-btn.grp-sky:hover { background: rgba(224,222,242,0.80); color: #352e7a; border-color: rgba(53,46,122,0.50); }
|
||||||
@@ -253,6 +255,7 @@
|
|||||||
/* Dropdown buttons - lighter palette to match toggle pills */
|
/* Dropdown buttons - lighter palette to match toggle pills */
|
||||||
.cs-btn.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; }
|
.cs-btn.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; }
|
||||||
.cs-btn.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; }
|
.cs-btn.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; }
|
||||||
|
.cs-btn.grp-pets.on { background: #34a0a8; border-color: #34a0a8; color: #fff; }
|
||||||
.cs-btn.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; }
|
.cs-btn.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; }
|
||||||
.cs-btn.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; }
|
.cs-btn.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; }
|
||||||
.cs-btn.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; }
|
.cs-btn.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; }
|
||||||
@@ -262,6 +265,7 @@
|
|||||||
|
|
||||||
.cs-btn.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; }
|
.cs-btn.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; }
|
||||||
.cs-btn.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; }
|
.cs-btn.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; }
|
||||||
|
.cs-btn.grp-pets.on:hover { background: #17868f; border-color: #17868f; color: #fff; }
|
||||||
.cs-btn.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; }
|
.cs-btn.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; }
|
||||||
.cs-btn.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; }
|
.cs-btn.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; }
|
||||||
.cs-btn.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; }
|
.cs-btn.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; }
|
||||||
@@ -272,6 +276,7 @@
|
|||||||
/* Toggle pills - lighter palette */
|
/* Toggle pills - lighter palette */
|
||||||
.col-toggle.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; }
|
.col-toggle.grp-felt.on { background: #2ab578; border-color: #2ab578; color: #fff; }
|
||||||
.col-toggle.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; }
|
.col-toggle.grp-surface.on { background: #4088d4; border-color: #4088d4; color: #fff; }
|
||||||
|
.col-toggle.grp-pets.on { background: #34a0a8; border-color: #34a0a8; color: #fff; }
|
||||||
.col-toggle.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; }
|
.col-toggle.grp-ambient.on { background: #929088; border-color: #929088; color: #fff; }
|
||||||
.col-toggle.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; }
|
.col-toggle.grp-precip.on { background: #c24e6c; border-color: #c24e6c; color: #fff; }
|
||||||
.col-toggle.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; }
|
.col-toggle.grp-sky.on { background: #786ec8; border-color: #786ec8; color: #fff; }
|
||||||
@@ -281,6 +286,7 @@
|
|||||||
|
|
||||||
.col-toggle.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; }
|
.col-toggle.grp-felt.on:hover { background: #1e9e64; border-color: #1e9e64; color: #fff; }
|
||||||
.col-toggle.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; }
|
.col-toggle.grp-surface.on:hover { background: #2e72bc; border-color: #2e72bc; color: #fff; }
|
||||||
|
.col-toggle.grp-pets.on:hover { background: #17868f; border-color: #17868f; color: #fff; }
|
||||||
.col-toggle.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; }
|
.col-toggle.grp-ambient.on:hover { background: #7a7870; border-color: #7a7870; color: #fff; }
|
||||||
.col-toggle.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; }
|
.col-toggle.grp-precip.on:hover { background: #a83c5a; border-color: #a83c5a; color: #fff; }
|
||||||
.col-toggle.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; }
|
.col-toggle.grp-sky.on:hover { background: #6258b0; border-color: #6258b0; color: #fff; }
|
||||||
@@ -893,8 +899,67 @@
|
|||||||
background: #f0e4c4;
|
background: #f0e4c4;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.welcome-remember strong {
|
||||||
|
color: #c8922a;
|
||||||
|
}
|
||||||
|
.welcome-note {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #8a7250;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
/* Weekly free-tier nudge (WeeklyNudge.js) - reuses the .welcome-* card. */
|
||||||
|
.nudge-fact {
|
||||||
|
text-align: center;
|
||||||
margin: 0 0 18px;
|
margin: 0 0 18px;
|
||||||
}
|
}
|
||||||
|
.nudge-fact-icon {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.nudge-fact .welcome-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.nudge-fact-body {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #4a3420;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.nudge-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.nudge-links a,
|
||||||
|
.nudge-links button {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 0 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #c8922a;
|
||||||
|
border-bottom: 1px solid rgba(200, 146, 42, 0.4);
|
||||||
|
}
|
||||||
|
.nudge-links .nudge-dismiss {
|
||||||
|
color: #b09870;
|
||||||
|
border-bottom-color: rgba(176, 152, 112, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.welcome-steps {
|
.welcome-steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0 0 22px;
|
margin: 0 0 22px;
|
||||||
@@ -996,6 +1061,30 @@
|
|||||||
.welcome-step-num {
|
.welcome-step-num {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
.welcome-steps {
|
||||||
|
gap: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.welcome-intro {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.welcome-remember {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.welcome-note {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.nudge-fact-icon {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
.nudge-fact .welcome-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.nudge-fact-body {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.utci-fetch-time {
|
.utci-fetch-time {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -1021,7 +1110,9 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-top: 0.5rem;
|
/* Was 0.5rem — the recents now follow the tool row, so they need a clearer
|
||||||
|
break above them. */
|
||||||
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
/* header.css sets align-items: center for the same tablet breakpoint, but
|
/* header.css sets align-items: center for the same tablet breakpoint, but
|
||||||
since ui.css is @imported after header.css in sunscope.css it wins at
|
since ui.css is @imported after header.css in sunscope.css it wins at
|
||||||
@@ -1292,9 +1383,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.utci-app {
|
.utci-app {
|
||||||
padding: 52px 10px 32px;
|
/* No top padding — the header is in the flow at the very top of the page.
|
||||||
|
Was 52px, reserving space for the nav back when it was absolutely
|
||||||
|
positioned over it. Narrowing --app-pad re-aims the header's and the
|
||||||
|
event note's full-bleed at the same time. */
|
||||||
|
--app-pad: 10px;
|
||||||
|
padding: 0 var(--app-pad) 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.utci-header {
|
.utci-header {
|
||||||
grid-template-columns: 1fr; /* stack the 3 header columns */
|
grid-template-columns: 1fr; /* stack the 3 header columns */
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1304,9 +1401,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-current-loc,
|
.utci-current-loc {
|
||||||
.utci-loc-coords,
|
|
||||||
.utci-fetch-time {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1357,10 +1452,6 @@
|
|||||||
padding-top: 80px;
|
padding-top: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.utci-fetch-time {
|
|
||||||
text-align: center; /* override the default left-align on mobile */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide the export panel on phones — no room for it, and the export
|
/* Hide the export panel on phones — no room for it, and the export
|
||||||
actions aren't useful at this size. */
|
actions aren't useful at this size. */
|
||||||
.export-panel {
|
.export-panel {
|
||||||
@@ -1435,6 +1526,15 @@
|
|||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Last update" closes the panel out — a hairline above it keeps it apart
|
||||||
|
from the contribution rows it follows. */
|
||||||
|
.insight-panel--why .utci-fetch-time {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid #ede4cc;
|
||||||
|
}
|
||||||
|
|
||||||
/* Solar model selector inside the Why it feels like panel */
|
/* Solar model selector inside the Why it feels like panel */
|
||||||
.insight-env-footer {
|
.insight-env-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1538,10 +1638,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Week rows carry a score bar, so they are a two-line grid rather than the
|
/* Week rows carry a score bar, so they are a two-line grid rather than the
|
||||||
single flex line the glance rows use: day and window on top, bar beneath. */
|
single flex line the glance rows use: day and window on top, bar beneath.
|
||||||
|
Day and window read as one sentence - "Sun 30 : all day" - so they sit
|
||||||
|
together at the left rather than being pushed to opposite edges, where the
|
||||||
|
gap between them made two short phrases look like two columns of data. */
|
||||||
.insight-panel--week .insight-row {
|
.insight-panel--week .insight-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 20px 1fr auto;
|
grid-template-columns: 20px auto minmax(0, 1fr);
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"icon label value"
|
"icon label value"
|
||||||
"icon score score";
|
"icon score score";
|
||||||
@@ -1550,8 +1653,42 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.insight-panel--week .insight-icon { grid-area: icon; }
|
.insight-panel--week .insight-icon { grid-area: icon; }
|
||||||
.insight-panel--week .insight-label { grid-area: label; }
|
.insight-panel--week .insight-label { grid-area: label; flex: none; }
|
||||||
.insight-panel--week .insight-value { grid-area: value; }
|
.insight-panel--week .insight-value {
|
||||||
|
grid-area: value;
|
||||||
|
text-align: left;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* The separator is drawn, not written into the string, so the value stays a
|
||||||
|
plain window ("all day") everywhere else it is used. */
|
||||||
|
.insight-panel--week .insight-value::before {
|
||||||
|
content: ': ';
|
||||||
|
color: #8a8375;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The washout row - no day qualified, so there is no day name and no score,
|
||||||
|
just a sentence. Forcing it through the day-and-window layout squeezed that
|
||||||
|
sentence into the narrow value column and stranded the colon on a line of
|
||||||
|
its own, so it gets the full width and drops the separator. Specificity is
|
||||||
|
raised past the rail rules below deliberately: this row stacks at every
|
||||||
|
width. */
|
||||||
|
.insight-panel--week .insight-row.insight-row--week-note {
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"icon label"
|
||||||
|
"icon value";
|
||||||
|
align-items: start;
|
||||||
|
row-gap: 2px;
|
||||||
|
}
|
||||||
|
.insight-panel--week .insight-row--week-note .insight-icon { padding-top: 2px; }
|
||||||
|
.insight-panel--week .insight-row--week-note .insight-value {
|
||||||
|
font-family: Manrope, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.insight-panel--week .insight-row--week-note .insight-value::before { content: none; }
|
||||||
|
|
||||||
/* How good the day is out of 100 — the bar does the comparing, the number
|
/* How good the day is out of 100 — the bar does the comparing, the number
|
||||||
settles close calls. */
|
settles close calls. */
|
||||||
@@ -1676,6 +1813,7 @@
|
|||||||
margin: -6px 0 10px;
|
margin: -6px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.insight-thermal-cat {
|
.insight-thermal-cat {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
@@ -1841,14 +1979,20 @@
|
|||||||
row-gap: 1px;
|
row-gap: 1px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
/* The rail is only 240px wide, so the window drops under the day name and
|
/* The rail is only 240px wide, but "Sun 30 : all day" still fits on one
|
||||||
the score bar takes a third line rather than sharing one. */
|
line, so the week rows keep day and window together and give the score
|
||||||
|
bar the second line - unlike the glance rows, which stack. */
|
||||||
.insight-panel--week .insight-row {
|
.insight-panel--week .insight-row {
|
||||||
|
grid-template-columns: 20px auto minmax(0, 1fr);
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"icon label"
|
"icon label value"
|
||||||
"icon value"
|
"icon score score";
|
||||||
"icon score";
|
|
||||||
row-gap: 3px;
|
row-gap: 3px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.insight-panel--week .insight-label,
|
||||||
|
.insight-panel--week .insight-value {
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.insight-panel--glance .insight-icon,
|
.insight-panel--glance .insight-icon,
|
||||||
.insight-panel--week .insight-icon {
|
.insight-panel--week .insight-icon {
|
||||||
|
|||||||
+449
-183
@@ -17,7 +17,7 @@
|
|||||||
// Preview the Pro view ......... useState(false) on isPro → flip to true
|
// Preview the Pro view ......... useState(false) on isPro → flip to true
|
||||||
// Starting location ............ useState({...}) on `location` near top
|
// Starting location ............ useState({...}) on `location` near top
|
||||||
// Default columns shown ........ useState({...}) on visibleCols
|
// Default columns shown ........ useState({...}) on visibleCols
|
||||||
// Page tagline / about copy .... search "utci-tagline" or "utci-about-text"
|
// Page tagline / about copy .... search "nav-logo-tag" or "utci-about-text"
|
||||||
// ════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
import { h, render, Fragment } from '../vendor/preact.js';
|
import { h, render, Fragment } from '../vendor/preact.js';
|
||||||
@@ -39,8 +39,9 @@ import { DayTabs } from './components/DayTabs.js';
|
|||||||
import { ConfigPanel } from './components/ConfigPanel.js';
|
import { ConfigPanel } from './components/ConfigPanel.js';
|
||||||
import { WelcomeModal } from './components/WelcomeModal.js';
|
import { WelcomeModal } from './components/WelcomeModal.js';
|
||||||
import { RestoreModal } from './components/RestoreModal.js';
|
import { RestoreModal } from './components/RestoreModal.js';
|
||||||
|
import { WeeklyNudge } from './components/WeeklyNudge.js';
|
||||||
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
import { buildColumnDefs, airTempRgb, petAirTempRgb } from './tableColumns.js';
|
||||||
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel } from './compute.js';
|
import { computeWhyFeelsLike, computeGlanceSummary, computeBestDay, bestDaysLabel, bestDaysHint } from './compute.js';
|
||||||
import { solarElevationDeg } from './physics.js';
|
import { solarElevationDeg } from './physics.js';
|
||||||
import { exportDayXls } from './export.js';
|
import { exportDayXls } from './export.js';
|
||||||
|
|
||||||
@@ -215,6 +216,7 @@ export function UTCIForecast() {
|
|||||||
visibleCols, setVisibleCols, toggleCol,
|
visibleCols, setVisibleCols, toggleCol,
|
||||||
showDecimals, toggleShowDecimals,
|
showDecimals, toggleShowDecimals,
|
||||||
welcomeOpen, closeWelcome, openWelcome,
|
welcomeOpen, closeWelcome, openWelcome,
|
||||||
|
weeklyNudgeOpen, closeWeeklyNudge,
|
||||||
restoreOpen, openRestore, closeRestore,
|
restoreOpen, openRestore, closeRestore,
|
||||||
panelOpen, openPanel, closePanel,
|
panelOpen, openPanel, closePanel,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
@@ -233,7 +235,7 @@ export function UTCIForecast() {
|
|||||||
indoorMode, setIndoorMode,
|
indoorMode, setIndoorMode,
|
||||||
pollenType, setPollenTypeAndSave,
|
pollenType, setPollenTypeAndSave,
|
||||||
utciEnv, setUtciEnv,
|
utciEnv, setUtciEnv,
|
||||||
tableRotated, toggleTableRotated,
|
tableRotated, setTableRotated,
|
||||||
headStickyRef, headTrackRef, headTableRef,
|
headStickyRef, headTrackRef, headTableRef,
|
||||||
bodyScrollRef, bodyTableRef, tableWrapRef,
|
bodyScrollRef, bodyTableRef, tableWrapRef,
|
||||||
colPopup, colPopupRef,
|
colPopup, colPopupRef,
|
||||||
@@ -253,6 +255,24 @@ export function UTCIForecast() {
|
|||||||
|
|
||||||
// Search is hidden by default and revealed via the magnifier next to the
|
// Search is hidden by default and revealed via the magnifier next to the
|
||||||
// location. The input then overlays the location line to save space.
|
// location. The input then overlays the location line to save space.
|
||||||
|
// Dashboard sign-in state, purely for the Account menu's Sign out/in
|
||||||
|
// item - a lightweight session check against the dashboard's own auth
|
||||||
|
// (see api/me.php), independent of isPro (which is the separate
|
||||||
|
// localStorage-based Extra flag and has nothing to do with being signed
|
||||||
|
// in to the journal).
|
||||||
|
const [dashUser, setDashUser] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('./api/me.php', { credentials: 'same-origin' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setDashUser(d.user ?? null))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const signOutDashboard = () => {
|
||||||
|
fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.then(() => setDashUser(null))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
// While closing, keep the overlay mounted so it can fade/close out before
|
// While closing, keep the overlay mounted so it can fade/close out before
|
||||||
// unmounting; cleared when the exit animation ends.
|
// unmounting; cleared when the exit animation ends.
|
||||||
@@ -270,27 +290,111 @@ export function UTCIForecast() {
|
|||||||
const NOTE_MS = 8000;
|
const NOTE_MS = 8000;
|
||||||
const NOTE_FADE_MS = 350;
|
const NOTE_FADE_MS = 350;
|
||||||
|
|
||||||
|
// Pending cross-fade timer. Held in a ref so a location change (or the
|
||||||
|
// strip closing) can cancel it: a fade that lands after the event list has
|
||||||
|
// been replaced used to set an index past the end of the new list, leaving
|
||||||
|
// the banner mounted with every slide hidden — a strip with nothing in it.
|
||||||
|
const noteFadeTimer = useRef(null);
|
||||||
|
const noteClosingRef = useRef(false);
|
||||||
|
|
||||||
|
const cancelNoteFade = () => {
|
||||||
|
if (noteFadeTimer.current) {
|
||||||
|
clearTimeout(noteFadeTimer.current);
|
||||||
|
noteFadeTimer.current = null;
|
||||||
|
}
|
||||||
|
setNoteFading(false);
|
||||||
|
};
|
||||||
|
|
||||||
const noteGoTo = (next) => {
|
const noteGoTo = (next) => {
|
||||||
if (next === noteIndex) return;
|
if (next === noteIndex) return;
|
||||||
setNoteFading(true);
|
setNoteFading(true);
|
||||||
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS);
|
if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
|
||||||
|
noteFadeTimer.current = setTimeout(() => {
|
||||||
|
noteFadeTimer.current = null;
|
||||||
|
setNoteIndex(next);
|
||||||
|
setNoteFading(false);
|
||||||
|
}, NOTE_FADE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the index in range if the event list shrinks between forecasts.
|
// ── Dismissal ────────────────────────────────────────────────────────
|
||||||
|
// Closing the strip hides it for the rest of the browser session, but only
|
||||||
|
// for the events that were showing at the time: the dismissal is stored
|
||||||
|
// against a signature of the active ids, so a new event (or a fresh weather
|
||||||
|
// warning) brings the strip back rather than staying silently suppressed.
|
||||||
|
const NOTE_CLOSE_KEY = 'sunscope_event_note_closed';
|
||||||
|
const NOTE_CLOSE_MS = 260;
|
||||||
|
const noteSig = activeEvents.map(e => e.id).sort().join('|');
|
||||||
|
const [noteClosedSig, setNoteClosedSig] = useState(() => {
|
||||||
|
try { return sessionStorage.getItem(NOTE_CLOSE_KEY) || ''; } catch (e) { return ''; }
|
||||||
|
});
|
||||||
|
// Kept mounted for the collapse animation, then dropped.
|
||||||
|
const [noteClosing, setNoteClosing] = useState(false);
|
||||||
|
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
|
||||||
|
|
||||||
|
const noteSlotRef = useRef(null);
|
||||||
|
|
||||||
|
const noteClose = () => {
|
||||||
|
if (noteClosing) return;
|
||||||
|
// Pin the slot to the height it actually has, then run it to zero on the
|
||||||
|
// next frame. The slot is bottom-anchored (see .event-note-slot), so the
|
||||||
|
// banner rides its shrinking bottom edge up behind the header while the
|
||||||
|
// page below closes the gap at exactly the same rate — one property, one
|
||||||
|
// movement. It is measured here rather than guessed in CSS because every
|
||||||
|
// stylesheet-side version of this (max-height, a 1fr → 0fr row) had to
|
||||||
|
// interpolate against an assumed size, which is what stalled part-way.
|
||||||
|
const el = noteSlotRef.current;
|
||||||
|
if (el) {
|
||||||
|
const reduced = window.matchMedia
|
||||||
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
el.style.height = `${el.offsetHeight}px`;
|
||||||
|
// Inline rather than through the is-closing class: that class only lands
|
||||||
|
// on the next render, which can be after the frame below.
|
||||||
|
el.style.transition = `height ${reduced ? 0.01 : NOTE_CLOSE_MS / 1000}s linear`;
|
||||||
|
void el.offsetHeight; // flush, so the next value starts a transition
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (noteSlotRef.current) noteSlotRef.current.style.height = '0px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
noteClosingRef.current = true;
|
||||||
|
cancelNoteFade();
|
||||||
|
setNoteClosing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
|
||||||
|
setNoteClosedSig(noteSig);
|
||||||
|
setNoteClosing(false);
|
||||||
|
noteClosingRef.current = false;
|
||||||
|
}, NOTE_CLOSE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start from the first slide whenever the event set itself changes — a new
|
||||||
|
// location brings a different list, and carrying an index (or a half-run
|
||||||
|
// fade) across leaves the strip showing nothing.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (noteIndex >= activeEvents.length) setNoteIndex(0);
|
cancelNoteFade();
|
||||||
}, [activeEvents.length]);
|
setNoteIndex(0);
|
||||||
|
}, [noteSig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeEvents.length <= 1) return;
|
if (activeEvents.length <= 1) return;
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
|
// Nothing to cross-fade to mid-close — the height animation would have
|
||||||
|
// to re-target a banner whose content just changed under it.
|
||||||
|
if (noteClosingRef.current) return;
|
||||||
setNoteFading(true);
|
setNoteFading(true);
|
||||||
setTimeout(() => {
|
if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
|
||||||
|
noteFadeTimer.current = setTimeout(() => {
|
||||||
|
noteFadeTimer.current = null;
|
||||||
setNoteIndex(i => (i + 1) % activeEvents.length);
|
setNoteIndex(i => (i + 1) % activeEvents.length);
|
||||||
setNoteFading(false);
|
setNoteFading(false);
|
||||||
}, NOTE_FADE_MS);
|
}, NOTE_FADE_MS);
|
||||||
}, NOTE_MS);
|
}, NOTE_MS);
|
||||||
return () => clearInterval(id);
|
return () => {
|
||||||
|
clearInterval(id);
|
||||||
|
if (noteFadeTimer.current) {
|
||||||
|
clearTimeout(noteFadeTimer.current);
|
||||||
|
noteFadeTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [activeEvents.length]);
|
}, [activeEvents.length]);
|
||||||
|
|
||||||
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
const [colTogglesOpen, setColTogglesOpen] = useState(false);
|
||||||
@@ -302,6 +406,33 @@ export function UTCIForecast() {
|
|||||||
activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
|
activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
|
||||||
const searchInputRef = useRef(null);
|
const searchInputRef = useRef(null);
|
||||||
const fscScrollRef = useRef(null);
|
const fscScrollRef = useRef(null);
|
||||||
|
// Quick view's card strip scrolls sideways like the table does, so it gets
|
||||||
|
// the same edge fades and chevrons — see .forecast-simple-wrap in table.css.
|
||||||
|
const [fscCanScrollLeft, setFscCanScrollLeft] = useState(false);
|
||||||
|
const [fscCanScrollRight, setFscCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = fscScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
setFscCanScrollLeft(el.scrollLeft > 1);
|
||||||
|
setFscCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
el.addEventListener('scroll', update, { passive: true });
|
||||||
|
// The strip's width and its content's width both move it: the viewport on
|
||||||
|
// resize, the card row when the day, interval or profile changes. Watching
|
||||||
|
// both keeps the deps list out of it.
|
||||||
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
|
||||||
|
if (ro) {
|
||||||
|
ro.observe(el);
|
||||||
|
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', update);
|
||||||
|
if (ro) ro.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = fscScrollRef.current;
|
const el = fscScrollRef.current;
|
||||||
@@ -346,12 +477,18 @@ export function UTCIForecast() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [simpleTemp, setSimpleTemp] = useState('utciAdj');
|
const [simpleTemp, setSimpleTemp] = useState('utciAdj');
|
||||||
|
// Skips the very first run so a page refresh keeps the saved Simple /
|
||||||
|
// Table / Detailed choice — the profile-driven jump to the table below
|
||||||
|
// is meant for an actual profile *switch*, not for restoring state.
|
||||||
|
const profileViewFirstRun = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
|
if (activeProfile === 'vehicle' || (activeProfile === 'outdoors' && outdoorsVariant === 'driver')) setSimpleTemp('vehicleT');
|
||||||
else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
|
else if (activeProfile === 'home' || (activeProfile === 'outdoors' && outdoorsVariant === 'office')) setSimpleTemp(indoorManaged ? 'managedT' : 'indoorT');
|
||||||
else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
|
else if (activeProfile === 'pets') setSimpleTemp('furSurfaceT');
|
||||||
else setSimpleTemp('utciAdj');
|
else setSimpleTemp('utciAdj');
|
||||||
if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
|
if (profileViewFirstRun.current) {
|
||||||
|
profileViewFirstRun.current = false;
|
||||||
|
} else if (['alltemps', 'showall', 'custom', 'farming', 'construction', 'market', 'windowcleaning', 'office'].includes(activeProfile)) {
|
||||||
setForecastView('table');
|
setForecastView('table');
|
||||||
}
|
}
|
||||||
}, [activeProfile, outdoorsVariant]);
|
}, [activeProfile, outdoorsVariant]);
|
||||||
@@ -405,10 +542,11 @@ export function UTCIForecast() {
|
|||||||
// Columns that mark the start of a logical group - used to draw a faint
|
// Columns that mark the start of a logical group - used to draw a faint
|
||||||
// vertical border separating groups in the forecast table.
|
// vertical border separating groups in the forecast table.
|
||||||
const GROUP_ORDER = {
|
const GROUP_ORDER = {
|
||||||
felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'burn', 'utci', 'delta', 'tmrt'],
|
felt: ['utciP', 'shadeT', 'vehicleT', 'indoorT', 'managedT', 'utci', 'burn'],
|
||||||
surface: ['concreteT', 'soilT', 'soilT6', 'soilM', 'furSurfaceT', 'pawT'],
|
surface: ['concreteT', 'soilT', 'soilT6', 'soilM'],
|
||||||
ambient: ['petShadeT', 'petHomeT', 'air', 'rh', 'dew'],
|
pets: ['furSurfaceT', 'pawT', 'petShadeT', 'petHomeT'],
|
||||||
precip: ['precip', 'precipProb', 'lightning'],
|
ambient: ['air', 'rh', 'dew'],
|
||||||
|
precip: ['precip', 'precipProb'],
|
||||||
sky: ['cloud', 'vis'],
|
sky: ['cloud', 'vis'],
|
||||||
wind: ['wind', 'dir'],
|
wind: ['wind', 'dir'],
|
||||||
airqual: ['aqi', 'pollen'],
|
airqual: ['aqi', 'pollen'],
|
||||||
@@ -439,7 +577,7 @@ export function UTCIForecast() {
|
|||||||
};
|
};
|
||||||
// Display names for the column groups — used for the spanning labels
|
// Display names for the column groups — used for the spanning labels
|
||||||
// above the columns normally, and for the divider rows when rotated.
|
// above the columns normally, and for the divider rows when rotated.
|
||||||
const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
|
const GROUP_LABELS = { felt: 'Felt', surface: 'Surface', pets: 'Pets', ambient: 'Ambient', precip: 'Precip', sky: 'Sky', wind: 'Wind', airqual: 'Air quality', solar: 'Solar' };
|
||||||
// Builds the group label row above the column headers.
|
// Builds the group label row above the column headers.
|
||||||
// Each visible group gets one spanning cell; groups with no visible
|
// Each visible group gets one spanning cell; groups with no visible
|
||||||
// columns are skipped entirely. Hour always gets a blank lead cell.
|
// columns are skipped entirely. Hour always gets a blank lead cell.
|
||||||
@@ -534,7 +672,9 @@ export function UTCIForecast() {
|
|||||||
);
|
);
|
||||||
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
|
// Week-scoped, so it is kept out of the day-scoped "At a glance" panel and
|
||||||
// rendered in its own "The Week Ahead" box beneath it.
|
// rendered in its own "The Week Ahead" box beneath it.
|
||||||
const weekAhead = computeBestDay(days, nowLocalISO);
|
// Scored on the profile's own main field, so the panel answers "when should
|
||||||
|
// I drive / when is the house bearable", not always "when is it nice out".
|
||||||
|
const weekAhead = computeBestDay(days, nowLocalISO, activeProfile, outdoorsVariant);
|
||||||
|
|
||||||
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
// Human-readable date for the "Day at a glance" heading, e.g. "Mon 2nd June 2026".
|
||||||
const glanceDate = (() => {
|
const glanceDate = (() => {
|
||||||
@@ -549,6 +689,18 @@ export function UTCIForecast() {
|
|||||||
return `${wd} ${day}${ord} ${mon} ${yr}`;
|
return `${wd} ${day}${ord} ${mon} ${yr}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Compact form for the rotated table's corner cell, e.g. "Thu 27 Aug 2026".
|
||||||
|
// That cell sets the width of the pinned metric-name column, and the long
|
||||||
|
// form ("Thu 27th August 2026") pushed it wider than the names need.
|
||||||
|
const glanceDateShort = (() => {
|
||||||
|
const key = days[selectedDay]?.key;
|
||||||
|
if (!key) return '';
|
||||||
|
const d = new Date(key + 'T00:00Z');
|
||||||
|
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
|
||||||
|
const mon = d.toLocaleDateString('en-GB', { month: 'short', timeZone: 'UTC' });
|
||||||
|
return `${wd} ${d.getUTCDate()} ${mon} ${d.getUTCFullYear()}`;
|
||||||
|
})();
|
||||||
|
|
||||||
// Pro: export the selected day's full hourly data as a styled spreadsheet.
|
// Pro: export the selected day's full hourly data as a styled spreadsheet.
|
||||||
const handleExportDay = () => {
|
const handleExportDay = () => {
|
||||||
exportDayXls(visible, {
|
exportDayXls(visible, {
|
||||||
@@ -611,15 +763,18 @@ export function UTCIForecast() {
|
|||||||
// The grid is measured in HALF-hour tracks, because a card centred on its
|
// The grid is measured in HALF-hour tracks, because a card centred on its
|
||||||
// landing hour starts on a half-hour boundary. All tracks are identical,
|
// landing hour starts on a half-hour boundary. All tracks are identical,
|
||||||
// and each card SPANS 2*bucket of them (rather than sitting in one track
|
// and each card SPANS 2*bucket of them (rather than sitting in one track
|
||||||
// at width:400%) — spanning divides a card's intrinsic width across the
|
// at width:400%). The card itself is a fixed width centred in that span,
|
||||||
// tracks it covers, so the grid's max-content width stays close to what
|
// so a longer bucket buys spacing between cards rather than a fatter card.
|
||||||
// it was and mobile doesn't gain a load of extra horizontal scroll.
|
|
||||||
//
|
//
|
||||||
// tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
|
// tracks = [ pad ][ hour 0 ][ hour 1 ] … [ hour H-1 ][ pad ]
|
||||||
// pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room)
|
// pad = bucket half-hours (>= the (bucket-1)/2 h overhang, + breathing room)
|
||||||
// hour j -> centre at (bucket + 2j + 1) / total
|
// hour j -> centre at (bucket + 2j + 1) / total
|
||||||
// card -> spans 2*bucket tracks, starting one half-hour after 2j
|
// card -> spans 2*bucket tracks, starting one half-hour after 2j
|
||||||
// => centre = 2j + 1 + bucket == hour j's centre ✓
|
// => centre = 2j + 1 + bucket == hour j's centre ✓
|
||||||
|
// Quick-view card width, and the minimum clearance between two cards. Kept
|
||||||
|
// here rather than only in CSS because the grid track sizing is derived from
|
||||||
|
// them; .fsc-card sets the same width.
|
||||||
|
const FSC_CARD_W = 92, FSC_CARD_GAP = 4;
|
||||||
const fscPlot = (() => {
|
const fscPlot = (() => {
|
||||||
if (!tableRows.length) return null;
|
if (!tableRows.length) return null;
|
||||||
const cards = tableRows.length;
|
const cards = tableRows.length;
|
||||||
@@ -632,10 +787,16 @@ export function UTCIForecast() {
|
|||||||
const landing = [];
|
const landing = [];
|
||||||
let j = 0;
|
let j = 0;
|
||||||
for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
|
for (const r of tableRows) { landing.push(j); j += r.isoHours ? r.isoHours.length : 1; }
|
||||||
// A card spans 2*bucket tracks, so this keeps its 55px minimum.
|
// Every card is a fixed FSC_CARD_W wide at every interval (see .fsc-card),
|
||||||
const unit = 55 / (2 * bucket);
|
// centred in the 2*bucket tracks it spans. So the tracks only have to be
|
||||||
|
// wide enough that neighbouring cards clear each other: a span of at least
|
||||||
|
// card + gutter. At 1h that is the tight case; at 3h/4h the same rule
|
||||||
|
// leaves a wider gap between cards, which is what the extra hours per card
|
||||||
|
// should look like. The 1fr half of the minmax lets the gaps grow further
|
||||||
|
// on a wide screen — the cards never do.
|
||||||
|
const unit = (FSC_CARD_W + FSC_CARD_GAP) / (2 * bucket);
|
||||||
return {
|
return {
|
||||||
hourly, hours, bucket, landing,
|
hours, bucket, landing,
|
||||||
cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
|
cols: `repeat(${total}, minmax(${unit.toFixed(2)}px, 1fr))`,
|
||||||
// Fraction of the track width at which hour j's data point sits.
|
// Fraction of the track width at which hour j's data point sits.
|
||||||
hourAt: j2 => (bucket + 2 * j2 + 1) / total,
|
hourAt: j2 => (bucket + 2 * j2 + 1) / total,
|
||||||
@@ -667,9 +828,60 @@ export function UTCIForecast() {
|
|||||||
// • "utci-about" — the explainer paragraphs at the bottom
|
// • "utci-about" — the explainer paragraphs at the bottom
|
||||||
// • "utci-footer" — the "reading the table" note
|
// • "utci-footer" — the "reading the table" note
|
||||||
return html`
|
return html`
|
||||||
<div class=${`utci-app${activeEvents.length > 0 ? ' has-event-note' : ''}`}>
|
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
|
||||||
${activeEvents.length > 0 && (() => {
|
<header class="utci-topnav" id="site-nav">
|
||||||
|
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
||||||
|
<div class="nav-logo">
|
||||||
|
<h1 class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></h1>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</div>
|
||||||
|
<nav class="nav-links" id="nav-drawer" aria-label="Main">
|
||||||
|
<a href="./index.html">Forecast</a>
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./about.html">Overview</a>
|
||||||
|
<a href="./sunsoak.html">SunSoak & the science</a>
|
||||||
|
<a href="./profiles.html">Forecast profiles</a>
|
||||||
|
<a href="./columns.html">Column reference</a>
|
||||||
|
<a href="./temperatures.html">Derived temperatures</a>
|
||||||
|
<a href="./features.html">On-screen features</a>
|
||||||
|
<a href="./stress-bands.html">Stress bands</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="./faq.html">FAQ</a>
|
||||||
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a href=${isPro
|
||||||
|
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||||||
|
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
||||||
|
target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
${dashUser
|
||||||
|
? html`<a href="#" onClick=${(e) => { e.preventDefault(); signOutDashboard(); }}>Sign out</a>`
|
||||||
|
: html`<a href="./dashboard.html">Sign in</a>`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
|
||||||
|
onClick=${() => {
|
||||||
|
const nav = document.getElementById('site-nav');
|
||||||
|
const drawer = document.getElementById('nav-drawer');
|
||||||
|
const btn = document.getElementById('burger-btn');
|
||||||
|
const open = nav.classList.toggle('nav-open');
|
||||||
|
drawer.classList.toggle('is-open', open);
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}}>
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
${activeEvents.length > 0 && !noteDismissed && (() => {
|
||||||
const ev = activeEvents[noteIndex] || activeEvents[0];
|
const ev = activeEvents[noteIndex] || activeEvents[0];
|
||||||
|
// Guards the window between an event list changing and the reset
|
||||||
|
// effect above running: an index past the end would hide every slide.
|
||||||
|
const slideIndex = noteIndex < activeEvents.length ? noteIndex : 0;
|
||||||
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
|
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
|
||||||
const fmtDate = (iso) => iso
|
const fmtDate = (iso) => iso
|
||||||
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
@@ -701,7 +913,11 @@ export function UTCIForecast() {
|
|||||||
};
|
};
|
||||||
})() : undefined;
|
})() : undefined;
|
||||||
return html`
|
return html`
|
||||||
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}`} style=${noteTintStyle}>
|
${/* The slot clips the banner: on close it collapses to nothing while
|
||||||
|
the note itself slides up inside it, so the strip reads as
|
||||||
|
tucking back up under the header rather than fading in place. */''}
|
||||||
|
<div ref=${noteSlotRef} class=${`event-note-slot${noteClosing ? ' is-closing' : ''}`}>
|
||||||
|
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
|
||||||
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
|
||||||
${/* Every slide is rendered, all stacked in one CSS grid cell, so the
|
${/* Every slide is rendered, all stacked in one CSS grid cell, so the
|
||||||
strip is always as tall as the LONGEST event's copy. Rotating to
|
strip is always as tall as the LONGEST event's copy. Rotating to
|
||||||
@@ -711,17 +927,21 @@ export function UTCIForecast() {
|
|||||||
<div class="event-note-stack">
|
<div class="event-note-stack">
|
||||||
${activeEvents.map((e, i) => {
|
${activeEvents.map((e, i) => {
|
||||||
const dateLine = dateLineFor(e);
|
const dateLine = dateLineFor(e);
|
||||||
const isActive = i === noteIndex && !noteFading;
|
const isActive = i === slideIndex && !noteFading;
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
key=${e.id || i}
|
key=${e.id || i}
|
||||||
class=${`event-note-slide${isActive ? ' is-active' : ''}`}
|
class=${`event-note-slide${isActive ? ' is-active' : ''}`}
|
||||||
aria-hidden=${isActive ? undefined : 'true'}
|
aria-hidden=${isActive ? undefined : 'true'}
|
||||||
>
|
>
|
||||||
<span class="event-note-emoji">${e.emoji}</span>
|
<div class="event-note-header">
|
||||||
<span class="event-note-title">${e.title}</span>
|
<span class="event-note-emoji">${e.emoji}</span>
|
||||||
<span class="event-note-msg">${e.message}</span>
|
<span class="event-note-title">${e.title}</span>
|
||||||
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
|
</div>
|
||||||
|
<div class="event-note-body">
|
||||||
|
<span class="event-note-msg">${e.message}</span>
|
||||||
|
${dateLine && html`<span class="event-note-dates">${dateLine}</span>`}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -731,59 +951,31 @@ export function UTCIForecast() {
|
|||||||
<button
|
<button
|
||||||
key=${i}
|
key=${i}
|
||||||
type="button"
|
type="button"
|
||||||
class=${`event-note-dot${i === noteIndex ? ' active' : ''}`}
|
class=${`event-note-dot${i === slideIndex ? ' active' : ''}`}
|
||||||
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
|
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
|
||||||
onClick=${() => noteGoTo(i)}
|
onClick=${() => noteGoTo(i)}
|
||||||
/>`)}
|
/>`)}
|
||||||
</div>`}
|
</div>`}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="event-note-close"
|
||||||
|
aria-label="Close event banner"
|
||||||
|
title="Close"
|
||||||
|
onClick=${noteClose}
|
||||||
|
>
|
||||||
|
<span class="event-note-close-label">Close</span>
|
||||||
|
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
|
||||||
|
<path d="M1.5 1.5 L10.5 10.5 M10.5 1.5 L1.5 10.5"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
})()}
|
})()}
|
||||||
<nav class="utci-topnav" id="site-nav">
|
|
||||||
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
|
|
||||||
<div class="nav-logo" aria-hidden="true">
|
|
||||||
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
|
||||||
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
|
||||||
</div>
|
|
||||||
<div class="nav-links" id="nav-drawer">
|
|
||||||
<a href="./index.html">Forecast</a>
|
|
||||||
<div class="nav-dropdown">
|
|
||||||
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
|
|
||||||
<div class="nav-dropdown-menu">
|
|
||||||
<a href="./about.html">Overview</a>
|
|
||||||
<a href="./sunsoak.html">SunSoak & the science</a>
|
|
||||||
<a href="./profiles.html">Forecast profiles</a>
|
|
||||||
<a href="./columns.html">Column reference</a>
|
|
||||||
<a href="./temperatures.html">Derived temperatures</a>
|
|
||||||
<a href="./features.html">On-screen features</a>
|
|
||||||
<a href="./stress-bands.html">Stress bands</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="./faq.html">FAQ</a>
|
|
||||||
<a href=${isPro
|
|
||||||
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
|
||||||
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
|
||||||
target="_blank" rel="noopener noreferrer">Account</a>
|
|
||||||
</div>
|
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
|
|
||||||
onClick=${() => {
|
|
||||||
const nav = document.getElementById('site-nav');
|
|
||||||
const drawer = document.getElementById('nav-drawer');
|
|
||||||
const btn = document.getElementById('burger-btn');
|
|
||||||
const open = nav.classList.toggle('nav-open');
|
|
||||||
drawer.classList.toggle('is-open', open);
|
|
||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
||||||
}}>
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="utci-shell">
|
<main class="utci-shell">
|
||||||
<div class="utci-header">
|
<div class="utci-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1 class="utci-title">
|
|
||||||
<span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
|
|
||||||
</h1>
|
|
||||||
<div class="utci-tagline">See the world the way your skin does.</div>
|
|
||||||
<div class="utci-loc-search" ref=${searchWrapRef}>
|
<div class="utci-loc-search" ref=${searchWrapRef}>
|
||||||
<div class="utci-current-loc">
|
<div class="utci-current-loc">
|
||||||
<button
|
<button
|
||||||
@@ -811,41 +1003,62 @@ export function UTCIForecast() {
|
|||||||
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div class="utci-loc-tools">
|
||||||
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
|
<div class="utci-loc-tool">
|
||||||
aria-label="Use my current location"
|
<button
|
||||||
title="Use my current location"
|
type="button"
|
||||||
disabled=${locating}
|
class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
|
||||||
onClick=${useMyLocation}
|
aria-label="Use my current location"
|
||||||
>
|
title="Use my current location"
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
disabled=${locating}
|
||||||
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
|
onClick=${useMyLocation}
|
||||||
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
|
>
|
||||||
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
|
||||||
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
</svg>
|
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
</button>
|
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
<button
|
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
type="button"
|
</svg>
|
||||||
class="utci-loc-action"
|
</button>
|
||||||
aria-label="Share this forecast"
|
<span class="utci-loc-tool-label">My spot</span>
|
||||||
title="Copy a link to this forecast"
|
</div>
|
||||||
onClick=${shareForecast}
|
<div class="utci-loc-tool">
|
||||||
>
|
<button
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
type="button"
|
||||||
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
class="utci-loc-action"
|
||||||
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
aria-label="Share this forecast"
|
||||||
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
title="Copy a link to this forecast"
|
||||||
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
|
onClick=${shareForecast}
|
||||||
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
|
>
|
||||||
</svg>
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
</button>
|
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
<span class="utci-loc-coords">
|
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
|
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
</span>
|
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
|
||||||
|
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="utci-loc-tool-label">Share</span>
|
||||||
|
</div>
|
||||||
|
<div class="utci-loc-tool">
|
||||||
|
<a
|
||||||
|
class="utci-loc-action"
|
||||||
|
href="./dashboard.html"
|
||||||
|
aria-label="Keep a weather journal"
|
||||||
|
title="Keep a weather journal — Dashboard"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||||
|
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
|
||||||
|
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
<line x1="8" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<span class="utci-loc-tool-label">Journal</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${(locateError || shareState) && html`
|
${(locateError || shareState) && html`
|
||||||
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
|
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
|
||||||
@@ -856,10 +1069,6 @@ export function UTCIForecast() {
|
|||||||
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
|
<button type="button" class="utci-loc-note-x" aria-label="Dismiss"
|
||||||
onClick=${() => setLocateError(null)}>×</button>`}
|
onClick=${() => setLocateError(null)}>×</button>`}
|
||||||
</div>`}
|
</div>`}
|
||||||
${fetchedAt && !loading && html`
|
|
||||||
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
|
|
||||||
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>`}
|
|
||||||
${(() => {
|
${(() => {
|
||||||
const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
|
const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
|
||||||
const recents = (recentLocations || [])
|
const recents = (recentLocations || [])
|
||||||
@@ -1032,6 +1241,10 @@ export function UTCIForecast() {
|
|||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
})()}
|
})()}
|
||||||
|
${fetchedAt && !loading && html`
|
||||||
|
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
|
||||||
|
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>`}
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1054,6 +1267,8 @@ export function UTCIForecast() {
|
|||||||
|
|
||||||
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
|
${welcomeOpen && html`<${WelcomeModal} onClose=${closeWelcome} />`}
|
||||||
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
|
${restoreOpen && html`<${RestoreModal} onClose=${closeRestore} setIsPro=${setIsPro} />`}
|
||||||
|
${weeklyNudgeOpen && !welcomeOpen && html`
|
||||||
|
<${WeeklyNudge} onClose=${closeWeeklyNudge} openRestore=${openRestore} />`}
|
||||||
|
|
||||||
${forecast && days.length > 0 && (() => {
|
${forecast && days.length > 0 && (() => {
|
||||||
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
|
const { mainLabel, mainConfigKey } = deriveProfileMain(activeProfile, outdoorsVariant);
|
||||||
@@ -1124,30 +1339,32 @@ export function UTCIForecast() {
|
|||||||
<div class="table-toolbar-row">
|
<div class="table-toolbar-row">
|
||||||
<span class="fvt-toolbar-left">
|
<span class="fvt-toolbar-left">
|
||||||
<span class="forecast-view-toggle-label">View:</span>
|
<span class="forecast-view-toggle-label">View:</span>
|
||||||
|
${/* One three-way control. Quick is the card layout; the other
|
||||||
|
two are the same hourly table on opposite axes — Detailed
|
||||||
|
runs hours along the top, Table runs them down the side.
|
||||||
|
Rotation used to be a separate toggle that only appeared
|
||||||
|
once you were already in the table, which hid the layout
|
||||||
|
people wanted behind a mode they had to find first. */''}
|
||||||
<span class="forecast-view-toggle">
|
<span class="forecast-view-toggle">
|
||||||
<button type="button" title="Quick view – visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')} onClick=${() => setForecastView('simple')}>
|
<button type="button" title="Quick view – visual card layout" class=${'fvt-btn' + (forecastView === 'simple' ? ' on' : '')}
|
||||||
|
aria-pressed=${forecastView === 'simple' ? 'true' : 'false'}
|
||||||
|
onClick=${() => setForecastView('simple')}>
|
||||||
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
|
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y=".5" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x=".5" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/><rect x="7" y="7" width="4.5" height="4.5" rx=".8" fill="currentColor"/></svg>
|
||||||
Quick
|
Quick
|
||||||
</button>
|
</button>
|
||||||
<button type="button" title="Detailed view – full hourly table" class=${'fvt-btn' + (forecastView === 'table' ? ' on' : '')} onClick=${() => setForecastView('table')}>
|
<button type="button" title="Detailed view – hours along the top, metrics down the side" class=${'fvt-btn' + (forecastView === 'table' && tableRotated ? ' on' : '')}
|
||||||
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg>
|
aria-pressed=${forecastView === 'table' && tableRotated ? 'true' : 'false'}
|
||||||
|
onClick=${() => { setForecastView('table'); setTableRotated(true); }}>
|
||||||
|
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><rect x=".5" y="1.5" width="3" height="10" rx=".6" fill="currentColor"/><rect x="4.5" y="1.5" width="2.5" height="10" rx=".5" fill="currentColor" opacity=".35"/><rect x="8" y="1.5" width="2.5" height="10" rx=".5" fill="currentColor" opacity=".25"/></svg>
|
||||||
Detailed
|
Detailed
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" title="Table view – hours down the side, metrics along the top" class=${'fvt-btn' + (forecastView === 'table' && !tableRotated ? ' on' : '')}
|
||||||
|
aria-pressed=${forecastView === 'table' && !tableRotated ? 'true' : 'false'}
|
||||||
|
onClick=${() => { setForecastView('table'); setTableRotated(false); }}>
|
||||||
|
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12"><rect x=".5" y="1.5" width="11" height="3" rx=".6" fill="currentColor"/><rect x=".5" y="5.5" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".35"/><rect x=".5" y="8.8" width="11" height="2.5" rx=".5" fill="currentColor" opacity=".25"/></svg>
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
${forecastView === 'table' && html`
|
|
||||||
<button type="button"
|
|
||||||
class=${'table-rotate-btn' + (tableRotated ? ' on' : '')}
|
|
||||||
title=${tableRotated ? 'Hours run along the top — click for hours down the side' : 'Hours run down the side — click for hours along the top'}
|
|
||||||
aria-pressed=${tableRotated ? 'true' : 'false'}
|
|
||||||
onClick=${toggleTableRotated}>
|
|
||||||
<svg class="fvt-btn-icon" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
|
|
||||||
<path d="M2.4 6.6a3.7 3.7 0 0 1 6-3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M9.6 5.4a3.7 3.7 0 0 1-6 3.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
|
||||||
<path d="M6.3 1.1l2.4 2.2-2.4 1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M5.7 10.9L3.3 8.7l2.4-1.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
Rotate
|
|
||||||
</button>`}
|
|
||||||
<button
|
<button
|
||||||
class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
|
class=${'col-toggles-edit-btn col-toggles-edit-btn--toolbar' + (colTogglesOpen ? ' col-toggles-edit-btn--open' : '') + (isPro ? '' : ' col-toggles-edit-btn--locked')}
|
||||||
title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
|
title=${isPro ? '' : 'Custom columns are part of SunScope Extra'}
|
||||||
@@ -1196,27 +1413,24 @@ export function UTCIForecast() {
|
|||||||
<div class="col-toggles-body">
|
<div class="col-toggles-body">
|
||||||
${isPro && html`<${Fragment}>
|
${isPro && html`<${Fragment}>
|
||||||
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
|
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
|
||||||
|
${colOffered('shadeT') && html`<button class=${`col-toggle grp-felt${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
|
||||||
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
|
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
|
||||||
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
|
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
|
||||||
${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-surface${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur</button>`}
|
|
||||||
${colOffered('pawT') && html`<button class=${`col-toggle grp-surface${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
|
|
||||||
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
|
|
||||||
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
|
|
||||||
${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
|
|
||||||
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
|
||||||
${colOffered('delta') && html`<button class=${`col-toggle grp-felt${visibleCols.delta ? ' on' : ''}`} onClick=${() => toggleCol('delta')}>Δ</button>`}
|
${colOffered('burn') && html`<button class=${`col-toggle grp-felt${visibleCols.burn ? ' on' : ''}`} onClick=${() => toggleCol('burn')}>Burn</button>`}
|
||||||
${colOffered('tmrt') && html`<button class=${`col-toggle grp-felt${visibleCols.tmrt ? ' on' : ''}`} onClick=${() => toggleCol('tmrt')}>Tmrt</button>`}
|
|
||||||
${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
${colOffered('concreteT') && html`<button class=${`col-toggle grp-surface${visibleCols.concreteT ? ' on' : ''}`} onClick=${() => toggleCol('concreteT')}>Concrete</button>`}
|
||||||
${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
${colOffered('soilT') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT ? ' on' : ''}`} onClick=${() => toggleCol('soilT')}>Soil °C</button>`}
|
||||||
${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
${colOffered('soilT6') && html`<button class=${`col-toggle grp-surface${visibleCols.soilT6 ? ' on' : ''}`} onClick=${() => toggleCol('soilT6')}>Soil 6cm</button>`}
|
||||||
${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
${colOffered('soilM') && html`<button class=${`col-toggle grp-surface${visibleCols.soilM ? ' on' : ''}`} onClick=${() => toggleCol('soilM')}>Soil moist</button>`}
|
||||||
${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
|
${colOffered('furSurfaceT') && html`<button class=${`col-toggle grp-pets${visibleCols.furSurfaceT ? ' on' : ''}`} onClick=${() => toggleCol('furSurfaceT')}>Fur</button>`}
|
||||||
|
${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
|
||||||
|
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
|
||||||
|
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
|
||||||
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
|
||||||
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
|
||||||
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
|
||||||
${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
${colOffered('precip') && html`<button class=${`col-toggle grp-precip${visibleCols.precip ? ' on' : ''}`} onClick=${() => toggleCol('precip')}>Precip</button>`}
|
||||||
${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
|
${colOffered('precipProb') && html`<button class=${`col-toggle grp-precip${visibleCols.precipProb ? ' on' : ''}`} onClick=${() => toggleCol('precipProb')}>Rain%</button>`}
|
||||||
${colOffered('lightning') && html`<button class=${`col-toggle grp-precip${visibleCols.lightning ? ' on' : ''}`} onClick=${() => toggleCol('lightning')}>Lightning</button>`}
|
|
||||||
${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
${colOffered('cloud') && html`<button class=${`col-toggle grp-sky${visibleCols.cloud ? ' on' : ''}`} onClick=${() => toggleCol('cloud')}>Cloud</button>`}
|
||||||
${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
|
${colOffered('vis') && html`<button class=${`col-toggle grp-sky${visibleCols.vis ? ' on' : ''}`} onClick=${() => toggleCol('vis')}>Visibility</button>`}
|
||||||
${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
${colOffered('wind') && html`<button class=${`col-toggle grp-wind${visibleCols.wind ? ' on' : ''}`} onClick=${() => toggleCol('wind')}>Wind</button>`}
|
||||||
@@ -1244,7 +1458,11 @@ export function UTCIForecast() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="forecast-simple-wrap">
|
<div class=${'forecast-simple-wrap'
|
||||||
|
+ (fscCanScrollLeft ? ' scroll-fade-left' : '')
|
||||||
|
+ (fscCanScrollRight ? ' scroll-fade-right' : '')}>
|
||||||
|
<span class="fsc-scroll-chevron left" aria-hidden="true">‹</span>
|
||||||
|
<span class="fsc-scroll-chevron right" aria-hidden="true">›</span>
|
||||||
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
<div class="forecast-simple-scroll" ref=${fscScrollRef}>
|
||||||
<div class="forecast-simple-inner">
|
<div class="forecast-simple-inner">
|
||||||
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
|
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
|
||||||
@@ -1255,8 +1473,34 @@ export function UTCIForecast() {
|
|||||||
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
|
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
|
||||||
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
|
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
|
||||||
const domIcon = (() => {
|
const domIcon = (() => {
|
||||||
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0))
|
// Two rain numbers, reconciled the same way the day tabs do it
|
||||||
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`;
|
// (see DayTabs). precipProb answers WHETHER it rains, precip
|
||||||
|
// answers HOW MUCH — and they come from different model runs,
|
||||||
|
// so a 97%-certain hour can still carry 0.0mm. Gating the icon
|
||||||
|
// on the amount alone left those hours wearing a cloud while
|
||||||
|
// the card underneath read 97%. So: probability decides IF the
|
||||||
|
// icon shows rain, and the two together decide how hard it looks.
|
||||||
|
// • >= 50% — rain icon, whatever the amount says
|
||||||
|
// • > 20% with mm/cm — rain icon (a light but real shower)
|
||||||
|
// • otherwise — cloud
|
||||||
|
const pProb = r.precipProb ?? 0;
|
||||||
|
const wet = r.precip > 0 || r.snow > 0;
|
||||||
|
if (pProb >= 50 || (pProb > 20 && wet)) {
|
||||||
|
const c01 = v => Math.max(0, Math.min(1, v));
|
||||||
|
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
|
||||||
|
const probT = c01((pProb - 50) / 50);
|
||||||
|
// Amount half: "heavy" taken as 2mm in a single hour.
|
||||||
|
const amountT = c01((r.precip || 0) / 2);
|
||||||
|
// Certainty alone tops out at medium-high; only a genuinely
|
||||||
|
// wet hour reaches the 3-drop downpour.
|
||||||
|
const score = probT * 0.55 + amountT * 0.45;
|
||||||
|
const drops = score < 0.33 ? 1 : score < 0.70 ? 2 : 3;
|
||||||
|
// PrecipIcon draws its dry dash when precip and snow are both
|
||||||
|
// 0, so a certain-but-zero-amount hour needs a nominal trace
|
||||||
|
// to draw drops at all. drops= still sets the real intensity.
|
||||||
|
const iconPrecip = r.snow > 0 ? (r.precip || 0) : (r.precip > 0 ? r.precip : 0.01);
|
||||||
|
return html`<${PrecipIcon} precip=${iconPrecip} snow=${r.snow} drops=${drops} size=${42} filled=${true} />`;
|
||||||
|
}
|
||||||
if (r.cloudCat && r.cloudCat !== 'clear')
|
if (r.cloudCat && r.cloudCat !== 'clear')
|
||||||
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
|
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
|
||||||
return null;
|
return null;
|
||||||
@@ -1293,8 +1537,7 @@ export function UTCIForecast() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="fsc-meta">
|
<div class="fsc-meta">
|
||||||
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
|
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
|
||||||
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph</span>
|
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph${r.compass ? html` <span class="fsc-meta-dir">${r.compass.label}</span>` : ''}</span>
|
||||||
<span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="fsc-temp">${Math.round(dispTemp)}°</div>
|
<div class="fsc-temp">${Math.round(dispTemp)}°</div>
|
||||||
<div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
|
<div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
|
||||||
@@ -1305,20 +1548,25 @@ export function UTCIForecast() {
|
|||||||
${(() => {
|
${(() => {
|
||||||
if (!fscPlot) return null;
|
if (!fscPlot) return null;
|
||||||
const fscSvgW = 1000, fscSvgH = 80;
|
const fscSvgW = 1000, fscSvgH = 80;
|
||||||
const fscSrc = fscPlot.hourly;
|
// The curve plots the SAME rows the cards do. It used to plot the
|
||||||
|
// raw hourly series instead, which agrees with the cards at 1h and
|
||||||
|
// diverges at every longer interval: a card shows its bucket's
|
||||||
|
// aggregate, so an "8am 24°" card (the 8–11am mean) sat above a
|
||||||
|
// curve drawn at 8am's own 14.5°, and its connector pointed at a
|
||||||
|
// height the card never claimed. One series, one number.
|
||||||
|
const fscSrc = tableRows;
|
||||||
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
// Fixed scale: bottom = Freezing band bottom (−10) − 10, top = Danger start (44) + 10
|
||||||
const fscMin = -15, fscMax = 45;
|
const fscMin = -15, fscMax = 45;
|
||||||
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
const toY = t => fscSvgH - ((t - fscMin) / (fscMax - fscMin)) * fscSvgH;
|
||||||
const getT = r => r[simpleTemp] ?? r.utciAdj;
|
const getT = r => r[simpleTemp] ?? r.utciAdj;
|
||||||
// Same hour->x mapping the cards grid uses (see fscPlot above), so a
|
// x of card i — the landing hour it is centred on, via the same
|
||||||
// card's centre and its hour's data point are the same x by
|
// mapping that placed the card in the grid, so a point and its
|
||||||
// construction — every hour of the day stays on screen.
|
// card share an x by construction.
|
||||||
const fscHourX = j => fscPlot.hourAt(j) * fscSvgW;
|
const fscCardX = i => fscPlot.hourAt(fscPlot.landing[i]) * fscSvgW;
|
||||||
const fscStopPct = j => (fscPlot.hourAt(j) * 100).toFixed(1);
|
const fscStopPct = i => (fscPlot.hourAt(fscPlot.landing[i]) * 100).toFixed(1);
|
||||||
const fscAllPts = fscSrc.map((r, j) => ({ x: fscHourX(j), y: toY(getT(r)) }));
|
const fscAllPts = fscSrc.map((r, i) => ({ x: fscCardX(i), y: toY(getT(r)) }));
|
||||||
// Connector points — one per card, planted on its landing hour's data
|
// One point per card now, so the connectors ARE the data points.
|
||||||
// point, which is exactly where that card is centred.
|
const fscPts = fscAllPts;
|
||||||
const fscPts = fscPlot.landing.map(j => fscAllPts[Math.min(j, fscAllPts.length - 1)]);
|
|
||||||
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
const fscNowFlags = tableRows.map(r => r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO);
|
||||||
// Flat runs from each edge into the first/last hour, so the fill still
|
// Flat runs from each edge into the first/last hour, so the fill still
|
||||||
// covers the inset spacer strips at both ends.
|
// covers the inset spacer strips at both ends.
|
||||||
@@ -1444,31 +1692,44 @@ export function UTCIForecast() {
|
|||||||
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
<span class="utci-scroll-chevron left" aria-hidden="true">‹</span>
|
||||||
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
<span class="utci-scroll-chevron right" aria-hidden="true">›</span>
|
||||||
|
|
||||||
<div class="utci-tbody-scroll" ref=${bodyScrollRef}>
|
${/* Head split out of the body table, exactly as the normal
|
||||||
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
|
orientation does it: the hour row has to pin to the
|
||||||
<thead>
|
viewport as the page scrolls, and a <thead> inside the
|
||||||
<tr>
|
horizontal scroller can only stick to that scroller.
|
||||||
<th class="rot-corner col-info-th" scope="col"
|
useTableScroll keeps the two tables' column widths and
|
||||||
onClick=${(e) => handleThClick('hour', e)}
|
horizontal offset in step. */''}
|
||||||
onMouseEnter=${(e) => handleThEnter('hour', e)}
|
<div class="utci-thead-sticky" ref=${headStickyRef}>
|
||||||
onMouseLeave=${handleThLeave}>
|
<div class="utci-thead-track" ref=${headTrackRef}>
|
||||||
<span class="rot-corner-date">${glanceDate}</span>
|
<table class="utci-table utci-table-rotated utci-table-head" ref=${headTableRef}>
|
||||||
<span class="rot-corner-controls">
|
<thead>
|
||||||
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
<tr>
|
||||||
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
<th class="rot-corner col-info-th" scope="col"
|
||||||
|
onClick=${(e) => handleThClick('hour', e)}
|
||||||
|
onMouseEnter=${(e) => handleThEnter('hour', e)}
|
||||||
|
onMouseLeave=${handleThLeave}>
|
||||||
|
<span class="rot-corner-date">${glanceDateShort}</span>
|
||||||
|
<span class="rot-corner-controls">
|
||||||
|
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
|
||||||
|
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</th>
|
||||||
</th>
|
${tableRows.map(r => {
|
||||||
${tableRows.map(r => {
|
const f = hourFlags(r);
|
||||||
const f = hourFlags(r);
|
return html`
|
||||||
return html`
|
<th key=${r.iso} scope="col"
|
||||||
<th key=${r.iso} scope="col"
|
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
|
||||||
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
|
${timeCell(r)}
|
||||||
${timeCell(r)}
|
</th>`;
|
||||||
</th>`;
|
})}
|
||||||
})}
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
|
||||||
|
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
|
||||||
<tbody>
|
<tbody>
|
||||||
${visibleColumnDefs.map((d, i) => {
|
${visibleColumnDefs.map((d, i) => {
|
||||||
// A group heading row is emitted whenever the
|
// A group heading row is emitted whenever the
|
||||||
@@ -1613,13 +1874,13 @@ export function UTCIForecast() {
|
|||||||
<div class="insight-panel insight-panel--week">
|
<div class="insight-panel insight-panel--week">
|
||||||
<div class="insight-panel-title">The Week Ahead</div>
|
<div class="insight-panel-title">The Week Ahead</div>
|
||||||
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
|
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
|
||||||
${weekAhead.map(({ icon, label, value, score }) => html`
|
${weekAhead.map(({ icon, iconTitle, label, value, score }) => html`
|
||||||
<div class="insight-row" key=${label}>
|
<div class=${`insight-row${score == null ? ' insight-row--week-note' : ''}`} key=${label}>
|
||||||
<span class="insight-icon">${icon}</span>
|
<span class="insight-icon" title=${iconTitle}>${icon}</span>
|
||||||
<span class="insight-label">${titleCaseText(label)}</span>
|
<span class="insight-label">${titleCaseText(label)}</span>
|
||||||
<span class="insight-value">${titleCaseText(value)}</span>
|
<span class="insight-value">${titleCaseText(value)}</span>
|
||||||
${score != null && html`
|
${score != null && html`
|
||||||
<span class="week-score" title=${`${score}% of an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher`}>
|
<span class="week-score" title=${bestDaysHint(activeProfile, outdoorsVariant, score)}>
|
||||||
<span class="week-score__track">
|
<span class="week-score__track">
|
||||||
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
|
<span class="week-score__fill" style=${`width:${score}%;background:${scoreFillColor(score)}`}></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -1711,7 +1972,9 @@ export function UTCIForecast() {
|
|||||||
<span class="utci-legend-label">Thermal stress bands</span>
|
<span class="utci-legend-label">Thermal stress bands</span>
|
||||||
<div class="utci-legend-row">
|
<div class="utci-legend-row">
|
||||||
${[
|
${[
|
||||||
{ t: -9, label: 'Freezing', value: '< 0°C' },
|
{ t: -25, label: 'Extreme cold', value: '< -20°C' },
|
||||||
|
{ t: -15, label: 'Arctic', value: '-20–-10°C' },
|
||||||
|
{ t: -5, label: 'Freezing', value: '-10–0°C' },
|
||||||
{ t: 1, label: 'Cold', value: '0–10°C' },
|
{ t: 1, label: 'Cold', value: '0–10°C' },
|
||||||
{ t: 11, label: 'Cool', value: '10–19°C' },
|
{ t: 11, label: 'Cool', value: '10–19°C' },
|
||||||
{ t: 21, label: 'Comfortable', value: '19–24°C', bold: true },
|
{ t: 21, label: 'Comfortable', value: '19–24°C', bold: true },
|
||||||
@@ -1738,7 +2001,9 @@ export function UTCIForecast() {
|
|||||||
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
|
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
|
||||||
<div class="utci-legend-row">
|
<div class="utci-legend-row">
|
||||||
${[
|
${[
|
||||||
{ t: -15, label: 'Freezing', value: '< -8°C' },
|
{ t: -35, label: 'Extreme cold', value: '< -28°C' },
|
||||||
|
{ t: -23, label: 'Arctic', value: '-28–-18°C' },
|
||||||
|
{ t: -13, label: 'Freezing', value: '-18–-8°C' },
|
||||||
{ t: 0, label: 'Cold', value: '-8–2°C' },
|
{ t: 0, label: 'Cold', value: '-8–2°C' },
|
||||||
{ t: 9, label: 'Cool', value: '2–11°C' },
|
{ t: 9, label: 'Cool', value: '2–11°C' },
|
||||||
{ t: 18, label: 'Comfortable', value: '11–25°C', bold: true },
|
{ t: 18, label: 'Comfortable', value: '11–25°C', bold: true },
|
||||||
@@ -1872,6 +2137,7 @@ export function UTCIForecast() {
|
|||||||
· <a href="./index.html">Forecast</a>
|
· <a href="./index.html">Forecast</a>
|
||||||
· <a href="./about.html">About</a>
|
· <a href="./about.html">About</a>
|
||||||
· <a href="./faq.html">FAQ</a>
|
· <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a>
|
||||||
· <a href=${isPro
|
· <a href=${isPro
|
||||||
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||||||
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
|
||||||
|
|||||||
+40
-3
@@ -1281,7 +1281,12 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
|
|||||||
// heavy snow (- 0.5cm/h) - 2 flakes
|
// heavy snow (- 0.5cm/h) - 2 flakes
|
||||||
// mixed - 1 drop + 1 flake
|
// mixed - 1 drop + 1 flake
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false }) {
|
// drops - optional override for the rain-drop count (1-3). The day tabs
|
||||||
|
// pass this because their intensity is a blend of amount AND
|
||||||
|
// forecast probability, not amount alone (see the "two rain
|
||||||
|
// numbers" block in components/DayTabs.js). Callers that only have
|
||||||
|
// an mm/h figure omit it and get the amount-derived bands above.
|
||||||
|
export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false, drops = null }) {
|
||||||
const r = size / 2;
|
const r = size / 2;
|
||||||
const hasRain = precip > 0;
|
const hasRain = precip > 0;
|
||||||
const hasSnow = snow > 0;
|
const hasSnow = snow > 0;
|
||||||
@@ -1321,7 +1326,9 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rain intensity - number of drops
|
// Rain intensity - number of drops
|
||||||
const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3;
|
const rainDrops = !hasRain ? 0
|
||||||
|
: drops != null ? Math.max(1, Math.min(3, drops))
|
||||||
|
: precip < 1 ? 1 : precip < 4 ? 2 : 3;
|
||||||
// Snow intensity - number of flakes
|
// Snow intensity - number of flakes
|
||||||
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
|
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
|
||||||
const fr = size * 0.08; // flake arm length
|
const fr = size * 0.08; // flake arm length
|
||||||
@@ -1346,7 +1353,7 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
|
|||||||
: hasRain
|
: hasRain
|
||||||
? Array.from({ length: rainDrops }, (_, i) => html`
|
? Array.from({ length: rainDrops }, (_, i) => html`
|
||||||
<${Fragment} key=${i}>
|
<${Fragment} key=${i}>
|
||||||
${rainStroke(spacing * (i + 1), size * 0.53, precip >= 4)}
|
${rainStroke(spacing * (i + 1), size * 0.53, rainDrops >= 3)}
|
||||||
</>`)
|
</>`)
|
||||||
: Array.from({ length: snowFlakes }, (_, i) =>
|
: Array.from({ length: snowFlakes }, (_, i) =>
|
||||||
flake(spacing * (i + 1), size * 0.66, fr)
|
flake(spacing * (i + 1), size * 0.66, fr)
|
||||||
@@ -1425,6 +1432,36 @@ export function FogIcon({ size = 30 }) {
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WINDICON - Brass Line gust glyph for the day-tab wind badge. Body paths are
|
||||||
|
// Lucide's "wind" icon (ISC licence) on its native 24x24 grid, same approach
|
||||||
|
// as CarIcon. Gale-force and above (tier >= 3) swap the brass/ink pairing for
|
||||||
|
// a colder storm blue so the step up from "blustery" is visible at a glance.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// halo - draws each gust line twice, a wider white stroke underneath the
|
||||||
|
// coloured one. Needed when the glyph is overlaid on the cloud/rain
|
||||||
|
// icon: without it the two line drawings tangle into one another and
|
||||||
|
// neither reads. Costs nothing when drawn on a flat tab background.
|
||||||
|
export function WindIcon({ size = 30, tier = 2, halo = false }) {
|
||||||
|
const severe = tier >= 3;
|
||||||
|
const lead = severe ? '#2f5d82' : '#c8922a';
|
||||||
|
const trail = severe ? '#3a6a92' : '#2a1a08';
|
||||||
|
const gusts = [
|
||||||
|
{ d: 'M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2', stroke: lead },
|
||||||
|
{ d: 'M9.6 4.6A2 2 0 1 1 11 8H2', stroke: trail },
|
||||||
|
{ d: 'M12.6 19.4A2 2 0 1 0 14 16H2', stroke: trail },
|
||||||
|
];
|
||||||
|
return html`
|
||||||
|
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
|
||||||
|
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
|
||||||
|
<g stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
${halo && gusts.map((p, i) => html`
|
||||||
|
<path key=${'h' + i} d=${p.d} stroke="#fdf6e8" stroke-width="4.4" opacity="0.92" />`)}
|
||||||
|
${gusts.map((p, i) => html`
|
||||||
|
<path key=${i} d=${p.d} stroke=${p.stroke} stroke-width="1.9" />`)}
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function IceIcon({ size = 30 }) {
|
export function IceIcon({ size = 30 }) {
|
||||||
const icy = '#3f73c4';
|
const icy = '#3f73c4';
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function ConfigPanel({
|
|||||||
{ key: 'fur', show: showFur, node: html`
|
{ key: 'fur', show: showFur, node: html`
|
||||||
<span class="config-item-label">Fur Colour</span>
|
<span class="config-item-label">Fur Colour</span>
|
||||||
<${CustomSelect}
|
<${CustomSelect}
|
||||||
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-surface"
|
value=${furColor} isOn=${true} noHide=${true} grpClass="grp-pets"
|
||||||
hideLabel="Fur Colour"
|
hideLabel="Fur Colour"
|
||||||
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
|
options=${Object.entries(FUR_COLORS).map(([k, v]) => ({ value: k, label: v.name }))}
|
||||||
onChange=${(v) => setFurColor(v)}
|
onChange=${(v) => setFurColor(v)}
|
||||||
|
|||||||
+590
-62
@@ -32,10 +32,11 @@
|
|||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
import { h, Fragment } from '../../vendor/preact.js';
|
import { h, Fragment } from '../../vendor/preact.js';
|
||||||
|
import { useEffect, useRef } from '../../vendor/preact-hooks.js';
|
||||||
import htm from '../../vendor/htm.js';
|
import htm from '../../vendor/htm.js';
|
||||||
import { confidenceBand, utciCategory, petCategory, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
|
import { confidenceBand, utciCategory, petCategory, DAY_TAB_UTCI_BANDS, DAY_TAB_PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
|
||||||
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
|
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
|
||||||
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js';
|
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon, WindIcon } from '../components.js';
|
||||||
import { SubscribeModal } from './SubscribeModal.js';
|
import { SubscribeModal } from './SubscribeModal.js';
|
||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
@@ -102,6 +103,94 @@ function weatherGradientNeutral(r, g, b, edgeBlend = 0.50, centerBlend = null) {
|
|||||||
return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`;
|
return `radial-gradient(circle at 50% 50%, ${center} 0%, ${center} 28%, ${edge} 100%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Colour-driving "sustained high": the day's high with brief spikes ignored, so a
|
||||||
|
// single hot hour on an otherwise cool, damp day doesn't paint the whole tab warm.
|
||||||
|
// Drops the top ~10% of hours (always at least the single hottest) and takes the next
|
||||||
|
// value down — a temperature that actually persisted. The PRINTED hi number and the
|
||||||
|
// Danger alarm still key off the true peak, not this.
|
||||||
|
function sustainedHigh(vals) {
|
||||||
|
if (!vals.length) return null;
|
||||||
|
const sorted = [...vals].sort((a, b) => b - a);
|
||||||
|
const drop = Math.max(1, Math.round(sorted.length * 0.1));
|
||||||
|
return sorted[Math.min(drop, sorted.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lays a sky tint (rain blue, snow blue, cloud grey) over a temperature colour
|
||||||
|
// without the blend passing through green on the way.
|
||||||
|
//
|
||||||
|
// A straight mixRgb() from the Warm band's yellow to a rain blue travels
|
||||||
|
// through olive and then green — which is why the tabs used to pick ONE
|
||||||
|
// dimension by priority instead of blending. Desaturating the base toward its
|
||||||
|
// own luminance first routes yellow → khaki → grey → blue instead, so the
|
||||||
|
// temperature can genuinely shade toward the weather without inventing a hue
|
||||||
|
// that belongs to neither.
|
||||||
|
// base - [r,g,b] temperature colour from the band ramp
|
||||||
|
// target - [r,g,b] weather tint (rainTint / snowTint)
|
||||||
|
// w - 0..1 how strongly the weather pulls
|
||||||
|
function overlayTint(base, target, w) {
|
||||||
|
if (!(w > 0)) return base;
|
||||||
|
// The pre-desaturation exists ONLY to keep a hue-to-hue blend off the green
|
||||||
|
// diagonal — yellow travelling to rain-blue would otherwise pass through
|
||||||
|
// olive. A target with no hue of its own has no such diagonal to avoid, and
|
||||||
|
// mixing toward a grey already desaturates by definition, so applying the
|
||||||
|
// full guard to the cloud tint stripped the base colour twice over. Scale it
|
||||||
|
// by how much hue the target actually carries: rain (a saturated blue) keeps
|
||||||
|
// essentially all of it, cloud (a near-neutral grey) almost none.
|
||||||
|
const chroma = (Math.max(...target) - Math.min(...target)) / 255;
|
||||||
|
const hueGuard = Math.min(1, chroma * 2.2);
|
||||||
|
const lum = 0.299 * base[0] + 0.587 * base[1] + 0.114 * base[2];
|
||||||
|
const desat = mixRgb(base, [lum, lum, lum], Math.min(1, w * 0.9 * hueGuard));
|
||||||
|
return mixRgb(desat, target, w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cloud is applied differently from rain, and deliberately so.
|
||||||
|
//
|
||||||
|
// Rain has a hue of its own — a day IS blue-grey with rain — so it earns a
|
||||||
|
// blend toward that colour. Cloud does not: an overcast 20° day is still a 20°
|
||||||
|
// day, just duller. Blending toward a grey HUE meant every warm overcast tab
|
||||||
|
// had to travel from gold to grey, and the route passes through khaki: at a
|
||||||
|
// middling weight #edd449 lands on #b4a85f, the muddy olive that reads as
|
||||||
|
// neither temperature nor weather. Pushing the weight high enough to clear the
|
||||||
|
// mud (0.80) instead erased the temperature entirely, which is how every day
|
||||||
|
// from +4° to +23° ended up the same grey.
|
||||||
|
//
|
||||||
|
// There is no weight that avoids both, because the problem is the path, not
|
||||||
|
// the distance. So cloud no longer moves the hue at all: it desaturates the
|
||||||
|
// temperature colour toward its OWN luminance and dims it slightly. Same hue,
|
||||||
|
// less vivid, a little darker — which is what overcast actually looks like —
|
||||||
|
// and because it never crosses between two hues there is no muddy middle at
|
||||||
|
// any weight.
|
||||||
|
function cloudDim(rgb, w) {
|
||||||
|
if (!(w > 0)) return rgb;
|
||||||
|
// Desaturate toward a neutral that is never DARKER than the colour it came
|
||||||
|
// from, and lift it slightly. This matters more than it looks: a darkened
|
||||||
|
// desaturated yellow is olive — that is simply what olive is — so the
|
||||||
|
// earlier version, which dulled toward the colour's own luminance and then
|
||||||
|
// dimmed 16%, walked the gold Comfortable band straight down into khaki
|
||||||
|
// (#edd449 -> #cabc75 -> #b3ad8c) and put green back on the strip by a
|
||||||
|
// different route than the palette had.
|
||||||
|
//
|
||||||
|
// Overcast is diffuse and flat, not dark, so lifting rather than dimming is
|
||||||
|
// also the truer look: gold travels gold -> cream -> pale sand
|
||||||
|
// (#e3d799 -> #e2dbb4), never through olive, and the blues at the cold end
|
||||||
|
// pale out the same way.
|
||||||
|
const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
||||||
|
const neutral = Math.min(255, lum + 14);
|
||||||
|
return mixRgb(rgb, [neutral, neutral, neutral], w);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather weights are pushed through this before they are used.
|
||||||
|
//
|
||||||
|
// A linear weight means a day with middling rain sits at a 50/50 mix of
|
||||||
|
// temperature colour and rain blue — which is a muddy olive that reads as
|
||||||
|
// neither, and the whole point of the tab shade is that someone can tell what
|
||||||
|
// the day is at a glance. Smoothstep drags low weights lower and high weights
|
||||||
|
// higher, so the colour spends as little time as possible in that ambiguous
|
||||||
|
// middle: a bit of weather barely disturbs the temperature colour, and weather
|
||||||
|
// that genuinely IS the day takes the tab almost completely.
|
||||||
|
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
||||||
|
const decisive = (t) => { const c = clamp01(t); return c * c * (3 - 2 * c); };
|
||||||
|
|
||||||
function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) {
|
function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) {
|
||||||
const blend = (c, amt) => Math.round(c + (255 - c) * amt);
|
const blend = (c, amt) => Math.round(c + (255 - c) * amt);
|
||||||
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
|
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
|
||||||
@@ -155,7 +244,7 @@ export function DayTabs({
|
|||||||
// those controls rather than a separate, unrelated style.
|
// those controls rather than a separate, unrelated style.
|
||||||
const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind'
|
const rowGrpClass = mainConfigKey === 'vehicle' ? 'grp-wind'
|
||||||
: mainConfigKey === 'indoor' ? 'grp-ambient'
|
: mainConfigKey === 'indoor' ? 'grp-ambient'
|
||||||
: mainConfigKey === 'fur' ? 'grp-surface'
|
: mainConfigKey === 'fur' ? 'grp-pets'
|
||||||
: 'grp-felt';
|
: 'grp-felt';
|
||||||
|
|
||||||
// Within that group, shade further by the exact option chosen — same idea
|
// Within that group, shade further by the exact option chosen — same idea
|
||||||
@@ -167,10 +256,34 @@ export function DayTabs({
|
|||||||
: utciEnv;
|
: utciEnv;
|
||||||
const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
|
const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
|
||||||
|
|
||||||
|
// The row pins to the top of the viewport on wide screens, and the table's
|
||||||
|
// sticky hour header has to stop just below it. Publish the measured height
|
||||||
|
// (plus its 4px margin) as --profile-row-h rather than hard-coding one in
|
||||||
|
// table.css: the row is one line or two depending on how long the profile
|
||||||
|
// and thermal-basis labels are, so a fixed offset leaves the header tucked
|
||||||
|
// under it.
|
||||||
|
const profileRowRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = profileRowRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const publish = () => {
|
||||||
|
const h = Math.round(el.getBoundingClientRect().height) + 4;
|
||||||
|
document.documentElement.style.setProperty('--profile-row-h', `${h}px`);
|
||||||
|
};
|
||||||
|
publish();
|
||||||
|
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(publish) : null;
|
||||||
|
if (ro) ro.observe(el);
|
||||||
|
window.addEventListener('resize', publish);
|
||||||
|
return () => {
|
||||||
|
if (ro) ro.disconnect();
|
||||||
|
window.removeEventListener('resize', publish);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Fragment}>
|
<${Fragment}>
|
||||||
|
|
||||||
<div class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
|
<div ref=${profileRowRef} class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
|
||||||
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
|
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
|
||||||
style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
|
style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
|
||||||
title="Change profile & settings">
|
title="Change profile & settings">
|
||||||
@@ -232,6 +345,83 @@ export function DayTabs({
|
|||||||
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
|
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
|
||||||
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
|
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
|
||||||
|
|
||||||
|
// Day-tab weather icon - use core daylight rows (elev > 10°) where
|
||||||
|
// available, falling back to any above-horizon rows, then all rows.
|
||||||
|
const coreRows = d.rows.filter(r => r.elev > 10);
|
||||||
|
const aboveRows = d.rows.filter(r => r.elev > 0);
|
||||||
|
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows;
|
||||||
|
|
||||||
|
// The tab COLOUR is keyed to a spike-resistant "sustained high" rather than
|
||||||
|
// the raw max, so a single warm hour on a cool/damp day doesn't paint the
|
||||||
|
// whole tab warm. The Danger tier is the exception — it keeps alarming on
|
||||||
|
// the true peak — and colourHi collapses back to dayHi there, so a real
|
||||||
|
// Danger day still gets its full colouring. The printed hi/lo numbers below
|
||||||
|
// always show the true dayHi/dayLo.
|
||||||
|
const catFn = isPetsProfile ? petCategory : utciCategory;
|
||||||
|
const isDanger = dayHi !== null && catFn(dayHi).solid === true;
|
||||||
|
// Spike-filter the DAYLIGHT hours, not the whole 24. Run over every
|
||||||
|
// row, sustainedHigh() drops the top ~10% of a 24-hour list — two or
|
||||||
|
// three hours — and on a summer day with a big diurnal swing that is
|
||||||
|
// the entire afternoon peak, not a spike. A 28° day was colouring
|
||||||
|
// from ~25°, a whole band down from the number printed on the tab.
|
||||||
|
// repRows is already what the icon, cloud and rain read, so the hue
|
||||||
|
// now comes from the same slice of the day as everything else.
|
||||||
|
const colourVals = repRows.map(r => r[mainField]).filter(v => isFinite(v));
|
||||||
|
|
||||||
|
// On a cold day the sustained HIGH is the wrong representative.
|
||||||
|
// Heat is a PEAK risk — the hottest hours are the dangerous ones, so
|
||||||
|
// colouring from the high is right at the top of the scale. Cold is
|
||||||
|
// an EXPOSURE risk: what matters is how cold it stayed all day, not
|
||||||
|
// the one mild moment. Invercargill on 31 Aug peaked at -4.8° at 6pm
|
||||||
|
// against daylight hours running -6.5° to -18.6°, so colouring from
|
||||||
|
// the peak put a pale Freezing tab on a genuinely Arctic day.
|
||||||
|
//
|
||||||
|
// Below 10° the colour therefore crossfades from the sustained high
|
||||||
|
// toward the daylight MEAN, arriving fully at the mean by 6°. It is
|
||||||
|
// crossfaded rather than switched so two adjacent days either side of
|
||||||
|
// the threshold can't jump a band against each other in the strip.
|
||||||
|
// The printed hi/lo numbers are untouched — they stay the true peak
|
||||||
|
// and trough, as they already do for the spike filter.
|
||||||
|
const colourAvg = colourVals.length
|
||||||
|
? colourVals.reduce((s, v) => s + v, 0) / colourVals.length
|
||||||
|
: null;
|
||||||
|
const sustHi = sustainedHigh(colourVals.length ? colourVals : mainVals);
|
||||||
|
const coldWeight = colourAvg === null ? 0 : clamp01((10 - colourAvg) / 4);
|
||||||
|
const colourHi = dayHi === null ? null
|
||||||
|
: isDanger ? dayHi
|
||||||
|
: Math.round(sustHi + (colourAvg - sustHi) * coldWeight);
|
||||||
|
|
||||||
|
// ---- Which hours the tab's COLOUR should read the sky from -------
|
||||||
|
// The tint used to average cloud and total rain across every daylight
|
||||||
|
// hour, which silently assumes the day is all one weather. Split days
|
||||||
|
// break that badly: a heavy wet morning followed by a hot, clear
|
||||||
|
// afternoon totals up as "wet and overcast", so the tab got painted
|
||||||
|
// rain-blue even though the hours it is printing a temperature FOR
|
||||||
|
// were dry and sunny.
|
||||||
|
//
|
||||||
|
// The tab's number already comes from the hottest sustained hours, so
|
||||||
|
// the shading should come from the same ones. colourRows is that
|
||||||
|
// slice — daylight hours within 3° of colourHi, never fewer than the
|
||||||
|
// three hottest — and it drives the tint only.
|
||||||
|
//
|
||||||
|
// The ICON deliberately keeps reading the whole day: it did rain this
|
||||||
|
// morning, and the glyph is the right place to say so. Colour answers
|
||||||
|
// "how hot is this day", the glyph answers "did it rain".
|
||||||
|
const colourRows = (() => {
|
||||||
|
if (colourHi === null || !repRows.length) return repRows;
|
||||||
|
const near = repRows.filter(r => isFinite(r[mainField]) && r[mainField] >= colourHi - 3);
|
||||||
|
if (near.length >= 3) return near;
|
||||||
|
return [...repRows]
|
||||||
|
.filter(r => isFinite(r[mainField]))
|
||||||
|
.sort((a, b) => b[mainField] - a[mainField])
|
||||||
|
.slice(0, 3);
|
||||||
|
})();
|
||||||
|
const peakPrecip = colourRows.reduce((s, r) => s + (r.precip || 0), 0);
|
||||||
|
const peakSnow = colourRows.reduce((s, r) => s + (r.snow || 0), 0);
|
||||||
|
const peakProbs = colourRows.map(r => r.precipProb).filter(v => isFinite(v));
|
||||||
|
const peakProb = peakProbs.length ? Math.max(...peakProbs) : 0;
|
||||||
|
const peakWet = clamp01((peakProb - 50) / 25);
|
||||||
|
|
||||||
// Secondary row under each hi/lo: Shade for SunSoak, Managed for
|
// Secondary row under each hi/lo: Shade for SunSoak, Managed for
|
||||||
// indoor, Pet Shade for pets, and plain air temperature for
|
// indoor, Pet Shade for pets, and plain air temperature for
|
||||||
// Vehicle/Driver so the outside-vs-cabin gap is visible.
|
// Vehicle/Driver so the outside-vs-cabin gap is visible.
|
||||||
@@ -242,11 +432,6 @@ export function DayTabs({
|
|||||||
shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null;
|
shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Day-tab weather icon - use core daylight rows (elev > 10°) where
|
|
||||||
// available, falling back to any above-horizon rows, then all rows.
|
|
||||||
const coreRows = d.rows.filter(r => r.elev > 10);
|
|
||||||
const aboveRows = d.rows.filter(r => r.elev > 0);
|
|
||||||
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows;
|
|
||||||
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
|
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
|
||||||
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
|
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
|
||||||
const catCounts = {};
|
const catCounts = {};
|
||||||
@@ -256,7 +441,43 @@ export function DayTabs({
|
|||||||
const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null);
|
const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null);
|
||||||
const repElev = noonRow ? noonRow.elev : 45;
|
const repElev = noonRow ? noonRow.elev : 45;
|
||||||
const repDt = noonRow ? noonRow.dt : dDate;
|
const repDt = noonRow ? noonRow.dt : dDate;
|
||||||
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
|
|
||||||
|
// ---- The two rain numbers, reconciled --------------------------
|
||||||
|
// The forecast gives us two different things and they answer
|
||||||
|
// different questions: precipProb is WHETHER it rains, precip is HOW
|
||||||
|
// MUCH if it does. Reading either one alone gets the day wrong — an
|
||||||
|
// amount-only reading puts a rain icon on a 37% day that will most
|
||||||
|
// likely stay dry, while a probability-only reading can't tell
|
||||||
|
// drizzle from a downpour.
|
||||||
|
//
|
||||||
|
// So: probability decides IF the tab shows rain at all, and then the
|
||||||
|
// two together decide how hard that rain looks.
|
||||||
|
// • under 50% — no rain icon; it probably won't rain
|
||||||
|
// • 50% → 100% — rain icon, climbing to medium-high on
|
||||||
|
// confidence alone
|
||||||
|
// • + heavy amount — climbs the rest of the way to a downpour
|
||||||
|
// The tab COLOUR reuses the same 50% gate, but reads it over the peak
|
||||||
|
// hours (peakWet, above), so the icon and the shade can never
|
||||||
|
// disagree about WHETHER it rains — only about whether it was still
|
||||||
|
// raining during the part of the day the tab is printing a
|
||||||
|
// temperature for.
|
||||||
|
const probVals = repRows.map(r => r.precipProb).filter(v => isFinite(v));
|
||||||
|
const maxProb = probVals.length ? Math.max(...probVals) : 0;
|
||||||
|
|
||||||
|
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
|
||||||
|
const probT = clamp01((maxProb - 50) / 50);
|
||||||
|
// Amount half: the daylight total, "heavy" taken as 8mm across the day.
|
||||||
|
const amountT = clamp01(dayPrecip / 8);
|
||||||
|
// Weighted so certainty alone tops out around medium-high (2 drops)
|
||||||
|
// and only a genuinely wet forecast reaches the 3-drop downpour.
|
||||||
|
const rainScore = probT * 0.55 + amountT * 0.45;
|
||||||
|
const rainDrops = rainScore < 0.33 ? 1 : rainScore < 0.70 ? 2 : 3;
|
||||||
|
|
||||||
|
// Snow gets the same probability gate — precipProb covers all
|
||||||
|
// precipitation — but keeps its own amount-driven flake count.
|
||||||
|
const showSnow = daySnow >= 0.1 && maxProb >= 50;
|
||||||
|
const showRain = !showSnow && dayPrecip >= 0.3 && maxProb >= 50;
|
||||||
|
const showPrecip = showRain || showSnow;
|
||||||
|
|
||||||
// Vehicle tab: swap the default car glyph for a hazard icon when
|
// Vehicle tab: swap the default car glyph for a hazard icon when
|
||||||
// conditions could affect driving. Uses the full day (not just
|
// conditions could affect driving. Uses the full day (not just
|
||||||
@@ -282,71 +503,361 @@ export function DayTabs({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Day-tab colour — PRIORITY model, no hue blending.
|
// Wind badge — gusts over the WHOLE day, not just the daylight
|
||||||
// Mixing a warm yellow with grey/blue passes through green, so rather
|
// repRows: a gale that runs into the evening still defines the day.
|
||||||
// than blend temperature with sky we pick ONE dimension by priority and
|
// Tiers mirror windCategory() in events/weather-checks.js so the tab
|
||||||
// only vary its shade: heat → snow → rain → cloud → sun (first wins).
|
// and the At a Glance advisory can never disagree about what counts
|
||||||
// • Hot/extreme days always keep their heat colour (a safety signal).
|
// as a gale.
|
||||||
// • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour.
|
const gustVals = d.rows.map(r => r.gust ?? r.va).filter(v => isFinite(v));
|
||||||
|
const gustsMph = gustVals.map(v => v * 2.237);
|
||||||
|
// Round BEFORE tiering, not after. The table's gust column prints a
|
||||||
|
// rounded figure, so a 31.99 mph day already reads "32 mph" there —
|
||||||
|
// testing the raw value against a 32 mph threshold then withheld the
|
||||||
|
// badge from a day the rest of the UI was calling a 32 mph day.
|
||||||
|
// Tier the same number the user is actually shown.
|
||||||
|
const maxGust = gustsMph.length ? Math.round(Math.max(...gustsMph)) : 0;
|
||||||
|
const avgGust = gustsMph.length
|
||||||
|
? gustsMph.reduce((s, v) => s + v, 0) / gustsMph.length : 0;
|
||||||
|
// A day doesn't have to spike to be a windy day. Peak-only tiering
|
||||||
|
// missed the kind people actually notice — hour after hour of 25-30
|
||||||
|
// mph gusts, a Strong Breeze (Force 6) that never quite reaches Near
|
||||||
|
// Gale — while a day with one brief 32 mph gust between calm hours
|
||||||
|
// got the badge. So a day that stays blustery for at least half its
|
||||||
|
// hours earns the badge on its own. 25 mph is GUST_BLUSTERY in
|
||||||
|
// events/weather-checks.js.
|
||||||
|
const blusteryFrac = gustsMph.length
|
||||||
|
? gustsMph.filter(v => v >= 25).length / gustsMph.length : 0;
|
||||||
|
const sustainedWind = blusteryFrac >= 0.5;
|
||||||
|
const windTier = maxGust >= 74 ? 5 : maxGust >= 55 ? 4
|
||||||
|
: maxGust >= 39 ? 3 : maxGust >= 32 ? 2
|
||||||
|
: sustainedWind ? 2 : 0;
|
||||||
|
const windLabel = windTier >= 5 ? 'Storm force'
|
||||||
|
: windTier >= 4 ? 'Severe gale'
|
||||||
|
: windTier >= 3 ? 'Gale'
|
||||||
|
: maxGust >= 32 ? 'Near gale'
|
||||||
|
: 'Blustery';
|
||||||
|
// Badge size tracks what the day AVERAGED rather than how hard it
|
||||||
|
// spiked, so a day that simply stays windy reads heavier than one
|
||||||
|
// carried by a single gust. 18 mph averages to the smallest badge,
|
||||||
|
// 34 mph to the largest.
|
||||||
|
const windBadgeSize = Math.round(15 + 9 * clamp01((avgGust - 18) / 16));
|
||||||
|
|
||||||
|
// Day-tab colour — WEIGHTED model.
|
||||||
|
// Temperature is always the base: the band ramp, so the tab reads as a
|
||||||
|
// temperature first and a 17° day can never come out looking like a
|
||||||
|
// 26° one. Sky conditions then shade that base by influence, in order
|
||||||
|
// of how much they change the day:
|
||||||
|
// 1. temperature - base colour, from the band ramp
|
||||||
|
// 2. cloud cover - dulls that colour in place, same hue
|
||||||
|
// 3. rain / snow - pulls toward blue, by amount AND confidence
|
||||||
|
// Thermal first, then cloud, then precipitation. Cloud is the sky
|
||||||
|
// condition EVERY day has some of, so it is the general case and rain
|
||||||
|
// is the special one layered over it — and crucially it has to be
|
||||||
|
// cloudy for it to rain, so rain never lands on a raw band colour.
|
||||||
|
// By the time rain applies, cloud has already carried the tab to
|
||||||
|
// grey, and grey is the one starting point from which deep rain-blue
|
||||||
|
// is a clean path: gold straight to blue goes through khaki and
|
||||||
|
// olive, gold to grey to blue does not. That ordering is what keeps
|
||||||
|
// green off the strip, not a choice of weights.
|
||||||
|
// Rain and snow blend through overlayTint() so a warm yellow shading
|
||||||
|
// toward rain-blue routes via grey rather than passing through green;
|
||||||
|
// cloud uses cloudDim(), which never moves the hue at all.
|
||||||
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
|
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
|
||||||
// event banner can colour itself from exactly the same source.
|
// event banner can colour itself from exactly the same source.
|
||||||
const catFn = isPetsProfile ? petCategory : utciCategory;
|
const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' };
|
||||||
const tBand = dayHi !== null ? catFn(dayHi) : { bg: '#f8e554' };
|
// Band colour, but ramped: bandRampRgb() interpolates between the
|
||||||
|
// this band's colour and the next one's, so 30° isn't the same
|
||||||
|
// swatch as 27° while 32° still lands on the Extreme orange
|
||||||
|
// (see BAND COLOUR RAMP in utils.js). Danger stays flat — it's an
|
||||||
|
// alarm colour, not a point on the gradient.
|
||||||
|
const tBandRgb = tBand.solid
|
||||||
|
? hexToRgb(tBand.bg)
|
||||||
|
: bandRampRgb(colourHi, isPetsProfile ? DAY_TAB_PET_BANDS : DAY_TAB_UTCI_BANDS);
|
||||||
|
|
||||||
const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v));
|
// Cloud for the TINT, averaged over the peak hours only — see
|
||||||
|
// colourRows above. A wet overcast morning no longer greys out a
|
||||||
|
// clear hot afternoon.
|
||||||
|
const ccVals = colourRows.map(r => r.cc).filter(v => isFinite(v));
|
||||||
const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
|
const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
|
||||||
|
|
||||||
// Hot/cold cutoffs below are calibrated against UTCI_BANDS; for the
|
// Heat cutoffs are calibrated against the UTCI bands; for the Pets
|
||||||
// Pets profile (furSurfaceT, judged against the shifted PET_BANDS)
|
// profile (furSurfaceT, judged against the shifted pet bands) the same cutoffs
|
||||||
// the same cutoffs are re-projected onto the pet scale so the
|
// are re-projected onto the pet scale so each one kicks in at the same
|
||||||
// "hot hue" / "plain cold" branches kick in at the same relative
|
// relative point in the band, not the same absolute degree.
|
||||||
// point in each band, not the same absolute degree.
|
// heatT drives both how far the sky is allowed to shade the colour
|
||||||
const isHot = dayHi !== null && dayHi >= (isPetsProfile ? 35 : 27); // Hot band and above
|
// (skyCap, below) and how pastel the tab is drawn (further down).
|
||||||
const isDanger = tBand.solid === true;
|
// heatFloor is the START of the hot half — the Warm band threshold,
|
||||||
|
// not the Caution one. Anchoring it at Caution meant a day sitting in
|
||||||
|
// the bottom of Caution scored heatT ~= 0 and was therefore treated
|
||||||
|
// by every heat guard below exactly like a 20° day: full sky shading,
|
||||||
|
// no band weighting. A 28° tab came out the same grey as a 17° one.
|
||||||
|
// Starting the ramp at Warm makes the guards engage across the whole
|
||||||
|
// hot half, which is where they were always meant to apply.
|
||||||
|
const heatFloor = isPetsProfile ? 25 : 24; // Warm threshold
|
||||||
|
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
|
||||||
|
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
|
||||||
|
|
||||||
|
// The spike filter that protects the tab's BAND COLOUR should not
|
||||||
|
// also decide how hot the tab is allowed to LOOK. sustainedHigh()
|
||||||
|
// deliberately throws away the day's top ~10% of hours so a single
|
||||||
|
// warm hour can't paint a cool day warm — right for choosing the hue,
|
||||||
|
// wrong for choosing vividness. On a muggy UK heat day it discounts
|
||||||
|
// the part of the day people actually remember: 26 Aug 2026 at Path
|
||||||
|
// Hill printed 33° but coloured from 29°, which halved heatT and let
|
||||||
|
// 87% cloud grey out a genuinely hot afternoon.
|
||||||
|
// So the hue keeps riding on the spike-filtered colourHi, while
|
||||||
|
// vividness and cloud-resistance ride on the midpoint between that
|
||||||
|
// and the true peak: the day still has to have been broadly hot, but
|
||||||
|
// a real hot spell is no longer discounted away to nothing.
|
||||||
|
const heatDrive = colourHi === null ? null : (colourHi + dayHi) / 2;
|
||||||
|
const heatT = heatDrive === null ? 0 : clamp01((heatDrive - heatFloor) / (heatMid - heatFloor));
|
||||||
|
const overT = heatDrive === null ? 0 : clamp01((heatDrive - heatMid) / (heatTop - heatMid));
|
||||||
|
|
||||||
|
// Cold counterpart to heatT, anchored on the same two bands at the
|
||||||
|
// other end of the scale: it starts at the Very cold threshold and
|
||||||
|
// reaches full strength at Arctic. Used only for skyCap below — the
|
||||||
|
// pastel ramp stays keyed to heat.
|
||||||
|
// Two segments, mirroring heatFloor/heatMid/heatTop on the hot end.
|
||||||
|
// This used to be a single ramp that finished at the ARCTIC
|
||||||
|
// threshold, which meant coldT pinned to 1 from -10° downward and
|
||||||
|
// every day below it — the whole of Arctic AND the whole of Extreme
|
||||||
|
// cold — was drawn identically. Invercargill at -12° and at -28°
|
||||||
|
// came out the same tab. The hot end never had that problem because
|
||||||
|
// it ramps across Warm → Caution → Extreme; the cold end now does
|
||||||
|
// the same, with underT carrying the deep half.
|
||||||
|
const coldFloor = isPetsProfile ? -3 : 5; // Very cold threshold
|
||||||
|
const coldMid = isPetsProfile ? -18 : -10; // Arctic threshold
|
||||||
|
const coldBottom = isPetsProfile ? -28 : -20; // Extreme cold threshold
|
||||||
|
const coldT = colourHi === null ? 0 : clamp01((coldFloor - colourHi) / (coldFloor - coldMid));
|
||||||
|
const underT = colourHi === null ? 0 : clamp01((coldMid - colourHi) / (coldMid - coldBottom));
|
||||||
|
|
||||||
|
// How far a day sits from the benign middle of the scale, in either
|
||||||
|
// direction. Chilly/Cool/Comfortable/Warm days are ordinary weather
|
||||||
|
// and let the sky do the talking; the further a day pushes toward
|
||||||
|
// either extreme, the more the temperature holds the colour.
|
||||||
|
const extremityT = Math.max(heatT, coldT);
|
||||||
|
|
||||||
|
// Extreme band gets a less pastelised gradient so it reads as a
|
||||||
|
// clear step between Caution and the pulsing Danger tier.
|
||||||
|
const isExtreme = tBand.label === 'Extreme';
|
||||||
|
|
||||||
// Home/indoor/vehicle tabs show a modelled temperature, not the
|
// Home/indoor/vehicle tabs show a modelled temperature, not the
|
||||||
// outdoor sky, so the tab shade should track that temperature only
|
// outdoor sky, so the tab shade should track that temperature only
|
||||||
// — no rain/snow/cloud tinting, and no weather icon.
|
// — no rain/snow/cloud tinting, and no weather icon.
|
||||||
const tempOnly = isHomeOrOffice || isVehicleProfile;
|
const tempOnly = isHomeOrOffice || isVehicleProfile;
|
||||||
|
|
||||||
|
// At gale force and above on a dry day, the wind IS the day's weather
|
||||||
|
// — a sun glyph with a wind badge tucked in the corner undersells a
|
||||||
|
// 41 mph gale on a bright, dry Monday. So the wind glyph takes the
|
||||||
|
// main icon slot instead, and the badge is only used for the tier
|
||||||
|
// below (Near Gale) or when rain already owns the main slot.
|
||||||
|
const windIsHeadline = !tempOnly && windTier >= 3 && !showPrecip;
|
||||||
|
|
||||||
|
// How far the temperature colour is allowed to be shaded by the sky.
|
||||||
|
// Both ends of the scale are a safety signal, so an Extreme-heat or
|
||||||
|
// Arctic day keeps its colour almost intact however thick the cloud —
|
||||||
|
// the sky can only really take the tab on the ordinary days in
|
||||||
|
// between. Danger is immovable.
|
||||||
|
// Heat gets a second, separate reduction on top of that. Heat and a
|
||||||
|
// wet sky are not opposites — a muggy 28° afternoon under rain is
|
||||||
|
// still a hot day, and the story is the heat. This used to be applied
|
||||||
|
// to CLOUD only (as cloudHeatCut), which left rain free to repaint a
|
||||||
|
// hot day blue at up to 0.92 strength; and because reducing the rain
|
||||||
|
// weight hands the leftover influence straight to cloud, cutting one
|
||||||
|
// without the other just swaps blue for grey. Cutting skyCap itself
|
||||||
|
// cuts both together, and keeps the rain→cloud handover coherent.
|
||||||
|
// Raised to a fractional power so the cut BITES IN LOW CAUTION rather
|
||||||
|
// than waiting for Extreme. A straight linear cut left a 28° day at
|
||||||
|
// ~0.43 sky influence — enough rain-blue to still read as a cold wet
|
||||||
|
// day — because the two caps multiply and each is gentle on its own.
|
||||||
|
// The rain/snow GLYPH already tells you the day is wet; past the
|
||||||
|
// middle of the hot half the colour's job is to say how hot it is.
|
||||||
|
const skyHeatCut = 1 - 0.92 * Math.pow(heatT, 0.75);
|
||||||
|
const skyCap = tBand.solid ? 0 : (1 - 0.80 * extremityT) * skyHeatCut;
|
||||||
|
|
||||||
let rgb;
|
let rgb;
|
||||||
if (tempOnly) {
|
if (tempOnly) {
|
||||||
rgb = hexToRgb(tBand.bg);
|
// Home/indoor/vehicle tabs show a modelled temperature, not the
|
||||||
} else if (isHot) {
|
// outdoor sky — no weather shading at all.
|
||||||
// Keep the heat hue; cloud only mutes it a touch (orange/red never
|
rgb = tBandRgb;
|
||||||
// greens) and rain does not override heat.
|
|
||||||
const heatFactor = isPetsProfile
|
|
||||||
? (dayHi >= 49 ? 0.15 : dayHi >= 43 ? 0.30 : 0.45)
|
|
||||||
: (dayHi >= 39 ? 0.15 : dayHi >= 34 ? 0.30 : 0.45);
|
|
||||||
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
|
|
||||||
rgb = mixRgb(hexToRgb(tBand.bg), [170, 162, 152], t);
|
|
||||||
} else if (daySnow >= 0.1) {
|
|
||||||
// Snow: pale → icy blue with depth.
|
|
||||||
rgb = snowTint(daySnow);
|
|
||||||
} else if (showPrecip) {
|
|
||||||
// Rain: light → deep blue with amount (pure blue, no temperature hue).
|
|
||||||
rgb = rainTint(dayPrecip);
|
|
||||||
} else if (avgCloud >= 30) {
|
|
||||||
// Cloud: light → dark grey with cover (pure grey, no temperature hue).
|
|
||||||
rgb = cloudTint(avgCloud);
|
|
||||||
} else if (dayHi !== null && dayHi < (isPetsProfile ? 2 : 10)) {
|
|
||||||
// Clear & cold: keep the cold temperature blue.
|
|
||||||
rgb = hexToRgb(tBand.bg);
|
|
||||||
} else {
|
} else {
|
||||||
// Clear & mild/warm: a plain sunny yellow.
|
rgb = tBandRgb;
|
||||||
rgb = hexToRgb('#f8e554');
|
|
||||||
|
// 2. Cloud — dulls the temperature colour in place (cloudDim:
|
||||||
|
// same hue, less vivid, slightly darker). Applied BEFORE rain,
|
||||||
|
// because cloud is the sky condition every day has some of, so
|
||||||
|
// it is the general case that rain is then layered onto.
|
||||||
|
// avgCloud comes from the peak hours (colourRows), so a wet
|
||||||
|
// overcast morning no longer greys out a clear hot afternoon.
|
||||||
|
let cloudW = 0;
|
||||||
|
if (avgCloud >= 30) {
|
||||||
|
cloudW = decisive((avgCloud - 30) / 70) * 0.85 * skyCap;
|
||||||
|
rgb = cloudDim(rgb, cloudW);
|
||||||
|
} else if (colourHi !== null) {
|
||||||
|
// Clear skies: a small warm lift so a sunny day of a given
|
||||||
|
// temperature reads brighter than an overcast one at the same
|
||||||
|
// temperature. Deliberately gentle — it tops out at 0.16, so it
|
||||||
|
// tints the band colour rather than replacing it the way the old
|
||||||
|
// flat "sunny yellow" branch did.
|
||||||
|
const sunW = clamp01((30 - avgCloud) / 30) * 0.16 * skyCap;
|
||||||
|
rgb = overlayTint(rgb, hexToRgb('#f8e554'), sunW);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rain / snow — last, and only with the influence cloud did not
|
||||||
|
// already claim, so the two hand over smoothly instead of
|
||||||
|
// fighting. Sits behind the same 50% gate the icon uses, so the
|
||||||
|
// tab can never be painted wet while showing a dry sky glyph.
|
||||||
|
// Amount, confidence and tint colour all come from the peak
|
||||||
|
// hours (colourRows), so rain that has already cleared by the
|
||||||
|
// time the day gets hot no longer colours the tab. The showRain
|
||||||
|
// / showSnow gates still read the whole day, so a wet-morning /
|
||||||
|
// hot-afternoon day keeps its rain glyph over a warm tab.
|
||||||
|
// It has to be cloudy for it to rain, so by the time rain is
|
||||||
|
// applied the tab has ALREADY been taken to grey by step 2 — and
|
||||||
|
// grey is the one starting point from which deep rain-blue is a
|
||||||
|
// clean path. That is why rain is no longer throttled by
|
||||||
|
// (1 - cloudW): the previous version handed cloud the influence
|
||||||
|
// first and then left rain the scraps, which on a heavily overcast
|
||||||
|
// day meant rain could barely be seen at all. Rain now tints the
|
||||||
|
// grey at full strength for its amount and confidence.
|
||||||
|
//
|
||||||
|
// The ceiling drops from 0.92 to 0.60 to match: 0.92 was sized for
|
||||||
|
// pulling a saturated BAND colour toward blue, but pulling an
|
||||||
|
// already-grey tab that far just produces a flat saturated blue.
|
||||||
|
// 0.60 lands on a deep blue-grey — a wet overcast day — which is
|
||||||
|
// what the tab should say.
|
||||||
|
if (showSnow) {
|
||||||
|
rgb = overlayTint(rgb, snowTint(peakSnow),
|
||||||
|
decisive(peakSnow / 1.2) * 0.60 * skyCap);
|
||||||
|
} else if (showRain && peakWet > 0) {
|
||||||
|
rgb = overlayTint(rgb, rainTint(peakPrecip),
|
||||||
|
decisive(peakPrecip / 4) * peakWet * 0.60 * skyCap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Extreme tabs lean toward Danger's crimson as the daily high climbs
|
||||||
|
// through the band (32→41°C human, 40→52°C pet) — so a day peaking
|
||||||
|
// near 40° visibly edges into danger-red without becoming Danger.
|
||||||
|
if (isExtreme) {
|
||||||
|
const floor = isPetsProfile ? 40 : 32;
|
||||||
|
const ceil = isPetsProfile ? 52 : 41;
|
||||||
|
const t = Math.max(0, Math.min(1, (colourHi - floor) / (ceil - floor)));
|
||||||
|
rgb = mixRgb(rgb, [136, 0, 0], t * 0.55);
|
||||||
}
|
}
|
||||||
const [wR, wG, wB] = rgb;
|
const [wR, wG, wB] = rgb;
|
||||||
|
|
||||||
const wBgNeutral = isDanger
|
// How pastel the tab is drawn also carries heat: the tint is blended
|
||||||
? weatherGradientNeutral(wR, wG, wB, 0.15, 0.72)
|
// toward white, and the hotter the day the less white goes in. This
|
||||||
: weatherGradientNeutral(wR, wG, wB);
|
// used to step at the band edges (0.50 for everything up to Caution,
|
||||||
const wBgActive = isDanger
|
// then 0.20 at Extreme), which made 30° look like a washed-out yellow
|
||||||
? weatherGradientActive(wR, wG, wB, 0.12, 0.72)
|
// while 32° snapped to a solid orange. Ramp the blend continuously
|
||||||
: weatherGradientActive(wR, wG, wB);
|
// instead — the endpoints still land on the old Extreme (heatT = 1)
|
||||||
|
// and Danger (both = 1) values, so only the in-between days change.
|
||||||
|
//
|
||||||
|
// The cold end goes TWO-TONE instead of just getting darker. Cold
|
||||||
|
// band colours are pale to begin with, so deepening them uniformly
|
||||||
|
// only makes a murky blue-grey — and a hard winter's day should look
|
||||||
|
// unmistakably different from a mild one, not slightly duller. So the
|
||||||
|
// two ends of the gradient are pulled apart as the day gets colder:
|
||||||
|
// the rim deepens toward the true band colour while the core lifts
|
||||||
|
// toward frost-white, giving a frosted, iced-over tab. Same mechanism
|
||||||
|
// Danger already uses (dark edge, light centre) at the opposite end
|
||||||
|
// of the scale. decisive() delays the onset so an ordinary 3°C
|
||||||
|
// morning stays plain and only a genuinely cold day frosts over.
|
||||||
|
// The frost lift on the CENTRE was +0.22, which put the core at 0.91
|
||||||
|
// — effectively pure white (#f3f9fd). The band colour survived only
|
||||||
|
// as a thin rim, so the coldest, most dangerous days rendered as the
|
||||||
|
// faintest tabs on the strip, inverting the safety hierarchy the hot
|
||||||
|
// end enforces. Pulled back to +0.14 so the core still reads as iced
|
||||||
|
// over but the tab keeps its body; underT then takes the deep half
|
||||||
|
// DOWN again, so Extreme cold is the most saturated cold tab rather
|
||||||
|
// than the most washed-out.
|
||||||
|
const frostT = decisive(coldT);
|
||||||
|
// Band WEIGHT — how much of the band colour is left standing rather
|
||||||
|
// than washed out toward white. The two numbers below are "how far
|
||||||
|
// this stop blends toward white", so weighting a tier up means
|
||||||
|
// scaling BOTH stops down together. Scaling only the core is what the
|
||||||
|
// first attempt did, and it pulled the centre down onto the rim until
|
||||||
|
// the two met — which flattened the radial into a slab of flat colour
|
||||||
|
// and lost the lit-from-within look the whole tab strip is built on.
|
||||||
|
// Scaling both keeps the centre-to-rim distance intact and simply
|
||||||
|
// moves the whole gradient deeper into the band colour.
|
||||||
|
//
|
||||||
|
// Stepped by tier, because the tiers are the safety story and have to
|
||||||
|
// escalate visibly rather than read as three similar oranges.
|
||||||
|
// Danger is deliberately excluded: it already has its own treatment
|
||||||
|
// (flat crimson, dark rim, light core, outlined text) and weighting
|
||||||
|
// it as well buried its hi/lo numbers.
|
||||||
|
const tierFrac = tBand.solid ? 0
|
||||||
|
: tBand.label === 'Extreme' ? 0.75 // 75% more weight
|
||||||
|
: tBand.label === 'Caution' ? 0.50 // 50% more weight
|
||||||
|
: 0;
|
||||||
|
// Eased in across Caution so 27° is a smooth departure from an
|
||||||
|
// ordinary day rather than a hard step. Extreme is already past the
|
||||||
|
// top of that ramp, so it takes its weight flat.
|
||||||
|
const ramp = tBand.label === 'Caution' ? heatT : 1;
|
||||||
|
// Deepen BOTH stops by the same amount — never scale them. Scaling
|
||||||
|
// preserves their ratio but crushes the DISTANCE between them, which
|
||||||
|
// is what the eye actually reads as the lit-from-within core: at full
|
||||||
|
// Caution weight it closed the 0.41 core-to-rim gap to 0.24 and the
|
||||||
|
// radial flattened into a slab of colour. Subtracting a constant
|
||||||
|
// moves the whole gradient deeper into the band while leaving that
|
||||||
|
// distance untouched. The offset is a fraction of the rim's own
|
||||||
|
// headroom, so neither stop can be pushed past the pure band colour.
|
||||||
|
//
|
||||||
|
// But the offset is a RIM treatment — its whole job is to push the
|
||||||
|
// outer edge further into the band colour. Taking the full offset off
|
||||||
|
// the centre too dragged the core down in lockstep, so the weighted
|
||||||
|
// tiers (Caution and Extreme — the only ones with a non-zero offset)
|
||||||
|
// were exactly the tabs that lost their white core: the hotter the
|
||||||
|
// day, the more of the lit-from-within look it gave away. The centre
|
||||||
|
// now takes only part of the offset.
|
||||||
|
//
|
||||||
|
// Note this WIDENS the core-to-rim distance rather than closing it,
|
||||||
|
// which is the safe direction — the failure mode the note above warns
|
||||||
|
// about is the centre collapsing onto the rim, not lifting away from
|
||||||
|
// it. Lifting is the effect that reads as "lit from within".
|
||||||
|
const CENTRE_DEEPEN = 0.55;
|
||||||
|
const deepen = (edge, centre) => {
|
||||||
|
const off = clamp01(edge) * tierFrac * ramp;
|
||||||
|
return [clamp01(edge - off), clamp01(centre - off * CENTRE_DEEPEN)];
|
||||||
|
};
|
||||||
|
// Minimum core-to-rim distance — the "glow" floor.
|
||||||
|
//
|
||||||
|
// Deepening only ever fires on Caution/Extreme, and frostT only at
|
||||||
|
// the cold end, which leaves the entire middle of the scale — Cool,
|
||||||
|
// Comfortable, Warm — with its two stops barely 0.19 apart. At that
|
||||||
|
// distance the radial is invisible, so those tabs read as flat slabs
|
||||||
|
// of pale colour sitting next to hot ones that visibly glow.
|
||||||
|
//
|
||||||
|
// The middle tabs are not actually less white in the centre — 0.69
|
||||||
|
// beats Extreme's 0.59. They have no CONTRAST to make that whiteness
|
||||||
|
// legible. So the fix is distance, not lightness: push the two stops
|
||||||
|
// apart around their own midpoint, which lifts the core and deepens
|
||||||
|
// the rim together and leaves the tab's overall lightness where it
|
||||||
|
// was. Tabs already clearing the floor are returned untouched, so the
|
||||||
|
// hot and frosted ends keep exactly the treatment they have.
|
||||||
|
const MIN_GLOW = 0.36;
|
||||||
|
const glow = ([edge, centre]) => {
|
||||||
|
if (centre - edge >= MIN_GLOW) return [edge, centre];
|
||||||
|
const mid = (edge + centre) / 2;
|
||||||
|
return [clamp01(mid - MIN_GLOW / 2), clamp01(mid + MIN_GLOW / 2)];
|
||||||
|
};
|
||||||
|
const [edgeNeutral, centreNeutral] = glow(deepen(
|
||||||
|
0.50 - 0.30 * heatT - 0.05 * overT - 0.34 * frostT - 0.10 * underT, // edge: 0.50 → 0.20 → 0.15 · cold → 0.16 → 0.06
|
||||||
|
0.69 - 0.03 * heatT + 0.06 * overT + 0.14 * frostT - 0.06 * underT, // centre: 0.69 → 0.66 → 0.72 · cold → 0.83 → 0.77
|
||||||
|
));
|
||||||
|
const [edgeActive, centreActive] = glow(deepen(
|
||||||
|
0.42 - 0.26 * heatT - 0.04 * overT - 0.30 * frostT - 0.06 * underT, // edge: 0.42 → 0.16 → 0.12 · cold → 0.12 → 0.06
|
||||||
|
0.64 + 0.02 * heatT + 0.06 * overT + 0.18 * frostT - 0.06 * underT, // centre: 0.64 → 0.66 → 0.72 · cold → 0.82 → 0.76
|
||||||
|
));
|
||||||
|
const wBgNeutral = weatherGradientNeutral(wR, wG, wB, edgeNeutral, centreNeutral);
|
||||||
|
const wBgActive = weatherGradientActive(wR, wG, wB, edgeActive, centreActive);
|
||||||
const wText = '#2a1d10';
|
const wText = '#2a1d10';
|
||||||
// Active-tab outline: the day's weather colour, 20% darker.
|
// Active-tab outline: the day's weather colour, 20% darker.
|
||||||
const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`;
|
const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`;
|
||||||
|
// Extreme tabs get a strong red border so the tier pops even when
|
||||||
|
// the tab is not selected — a firmer warning than the tan default.
|
||||||
|
const wBorder = isExtreme ? '#c22f1a' : '#c9b08a';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
@@ -361,16 +872,17 @@ export function DayTabs({
|
|||||||
setProPromptDay(null);
|
setProPromptDay(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title=${locked
|
title=${(locked
|
||||||
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
? `${band.label} · SunScope Extra unlocks day ${i + 1}`
|
||||||
: `${band.label} · day ${i + 1} of 14`}
|
: `${band.label} · day ${i + 1} of 14`)
|
||||||
|
+ (windTier >= 2 ? ` · ${windLabel} — gusts to ${maxGust} mph` : '')}
|
||||||
style=${{
|
style=${{
|
||||||
background: isActive ? wBgActive : wBgNeutral,
|
background: isActive ? wBgActive : wBgNeutral,
|
||||||
color: wText,
|
color: wText,
|
||||||
borderTop: '1px solid #c9b08a',
|
borderTop: `1px solid ${wBorder}`,
|
||||||
borderRight: 'none',
|
borderRight: 'none',
|
||||||
borderBottom: '1px solid #c9b08a',
|
borderBottom: `1px solid ${wBorder}`,
|
||||||
borderLeft: i === 0 ? '1px solid #c9b08a' : 'none',
|
borderLeft: i === 0 ? `1px solid ${wBorder}` : 'none',
|
||||||
opacity: locked ? 0.5 : 1,
|
opacity: locked ? 0.5 : 1,
|
||||||
cursor: locked ? 'not-allowed' : 'pointer',
|
cursor: locked ? 'not-allowed' : 'pointer',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -386,6 +898,7 @@ export function DayTabs({
|
|||||||
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
|
||||||
</span>
|
</span>
|
||||||
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
|
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
|
||||||
|
<span class="utci-day-icon">
|
||||||
${tempOnly
|
${tempOnly
|
||||||
? (isVehicleProfile
|
? (isVehicleProfile
|
||||||
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
|
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
|
||||||
@@ -395,8 +908,23 @@ export function DayTabs({
|
|||||||
: html`<${CarIcon} size=${30} />`)
|
: html`<${CarIcon} size=${30} />`)
|
||||||
: html`<${HouseIcon} size=${30} />`)
|
: html`<${HouseIcon} size=${30} />`)
|
||||||
: showPrecip
|
: showPrecip
|
||||||
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />`
|
? html`<${PrecipIcon} precip=${showRain ? dayPrecip : 0} snow=${daySnow}
|
||||||
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
|
drops=${rainDrops} size=${30} />`
|
||||||
|
: windIsHeadline
|
||||||
|
? html`<${WindIcon} size=${30} tier=${windTier} />`
|
||||||
|
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
|
||||||
|
${/* Wind that is real but isn't the day's headline (Near Gale, or
|
||||||
|
a gale on a day rain already speaks for) rides ON TOP of the
|
||||||
|
sky glyph rather than sitting off in the tab corner, so the
|
||||||
|
icon states one combined condition — "windy AND wet" — the
|
||||||
|
way the eye reads it anyway. The halo keeps the gust lines
|
||||||
|
legible over the cloud beneath. */''}
|
||||||
|
${windTier >= 2 && !windIsHeadline && html`
|
||||||
|
<span class="utci-day-wind"
|
||||||
|
aria-label=${`${windLabel}, gusts to ${maxGust} mph`}>
|
||||||
|
<${WindIcon} size=${windBadgeSize} tier=${windTier} halo />
|
||||||
|
</span>`}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
${dayHi !== null && html`
|
${dayHi !== null && html`
|
||||||
<span style=${{
|
<span style=${{
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import { Wordmark } from './Wordmark.js';
|
|||||||
|
|
||||||
const html = htm.bind(h);
|
const html = htm.bind(h);
|
||||||
|
|
||||||
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
export const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
|
export const SUBSCRIBE_URL_ONEOFF = 'https://buy.stripe.com/14A14odTJ6bT0jY4jwd7q02';
|
||||||
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
|
export const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
|
|
||||||
export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
export function SubscribeModal({ title, detail, onClose, openRestore }) {
|
||||||
// Dismiss on Esc.
|
// Dismiss on Esc.
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// components/WeeklyNudge.js - Friendly weekly reminder for free users.
|
||||||
|
//
|
||||||
|
// Shown at most once every 7 days to non-subscribers (never on the very
|
||||||
|
// first visit - the WelcomeModal owns that moment). Leads with a small
|
||||||
|
// sun-themed fact so it reads as a bit of sunshine trivia rather than a
|
||||||
|
// nag, then offers SunScope Extra.
|
||||||
|
//
|
||||||
|
// Reuses the .welcome-* overlay/card styling. Purely presentational - the
|
||||||
|
// open/close state lives in useAppState (weeklyNudgeOpen).
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// onClose - dismiss and stamp the "shown" date (7-day cooldown)
|
||||||
|
// openRestore - opens the "Already subscribed?" restore modal
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { h } from '../../vendor/preact.js';
|
||||||
|
import { useEffect, useMemo } from '../../vendor/preact-hooks.js';
|
||||||
|
import htm from '../../vendor/htm.js';
|
||||||
|
import { Wordmark } from './Wordmark.js';
|
||||||
|
import { SUBSCRIBE_URL, SUBSCRIBE_URL_ONEOFF } from './SubscribeModal.js';
|
||||||
|
|
||||||
|
const html = htm.bind(h);
|
||||||
|
|
||||||
|
// One of these is picked at random each time the nudge appears, so a
|
||||||
|
// regular user gets a different bit of sun trivia week to week.
|
||||||
|
const SUN_FACTS = [
|
||||||
|
{
|
||||||
|
icon: '☀️',
|
||||||
|
title: 'Sunlight is eight minutes old',
|
||||||
|
body: html`Every ray that warms your face left the Sun eight and a half minutes ago and
|
||||||
|
crossed 93 million miles to get here. The least you can do is dress for it.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🌡️',
|
||||||
|
title: 'Shade can feel 15°C cooler',
|
||||||
|
body: html`Air temperature barely changes when you step into shade — but what your body
|
||||||
|
feels can drop by ten or fifteen degrees. That gap is exactly what SunSoak measures.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🧴',
|
||||||
|
title: 'Clouds are not sunscreen',
|
||||||
|
body: html`Up to 80% of UV sails straight through thin cloud, which is why overcast days
|
||||||
|
catch people out. SunScope's UV and sunburn-time columns keep score for you.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🚗',
|
||||||
|
title: 'A parked car beats the desert',
|
||||||
|
body: html`On a mild 22°C afternoon a car's interior can pass 50°C in under an hour —
|
||||||
|
hotter than the Sahara. SunScope can show you that number before you park.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '💨',
|
||||||
|
title: 'Wind steals your warmth',
|
||||||
|
body: html`A brisk breeze can strip several degrees off how cold you feel without moving
|
||||||
|
the thermometer at all. Wind chill is baked into every SunSoak reading.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🌅',
|
||||||
|
title: 'The golden hour is real',
|
||||||
|
body: html`Low sun travels through far more atmosphere, so early and late light is gentler
|
||||||
|
on skin — a good reason to check the hourly view before heading out.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WeeklyNudge({ onClose, openRestore }) {
|
||||||
|
const fact = useMemo(() => SUN_FACTS[Math.floor(Math.random() * SUN_FACTS.length)], []);
|
||||||
|
|
||||||
|
// Dismiss on Esc.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="welcome-overlay" onClick=${onClose}>
|
||||||
|
<div class="welcome-card" role="dialog" aria-modal="true" aria-labelledby="nudge-title"
|
||||||
|
onClick=${(e) => e.stopPropagation()} style=${{ maxWidth: '440px' }}>
|
||||||
|
<button class="welcome-close" aria-label="Close" onClick=${onClose}>×</button>
|
||||||
|
<div class="welcome-head">
|
||||||
|
<${Wordmark} />
|
||||||
|
<div class="welcome-kicker" style=${{ marginTop: '10px' }}>Your weekly ray of sun</div>
|
||||||
|
</div>
|
||||||
|
<div class="nudge-fact">
|
||||||
|
<span class="nudge-fact-icon" aria-hidden="true">${fact.icon}</span>
|
||||||
|
<h2 class="welcome-title" id="nudge-title">${fact.title}</h2>
|
||||||
|
<p class="nudge-fact-body">${fact.body}</p>
|
||||||
|
</div>
|
||||||
|
<p class="welcome-remember">
|
||||||
|
✨ <strong>SunScope Extra</strong> unlocks the 🔒 profiles — hiking, sailing, fishing,
|
||||||
|
photography, winter sports and the whole Work set — plus Show All and custom columns,
|
||||||
|
so you can build your own view. £2 a month.
|
||||||
|
</p>
|
||||||
|
<a class="welcome-cta" href=${SUBSCRIBE_URL} target="_blank" rel="noopener noreferrer"
|
||||||
|
style=${{ textDecoration: 'none', textAlign: 'center', marginBottom: '10px' }}>
|
||||||
|
Subscribe — £2/month
|
||||||
|
</a>
|
||||||
|
<div class="nudge-links">
|
||||||
|
<a href=${SUBSCRIBE_URL_ONEOFF} target="_blank" rel="noopener noreferrer">
|
||||||
|
Or make a one-off payment →
|
||||||
|
</a>
|
||||||
|
<button type="button" onClick=${() => { onClose(); openRestore(); }}>
|
||||||
|
Already subscribed? Restore access →
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nudge-dismiss" onClick=${onClose}>
|
||||||
|
Maybe next week
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
@@ -21,23 +21,21 @@ const html = htm.bind(h);
|
|||||||
const STEPS = [
|
const STEPS = [
|
||||||
{
|
{
|
||||||
icon: '☀️',
|
icon: '☀️',
|
||||||
title: 'SunSoak is the number to watch',
|
title: 'Watch SunSoak',
|
||||||
body: html`Pick a <strong>Solar model</strong> (open, urban, beach…) in the SunSoak dropdown
|
body: html`It's how the sun actually feels, not just air temperature. Pick the
|
||||||
to match where you are. It shows what your body actually feels in the sun — not just the
|
<strong>Solar model</strong> that matches where you are.`,
|
||||||
air temperature.`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '🎯',
|
icon: '🎯',
|
||||||
title: 'Choose a profile',
|
title: 'Pick a profile',
|
||||||
body: html`Tap <strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong>
|
body: html`<strong>Places</strong>, <strong>Activities</strong> or <strong>Work</strong> —
|
||||||
to instantly load the columns that matter for your situation — like Beach, Cycling or
|
Beach, Cycling, Farming and more — loads the right columns instantly.`,
|
||||||
Farming.`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '⚙️',
|
icon: '⚙️',
|
||||||
title: 'Make it yours',
|
title: 'Make it yours',
|
||||||
body: html`Hit <strong>Edit columns</strong> to add or remove any data you like — UV and
|
body: html`Every profile brings its own columns — UV, sunburn time, vehicle interior, soil,
|
||||||
sunburn time, vehicle interior, soil, air quality and more.`,
|
air quality. <strong>Edit columns</strong> (Extra) lets you pick your own.`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -59,9 +57,8 @@ export function WelcomeModal({ onClose }) {
|
|||||||
<h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2>
|
<h2 class="welcome-title" id="welcome-title">How to read the sky in 3 steps</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="welcome-intro">
|
<p class="welcome-intro">
|
||||||
This isn't your usual weather forecast. SunScope goes deeper than temperature alone —
|
Not your usual forecast — SunScope shows how the weather affects
|
||||||
it shows how the environment affects you: sun exposure, wind chill, UV intensity, and more.
|
<strong>you</strong>: sun, wind chill, UV and more.
|
||||||
For the best results, pick the <strong>environment</strong> that matches where you'll be.
|
|
||||||
</p>
|
</p>
|
||||||
<ol class="welcome-steps">
|
<ol class="welcome-steps">
|
||||||
${STEPS.map((s, i) => html`
|
${STEPS.map((s, i) => html`
|
||||||
@@ -74,7 +71,11 @@ export function WelcomeModal({ onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
</li>`)}
|
</li>`)}
|
||||||
</ol>
|
</ol>
|
||||||
<p class="welcome-remember">💾 Don't worry — SunScope remembers your choices for next time.</p>
|
<p class="welcome-remember">
|
||||||
|
✨ <strong>SunScope Extra</strong> unlocks the 🔒 profiles and custom columns,
|
||||||
|
£2/month — tap any locked one to subscribe.
|
||||||
|
</p>
|
||||||
|
<p class="welcome-note">💾 SunScope remembers your choices for next time.</p>
|
||||||
<button class="welcome-cta" onClick=${onClose}>Got it</button>
|
<button class="welcome-cta" onClick=${onClose}>Got it</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
+262
-42
@@ -17,7 +17,7 @@ import {
|
|||||||
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
calcIndoorTempPass, calcManagedIndoorTempPass, calcShadeAirTemp, calcShadeFeltTemp,
|
||||||
calcFurSurfaceTempPass,
|
calcFurSurfaceTempPass,
|
||||||
} from './physics.js';
|
} from './physics.js';
|
||||||
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS } from './utils.js';
|
import { windCompass8, uvSplit, cloudCategory, precipPenalty, sunburnMinutes, burnLabel, FUR_COLORS, UTCI_BANDS, PET_BANDS } from './utils.js';
|
||||||
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
import { UTCI_ENVIRONMENTS, CROP_CALENDAR, deriveProfileMain } from './config.js';
|
||||||
|
|
||||||
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
export function buildHourlyRows({ forecast, airQuality, location, vehicleType, vehicleVent, vehicleSpeed, buildingType, furColor, utciEnv }) {
|
||||||
@@ -1003,7 +1003,17 @@ function bestRun(arr, pred) {
|
|||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
|
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
|
||||||
const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
|
const PICK_MAX_DAYS = 7; // the whole week, when the whole week is good
|
||||||
|
|
||||||
|
// Said when no day qualifies, phrased for what was actually being judged -
|
||||||
|
// "no settled outdoor windows" is meaningless when the panel was reading a
|
||||||
|
// car cabin.
|
||||||
|
const EMPTY_VALUE = {
|
||||||
|
utciAdj: 'no settled outdoor windows',
|
||||||
|
vehicleT: 'no comfortable cabin windows',
|
||||||
|
indoorT: 'no comfortable indoor windows',
|
||||||
|
furSurfaceT: 'no safe windows for a pet outside',
|
||||||
|
};
|
||||||
|
|
||||||
// ── The four bands ───────────────────────────────────────────────────────
|
// ── The four bands ───────────────────────────────────────────────────────
|
||||||
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that
|
// Each is [ideal, limit]: at the ideal end an hour scores full marks on that
|
||||||
@@ -1020,13 +1030,18 @@ const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist
|
|||||||
// Read off UTCI_BANDS rather than written as numbers, so the panel and the
|
// Read off UTCI_BANDS rather than written as numbers, so the panel and the
|
||||||
// table's colour bands can never drift apart: today that is a 19-27 plateau
|
// table's colour bands can never drift apart: today that is a 19-27 plateau
|
||||||
// ramping out to 15 and 32.
|
// ramping out to 15 and 32.
|
||||||
const bandMax = (label) => UTCI_BANDS.find(b => b.label === label).max;
|
const bandMax = (bands, label) => bands.find(b => b.label === label).max;
|
||||||
const SUNSOAK = {
|
const plateau = (bands) => ({
|
||||||
min: bandMax('Chilly'), // bottom of Cool - score 0
|
min: bandMax(bands, 'Chilly'), // bottom of Cool - score 0
|
||||||
flatLo: bandMax('Cool'), // bottom of Comfortable - score 1
|
flatLo: bandMax(bands, 'Cool'), // bottom of Comfortable - score 1
|
||||||
flatHi: bandMax('Warm'), // top of Warm - score 1
|
flatHi: bandMax(bands, 'Warm'), // top of Warm - score 1
|
||||||
max: bandMax('Caution'), // top of Caution - score 0
|
max: bandMax(bands, 'Caution'), // top of Caution - score 0
|
||||||
};
|
});
|
||||||
|
const SUNSOAK = plateau(UTCI_BANDS);
|
||||||
|
// Pets are judged on fur-surface temperature, which is coloured off PET_BANDS
|
||||||
|
// in the table - reading the plateau off the same array keeps the panel and
|
||||||
|
// the cell colours in step there too.
|
||||||
|
const PET_PLATEAU = plateau(PET_BANDS);
|
||||||
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
|
const BAND_GUST = { ideal: 15, limit: 25 }; // mph
|
||||||
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
|
const BAND_RAIN = { ideal: 0, limit: 25 }; // % chance
|
||||||
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
|
const BAND_SOIL = { ideal: 0.10, limit: 0.25 }; // m3/m3 - dry ground to soggy
|
||||||
@@ -1039,44 +1054,212 @@ function bandScore(v, { ideal, limit }) {
|
|||||||
return Math.max(0, Math.min(1, f));
|
return Math.max(0, Math.min(1, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
function sunsoakScore(v) {
|
function plateauScore(v, pl) {
|
||||||
if (v == null) return null;
|
if (v == null) return null;
|
||||||
if (v >= SUNSOAK.flatLo && v <= SUNSOAK.flatHi) return 1;
|
if (v >= pl.flatLo && v <= pl.flatHi) return 1;
|
||||||
const f = v < SUNSOAK.flatLo
|
const f = v < pl.flatLo
|
||||||
? (v - SUNSOAK.min) / (SUNSOAK.flatLo - SUNSOAK.min)
|
? (v - pl.min) / (pl.flatLo - pl.min)
|
||||||
: (SUNSOAK.max - v) / (SUNSOAK.max - SUNSOAK.flatHi);
|
: (pl.max - v) / (pl.max - pl.flatHi);
|
||||||
return Math.max(0, Math.min(1, f));
|
return Math.max(0, Math.min(1, f));
|
||||||
}
|
}
|
||||||
|
|
||||||
function comfortableHour(r) {
|
// ── What "a good window" means for the active profile ────────────────────
|
||||||
if (!(r.elev > 0)) return false; // daylight only
|
// The panel used to answer one question - "would I enjoy standing outside"
|
||||||
if (r.utciAdj == null) return false;
|
// - whatever profile was selected, which made it useless to the very users
|
||||||
if (r.utciAdj < SUNSOAK.min || r.utciAdj > SUNSOAK.max) return false;
|
// who had told the app what they were doing. It now scores the SAME field the
|
||||||
if ((r.precipProb ?? 0) > BAND_RAIN.limit) return false;
|
// rest of the app is built around for that profile (the day tabs, the dial,
|
||||||
if ((r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
|
// the main table column), so "the week ahead" answers the question the
|
||||||
|
// profile implies: when to drive, when the house is bearable, when to walk
|
||||||
|
// the dog.
|
||||||
|
//
|
||||||
|
// thermal - weight on the profile's main temperature field
|
||||||
|
// rain / gust / soil - the outdoor nuisances, only where they can matter
|
||||||
|
//
|
||||||
|
// Driving keeps rain and gust (both are what makes a journey unpleasant or
|
||||||
|
// unsafe) and drops soil, which says nothing about a road. Indoors drops all
|
||||||
|
// three: rain on the roof does not change whether the bedroom is sleepable.
|
||||||
|
// Daylight-only is likewise a solar concern - a cool cabin at 6am is a real
|
||||||
|
// answer to "when should I drive", so vehicle and indoor windows run around
|
||||||
|
// the clock.
|
||||||
|
function bestDayCriteria(profile, variant) {
|
||||||
|
const { mainField } = deriveProfileMain(profile, variant);
|
||||||
|
switch (mainField) {
|
||||||
|
case 'vehicleT':
|
||||||
|
return { field: 'vehicleT', pl: SUNSOAK, daylightOnly: false,
|
||||||
|
w: { thermal: 0.55, rain: 0.25, gust: 0.20, soil: 0 } };
|
||||||
|
case 'indoorT':
|
||||||
|
return { field: 'indoorT', pl: SUNSOAK, daylightOnly: false,
|
||||||
|
w: { thermal: 1, rain: 0, gust: 0, soil: 0 } };
|
||||||
|
case 'furSurfaceT':
|
||||||
|
return { field: 'furSurfaceT', pl: PET_PLATEAU, daylightOnly: true,
|
||||||
|
w: { thermal: 0.55, rain: 0.30, gust: 0.15, soil: 0 } };
|
||||||
|
default:
|
||||||
|
return { field: 'utciAdj', pl: SUNSOAK, daylightOnly: true,
|
||||||
|
w: { thermal: 0.40, rain: 0.25, gust: 0.20, soil: 0.15 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function comfortableHour(r, crit) {
|
||||||
|
if (crit.daylightOnly && !(r.elev > 0)) return false;
|
||||||
|
const t = r[crit.field];
|
||||||
|
if (t == null) return false;
|
||||||
|
if (t < crit.pl.min || t > crit.pl.max) return false;
|
||||||
|
// A measure that carries no weight for this profile can't exclude an hour
|
||||||
|
// either, or an indoor window would still be cut short by a shower.
|
||||||
|
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) return false;
|
||||||
|
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) return false;
|
||||||
// Soil comes from a separate feed and is null wherever that fetch fails or
|
// Soil comes from a separate feed and is null wherever that fetch fails or
|
||||||
// the model has no coverage - treat missing as passing, or a soil outage
|
// the model has no coverage - treat missing as passing, or a soil outage
|
||||||
// would silently empty the panel everywhere. Only the wet end excludes: dust
|
// would silently empty the panel everywhere. Only the wet end excludes: dust
|
||||||
// -dry ground is a poor day out for the garden, not for the person on it.
|
// -dry ground is a poor day out for the garden, not for the person on it.
|
||||||
if (r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
|
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each measure's own 0-1 score for one hour, keyed so the same numbers can be
|
||||||
|
// both averaged into a quality score and read back afterwards to say WHICH
|
||||||
|
// measure cost the day its marks.
|
||||||
|
function hourParts(r, crit) {
|
||||||
|
return {
|
||||||
|
thermal: plateauScore(r[crit.field], crit.pl),
|
||||||
|
rain: bandScore(r.precipProb ?? 0, BAND_RAIN),
|
||||||
|
gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST),
|
||||||
|
soil: bandScore(r.soilM, BAND_SOIL),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each
|
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each
|
||||||
// measure actually decides whether the day was worth going out for.
|
// measure actually decides whether the day was worth going out for.
|
||||||
function hourQuality(r) {
|
function hourQuality(r, crit) {
|
||||||
const parts = [
|
const p = hourParts(r, crit);
|
||||||
[sunsoakScore(r.utciAdj), 0.40],
|
const parts = Object.keys(p)
|
||||||
[bandScore(r.precipProb ?? 0, BAND_RAIN), 0.25],
|
.map(k => [p[k], crit.w[k]])
|
||||||
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), 0.20],
|
.filter(([v, w]) => v != null && w > 0);
|
||||||
[bandScore(r.soilM, BAND_SOIL), 0.15],
|
|
||||||
].filter(([v]) => v != null);
|
|
||||||
const wsum = parts.reduce((s, [, w]) => s + w, 0);
|
const wsum = parts.reduce((s, [, w]) => s + w, 0);
|
||||||
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
|
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeBestDay(weekDays, nowLocalISO) {
|
// ── Why a day scored what it scored ──────────────────────────────────────
|
||||||
|
// A calendar icon on every row said nothing the day name had not already
|
||||||
|
// said. The icon now names the single biggest reason the day is not a
|
||||||
|
// hundred, so a column of scores can be read without opening each day: three
|
||||||
|
// windy days and one hot one is a different week from four wet ones.
|
||||||
|
//
|
||||||
|
// The icon is always a weather influence - heat, cold, rain, wind, wet ground
|
||||||
|
// - never "the window was short". Length is a symptom: the window is short
|
||||||
|
// BECAUSE the morning was cold or it rained after three, and the times beside
|
||||||
|
// the icon already say how long it is. So when marks are lost to length, the
|
||||||
|
// hours outside the window are asked what ruled them out and that is what the
|
||||||
|
// row shows.
|
||||||
|
// One vocabulary for both routes to a cause - hours inside the window scoring
|
||||||
|
// poorly, and hours outside it being ruled out - so the same influence always
|
||||||
|
// reads the same way whichever way it cost the day marks.
|
||||||
|
const SHORTFALL_ICON = {
|
||||||
|
rain: { icon: '🌧️', name: 'Rain' },
|
||||||
|
gust: { icon: '💨', name: 'Wind' },
|
||||||
|
soil: { icon: '💧', name: 'Wet ground' },
|
||||||
|
hot: { icon: '🔥', name: 'Heat' },
|
||||||
|
cold: { icon: '🥶', name: 'Cold' },
|
||||||
|
none: { icon: '✅', name: 'Nothing much against it' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The tooltip is the influence and what it cost, in points of the day's own
|
||||||
|
// score: "Heat = 34%" says both what is wrong and how much it matters, so a
|
||||||
|
// 90% row and a 60% row with the same icon are not read as the same warning.
|
||||||
|
const causeTip = (cause) =>
|
||||||
|
cause.cost ? `${cause.name} = ${cause.cost}%` : cause.name;
|
||||||
|
|
||||||
|
// Why one hour failed to qualify. An hour can fail on more than one count -
|
||||||
|
// a cold wet morning is both - so every breach is returned and the day is
|
||||||
|
// decided on which comes up most, not on which happens to be tested first.
|
||||||
|
function failReasons(r, crit) {
|
||||||
|
const out = [];
|
||||||
|
const t = r[crit.field];
|
||||||
|
if (t == null) return out;
|
||||||
|
if (t < crit.pl.min) out.push('cold');
|
||||||
|
if (t > crit.pl.max) out.push('hot');
|
||||||
|
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain');
|
||||||
|
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust');
|
||||||
|
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What kept the rest of the day out of the window. Returns null when those
|
||||||
|
// hours give no reason at all - dark hours for a daylight profile, or a gap
|
||||||
|
// in the data - so the caller can fall back to the in-window measures rather
|
||||||
|
// than print a non-answer.
|
||||||
|
function shortBecause(c, crit) {
|
||||||
|
const inRun = new Set(c.run.map(r => r.iso));
|
||||||
|
const tally = {};
|
||||||
|
for (const r of c.usableRows) {
|
||||||
|
if (inRun.has(r.iso)) continue;
|
||||||
|
for (const k of failReasons(r, crit)) tally[k] = (tally[k] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
// Cost is attached by the caller: those hours were ruled out entirely, so
|
||||||
|
// what this influence cost is the whole of the length shortfall.
|
||||||
|
const worst = Object.keys(tally).sort((a, b) => tally[b] - tally[a])[0];
|
||||||
|
return worst ? SHORTFALL_ICON[worst] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below this the day is as good as the bands allow and picking a "cause"
|
||||||
|
// would be inventing one: at five points the strongest complaint about the
|
||||||
|
// day is worth a twentieth of its score, which is noise, not a reason to
|
||||||
|
// stay in. Set higher and genuinely breezy days went out labelled faultless.
|
||||||
|
const SHORTFALL_FLOOR = 0.05;
|
||||||
|
|
||||||
|
// Marks lost, as whole points of the 0-100 score the row already shows, so
|
||||||
|
// the tooltip's number and the bar's number are in the same units.
|
||||||
|
const pts = (frac) => Math.round(frac * 100);
|
||||||
|
|
||||||
|
function limitingFactor(c, crit, span) {
|
||||||
|
const run = c.run;
|
||||||
|
// Mean score per measure across the run, so one bad hour in nine can't
|
||||||
|
// name the day.
|
||||||
|
const sums = {}, counts = {};
|
||||||
|
for (const r of run) {
|
||||||
|
const p = hourParts(r, crit);
|
||||||
|
for (const k of Object.keys(p)) {
|
||||||
|
if (p[k] == null || !crit.w[k]) continue;
|
||||||
|
sums[k] = (sums[k] ?? 0) + p[k];
|
||||||
|
counts[k] = (counts[k] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wsum = Object.keys(counts).reduce((s, k) => s + crit.w[k], 0);
|
||||||
|
|
||||||
|
// Marks lost, in points of the final score: quality is half of it, split
|
||||||
|
// between the measures by weight; window length is the other half.
|
||||||
|
const lost = [['short', 0.5 * (1 - span)]];
|
||||||
|
for (const k of Object.keys(counts)) {
|
||||||
|
const mean = sums[k] / counts[k];
|
||||||
|
lost.push([k, 0.5 * (crit.w[k] / wsum) * (1 - mean)]);
|
||||||
|
}
|
||||||
|
lost.sort((a, b) => b[1] - a[1]);
|
||||||
|
if (lost[0][1] < SHORTFALL_FLOOR) return SHORTFALL_ICON.none;
|
||||||
|
|
||||||
|
// Length lost the most marks: name what ruled the other hours out. If they
|
||||||
|
// can't say, drop through to whichever measure was weakest inside the
|
||||||
|
// window - still an influence, which is the whole point of the icon.
|
||||||
|
if (lost[0][0] === 'short') {
|
||||||
|
const because = shortBecause(c, crit);
|
||||||
|
if (because) return { ...because, cost: pts(lost[0][1]) };
|
||||||
|
lost.shift();
|
||||||
|
if (lost.length === 0) return SHORTFALL_ICON.none;
|
||||||
|
}
|
||||||
|
const [worst, amount] = lost[0];
|
||||||
|
const cost = pts(amount);
|
||||||
|
|
||||||
|
// Thermal has two opposite causes and they want opposite icons - a cold
|
||||||
|
// morning and a scorching afternoon are not the same warning.
|
||||||
|
if (worst === 'thermal') {
|
||||||
|
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
|
||||||
|
return { ...(meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold), cost };
|
||||||
|
}
|
||||||
|
return { ...SHORTFALL_ICON[worst], cost };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
||||||
if (!weekDays || weekDays.length === 0) return [];
|
if (!weekDays || weekDays.length === 0) return [];
|
||||||
|
const crit = bestDayCriteria(profile, variant);
|
||||||
|
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
for (const day of weekDays.slice(0, 7)) {
|
for (const day of weekDays.slice(0, 7)) {
|
||||||
@@ -1088,7 +1271,7 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
|
|
||||||
let start = -1, len = 0, dayBest = null;
|
let start = -1, len = 0, dayBest = null;
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
if (comfortableHour(rows[i])) {
|
if (comfortableHour(rows[i], crit)) {
|
||||||
if (start < 0) start = i;
|
if (start < 0) start = i;
|
||||||
len++;
|
len++;
|
||||||
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
|
if (!dayBest || len > dayBest.len) dayBest = { start, len, end: i };
|
||||||
@@ -1102,21 +1285,28 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
// temperate summer week half of them qualify dawn to dusk, and picking
|
// temperate summer week half of them qualify dawn to dusk, and picking
|
||||||
// arbitrarily among those is a coin toss.
|
// arbitrarily among those is a coin toss.
|
||||||
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
const run = rows.slice(dayBest.start, dayBest.end + 1);
|
||||||
const quality = run.reduce((s, r) => s + hourQuality(r), 0) / run.length;
|
const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;
|
||||||
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
len: dayBest.len, quality, key: day.key,
|
len: dayBest.len, quality, key: day.key, run,
|
||||||
from: rows[dayBest.start], to: rows[dayBest.end],
|
from: rows[dayBest.start], to: rows[dayBest.end],
|
||||||
daylight: rows.filter(r => r.elev > 0).length,
|
// What the window is measured AGAINST - and, for the hours outside the
|
||||||
|
// run, the evidence for WHY it stopped where it did: the daylight for a
|
||||||
|
// profile that
|
||||||
|
// only counts daylight hours, otherwise the whole day. Using daylight
|
||||||
|
// for a round-the-clock profile would let a 14-hour overnight window
|
||||||
|
// score over 100% of a 16-hour day and print "all day" for a spell that
|
||||||
|
// ends at breakfast.
|
||||||
|
usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Nothing qualifying is itself worth saying - an empty panel reads as a bug,
|
// Nothing qualifying is itself worth saying - an empty panel reads as a bug,
|
||||||
// and "don't bother this week" is a real answer to the question being asked.
|
// and "don't bother this week" is a real answer to the question being asked.
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return [{
|
return [{
|
||||||
icon: '🌧️',
|
icon: crit.field === 'utciAdj' ? '🌧️' : '🚫',
|
||||||
label: 'Next 7 days',
|
label: 'Next 7 days',
|
||||||
value: 'no settled outdoor windows',
|
value: EMPTY_VALUE[crit.field] ?? EMPTY_VALUE.utciAdj,
|
||||||
alert: false,
|
alert: false,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -1132,12 +1322,19 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
// window of perfect ones are both worth knowing about, and letting either
|
// window of perfect ones are both worth knowing about, and letting either
|
||||||
// half dominate hides one of them.
|
// half dominate hides one of them.
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
const span = c.daylight > 0 ? Math.min(c.len / c.daylight, 1) : 0;
|
const span = c.usableRows.length > 0
|
||||||
|
? Math.min(c.len / c.usableRows.length, 1)
|
||||||
|
: 0;
|
||||||
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
|
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
|
||||||
|
// Same span the score used, so the icon can never blame the weather for
|
||||||
|
// marks that were actually lost to a short window.
|
||||||
|
c.cause = limitingFactor(c, crit, span);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the top few are kept: past four rows the panel stops being a
|
// A settled week really can have seven good days, and cutting it to a
|
||||||
// shortlist and turns back into the day tabs.
|
// shortlist there would say the opposite of what the forecast shows. Only
|
||||||
|
// days that actually qualify get a row, so the panel still stays short in
|
||||||
|
// an unsettled week - the cap is the week itself, not an arbitrary four.
|
||||||
const shown = [...candidates]
|
const shown = [...candidates]
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, PICK_MAX_DAYS)
|
.slice(0, PICK_MAX_DAYS)
|
||||||
@@ -1160,12 +1357,15 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
|
|
||||||
// A run spanning nearly all the daylight is better said than shown: printing
|
// A run spanning nearly all the daylight is better said than shown: printing
|
||||||
// "6am - 9pm" makes the reader parse a time range to learn "all day".
|
// "6am - 9pm" makes the reader parse a time range to learn "all day".
|
||||||
const window = c.daylight > 0 && c.len >= c.daylight - 1
|
const window = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1
|
||||||
? 'all day'
|
? 'all day'
|
||||||
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
: `${hh(c.from.iso)} – ${hh(endIso)}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon: '📅',
|
// Not a calendar - the row already says which day. The icon carries the
|
||||||
|
// one thing the score cannot: what is holding the day back.
|
||||||
|
icon: c.cause.icon,
|
||||||
|
iconTitle: causeTip(c.cause),
|
||||||
label: dayName,
|
label: dayName,
|
||||||
value: window,
|
value: window,
|
||||||
// Rows stay in date order - the order you plan in - so the ranking is
|
// Rows stay in date order - the order you plan in - so the ranking is
|
||||||
@@ -1182,9 +1382,29 @@ export function computeBestDay(weekDays, nowLocalISO) {
|
|||||||
// Header line for the panel: names what is being judged when a variant makes
|
// Header line for the panel: names what is being judged when a variant makes
|
||||||
// that non-obvious, otherwise the plain framing.
|
// that non-obvious, otherwise the plain framing.
|
||||||
export function bestDaysLabel(profile, variant) {
|
export function bestDaysLabel(profile, variant) {
|
||||||
|
const { mainField, mainLabel } = deriveProfileMain(profile, variant);
|
||||||
|
// The vehicle and home profiles have no variant to name, but "best times
|
||||||
|
// out" would be plainly wrong for them - the panel is now reading the cabin
|
||||||
|
// and the room, not the pavement.
|
||||||
|
if (profile === 'vehicle') return 'Best times to drive';
|
||||||
|
if (profile === 'home') return 'Best times indoors';
|
||||||
|
if (profile === 'pets') return 'Best times for pets';
|
||||||
return profile === 'outdoors' && variant
|
return profile === 'outdoors' && variant
|
||||||
? `Best times for ${deriveProfileMain(profile, variant).mainLabel}`
|
? `Best times for ${mainLabel}`
|
||||||
: 'Best times out';
|
: (mainField === 'utciAdj' ? 'Best times out' : `Best times for ${mainLabel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip on the score bar. It has to name the field being scored, or a
|
||||||
|
// driver reads "temperatures nearer 20 °C" against the outside air and
|
||||||
|
// concludes the panel is broken.
|
||||||
|
export function bestDaysHint(profile, variant, score) {
|
||||||
|
const { mainField } = deriveProfileMain(profile, variant);
|
||||||
|
const what = {
|
||||||
|
vehicleT: 'an ideal driving day — longer stretches with a comfortable cabin score higher',
|
||||||
|
indoorT: 'an ideal day indoors — longer stretches with the building in comfortable range score higher',
|
||||||
|
furSurfaceT: 'an ideal day for a pet outside — longer stretches with fur and ground temperatures in safe range score higher',
|
||||||
|
}[mainField] ?? 'an ideal day out — longer comfortable stretches and temperatures nearer 20 °C score higher';
|
||||||
|
return `${score}% of ${what}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeCropAdvice(weekDays, lat) {
|
export function computeCropAdvice(weekDays, lat) {
|
||||||
|
|||||||
+31
-34
@@ -32,25 +32,25 @@ export const FILTER_PROFILES = {
|
|||||||
// Basic is the "just tell me" preset - it opens in Quick view (see `view`
|
// Basic is the "just tell me" preset - it opens in Quick view (see `view`
|
||||||
// below) with the smallest useful column set behind it.
|
// below) with the smallest useful column set behind it.
|
||||||
view: 'simple',
|
view: 'simple',
|
||||||
cols: { hour: true, air: false, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: false, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
icon: '🏠',
|
icon: '🏠',
|
||||||
scene: "url('assets/images/profiles/home.png') center / cover no-repeat, linear-gradient(160deg,#b0d890,#d0ecb8)",
|
scene: "url('assets/images/profiles/home.png') center / cover no-repeat, linear-gradient(160deg,#b0d890,#d0ecb8)",
|
||||||
cols: { hour: true, air: true, shadeT: false, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: false, rh: true, dew: true, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: true, managedT: true, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
vehicle: {
|
vehicle: {
|
||||||
label: 'Vehicle',
|
label: 'Vehicle',
|
||||||
icon: '🚗',
|
icon: '🚗',
|
||||||
scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#a8bcc8,#ccd8e4)",
|
scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#a8bcc8,#ccd8e4)",
|
||||||
cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
pets: {
|
pets: {
|
||||||
label: 'Pets',
|
label: 'Pets',
|
||||||
icon: '🐾',
|
icon: '🐾',
|
||||||
scene: "url('assets/images/profiles/pets.png') center / cover no-repeat, linear-gradient(160deg,#e0c0a0,#f0ddc8)",
|
scene: "url('assets/images/profiles/pets.png') center / cover no-repeat, linear-gradient(160deg,#e0c0a0,#f0ddc8)",
|
||||||
cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true },
|
cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true },
|
||||||
},
|
},
|
||||||
farming: {
|
farming: {
|
||||||
label: 'Farming',
|
label: 'Farming',
|
||||||
@@ -59,7 +59,7 @@ export const FILTER_PROFILES = {
|
|||||||
// Farming sits in the Work menu alongside the outdoors variants, so it
|
// Farming sits in the Work menu alongside the outdoors variants, so it
|
||||||
// opens the detailed table the same way they do.
|
// opens the detailed table the same way they do.
|
||||||
view: 'table',
|
view: 'table',
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: true, soilT6: true, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
outdoors: {
|
outdoors: {
|
||||||
label: 'Places',
|
label: 'Places',
|
||||||
@@ -67,21 +67,21 @@ export const FILTER_PROFILES = {
|
|||||||
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
|
scene: "url('assets/images/profiles/places.png') center / cover no-repeat, linear-gradient(160deg,#98ccc0,#c4e4dc)",
|
||||||
view: 'table',
|
view: 'table',
|
||||||
// Default cols match the first variant (urban). Switching variant updates visibleCols.
|
// Default cols match the first variant (urban). Switching variant updates visibleCols.
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
alltemps: {
|
alltemps: {
|
||||||
label: 'Temps',
|
label: 'Temps',
|
||||||
icon: '🌡️',
|
icon: '🌡️',
|
||||||
scene: "url('assets/images/profiles/temps.png') center / cover no-repeat, linear-gradient(160deg,#d8b0b0,#ecd0c8)",
|
scene: "url('assets/images/profiles/temps.png') center / cover no-repeat, linear-gradient(160deg,#d8b0b0,#ecd0c8)",
|
||||||
proOnly: true,
|
proOnly: true,
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, precipProb: false, lightning: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: false, precipProb: false, soilT: true, soilT6: false, soilM: false, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
showall: {
|
showall: {
|
||||||
label: 'Show All',
|
label: 'Show All',
|
||||||
icon: '⚙️',
|
icon: '⚙️',
|
||||||
scene: "url('assets/images/profiles/all.png') center / cover no-repeat, linear-gradient(160deg,#b4b4d4,#d4d4ec)",
|
scene: "url('assets/images/profiles/all.png') center / cover no-repeat, linear-gradient(160deg,#b4b4d4,#d4d4ec)",
|
||||||
proOnly: true,
|
proOnly: true,
|
||||||
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: true, diffuse: true, tmrt: true, delta: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: true, soilT6: true, soilM: true, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: true, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true },
|
cols: { hour: true, air: true, shadeT: true, rh: true, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: true, diffuse: true, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: true, soilT6: true, soilM: true, concreteT: true, vehicleT: true, indoorT: true, managedT: true, vis: true, aqi: true, pollen: true, furSurfaceT: true, pawT: true, petShadeT: true, petHomeT: true },
|
||||||
},
|
},
|
||||||
custom: {
|
custom: {
|
||||||
label: 'Custom',
|
label: 'Custom',
|
||||||
@@ -89,39 +89,39 @@ export const FILTER_PROFILES = {
|
|||||||
scene: "url('assets/images/profiles/custom.png') center / cover no-repeat, linear-gradient(160deg,#d8b0cc,#ecd0e4)",
|
scene: "url('assets/images/profiles/custom.png') center / cover no-repeat, linear-gradient(160deg,#d8b0cc,#ecd0e4)",
|
||||||
proOnly: true,
|
proOnly: true,
|
||||||
// Blank slate - only Hour is on. User enables whichever columns they want.
|
// Blank slate - only Hour is on. User enables whichever columns they want.
|
||||||
cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: false, precipProb: false, lightning: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
cols: { hour: true, air: false, shadeT: false, rh: false, dew: false, wind: false, dir: false, cloud: false, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: false, precipProb: false, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Places sub-variants - each has its own column set.
|
// Places sub-variants - each has its own column set.
|
||||||
// Selecting a variant applies its cols to visibleCols.
|
// Selecting a variant applies its cols to visibleCols.
|
||||||
export const OUTDOORS_VARIANTS = {
|
export const OUTDOORS_VARIANTS = {
|
||||||
urban: { name: 'Urban', scene: "url('assets/images/profiles/urban.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
urban: { name: 'Urban', scene: "url('assets/images/profiles/urban.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
beach: { name: 'Beach', scene: "url('assets/images/profiles/beach.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
beach: { name: 'Beach', scene: "url('assets/images/profiles/beach.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
events: { name: 'Events', scene: "url('assets/images/profiles/events.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
events: { name: 'Events', scene: "url('assets/images/profiles/events.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: true, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
||||||
festival: { name: 'Festival', scene: "url('assets/images/profiles/festival.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
|
festival: { name: 'Festival', scene: "url('assets/images/profiles/festival.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: true, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
|
||||||
wintersports: { name: 'Winter Sports', proOnly: true, scene: "url('assets/images/profiles/winter.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
wintersports: { name: 'Winter Sports', proOnly: true, scene: "url('assets/images/profiles/winter.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: false, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
naturist: { name: 'Naturist', proOnly: true, scene: "url('assets/images/profiles/naturist.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
|
naturist: { name: 'Naturist', proOnly: true, scene: "url('assets/images/profiles/naturist.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: false, cloud: true, sun: true, direct: false, diffuse: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: true, pollen: true } },
|
||||||
sailing: { name: 'Sailing', proOnly: true, scene: "url('assets/images/profiles/sailing.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
sailing: { name: 'Sailing', proOnly: true, scene: "url('assets/images/profiles/sailing.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
// Places - free
|
// Places - free
|
||||||
park: { name: 'Park / Picnic', scene: "url('assets/images/profiles/park.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
park: { name: 'Park / Picnic', scene: "url('assets/images/profiles/park.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
||||||
forest: { name: 'Forest / Woodland', scene: "url('assets/images/profiles/forest.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: true } },
|
forest: { name: 'Forest / Woodland', scene: "url('assets/images/profiles/forest.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: true } },
|
||||||
airport: { name: 'Airport / Travel', scene: "url('assets/images/profiles/airport.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
airport: { name: 'Airport / Travel', scene: "url('assets/images/profiles/airport.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
// Places - Pro
|
// Places - Pro
|
||||||
construction: { name: 'Construction', proOnly: true, scene: "url('assets/images/profiles/construction.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
construction: { name: 'Construction', proOnly: true, scene: "url('assets/images/profiles/construction.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: true, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
// Activities - free
|
// Activities - free
|
||||||
cycling: { name: 'Cycling', scene: "url('assets/images/profiles/cycling.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
cycling: { name: 'Cycling', scene: "url('assets/images/profiles/cycling.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
running: { name: 'Running', scene: "url('assets/images/profiles/running.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
running: { name: 'Running', scene: "url('assets/images/profiles/running.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: true, uvA: false, uvB: false, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
||||||
dogwalk: { name: 'Dog Walking', scene: "url('assets/images/profiles/walking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
dogwalk: { name: 'Dog Walking', scene: "url('assets/images/profiles/walking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: false, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: false, aqi: false, pollen: true } },
|
||||||
// Activities - Pro
|
// Activities - Pro
|
||||||
hiking: { name: 'Hiking', proOnly: true, scene: "url('assets/images/profiles/hiking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, tmrt: false, delta: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
hiking: { name: 'Hiking', proOnly: true, scene: "url('assets/images/profiles/hiking.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: true, direct: false, diffuse: false, utci: true, uvA: true, uvB: true, burn: true, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
photography: { name: 'Photography', proOnly: true, scene: "url('assets/images/profiles/photography.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
photography: { name: 'Photography', proOnly: true, scene: "url('assets/images/profiles/photography.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: true, utci: false, uvA: false, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
fishing: { name: 'Fishing', proOnly: true, scene: "url('assets/images/profiles/fishing.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
fishing: { name: 'Fishing', proOnly: true, scene: "url('assets/images/profiles/fishing.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: true, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
// Work variants
|
// Work variants
|
||||||
market: { name: 'Market Trading', proOnly: true, scene: "url('assets/images/profiles/market.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
market: { name: 'Market Trading', proOnly: true, scene: "url('assets/images/profiles/market.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: true, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
windowcleaning: { name: 'Window Cleaning', proOnly: true, scene: "url('assets/images/profiles/window.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
windowcleaning: { name: 'Window Cleaning', proOnly: true, scene: "url('assets/images/profiles/window.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
office: { name: 'Office', proOnly: true, scene: "url('assets/images/profiles/office.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: false, tmrt: false, delta: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: true, vis: false, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
office: { name: 'Office', proOnly: true, scene: "url('assets/images/profiles/office.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: true, dew: false, wind: false, dir: false, cloud: true, sun: true, direct: true, diffuse: false, utci: false, uvA: false, uvB: false, burn: false, utciP: true, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: false, indoorT: false, managedT: true, vis: false, aqi: true, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
driver: { name: 'Driver / Trucker', proOnly: true, scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, tmrt: false, delta: false, utci: false, uvA: true, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, lightning: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
driver: { name: 'Driver / Trucker', proOnly: true, scene: "url('assets/images/profiles/vehicle.png') center / cover no-repeat, linear-gradient(160deg,#c2d4cc,#e2ece6)", cols: { hour: true, air: true, shadeT: false, rh: false, dew: false, wind: true, dir: true, cloud: true, sun: false, direct: false, diffuse: false, utci: false, uvA: true, uvB: false, burn: false, utciP: false, precip: true, precipProb: true, soilT: false, soilT6: false, soilM: false, concreteT: false, vehicleT: true, indoorT: false, managedT: false, vis: true, aqi: false, pollen: false, furSurfaceT: false, pawT: false, petShadeT: false, petHomeT: false } },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pollen type options shown in the pulldown selector.
|
// Pollen type options shown in the pulldown selector.
|
||||||
@@ -279,16 +279,13 @@ export const COL_DESCRIPTIONS = {
|
|||||||
sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.', short: "The angle of the sun above the horizon. Below 0° the sun has set; higher angles mean stronger radiation.", link: "./columns.html#col-sun" },
|
sun: { title: 'Sun Elevation', desc: 'The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the solar radiation reaching the ground.', short: "The angle of the sun above the horizon. Below 0° the sun has set; higher angles mean stronger radiation.", link: "./columns.html#col-sun" },
|
||||||
direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.', short: "Solar radiation arriving in a direct beam from the sun (W/m²) — the main driver of skin heating on sunny days.", link: "./columns.html#col-direct" },
|
direct: { title: 'Direct Radiation', desc: 'Shortwave solar radiation arriving in a direct beam from the sun (W/m²). The primary driver of skin heating on sunny days.', short: "Solar radiation arriving in a direct beam from the sun (W/m²) — the main driver of skin heating on sunny days.", link: "./columns.html#col-direct" },
|
||||||
diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.', short: "Solar radiation scattered across the sky (W/m²). Present even under cloud, and adds to the total solar load.", link: "./columns.html#col-diffuse" },
|
diffuse: { title: 'Diffuse Radiation', desc: 'Scattered solar radiation arriving from all directions across the sky (W/m²). Present even under cloud; contributes to overall solar load.', short: "Solar radiation scattered across the sky (W/m²). Present even under cloud, and adds to the total solar load.", link: "./columns.html#col-diffuse" },
|
||||||
tmrt: { title: 'Mean Radiant Temp', desc: 'The combined heat from direct sun, scattered sky light, and ground reflection expressed as a temperature. Typically well above air temperature even on overcast days, and is one of the inputs used to calculate UTCI.', short: "Heat from direct sun, sky light and ground reflection expressed as a temperature. One of the inputs to UTCI.", link: "./columns.html#col-tmrt" },
|
|
||||||
delta: { title: 'UTCI − Air Delta', desc: 'The difference between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.', short: "The gap between UTCI felt temperature and plain air temperature. A big positive value means the sun is adding real heat stress.", link: "./columns.html#col-delta" },
|
|
||||||
utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does NOT move when you change the Solar Model, and it does not include precipitation. That is the point of it — a fixed benchmark to read SunSoak against, so the gap between the two columns shows exactly what your surroundings and the weather are adding.', short: "The peer-reviewed standard felt temperature. Always taken from the raw open-ground reading, so it stays fixed as a benchmark to read SunSoak against.", link: "./columns.html#col-utci" },
|
utci: { title: 'UTCI', desc: 'Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does NOT move when you change the Solar Model, and it does not include precipitation. That is the point of it — a fixed benchmark to read SunSoak against, so the gap between the two columns shows exactly what your surroundings and the weather are adding.', short: "The peer-reviewed standard felt temperature. Always taken from the raw open-ground reading, so it stays fixed as a benchmark to read SunSoak against.", link: "./columns.html#col-utci" },
|
||||||
uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.', short: "Estimated UV-A index. UV-A reaches deeper into the skin and drives long-term ageing, even through glass.", link: "./columns.html#col-uvA" },
|
uvA: { title: 'UV-A Index', desc: 'Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass.', short: "Estimated UV-A index. UV-A reaches deeper into the skin and drives long-term ageing, even through glass.", link: "./columns.html#col-uvA" },
|
||||||
uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.', short: "Estimated UV-B index. UV-B causes sunburn and drives vitamin D production.", link: "./columns.html#col-uvB" },
|
uvB: { title: 'UV-B Index', desc: 'Estimated UV-B radiation index. UV-B causes sunburn and is the main driver of vitamin D production. Intensity depends strongly on solar elevation and cloud cover.', short: "Estimated UV-B index. UV-B causes sunburn and drives vitamin D production.", link: "./columns.html#col-uvB" },
|
||||||
burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.', short: "Estimated time to sunburn (one MED) for your selected skin type. A guide, not a medical measurement.", link: "./columns.html#col-burn" },
|
burn: { title: 'Burn Time', desc: 'Estimated time to reach one Minimal Erythemal Dose (MED) — the threshold for sunburn — based on the UV index and your selected skin type. This is a guide, not a medical measurement.', short: "Estimated time to sunburn (one MED) for your selected skin type. A guide, not a medical measurement.", link: "./columns.html#col-burn" },
|
||||||
utciP: { title: 'SunSoak', desc: 'SunScope\'s felt-temperature index. Based on UTCI — the standard combining air temp, humidity, wind, and solar radiation — with added adjustments for rain/snow and your chosen surroundings.', short: "SunScope's felt-temperature index: UTCI plus adjustments for rain or snow and your chosen surroundings.", link: "./columns.html#col-utciP" },
|
utciP: { title: 'SunSoak', desc: 'SunScope\'s felt-temperature index. Based on UTCI — the standard combining air temp, humidity, wind, and solar radiation — with added adjustments for rain/snow and your chosen surroundings.', short: "SunScope's felt-temperature index: UTCI plus adjustments for rain or snow and your chosen surroundings.", link: "./columns.html#col-utciP" },
|
||||||
precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.', short: "Expected rainfall in mm per hour, or snow in cm. Even drizzle cuts felt temperature noticeably in wind.", link: "./columns.html#col-precip" },
|
precip: { title: 'Precipitation', desc: 'Expected rainfall or snowfall in mm per hour. Snow is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.', short: "Expected rainfall in mm per hour, or snow in cm. Even drizzle cuts felt temperature noticeably in wind.", link: "./columns.html#col-precip" },
|
||||||
precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss.', short: "Chance of precipitation this hour, from ensemble model runs. Useful for spotting showers the main forecast misses.", link: "./columns.html#col-precipProb" },
|
precipProb: { title: 'Rain Probability', desc: 'Probability of precipitation for this hour as a percentage (0-100%). Derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low - useful for spotting showers that the deterministic forecast may miss. A ⚡ marks a thundery hour: it appears once the Lightning Potential Index passes 5 J/kg (moderate risk) and turns bold above 25 (high risk), tinting the cell amber.', short: "Chance of precipitation this hour, from ensemble model runs. A ⚡ marks hours with lightning potential.", link: "./columns.html#col-precipProb" },
|
||||||
lightning: { title: 'Lightning Potential', desc: 'Lightning Potential Index (LPI) from Open-Meteo in J/kg. A convective energy index — values above 5 suggest moderate lightning risk, above 25 suggest high risk.', short: "Lightning Potential Index in J/kg. Above 5 suggests moderate risk, above 25 high risk.", link: "./columns.html#col-lightning" },
|
|
||||||
soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.', short: "Soil temperature at the surface. Most seeds germinate above 7–10 °C.", link: "./columns.html#col-soilT" },
|
soilT: { title: 'Soil Temperature', desc: 'Temperature of the soil at the surface (0 cm depth). Useful for planting decisions — most seeds germinate above 7–10 °C.', short: "Soil temperature at the surface. Most seeds germinate above 7–10 °C.", link: "./columns.html#col-soilT" },
|
||||||
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.', short: "Soil temperature at 6 cm — the root zone for many crops. Lags the surface by several hours.", link: "./columns.html#col-soilT6" },
|
soilT6: { title: 'Soil Temp 6 cm', desc: 'Temperature of the soil at 6 cm depth, the root zone for many crops and seedlings. Lags behind surface temperature by several hours.', short: "Soil temperature at 6 cm — the root zone for many crops. Lags the surface by several hours.", link: "./columns.html#col-soilT6" },
|
||||||
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.', short: "Water saturation of the top 1 cm of soil. Above 40% is saturated ground; below 20% is dry.", link: "./columns.html#col-soilM" },
|
soilM: { title: 'Soil Moisture', desc: 'Water saturation of the top 1 cm of soil as a percentage. Above 40% suggests saturated ground; below 20% indicates dry conditions.', short: "Water saturation of the top 1 cm of soil. Above 40% is saturated ground; below 20% is dry.", link: "./columns.html#col-soilM" },
|
||||||
|
|||||||
@@ -442,8 +442,12 @@ export function useAppState() {
|
|||||||
if (key !== 'custom') {
|
if (key !== 'custom') {
|
||||||
setVisibleCols({ ...profile.cols });
|
setVisibleCols({ ...profile.cols });
|
||||||
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
const hasIndoor = profile.cols['indoorT'] || profile.cols['managedT'];
|
||||||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
// Must go through the persisting setters: the Indoors pill and column
|
||||||
setIndoorManaged(false);
|
// read indoorMode, and its initialiser reads it back from localStorage
|
||||||
|
// on the next load. Setting only the React state left the stored value
|
||||||
|
// behind, so a reload restored the previous profile's Indoors state.
|
||||||
|
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
|
||||||
|
setIndoorManagedAndSave(false);
|
||||||
if (hasIndoor) setBuildingTypeAndSave('brick');
|
if (hasIndoor) setBuildingTypeAndSave('brick');
|
||||||
const defaultEnv = VARIANT_DEFAULT_ENV[key];
|
const defaultEnv = VARIANT_DEFAULT_ENV[key];
|
||||||
if (defaultEnv) setUtciEnvAndSave(defaultEnv);
|
if (defaultEnv) setUtciEnvAndSave(defaultEnv);
|
||||||
@@ -453,8 +457,8 @@ export function useAppState() {
|
|||||||
if (customCols) {
|
if (customCols) {
|
||||||
setVisibleCols({ ...customCols });
|
setVisibleCols({ ...customCols });
|
||||||
const hasIndoor = customCols['indoorT'] || customCols['managedT'];
|
const hasIndoor = customCols['indoorT'] || customCols['managedT'];
|
||||||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
|
||||||
setIndoorManaged(false);
|
setIndoorManagedAndSave(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -501,7 +505,7 @@ export function useAppState() {
|
|||||||
if (variantCols) {
|
if (variantCols) {
|
||||||
setVisibleCols({ ...variantCols });
|
setVisibleCols({ ...variantCols });
|
||||||
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
|
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
|
||||||
setIndoorMode(hasIndoor ? 'on' : 'off');
|
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
|
||||||
}
|
}
|
||||||
// Auto-set UTCI environment modifier to match the selected variant.
|
// Auto-set UTCI environment modifier to match the selected variant.
|
||||||
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
|
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
|
||||||
@@ -548,11 +552,19 @@ export function useAppState() {
|
|||||||
};
|
};
|
||||||
const [indoorMode, setIndoorMode] = useState(() => {
|
const [indoorMode, setIndoorMode] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('sunscope_indoor_mode');
|
|
||||||
if (saved) return saved;
|
|
||||||
const profile = localStorage.getItem('sunscope_profile') || 'basic';
|
const profile = localStorage.getItem('sunscope_profile') || 'basic';
|
||||||
const cols = FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols;
|
const cols = profile === 'outdoors'
|
||||||
return (cols['indoorT'] || cols['managedT']) ? 'on' : 'off';
|
? (OUTDOORS_VARIANTS[localStorage.getItem('sunscope_outdoors_variant') || 'urban']?.cols
|
||||||
|
?? FILTER_PROFILES.outdoors.cols)
|
||||||
|
: (FILTER_PROFILES[profile]?.cols ?? FILTER_PROFILES.basic.cols);
|
||||||
|
const offered = !!(cols['indoorT'] || cols['managedT']);
|
||||||
|
// A profile that has no Indoors columns has no Indoors pill either, so
|
||||||
|
// an 'on' left over from a profile that did would show the column with
|
||||||
|
// no way to turn it off. The profile decides whether it's available;
|
||||||
|
// the saved value only decides the state within a profile that offers it.
|
||||||
|
if (!offered) return 'off';
|
||||||
|
const saved = localStorage.getItem('sunscope_indoor_mode');
|
||||||
|
return saved || 'on';
|
||||||
} catch (e) { return 'off'; }
|
} catch (e) { return 'off'; }
|
||||||
});
|
});
|
||||||
const setIndoorModeAndSave = (v) => {
|
const setIndoorModeAndSave = (v) => {
|
||||||
@@ -614,6 +626,30 @@ export function useAppState() {
|
|||||||
};
|
};
|
||||||
const openWelcome = () => setWelcomeOpen(true);
|
const openWelcome = () => setWelcomeOpen(true);
|
||||||
|
|
||||||
|
// ── Weekly subscribe nudge (free users only) ─────────────────────────
|
||||||
|
// At most once every 7 days, and never on the first visit - the welcome
|
||||||
|
// popup owns that moment. The last-shown date lives in localStorage;
|
||||||
|
// subscribing (isPro) suppresses it entirely.
|
||||||
|
const NUDGE_KEY = 'sunscope_nudge_last';
|
||||||
|
const NUDGE_COOLDOWN = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const [weeklyNudgeOpen, setWeeklyNudgeOpen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPro || welcomeOpen) return;
|
||||||
|
let last = 0;
|
||||||
|
try {
|
||||||
|
last = Number(localStorage.getItem(NUDGE_KEY)) || 0;
|
||||||
|
// First run for an existing user: start the clock, don't nudge yet.
|
||||||
|
if (!last) { localStorage.setItem(NUDGE_KEY, String(Date.now())); return; }
|
||||||
|
} catch (e) { return; }
|
||||||
|
if (Date.now() - last < NUDGE_COOLDOWN) return;
|
||||||
|
setWeeklyNudgeOpen(true);
|
||||||
|
track('weekly_nudge_shown');
|
||||||
|
}, [isPro, welcomeOpen]);
|
||||||
|
const closeWeeklyNudge = () => {
|
||||||
|
try { localStorage.setItem(NUDGE_KEY, String(Date.now())); } catch (e) { /* ignore */ }
|
||||||
|
setWeeklyNudgeOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Restore-access modal ("Already subscribed?").
|
// Restore-access modal ("Already subscribed?").
|
||||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||||
const openRestore = () => setRestoreOpen(true);
|
const openRestore = () => setRestoreOpen(true);
|
||||||
@@ -643,11 +679,10 @@ export function useAppState() {
|
|||||||
const [tableRotated, setTableRotated] = useState(() => {
|
const [tableRotated, setTableRotated] = useState(() => {
|
||||||
try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; }
|
try { return localStorage.getItem('sunscope_table_rotated') === '1'; } catch (e) { return false; }
|
||||||
});
|
});
|
||||||
const toggleTableRotated = () => setTableRotated(v => {
|
const setTableRotatedAndSave = (next) => {
|
||||||
const next = !v;
|
|
||||||
try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ }
|
try { localStorage.setItem('sunscope_table_rotated', next ? '1' : '0'); } catch (e) { /* ignore */ }
|
||||||
return next;
|
setTableRotated(!!next);
|
||||||
});
|
};
|
||||||
|
|
||||||
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
|
// Table row interval: 1 = every hour, 2/3/4 = clock-aligned buckets. Default 2.
|
||||||
const [tableInterval, setTableIntervalState] = useState(() => {
|
const [tableInterval, setTableIntervalState] = useState(() => {
|
||||||
@@ -899,11 +934,12 @@ export function useAppState() {
|
|||||||
utciEnv, setUtciEnv: setUtciEnvAndSave,
|
utciEnv, setUtciEnv: setUtciEnvAndSave,
|
||||||
showDecimals, toggleShowDecimals,
|
showDecimals, toggleShowDecimals,
|
||||||
welcomeOpen, closeWelcome, openWelcome,
|
welcomeOpen, closeWelcome, openWelcome,
|
||||||
|
weeklyNudgeOpen, closeWeeklyNudge,
|
||||||
restoreOpen, openRestore, closeRestore,
|
restoreOpen, openRestore, closeRestore,
|
||||||
panelOpen, openPanel, closePanel,
|
panelOpen, openPanel, closePanel,
|
||||||
showUnits, toggleShowUnits,
|
showUnits, toggleShowUnits,
|
||||||
tableInterval, setTableInterval,
|
tableInterval, setTableInterval,
|
||||||
tableRotated, toggleTableRotated,
|
tableRotated, setTableRotated: setTableRotatedAndSave,
|
||||||
forecastView, setForecastView,
|
forecastView, setForecastView,
|
||||||
// table refs
|
// table refs
|
||||||
headStickyRef, headTrackRef, headTableRef,
|
headStickyRef, headTrackRef, headTableRef,
|
||||||
|
|||||||
@@ -140,11 +140,10 @@ export function useTableScroll({
|
|||||||
const bodyScroll = bodyScrollRef.current;
|
const bodyScroll = bodyScrollRef.current;
|
||||||
if (!bodyTable || !bodyScroll) return;
|
if (!bodyTable || !bodyScroll) return;
|
||||||
|
|
||||||
// Rotated renders a SINGLE table, so there is no head table and
|
// Fallback for any layout rendered as a single table (no head to
|
||||||
// nothing to keep in step. The scroll-fade still needs to know how
|
// keep in step). Both orientations split head from body now, so this
|
||||||
// wide the pinned column is, though — and it can't be assumed from
|
// is a safety net rather than a path — the scroll-fade still needs
|
||||||
// CSS, because auto layout widens that column to fit the longest
|
// the pinned column's width, so measure that and stop.
|
||||||
// metric name. Measure it, then stop.
|
|
||||||
if (!headTable) {
|
if (!headTable) {
|
||||||
const firstCell = bodyTable.querySelector('tbody tr > *');
|
const firstCell = bodyTable.querySelector('tbody tr > *');
|
||||||
if (firstCell && tableWrapRef && tableWrapRef.current) {
|
if (firstCell && tableWrapRef && tableWrapRef.current) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// nav-account.js - Wires the "Account" nav dropdown on every static page
|
||||||
|
// (about.html, faq.html, dashboard.html, etc.) so it behaves exactly like
|
||||||
|
// the Preact-driven one on the main app (see app.js's Account dropdown):
|
||||||
|
// - Subscription link points at the manage-billing URL if the visitor
|
||||||
|
// already has Extra (localStorage sunscope_pro), else the buy URL.
|
||||||
|
// - Sign in/out reflects the dashboard's own session (api/me.php),
|
||||||
|
// independent of the Extra flag above.
|
||||||
|
//
|
||||||
|
// Plain vanilla JS - these pages have no build step and no framework.
|
||||||
|
// Expects the markup produced by nav-account-menu() in build.js's
|
||||||
|
// injectAccountMenu(), or written by hand to match: a toggle link with
|
||||||
|
// id="nav-account-signinout" for the Sign in/out item.
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
|
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
|
|
||||||
|
const subLink = document.getElementById('nav-account-subscription');
|
||||||
|
if (subLink) {
|
||||||
|
subLink.href = localStorage.getItem('sunscope_pro') === '1' ? MANAGE_URL : SUBSCRIBE_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const signLink = document.getElementById('nav-account-signinout');
|
||||||
|
if (!signLink) return;
|
||||||
|
|
||||||
|
function setSignedOut() {
|
||||||
|
signLink.textContent = 'Sign in';
|
||||||
|
signLink.href = './dashboard.html';
|
||||||
|
signLink.onclick = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSignedIn() {
|
||||||
|
signLink.textContent = 'Sign out';
|
||||||
|
signLink.href = '#';
|
||||||
|
signLink.onclick = function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.then(setSignedOut)
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('./api/me.php', { credentials: 'same-origin' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => (d.user ? setSignedIn() : setSignedOut()))
|
||||||
|
.catch(() => {});
|
||||||
|
})();
|
||||||
+32
-44
@@ -18,7 +18,8 @@
|
|||||||
// label heading text
|
// label heading text
|
||||||
// unit the small print under the heading ("°C", "mm/h", …)
|
// unit the small print under the heading ("°C", "mm/h", …)
|
||||||
// unitNone true → render the unit muted+italic (no data for this day)
|
// unitNone true → render the unit muted+italic (no data for this day)
|
||||||
// group felt | surface | ambient | precip | sky | wind | airqual | solar
|
// group felt | surface | pets | ambient | precip | sky | wind |
|
||||||
|
// airqual | solar
|
||||||
// visible resolved from visibleCols / indoorMode
|
// visible resolved from visibleCols / indoorMode
|
||||||
// headClass extra class on the heading cell ('utci-tight-head', …)
|
// headClass extra class on the heading cell ('utci-tight-head', …)
|
||||||
// cellClass extra class on the data cell ('utci-dir-cell', …)
|
// cellClass extra class on the data cell ('utci-dir-cell', …)
|
||||||
@@ -40,7 +41,18 @@ const html = htm.bind(h);
|
|||||||
// ── COLOUR SCALE ────────────────────────────────────────────────────────
|
// ── COLOUR SCALE ────────────────────────────────────────────────────────
|
||||||
// Continuous temperature colour scale — shared by every temperature column
|
// Continuous temperature colour scale — shared by every temperature column
|
||||||
// and by the thermal-stress legend in app.js.
|
// and by the thermal-stress legend in app.js.
|
||||||
|
// Anchored to match the day tabs' band ramp at the cold end: -30 is the
|
||||||
|
// Extreme cold anchor, -20 the Arctic one and -10 the Freezing one, the same
|
||||||
|
// three points bandRampRgb() uses (see BAND COLOUR RAMP in utils.js). The
|
||||||
|
// scale used to stop at -10, so every reading below it clamped to one flat
|
||||||
|
// blue — Invercargill at -28 C coloured identically to -10 C in the table,
|
||||||
|
// and the whole Arctic and Extreme cold range was invisible in Quick,
|
||||||
|
// Detailed and Table alike. Pet columns inherit the fix for free: they route
|
||||||
|
// through petEquivHumanTemp(), which already maps pet -28/-18 onto human
|
||||||
|
// -20/-10 but had nothing to look up down there.
|
||||||
export const TEMP_STOPS = [
|
export const TEMP_STOPS = [
|
||||||
|
[-30, [125, 80, 180]],
|
||||||
|
[-20, [ 85, 125, 205]],
|
||||||
[-10, [ 90, 155, 220]],
|
[-10, [ 90, 155, 220]],
|
||||||
[ 0, [140, 195, 235]],
|
[ 0, [140, 195, 235]],
|
||||||
[ 10, [155, 215, 195]],
|
[ 10, [155, 215, 195]],
|
||||||
@@ -163,14 +175,6 @@ const soilMoistureBg = (v) => {
|
|||||||
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
|
return { bg: `rgba(${r},${g},${b},0.18)`, fg: '#3a2a10' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const deltaBg = (v) => {
|
|
||||||
if (v == null || isNaN(v)) return 'transparent';
|
|
||||||
if (Math.abs(v) < 1) return 'transparent';
|
|
||||||
const x = Math.min(1, Math.abs(v) / 12);
|
|
||||||
const a = 0.035 + x * 0.13;
|
|
||||||
return v > 0 ? `rgba(230,140,50,${a.toFixed(3)})` : `rgba(80,135,210,${a.toFixed(3)})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const burnBg = (mins, uv) => {
|
const burnBg = (mins, uv) => {
|
||||||
if (!isFinite(mins) || uv <= 0) return 'transparent';
|
if (!isFinite(mins) || uv <= 0) return 'transparent';
|
||||||
if (mins >= 240) return 'transparent';
|
if (mins >= 240) return 'transparent';
|
||||||
@@ -229,6 +233,7 @@ export function buildColumnDefs(ctx) {
|
|||||||
...tempCol('managedT', 'Managed', '°C est.', 'felt', r => r.managedT),
|
...tempCol('managedT', 'Managed', '°C est.', 'felt', r => r.managedT),
|
||||||
visible: indoorMode === 'on' && indoorManaged,
|
visible: indoorMode === 'on' && indoorManaged,
|
||||||
},
|
},
|
||||||
|
tempCol('utci', 'UTCI', '°C felt', 'felt', r => r.utci),
|
||||||
{
|
{
|
||||||
key: 'burn', label: 'Burn', unit: 'to MED', group: 'felt',
|
key: 'burn', label: 'Burn', unit: 'to MED', group: 'felt',
|
||||||
visible: !!visibleCols.burn,
|
visible: !!visibleCols.burn,
|
||||||
@@ -243,25 +248,7 @@ export function buildColumnDefs(ctx) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tempCol('utci', 'UTCI', '°C felt', 'felt', r => r.utci),
|
|
||||||
{
|
|
||||||
key: 'delta', label: 'Δ', unit: 'UTCI−Air', group: 'felt',
|
|
||||||
visible: !!visibleCols.delta,
|
|
||||||
render: (r) => {
|
|
||||||
const d = r.utci - r.Ta;
|
|
||||||
return {
|
|
||||||
content: `${d > 0 ? '+' : ''}${fmt(d)}${u('°C')}`,
|
|
||||||
style: {
|
|
||||||
color: d > 3 ? '#c8601a' : d < -3 ? '#3f73c4' : '#4a3218',
|
|
||||||
background: deltaBg(d),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tempCol('tmrt', 'Tmrt', '°C', 'felt', r => r.Tmrt),
|
|
||||||
tempCol('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT),
|
tempCol('concreteT', 'Concrete', '°C surface', 'surface', r => r.concreteT),
|
||||||
tempCol('furSurfaceT', 'Fur', '°C surface', 'surface', r => r.furSurfaceT, { pet: true, petTitle: true }),
|
|
||||||
tempCol('pawT', 'Paw', '°C surface', 'surface', r => r.pawT, { pet: true, petTitle: true }),
|
|
||||||
tempCol('soilT', 'Soil °C', 'surface', 'surface', r => r.soilT0),
|
tempCol('soilT', 'Soil °C', 'surface', 'surface', r => r.soilT0),
|
||||||
tempCol('soilT6', 'Soil 6cm', '°C root', 'surface', r => r.soilT6),
|
tempCol('soilT6', 'Soil 6cm', '°C root', 'surface', r => r.soilT6),
|
||||||
{
|
{
|
||||||
@@ -275,10 +262,12 @@ export function buildColumnDefs(ctx) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tempCol('petShadeT', 'Pet Shade', '°C', 'ambient', r => r.petShadeT, { pet: true, petTitle: true, headClass: 'utci-tight-head' }),
|
tempCol('furSurfaceT', 'Fur', '°C surface', 'pets', r => r.furSurfaceT, { pet: true, petTitle: true }),
|
||||||
|
tempCol('pawT', 'Paw', '°C surface', 'pets', r => r.pawT, { pet: true, petTitle: true }),
|
||||||
|
tempCol('petShadeT', 'Pet Shade', '°C', 'pets', r => r.petShadeT, { pet: true, petTitle: true, headClass: 'utci-tight-head' }),
|
||||||
// Pet Home reads the same indoorT figure as the Indoors column, just on
|
// Pet Home reads the same indoorT figure as the Indoors column, just on
|
||||||
// the pet-calibrated colour scale.
|
// the pet-calibrated colour scale.
|
||||||
tempCol('petHomeT', 'Pet Home', '°C est.', 'ambient', r => r.indoorT, { pet: true, petTitle: true }),
|
tempCol('petHomeT', 'Pet Home', '°C est.', 'pets', r => r.indoorT, { pet: true, petTitle: true }),
|
||||||
tempCol('air', 'Air', '°C', 'ambient', r => r.Ta, { headClass: 'utci-tight-head' }),
|
tempCol('air', 'Air', '°C', 'ambient', r => r.Ta, { headClass: 'utci-tight-head' }),
|
||||||
{
|
{
|
||||||
key: 'rh', label: 'RH', unit: '%', group: 'ambient',
|
key: 'rh', label: 'RH', unit: '%', group: 'ambient',
|
||||||
@@ -306,27 +295,26 @@ export function buildColumnDefs(ctx) {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Rain % carries the storm warning too: rather than spend a whole
|
||||||
|
// column on the Lightning Potential Index, an amber ⚡ rides alongside
|
||||||
|
// the percentage whenever LPI clears the moderate-risk threshold (5),
|
||||||
|
// going bold at the high-risk one (25). Same thresholds the old
|
||||||
|
// Lightning column and the Lightning-risk insight use.
|
||||||
key: 'precipProb', label: 'Rain', unit: '%', group: 'precip',
|
key: 'precipProb', label: 'Rain', unit: '%', group: 'precip',
|
||||||
visible: !!visibleCols.precipProb, headClass: 'utci-tight-head',
|
visible: !!visibleCols.precipProb, headClass: 'utci-tight-head',
|
||||||
render: (r) => ({
|
|
||||||
content: `${r.precipProb}${u('%')}`,
|
|
||||||
style: {
|
|
||||||
background: scaleBg(r.precipProb, 0, 100, '70,145,200'),
|
|
||||||
color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'lightning', label: '⚡', unit: 'LPI', group: 'precip',
|
|
||||||
visible: !!visibleCols.lightning, headClass: 'utci-tight-head',
|
|
||||||
render: (r) => {
|
render: (r) => {
|
||||||
const lpi = r.lightning ?? 0;
|
const lpi = r.lightning ?? 0;
|
||||||
|
const storm = lpi >= 25 ? 'high' : lpi >= 5 ? 'mod' : null;
|
||||||
return {
|
return {
|
||||||
content: lpi > 0 ? `${Math.round(lpi)}` : '',
|
title: storm ? `Lightning potential ${Math.round(lpi)} J/kg — ${storm === 'high' ? 'high' : 'moderate'} risk` : undefined,
|
||||||
|
content: html`${r.precipProb}${u('%')}${storm
|
||||||
|
? html`<span style=${{ marginLeft: '4px', fontWeight: storm === 'high' ? 700 : 'normal', opacity: storm === 'high' ? 1 : 0.75, color: storm === 'high' ? '#7a4800' : '#6a5800' }}>⚡</span>`
|
||||||
|
: ''}`,
|
||||||
style: {
|
style: {
|
||||||
background: lpi >= 25 ? 'rgba(255,180,0,0.25)' : lpi >= 5 ? 'rgba(255,210,0,0.15)' : 'transparent',
|
background: storm === 'high' ? 'rgba(255,180,0,0.25)'
|
||||||
color: lpi >= 25 ? '#7a4800' : lpi >= 5 ? '#6a5800' : '#7a8a90',
|
: storm === 'mod' ? 'rgba(255,210,0,0.15)'
|
||||||
textAlign: 'center',
|
: scaleBg(r.precipProb, 0, 100, '70,145,200'),
|
||||||
|
color: r.precipProb >= 50 ? '#1a4a70' : r.precipProb > 0 ? '#2a6a90' : '#7a8a90',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
+107
-4
@@ -56,15 +56,68 @@ export function utciCategory(u) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// BAND COLOUR RAMP - continuous version of the banded palette.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// utciCategory()/petCategory() hand back ONE flat colour per band, which
|
||||||
|
// is right for table cells (a cell states which band you are in) but wrong
|
||||||
|
// for anything that should read as a temperature gradient: every day from
|
||||||
|
// 27 -C to 31.9 -C is the same "Caution" swatch, so the only visible change
|
||||||
|
// is the hard jump at the next threshold.
|
||||||
|
//
|
||||||
|
// bandRampRgb() anchors each band's colour at the BOTTOM of that band's
|
||||||
|
// range and interpolates toward the next band's colour across the band. So
|
||||||
|
// a temperature sitting exactly on a threshold still gets that band's own
|
||||||
|
// published colour (32 -C is still the Extreme orange), and every degree
|
||||||
|
// above it slides proportionally toward the next tier - 30 -C lands 60% of
|
||||||
|
// the way from Caution to Extreme instead of being identical to 27 -C.
|
||||||
|
//
|
||||||
|
// The solid Danger band is left out of the ramp: it's a flat stop-everything
|
||||||
|
// alarm colour, not part of the gradient, so the ramp clamps at the last
|
||||||
|
// graded band and callers keep handling Danger separately.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
const rampAnchorCache = new WeakMap();
|
||||||
|
|
||||||
|
function rampAnchors(bands) {
|
||||||
|
let anchors = rampAnchorCache.get(bands);
|
||||||
|
if (anchors) return anchors;
|
||||||
|
anchors = [];
|
||||||
|
let lower = null;
|
||||||
|
for (const band of bands) {
|
||||||
|
if (band.solid) break;
|
||||||
|
// The first band is open-ended downward; give it a nominal 10 -C span so
|
||||||
|
// it still has an anchor below its own threshold.
|
||||||
|
anchors.push({ at: lower === null ? band.max - 10 : lower, rgb: hexToRgb(band.bg) });
|
||||||
|
if (band.max === undefined) break;
|
||||||
|
lower = band.max;
|
||||||
|
}
|
||||||
|
rampAnchorCache.set(bands, anchors);
|
||||||
|
return anchors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandRampRgb(t, bands) {
|
||||||
|
const anchors = rampAnchors(bands);
|
||||||
|
if (t === null || !isFinite(t)) return anchors[0].rgb.slice();
|
||||||
|
if (t <= anchors[0].at) return anchors[0].rgb.slice();
|
||||||
|
const last = anchors[anchors.length - 1];
|
||||||
|
if (t >= last.at) return last.rgb.slice();
|
||||||
|
for (let i = 1; i < anchors.length; i++) {
|
||||||
|
const a = anchors[i - 1], b = anchors[i];
|
||||||
|
if (t <= b.at) return mixRgb(a.rgb, b.rgb, (t - a.at) / (b.at - a.at));
|
||||||
|
}
|
||||||
|
return last.rgb.slice();
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// WEATHER TINT PALETTE - single source of truth for "what colour is
|
// WEATHER TINT PALETTE - single source of truth for "what colour is
|
||||||
// this weather", shared by the day tabs (components/DayTabs.js) and the
|
// this weather", shared by the day tabs (components/DayTabs.js) and the
|
||||||
// warning event banner (events/weather-checks.js + app.js).
|
// warning event banner (events/weather-checks.js + app.js).
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// The day tabs pick ONE dimension by priority (heat -> snow -> rain ->
|
// The day tabs start from a temperature colour (utciCategory().bg /
|
||||||
// cloud -> sun) and only vary its shade; these helpers are the shade
|
// petCategory().bg, ramped by bandRampRgb) and then shade it toward the
|
||||||
// ramps for the non-temperature dimensions. Temperature-driven tints
|
// sky by influence - rain/snow first, then cloud on days that will stay
|
||||||
// come from utciCategory().bg / petCategory().bg instead.
|
// dry. These helpers are the shade ramps for those non-temperature
|
||||||
|
// dimensions; see the WEIGHTED model block in components/DayTabs.js.
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
export const hexToRgb = (hx) => {
|
export const hexToRgb = (hx) => {
|
||||||
const n = parseInt(hx.replace('#', ''), 16);
|
const n = parseInt(hx.replace('#', ''), 16);
|
||||||
@@ -127,6 +180,56 @@ export function petCategory(t) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// DAY-TAB BAND VARIANTS - the same bands, one colour swapped.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// In the table a cell answers "which stress band is this reading in", and
|
||||||
|
// green is the right answer for Comfortable: it is the safe band in a
|
||||||
|
// column of warnings. A day tab answers a different question - "what will
|
||||||
|
// this day feel like" - and there green reads as damp and mild rather
|
||||||
|
// than as the best day of the week. So on the tabs (and ONLY on the tabs)
|
||||||
|
// Comfortable becomes a deep gold: sitting just under Warm's brighter
|
||||||
|
// yellow, it puts the settled 19-24 -C days on the same warm run as the
|
||||||
|
// rest of the pleasant half of the scale instead of interrupting it with
|
||||||
|
// a green step.
|
||||||
|
//
|
||||||
|
// Everything else - labels, thresholds, fg colours, the solid Danger
|
||||||
|
// alarm - is shared with the table palettes above, so the two can never
|
||||||
|
// drift apart on anything but this one deliberate colour.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8e554
|
||||||
|
|
||||||
|
// The same reasoning, carried down the rest of the HUMAN scale.
|
||||||
|
//
|
||||||
|
// UTCI_BANDS runs cyan -> mint -> green -> gold across Cold, Chilly and Cool,
|
||||||
|
// so a tab crossing from the cold half of the scale to the warm half has to
|
||||||
|
// travel through green. That hurts twice over: the strip reads as a green
|
||||||
|
// interruption between the blues and the golds, and because cloud dulls a tab
|
||||||
|
// toward its own hue, every overcast mid-scale day desaturates into a sickly
|
||||||
|
// green-grey rather than into something neutral.
|
||||||
|
//
|
||||||
|
// On the tabs the scale therefore goes blue -> GREY -> gold, with the neutral
|
||||||
|
// point sitting exactly where a day has stopped being cold but has not yet
|
||||||
|
// become pleasant - which is what Chilly means. Nothing passes through green
|
||||||
|
// in either direction.
|
||||||
|
//
|
||||||
|
// Human only. The pet scale is already blue through the equivalent bands
|
||||||
|
// (pet Cool is #b5dee9, not a green), and its thresholds sit at completely
|
||||||
|
// different temperatures, so it keeps the Comfortable override alone.
|
||||||
|
const DAY_TAB_HUMAN = {
|
||||||
|
Comfortable: DAY_TAB_COMFORTABLE,
|
||||||
|
Cool: '#ded9c3', // warm oatmeal, one step under Comfortable's gold
|
||||||
|
Chilly: '#d3d5d2', // the neutral pivot between the warm and cold halves
|
||||||
|
Cold: '#c2d3dd', // first step back into blue
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTabComfort = (bands) =>
|
||||||
|
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: DAY_TAB_COMFORTABLE } : b));
|
||||||
|
|
||||||
|
export const DAY_TAB_UTCI_BANDS =
|
||||||
|
UTCI_BANDS.map(b => (DAY_TAB_HUMAN[b.label] ? { ...b, bg: DAY_TAB_HUMAN[b.label] } : b));
|
||||||
|
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS);
|
||||||
|
|
||||||
// Diagonal sweep gradient for a band background hex colour.
|
// Diagonal sweep gradient for a band background hex colour.
|
||||||
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
|
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
|
||||||
// then applies a light→mid→dark sweep for depth.
|
// then applies a light→mid→dark sweep for depth.
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
Page background css/base.css body { background }
|
Page background css/base.css body { background }
|
||||||
Body text colour css/base.css .utci-app { color }
|
Body text colour css/base.css .utci-app { color }
|
||||||
Brand "brass" colour search files for #c8922a
|
Brand "brass" colour search files for #c8922a
|
||||||
Headline font css/header.css .utci-title { font-family }
|
Headline font css/base.css .nav-logo-sun { font-family }
|
||||||
Body text font css/base.css .utci-app { font-family }
|
Body text font css/base.css .utci-app { font-family }
|
||||||
Max page width css/base.css .utci-shell { max-width }
|
Max page width css/base.css .utci-shell { max-width }
|
||||||
Mobile breakpoint css/ui.css @media (max-width: 800px)
|
Mobile breakpoint css/ui.css @media (max-width: 800px)
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
FILE STRUCTURE
|
FILE STRUCTURE
|
||||||
──────────────────────────────────────────────────────────────────────
|
──────────────────────────────────────────────────────────────────────
|
||||||
css/base.css Colour palette docs, fonts+reset, page shell, nav
|
css/base.css Colour palette docs, fonts+reset, page shell, nav
|
||||||
css/header.css Header grid, title, dial, search box
|
css/header.css Header grid, dial, search box (site header: base.css)
|
||||||
css/table.css Day tabs, profiles, column toggles, hourly table,
|
css/table.css Day tabs, profiles, column toggles, hourly table,
|
||||||
now-row + night-row decorations
|
now-row + night-row decorations
|
||||||
css/ui.css Status banners, legend, about, footer, responsive
|
css/ui.css Status banners, legend, about, footer, responsive
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function cleanDist() {
|
|||||||
path.join(DIST, 'temperatures.html'),
|
path.join(DIST, 'temperatures.html'),
|
||||||
path.join(DIST, 'features.html'),
|
path.join(DIST, 'features.html'),
|
||||||
path.join(DIST, 'stress-bands.html'),
|
path.join(DIST, 'stress-bands.html'),
|
||||||
|
path.join(DIST, 'dashboard.html'),
|
||||||
path.join(ASSETS, '_tmp_flat.css'),
|
path.join(ASSETS, '_tmp_flat.css'),
|
||||||
path.join(ASSETS, 'about.min.css'),
|
path.join(ASSETS, 'about.min.css'),
|
||||||
];
|
];
|
||||||
@@ -86,7 +87,10 @@ function bumpVersion() {
|
|||||||
// about.min.css with a version param, and injects the JS bundle.
|
// about.min.css with a version param, and injects the JS bundle.
|
||||||
function rewriteHTML(html, version) {
|
function rewriteHTML(html, version) {
|
||||||
html = html.replace(/<link\s[^>]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, '');
|
html = html.replace(/<link\s[^>]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, '');
|
||||||
html = html.replace(/<script\s[^>]*src=["'][^"']*assets\/js\/[^"']*["'][^>]*><\/script>/gi, '');
|
// Only strips the dev entry point (main.js) - other plain scripts these
|
||||||
|
// pages load directly, like nav-account.js, are not part of the Preact
|
||||||
|
// bundle and must survive untouched.
|
||||||
|
html = html.replace(/<script\s[^>]*src=["'][^"']*assets\/js\/main\.js[^"']*["'][^>]*><\/script>/gi, '');
|
||||||
// Rewrite standalone about.css reference to versioned minified file.
|
// Rewrite standalone about.css reference to versioned minified file.
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/(<link\s[^>]*href=["'][^"']*assets\/)about\.css[^"']*(["'][^>]*>)/gi,
|
/(<link\s[^>]*href=["'][^"']*assets\/)about\.css[^"']*(["'][^>]*>)/gi,
|
||||||
@@ -232,8 +236,12 @@ async function build() {
|
|||||||
|
|
||||||
// 8. Copy static files
|
// 8. Copy static files
|
||||||
console.log(' Copying static files...');
|
console.log(' Copying static files...');
|
||||||
|
// dashboard.html is copied as-is, not run through rewriteHTML - it is a
|
||||||
|
// standalone page (inline CSS/JS, no #root) and has no use for the
|
||||||
|
// Preact app bundle the other pages get.
|
||||||
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
|
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
|
||||||
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php']) {
|
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php', 'dashboard.html',
|
||||||
|
'assets/js/nav-account.js']) {
|
||||||
const src = path.join(ROOT, f);
|
const src = path.join(ROOT, f);
|
||||||
if (fs.existsSync(src)) {
|
if (fs.existsSync(src)) {
|
||||||
copyFile(src, path.join(DIST, f));
|
copyFile(src, path.join(DIST, f));
|
||||||
@@ -241,6 +249,25 @@ async function build() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 9. Copy the dashboard backend (lib/ shared PHP + api/ endpoints)
|
||||||
|
console.log(' Copying dashboard backend...');
|
||||||
|
const copyDirRecursive = (src, dest) => {
|
||||||
|
fs.mkdirSync(dest, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||||
|
const srcPath = path.join(src, entry.name);
|
||||||
|
const destPath = path.join(dest, entry.name);
|
||||||
|
if (entry.isDirectory()) copyDirRecursive(srcPath, destPath);
|
||||||
|
else fs.copyFileSync(srcPath, destPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const dir of ['lib', 'api']) {
|
||||||
|
const src = path.join(ROOT, dir);
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
copyDirRecursive(src, path.join(DIST, dir));
|
||||||
|
console.log(' -> ' + dir + '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
|
const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
|
|||||||
+17
-8
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Column reference · SunScope</title>
|
<title>Column reference · SunScope</title>
|
||||||
<meta name="description" content="Every column in the SunScope forecast table explained in plain language — from Air and Shade through wind, UV, rain probability, lightning potential, air quality, pollen, and SunSoak." />
|
<meta name="description" content="Every column in the SunScope forecast table explained in plain language — from Air and Shade through wind, UV, rain probability with lightning warnings, air quality, pollen, and SunSoak." />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<link rel="canonical" href="https://sunscope.net/columns.html" />
|
<link rel="canonical" href="https://sunscope.net/columns.html" />
|
||||||
|
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-co"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-co)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-co"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-co)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -52,7 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -69,6 +79,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Column reference</h1>
|
<h1>Column reference</h1>
|
||||||
@@ -95,8 +106,6 @@
|
|||||||
<tr id="col-sun"><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
|
<tr id="col-sun"><td><strong>Sun °</strong></td><td>The angle of the sun above the horizon in degrees. Below 0° the sun has set. The higher the elevation, the more intense the radiation reaching the ground.</td></tr>
|
||||||
<tr id="col-direct"><td><strong>Direct W/m²</strong></td><td>Shortwave solar radiation arriving as a direct beam from the sun. The primary driver of skin heating on sunny days.</td></tr>
|
<tr id="col-direct"><td><strong>Direct W/m²</strong></td><td>Shortwave solar radiation arriving as a direct beam from the sun. The primary driver of skin heating on sunny days.</td></tr>
|
||||||
<tr id="col-diffuse"><td><strong>Diffuse W/m²</strong></td><td>Solar radiation scattered from all directions across the sky. Present even under cloud cover; contributes to the overall solar heat load.</td></tr>
|
<tr id="col-diffuse"><td><strong>Diffuse W/m²</strong></td><td>Solar radiation scattered from all directions across the sky. Present even under cloud cover; contributes to the overall solar heat load.</td></tr>
|
||||||
<tr id="col-tmrt"><td><strong>Tmrt °C</strong></td><td>Mean Radiant Temperature — the combined heat from direct sun, scattered sky light, and ground reflection expressed as a temperature. Can exceed air temperature by 20–30 °C on a sunny day; this is why shade feels so much cooler, and it is one of the inputs to UTCI.</td></tr>
|
|
||||||
<tr id="col-delta"><td><strong>UTCI−Air Δ</strong></td><td>The gap between the UTCI felt temperature and the plain air temperature. A large positive value means solar radiation is adding significant heat stress beyond what the thermometer shows.</td></tr>
|
|
||||||
<tr id="col-utci"><td><strong>UTCI °C</strong></td><td>Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does not move when you change the Solar Model, and it excludes precipitation. That is deliberate — it is the fixed benchmark to read SunSoak against, so the gap between the two shows exactly what your surroundings and the weather are adding.</td></tr>
|
<tr id="col-utci"><td><strong>UTCI °C</strong></td><td>Universal Thermal Climate Index — the peer-reviewed standard felt temperature, combining air temp, humidity, wind, and solar radiation. Always computed from the raw open-ground reading: it does not move when you change the Solar Model, and it excludes precipitation. That is deliberate — it is the fixed benchmark to read SunSoak against, so the gap between the two shows exactly what your surroundings and the weather are adding.</td></tr>
|
||||||
<tr id="col-uvA"><td><strong>UV-A</strong></td><td>Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass. Present throughout daylight hours.</td></tr>
|
<tr id="col-uvA"><td><strong>UV-A</strong></td><td>Estimated UV-A radiation index. UV-A penetrates deeper into the skin and contributes to long-term ageing and some skin cancers, even through glass. Present throughout daylight hours.</td></tr>
|
||||||
<tr id="col-uvB"><td><strong>UV-B</strong></td><td>Estimated UV-B radiation index. Causes sunburn and drives vitamin D production. Intensity depends strongly on solar elevation and cloud cover, falling sharply at low sun angles.</td></tr>
|
<tr id="col-uvB"><td><strong>UV-B</strong></td><td>Estimated UV-B radiation index. Causes sunburn and drives vitamin D production. Intensity depends strongly on solar elevation and cloud cover, falling sharply at low sun angles.</td></tr>
|
||||||
@@ -104,8 +113,7 @@
|
|||||||
<tr id="col-utciP"><td><strong>SunSoak °C</strong></td><td>SunScope's signature felt-temperature index. Starts from UTCI then adds three layers: a rain and snow penalty (wet clothing can drop felt temperature by up to 8 °C), a Solar Model for where you actually are (forest, alpine, beach, river, open water, desert), and full mean radiant temperature from surrounding surfaces. The most honest single answer to: what will it actually feel like out there?</td></tr>
|
<tr id="col-utciP"><td><strong>SunSoak °C</strong></td><td>SunScope's signature felt-temperature index. Starts from UTCI then adds three layers: a rain and snow penalty (wet clothing can drop felt temperature by up to 8 °C), a Solar Model for where you actually are (forest, alpine, beach, river, open water, desert), and full mean radiant temperature from surrounding surfaces. The most honest single answer to: what will it actually feel like out there?</td></tr>
|
||||||
<tr id="col-shadeT"><td><strong>Shade °C felt</strong></td><td>SunSoak with the sun taken away — the felt temperature standing in the typical shade of the selected Solar Model (building shadow, beach umbrella, tree canopy, boat panels, and so on). You are still outdoors, so wind, humidity and rain affect you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of.</td></tr>
|
<tr id="col-shadeT"><td><strong>Shade °C felt</strong></td><td>SunSoak with the sun taken away — the felt temperature standing in the typical shade of the selected Solar Model (building shadow, beach umbrella, tree canopy, boat panels, and so on). You are still outdoors, so wind, humidity and rain affect you exactly as they do in the open; only the direct beam and part of the sky are blocked. Expect several degrees below SunSoak in daytime sun, and near-identical readings after dark, when there is no sun to stand out of.</td></tr>
|
||||||
<tr id="col-precip"><td><strong>Precip mm</strong></td><td>Expected rainfall in mm per hour; snowfall is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.</td></tr>
|
<tr id="col-precip"><td><strong>Precip mm</strong></td><td>Expected rainfall in mm per hour; snowfall is shown in cm. Even light drizzle meaningfully reduces felt temperature when combined with wind.</td></tr>
|
||||||
<tr id="col-precipProb"><td><strong>Rain %</strong></td><td>Probability of precipitation for this hour (0–100%), derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low — useful for spotting showers the deterministic forecast may miss.</td></tr>
|
<tr id="col-precipProb"><td><strong>Rain %</strong></td><td>Probability of precipitation for this hour (0–100%), derived from Open-Meteo ensemble model runs. A high value means rain is likely even if the expected rate is low — useful for spotting showers the deterministic forecast may miss. A ⚡ beside the percentage marks a thundery hour, driven by the Lightning Potential Index (LPI) from Open-Meteo: it appears above 5 J/kg (moderate risk) and turns bold above 25 (high risk), with the cell tinted amber either way.</td></tr>
|
||||||
<tr id="col-lightning"><td><strong>Lightning</strong></td><td>Lightning Potential Index (LPI) from Open-Meteo in J/kg — a convective energy index. Values above 5 suggest moderate lightning risk, above 25 suggest high risk.</td></tr>
|
|
||||||
<tr id="col-soilT"><td><strong>Soil °C surface</strong></td><td>Temperature of the soil at the surface (0 cm depth). Most seeds germinate above 7–10 °C. Useful for sowing decisions and frost assessment.</td></tr>
|
<tr id="col-soilT"><td><strong>Soil °C surface</strong></td><td>Temperature of the soil at the surface (0 cm depth). Most seeds germinate above 7–10 °C. Useful for sowing decisions and frost assessment.</td></tr>
|
||||||
<tr id="col-soilT6"><td><strong>Soil °C 6 cm</strong></td><td>Soil temperature at 6 cm depth — the root zone for many crops and seedlings. Lags behind the surface by several hours.</td></tr>
|
<tr id="col-soilT6"><td><strong>Soil °C 6 cm</strong></td><td>Soil temperature at 6 cm depth — the root zone for many crops and seedlings. Lags behind the surface by several hours.</td></tr>
|
||||||
<tr id="col-soilM"><td><strong>Soil moisture</strong></td><td>Water saturation of the top 1 cm of soil as a percentage. Above 40% indicates saturated ground; below 20% is dry. Useful for assessing whether ground is workable, and for motorhome or caravan pitch suitability.</td></tr>
|
<tr id="col-soilM"><td><strong>Soil moisture</strong></td><td>Water saturation of the top 1 cm of soil as a percentage. Above 40% indicates saturated ground; below 20% is dry. Useful for assessing whether ground is workable, and for motorhome or caravan pitch suitability.</td></tr>
|
||||||
@@ -132,7 +140,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+983
@@ -0,0 +1,983 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dashboard · SunScope</title>
|
||||||
|
<meta name="description" content="Your SunScope journal — sign in to record notes against the conditions on any given day." />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="./assets/about.css?v=15b">
|
||||||
|
<style>
|
||||||
|
main { max-width: 620px; }
|
||||||
|
|
||||||
|
.db-columns { display: block; }
|
||||||
|
.db-col { min-width: 0; }
|
||||||
|
|
||||||
|
/* Two columns once there's room for both without squeezing either -
|
||||||
|
left carries the inputs (location/profile, add entry), right carries
|
||||||
|
the review (trend graph, entry list). The account panel and page
|
||||||
|
heading stay full-width above this. */
|
||||||
|
@media (min-width: 920px) {
|
||||||
|
main { max-width: 1020px; }
|
||||||
|
.db-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0 2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-panel {
|
||||||
|
background: #fef8ea;
|
||||||
|
border: 1px solid #e3d3ac;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.5rem 1.75rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-date-range {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.db-field-optional {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #b09870;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-field { margin-bottom: 1rem; }
|
||||||
|
.db-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #7a5c2a;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.db-field input,
|
||||||
|
.db-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #1e1208;
|
||||||
|
background: #fffdf6;
|
||||||
|
border: 1px solid #d4c0a0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
}
|
||||||
|
.db-field textarea { resize: vertical; min-height: 4.5rem; }
|
||||||
|
|
||||||
|
.db-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #fffdf6;
|
||||||
|
background: #c8922a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.6rem 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.db-btn:hover { background: #a8761c; }
|
||||||
|
.db-btn:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
.db-btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #9a7a4a;
|
||||||
|
border: 1px solid #d4c0a0;
|
||||||
|
}
|
||||||
|
.db-btn--ghost:hover { background: #f0e4c4; color: #1e1208; }
|
||||||
|
|
||||||
|
.db-error {
|
||||||
|
color: #a03a2a;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: -0.4rem;
|
||||||
|
margin-bottom: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-toggle-row {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #7a5c2a;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
.db-toggle-row a { cursor: pointer; }
|
||||||
|
|
||||||
|
.db-account-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.db-account-email {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-entry-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.db-entry-form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-entries { list-style: none; }
|
||||||
|
.db-entry {
|
||||||
|
border-bottom: 1px solid #e8dcbf;
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.db-entry:last-child { border-bottom: none; }
|
||||||
|
.db-entry-date {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #9a7a4a;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.db-entry-note {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: #3a2a12;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.db-entry-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.db-entry-edit,
|
||||||
|
.db-entry-del {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #b09870;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
.db-entry-edit:hover { color: #c8922a; }
|
||||||
|
.db-entry-del:hover { color: #a03a2a; }
|
||||||
|
|
||||||
|
.db-empty {
|
||||||
|
color: #9a7a4a;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-loc-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.db-loc-name {
|
||||||
|
font-family: 'Fraunces', serif;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
color: #1e1208;
|
||||||
|
}
|
||||||
|
.db-loc-name.is-unset { color: #9a7a4a; font-style: normal; font-weight: 500; }
|
||||||
|
.db-loc-change {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #c8922a;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.db-loc-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.db-loc-search input { flex: 1; }
|
||||||
|
.db-loc-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.db-loc-result {
|
||||||
|
text-align: left;
|
||||||
|
background: #fffdf6;
|
||||||
|
border: 1px solid #e3d3ac;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #3a2a12;
|
||||||
|
}
|
||||||
|
.db-loc-result:hover { background: #f0e4c4; }
|
||||||
|
|
||||||
|
.db-select {
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #1e1208;
|
||||||
|
background: #fffdf6;
|
||||||
|
border: 1px solid #d4c0a0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-reading-preview {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #7a5c2a;
|
||||||
|
margin: -0.4rem 0 1rem;
|
||||||
|
}
|
||||||
|
.db-reading-preview strong { color: #1e1208; }
|
||||||
|
|
||||||
|
.db-entry-reading {
|
||||||
|
color: #c8922a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.db-entry-meta {
|
||||||
|
color: #b09870;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-graph-empty {
|
||||||
|
color: #9a7a4a;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.db-graph-svg { width: 100%; height: auto; overflow: visible; }
|
||||||
|
.db-graph-line { fill: none; stroke: #c8922a; stroke-width: 2; }
|
||||||
|
.db-graph-dot { fill: #c8922a; stroke: #fef8ea; stroke-width: 1.5; cursor: pointer; }
|
||||||
|
.db-graph-dot:hover { fill: #a8761c; }
|
||||||
|
.db-graph-axis { stroke: #d4c0a0; stroke-width: 1; }
|
||||||
|
.db-graph-label {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
fill: #9a7a4a;
|
||||||
|
}
|
||||||
|
.db-graph-legend {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #9a7a4a;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "SunScope Dashboard",
|
||||||
|
"url": "https://sunscope.net/dashboard.html"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header id="site-nav">
|
||||||
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-db"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-db)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
|
<div class="nav-links" id="nav-drawer">
|
||||||
|
<a href="./index.html">Forecast</a>
|
||||||
|
<div class="nav-dropdown">
|
||||||
|
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./about.html">Overview</a>
|
||||||
|
<a href="./sunsoak.html">SunSoak & the science</a>
|
||||||
|
<a href="./profiles.html">Forecast profiles</a>
|
||||||
|
<a href="./columns.html">Column reference</a>
|
||||||
|
<a href="./temperatures.html">Derived temperatures</a>
|
||||||
|
<a href="./features.html">On-screen features</a>
|
||||||
|
<a href="./stress-bands.html">Stress bands</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="./faq.html">FAQ</a>
|
||||||
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle active">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html" class="active">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<script>
|
||||||
|
function toggleNav(force) {
|
||||||
|
const nav = document.getElementById('site-nav');
|
||||||
|
const drawer = document.getElementById('nav-drawer');
|
||||||
|
const btn = document.getElementById('burger-btn');
|
||||||
|
const open = force !== undefined ? force : !nav.classList.contains('nav-open');
|
||||||
|
nav.classList.toggle('nav-open', open);
|
||||||
|
drawer.classList.toggle('is-open', open);
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="tagline">Your SunScope journal.</div>
|
||||||
|
|
||||||
|
<div id="db-signed-out">
|
||||||
|
<div class="db-panel">
|
||||||
|
<form id="db-login-form">
|
||||||
|
<div class="db-field">
|
||||||
|
<label for="db-email">Email</label>
|
||||||
|
<input id="db-email" type="email" autocomplete="email" required />
|
||||||
|
</div>
|
||||||
|
<div class="db-field">
|
||||||
|
<label for="db-password">Password</label>
|
||||||
|
<input id="db-password" type="password" autocomplete="current-password" required minlength="8" />
|
||||||
|
</div>
|
||||||
|
<p class="db-error" id="db-auth-error" hidden></p>
|
||||||
|
<button class="db-btn" type="submit" id="db-auth-submit">Sign in</button>
|
||||||
|
<div class="db-toggle-row">
|
||||||
|
<span id="db-mode-prompt">New here? <a id="db-mode-toggle">Create an account</a></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Signing in lets you keep a running journal of notes against the weather — a place to record how a day
|
||||||
|
actually felt, separate from the forecast itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="db-signed-in" hidden>
|
||||||
|
<div class="db-panel">
|
||||||
|
<div class="db-account-row">
|
||||||
|
<span class="db-account-email" id="db-account-email"></span>
|
||||||
|
<button class="db-btn db-btn--ghost" id="db-logout-btn">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-columns">
|
||||||
|
<div class="db-col">
|
||||||
|
<h2>Location & profile</h2>
|
||||||
|
<div class="db-panel">
|
||||||
|
<div class="db-loc-row">
|
||||||
|
<span class="db-loc-name is-unset" id="db-loc-name">No location set</span>
|
||||||
|
<button class="db-loc-change" type="button" id="db-loc-change-btn">Change</button>
|
||||||
|
</div>
|
||||||
|
<div class="db-loc-search" id="db-loc-search" hidden>
|
||||||
|
<input type="text" id="db-loc-input" placeholder="Search for a town or city…" />
|
||||||
|
<button class="db-btn db-btn--ghost" type="button" id="db-loc-search-btn">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="db-loc-results" id="db-loc-results"></div>
|
||||||
|
<div class="db-field">
|
||||||
|
<label for="db-profile-select">Profile</label>
|
||||||
|
<select class="db-select" id="db-profile-select">
|
||||||
|
<option value="basic">Basic (SunSoak)</option>
|
||||||
|
<option value="home">Home (Indoor)</option>
|
||||||
|
<option value="vehicle">Vehicle (Cabin)</option>
|
||||||
|
<option value="pets">Pets (Fur)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="db-field" id="db-env-field" style="margin-bottom: 0;">
|
||||||
|
<label for="db-env-select">Solar model</label>
|
||||||
|
<select class="db-select" id="db-env-select"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="db-entry-form-heading">Add an entry</h2>
|
||||||
|
<div class="db-panel">
|
||||||
|
<form id="db-entry-form" class="db-entry-form">
|
||||||
|
<div class="db-date-range">
|
||||||
|
<div class="db-field">
|
||||||
|
<label for="db-entry-date">Start date</label>
|
||||||
|
<input id="db-entry-date" type="date" required />
|
||||||
|
</div>
|
||||||
|
<div class="db-field">
|
||||||
|
<label for="db-entry-end-date">End date <span class="db-field-optional">(optional)</span></label>
|
||||||
|
<input id="db-entry-end-date" type="date" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="db-reading-preview" id="db-reading-preview"></p>
|
||||||
|
<div class="db-field">
|
||||||
|
<label for="db-entry-note">Note</label>
|
||||||
|
<textarea id="db-entry-note" placeholder="How did the day feel?" required></textarea>
|
||||||
|
</div>
|
||||||
|
<p class="db-error" id="db-entry-error" hidden></p>
|
||||||
|
<div class="db-entry-form-actions">
|
||||||
|
<button class="db-btn" type="submit" id="db-entry-submit">Save entry</button>
|
||||||
|
<button class="db-btn db-btn--ghost" type="button" id="db-entry-cancel" hidden>Cancel edit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-col">
|
||||||
|
<h2>Your trend</h2>
|
||||||
|
<div class="db-panel">
|
||||||
|
<div id="db-graph-wrap"></div>
|
||||||
|
<p class="db-graph-empty" id="db-graph-empty">Save a few entries with a location and profile set to see your trend here.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Your entries</h2>
|
||||||
|
<ul class="db-entries" id="db-entries-list"></ul>
|
||||||
|
<p class="db-empty" id="db-entries-empty" hidden>Nothing recorded yet — add your first entry above.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import { buildHourlyRows } from './assets/js/compute.js';
|
||||||
|
import { deriveProfileMain, UTCI_ENVIRONMENTS } from './assets/js/config.js';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const PROFILE_LABELS = { basic: 'SunSoak', home: 'Indoor', vehicle: 'Cabin', pets: 'Fur' };
|
||||||
|
|
||||||
|
async function api(path, body) {
|
||||||
|
const res = await fetch('./api/' + path, {
|
||||||
|
method: body ? 'POST' : 'GET',
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || data.ok === false) throw new Error(data.error || 'Something went wrong.');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth ────────────────────────────────────────────────────────────
|
||||||
|
let mode = 'login'; // or 'register'
|
||||||
|
|
||||||
|
// Delegated on the prompt's parent, not the <a> itself - the toggle link
|
||||||
|
// is re-created by the innerHTML swap below, so a listener bound directly
|
||||||
|
// to it would be gone after the first click.
|
||||||
|
$('db-mode-prompt').parentElement.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id !== 'db-mode-toggle') return;
|
||||||
|
mode = mode === 'login' ? 'register' : 'login';
|
||||||
|
$('db-auth-submit').textContent = mode === 'login' ? 'Sign in' : 'Create account';
|
||||||
|
$('db-mode-prompt').innerHTML = mode === 'login'
|
||||||
|
? 'New here? <a id="db-mode-toggle">Create an account</a>'
|
||||||
|
: 'Already have an account? <a id="db-mode-toggle">Sign in</a>';
|
||||||
|
$('db-auth-error').hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keeps the "Account" nav dropdown's Sign in/out item in step with the
|
||||||
|
// page's own auth state, live - rather than loading nav-account.js
|
||||||
|
// alongside this script to do its own separate, one-shot me.php check
|
||||||
|
// that would go stale the moment someone signs in or out down here.
|
||||||
|
function syncNavAccountLink(signedIn) {
|
||||||
|
const link = $('nav-account-signinout');
|
||||||
|
if (!link) return;
|
||||||
|
if (signedIn) {
|
||||||
|
link.textContent = 'Sign out';
|
||||||
|
link.href = '#';
|
||||||
|
link.onclick = (e) => { e.preventDefault(); $('db-logout-btn').click(); };
|
||||||
|
} else {
|
||||||
|
link.textContent = 'Sign in';
|
||||||
|
link.href = './dashboard.html';
|
||||||
|
link.onclick = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('db-login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
$('db-auth-error').hidden = true;
|
||||||
|
const email = $('db-email').value.trim();
|
||||||
|
const password = $('db-password').value;
|
||||||
|
try {
|
||||||
|
await api(mode === 'login' ? 'login.php' : 'register.php', { email, password });
|
||||||
|
$('db-account-email').textContent = email;
|
||||||
|
syncNavAccountLink(true);
|
||||||
|
await showDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
$('db-auth-error').textContent = err.message;
|
||||||
|
$('db-auth-error').hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('db-logout-btn').addEventListener('click', async () => {
|
||||||
|
await api('logout.php', {});
|
||||||
|
$('db-signed-in').hidden = true;
|
||||||
|
$('db-signed-out').hidden = false;
|
||||||
|
syncNavAccountLink(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Location & profile ─────────────────────────────────────────────
|
||||||
|
// A location the browser can pull the live 14-day forecast for, so an
|
||||||
|
// entry's date can carry a computed felt-temp reading the same way a
|
||||||
|
// day tab does - peak-of-day value of the profile's main field. Kept in
|
||||||
|
// localStorage, independent of the main app's own saved location.
|
||||||
|
const LOC_KEY = 'sunscope_dashboard_location';
|
||||||
|
const PROFILE_KEY = 'sunscope_dashboard_profile';
|
||||||
|
const ENV_KEY = 'sunscope_dashboard_env';
|
||||||
|
|
||||||
|
function getLocation() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(LOC_KEY) || 'null'); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function setLocation(loc) {
|
||||||
|
localStorage.setItem(LOC_KEY, JSON.stringify(loc));
|
||||||
|
renderLocation();
|
||||||
|
forecastDaysCache = null;
|
||||||
|
updateReadingPreview();
|
||||||
|
}
|
||||||
|
function getProfile() {
|
||||||
|
return localStorage.getItem(PROFILE_KEY) || 'basic';
|
||||||
|
}
|
||||||
|
function setProfile(p) {
|
||||||
|
localStorage.setItem(PROFILE_KEY, p);
|
||||||
|
}
|
||||||
|
function getEnv() {
|
||||||
|
const saved = localStorage.getItem(ENV_KEY);
|
||||||
|
return saved && UTCI_ENVIRONMENTS[saved] ? saved : 'open';
|
||||||
|
}
|
||||||
|
function setEnv(e) {
|
||||||
|
localStorage.setItem(ENV_KEY, e);
|
||||||
|
forecastDaysCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solar model options, same list and labels as the main app's selector -
|
||||||
|
// only actually moves the Basic/SunSoak reading (Home/Vehicle/Pets don't
|
||||||
|
// read the environment modifier), but it's offered alongside the profile
|
||||||
|
// regardless, same as the main app does.
|
||||||
|
for (const [key, env] of Object.entries(UTCI_ENVIRONMENTS)) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = key;
|
||||||
|
opt.textContent = env.label;
|
||||||
|
$('db-env-select').appendChild(opt);
|
||||||
|
}
|
||||||
|
$('db-env-select').value = getEnv();
|
||||||
|
$('db-env-select').addEventListener('change', () => {
|
||||||
|
setEnv($('db-env-select').value);
|
||||||
|
updateReadingPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderLocation() {
|
||||||
|
const loc = getLocation();
|
||||||
|
const nameEl = $('db-loc-name');
|
||||||
|
if (loc) {
|
||||||
|
nameEl.textContent = loc.name;
|
||||||
|
nameEl.classList.remove('is-unset');
|
||||||
|
} else {
|
||||||
|
nameEl.textContent = 'No location set';
|
||||||
|
nameEl.classList.add('is-unset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('db-profile-select').value = getProfile();
|
||||||
|
$('db-profile-select').addEventListener('change', () => {
|
||||||
|
setProfile($('db-profile-select').value);
|
||||||
|
updateReadingPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('db-loc-change-btn').addEventListener('click', () => {
|
||||||
|
const search = $('db-loc-search');
|
||||||
|
search.hidden = !search.hidden;
|
||||||
|
if (!search.hidden) $('db-loc-input').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runLocationSearch() {
|
||||||
|
const query = $('db-loc-input').value.trim();
|
||||||
|
const results = $('db-loc-results');
|
||||||
|
results.innerHTML = '';
|
||||||
|
if (!query) return;
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5&language=en&format=json`);
|
||||||
|
const data = await res.json();
|
||||||
|
list = data.results || [];
|
||||||
|
} catch (e) {
|
||||||
|
results.innerHTML = '<span class="db-empty">Search failed - try again.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!list.length) {
|
||||||
|
results.innerHTML = '<span class="db-empty">No matches found.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const r of list) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'db-loc-result';
|
||||||
|
btn.textContent = `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}, ${r.country}`;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
setLocation({ name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, lat: r.latitude, lon: r.longitude });
|
||||||
|
$('db-loc-search').hidden = true;
|
||||||
|
$('db-loc-input').value = '';
|
||||||
|
results.innerHTML = '';
|
||||||
|
});
|
||||||
|
results.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('db-loc-search-btn').addEventListener('click', runLocationSearch);
|
||||||
|
$('db-loc-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); runLocationSearch(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Live forecast → computed reading ───────────────────────────────
|
||||||
|
// Same hourly fields the main app requests, same buildHourlyRows()
|
||||||
|
// engine, same "peak value of the day" rule the day tabs use for their
|
||||||
|
// headline number - see deriveProfileMain() in config.js.
|
||||||
|
function buildForecastUrl(loc) {
|
||||||
|
return `https://api.open-meteo.com/v1/forecast` +
|
||||||
|
`?latitude=${loc.lat}&longitude=${loc.lon}` +
|
||||||
|
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
|
||||||
|
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
|
||||||
|
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
|
||||||
|
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
|
||||||
|
`uv_index,precipitation,precipitation_probability,lightning_potential,cape,snowfall,visibility,` +
|
||||||
|
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
|
||||||
|
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let forecastDaysCache = null; // { cacheKey, days } - cacheKey includes the solar model, since that reshapes the computed rows
|
||||||
|
async function getForecastDays(loc) {
|
||||||
|
const fetchKey = `${loc.lat.toFixed(3)},${loc.lon.toFixed(3)}`;
|
||||||
|
const env = getEnv();
|
||||||
|
const cacheKey = fetchKey + ',' + env;
|
||||||
|
if (forecastDaysCache && forecastDaysCache.cacheKey === cacheKey) return forecastDaysCache.days;
|
||||||
|
|
||||||
|
const sessionKey = 'sunscope_dashboard_fc_' + fetchKey;
|
||||||
|
let forecast = null;
|
||||||
|
try {
|
||||||
|
const cached = JSON.parse(sessionStorage.getItem(sessionKey) || 'null');
|
||||||
|
if (cached && (Date.now() - cached.fetchedAt) < 30 * 60 * 1000) forecast = cached.data;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
if (!forecast) {
|
||||||
|
const res = await fetch(buildForecastUrl(loc));
|
||||||
|
if (!res.ok) throw new Error('Could not reach the weather service.');
|
||||||
|
forecast = await res.json();
|
||||||
|
try { sessionStorage.setItem(sessionKey, JSON.stringify({ data: forecast, fetchedAt: Date.now() })); } catch (e) { /* ignore, e.g. quota */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { days } = buildHourlyRows({
|
||||||
|
forecast, airQuality: null, location: loc,
|
||||||
|
vehicleType: 'car', vehicleVent: false, vehicleSpeed: 0, // mph - "Static"/parked; buildHourlyRows wants a number here, not the VEHICLE_SPEEDS key
|
||||||
|
buildingType: 'brick', furColor: 'brown', utciEnv: env,
|
||||||
|
});
|
||||||
|
forecastDaysCache = { cacheKey, days };
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every date from start to end inclusive, capped at 60 days as a sanity
|
||||||
|
// bound - real coverage is limited by the 14-day forecast anyway.
|
||||||
|
function enumerateDates(start, end) {
|
||||||
|
const dates = [];
|
||||||
|
const d = new Date(start + 'T00:00:00Z');
|
||||||
|
const endD = new Date(end + 'T00:00:00Z');
|
||||||
|
for (let guard = 0; d <= endD && guard < 60; guard++) {
|
||||||
|
dates.push(d.toISOString().slice(0, 10));
|
||||||
|
d.setUTCDate(d.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak value of the active profile's main field across the entry's date
|
||||||
|
// range - a single date behaves exactly as a day tab would (peak of that
|
||||||
|
// one day); a multi-day range takes the peak across every day in it that
|
||||||
|
// falls inside the 14-day forecast window. Returns null if none of the
|
||||||
|
// range is covered, or nothing is set up yet to compute it from.
|
||||||
|
async function computeReading(dateStr, endDateStr) {
|
||||||
|
const loc = getLocation();
|
||||||
|
if (!loc || !dateStr) return null;
|
||||||
|
let days;
|
||||||
|
try { days = await getForecastDays(loc); } catch (e) { return null; }
|
||||||
|
const { mainField } = deriveProfileMain(getProfile(), null);
|
||||||
|
const range = (endDateStr && endDateStr > dateStr) ? enumerateDates(dateStr, endDateStr) : [dateStr];
|
||||||
|
const allVals = [];
|
||||||
|
for (const key of range) {
|
||||||
|
const day = days.find(d => d.key === key);
|
||||||
|
if (day) allVals.push(...day.rows.map(r => r[mainField]).filter(v => Number.isFinite(v)));
|
||||||
|
}
|
||||||
|
if (!allVals.length) return null;
|
||||||
|
return Math.round(Math.max(...allVals) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The end-date picker can't be set before the start date.
|
||||||
|
function syncEndDateMin() {
|
||||||
|
$('db-entry-end-date').min = $('db-entry-date').value;
|
||||||
|
if ($('db-entry-end-date').value && $('db-entry-end-date').value < $('db-entry-date').value) {
|
||||||
|
$('db-entry-end-date').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentReading = null;
|
||||||
|
// Bumped on every call so a slower, older lookup can never clobber a
|
||||||
|
// newer one's result - comparing the date/end-date fields alone wasn't
|
||||||
|
// enough, since profile/solar-model changes (or two lookups racing after
|
||||||
|
// a flaky network response) don't touch those fields at all.
|
||||||
|
let readingRequestSeq = 0;
|
||||||
|
async function updateReadingPreview() {
|
||||||
|
const requestId = ++readingRequestSeq;
|
||||||
|
const preview = $('db-reading-preview');
|
||||||
|
const date = $('db-entry-date').value;
|
||||||
|
const endDate = $('db-entry-end-date').value;
|
||||||
|
const isRange = endDate && endDate > date;
|
||||||
|
const loc = getLocation();
|
||||||
|
if (!loc) {
|
||||||
|
if (requestId !== readingRequestSeq) return;
|
||||||
|
preview.textContent = 'Set a location above to attach a reading to this entry.';
|
||||||
|
currentReading = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
preview.textContent = 'Checking the forecast…';
|
||||||
|
const reading = await computeReading(date, endDate);
|
||||||
|
// A newer call has since started (date/end-date/profile/solar-model
|
||||||
|
// changed again, or another lookup is already further along) - let
|
||||||
|
// that one own the result instead.
|
||||||
|
if (requestId !== readingRequestSeq) return;
|
||||||
|
currentReading = reading;
|
||||||
|
if (reading == null) {
|
||||||
|
preview.textContent = isRange
|
||||||
|
? "None of this range falls in the 14-day forecast - this entry will be note-only."
|
||||||
|
: 'Outside the 14-day forecast - this entry will be note-only.';
|
||||||
|
} else {
|
||||||
|
const when = isRange ? `peak across the range` : 'peak for the day';
|
||||||
|
preview.innerHTML = `Auto: <strong>${PROFILE_LABELS[getProfile()]} ${reading}°</strong> (${when}, ${UTCI_ENVIRONMENTS[getEnv()].label})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('db-entry-date').addEventListener('input', () => { syncEndDateMin(); updateReadingPreview(); });
|
||||||
|
$('db-entry-end-date').addEventListener('input', updateReadingPreview);
|
||||||
|
|
||||||
|
// ── Journal ─────────────────────────────────────────────────────────
|
||||||
|
// Editing reuses the same form as adding - startEdit() fills it from an
|
||||||
|
// existing entry and remembers its id; the submit handler includes that
|
||||||
|
// id when present so journal-save.php updates instead of inserting. The
|
||||||
|
// reading is always recomputed fresh against whatever profile/solar
|
||||||
|
// model/location are currently selected, same as a brand new entry -
|
||||||
|
// an edited entry is never left showing a stale number.
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
function startEdit(entry) {
|
||||||
|
editingId = entry.id;
|
||||||
|
$('db-entry-date').value = entry.date;
|
||||||
|
$('db-entry-end-date').value = entry.endDate || '';
|
||||||
|
syncEndDateMin();
|
||||||
|
$('db-entry-note').value = entry.note;
|
||||||
|
$('db-entry-form-heading').textContent = 'Edit entry';
|
||||||
|
$('db-entry-submit').textContent = 'Update entry';
|
||||||
|
$('db-entry-cancel').hidden = false;
|
||||||
|
updateReadingPreview();
|
||||||
|
$('db-entry-form').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
$('db-entry-form').reset();
|
||||||
|
$('db-entry-date').valueAsDate = new Date();
|
||||||
|
syncEndDateMin();
|
||||||
|
$('db-entry-form-heading').textContent = 'Add an entry';
|
||||||
|
$('db-entry-submit').textContent = 'Save entry';
|
||||||
|
$('db-entry-cancel').hidden = true;
|
||||||
|
$('db-entry-error').hidden = true;
|
||||||
|
updateReadingPreview();
|
||||||
|
}
|
||||||
|
$('db-entry-cancel').addEventListener('click', cancelEdit);
|
||||||
|
|
||||||
|
$('db-entry-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
$('db-entry-error').hidden = true;
|
||||||
|
const date = $('db-entry-date').value;
|
||||||
|
const endDate = $('db-entry-end-date').value;
|
||||||
|
const note = $('db-entry-note').value.trim();
|
||||||
|
const loc = getLocation();
|
||||||
|
const payload = { date, note };
|
||||||
|
if (editingId) payload.id = editingId;
|
||||||
|
if (endDate && endDate > date) payload.endDate = endDate;
|
||||||
|
if (currentReading != null && loc) {
|
||||||
|
payload.reading = currentReading;
|
||||||
|
payload.profile = getProfile();
|
||||||
|
payload.solarEnv = getEnv();
|
||||||
|
payload.locationName = loc.name;
|
||||||
|
payload.lat = loc.lat;
|
||||||
|
payload.lon = loc.lon;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api('journal-save.php', payload);
|
||||||
|
const wasEditing = editingId != null;
|
||||||
|
editingId = null;
|
||||||
|
$('db-entry-note').value = '';
|
||||||
|
$('db-entry-end-date').value = '';
|
||||||
|
if (wasEditing) {
|
||||||
|
$('db-entry-form-heading').textContent = 'Add an entry';
|
||||||
|
$('db-entry-submit').textContent = 'Save entry';
|
||||||
|
$('db-entry-cancel').hidden = true;
|
||||||
|
}
|
||||||
|
await loadEntries();
|
||||||
|
} catch (err) {
|
||||||
|
$('db-entry-error').textContent = err.message;
|
||||||
|
$('db-entry-error').hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every entry, always shown in full - no filtering by the current
|
||||||
|
// profile/solar model. A reading only means what it says alongside
|
||||||
|
// whatever profile and solar model produced it, so each one carries that
|
||||||
|
// label with it (see the chip in renderEntriesList and the tooltip in
|
||||||
|
// renderGraph) rather than the page hiding entries that don't match
|
||||||
|
// what happens to be selected right now.
|
||||||
|
let allEntries = [];
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
const { entries } = await api('journal-list.php');
|
||||||
|
allEntries = entries;
|
||||||
|
renderEntriesList(allEntries);
|
||||||
|
renderGraph(allEntries.filter(e => e.reading != null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "2026-08-12" for a single day, "2026-08-12 – 2026-08-19" for a range.
|
||||||
|
function formatDateLabel(entry) {
|
||||||
|
return entry.endDate ? `${entry.date} – ${entry.endDate}` : entry.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntriesList(entries) {
|
||||||
|
const list = $('db-entries-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
$('db-entries-empty').hidden = entries.length > 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'db-entry';
|
||||||
|
const dateSpan = document.createElement('span');
|
||||||
|
dateSpan.className = 'db-entry-date';
|
||||||
|
dateSpan.textContent = formatDateLabel(entry);
|
||||||
|
const noteSpan = document.createElement('span');
|
||||||
|
noteSpan.className = 'db-entry-note';
|
||||||
|
noteSpan.textContent = entry.note;
|
||||||
|
if (entry.reading != null) {
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.className = 'db-entry-reading';
|
||||||
|
chip.textContent = ` · ${PROFILE_LABELS[entry.profile] ?? entry.profile} ${entry.reading}°`;
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'db-entry-meta';
|
||||||
|
meta.textContent = ` (${UTCI_ENVIRONMENTS[entry.solarEnv]?.label ?? entry.solarEnv}${entry.locationName ? ' · ' + entry.locationName : ''})`;
|
||||||
|
noteSpan.append(chip, meta);
|
||||||
|
}
|
||||||
|
const actions = document.createElement('span');
|
||||||
|
actions.className = 'db-entry-actions';
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'db-entry-edit';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.addEventListener('click', () => startEdit(entry));
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.className = 'db-entry-del';
|
||||||
|
delBtn.textContent = 'Delete';
|
||||||
|
delBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Delete this entry? This can\'t be undone.')) return;
|
||||||
|
await api('journal-delete.php', { id: entry.id });
|
||||||
|
if (editingId === entry.id) cancelEdit();
|
||||||
|
await loadEntries();
|
||||||
|
});
|
||||||
|
actions.append(editBtn, delBtn);
|
||||||
|
li.append(dateSpan, noteSpan, actions);
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Graph ───────────────────────────────────────────────────────────
|
||||||
|
// Hand-rolled inline SVG, no charting library - house style (see the
|
||||||
|
// scope dial and day timeline in the main app). Plots every entry that
|
||||||
|
// carries a reading, oldest to newest.
|
||||||
|
function renderGraph(entries) {
|
||||||
|
const wrap = $('db-graph-wrap');
|
||||||
|
const points = entries
|
||||||
|
.filter(e => e.reading != null)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
$('db-graph-empty').hidden = points.length > 0;
|
||||||
|
if (points.length === 0) return;
|
||||||
|
|
||||||
|
const W = 560, H = 180, padL = 34, padR = 12, padT = 14, padB = 22;
|
||||||
|
const innerW = W - padL - padR, innerH = H - padT - padB;
|
||||||
|
|
||||||
|
const values = points.map(p => p.reading);
|
||||||
|
let min = Math.min(...values), max = Math.max(...values);
|
||||||
|
if (min === max) { min -= 1; max += 1; } // flat lines still get visible headroom
|
||||||
|
const pad = (max - min) * 0.12;
|
||||||
|
min -= pad; max += pad;
|
||||||
|
|
||||||
|
const x = (i) => padL + (points.length === 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
|
||||||
|
const y = (v) => padT + innerH - ((v - min) / (max - min)) * innerH;
|
||||||
|
|
||||||
|
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(p.reading).toFixed(1)}`).join(' ');
|
||||||
|
|
||||||
|
// Y-axis: min/mid/max gridlines with labels.
|
||||||
|
const ticks = [min + (max - min) * 0.02, (min + max) / 2, max - (max - min) * 0.02];
|
||||||
|
const svgParts = [];
|
||||||
|
svgParts.push(`<line class="db-graph-axis" x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT + innerH}" />`);
|
||||||
|
svgParts.push(`<line class="db-graph-axis" x1="${padL}" y1="${padT + innerH}" x2="${padL + innerW}" y2="${padT + innerH}" />`);
|
||||||
|
for (const t of [min + pad, (min + max) / 2, max - pad]) {
|
||||||
|
const ty = y(t);
|
||||||
|
svgParts.push(`<text class="db-graph-label" x="${padL - 6}" y="${ty + 3}" text-anchor="end">${t.toFixed(1)}°</text>`);
|
||||||
|
}
|
||||||
|
// X-axis: first and last date only, to stay legible at any entry count.
|
||||||
|
svgParts.push(`<text class="db-graph-label" x="${x(0)}" y="${H - 4}" text-anchor="start">${points[0].date}</text>`);
|
||||||
|
if (points.length > 1) {
|
||||||
|
svgParts.push(`<text class="db-graph-label" x="${x(points.length - 1)}" y="${H - 4}" text-anchor="end">${points[points.length - 1].date}</text>`);
|
||||||
|
}
|
||||||
|
svgParts.push(`<path class="db-graph-line" d="${linePath}" />`);
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
const envLabel = UTCI_ENVIRONMENTS[p.solarEnv]?.label ?? p.solarEnv;
|
||||||
|
svgParts.push(`<circle class="db-graph-dot" cx="${x(i).toFixed(1)}" cy="${y(p.reading).toFixed(1)}" r="3.5"><title>${formatDateLabel(p)} · ${PROFILE_LABELS[p.profile] ?? p.profile} ${p.reading}° (${envLabel})${p.locationName ? ' · ' + p.locationName : ''}</title></circle>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.innerHTML = `<svg class="db-graph-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">${svgParts.join('')}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDashboard() {
|
||||||
|
$('db-signed-out').hidden = true;
|
||||||
|
$('db-signed-in').hidden = false;
|
||||||
|
$('db-entry-date').valueAsDate = new Date();
|
||||||
|
syncEndDateMin();
|
||||||
|
renderLocation();
|
||||||
|
updateReadingPreview();
|
||||||
|
await loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav dropdown's Subscription link - same rule as nav-account.js, just
|
||||||
|
// set directly here since this page already has its own boot sequence.
|
||||||
|
const subLink = $('nav-account-subscription');
|
||||||
|
if (subLink) {
|
||||||
|
subLink.href = localStorage.getItem('sunscope_pro') === '1'
|
||||||
|
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
|
||||||
|
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot: check for an existing session ────────────────────────────
|
||||||
|
api('me.php').then(({ user }) => {
|
||||||
|
if (user) {
|
||||||
|
$('db-account-email').textContent = user.email;
|
||||||
|
syncNavAccountLink(true);
|
||||||
|
showDashboard();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -205,5 +205,35 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"vehicle": 1
|
"vehicle": 1
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"2026-08-26": {
|
||||||
|
"visits": 99,
|
||||||
|
"profiles": {
|
||||||
|
"basic": 15,
|
||||||
|
"home": 13,
|
||||||
|
"vehicle": 11,
|
||||||
|
"pets": 8,
|
||||||
|
"alltemps": 5,
|
||||||
|
"showall": 4,
|
||||||
|
"custom": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2026-08-27": {
|
||||||
|
"visits": 82,
|
||||||
|
"profiles": {
|
||||||
|
"basic": 8,
|
||||||
|
"showall": 3,
|
||||||
|
"alltemps": 11,
|
||||||
|
"vehicle": 9,
|
||||||
|
"home": 10,
|
||||||
|
"pets": 13,
|
||||||
|
"farming": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2026-08-30": {
|
||||||
|
"visits": 17,
|
||||||
|
"profiles": {
|
||||||
|
"showall": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- SunScope Dashboard — initial schema.
|
||||||
|
-- Minimal v1: accounts (email + password) and journal entries.
|
||||||
|
-- No Stripe/entitlement linking yet — that stays in the front-end
|
||||||
|
-- localStorage Pro check for now, same as the rest of the site.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(32) NOT NULL PRIMARY KEY,
|
||||||
|
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
public_id CHAR(22) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_users_public_id (public_id),
|
||||||
|
UNIQUE KEY uq_users_email (email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
token_hash BINARY(32) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
KEY ix_sessions_user (user_id),
|
||||||
|
UNIQUE KEY uq_sessions_token_hash (token_hash),
|
||||||
|
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS journal_entries (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
entry_date DATE NOT NULL,
|
||||||
|
note TEXT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
KEY ix_journal_user_date (user_id, entry_date),
|
||||||
|
CONSTRAINT fk_journal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO schema_migrations (version) VALUES ('001_init');
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Adds an optional computed reading to a journal entry, captured the same
|
||||||
|
-- way the day tabs derive their headline number: peak value of the active
|
||||||
|
-- profile's main field across that day's hourly forecast rows. Only
|
||||||
|
-- possible for dates inside the 14-day forecast window, so it's nullable -
|
||||||
|
-- older/out-of-range entries stay note-only.
|
||||||
|
|
||||||
|
ALTER TABLE journal_entries
|
||||||
|
ADD COLUMN profile VARCHAR(32) NULL AFTER note,
|
||||||
|
ADD COLUMN reading DECIMAL(5,1) NULL AFTER profile,
|
||||||
|
ADD COLUMN location_name VARCHAR(255) NULL AFTER reading,
|
||||||
|
ADD COLUMN lat DECIMAL(8,5) NULL AFTER location_name,
|
||||||
|
ADD COLUMN lon DECIMAL(8,5) NULL AFTER lat;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO schema_migrations (version) VALUES ('002_add_reading');
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- The solar model (environment modifier) changes the SunSoak reading just
|
||||||
|
-- as much as the profile does, so a reading needs both stored to mean
|
||||||
|
-- anything when filtering the journal/graph by "what's currently selected".
|
||||||
|
|
||||||
|
ALTER TABLE journal_entries
|
||||||
|
ADD COLUMN solar_env VARCHAR(16) NULL AFTER profile;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO schema_migrations (version) VALUES ('003_add_solar_env');
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- A note can now span a period rather than one day (e.g. a holiday week).
|
||||||
|
-- entry_date remains the start of the range; end_date is NULL for a
|
||||||
|
-- single-day entry, keeping every existing row valid with no backfill.
|
||||||
|
|
||||||
|
ALTER TABLE journal_entries
|
||||||
|
ADD COLUMN end_date DATE NULL AFTER entry_date;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO schema_migrations (version) VALUES ('004_add_end_date');
|
||||||
@@ -72,7 +72,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fq"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fq)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fq"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fq)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -88,7 +91,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html" class="active">FAQ</a>
|
<a href="./faq.html" class="active">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -105,6 +115,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Frequently Asked Questions</h1>
|
<h1>Frequently Asked Questions</h1>
|
||||||
@@ -358,7 +369,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · · <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+15
-3
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fe"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fe)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fe"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fe)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -52,7 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -69,6 +79,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>On-screen features</h1>
|
<h1>On-screen features</h1>
|
||||||
@@ -163,7 +174,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope — Session auth ──────────────────────────────────────────────
|
||||||
|
// Opaque DB-backed session token in an HttpOnly cookie, not $_SESSION —
|
||||||
|
// keeps the door open for a future magic-link flow without a rewrite, and
|
||||||
|
// means a session can be revoked by deleting one row.
|
||||||
|
//
|
||||||
|
// The cookie holds the plaintext token; only its SHA-256 hash is stored,
|
||||||
|
// same pattern as a password hash, so a DB read alone can't forge a login.
|
||||||
|
|
||||||
|
const SESSION_COOKIE = 'ss_session';
|
||||||
|
const SESSION_DAYS = 30;
|
||||||
|
|
||||||
|
function new_public_id(): string {
|
||||||
|
return rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
function issue_session(int $userId): void {
|
||||||
|
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||||
|
$hash = hash('sha256', $token, true);
|
||||||
|
$expiresAt = (new DateTime())->modify('+' . SESSION_DAYS . ' days')->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId, $hash, $expiresAt]);
|
||||||
|
|
||||||
|
setcookie(SESSION_COOKIE, $token, [
|
||||||
|
'expires' => time() + SESSION_DAYS * 86400,
|
||||||
|
'path' => '/',
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
// 'secure' left off for local XAMPP http:// dev; set true behind HTTPS in production.
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear_session(): void {
|
||||||
|
$token = $_COOKIE[SESSION_COOKIE] ?? null;
|
||||||
|
if ($token) {
|
||||||
|
$hash = hash('sha256', $token, true);
|
||||||
|
$stmt = db()->prepare('DELETE FROM sessions WHERE token_hash = ?');
|
||||||
|
$stmt->execute([$hash]);
|
||||||
|
}
|
||||||
|
setcookie(SESSION_COOKIE, '', ['expires' => time() - 3600, 'path' => '/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the logged-in user's row, or null if there is no valid session.
|
||||||
|
function current_user(): ?array {
|
||||||
|
$token = $_COOKIE[SESSION_COOKIE] ?? null;
|
||||||
|
if (!$token) return null;
|
||||||
|
|
||||||
|
$hash = hash('sha256', $token, true);
|
||||||
|
$stmt = db()->prepare(
|
||||||
|
'SELECT u.id, u.public_id, u.email
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON u.id = s.user_id
|
||||||
|
WHERE s.token_hash = ? AND s.expires_at > NOW()'
|
||||||
|
);
|
||||||
|
$stmt->execute([$hash]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends the request with 401 if not logged in; otherwise returns the user row.
|
||||||
|
function require_user(): array {
|
||||||
|
$user = current_user();
|
||||||
|
if (!$user) json_error('Not signed in', 401);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope — API bootstrap ─────────────────────────────────────────────
|
||||||
|
// require'd at the top of every api/*.php endpoint.
|
||||||
|
|
||||||
|
require __DIR__ . '/../secrets.local.php';
|
||||||
|
require __DIR__ . '/db.php';
|
||||||
|
require __DIR__ . '/http.php';
|
||||||
|
require __DIR__ . '/auth.php';
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope — Database connection ──────────────────────────────────────
|
||||||
|
// One shared PDO connection, MySQL/MariaDB via XAMPP locally. Credentials
|
||||||
|
// live in secrets.local.php alongside the Stripe key, never committed.
|
||||||
|
|
||||||
|
function db(): PDO {
|
||||||
|
static $pdo = null;
|
||||||
|
if ($pdo !== null) return $pdo;
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
// ─── SunScope — JSON response helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
function json_out(array $data, int $status = 200): void {
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function json_error(string $message, int $status = 400): void {
|
||||||
|
json_out(['ok' => false, 'error' => $message], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json_body(): array {
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
|
return is_array($body) ? $body : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function require_method(string $method): void {
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== $method) {
|
||||||
|
json_error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-7
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-pr"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-pr)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-pr"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-pr)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -52,7 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -69,6 +79,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Forecast profiles</h1>
|
<h1>Forecast profiles</h1>
|
||||||
@@ -179,10 +190,10 @@
|
|||||||
<p>
|
<p>
|
||||||
Full control over which columns appear in the table, starting from a blank slate. Toggle any
|
Full control over which columns appear in the table, starting from a blank slate. Toggle any
|
||||||
combination of air temperature, Shade Air, dew point, relative humidity, wind, direction, cloud,
|
combination of air temperature, Shade Air, dew point, relative humidity, wind, direction, cloud,
|
||||||
visibility, air quality index, pollen, sun elevation, direct and diffuse radiation, mean radiant
|
visibility, air quality index, pollen, sun elevation, direct and diffuse radiation,
|
||||||
temperature, UTCI delta, UTCI, UV-A, UV-B, burn time, vehicle interior, indoor, managed indoor,
|
UTCI, UV-A, UV-B, burn time, vehicle interior, indoor, managed indoor,
|
||||||
soil temperatures, soil moisture, concrete surface, precipitation, rain probability, lightning
|
soil temperatures, soil moisture, concrete surface, precipitation, rain probability,
|
||||||
potential, and SunSoak. Build exactly the view that suits your situation.
|
and SunSoak. Build exactly the view that suits your situation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,7 +458,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+15
-3
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-sb"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-sb)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-sb"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-sb)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -52,7 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -69,6 +79,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Reading the stress bands</h1>
|
<h1>Reading the stress bands</h1>
|
||||||
@@ -107,7 +118,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+15
-3
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ss"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ss)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ss"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ss)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -52,7 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -69,6 +79,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>SunSoak & the science</h1>
|
<h1>SunSoak & the science</h1>
|
||||||
@@ -137,7 +148,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+15
-3
@@ -36,7 +36,10 @@
|
|||||||
|
|
||||||
<header id="site-nav">
|
<header id="site-nav">
|
||||||
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
<div class="nav-overlay" onclick="toggleNav(false)"></div>
|
||||||
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-te"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-te)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a>
|
<a href="./index.html" class="nav-logo">
|
||||||
|
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-te"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-te)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
|
||||||
|
<span class="nav-logo-tag">See the world the way your skin does.</span>
|
||||||
|
</a>
|
||||||
<div class="nav-links" id="nav-drawer">
|
<div class="nav-links" id="nav-drawer">
|
||||||
<a href="./index.html">Forecast</a>
|
<a href="./index.html">Forecast</a>
|
||||||
<div class="nav-dropdown">
|
<div class="nav-dropdown">
|
||||||
@@ -52,7 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="./faq.html">FAQ</a>
|
<a href="./faq.html">FAQ</a>
|
||||||
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
|
<div class="nav-dropdown nav-dropdown--right">
|
||||||
|
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
|
||||||
|
<div class="nav-dropdown-menu">
|
||||||
|
<a href="./dashboard.html">Dashboard</a>
|
||||||
|
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
|
||||||
|
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -69,6 +79,7 @@
|
|||||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="./assets/js/nav-account.js" defer></script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<h1>Derived temperatures</h1>
|
<h1>Derived temperatures</h1>
|
||||||
@@ -173,7 +184,8 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
© 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
|
||||||
|
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user