2331 lines
111 KiB
HTML
2331 lines
111 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Background Clouds3D</title>
|
|
<script src="config.js"></script>
|
|
<script src="i18n.js"></script>
|
|
<script>
|
|
(function() {
|
|
var theme = (window.CONFIG && window.CONFIG.theme) || 'ash';
|
|
var cssFile = window.THEMES && window.THEMES[theme];
|
|
var css = document.getElementById('theme-css');
|
|
if (css) css.href = cssFile || (window.THEMES && window.THEMES.ash) || 'ash.css';
|
|
})();
|
|
</script>
|
|
<link id="theme-css" rel="stylesheet" href="ash.css">
|
|
<script src="vendor/suncalc.js"></script>
|
|
<style>
|
|
html, body { margin: 0; padding: 0; overflow: hidden; width: 100%; height: 100%; font-family: monospace; }
|
|
#scene { width: 100vw; height: 100vh; position: fixed; top: 0; left: 0; }
|
|
#debug-panel {
|
|
position: fixed;
|
|
bottom: 2vh;
|
|
left: 2vh;
|
|
width: 20vw;
|
|
min-width: 200px;
|
|
max-width: 320px;
|
|
background: rgba(28, 27, 31, 0.85);
|
|
border: 1px solid rgba(230, 223, 209, 0.3);
|
|
border-radius: 0.6vh;
|
|
padding: 1vh 1vw;
|
|
color: var(--bone);
|
|
font-family: monospace;
|
|
font-size: clamp(9px, 1.2vh, 14px);
|
|
line-height: 1.4;
|
|
z-index: 9999;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
.debug-title {
|
|
font-size: 1.2em;
|
|
font-weight: bold;
|
|
color: var(--orange);
|
|
padding-bottom: 4px;
|
|
margin-bottom: 6px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.debug-title.expanded {
|
|
border-bottom: 1px solid rgba(230, 223, 209, 0.3);
|
|
margin-bottom: 6px;
|
|
}
|
|
.debug-title-toggle {
|
|
font-size: 0.8em;
|
|
color: var(--icon);
|
|
margin-left: 8px;
|
|
}
|
|
.debug-content.collapsed {
|
|
display: none;
|
|
}
|
|
.debug-controls {
|
|
display: flex;
|
|
gap: 5px;
|
|
margin-bottom: 8px;
|
|
}
|
|
.debug-select {
|
|
background: var(--dark);
|
|
color: var(--bone);
|
|
border: 1px solid var(--border);
|
|
border-radius: 2px;
|
|
padding: 2px 4px;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
.debug-section {
|
|
font-size: 1em;
|
|
color: var(--icon);
|
|
margin-top: 6px;
|
|
margin-bottom: 2px;
|
|
}
|
|
.debug-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 1px 0;
|
|
}
|
|
.debug-label {
|
|
color: var(--icon);
|
|
min-width: 80px;
|
|
}
|
|
.libel {
|
|
color: var(--icon);
|
|
min-width: 50px;
|
|
}
|
|
.hex {
|
|
color: var(--bone);
|
|
font-size: 0.9em;
|
|
font-family: monospace;
|
|
}
|
|
.swatch {
|
|
width: 1.4em;
|
|
height: 1.4em;
|
|
border: 1px solid var(--stone);
|
|
border-radius: 2px;
|
|
}
|
|
.debug-row-buttons {
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
}
|
|
.dbg-btn {
|
|
background: var(--panel);
|
|
color: var(--icon);
|
|
border: 1px solid var(--border);
|
|
border-radius: 2px;
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
font-family: inherit;
|
|
cursor: pointer;
|
|
}
|
|
.dbg-btn:hover { background: var(--raised); }
|
|
.dbg-btn.dbg-btn-toggle.on { background: var(--ember); color: var(--dark); }
|
|
.theme-table {
|
|
font-size: 0.9em;
|
|
margin-top: 4px;
|
|
}
|
|
.theme-table-row {
|
|
display: grid;
|
|
grid-template-columns: 5em 2.4em 2.4em 2.4em 2.4em 2.8em;
|
|
gap: 5px;
|
|
align-items: center;
|
|
padding: 1px 0;
|
|
}
|
|
.palette-table-row {
|
|
display: grid;
|
|
grid-template-columns: 7em 1.6em auto;
|
|
gap: 5px;
|
|
align-items: center;
|
|
padding: 1px 0;
|
|
}
|
|
.palette-table-row .theme-table-name {
|
|
text-align: right;
|
|
padding-right: 4px;
|
|
}
|
|
.palette-table-row .theme-table-speed {
|
|
text-align: left;
|
|
}
|
|
.theme-table-header {
|
|
color: var(--icon);
|
|
font-weight: bold;
|
|
border-bottom: 1px solid var(--border);
|
|
padding-bottom: 2px;
|
|
}
|
|
.theme-table-name {
|
|
color: var(--bone);
|
|
}
|
|
.theme-table-mini-swatch {
|
|
width: 1.3em;
|
|
height: 1.3em;
|
|
border: 1px solid var(--border);
|
|
border-radius: 1px;
|
|
}
|
|
.theme-table-speed {
|
|
color: var(--icon);
|
|
font-size: 0.85em;
|
|
}
|
|
@media (orientation: portrait) and (max-width: 700px) {
|
|
#debug-panel {
|
|
display: none;
|
|
}
|
|
}
|
|
#debug-panel.disabled {
|
|
display: none !important;
|
|
}
|
|
#info-panel {
|
|
position: fixed;
|
|
top: 2vh;
|
|
right: 2vh;
|
|
bottom: 2vh;
|
|
color: var(--bone);
|
|
background: rgba(28, 27, 31, 0.7);
|
|
padding: 1vh 1vw;
|
|
border-radius: 0.6vh;
|
|
z-index: 9999;
|
|
font-size: clamp(10px, 1.2vh, 14px);
|
|
line-height: 1.4;
|
|
width: clamp(260px, min(33vh, 28vw), 40vw);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
align-items: stretch;
|
|
}
|
|
|
|
@media (orientation: portrait) and (max-width: 700px) {
|
|
#info-panel {
|
|
width: 90vw;
|
|
}
|
|
}
|
|
#analog-clock {
|
|
width: 100%;
|
|
height: auto;
|
|
margin: 0 auto 0.4vh auto;
|
|
display: block;
|
|
flex-shrink: 0;
|
|
}
|
|
#digital-clock {
|
|
text-align: center;
|
|
font-size: 1.3em;
|
|
letter-spacing: 2px;
|
|
margin-bottom: 0.6vh;
|
|
padding-bottom: 0.5vh;
|
|
border-bottom: 1px solid rgba(230, 223, 209, 0.2);
|
|
white-space: pre;
|
|
}
|
|
.info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6vw;
|
|
padding: 0.05vh 0;
|
|
}
|
|
.info-icon {
|
|
width: 1.4em;
|
|
height: 1.4em;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.info-icon svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.info-content {
|
|
flex-grow: 1;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.6vw;
|
|
}
|
|
.info-label {
|
|
color: var(--icon);
|
|
}
|
|
.info-value {
|
|
color: var(--bone);
|
|
font-weight: bold;
|
|
}
|
|
.info-sub {
|
|
color: var(--stone);
|
|
font-size: 0.85em;
|
|
margin-left: 4px;
|
|
}
|
|
.weather-divider {
|
|
height: 1px;
|
|
background: rgba(230, 223, 209, 0.2);
|
|
margin: 0.5vh 0;
|
|
}
|
|
.weather-current {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.8vw;
|
|
padding: 0.8vh 0 1vh 0;
|
|
border-bottom: 1px solid rgba(230, 223, 209, 0.2);
|
|
margin-bottom: 1vh;
|
|
}
|
|
.weather-current-icon {
|
|
width: 3.5em;
|
|
height: 3.5em;
|
|
flex-shrink: 0;
|
|
}
|
|
.weather-current-icon svg { width: 100%; height: 100%; }
|
|
.weather-current-temp {
|
|
font-size: 2.4em;
|
|
font-weight: bold;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
gap: 1vw;
|
|
}
|
|
.weather-location {
|
|
font-size: 0.45em;
|
|
font-weight: normal;
|
|
color: var(--icon);
|
|
text-align: right;
|
|
word-break: break-word;
|
|
max-width: 60%;
|
|
}
|
|
.weather-current-detail {
|
|
font-size: 1em;
|
|
color: var(--icon);
|
|
line-height: 1.3;
|
|
}
|
|
.credits {
|
|
margin-top: 1.2vh;
|
|
font-size: 0.72em;
|
|
color: var(--gray);
|
|
opacity: 0.75;
|
|
line-height: 1.45;
|
|
text-align: center;
|
|
}
|
|
.weather-day {
|
|
margin-bottom: 0.6vh;
|
|
}
|
|
.weather-day-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
padding: 0.2vh 0;
|
|
font-size: 1em;
|
|
}
|
|
.weather-day-name {
|
|
color: var(--bone);
|
|
font-weight: bold;
|
|
}
|
|
.weather-day-minmax {
|
|
color: var(--bone);
|
|
font-size: 0.85em;
|
|
}
|
|
.weather-day-slots {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.3vw;
|
|
font-size: 0.9em;
|
|
margin-top: 0.3vh;
|
|
}
|
|
.weather-slot {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
flex: 1;
|
|
gap: 0.3vh;
|
|
}
|
|
.weather-slot-icon {
|
|
width: 2em;
|
|
height: 2em;
|
|
}
|
|
.weather-slot-icon svg { width: 100%; height: 100%; }
|
|
.weather-slot-label {
|
|
color: var(--stone);
|
|
font-size: 0.8em;
|
|
}
|
|
.weather-slot-temp {
|
|
color: var(--bone);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="scene"></div>
|
|
<div id="debug-panel">
|
|
<div class="debug-title expanded" onclick="toggleDebugPanel()">\n<span>DEBUG · <span id="dbg-fps">--</span> fps</span><span class="debug-title-toggle" id="debug-toggle-icon">▼</span></div>
|
|
<div id="debug-content" class="debug-content">
|
|
<div class="debug-controls">
|
|
<select id="dbg-theme-select" class="debug-select" onchange="setDebugTheme(this.value)">
|
|
<!-- Populated dynamically by populateDebugSelects() -->
|
|
</select>
|
|
<select id="dbg-units-select" class="debug-select" onchange="setDebugUnits(this.value)">
|
|
<!-- Populated dynamically by populateDebugSelects() -->
|
|
</select>
|
|
<select id="dbg-language-select" class="debug-select" onchange="setDebugLanguage(this.value)">
|
|
<!-- Populated dynamically by populateDebugSelects() -->
|
|
</select>
|
|
</div>
|
|
<div class="debug-row"><span class="debug-label">Time:</span> <span id="dbg-time">--</span></div>
|
|
<div class="debug-row"><span class="debug-label">Day%:</span> <span id="dbg-day">--</span></div>
|
|
<div class="debug-row">
|
|
<span class="debug-label">Weather:</span>
|
|
<span id="dbg-weather">--</span>
|
|
</div>
|
|
<div class="debug-row debug-row-buttons">
|
|
<button class="dbg-btn" onclick="setWeatherTheme('sunny')">Sunny</button>
|
|
<button class="dbg-btn" onclick="setWeatherTheme('cloudy')">Cloudy</button>
|
|
<button class="dbg-btn" onclick="setWeatherTheme('stormy')">Stormy</button>
|
|
<button class="dbg-btn" onclick="setWeatherTheme('night')">Night</button>
|
|
</div>
|
|
<div class="debug-row debug-row-buttons">
|
|
<button class="dbg-btn dbg-btn-toggle" id="dbg-btn-sim"
|
|
onclick="toggleWeatherSim()">Auto-Cycle: off</button>
|
|
<button class="dbg-btn" onclick="stopWeatherSim()">Stop</button>
|
|
<button class="dbg-btn" onclick="startWeatherSim(15000)">15s cycle</button>
|
|
<button class="dbg-btn" onclick="startWeatherSim(5000)">5s cycle</button>
|
|
</div>
|
|
<div class="debug-row"><span class="debug-label">Theme:</span> <span id="dbg-theme">--</span></div>
|
|
<div class="debug-row"><span class="debug-label">Speed:</span> <span id="dbg-speed">--</span></div>
|
|
<div class="debug-row"><span class="debug-label">Transition:</span> <span id="dbg-trans">--</span></div>
|
|
<div class="debug-section">Effective theme</div>
|
|
<div class="debug-row"><span class="libel">skyColor</span> <div class="swatch" id="dbg-t-sky"></div> <span class="hex" id="dbg-h-sky">--</span></div>
|
|
<div class="debug-row"><span class="libel">cloudColor</span> <div class="swatch" id="dbg-t-cloud"></div> <span class="hex" id="dbg-h-cloud">--</span></div>
|
|
<div class="debug-row"><span class="libel">lightColor</span> <div class="swatch" id="dbg-t-sunLight"></div> <span class="hex" id="dbg-h-sunLight">--</span></div>
|
|
<div class="debug-row"><span class="libel">bgColor</span> <div class="swatch" id="dbg-t-bg"></div> <span class="hex" id="dbg-h-bg">--</span></div>
|
|
<div class="debug-section">Scene palette</div>
|
|
<div class="debug-row"><span class="libel">skyColor</span> <div class="swatch" id="dbg-v-sky"></div> <span class="hex" id="dbg-vh-sky">--</span></div>
|
|
<div class="debug-row"><span class="libel">cloudColor</span> <div class="swatch" id="dbg-v-cloud"></div> <span class="hex" id="dbg-vh-cloud">--</span></div>
|
|
<div class="debug-row"><span class="libel">lightColor</span> <div class="swatch" id="dbg-v-sunLight"></div> <span class="hex" id="dbg-vh-sunLight">--</span></div>
|
|
<div class="debug-row"><span class="libel">bgColor</span> <div class="swatch" id="dbg-v-bg"></div> <span class="hex" id="dbg-vh-bg">--</span></div>
|
|
<div class="debug-section">Theme table</div>
|
|
<div id="dbg-theme-table" class="theme-table"></div>
|
|
<div id="dbg-color-table" class="theme-table"></div>
|
|
<div class="debug-section">RPG angles (degrees)</div>
|
|
<div class="debug-row"><span class="libel">sun rise</span> <span id="dbg-sun-rise-angle">--</span></div>
|
|
<div class="debug-row"><span class="libel">sun set</span> <span id="dbg-sun-set-angle">--</span></div>
|
|
<div class="debug-row"><span class="libel">sun now</span> <span id="dbg-sun-now-angle">--</span></div>
|
|
<div class="debug-row"><span class="libel">moon rise</span> <span id="dbg-moon-rise-angle">--</span></div>
|
|
<div class="debug-row"><span class="libel">moon set</span> <span id="dbg-moon-set-angle">--</span></div>
|
|
<div class="debug-row"><span class="libel">moon now</span> <span id="dbg-moon-now-angle">--</span></div>
|
|
</div>
|
|
</div>
|
|
<div id="info-panel">
|
|
<svg id="analog-clock" viewBox="0 0 280 280">
|
|
<!-- Clock face -->
|
|
<circle cx="140" cy="140" r="135" fill="rgba(28,27,31,0.3)" stroke="var(--bone)" stroke-width="2"/>
|
|
|
|
<!-- Hour marks -->
|
|
<g id="hour-marks"></g>
|
|
|
|
<!-- Sun orbit (radius 65) -->
|
|
<circle cx="140" cy="140" r="65" stroke="var(--gray-dark)" stroke-width="1" fill="none" stroke-dasharray="2,3" opacity="0.6"/>
|
|
|
|
<!-- Moon orbit (radius 88) -->
|
|
<circle cx="140" cy="140" r="88" stroke="var(--gray-dark)" stroke-width="1" fill="none" stroke-dasharray="2,3" opacity="0.6"/>
|
|
|
|
<!-- Sun arc (from sunrise to sunset) -->
|
|
<path id="sun-arc" d="" stroke="var(--orange)" stroke-width="2.5" fill="none"/>
|
|
|
|
<!-- Moon arc (from moonrise to moonset) -->
|
|
<path id="moon-arc" d="" stroke="var(--icon)" stroke-width="2.5" fill="none"/>
|
|
|
|
<!-- Sun rise arrow (pointing in / up at sunrise) -->
|
|
<g id="sun-rise-arrow" fill="var(--orange)">
|
|
<polygon points="0,-9 8,0 -9,0"/>
|
|
</g>
|
|
|
|
<!-- Sun set arrow (pointing out / down at sunset) -->
|
|
<g id="sun-set-arrow" fill="var(--orange)">
|
|
<polygon points="0,9 8,0 -9,0"/>
|
|
</g>
|
|
|
|
<!-- Moon rise arrow -->
|
|
<g id="moon-rise-arrow" fill="var(--icon)">
|
|
<polygon points="0,-9 8,0 -9,0"/>
|
|
</g>
|
|
|
|
<!-- Moon set arrow -->
|
|
<g id="moon-set-arrow" fill="var(--icon)">
|
|
<polygon points="0,9 8,0 -9,0"/>
|
|
</g>
|
|
|
|
<!-- Sun zenith marker (diamond shape, small) -->
|
|
<g id="sun-zenith-marker" fill="var(--orange)">
|
|
<polygon points="0,-5 5,0 0,5 -5,0"/>
|
|
</g>
|
|
|
|
<!-- Moon zenith marker removed -->
|
|
|
|
<!-- Sun position (rendered after arcs/arrows so it's visible) -->
|
|
<circle id="rpg-sun-glow" cx="140" cy="140" r="14" fill="var(--orange)" opacity="0.4"/>
|
|
<circle id="rpg-sun" cx="140" cy="140" r="8" fill="var(--orange)"/>
|
|
<circle id="rpg-sun-core" cx="140" cy="140" r="3.5" fill="var(--chalk)"/>
|
|
|
|
<!-- Moon position (with phase) -->
|
|
<g id="rpg-moon">
|
|
<circle id="rpg-moon-bg" cx="0" cy="0" r="10" fill="var(--dark)" stroke="var(--stone)" stroke-width="1"/>
|
|
<path id="rpg-moon-lit" d="" fill="var(--bone)"/>
|
|
<path id="rpg-moon-dark" d="" fill="var(--gray)"/>
|
|
</g>
|
|
|
|
<!-- Clock hands (on top of everything) -->
|
|
<line id="hour-hand" x1="140" y1="140" x2="140" y2="49" stroke="var(--bone)" stroke-width="7" stroke-linecap="round" opacity="0.7"/>
|
|
<line id="minute-hand" x1="140" y1="140" x2="140" y2="28" stroke="var(--bone)" stroke-width="5" stroke-linecap="round" opacity="0.7"/>
|
|
<line id="second-hand" x1="140" y1="140" x2="140" y2="20" stroke="var(--red)" stroke-width="2.5" stroke-linecap="round"/>
|
|
<!-- Flight-direction compass needle: north tip points where the
|
|
camera looks (driven by window.__flightBearing from clouds3d) -->
|
|
<g id="compass-needle" transform="rotate(0 140 140)" style="display:none">
|
|
<polygon points="140,103 134,140 140,146 146,140" fill="var(--red)" opacity="0.9"/>
|
|
<polygon points="140,177 134,140 140,134 146,140" fill="var(--stone)" opacity="0.6"/>
|
|
<circle cx="140" cy="140" r="6" fill="var(--dark)" stroke="var(--bone)" stroke-width="1.5"/>
|
|
</g>
|
|
<circle cx="140" cy="140" r="5" fill="var(--red)"/>
|
|
</svg>
|
|
<div id="digital-clock">00:00:00</div>
|
|
<div class="info-row">
|
|
<div class="info-icon">
|
|
<svg viewBox="0 0 24 24" fill="var(--orange)"><circle cx="12" cy="12" r="4"/><g stroke="var(--orange)" stroke-width="2" stroke-linecap="round"><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.9" y2="6.9"/><line x1="17.1" y1="17.1" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.9" y2="17.1"/><line x1="17.1" y1="6.9" x2="19.1" y2="4.9"/></g></svg>
|
|
</div>
|
|
<div class="info-content"><span class="info-label" data-i18n="Sunrise">Sunrise</span><span><span class="info-value" id="sunrise-value">--:--</span><span class="info-sub" id="sunrise-when"></span></span></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-icon">
|
|
<svg viewBox="0 0 24 24" fill="var(--orange)"><circle cx="12" cy="12" r="4"/><line x1="12" y1="12" x2="12" y2="3" stroke="var(--orange)" stroke-width="2"/></svg>
|
|
</div>
|
|
<div class="info-content"><span class="info-label" data-i18n="Sun Zenith">Sun Zenith</span><span><span class="info-value" id="sun-zenith-value">--:--</span><span class="info-sub" id="sun-zenith-when"></span></span></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="var(--orange)" stroke-width="2"><path d="M3 12 A9 9 0 0 1 21 12 Z"/></svg>
|
|
</div>
|
|
<div class="info-content"><span class="info-label" data-i18n="Sunset">Sunset</span><span><span class="info-value" id="sunset-value">--:--</span><span class="info-sub" id="sunset-when"></span></span></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-icon">
|
|
<svg id="moon-phase-icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="var(--dark)"/></svg>
|
|
</div>
|
|
<div class="info-content"><span class="info-label" data-i18n="Moon Phase">Moon Phase</span><span class="info-value" id="moon-phase-text">--</span></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-icon">
|
|
<svg viewBox="0 0 24 24" fill="var(--icon)"><path d="M12 2 a10 10 0 1 0 0 20 a8 8 0 1 1 0 -16 a6 6 0 1 1 0 8"/></svg>
|
|
</div>
|
|
<div class="info-content"><span class="info-label" data-i18n="Moonrise">Moonrise</span><span><span class="info-value" id="moonrise-value">--:--</span><span class="info-sub" id="moonrise-when"></span></span></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-icon">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="var(--icon)" stroke-width="2"><path d="M3 12 A9 9 0 0 1 21 22 Z"/></svg>
|
|
</div>
|
|
<div class="info-content"><span class="info-label" data-i18n="Moonset">Moonset</span><span><span class="info-value" id="moonset-value">--:--</span><span class="info-sub" id="moonset-when"></span></span></div>
|
|
</div>
|
|
<div id="weather-divider" class="weather-divider"></div>
|
|
<div id="weather-current" class="weather-current"></div>
|
|
<div id="weather-forecast" class="weather-forecast"></div>
|
|
<div id="credits" class="credits"></div>
|
|
</div>
|
|
<script src="vendor/clouds3d.js"></script>
|
|
<script>
|
|
window.__cloudsStarted = true;
|
|
try {
|
|
// Parse URL hash params: #key=value&key=value...
|
|
// Supported: #flight=N|NE|E|SE|S|SW|W|NW, #fetch=N|NE|E|SE|S|SW|W|NW
|
|
function _parseHashParams(hash) {
|
|
var out = {};
|
|
hash = (hash || '').replace(/^#/, '');
|
|
var pairs = hash.split(/[&;]/);
|
|
for (var i = 0; i < pairs.length; i++) {
|
|
var eq = pairs[i].indexOf('=');
|
|
if (eq > 0) {
|
|
out[pairs[i].slice(0, eq).toLowerCase()] = pairs[i].slice(eq + 1);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
var _hashParams = _parseHashParams(location.hash);
|
|
|
|
// Compass directions for flight and fetch (independent). Each direction
|
|
// has cos/sin (corridor rotation matrix) and dx/dz (camera motion unit vec).
|
|
var FLIGHT_DIRS = {
|
|
N: { cos: 0.0, sin: 1.0, dx: 0, dz: 1 },
|
|
NE: { cos: 0.7071068, sin: 0.7071068, dx: 0.7071068, dz: 0.7071068 },
|
|
E: { cos: 1.0, sin: 0.0, dx: 1, dz: 0 },
|
|
SE: { cos: 0.7071068, sin:-0.7071068, dx: 0.7071068, dz:-0.7071068 },
|
|
S: { cos: 0.0, sin:-1.0, dx: 0, dz:-1 },
|
|
SW: { cos:-0.7071068, sin:-0.7071068, dx:-0.7071068, dz:-0.7071068 },
|
|
W: { cos:-1.0, sin: 0.0, dx:-1, dz: 0 },
|
|
NW: { cos:-0.7071068, sin: 0.7071068, dx:-0.7071068, dz: 0.7071068 }
|
|
};
|
|
function _pickDir(key) {
|
|
var raw = (_hashParams[key] || 'N').toUpperCase();
|
|
return FLIGHT_DIRS[raw] || FLIGHT_DIRS.N;
|
|
}
|
|
window.__flightDir = _pickDir('flight');
|
|
window.__fetchDir = _pickDir('fetch');
|
|
|
|
// ---- WebGL scene (clouds3d.js) ------------------------------------------
|
|
var sceneEl = document.getElementById('scene');
|
|
function sceneRender() {
|
|
if (sceneEl && typeof window.clouds3dRender === 'function') {
|
|
window.clouds3dRender(sceneEl);
|
|
}
|
|
}
|
|
sceneRender();
|
|
window.addEventListener('resize', sceneRender);
|
|
(function sceneLoop() {
|
|
sceneRender();
|
|
window._sceneRaf = requestAnimationFrame(sceneLoop);
|
|
})();
|
|
|
|
function hexToRgb(h) {
|
|
return [(h >> 16) & 255, (h >> 8) & 255, h & 255];
|
|
}
|
|
function rgbToHex(r, g, b) {
|
|
return (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b);
|
|
}
|
|
function lerp(a, b, t) {
|
|
return a + (b - a) * t;
|
|
}
|
|
function lerpColor(c1, c2, t) {
|
|
var a = hexToRgb(c1), b = hexToRgb(c2);
|
|
return rgbToHex(lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t));
|
|
}
|
|
function smooth(t) {
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
// Day simulation: CONFIG.day.simulation runs a full 24h cycle in
|
|
// CONFIG.day.cycleSeconds (sun rises east, culminates south, sets
|
|
// west). Off = real SunCalc positions.
|
|
var DAY_SIM = !!(window.CONFIG && window.CONFIG.day && window.CONFIG.day.simulation);
|
|
var CYCLE = ((window.CONFIG && window.CONFIG.day && window.CONFIG.day.cycleSeconds) || 20) * 1000;
|
|
// simulated sun altitude (rad): t=0 midnight, 0.25 sunrise, 0.5 noon
|
|
function simSunAlt(t) {
|
|
return Math.sin((t - 0.25) * Math.PI * 2) * 1.45;
|
|
}
|
|
// Virtual calendar for day-switching features (weather forecast):
|
|
// same wall clock as reality, but the DATE advances one day per
|
|
// simulated cycle. The visible clock hands/digital stay real.
|
|
function simNow() {
|
|
if (!DAY_SIM) return new Date();
|
|
return new Date(startTime + (Date.now() - startTime) * (86400000 / CYCLE));
|
|
}
|
|
var __simT = 0;
|
|
|
|
var SITE_LAT = (window.CONFIG && window.CONFIG.location && window.CONFIG.location.lat) || 48.5667;
|
|
var SITE_LON = (window.CONFIG && window.CONFIG.location && window.CONFIG.location.lon) || 13.4319;
|
|
|
|
var realDayProgress = 0;
|
|
|
|
// Read color palette from CSS variables
|
|
function getColors() {
|
|
var root = getComputedStyle(document.documentElement);
|
|
var keys = [
|
|
'void', 'panel', 'raised', 'border', 'mid', 'ash',
|
|
'icon', 'stone', 'bone', 'chalk',
|
|
'ember', 'gold', 'blood', 'rust', 'rot', 'moss',
|
|
'steel', 'steelLight', 'blue', 'blueLight',
|
|
'boneLight', 'chalkLight', 'violet'
|
|
];
|
|
var map = {
|
|
void: 'black',
|
|
panel: 'dark',
|
|
raised: 'gray-darker',
|
|
border: 'gray-dark',
|
|
mid: 'gray',
|
|
ash: 'gray-light',
|
|
ember: 'orange',
|
|
gold: 'yellow',
|
|
blood: 'red',
|
|
rust: 'brown',
|
|
rot: 'pink',
|
|
moss: 'green',
|
|
steel: 'blue',
|
|
steelLight: 'blue',
|
|
blue: 'blue',
|
|
blueLight: 'blue-light',
|
|
boneLight: 'bone-light',
|
|
chalkLight: 'chalk-light',
|
|
violet: 'purple'
|
|
};
|
|
var result = {};
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var ashKey = keys[i];
|
|
var cssKey = map[ashKey] || ashKey;
|
|
result[ashKey] = root.getPropertyValue('--' + cssKey).trim();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function hexToInt(hex) {
|
|
return parseInt(hex.replace('#', ''), 16);
|
|
}
|
|
|
|
// Refreshable color cache - re-reads from CSS variables when theme changes
|
|
function getColors() {
|
|
var root = getComputedStyle(document.documentElement);
|
|
var keys = [
|
|
'void', 'panel', 'raised', 'border', 'mid', 'ash',
|
|
'icon', 'stone', 'bone', 'chalk',
|
|
'ember', 'gold', 'blood', 'rust', 'rot', 'moss',
|
|
'steel', 'steelLight', 'blue', 'blueLight',
|
|
'boneLight', 'chalkLight', 'violet'
|
|
];
|
|
var map = {
|
|
void: 'black',
|
|
panel: 'dark',
|
|
raised: 'gray-darker',
|
|
border: 'gray-dark',
|
|
mid: 'gray',
|
|
ash: 'gray-light',
|
|
ember: 'orange',
|
|
gold: 'yellow',
|
|
blood: 'red',
|
|
rust: 'brown',
|
|
rot: 'pink',
|
|
moss: 'green',
|
|
steel: 'blue',
|
|
steelLight: 'blue',
|
|
blue: 'blue',
|
|
blueLight: 'blue-light',
|
|
boneLight: 'bone-light',
|
|
chalkLight: 'chalk-light',
|
|
violet: 'purple'
|
|
};
|
|
var result = {};
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var ashKey = keys[i];
|
|
var cssKey = map[ashKey] || ashKey;
|
|
result[ashKey] = root.getPropertyValue('--' + cssKey).trim();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
var ASH = getColors();
|
|
|
|
function rebuildFromColors() {
|
|
ASH = getColors();
|
|
// Rebuild palette
|
|
window.sunPalettes = [
|
|
{ alt: -Math.PI / 2, sky: hexToInt(ASH.void), cloud: hexToInt(ASH.void), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone), sunVis: 0 },
|
|
{ alt: -0.262, sky: hexToInt(ASH.void), cloud: hexToInt(ASH.void), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone), sunVis: 0 },
|
|
{ alt: -0.087, sky: hexToInt(ASH.panel), cloud: hexToInt(ASH.void), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.panel), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone), sunVis: 0 },
|
|
{ alt: 0.0, sky: hexToInt(ASH.rust), cloud: hexToInt(ASH.border), light: hexToInt(ASH.gold), bg: hexToInt(ASH.rust), cloudShadow: hexToInt(ASH.raised), sun: hexToInt(ASH.ember), sunGlare: hexToInt(ASH.gold), sunLight: hexToInt(ASH.ember), sunVis: 0.8 },
|
|
{ alt: 0.087, sky: hexToInt(ASH.blood), cloud: hexToInt(ASH.border), light: hexToInt(ASH.ember), bg: hexToInt(ASH.blood), cloudShadow: hexToInt(ASH.raised), sun: hexToInt(ASH.ember), sunGlare: hexToInt(ASH.ember), sunLight: hexToInt(ASH.ember), sunVis: 0.7 },
|
|
{ alt: 0.4, sky: hexToInt(ASH.blue), cloud: hexToInt(ASH.border), light: hexToInt(ASH.bone), bg: hexToInt(ASH.blue), cloudShadow: hexToInt(ASH.steel), sun: hexToInt(ASH.chalk), sunGlare: hexToInt(ASH.bone), sunLight: hexToInt(ASH.chalk), sunVis: 1 },
|
|
{ alt: Math.PI / 2, sky: hexToInt(ASH.blueLight), cloud: hexToInt(ASH.border), light: hexToInt(ASH.chalk), bg: hexToInt(ASH.blueLight), cloudShadow: hexToInt(ASH.blue), sun: hexToInt(ASH.chalk), sunGlare: hexToInt(ASH.bone), sunLight: hexToInt(ASH.chalk), sunVis: 1 }
|
|
];
|
|
window.nightPaletteNew = { sky: hexToInt(ASH.void), cloud: hexToInt(ASH.raised), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone) };
|
|
window.nightPaletteFull = { sky: hexToInt(ASH.panel), cloud: hexToInt(ASH.border), light: hexToInt(ASH.icon), bg: hexToInt(ASH.panel), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.icon), sunGlare: hexToInt(ASH.mid), sunLight: hexToInt(ASH.icon) };
|
|
window.weatherThemes = {
|
|
sunny: {
|
|
skyColor: hexToInt(ASH.blueLight),
|
|
cloudColor: hexToInt(ASH.stone),
|
|
lightColor: hexToInt(ASH.chalk),
|
|
backgroundColor: hexToInt(ASH.bone),
|
|
speed: 1.0
|
|
},
|
|
cloudy: {
|
|
skyColor: hexToInt(ASH.blue),
|
|
cloudColor: hexToInt(ASH.mid),
|
|
lightColor: hexToInt(ASH.bone),
|
|
backgroundColor: hexToInt(ASH.border),
|
|
speed: 1.2
|
|
},
|
|
stormy: {
|
|
skyColor: hexToInt(ASH.steel),
|
|
cloudColor: hexToInt(ASH.panel),
|
|
lightColor: hexToInt(ASH.stone),
|
|
backgroundColor: hexToInt(ASH.void),
|
|
speed: 2.5
|
|
},
|
|
night: {
|
|
skyColor: hexToInt(ASH.void),
|
|
cloudColor: hexToInt(ASH.raised),
|
|
lightColor: hexToInt(ASH.icon),
|
|
backgroundColor: hexToInt(ASH.void),
|
|
speed: 0.4
|
|
}
|
|
};
|
|
}
|
|
|
|
// Sun position palette (altitude-based) - all colors from Ash palette
|
|
// altitude in radians: -pi/2 (below) to pi/2 (zenith)
|
|
// Sun position palette (altitude-based) - all colors from current theme
|
|
// altitude in radians: -pi/2 (below) to pi/2 (zenith)
|
|
// Compressed twilight: dusk and dawn palette entries are now at
|
|
// -5deg / +5deg so the sunset transition completes within roughly
|
|
// an hour of clock time. -15deg is already full-night (void), so
|
|
// the sky is properly dark by ~1h after sunset.
|
|
window.sunPalettes = [
|
|
{ alt: -Math.PI / 2, sky: hexToInt(ASH.void), cloud: hexToInt(ASH.void), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone), sunVis: 0 },
|
|
{ alt: -0.262, sky: hexToInt(ASH.void), cloud: hexToInt(ASH.void), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone), sunVis: 0 },
|
|
{ alt: -0.087, sky: hexToInt(ASH.panel), cloud: hexToInt(ASH.void), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.panel), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone), sunVis: 0 },
|
|
{ alt: 0.0, sky: hexToInt(ASH.rust), cloud: hexToInt(ASH.border), light: hexToInt(ASH.gold), bg: hexToInt(ASH.rust), cloudShadow: hexToInt(ASH.raised), sun: hexToInt(ASH.ember), sunGlare: hexToInt(ASH.gold), sunLight: hexToInt(ASH.ember), sunVis: 0.8 },
|
|
{ alt: 0.087, sky: hexToInt(ASH.blood), cloud: hexToInt(ASH.border), light: hexToInt(ASH.ember), bg: hexToInt(ASH.blood), cloudShadow: hexToInt(ASH.raised), sun: hexToInt(ASH.ember), sunGlare: hexToInt(ASH.ember), sunLight: hexToInt(ASH.ember), sunVis: 0.7 },
|
|
{ alt: 0.4, sky: hexToInt(ASH.blue), cloud: hexToInt(ASH.border), light: hexToInt(ASH.bone), bg: hexToInt(ASH.blue), cloudShadow: hexToInt(ASH.steel), sun: hexToInt(ASH.chalk), sunGlare: hexToInt(ASH.bone), sunLight: hexToInt(ASH.chalk), sunVis: 1 },
|
|
{ alt: Math.PI / 2, sky: hexToInt(ASH.blueLight), cloud: hexToInt(ASH.border), light: hexToInt(ASH.chalk), bg: hexToInt(ASH.blueLight), cloudShadow: hexToInt(ASH.blue), sun: hexToInt(ASH.chalk), sunGlare: hexToInt(ASH.bone), sunLight: hexToInt(ASH.chalk), sunVis: 1 }
|
|
];
|
|
|
|
// Night palette based on moon phase (0 = new, 0.5 = full, 1 = new) - Ash colors
|
|
window.nightPaletteNew = { sky: hexToInt(ASH.void), cloud: hexToInt(ASH.raised), light: hexToInt(ASH.stone), bg: hexToInt(ASH.void), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.stone), sunGlare: hexToInt(ASH.ash), sunLight: hexToInt(ASH.stone) };
|
|
window.nightPaletteFull = { sky: hexToInt(ASH.panel), cloud: hexToInt(ASH.border), light: hexToInt(ASH.icon), bg: hexToInt(ASH.panel), cloudShadow: hexToInt(ASH.void), sun: hexToInt(ASH.icon), sunGlare: hexToInt(ASH.mid), sunLight: hexToInt(ASH.icon) };
|
|
|
|
var MIN_NIGHT_BRIGHTNESS = 0.25; // minimum night brightness (even at new moon)
|
|
|
|
function getDayColor(sunAlt) {
|
|
// Find palette segment based on sun altitude
|
|
var palette;
|
|
if (sunAlt <= window.sunPalettes[0].alt) {
|
|
palette = window.sunPalettes[0];
|
|
} else if (sunAlt >= window.sunPalettes[window.sunPalettes.length - 1].alt) {
|
|
palette = window.sunPalettes[window.sunPalettes.length - 1];
|
|
} else {
|
|
for (var i = 0; i < window.sunPalettes.length - 1; i++) {
|
|
if (sunAlt >= window.sunPalettes[i].alt && sunAlt <= window.sunPalettes[i + 1].alt) {
|
|
var span = window.sunPalettes[i + 1].alt - window.sunPalettes[i].alt;
|
|
var localT = span === 0 ? 0 : (sunAlt - window.sunPalettes[i].alt) / span;
|
|
var s = smooth(localT);
|
|
palette = {
|
|
sky: lerpColor(window.sunPalettes[i].sky, window.sunPalettes[i + 1].sky, s),
|
|
cloud: lerpColor(window.sunPalettes[i].cloud, window.sunPalettes[i + 1].cloud, s),
|
|
light: lerpColor(window.sunPalettes[i].light, window.sunPalettes[i + 1].light, s),
|
|
bg: lerpColor(window.sunPalettes[i].bg, window.sunPalettes[i + 1].bg, s),
|
|
cloudShadow: lerpColor(window.sunPalettes[i].cloudShadow, window.sunPalettes[i + 1].cloudShadow, s),
|
|
sun: lerpColor(window.sunPalettes[i].sun, window.sunPalettes[i + 1].sun, s),
|
|
sunGlare: lerpColor(window.sunPalettes[i].sunGlare, window.sunPalettes[i + 1].sunGlare, s),
|
|
sunLight: lerpColor(window.sunPalettes[i].sunLight, window.sunPalettes[i + 1].sunLight, s),
|
|
sunVis: window.sunPalettes[i].sunVis + (window.sunPalettes[i + 1].sunVis - window.sunPalettes[i].sunVis) * s
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return palette;
|
|
}
|
|
|
|
function getNightColor(moonIllum) {
|
|
// moonIllum.fraction: 0 (new moon) to 1 (full moon)
|
|
// Interpolate between new and full moon palette based on fraction
|
|
var s = moonIllum.fraction;
|
|
var c = {
|
|
sky: lerpColor(window.nightPaletteNew.sky, window.nightPaletteFull.sky, s),
|
|
cloud: lerpColor(window.nightPaletteNew.cloud, window.nightPaletteFull.cloud, s),
|
|
light: lerpColor(window.nightPaletteNew.light, window.nightPaletteFull.light, s),
|
|
bg: lerpColor(window.nightPaletteNew.bg, window.nightPaletteFull.bg, s),
|
|
cloudShadow: lerpColor(window.nightPaletteNew.cloudShadow, window.nightPaletteFull.cloudShadow, s),
|
|
sun: lerpColor(window.nightPaletteNew.sun, window.nightPaletteFull.sun, s),
|
|
sunGlare: lerpColor(window.nightPaletteNew.sunGlare, window.nightPaletteFull.sunGlare, s),
|
|
sunLight: lerpColor(window.nightPaletteNew.sunLight, window.nightPaletteFull.sunLight, s)
|
|
};
|
|
// 'light' is moonlight shining ON clouds / stars — keep it
|
|
// bright. Only sky / bg / bg-adjacent get the new-moon
|
|
// darkening, and only partially (otherwise new-moon nights
|
|
// are pitch black with no cloud structure).
|
|
var darkness = 1.0 - (MIN_NIGHT_BRIGHTNESS + (1 - MIN_NIGHT_BRIGHTNESS) * s);
|
|
var shift = -Math.round(255 * darkness * 0.4);
|
|
return {
|
|
sky: shiftColor(c.sky, shift),
|
|
cloud: c.cloud, // keep contrast vs sky
|
|
light: c.light, // moonlight stays bright
|
|
bg: shiftColor(c.bg, shift),
|
|
cloudShadow: shiftColor(c.cloudShadow, Math.round(-shift * 0.5)),
|
|
sun: c.sun,
|
|
sunGlare: c.sunGlare,
|
|
sunLight: c.sunLight
|
|
};
|
|
}
|
|
|
|
function getColor(t) {
|
|
if (DAY_SIM) {
|
|
// t: 0 = Mitternacht, 0.25 Sonnenaufgang (Osten),
|
|
// 0.5 Mittag (Sueden), 0.75 Sonnenuntergang (Westen).
|
|
return getDayColor(simSunAlt(t));
|
|
}
|
|
var now = new Date();
|
|
var sunPos = SunCalc.getPosition(now, SITE_LAT, SITE_LON);
|
|
var moonIllum = SunCalc.getMoonIllumination(now);
|
|
|
|
// The day palette covers sunAlt down to sunPalettes[0].alt
|
|
// (-pi/2, deep below horizon). Only below that do we hand off
|
|
// to the moon-based night palette. This makes twilight a
|
|
// smooth transition through the day palette entries.
|
|
if (sunPos.altitude > window.sunPalettes[0].alt) {
|
|
return getDayColor(sunPos.altitude);
|
|
} else {
|
|
return getNightColor(moonIllum);
|
|
}
|
|
}
|
|
|
|
function adjustColor(color, factor) {
|
|
var r = Math.min(255, Math.floor(((color >> 16) & 255) * factor));
|
|
var g = Math.min(255, Math.floor(((color >> 8) & 255) * factor));
|
|
var b = Math.min(255, Math.floor((color & 255) * factor));
|
|
return (r << 16) | (g << 8) | b;
|
|
}
|
|
|
|
function shiftColor(color, delta) {
|
|
var r = Math.max(0, Math.min(255, ((color >> 16) & 255) + delta));
|
|
var g = Math.max(0, Math.min(255, ((color >> 8) & 255) + delta));
|
|
var b = Math.max(0, Math.min(255, (color & 255) + delta));
|
|
return (r << 16) | (g << 8) | b;
|
|
}
|
|
|
|
function getWeatherModifiers(code) {
|
|
// Returns {cloudDark: factor, cloudLight: factor, skyShift: shift, bgShift: shift}
|
|
// code is WMO weather code from open-meteo
|
|
if (code === 0) {
|
|
// Clear sky - lighter clouds, brighter sky
|
|
return { cloudDark: 0.7, cloudLight: 1.3, skyShift: 15, bgShift: 15 };
|
|
} else if (code >= 1 && code <= 3) {
|
|
// Mainly clear to overcast - neutral
|
|
return { cloudDark: 0.95, cloudLight: 1.0, skyShift: 0, bgShift: -5 };
|
|
} else if (code === 45 || code === 48) {
|
|
// Fog - cool, washed out
|
|
return { cloudDark: 1.1, cloudLight: 0.85, skyShift: 10, bgShift: 10 };
|
|
} else if ((code >= 51 && code <= 57) || (code >= 61 && code <= 67)) {
|
|
// Drizzle / Rain - darker, slightly cooler
|
|
return { cloudDark: 1.3, cloudLight: 0.7, skyShift: -15, bgShift: -20 };
|
|
} else if ((code >= 71 && code <= 77) || (code >= 85 && code <= 86)) {
|
|
// Snow - brighter (light reflection)
|
|
return { cloudDark: 0.7, cloudLight: 1.5, skyShift: 25, bgShift: 25 };
|
|
} else if (code >= 80 && code <= 82) {
|
|
// Rain showers - darker
|
|
return { cloudDark: 1.4, cloudLight: 0.6, skyShift: -20, bgShift: -25 };
|
|
} else if (code >= 95) {
|
|
// Thunderstorm - very dark, dramatic
|
|
return { cloudDark: 1.6, cloudLight: 0.4, skyShift: -30, bgShift: -35 };
|
|
}
|
|
return { cloudDark: 1.0, cloudLight: 1.0, skyShift: 0, bgShift: 0 };
|
|
}
|
|
|
|
var currentWeatherCode = -1;
|
|
|
|
function applyWeatherModifiers(c, code) {
|
|
var m = getWeatherModifiers(code);
|
|
return {
|
|
sky: c.sky,
|
|
cloud: adjustColor(c.cloud, m.cloudLight),
|
|
cloudShadow: adjustColor(c.cloudShadow, m.cloudDark),
|
|
sun: adjustColor(c.sun, m.cloudLight),
|
|
sunGlare: adjustColor(c.sunGlare, m.cloudLight),
|
|
sunLight: adjustColor(c.sunLight, m.cloudLight),
|
|
bg: shiftColor(c.bg, m.bgShift),
|
|
sunVis: c.sunVis
|
|
};
|
|
}
|
|
|
|
// ============ Clock & Sun/Moon Calculations ============
|
|
|
|
var hourHand = document.getElementById('hour-hand');
|
|
var minuteHand = document.getElementById('minute-hand');
|
|
var secondHand = document.getElementById('second-hand');
|
|
var compassNeedle = document.getElementById('compass-needle');
|
|
var hourMarks = document.getElementById('hour-marks');
|
|
var rpgSunArc = document.getElementById('sun-arc');
|
|
var rpgMoonArc = document.getElementById('moon-arc');
|
|
var rpgSun = document.getElementById('rpg-sun');
|
|
var rpgSunGlow = document.getElementById('rpg-sun-glow');
|
|
var rpgSunCore = document.getElementById('rpg-sun-core');
|
|
var rpgMoon = document.getElementById('rpg-moon');
|
|
var rpgMoonLit = document.getElementById('rpg-moon-lit');
|
|
var rpgMoonDark = document.getElementById('rpg-moon-dark');
|
|
var sunRiseArrow = document.getElementById('sun-rise-arrow');
|
|
var sunSetArrow = document.getElementById('sun-set-arrow');
|
|
var moonRiseArrow = document.getElementById('moon-rise-arrow');
|
|
var moonsetArrow = document.getElementById('moon-set-arrow');
|
|
var sunZenithMarker = document.getElementById('sun-zenith-marker');
|
|
|
|
var SUN_ORBIT_RADIUS = 65;
|
|
var MOON_ORBIT_RADIUS = 88;
|
|
|
|
// Generate hour marks
|
|
for (var i = 0; i < 12; i++) {
|
|
var angle = (i * 30) * Math.PI / 180;
|
|
var isMain = i % 3 === 0;
|
|
var innerR = isMain ? 118 : 122;
|
|
var outerR = isMain ? 130 : 128;
|
|
var x1 = 140 + Math.sin(angle) * innerR;
|
|
var y1 = 140 - Math.cos(angle) * innerR;
|
|
var x2 = 140 + Math.sin(angle) * outerR;
|
|
var y2 = 140 - Math.cos(angle) * outerR;
|
|
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
line.setAttribute('x1', x1);
|
|
line.setAttribute('y1', y1);
|
|
line.setAttribute('x2', x2);
|
|
line.setAttribute('y2', y2);
|
|
line.setAttribute('stroke', ASH.bone);
|
|
line.setAttribute('stroke-width', isMain ? 5 : 3);
|
|
line.setAttribute('stroke-linecap', 'round');
|
|
hourMarks.appendChild(line);
|
|
}
|
|
|
|
function formatTimeHM(date) {
|
|
if (!date || isNaN(date.getTime())) return '--:--';
|
|
var h = date.getHours();
|
|
var m = date.getMinutes();
|
|
return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m;
|
|
}
|
|
|
|
function formatTimeHMS(h, m, s) {
|
|
return (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
|
}
|
|
|
|
function pad2(n) { return n < 10 ? '0' + n : '' + n; }
|
|
function pad3(n) { return n < 10 ? '00' + n : n < 100 ? '0' + n : '' + n; }
|
|
|
|
function formatDateTime(format, date) {
|
|
// Strict ISO 8601 format tokens (case-sensitive):
|
|
// YYYY 4-digit year (2026)
|
|
// MM month 01-12 (10) -- always month, never minute
|
|
// DD day 01-31 (07)
|
|
// HH hour 00-23 (14)
|
|
// mm minute 00-59 (35) -- lowercase
|
|
// ss second 00-59 (22) -- lowercase
|
|
// sss millisecond 000-999 (456)
|
|
// T literal 'T'
|
|
// Any other character is a literal (including whitespace).
|
|
//
|
|
// Non-standard extension tokens (lookup in i18n dict):
|
|
// DDD weekday short name (Mo., Tu., ...) from i18n
|
|
// dddd weekday full name (Monday, Tuesday,...) from i18n
|
|
// MMM month short name (Jan., Feb., ...) from i18n
|
|
// MMMM month full name (January, ...) from i18n
|
|
//
|
|
// Escape sequences are also evaluated:
|
|
// \n newline
|
|
// \t tab
|
|
// \\ literal backslash
|
|
var result = '';
|
|
var i = 0;
|
|
var weekdayKeys = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
var weekdayFull = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
var monthKeys = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
while (i < format.length) {
|
|
var ch = format.charAt(i);
|
|
// Handle escape sequences \n, \t, \\
|
|
if (ch === '\\' && i + 1 < format.length) {
|
|
var next = format.charAt(i + 1);
|
|
if (next === 'n') result += '\n';
|
|
else if (next === 't') result += '\t';
|
|
else if (next === '\\') result += '\\';
|
|
else result += ch + next;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (ch === 'Y' && format.substr(i, 4) === 'YYYY') {
|
|
result += date.getFullYear();
|
|
i += 4;
|
|
} else if (ch === 'Y') {
|
|
result += date.getFullYear();
|
|
i += 1;
|
|
} else if (ch === 'M' && format.substr(i, 4) === 'MMMM') {
|
|
result += t(monthKeys[date.getMonth()]);
|
|
i += 4;
|
|
} else if (ch === 'M' && format.substr(i, 3) === 'MMM') {
|
|
result += t(monthKeys[date.getMonth()].substring(0, 3));
|
|
i += 3;
|
|
} else if (ch === 'M' && format.substr(i, 2) === 'MM') {
|
|
result += pad2(date.getMonth() + 1);
|
|
i += 2;
|
|
} else if (ch === 'D' && format.substr(i, 4) === 'dddd') {
|
|
result += t(weekdayFull[date.getDay()]);
|
|
i += 4;
|
|
} else if (ch === 'D' && format.substr(i, 3) === 'DDD') {
|
|
result += t(weekdayKeys[date.getDay()]);
|
|
i += 3;
|
|
} else if (ch === 'D' && format.substr(i, 2) === 'DD') {
|
|
result += pad2(date.getDate());
|
|
i += 2;
|
|
} else if (ch === 'H' && format.substr(i, 2) === 'HH') {
|
|
result += pad2(date.getHours());
|
|
i += 2;
|
|
} else if (ch === 'm' && format.substr(i, 2) === 'mm') {
|
|
result += pad2(date.getMinutes());
|
|
i += 2;
|
|
} else if (ch === 's' && format.substr(i, 3) === 'sss') {
|
|
result += pad3(date.getMilliseconds());
|
|
i += 3;
|
|
} else if ((ch === 's' || ch === 'S') && format.substr(i, 2).toLowerCase() === 'ss') {
|
|
result += pad2(date.getSeconds());
|
|
i += 2;
|
|
} else if (ch === 'a' && format.substr(i, 4) === 'aaaa') {
|
|
// Full am/pm text (lowercase)
|
|
result += date.getHours() < 12 ? 'am' : 'pm';
|
|
i += 4;
|
|
} else if (ch === 'A' && format.substr(i, 4) === 'AAAA') {
|
|
// Full AM/PM text (uppercase)
|
|
result += date.getHours() < 12 ? 'AM' : 'PM';
|
|
i += 4;
|
|
} else if (ch === 'a') {
|
|
// am/pm (lowercase)
|
|
result += date.getHours() < 12 ? 'am' : 'pm';
|
|
i += 1;
|
|
} else if (ch === 'A') {
|
|
// AM/PM (uppercase)
|
|
result += date.getHours() < 12 ? 'AM' : 'PM';
|
|
i += 1;
|
|
} else {
|
|
result += ch;
|
|
i += 1;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function formatDateShort(date) {
|
|
var d = date.getDate();
|
|
var mo = date.getMonth() + 1;
|
|
return (d < 10 ? '0' : '') + d + '.' + (mo < 10 ? '0' : '') + mo + '.';
|
|
}
|
|
|
|
function formatDateLong(date) {
|
|
var days = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'];
|
|
var d = date.getDate();
|
|
var mo = date.getMonth() + 1;
|
|
return days[date.getDay()] + ' ' + (d < 10 ? '0' : '') + d + '.' + (mo < 10 ? '0' : '') + mo + '.';
|
|
}
|
|
|
|
function getNextEvent(todayEvent, now, lat, lon, type) {
|
|
if (!todayEvent || isNaN(todayEvent.getTime())) {
|
|
return { date: null, label: '' };
|
|
}
|
|
if (todayEvent > now) {
|
|
return { date: todayEvent, label: 'heute' };
|
|
}
|
|
for (var d = 1; d <= 7; d++) {
|
|
var nextDate = new Date(now);
|
|
nextDate.setDate(nextDate.getDate() + d);
|
|
var times = type.indexOf('moon') === 0 ? SunCalc.getMoonTimes(nextDate, lat, lon) : SunCalc.getTimes(nextDate, lat, lon);
|
|
var ev = type === 'moonrise' ? times.rise : type === 'moonset' ? times.set : times[type];
|
|
if (ev && !isNaN(ev.getTime())) {
|
|
var dayLabel = d === 1 ? t('tomorrow') : formatDateLong(nextDate);
|
|
return { date: ev, label: dayLabel };
|
|
}
|
|
}
|
|
return { date: null, label: '' };
|
|
}
|
|
|
|
function getMoonPhaseText(phase) {
|
|
var p = phase;
|
|
var keys = ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
|
|
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'];
|
|
var idx;
|
|
if (p < 0.0625 || p >= 0.9375) idx = 0;
|
|
else if (p < 0.1875) idx = 1;
|
|
else if (p < 0.3125) idx = 2;
|
|
else if (p < 0.4375) idx = 3;
|
|
else if (p < 0.5625) idx = 4;
|
|
else if (p < 0.6875) idx = 5;
|
|
else if (p < 0.8125) idx = 6;
|
|
else idx = 7;
|
|
return t(keys[idx]);
|
|
}
|
|
|
|
var CENTER_X = 140;
|
|
var CENTER_Y = 140;
|
|
|
|
function angleForTime(date) {
|
|
// 0:00 = bottom, 6:00 = left, 12:00 = top, 18:00 = right
|
|
var hours = date.getHours() + date.getMinutes() / 60;
|
|
return ((hours / 24) * 360 + 90) * Math.PI / 180;
|
|
}
|
|
|
|
// The Moon advances ~12.19° per day relative to the Sun (synodic month
|
|
// ~29.53 days). Use SunCalc.getMoonIllumination().phase as the offset.
|
|
// phase=0: New moon (same direction as Sun)
|
|
// phase=0.5: Full moon (opposite the Sun)
|
|
function moonAngleForTime(date) {
|
|
var moonIllum = SunCalc.getMoonIllumination(date);
|
|
var hours = date.getHours() + date.getMinutes() / 60;
|
|
var hourAngle = (hours / 24) * 360 + 90;
|
|
// Moon is roughly at hourAngle + phase * 360
|
|
return ((hourAngle + moonIllum.phase * 360) % 360) * Math.PI / 180;
|
|
}
|
|
|
|
function pointOnCircle(date, radius) {
|
|
var angle = angleForTime(date);
|
|
return {
|
|
x: CENTER_X + Math.cos(angle) * radius,
|
|
y: CENTER_Y + Math.sin(angle) * radius
|
|
};
|
|
}
|
|
|
|
function arcBetween(startDate, endDate, radius) {
|
|
var sp = pointOnCircle(startDate, radius);
|
|
var ep = pointOnCircle(endDate, radius);
|
|
var startA = angleForTime(startDate) * 180 / Math.PI;
|
|
var endA = angleForTime(endDate) * 180 / Math.PI;
|
|
var diff = endA - startA;
|
|
if (diff < 0) diff += 360;
|
|
var largeArc = diff > 180 ? 1 : 0;
|
|
return 'M ' + sp.x + ' ' + sp.y + ' A ' + radius + ' ' + radius + ' 0 ' + largeArc + ' 1 ' + ep.x + ' ' + ep.y;
|
|
}
|
|
|
|
function getMoonPosition(now, moonTimes) {
|
|
try {
|
|
var rise = moonTimes.rise;
|
|
var set = moonTimes.set;
|
|
|
|
if (!rise || !set) {
|
|
return { point: pointOnCircle(now, MOON_ORBIT_RADIUS), isVisible: false };
|
|
}
|
|
|
|
var isVisible;
|
|
if (rise <= set) {
|
|
isVisible = (now >= rise && now <= set);
|
|
} else {
|
|
isVisible = (now >= rise || now <= set);
|
|
}
|
|
|
|
if (!isVisible) {
|
|
return { point: pointOnCircle(now, MOON_ORBIT_RADIUS), isVisible: false };
|
|
}
|
|
|
|
var riseAngle = angleForTime(rise);
|
|
var setAngle = angleForTime(set);
|
|
|
|
var diff = setAngle - riseAngle;
|
|
if (diff > Math.PI) diff -= 2 * Math.PI;
|
|
if (diff < -Math.PI) diff += 2 * Math.PI;
|
|
|
|
var progress;
|
|
if (rise <= set) {
|
|
progress = (now.getTime() - rise.getTime()) / (set.getTime() - rise.getTime());
|
|
} else {
|
|
if (now >= rise) {
|
|
var endMs = set.getTime() + 24 * 3600 * 1000;
|
|
progress = (now.getTime() - rise.getTime()) / (endMs - rise.getTime());
|
|
} else {
|
|
progress = (now.getTime() + 24 * 3600 * 1000 - rise.getTime()) /
|
|
(set.getTime() + 24 * 3600 * 1000 - rise.getTime());
|
|
}
|
|
}
|
|
|
|
var moonAngle = riseAngle + diff * progress;
|
|
return {
|
|
point: {
|
|
x: CENTER_X + Math.cos(moonAngle) * MOON_ORBIT_RADIUS,
|
|
y: CENTER_Y + Math.sin(moonAngle) * MOON_ORBIT_RADIUS
|
|
},
|
|
isVisible: true
|
|
};
|
|
} catch (e) {
|
|
return { point: pointOnCircle(now, MOON_ORBIT_RADIUS), isVisible: false };
|
|
}
|
|
}
|
|
|
|
function renderMoonPhaseIcon(phase, fraction) {
|
|
var icon = document.getElementById('moon-phase-icon');
|
|
icon.innerHTML = '';
|
|
|
|
var r = 9;
|
|
var bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
bg.setAttribute('cx', '12'); bg.setAttribute('cy', '12'); bg.setAttribute('r', r);
|
|
bg.setAttribute('fill', ASH.panel);
|
|
bg.setAttribute('stroke', ASH.stone);
|
|
bg.setAttribute('stroke-width', '0.5');
|
|
icon.appendChild(bg);
|
|
|
|
var rx = r * (1 - 2 * fraction);
|
|
|
|
var path;
|
|
if (phase < 0.5) {
|
|
if (rx > 0) {
|
|
path = 'M 12 3 A 9 9 0 0 0 12 21 A ' + rx + ' 9 0 0 1 12 3 Z';
|
|
} else {
|
|
path = 'M 12 3 A 9 9 0 0 0 12 21 A ' + Math.abs(rx) + ' 9 0 0 0 12 3 Z';
|
|
}
|
|
} else {
|
|
if (rx > 0) {
|
|
path = 'M 12 3 A 9 9 0 0 1 12 21 A ' + rx + ' 9 0 0 0 12 3 Z';
|
|
} else {
|
|
path = 'M 12 3 A 9 9 0 0 1 12 21 A ' + Math.abs(rx) + ' 9 0 0 1 12 3 Z';
|
|
}
|
|
}
|
|
|
|
var lit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
lit.setAttribute('d', path);
|
|
lit.setAttribute('fill', ASH.bone);
|
|
icon.appendChild(lit);
|
|
}
|
|
|
|
function renderRpgMoon(phase, fraction) {
|
|
var r = 10;
|
|
rpgMoonDark.setAttribute('d', '');
|
|
|
|
var rx = r * (1 - 2 * fraction);
|
|
|
|
var path;
|
|
if (phase < 0.5) {
|
|
if (rx > 0) {
|
|
path = 'M 0 -10 A 10 10 0 0 0 0 10 A ' + rx + ' 10 0 0 1 0 -10 Z';
|
|
} else {
|
|
path = 'M 0 -10 A 10 10 0 0 0 0 10 A ' + Math.abs(rx) + ' 10 0 0 0 0 -10 Z';
|
|
}
|
|
} else {
|
|
if (rx > 0) {
|
|
path = 'M 0 -10 A 10 10 0 0 1 0 10 A ' + rx + ' 10 0 0 0 0 -10 Z';
|
|
} else {
|
|
path = 'M 0 -10 A 10 10 0 0 1 0 10 A ' + Math.abs(rx) + ' 10 0 0 1 0 -10 Z';
|
|
}
|
|
}
|
|
|
|
rpgMoonLit.setAttribute('d', path);
|
|
}
|
|
|
|
function updateClockAndEvents() {
|
|
var now = new Date();
|
|
|
|
// Calculate real day progress based on sun position
|
|
// 0% = midnight, 50% = noon, 100% = next midnight
|
|
if (DAY_SIM) {
|
|
realDayProgress = __simT;
|
|
} else {
|
|
var sunTimes = SunCalc.getTimes(now, SITE_LAT, SITE_LON);
|
|
var sunrise = sunTimes.sunrise;
|
|
var sunset = sunTimes.sunset;
|
|
if (now < sunrise) {
|
|
var prevSunset = new Date(sunset.getTime() - 24 * 3600 * 1000);
|
|
realDayProgress = (now.getTime() - prevSunset.getTime()) / (sunrise.getTime() - prevSunset.getTime());
|
|
} else if (now < sunset) {
|
|
realDayProgress = 0.5 + (now.getTime() - sunrise.getTime()) / (sunset.getTime() - sunrise.getTime()) * 0.5;
|
|
} else {
|
|
realDayProgress = 0.5 + (now.getTime() - sunset.getTime()) / (sunrise.getTime() + 24 * 3600 * 1000 - sunset.getTime()) * 0.5;
|
|
}
|
|
realDayProgress = Math.max(0, Math.min(1, realDayProgress));
|
|
}
|
|
|
|
// Real-time clock hands and digital
|
|
var h = now.getHours();
|
|
var m = now.getMinutes();
|
|
var s = now.getSeconds();
|
|
|
|
document.getElementById('digital-clock').textContent = formatDateTime((window.CONFIG && window.CONFIG.timeFormat) || 'HH:mm:ss', now);
|
|
|
|
var hourAngle = ((h % 12) + m / 60) * 30;
|
|
var minuteAngle = (m + s / 60) * 6;
|
|
var secondAngle = s * 6;
|
|
|
|
hourHand.setAttribute('transform', 'rotate(' + hourAngle + ' 140 140)');
|
|
minuteHand.setAttribute('transform', 'rotate(' + minuteAngle + ' 140 140)');
|
|
secondHand.setAttribute('transform', 'rotate(' + secondAngle + ' 140 140)');
|
|
|
|
// Sun times
|
|
var sunTimes = SunCalc.getTimes(now, SITE_LAT, SITE_LON);
|
|
var sunriseNext = getNextEvent(sunTimes.sunrise, now, SITE_LAT, SITE_LON, 'sunrise');
|
|
var sunsetNext = getNextEvent(sunTimes.sunset, now, SITE_LAT, SITE_LON, 'sunset');
|
|
|
|
document.getElementById('sunrise-value').textContent = formatTimeHM(sunriseNext.date);
|
|
document.getElementById('sunrise-when').textContent = sunriseNext.label ? '· ' + sunriseNext.label : '';
|
|
document.getElementById('sunset-value').textContent = formatTimeHM(sunsetNext.date);
|
|
document.getElementById('sunset-when').textContent = sunsetNext.label ? '· ' + sunsetNext.label : '';
|
|
|
|
// Sun zenith (solar noon) - find next occurrence
|
|
var sunZenith = sunTimes.solarNoon;
|
|
var sunZenithWhen = sunZenith > now ? t('today') : t('tomorrow');
|
|
if (sunZenith <= now) {
|
|
var tomorrow = new Date(now);
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
sunZenith = SunCalc.getTimes(tomorrow, SITE_LAT, SITE_LON).solarNoon;
|
|
sunZenithWhen = formatDateLong(tomorrow);
|
|
}
|
|
document.getElementById('sun-zenith-value').textContent = formatTimeHM(sunZenith);
|
|
document.getElementById('sun-zenith-when').textContent = '· ' + sunZenithWhen;
|
|
|
|
// Moon times
|
|
var moonTimes = SunCalc.getMoonTimes(now, SITE_LAT, SITE_LON);
|
|
var moonriseNext = getNextEvent(moonTimes.rise, now, SITE_LAT, SITE_LON, 'moonrise');
|
|
var moonsetNext = getNextEvent(moonTimes.set, now, SITE_LAT, SITE_LON, 'moonset');
|
|
|
|
document.getElementById('moonrise-value').textContent = formatTimeHM(moonriseNext.date);
|
|
document.getElementById('moonrise-when').textContent = moonriseNext.label ? '· ' + moonriseNext.label : '';
|
|
document.getElementById('moonset-value').textContent = formatTimeHM(moonsetNext.date);
|
|
document.getElementById('moonset-when').textContent = moonsetNext.label ? '· ' + moonsetNext.label : '';
|
|
|
|
// Sun arc
|
|
if (sunTimes.sunrise && sunTimes.sunset) {
|
|
rpgSunArc.setAttribute('d', arcBetween(sunTimes.sunrise, sunTimes.sunset, SUN_ORBIT_RADIUS));
|
|
}
|
|
|
|
// Moon arc
|
|
if (moonTimes.rise && moonTimes.set) {
|
|
rpgMoonArc.setAttribute('d', arcBetween(moonTimes.rise, moonTimes.set, MOON_ORBIT_RADIUS));
|
|
}
|
|
|
|
// Sun position
|
|
var sunP = pointOnCircle(now, SUN_ORBIT_RADIUS);
|
|
rpgSun.setAttribute('cx', sunP.x);
|
|
rpgSun.setAttribute('cy', sunP.y);
|
|
rpgSunGlow.setAttribute('cx', sunP.x);
|
|
rpgSunGlow.setAttribute('cy', sunP.y);
|
|
rpgSunCore.setAttribute('cx', sunP.x);
|
|
rpgSunCore.setAttribute('cy', sunP.y);
|
|
|
|
var isDaytime = now >= sunTimes.sunrise && now <= sunTimes.sunset;
|
|
rpgSun.setAttribute('opacity', isDaytime ? '1' : '0.35');
|
|
rpgSunGlow.setAttribute('opacity', isDaytime ? '0.4' : '0.15');
|
|
rpgSunCore.setAttribute('opacity', isDaytime ? '1' : '0.35');
|
|
|
|
// Moon position - based on current time on the orbit
|
|
var moonAngle = moonAngleForTime(now);
|
|
var moonP = {
|
|
x: CENTER_X + Math.cos(moonAngle) * MOON_ORBIT_RADIUS,
|
|
y: CENTER_Y + Math.sin(moonAngle) * MOON_ORBIT_RADIUS
|
|
};
|
|
rpgMoon.setAttribute('transform', 'translate(' + moonP.x + ', ' + moonP.y + ')');
|
|
|
|
var moonVisible = false;
|
|
if (moonTimes.rise && moonTimes.set) {
|
|
if (moonTimes.rise < moonTimes.set) {
|
|
moonVisible = (now >= moonTimes.rise && now <= moonTimes.set);
|
|
} else {
|
|
moonVisible = (now >= moonTimes.rise || now <= moonTimes.set);
|
|
}
|
|
}
|
|
rpgMoon.setAttribute('opacity', moonVisible ? '1' : '0.6');
|
|
|
|
// Sun rise/set arrows
|
|
var srP = pointOnCircle(sunTimes.sunrise, SUN_ORBIT_RADIUS);
|
|
sunRiseArrow.setAttribute('transform', 'translate(' + srP.x + ', ' + srP.y + ')');
|
|
var ssP = pointOnCircle(sunTimes.sunset, SUN_ORBIT_RADIUS);
|
|
sunSetArrow.setAttribute('transform', 'translate(' + ssP.x + ', ' + ssP.y + ')');
|
|
|
|
// Sun zenith marker (next occurrence)
|
|
var sunZenithDate = sunTimes.solarNoon > now ? sunTimes.solarNoon : SunCalc.getTimes(new Date(now.getTime() + 86400000), SITE_LAT, SITE_LON).solarNoon;
|
|
var szP = pointOnCircle(sunZenithDate, SUN_ORBIT_RADIUS);
|
|
sunZenithMarker.setAttribute('transform', 'translate(' + szP.x + ', ' + szP.y + ')');
|
|
|
|
// Moon rise/set arrows
|
|
if (moonTimes.rise) {
|
|
var mrP = pointOnCircle(moonTimes.rise, MOON_ORBIT_RADIUS);
|
|
moonRiseArrow.setAttribute('transform', 'translate(' + mrP.x + ', ' + mrP.y + ')');
|
|
moonRiseArrow.setAttribute('opacity', '1');
|
|
} else {
|
|
moonRiseArrow.setAttribute('opacity', '0');
|
|
}
|
|
if (moonTimes.set) {
|
|
var msP = pointOnCircle(moonTimes.set, MOON_ORBIT_RADIUS);
|
|
moonsetArrow.setAttribute('transform', 'translate(' + msP.x + ', ' + msP.y + ')');
|
|
moonsetArrow.setAttribute('opacity', '1');
|
|
} else {
|
|
moonsetArrow.setAttribute('opacity', '0');
|
|
}
|
|
|
|
// Moon phase
|
|
var moonIllum = SunCalc.getMoonIllumination(now);
|
|
document.getElementById('moon-phase-text').textContent =
|
|
getMoonPhaseText(moonIllum.phase) + ' ' + Math.round(moonIllum.fraction * 100) + '%';
|
|
renderMoonPhaseIcon(moonIllum.phase, moonIllum.fraction);
|
|
renderRpgMoon(moonIllum.phase, moonIllum.fraction);
|
|
}
|
|
|
|
var startTime = Date.now();
|
|
|
|
window.weatherThemes = {
|
|
sunny: {
|
|
// Bright blue sky, near-white clouds — high contrast
|
|
skyColor: hexToInt(ASH.blueLight),
|
|
cloudColor: hexToInt(ASH.chalk),
|
|
lightColor: hexToInt(ASH.chalkLight),
|
|
backgroundColor: hexToInt(ASH.blue),
|
|
speed: 1.0
|
|
},
|
|
cloudy: {
|
|
// Mid-grey sky, light clouds — moody but visible
|
|
skyColor: hexToInt(ASH.blue),
|
|
cloudColor: hexToInt(ASH.boneLight),
|
|
lightColor: hexToInt(ASH.chalk),
|
|
backgroundColor: hexToInt(ASH.steel),
|
|
speed: 1.2
|
|
},
|
|
stormy: {
|
|
// Dark sky, medium clouds — heavy overcast
|
|
skyColor: hexToInt(ASH.steel),
|
|
cloudColor: hexToInt(ASH.stone),
|
|
lightColor: hexToInt(ASH.bone),
|
|
backgroundColor: hexToInt(ASH.panel),
|
|
speed: 2.5
|
|
},
|
|
night: {
|
|
// Very dark sky, moon-lit clouds
|
|
skyColor: hexToInt(ASH.void),
|
|
cloudColor: hexToInt(ASH.icon),
|
|
lightColor: hexToInt(ASH.chalk),
|
|
backgroundColor: hexToInt(ASH.void),
|
|
speed: 0.4
|
|
}
|
|
};
|
|
|
|
function getWeatherThemeName(code) {
|
|
if (code === 0) return 'sunny';
|
|
if (code >= 1 && code <= 3) return 'cloudy';
|
|
if (code >= 45 && code <= 48) return 'cloudy';
|
|
if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 71 && code <= 77) || (code >= 85 && code <= 86)) return 'stormy';
|
|
if (code >= 95) return 'stormy';
|
|
return 'cloudy';
|
|
}
|
|
|
|
function mixColor(color1, color2, factor) {
|
|
if (color1 === undefined || color2 === undefined) return color1 || color2;
|
|
var r1 = (color1 >> 16) & 255;
|
|
var g1 = (color1 >> 8) & 255;
|
|
var b1 = color1 & 255;
|
|
var r2 = (color2 >> 16) & 255;
|
|
var g2 = (color2 >> 8) & 255;
|
|
var b2 = color2 & 255;
|
|
var r = Math.round(r1 + (r2 - r1) * factor);
|
|
var g = Math.round(g1 + (g2 - g1) * factor);
|
|
var b = Math.round(b1 + (b2 - b1) * factor);
|
|
return (r << 16) | (g << 8) | b;
|
|
}
|
|
|
|
var prevThemeColors = null;
|
|
var targetThemeColors = null;
|
|
var transitionStartTime = 0;
|
|
var TRANSITION_DURATION = 5 * 60 * 1000;
|
|
var currentWeatherThemeName = null;
|
|
var currentThemeSpeed = 0.4;
|
|
|
|
function setWeatherTheme(themeName) {
|
|
if (currentWeatherThemeName !== themeName) {
|
|
prevThemeColors = getEffectiveThemeColors();
|
|
targetThemeColors = window.weatherThemes[themeName] || window.weatherThemes.cloudy;
|
|
transitionStartTime = Date.now();
|
|
currentWeatherThemeName = themeName;
|
|
}
|
|
}
|
|
|
|
// ---- Weather simulation -----------------------------------------
|
|
// Cycles through a list of weather states automatically. Useful
|
|
// for A/B testing the scene against every theme without
|
|
// waiting on the open-meteo API.
|
|
var simWeathers = ['sunny', 'cloudy', 'stormy', 'night'];
|
|
var simIndex = 0;
|
|
var simTimer = null;
|
|
var __weatherSimActive = false;
|
|
|
|
function startWeatherSim(intervalMs) {
|
|
stopWeatherSim();
|
|
var step = intervalMs || (window.CONFIG && window.CONFIG.weatherSimInterval) || 30000;
|
|
simIndex = 0;
|
|
setWeatherTheme(simWeathers[simIndex]);
|
|
simTimer = setInterval(function() {
|
|
simIndex = (simIndex + 1) % simWeathers.length;
|
|
setWeatherTheme(simWeathers[simIndex]);
|
|
}, step);
|
|
__weatherSimActive = true;
|
|
}
|
|
|
|
function stopWeatherSim() {
|
|
if (simTimer) {
|
|
clearInterval(simTimer);
|
|
simTimer = null;
|
|
}
|
|
__weatherSimActive = false;
|
|
updateSimButton();
|
|
}
|
|
|
|
function updateSimButton() {
|
|
var btn = document.getElementById('dbg-btn-sim');
|
|
if (btn) {
|
|
btn.textContent = 'Auto-Cycle: ' + (__weatherSimActive ? 'on' : 'off');
|
|
btn.classList.toggle('on', __weatherSimActive);
|
|
}
|
|
}
|
|
|
|
function toggleWeatherSim() {
|
|
if (__weatherSimActive) {
|
|
stopWeatherSim();
|
|
} else {
|
|
startWeatherSim();
|
|
}
|
|
updateSimButton();
|
|
}
|
|
|
|
// Periodically refresh the toggle state in case another tab
|
|
// toggled it.
|
|
setInterval(updateSimButton, 1000);
|
|
|
|
function getEffectiveThemeColors() {
|
|
if (!targetThemeColors) return null;
|
|
|
|
var elapsed = Date.now() - transitionStartTime;
|
|
var smoothT;
|
|
if (!prevThemeColors || elapsed >= TRANSITION_DURATION) {
|
|
smoothT = 1;
|
|
} else {
|
|
var t = elapsed / TRANSITION_DURATION;
|
|
smoothT = t * t * (3 - 2 * t);
|
|
}
|
|
|
|
var result = {};
|
|
var keys = ['skyColor', 'cloudColor', 'lightColor', 'backgroundColor'];
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var k = keys[i];
|
|
if (prevThemeColors && smoothT < 1) {
|
|
result[k] = mixColor(prevThemeColors[k], targetThemeColors[k], smoothT);
|
|
} else {
|
|
result[k] = targetThemeColors[k];
|
|
}
|
|
}
|
|
if (prevThemeColors && prevThemeColors.speed !== undefined &&
|
|
targetThemeColors.speed !== undefined) {
|
|
// ramp cloud speed with the colour transition instead of
|
|
// stepping it when the transition completes (a speed step
|
|
// reads as a sudden jump of the whole scene)
|
|
currentThemeSpeed = prevThemeColors.speed +
|
|
(targetThemeColors.speed - prevThemeColors.speed) * smoothT;
|
|
}
|
|
if (smoothT >= 1 && prevThemeColors !== targetThemeColors) {
|
|
prevThemeColors = targetThemeColors;
|
|
currentThemeSpeed = targetThemeColors.speed;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---- Data credits (bottom of the info box, CONFIG.credits) ----
|
|
(function renderCredits() {
|
|
var el = document.getElementById('credits');
|
|
if (!el) return;
|
|
if (window.CONFIG && window.CONFIG.credits === false) {
|
|
el.style.display = 'none';
|
|
return;
|
|
}
|
|
var cfg = window.CONFIG || {};
|
|
var lines = [];
|
|
var style = (cfg.ground && cfg.ground.style) || 'streets';
|
|
lines.push('Basemap: ' + (style === 'satellite'
|
|
? 'Esri World Imagery' : 'Esri World Street Map'));
|
|
var t = cfg.terrain || {};
|
|
if (t.enabled !== false) {
|
|
lines.push('Terrain: Copernicus DEM via Mapzen Terrarium');
|
|
if (t.nightLights !== false) lines.push('Night lights: NASA GIBS VIIRS');
|
|
if (t.carLights !== false || t.cityLights !== false) lines.push('Roads: \u00a9 OpenStreetMap contributors (ODbL)');
|
|
}
|
|
lines.push('Weather: Open-Meteo');
|
|
el.innerHTML = lines.join('<br>');
|
|
})();
|
|
|
|
function applyWeatherTheme(c) {
|
|
var themeColors = getEffectiveThemeColors();
|
|
if (!themeColors) return c;
|
|
|
|
// At night, weather only tints slightly - we don't want a
|
|
// 'cloudy' sky colour washing over deep night sky and
|
|
// making it look bright.
|
|
var _sunAlt = DAY_SIM ? (window.__currentSunAlt || 0)
|
|
: SunCalc.getPosition(new Date(), SITE_LAT, SITE_LON).altitude;
|
|
var nightFactor = Math.max(0, Math.min(1, -_sunAlt / 0.4));
|
|
// Daytime: 40% weather mix (was 60%) — keeps day/night
|
|
// palette visible underneath, weather only adds tint.
|
|
var mix = 0.4 * (1 - nightFactor * 0.7);
|
|
|
|
return {
|
|
sky: mixColor(c.sky, themeColors.skyColor, mix),
|
|
cloud: mixColor(c.cloud, themeColors.cloudColor, mix),
|
|
cloudShadow: c.cloudShadow !== undefined
|
|
? mixColor(c.cloudShadow, themeColors.cloudColor, mix * 0.8)
|
|
: mixColor(c.cloud, themeColors.cloudColor, mix * 0.8),
|
|
light: mixColor(c.sun, themeColors.lightColor, mix * 0.5),
|
|
bg: mixColor(c.bg, themeColors.backgroundColor, mix)
|
|
};
|
|
}
|
|
|
|
function getCurrentDayProgress() {
|
|
var now = new Date();
|
|
var startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
|
return (now.getTime() - startOfDay.getTime()) / 86400000;
|
|
}
|
|
|
|
function toggleDebugPanel() {
|
|
var content = document.getElementById('debug-content');
|
|
var title = document.querySelector('.debug-title');
|
|
var icon = document.getElementById('debug-toggle-icon');
|
|
var isCollapsed = content.classList.contains('collapsed');
|
|
if (isCollapsed) {
|
|
content.classList.remove('collapsed');
|
|
title.classList.add('expanded');
|
|
icon.textContent = '▼';
|
|
} else {
|
|
content.classList.add('collapsed');
|
|
title.classList.remove('expanded');
|
|
icon.textContent = '▶';
|
|
}
|
|
}
|
|
|
|
var UNITS_OPTIONS = [
|
|
{ value: 'metric', label: '°C, km/h' },
|
|
{ value: 'imperial', label: '°F, mph' }
|
|
];
|
|
|
|
var LANGUAGE_OPTIONS = [
|
|
{ value: 'en', label: 'EN' },
|
|
{ value: 'de', label: 'DE' },
|
|
{ value: 'it', label: 'IT' },
|
|
{ value: 'es', label: 'ES' },
|
|
{ value: 'fr', label: 'FR' },
|
|
{ value: 'ru', label: 'RU' }
|
|
];
|
|
|
|
function applyI18n() {
|
|
var els = document.querySelectorAll('[data-i18n]');
|
|
for (var i = 0; i < els.length; i++) {
|
|
var key = els[i].getAttribute('data-i18n');
|
|
els[i].textContent = t(key);
|
|
}
|
|
// Trigger a weather refresh so dynamic texts (slot labels, current
|
|
// weather details) are re-rendered with the new language.
|
|
if (typeof fetchWeather === 'function') fetchWeather();
|
|
}
|
|
|
|
function setDebugLanguage(lang) {
|
|
window.CONFIG.language = lang;
|
|
applyI18n();
|
|
// Trigger weather refresh to update day labels
|
|
if (typeof fetchWeather === 'function') fetchWeather();
|
|
// Force a debug update so the digital clock shows the new language
|
|
if (typeof updateDebug === 'function') {
|
|
var now = new Date();
|
|
updateDebug(0, null,
|
|
{ sky: 0, cloud: 0, light: 0, bg: 0 },
|
|
currentWeatherThemeName, realDayProgress);
|
|
}
|
|
}
|
|
|
|
function populateDebugSelects() {
|
|
// Theme select (from window.THEMES)
|
|
var themeSelect = document.getElementById('dbg-theme-select');
|
|
themeSelect.innerHTML = '';
|
|
if (window.THEMES) {
|
|
for (var key in window.THEMES) {
|
|
if (window.THEMES.hasOwnProperty(key)) {
|
|
var opt = document.createElement('option');
|
|
opt.value = key;
|
|
opt.textContent = key.charAt(0).toUpperCase() + key.slice(1);
|
|
if (key === window.CONFIG.theme) opt.selected = true;
|
|
themeSelect.appendChild(opt);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Units select
|
|
var unitsSelect = document.getElementById('dbg-units-select');
|
|
unitsSelect.innerHTML = '';
|
|
for (var i = 0; i < UNITS_OPTIONS.length; i++) {
|
|
var opt2 = document.createElement('option');
|
|
opt2.value = UNITS_OPTIONS[i].value;
|
|
opt2.textContent = UNITS_OPTIONS[i].label;
|
|
if (UNITS_OPTIONS[i].value === window.CONFIG.units) opt2.selected = true;
|
|
unitsSelect.appendChild(opt2);
|
|
}
|
|
|
|
// Language select
|
|
var langSelect = document.getElementById('dbg-language-select');
|
|
langSelect.innerHTML = '';
|
|
for (var j = 0; j < LANGUAGE_OPTIONS.length; j++) {
|
|
var opt3 = document.createElement('option');
|
|
opt3.value = LANGUAGE_OPTIONS[j].value;
|
|
opt3.textContent = LANGUAGE_OPTIONS[j].label;
|
|
if (LANGUAGE_OPTIONS[j].value === window.CONFIG.language) opt3.selected = true;
|
|
langSelect.appendChild(opt3);
|
|
}
|
|
}
|
|
|
|
function setDebugTheme(theme) {
|
|
var cssFile = window.THEMES && window.THEMES[theme];
|
|
var css = document.getElementById('theme-css');
|
|
if (css && cssFile) css.href = cssFile;
|
|
setTimeout(function() {
|
|
rebuildFromColors();
|
|
var pal = document.getElementById('dbg-palette-name');
|
|
if (pal) pal.textContent = theme.charAt(0).toUpperCase() + theme.slice(1);
|
|
}, 50);
|
|
window.CONFIG.theme = theme;
|
|
}
|
|
|
|
function setDebugUnits(units) {
|
|
window.CONFIG.units = units;
|
|
// Refresh weather to display new units
|
|
if (typeof fetchWeather === 'function') fetchWeather();
|
|
}
|
|
|
|
function updateDebug(t, palette, sceneColors, themeName, realDayProgress) {
|
|
var now = new Date();
|
|
var hh = now.getHours(), mm = now.getMinutes(), ss = now.getSeconds();
|
|
document.getElementById('dbg-time').textContent =
|
|
(hh < 10 ? '0' : '') + hh + ':' + (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss;
|
|
document.getElementById('dbg-day').textContent = realDayProgress != null ? (realDayProgress * 100).toFixed(1) + '%' : (t * 100).toFixed(1) + '%';
|
|
document.getElementById('dbg-weather').textContent =
|
|
currentWeatherCode + ' (' + (window.weatherThemes[themeName] ? themeName : '?') + ')'
|
|
+ (window.__weatherStale ? ' cache' : '');
|
|
|
|
// RPG angles
|
|
var sunTimes = SunCalc.getTimes(now, SITE_LAT, SITE_LON);
|
|
var moonTimes = SunCalc.getMoonTimes(now, SITE_LAT, SITE_LON);
|
|
document.getElementById('dbg-sun-rise-angle').textContent = (angleForTime(sunTimes.sunrise) * 180 / Math.PI).toFixed(1) + '°';
|
|
document.getElementById('dbg-sun-set-angle').textContent = (angleForTime(sunTimes.sunset) * 180 / Math.PI).toFixed(1) + '°';
|
|
document.getElementById('dbg-sun-now-angle').textContent = (angleForTime(now) * 180 / Math.PI).toFixed(1) + '°';
|
|
if (moonTimes.rise) {
|
|
document.getElementById('dbg-moon-rise-angle').textContent = (angleForTime(moonTimes.rise) * 180 / Math.PI).toFixed(1) + '°';
|
|
}
|
|
if (moonTimes.set) {
|
|
document.getElementById('dbg-moon-set-angle').textContent = (angleForTime(moonTimes.set) * 180 / Math.PI).toFixed(1) + '°';
|
|
}
|
|
document.getElementById('dbg-moon-now-angle').textContent = (moonAngleForTime(now) * 180 / Math.PI).toFixed(1) + '°';
|
|
document.getElementById('dbg-theme').textContent = themeName || '-';
|
|
document.getElementById('dbg-speed').textContent = currentThemeSpeed.toFixed(2);
|
|
|
|
var elapsed = Date.now() - transitionStartTime;
|
|
var transPct = targetThemeColors ? Math.min(100, (elapsed / TRANSITION_DURATION) * 100) : 100;
|
|
document.getElementById('dbg-trans').textContent = transPct.toFixed(0) + '%';
|
|
|
|
var themeColors = getEffectiveThemeColors();
|
|
if (themeColors) {
|
|
var keys = ['skyColor', 'cloudColor', 'lightColor', 'backgroundColor'];
|
|
var keyMap = {skyColor: 'sky', cloudColor: 'cloud', lightColor: 'sunLight', backgroundColor: 'bg'};
|
|
for (var i = 0; i < keys.length; i++) {
|
|
var k = keys[i];
|
|
var kk = keyMap[k];
|
|
var c = themeColors[k];
|
|
document.getElementById('dbg-t-' + kk).style.backgroundColor = '#' + ('00000' + c.toString(16)).slice(-6);
|
|
document.getElementById('dbg-h-' + kk).textContent = '#' + ('00000' + c.toString(16)).slice(-6);
|
|
}
|
|
}
|
|
|
|
if (sceneColors) {
|
|
var vKeys = ['sky', 'cloud', 'light', 'bg'];
|
|
var vKeyMap = {sky: 'sky', cloud: 'cloud', light: 'sunLight', bg: 'bg'};
|
|
for (var j = 0; j < vKeys.length; j++) {
|
|
var vk = vKeys[j];
|
|
var vc = sceneColors[vk];
|
|
var kk = vKeyMap[vk];
|
|
document.getElementById('dbg-v-' + kk).style.backgroundColor = '#' + ('00000' + vc.toString(16)).slice(-6);
|
|
document.getElementById('dbg-vh-' + kk).textContent = '#' + ('00000' + vc.toString(16)).slice(-6);
|
|
}
|
|
}
|
|
|
|
// Theme table
|
|
var themeNames = ['sunny', 'cloudy', 'stormy', 'night'];
|
|
var themeFields = [
|
|
{ key: 'skyColor', label: 'sky' },
|
|
{ key: 'cloudColor', label: 'cloud' },
|
|
{ key: 'lightColor', label: 'light' },
|
|
{ key: 'backgroundColor', label: 'bg' }
|
|
];
|
|
var tableHtml = '<div class="theme-table-row theme-table-header">';
|
|
tableHtml += '<span>Theme (Ash)</span>';
|
|
for (var t = 0; t < themeFields.length; t++) {
|
|
tableHtml += '<span title="' + themeFields[t].key + '">' + themeFields[t].label + '</span>';
|
|
}
|
|
tableHtml += '<span>speed</span>';
|
|
tableHtml += '</div>';
|
|
for (var n = 0; n < themeNames.length; n++) {
|
|
var tn = themeNames[n];
|
|
var th = window.weatherThemes[tn];
|
|
tableHtml += '<div class="theme-table-row">';
|
|
tableHtml += '<span class="theme-table-name">' + tn + '</span>';
|
|
for (var f = 0; f < themeFields.length; f++) {
|
|
var fc = th[themeFields[f].key];
|
|
tableHtml += '<div class="theme-table-mini-swatch" style="background:#' + ('00000' + fc.toString(16)).slice(-6) + '"></div>';
|
|
}
|
|
tableHtml += '<span class="theme-table-speed">' + th.speed + '</span>';
|
|
tableHtml += '</div>';
|
|
}
|
|
var __sigTheme = themeName + '|' + currentWeatherCode + '|' +
|
|
((window.CONFIG && window.CONFIG.theme) || '');
|
|
if (__sigTheme !== window.__dbgThemeSig) {
|
|
window.__dbgThemeSig = __sigTheme;
|
|
document.getElementById('dbg-theme-table').innerHTML = tableHtml;
|
|
}
|
|
|
|
// Color palette table - all colors from the palette
|
|
var colorKeys = [
|
|
'black', 'dark', 'gray-darker', 'gray-dark', 'gray', 'gray-light',
|
|
'icon', 'stone', 'bone', 'chalk',
|
|
'orange', 'yellow', 'red', 'brown', 'pink', 'green', 'blue', 'blue-light', 'purple'
|
|
];
|
|
var dbgRoot = getComputedStyle(document.documentElement);
|
|
var colorHtml = '<div class="theme-table-header">Palette</div>';
|
|
for (var k = 0; k < colorKeys.length; k++) {
|
|
var ck = colorKeys[k];
|
|
var cv = ASH[ck] || dbgRoot.getPropertyValue('--' + ck).trim();
|
|
colorHtml += '<div class="palette-table-row">' +
|
|
'<span class="theme-table-name">' + ck + '</span>' +
|
|
'<div class="theme-table-mini-swatch" style="background:' + cv + '"></div>' +
|
|
'<span class="theme-table-speed">' + cv + '</span>' +
|
|
'</div>';
|
|
}
|
|
var __sigPal = (window.CONFIG && window.CONFIG.theme) || '';
|
|
if (__sigPal !== window.__dbgPalSig) {
|
|
window.__dbgPalSig = __sigPal;
|
|
document.getElementById('dbg-color-table').innerHTML = colorHtml;
|
|
}
|
|
}
|
|
|
|
var __fpsCount = 0, __fpsT0 = performance.now(), __dbgT0 = 0;
|
|
|
|
function animate() {
|
|
var t = (Date.now() - startTime) / CYCLE % 1;
|
|
var c = getColor(t);
|
|
c = applyWeatherTheme(c);
|
|
// Expose current palette + weather info for the WebGL scene
|
|
window.__currentPalette = c;
|
|
window.__currentWeatherName = currentWeatherThemeName;
|
|
window.__currentDayProgress = realDayProgress;
|
|
window.__currentThemeSpeed = currentThemeSpeed;
|
|
// Sun altitude in radians. Negative = below horizon (night).
|
|
if (DAY_SIM) {
|
|
__simT = t;
|
|
window.__currentDayProgress = t;
|
|
// virtual weekday: starts at the real one and advances with
|
|
// every simulated day, so weekends pass by in the 20s cycle
|
|
if (typeof window.__simDow !== 'number') {
|
|
window.__simDow = new Date().getDay();
|
|
window.__simLastT = t;
|
|
} else if (t < window.__simLastT) {
|
|
window.__simDow = (window.__simDow + 1) % 7;
|
|
}
|
|
window.__simLastT = t;
|
|
window.__flightDow = window.__simDow;
|
|
var _alt = simSunAlt(t);
|
|
window.__currentSunAlt = _alt;
|
|
// bearing: 6:00 (t=0.25) = east (pi/2), 12:00 = south (pi),
|
|
// 18:00 = west (3pi/2) -> bearing = 2*PI*t
|
|
var _sunBearing = t * Math.PI * 2;
|
|
window.__currentSunDir = [
|
|
-Math.cos(_alt) * Math.sin(_sunBearing),
|
|
Math.sin(_alt),
|
|
Math.cos(_alt) * Math.cos(_sunBearing)
|
|
];
|
|
} else {
|
|
var _sunPos = SunCalc.getPosition(new Date(), SITE_LAT, SITE_LON);
|
|
window.__currentSunAlt = _sunPos.altitude;
|
|
window.__flightDow = new Date().getDay();
|
|
// Unit vector towards the sun in world space (X east, Z north,
|
|
// Y up). SunCalc azimuth is measured from south, clockwise ->
|
|
// bearing from north = azimuth + PI.
|
|
var _sunBearingR = _sunPos.azimuth + Math.PI;
|
|
window.__currentSunDir = [
|
|
-Math.cos(_sunPos.altitude) * Math.sin(_sunBearingR),
|
|
Math.sin(_sunPos.altitude),
|
|
Math.cos(_sunPos.altitude) * Math.cos(_sunBearingR)
|
|
];
|
|
}
|
|
// ---- Moon direction ------------------------------------------
|
|
// Start elongation = real lunar phase (SunCalc: 0 new .. 0.5
|
|
// full); in day simulation the elongation advances 360/29.53
|
|
// deg per simulated day -> correct waxing/waning as cycles pass.
|
|
if (typeof window.__moonElong0 !== 'number') {
|
|
window.__moonElong0 = SunCalc.getMoonIllumination(new Date()).phase * Math.PI * 2;
|
|
window.__moonSimStartMs = Date.now();
|
|
}
|
|
var _simDays = DAY_SIM ? (Date.now() - window.__moonSimStartMs) / CYCLE : 0;
|
|
var _elong = window.__moonElong0 + _simDays * (Math.PI * 2 / 29.53);
|
|
var _sd = window.__currentSunDir;
|
|
var _cp = SITE_LAT * Math.PI / 180;
|
|
var _kp = [0, Math.sin(_cp), Math.cos(_cp)]; // celestial pole
|
|
var _ecs = Math.cos(_elong), _esn = Math.sin(_elong);
|
|
var _kdv = _kp[0]*_sd[0] + _kp[1]*_sd[1] + _kp[2]*_sd[2];
|
|
var _cx = _kp[1]*_sd[2] - _kp[2]*_sd[1];
|
|
var _cy = _kp[2]*_sd[0] - _kp[0]*_sd[2];
|
|
var _cz = _kp[0]*_sd[1] - _kp[1]*_sd[0];
|
|
window.__currentMoonDir = [
|
|
_sd[0]*_ecs + _cx*_esn + _kp[0]*_kdv*(1-_ecs),
|
|
_sd[1]*_ecs + _cy*_esn + _kp[1]*_kdv*(1-_ecs),
|
|
_sd[2]*_ecs + _cz*_esn + _kp[2]*_kdv*(1-_ecs)
|
|
];
|
|
var __skyHex = '#' + ('00000' + c.sky.toString(16)).slice(-6);
|
|
if (__skyHex !== window.__lastSkyHex) {
|
|
window.__lastSkyHex = __skyHex;
|
|
document.body.style.backgroundColor = __skyHex;
|
|
}
|
|
// Flight-direction compass needle (only while clouds3d feeds it)
|
|
if (compassNeedle) {
|
|
if (typeof window.__flightBearing === 'number') {
|
|
var _nd = (window.__flightBearing * 180 / Math.PI).toFixed(1);
|
|
if (_nd !== window.__lastNeedleDeg) {
|
|
window.__lastNeedleDeg = _nd;
|
|
compassNeedle.style.display = '';
|
|
compassNeedle.setAttribute('transform', 'rotate(' + _nd + ' 140 140)');
|
|
}
|
|
} else if (compassNeedle.style.display !== 'none') {
|
|
compassNeedle.style.display = 'none';
|
|
}
|
|
}
|
|
// FPS counter (rendered frames/second of the rAF loop)
|
|
__fpsCount++;
|
|
var __nowMs = performance.now();
|
|
if (__nowMs - __fpsT0 >= 1000) {
|
|
window.__fps = Math.round(__fpsCount * 1000 / (__nowMs - __fpsT0));
|
|
var __fpsEl = document.getElementById('dbg-fps');
|
|
if (__fpsEl) __fpsEl.textContent = window.__fps;
|
|
__fpsCount = 0;
|
|
__fpsT0 = __nowMs;
|
|
}
|
|
// Debug DOM writes are expensive; 4 Hz is plenty for readouts
|
|
if (__nowMs - __dbgT0 >= 250) {
|
|
__dbgT0 = __nowMs;
|
|
updateDebug(t, null, c, currentWeatherThemeName, realDayProgress);
|
|
}
|
|
requestAnimationFrame(animate);
|
|
}
|
|
|
|
var __lastWeatherHour = null;
|
|
function tick() {
|
|
try {
|
|
updateClockAndEvents();
|
|
} catch (e) {
|
|
console.error('Tick error:', e);
|
|
}
|
|
// simulated day/hour switch -> refresh the weather block
|
|
if (DAY_SIM && weatherData) {
|
|
var wh = Math.floor(simNow().getTime() / 3600000);
|
|
if (wh !== __lastWeatherHour) {
|
|
__lastWeatherHour = wh;
|
|
try { updateWeatherDisplay(); } catch (e) { console.error(e); }
|
|
}
|
|
}
|
|
setTimeout(tick, 1000);
|
|
}
|
|
|
|
// ============ Weather ============
|
|
// Default location: London, UK
|
|
var DEFAULT_LAT = 51.5074;
|
|
var DEFAULT_LON = -0.1278;
|
|
|
|
var WEATHER_LAT = (window.CONFIG && window.CONFIG.location && window.CONFIG.location.lat) || DEFAULT_LAT;
|
|
var WEATHER_LON = (window.CONFIG && window.CONFIG.location && window.CONFIG.location.lon) || DEFAULT_LON;
|
|
|
|
// Try browser geolocation if no config location set
|
|
function initBrowserGeolocation() {
|
|
if (window.CONFIG && window.CONFIG.location && window.CONFIG.location.lat) return;
|
|
if (!navigator.geolocation) return;
|
|
navigator.geolocation.getCurrentPosition(
|
|
function(pos) {
|
|
WEATHER_LAT = pos.coords.latitude;
|
|
WEATHER_LON = pos.coords.longitude;
|
|
window.CONFIG.location = { lat: WEATHER_LAT, lon: WEATHER_LON };
|
|
// Reload weather with new coordinates
|
|
if (typeof fetchWeather === 'function') fetchWeather();
|
|
// Reverse geocode to get place name
|
|
reverseGeocode(WEATHER_LAT, WEATHER_LON);
|
|
},
|
|
function() { /* ignored - keep default */ },
|
|
{ timeout: 10000 }
|
|
);
|
|
}
|
|
|
|
// Reverse geocode lat/lon to a place name using Nominatim (OpenStreetMap).
|
|
// Free, no API key required. Has a 1 req/sec rate limit.
|
|
// Skipped if a custom label is already configured (luxury: user can
|
|
// set their own place name in CONFIG.location.label).
|
|
function reverseGeocode(lat, lon) {
|
|
if (window.CONFIG && window.CONFIG.location && window.CONFIG.location.label) return;
|
|
// cached label first: works offline and avoids hitting
|
|
// Nominatim on every start
|
|
if (window.BackgroundCache) {
|
|
window.BackgroundCache.get('meta/geolabel').then(function(rec) {
|
|
if (rec && rec.value && window.CONFIG && window.CONFIG.location &&
|
|
!window.CONFIG.location.label) {
|
|
window.CONFIG.location.label = rec.value;
|
|
}
|
|
});
|
|
}
|
|
var url = 'https://nominatim.openstreetmap.org/reverse?format=json&lat=' +
|
|
lat + '&lon=' + lon + '&zoom=10&addressdetails=1';
|
|
fetch(url, {
|
|
headers: { 'Accept-Language': (window.CONFIG && window.CONFIG.language) || 'en' }
|
|
})
|
|
.then(function(r) { return r.ok ? r.json() : null; })
|
|
.then(function(data) {
|
|
if (data && data.address) {
|
|
var addr = data.address;
|
|
var name = addr.city || addr.town || addr.village || addr.hamlet ||
|
|
addr.suburb || addr.county || addr.state || data.display_name;
|
|
if (window.CONFIG && window.CONFIG.location) {
|
|
window.CONFIG.location.label = name;
|
|
}
|
|
if (window.BackgroundCache) {
|
|
window.BackgroundCache.put('meta/geolabel', { value: name });
|
|
}
|
|
}
|
|
})
|
|
.catch(function() { /* ignore errors */ });
|
|
}
|
|
|
|
// If config has lat/lon but no label, fetch the label
|
|
function fetchLocationLabel() {
|
|
if (!window.CONFIG || !window.CONFIG.location) return;
|
|
if (window.CONFIG.location.label) return;
|
|
if (!window.CONFIG.location.lat) return;
|
|
reverseGeocode(window.CONFIG.location.lat, window.CONFIG.location.lon);
|
|
}
|
|
|
|
// Get timezone offset based on user's local time
|
|
function getTimezoneOffsetMinutes() {
|
|
return new Date().getTimezoneOffset();
|
|
}
|
|
var weatherData = null;
|
|
|
|
var WMO_ICONS = {
|
|
0: 'sun',
|
|
1: 'sun-cloud',
|
|
2: 'cloud-sun',
|
|
3: 'cloud',
|
|
45: 'fog',
|
|
48: 'fog',
|
|
51: 'drizzle',
|
|
53: 'drizzle',
|
|
55: 'drizzle',
|
|
56: 'drizzle',
|
|
57: 'drizzle',
|
|
61: 'rain',
|
|
63: 'rain',
|
|
65: 'rain',
|
|
66: 'rain',
|
|
67: 'rain',
|
|
71: 'snow',
|
|
73: 'snow',
|
|
75: 'snow',
|
|
77: 'snow',
|
|
80: 'rain',
|
|
81: 'rain',
|
|
82: 'rain',
|
|
85: 'snow',
|
|
86: 'snow',
|
|
95: 'thunder',
|
|
96: 'thunder',
|
|
99: 'thunder'
|
|
};
|
|
|
|
function weatherSVG(code) {
|
|
var c = 'var(--bone)';
|
|
switch (code) {
|
|
case 0:
|
|
return '<svg viewBox="0 0 24 24" fill="' + c + '"><circle cx="12" cy="12" r="5"/><g stroke="' + c + '" stroke-width="2" stroke-linecap="round"><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.3" y2="6.3"/><line x1="17.7" y1="17.7" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.3" y2="17.7"/><line x1="17.7" y1="6.3" x2="19.1" y2="4.9"/></g></svg>';
|
|
case 1:
|
|
case 2:
|
|
return '<svg viewBox="0 0 24 24" fill="none" stroke="' + c + '" stroke-width="2"><circle cx="8" cy="8" r="3" fill="' + c + '"/><line x1="8" y1="2" x2="8" y2="3"/><line x1="2" y1="8" x2="3" y2="8"/><line x1="3.5" y1="3.5" x2="4.2" y2="4.2"/><line x1="12.5" y1="3.5" x2="11.8" y2="4.2"/><path d="M10 14 a4 4 0 0 1 4 -4 a4 4 0 0 1 4 4 a3 3 0 0 1 0 6 h-8 a3 3 0 0 1 0 -6 z" fill="var(--stone)" stroke="none"/></svg>';
|
|
case 3:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 18 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/></svg>';
|
|
case 45:
|
|
case 48:
|
|
return '<svg viewBox="0 0 24 24" fill="none" stroke="' + c + '" stroke-width="2"><path d="M6 18 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z" fill="var(--stone)" stroke="' + c + '"/><line x1="8" y1="20" x2="8" y2="22"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="16" y1="20" x2="16" y2="22"/></svg>';
|
|
case 51:
|
|
case 55:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 14 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><g stroke="var(--blue)" stroke-width="1.5" stroke-linecap="round"><line x1="8" y1="17" x2="7" y2="20"/><line x1="13" y1="17" x2="12" y2="20"/><line x1="17" y1="17" x2="16" y2="20"/></g></svg>';
|
|
case 53:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 14 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><g stroke="var(--blue)" stroke-width="1.5" stroke-linecap="round"><line x1="9" y1="16" x2="8" y2="20"/><line x1="13" y1="16" x2="12" y2="20"/></g></svg>';
|
|
case 61:
|
|
case 80:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 13 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><g stroke="var(--blue)" stroke-width="1.5" stroke-linecap="round"><line x1="8" y1="16" x2="7" y2="21"/><line x1="13" y1="16" x2="12" y2="21"/><line x1="17" y1="16" x2="16" y2="21"/></g></svg>';
|
|
case 63:
|
|
case 81:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 13 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><g stroke="var(--blue)" stroke-width="1.5" stroke-linecap="round"><line x1="9" y1="15" x2="7" y2="21"/><line x1="13" y1="15" x2="11" y2="21"/><line x1="17" y1="15" x2="15" y2="21"/></g></svg>';
|
|
case 65:
|
|
case 82:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 13 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><g stroke="var(--blue)" stroke-width="1.5" stroke-linecap="round"><line x1="7" y1="15" x2="4" y2="22"/><line x1="11" y1="15" x2="8" y2="22"/><line x1="15" y1="15" x2="12" y2="22"/><line x1="19" y1="15" x2="16" y2="22"/></g></svg>';
|
|
case 71:
|
|
case 73:
|
|
case 75:
|
|
case 77:
|
|
case 85:
|
|
case 86:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 13 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><g fill="' + c + '"><circle cx="8" cy="18" r="1.5"/><circle cx="13" cy="19" r="1.5"/><circle cx="17" cy="18" r="1.5"/></g></svg>';
|
|
case 95:
|
|
case 96:
|
|
case 99:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><path d="M6 13 a4 4 0 0 1 -2 -7 a5 5 0 0 1 9 -3 a4 4 0 0 1 7 1 a3 3 0 0 1 0 6 h-14 z"/><path d="M12 2 L8 11 L12 11 L10 18 L16 9 L12 9 Z" fill="var(--yellow)"/></svg>';
|
|
default:
|
|
return '<svg viewBox="0 0 24 24" fill="var(--stone)"><circle cx="12" cy="12" r="6"/></svg>';
|
|
}
|
|
}
|
|
|
|
function fetchWeather() {
|
|
var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + WEATHER_LAT +
|
|
'&longitude=' + WEATHER_LON +
|
|
'¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m' +
|
|
'&hourly=temperature_2m,weather_code' +
|
|
'&daily=weather_code,temperature_2m_max,temperature_2m_min' +
|
|
'&timezone=Europe/Berlin&forecast_days=5';
|
|
|
|
fetch(url)
|
|
.then(function(response) { return response.json(); })
|
|
.then(function(data) {
|
|
weatherData = data;
|
|
window.__weatherStale = false;
|
|
if (window.BackgroundCache) {
|
|
window.BackgroundCache.put('meta/weather', { json: data });
|
|
}
|
|
currentWeatherCode = data.current.weather_code;
|
|
setWeatherTheme(getWeatherThemeName(currentWeatherCode));
|
|
updateWeatherDisplay();
|
|
})
|
|
.catch(function(err) {
|
|
console.log('Weather fetch failed:', err);
|
|
// offline fallback: last stored payload keeps the
|
|
// widgets and weather-driven colours alive
|
|
if (window.BackgroundCache) {
|
|
window.BackgroundCache.get('meta/weather').then(function(rec) {
|
|
if (rec && rec.json && rec.json.current) {
|
|
weatherData = rec.json;
|
|
window.__weatherStale = true;
|
|
currentWeatherCode = rec.json.current.weather_code;
|
|
setWeatherTheme(getWeatherThemeName(currentWeatherCode));
|
|
updateWeatherDisplay();
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function findHourlyIndex(targetDate, hourlyTimeArray) {
|
|
var bestIdx = 0;
|
|
var bestDiff = Infinity;
|
|
for (var i = 0; i < hourlyTimeArray.length; i++) {
|
|
var t = new Date(hourlyTimeArray[i]);
|
|
var diff = Math.abs(t.getTime() - targetDate.getTime());
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff;
|
|
bestIdx = i;
|
|
}
|
|
}
|
|
return bestIdx;
|
|
}
|
|
|
|
function updateWeatherDisplay() {
|
|
if (!weatherData) return;
|
|
|
|
var now = simNow(); // virtual calendar: real day-of-week start,
|
|
// one simulated day per CYCLE (clock stays real!)
|
|
var current = weatherData.current;
|
|
var hourly = weatherData.hourly;
|
|
var daily = weatherData.daily;
|
|
|
|
// how many simulated days have passed since the API's 'today' -
|
|
// wrapped into the 5-day forecast window, so an ever-advancing
|
|
// sim keeps cycling fresh-looking conditions instead of freezing
|
|
var firstDay = new Date(daily.time[0]);
|
|
var spanMs = daily.time.length * 86400000;
|
|
var eff = now;
|
|
var wrapGuard = 0;
|
|
while (DAY_SIM && eff.getTime() >= firstDay.getTime() + spanMs && wrapGuard++ < 2000) {
|
|
eff = new Date(eff.getTime() - spanMs);
|
|
}
|
|
if (DAY_SIM && eff.getTime() < firstDay.getTime()) eff = firstDay;
|
|
var dayOffset = DAY_SIM
|
|
? Math.max(0, Math.min(daily.time.length - 4,
|
|
Math.floor((eff - firstDay) / 86400000)))
|
|
: 0;
|
|
|
|
// "Current": in simulation mode use the hourly value at the
|
|
// simulated time; theme follows it too (unless the weather-sim
|
|
// button has manual control)
|
|
var curTemp = current.temperature_2m;
|
|
var curCode = current.weather_code;
|
|
if (DAY_SIM) {
|
|
var curIdx = findHourlyIndex(eff, hourly.time);
|
|
curTemp = hourly.temperature_2m[curIdx];
|
|
curCode = hourly.weather_code[curIdx];
|
|
if (!__weatherSimActive && curCode !== currentWeatherCode) {
|
|
currentWeatherCode = curCode;
|
|
setWeatherTheme(getWeatherThemeName(curCode));
|
|
}
|
|
}
|
|
|
|
// Current
|
|
var currentUnits = (window.CONFIG && window.CONFIG.units) || 'metric';
|
|
var tempDisplay = currentUnits === 'imperial'
|
|
? Math.round(curTemp * 9 / 5 + 32) + '\u00b0F'
|
|
: Math.round(curTemp) + '\u00b0C';
|
|
var windDisplay = currentUnits === 'imperial'
|
|
? Math.round(current.wind_speed_10m * 0.621371) + ' mph'
|
|
: Math.round(current.wind_speed_10m) + ' km/h';
|
|
var currentHtml = '<div class="weather-current-icon">' + weatherSVG(curCode) + '</div>' +
|
|
'<div><div class="weather-current-temp"><span>' + tempDisplay + '</span><span class="weather-location" id="weather-location">' +
|
|
((window.CONFIG && window.CONFIG.location && (window.CONFIG.location.label || (window.CONFIG.location.lat.toFixed(2) + ',' + window.CONFIG.location.lon.toFixed(2)))) || '') + '</span></div>' +
|
|
'<div class="weather-current-detail">' +
|
|
t('Humidity') + ' ' + Math.round(current.relative_humidity_2m) + '% · ' + t('Wind') + ' ' + windDisplay + '</div></div>';
|
|
document.getElementById('weather-current').innerHTML = currentHtml;
|
|
|
|
// Forecast - window starts at the simulated "today" so the
|
|
// list shifts forward each simulated cycle (tomorrow becomes
|
|
// today, a new day enters at the end)
|
|
var forecastHtml = '';
|
|
for (var d = dayOffset; d < dayOffset + 4 && d < daily.time.length; d++) {
|
|
var dayDate = new Date(daily.time[d]);
|
|
var dayLabel = d === dayOffset ? t('today')
|
|
: (d === dayOffset + 1 ? t('tomorrow') : '');
|
|
if (!dayLabel) {
|
|
var weekdayKey = ['Su.', 'Mo.', 'Tu.', 'We.', 'Th.', 'Fr.', 'Sa.'][dayDate.getDay()];
|
|
dayLabel = t(weekdayKey) + ' ' +
|
|
(dayDate.getDate() < 10 ? '0' : '') + dayDate.getDate() + '.' +
|
|
(dayDate.getMonth() + 1 < 10 ? '0' : '') + (dayDate.getMonth() + 1) + '.';
|
|
}
|
|
|
|
// Morning (8), Noon (14), Evening (20), Night (2)
|
|
var slots = [8, 14, 20, 2];
|
|
var slotsHtml = '';
|
|
for (var i = 0; i < slots.length; i++) {
|
|
var slotDate = new Date(dayDate);
|
|
slotDate.setHours(slots[i], 0, 0, 0);
|
|
if (i === 3 && d > 0) {
|
|
slotDate.setDate(slotDate.getDate() + 1);
|
|
} else if (i === 3 && d === 0) {
|
|
slotDate.setDate(slotDate.getDate() + 1);
|
|
}
|
|
var currentUnits = (window.CONFIG && window.CONFIG.units) || 'metric';
|
|
var convertTemp = function(c) {
|
|
return currentUnits === 'imperial'
|
|
? Math.round(c * 9 / 5 + 32) + '\u00b0F'
|
|
: Math.round(c) + '\u00b0C';
|
|
};
|
|
var slotIdx = findHourlyIndex(slotDate, hourly.time);
|
|
var slotCode = hourly.weather_code[slotIdx];
|
|
var slotTemp = hourly.temperature_2m[slotIdx];
|
|
var slotLabels = [t('Morning'), t('Noon'), t('Evening'), t('Night')];
|
|
slotsHtml += '<div class="weather-slot">' +
|
|
'<div class="weather-slot-icon">' + weatherSVG(slotCode) + '</div>' +
|
|
'<div class="weather-slot-label">' + slotLabels[i] + '</div>' +
|
|
'<div class="weather-slot-temp">' + convertTemp(slotTemp) + '</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
forecastHtml += '<div class="weather-day">' +
|
|
'<div class="weather-day-header">' +
|
|
'<span class="weather-day-name">' + dayLabel + '</span>' +
|
|
'<span class="weather-day-minmax">' + convertTemp(daily.temperature_2m_min[d]) + ' / ' + convertTemp(daily.temperature_2m_max[d]) + '</span>' +
|
|
'</div>' +
|
|
'<div class="weather-day-slots">' + slotsHtml + '</div>' +
|
|
'</div>';
|
|
}
|
|
document.getElementById('weather-forecast').innerHTML = forecastHtml;
|
|
}
|
|
|
|
// Update weather every 15 minutes
|
|
applyI18n();
|
|
populateDebugSelects();
|
|
// Apply debug flag from CONFIG
|
|
var debugEl = document.getElementById('debug-panel');
|
|
if (debugEl && !(window.CONFIG && window.CONFIG.debug)) {
|
|
debugEl.style.display = 'none';
|
|
}
|
|
// Try browser geolocation if no explicit location configured
|
|
initBrowserGeolocation();
|
|
// Fetch location label from lat/lon if available
|
|
fetchLocationLabel();
|
|
animate();
|
|
try { tick(); } catch (e) { console.error('Initial tick error:', e); }
|
|
|
|
// Weather source: real open-meteo API or local simulation
|
|
if (window.CONFIG && window.CONFIG.weatherSim) {
|
|
startWeatherSim(window.CONFIG.weatherSimInterval);
|
|
updateSimButton();
|
|
} else {
|
|
fetchWeather();
|
|
setInterval(fetchWeather, 15 * 60 * 1000);
|
|
}
|
|
} catch (e) {
|
|
window.__cloudsError = e.message || String(e);
|
|
console.error('Background init error:', e);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |