Compare commits
1
Commits
47818fba33
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71860b9dd9 |
+15
-3
@@ -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>
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-55
@@ -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.
|
||||||
@@ -408,33 +448,39 @@ body {
|
|||||||
color: #2a1a08;
|
color: #2a1a08;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapse on the way out — height and vertical spacing animate to zero so
|
/* Closing: the slot's height runs down to zero (measured and driven inline by
|
||||||
the page closes the gap smoothly instead of snapping upward. */
|
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 {
|
.event-note.is-closing {
|
||||||
animation: event-note-out 0.26s ease forwards;
|
animation: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@keyframes event-note-out {
|
|
||||||
from { opacity: 1; transform: translateY(0); max-height: 200px; }
|
|
||||||
to { opacity: 0; transform: translateY(-4px); max-height: 0;
|
|
||||||
margin-bottom: 0; padding-top: 0; padding-bottom: 0; border-width: 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Above true-mobile: the nav drops into the flow beneath the note. Its own
|
/* The note follows the header in the flow now, so it needs no special
|
||||||
32px side padding is dropped so the links line up with the note and the
|
positioning — only the gap the header's own margin-bottom would otherwise
|
||||||
content rather than sitting 32px inside them. */
|
have given the content it sits in front of. */
|
||||||
@media (min-width: 641px) {
|
|
||||||
.utci-app.has-event-note {
|
|
||||||
padding-top: 18px;
|
|
||||||
}
|
|
||||||
/* `relative`, not `static` — the nav keeps its z-index:100 that way, so
|
|
||||||
the About dropdown still paints over .utci-shell (which is z-index:1). */
|
|
||||||
.utci-app.has-event-note .utci-topnav {
|
|
||||||
position: relative;
|
|
||||||
padding-left: 0;
|
|
||||||
padding-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.event-note {
|
.event-note {
|
||||||
@@ -456,26 +502,89 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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;
|
||||||
}
|
}
|
||||||
@@ -484,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;
|
||||||
@@ -548,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;
|
||||||
@@ -616,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 {
|
||||||
|
|||||||
+271
-32
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1257,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; }
|
||||||
@@ -1975,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 {
|
||||||
@@ -2277,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;
|
||||||
@@ -2285,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;
|
||||||
@@ -2360,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;
|
||||||
}
|
}
|
||||||
@@ -2368,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 {
|
||||||
@@ -2503,6 +2672,11 @@
|
|||||||
opacity: 0.9;
|
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; }
|
||||||
|
|
||||||
@@ -2514,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 {
|
||||||
@@ -2566,7 +2746,7 @@
|
|||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
}
|
}
|
||||||
.fsc-temp-curve {
|
.fsc-temp-curve {
|
||||||
min-height: 48px;
|
height: 72px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2574,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.
|
||||||
@@ -2591,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
|
||||||
@@ -2703,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. */
|
||||||
@@ -2719,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 {
|
||||||
@@ -2732,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);
|
||||||
@@ -2745,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 {
|
||||||
@@ -2760,12 +2967,34 @@
|
|||||||
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) {
|
||||||
/* Tighter segments on narrow screens — the toolbar is already tight,
|
/* Tighter segments on narrow screens — the toolbar is already tight,
|
||||||
and there are three of them now. */
|
and there are three of them now. */
|
||||||
@@ -2828,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-18
@@ -1110,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
|
||||||
@@ -1381,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;
|
||||||
@@ -1393,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1446,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 {
|
||||||
@@ -1524,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;
|
||||||
@@ -1627,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";
|
||||||
@@ -1639,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. */
|
||||||
@@ -1765,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;
|
||||||
@@ -1930,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 {
|
||||||
|
|||||||
+323
-125
@@ -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';
|
||||||
@@ -255,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.
|
||||||
@@ -272,10 +290,30 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Dismissal ────────────────────────────────────────────────────────
|
// ── Dismissal ────────────────────────────────────────────────────────
|
||||||
@@ -293,31 +331,70 @@ export function UTCIForecast() {
|
|||||||
const [noteClosing, setNoteClosing] = useState(false);
|
const [noteClosing, setNoteClosing] = useState(false);
|
||||||
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
|
const noteDismissed = !!noteSig && noteSig === noteClosedSig;
|
||||||
|
|
||||||
|
const noteSlotRef = useRef(null);
|
||||||
|
|
||||||
const noteClose = () => {
|
const noteClose = () => {
|
||||||
if (noteClosing) return;
|
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);
|
setNoteClosing(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
|
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
|
||||||
setNoteClosedSig(noteSig);
|
setNoteClosedSig(noteSig);
|
||||||
setNoteClosing(false);
|
setNoteClosing(false);
|
||||||
|
noteClosingRef.current = false;
|
||||||
}, NOTE_CLOSE_MS);
|
}, NOTE_CLOSE_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the index in range if the event list shrinks between forecasts.
|
// 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);
|
||||||
@@ -329,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;
|
||||||
@@ -585,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, {
|
||||||
@@ -713,8 +829,59 @@ export function UTCIForecast() {
|
|||||||
// • "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 && !noteDismissed ? ' has-event-note' : ''}`}>
|
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
|
||||||
|
<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 && (() => {
|
${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' })
|
||||||
@@ -746,6 +913,10 @@ export function UTCIForecast() {
|
|||||||
};
|
};
|
||||||
})() : undefined;
|
})() : undefined;
|
||||||
return html`
|
return html`
|
||||||
|
${/* 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}>
|
<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
|
||||||
@@ -756,7 +927,7 @@ 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}
|
||||||
@@ -780,7 +951,7 @@ 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)}
|
||||||
/>`)}
|
/>`)}
|
||||||
@@ -798,54 +969,13 @@ export function UTCIForecast() {
|
|||||||
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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
|
||||||
@@ -873,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">
|
||||||
@@ -918,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 || [])
|
||||||
@@ -1094,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>
|
||||||
@@ -1262,6 +1413,7 @@ 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('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>`}
|
||||||
@@ -1274,7 +1426,6 @@ export function UTCIForecast() {
|
|||||||
${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</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('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('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
|
||||||
${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</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>`}
|
||||||
@@ -1307,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 }}>
|
||||||
@@ -1318,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;
|
||||||
@@ -1356,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>
|
||||||
@@ -1512,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
|
||||||
@@ -1681,9 +1874,9 @@ 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`
|
||||||
@@ -1779,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 },
|
||||||
@@ -1806,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 },
|
||||||
@@ -1940,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`
|
||||||
|
|||||||
+533
-66
@@ -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, UTCI_BANDS, PET_BANDS, bandRampRgb, 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);
|
||||||
@@ -114,6 +115,82 @@ function sustainedHigh(vals) {
|
|||||||
return sorted[Math.min(drop, sorted.length - 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));
|
||||||
@@ -179,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">
|
||||||
@@ -244,6 +345,12 @@ 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 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
|
// 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
|
// whole tab warm. The Danger tier is the exception — it keeps alarming on
|
||||||
@@ -252,8 +359,68 @@ export function DayTabs({
|
|||||||
// always show the true dayHi/dayLo.
|
// always show the true dayHi/dayLo.
|
||||||
const catFn = isPetsProfile ? petCategory : utciCategory;
|
const catFn = isPetsProfile ? petCategory : utciCategory;
|
||||||
const isDanger = dayHi !== null && catFn(dayHi).solid === true;
|
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
|
const colourHi = dayHi === null ? null
|
||||||
: (isDanger ? dayHi : Math.round(sustainedHigh(mainVals)));
|
: 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
|
||||||
@@ -265,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 = {};
|
||||||
@@ -279,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
|
||||||
@@ -305,12 +503,65 @@ 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 tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' };
|
const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' };
|
||||||
@@ -321,17 +572,71 @@ export function DayTabs({
|
|||||||
// alarm colour, not a point on the gradient.
|
// alarm colour, not a point on the gradient.
|
||||||
const tBandRgb = tBand.solid
|
const tBandRgb = tBand.solid
|
||||||
? hexToRgb(tBand.bg)
|
? hexToRgb(tBand.bg)
|
||||||
: bandRampRgb(colourHi, isPetsProfile ? PET_BANDS : UTCI_BANDS);
|
: 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 = colourHi !== null && colourHi >= (isPetsProfile ? 35 : 27); // Hot band and above
|
// (skyCap, below) and how pastel the tab is drawn (further down).
|
||||||
|
// 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
|
// Extreme band gets a less pastelised gradient so it reads as a
|
||||||
// clear step between Caution and the pulsing Danger tier.
|
// clear step between Caution and the pulsing Danger tier.
|
||||||
const isExtreme = tBand.label === 'Extreme';
|
const isExtreme = tBand.label === 'Extreme';
|
||||||
@@ -341,32 +646,93 @@ export function DayTabs({
|
|||||||
// — 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 = tBandRgb;
|
// 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
|
|
||||||
// greens) and rain does not override heat.
|
|
||||||
const heatFactor = isPetsProfile
|
|
||||||
? (colourHi >= 49 ? 0.15 : colourHi >= 43 ? 0.30 : 0.45)
|
|
||||||
: (colourHi >= 39 ? 0.15 : colourHi >= 34 ? 0.30 : 0.45);
|
|
||||||
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
|
|
||||||
rgb = mixRgb(tBandRgb, [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 (colourHi !== null && colourHi < (isPetsProfile ? 2 : 10)) {
|
|
||||||
// Clear & cold: keep the cold temperature blue.
|
|
||||||
rgb = tBandRgb;
|
rgb = tBandRgb;
|
||||||
} 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
|
// 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
|
// through the band (32→41°C human, 40→52°C pet) — so a day peaking
|
||||||
@@ -386,22 +752,106 @@ export function DayTabs({
|
|||||||
// while 32° snapped to a solid orange. Ramp the blend continuously
|
// while 32° snapped to a solid orange. Ramp the blend continuously
|
||||||
// instead — the endpoints still land on the old Extreme (heatT = 1)
|
// instead — the endpoints still land on the old Extreme (heatT = 1)
|
||||||
// and Danger (both = 1) values, so only the in-between days change.
|
// and Danger (both = 1) values, so only the in-between days change.
|
||||||
const heatFloor = isPetsProfile ? 35 : 27; // start of the hot half
|
//
|
||||||
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
|
// The cold end goes TWO-TONE instead of just getting darker. Cold
|
||||||
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
|
// band colours are pale to begin with, so deepening them uniformly
|
||||||
const clamp01 = (v) => Math.max(0, Math.min(1, v));
|
// only makes a murky blue-grey — and a hard winter's day should look
|
||||||
const heatT = colourHi === null ? 0 : clamp01((colourHi - heatFloor) / (heatMid - heatFloor));
|
// unmistakably different from a mild one, not slightly duller. So the
|
||||||
const overT = colourHi === null ? 0 : clamp01((colourHi - heatMid) / (heatTop - heatMid));
|
// two ends of the gradient are pulled apart as the day gets colder:
|
||||||
const wBgNeutral = weatherGradientNeutral(
|
// the rim deepens toward the true band colour while the core lifts
|
||||||
wR, wG, wB,
|
// toward frost-white, giving a frosted, iced-over tab. Same mechanism
|
||||||
0.50 - 0.30 * heatT - 0.05 * overT, // edge: 0.50 → 0.20 → 0.15
|
// Danger already uses (dark edge, light centre) at the opposite end
|
||||||
0.69 - 0.03 * heatT + 0.06 * overT, // centre: 0.69 → 0.66 → 0.72
|
// of the scale. decisive() delays the onset so an ordinary 3°C
|
||||||
);
|
// morning stays plain and only a genuinely cold day frosts over.
|
||||||
const wBgActive = weatherGradientActive(
|
// The frost lift on the CENTRE was +0.22, which put the core at 0.91
|
||||||
wR, wG, wB,
|
// — effectively pure white (#f3f9fd). The band colour survived only
|
||||||
0.42 - 0.26 * heatT - 0.04 * overT, // edge: 0.42 → 0.16 → 0.12
|
// as a thin rim, so the coldest, most dangerous days rendered as the
|
||||||
0.64 + 0.02 * heatT + 0.06 * overT, // centre: 0.64 → 0.66 → 0.72
|
// 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)})`;
|
||||||
@@ -422,9 +872,10 @@ 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,
|
||||||
@@ -447,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} />`
|
||||||
@@ -456,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=${{
|
||||||
|
|||||||
+155
-15
@@ -1003,7 +1003,7 @@ 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 -
|
// Said when no day qualifies, phrased for what was actually being judged -
|
||||||
// "no settled outdoor windows" is meaningless when the panel was reading a
|
// "no settled outdoor windows" is meaningless when the panel was reading a
|
||||||
@@ -1116,19 +1116,147 @@ function comfortableHour(r, crit) {
|
|||||||
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, crit) {
|
function hourQuality(r, crit) {
|
||||||
const parts = [
|
const p = hourParts(r, crit);
|
||||||
[plateauScore(r[crit.field], crit.pl), crit.w.thermal],
|
const parts = Object.keys(p)
|
||||||
[bandScore(r.precipProb ?? 0, BAND_RAIN), crit.w.rain],
|
.map(k => [p[k], crit.w[k]])
|
||||||
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust],
|
.filter(([v, w]) => v != null && w > 0);
|
||||||
[bandScore(r.soilM, BAND_SOIL), crit.w.soil],
|
|
||||||
].filter(([v, w]) => v != null && w > 0);
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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) {
|
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 crit = bestDayCriteria(profile, variant);
|
||||||
@@ -1160,14 +1288,16 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
|||||||
const quality = run.reduce((s, r) => s + hourQuality(r, crit), 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],
|
||||||
// What the window is measured AGAINST: the daylight for a profile that
|
// 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
|
// only counts daylight hours, otherwise the whole day. Using daylight
|
||||||
// for a round-the-clock profile would let a 14-hour overnight window
|
// 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
|
// score over 100% of a 16-hour day and print "all day" for a spell that
|
||||||
// ends at breakfast.
|
// ends at breakfast.
|
||||||
usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length,
|
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,
|
||||||
@@ -1192,12 +1322,19 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
|||||||
// 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.usable > 0 ? Math.min(c.len / c.usable, 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)
|
||||||
@@ -1220,12 +1357,15 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
|
|||||||
|
|
||||||
// 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.usable > 0 && c.len >= c.usable - 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
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
})();
|
||||||
@@ -41,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]],
|
||||||
|
|||||||
+55
-4
@@ -113,10 +113,11 @@ export function bandRampRgb(t, bands) {
|
|||||||
// 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);
|
||||||
@@ -179,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('========================================');
|
||||||
|
|||||||
+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-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>
|
||||||
@@ -129,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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-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>
|
||||||
@@ -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