Add locenice.zip containing necessary resources
continuous-integration/drone/push Build is passing

This commit is contained in:
martin.fencl
2026-10-31 17:41:59 +01:00
parent 4b684574c6
commit b08f70d18e
18 changed files with 2816 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# Klabava · pracovní 3D web
Tato složka obsahuje samostatný web s otočným modelem domu a pozemku, simulací slunce a odkazy na dodané PDF. Styl ovládání a časové chování simulace vychází z projektu Ločenice. Barvy a standardy domu zůstávají otevřené.
## Spuštění na počítači
Web používá ES moduly a Three.js z připnuté verze CDN jsDelivr. Spusťte místní server z této složky:
```sh
python3 -m http.server 8000
```
Potom otevřete <http://localhost:8000>. Prohlížeč potřebuje WebGL a připojení k CDN Three.js. Při nasazení na hosting nahrajte celý obsah složky `klabava/`; soubory `index.html`, `css/`, `js/`, `data/` a `references/` musí zůstat pohromadě. `index.php` je vstup pro PHP hosting.
## Ovládání
- Tažením myši model otáčíte, kolečkem přibližujete a pravým tlačítkem posouváte.
- Příjezd, Zahrada a Shora nastaví pohled; Terén, Parcela a Parkování přepínají vrstvy pozemku.
- Panel Slunce zachovává volbu data a času, sezónní dny, přehrávání dne, rychlost, východ/poledne/západ, azimut, výšku slunce, stíny, časové pásmo Europe/Prague a PNG.
- Zeměpisná poloha je nastavena přibližně na střed obce Klabava (49,7536° N, 13,5394° E). Natočení pozemku lze upravit v rozšířeném nastavení slunce.
- Stáhnout model GLB exportuje aktuálně viditelný 3D model domu a pozemku. Sluneční ovládání je součástí webu, nikoli souboru GLB.
## Podklady a míra přesnosti
Z půdorysu a pohledů vychází obrys domu 17,40 × 13,05 m, zastavěná plocha 178,75 m², užitná plocha 153,74 m² a výška hřebene +5,832 m. Situace uvádí parcelu 66/17 o výměře 1 037 m² a 206,54 m² včetně krytého vstupu a terasy. Ve výškopisu jsou zaměřené hodnoty přibližně 349,5 m n. m. u komunikace a 347,2 m n. m. u zadní části parcely.
Objem domu a terén jsou zjednodušené z PDF. Rozmístění otvorů a střešní roviny jsou schematické. Hranice parcely je převzatá graficky ze situačního PDF, nikoli z vytyčovacích souřadnic. Výšky terénu jsou interpolované mezi zaměřenými body. Stromy, příjezd, parkovací plocha a materiály slouží k prostorové orientaci. Model není BIM ani podklad pro stavbu, zemní práce nebo vytyčení.
Soubor `data/model.json` uchovává hlavní údaje projektu. Dodané PDF jsou zkopírované do `references/` a dostupné z panelu Projekt. Barvy fasády, střešní krytiny, oken a vybavení nejsou zatím vybrané.
## Soubory
- `index.html` — webová aplikace.
- `index.php` — jednoduchý vstup pro PHP hosting.
- `js/klabava.js` — sestavení 3D modelu, kamera, ovládání a export GLB.
- `js/solar.js` — výpočet polohy slunce převzatý z podkladového webu.
- `css/app.css` a `css/web.css` — vizuální základ z původního webu; `css/klabava.css` obsahuje úpravy pro Klabavu.
- `data/model.json` — souhrn údajů z výkresů.
- `references/` — výškopis, situace, půdorys, pohledy a řez.
+6
View File
@@ -0,0 +1,6 @@
Three.js r180 / 0.180.0 is loaded from jsDelivr. It is not bundled here.
Source: https://github.com/mrdoob/three.js/tree/r180
License: MIT, Copyright (c) 2010-2025 three.js authors.
The architectural drawings and survey in references/ were supplied for this project.
The 3D geometry is a visual interpretation of those PDFs and does not claim survey or construction accuracy.
+1728
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
:root{--solar:#bd984f;--solar-soft:#f5eedc;--sun-side:380px}
header h1{font-size:32px;letter-spacing:-1px}
.topright{z-index:5}
.panel{z-index:6;max-height:calc(100dvh - 96px);overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}
.panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}
.panel-kicker{font-size:9px;font-weight:700;letter-spacing:1.7px;color:#8d753d;margin-bottom:6px}
.panel-close{flex:0 0 30px;border:0;border-radius:50%;width:30px;height:30px;background:#eef1e9;color:#657160;font-size:21px;line-height:1}
.panel .row{gap:12px}
.panel .row strong{text-align:right}
.panel .status{margin-top:18px}
.document-links{gap:7px}
.document-links a.btn{font-size:10px;padding:8px 10px}
.site-switch{font-family:Arial,Helvetica,sans-serif}
.site-controls{display:flex}
#siteParking{accent-color:#536f56}
#loading{position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;gap:11px;background:#e7ebe3;color:#4f6252;font-size:12px;letter-spacing:.2px;transition:opacity .25s}
#loading.done{opacity:0;pointer-events:none}
#loading.error{inset:auto 50% 50% auto;transform:translate(50%,50%);width:min(420px,calc(100vw - 36px));padding:24px;background:#fafbf6;border:1px solid #d8ded4;border-radius:14px;box-shadow:0 12px 38px #26342c22;line-height:1.6}
.loader-dot{width:8px;height:8px;border-radius:50%;background:#607f63;box-shadow:0 0 0 5px #607f6320;animation:pulse 1s ease-in-out infinite alternate}
@keyframes pulse{to{transform:scale(.72);opacity:.5}}
#toast{position:fixed;left:50%;top:22px;z-index:10;transform:translateX(-50%);padding:9px 13px;border-radius:8px;background:#26342ce8;color:white;font-size:11px;opacity:0;pointer-events:none;transition:opacity .2s}
#sunOverlay{z-index:2}
#sunPanel{z-index:7}
#panel.open{display:block}
body.info-open #view,body.info-open #measure,body.info-open #sunOverlay{width:calc(100% - 390px);height:100%}
body.info-open .bottom{left:calc((100% - 390px)/2)}
body.info-open .compass{right:408px}
body.sun-open .bottom{left:calc((100% - var(--sun-side))/2)}
body.sun-open .compass{right:calc(var(--sun-side) + 18px)}
body.sun-open #view,body.sun-open #measure,body.sun-open #sunOverlay{width:calc(100% - var(--sun-side));height:100%}
#sunStamp{z-index:2}
@media(max-width:1100px) and (min-width:851px){header .metrics{gap:14px}.metric strong{font-size:16px}.topright{right:15px;gap:5px}.btn{padding:9px 11px}.toolbar .tool{padding:9px 8px;font-size:10px}.site-switch{font-size:9px;padding:8px 9px}.panel{width:320px}.sun-open #view,.sun-open #measure,.sun-open #sunOverlay{width:calc(100% - 360px)}#sunPanel{width:332px}.sun-open .bottom{left:calc((100% - 360px)/2)}.sun-open .compass{right:378px}}
@media(max-width:850px){header{left:18px;top:17px}header h1{font-size:23px;margin:6px 0 4px}.metrics{display:none!important}.sub{font-size:10px}.topright{top:15px;right:12px;gap:5px}.topright .btn{padding:8px 10px;font-size:10px}.topright .btn.primary{padding:8px 10px}.bottom{max-width:calc(100% - 14px);bottom:13px;gap:6px}.toolbar{gap:1px;max-width:100%;overflow-x:auto;padding:5px;border-radius:12px}.toolbar .tool{padding:8px 8px;font-size:10px}.toolbar .divider{margin:0 2px;height:17px}.site-switch{padding:8px 7px;font-size:9px}.help{font-size:8px}.compass{right:13px;bottom:12px;width:62px;height:62px}.panel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:min(72dvh,570px);border-radius:15px;padding:17px}.panel h2{font-size:20px}.panel p{font-size:11px}#sunPanel{left:8px;right:8px;top:auto;bottom:8px;width:auto;max-height:min(68dvh,570px);padding:15px;border-radius:15px}.sun-panel-head{margin-bottom:10px}.sun-date-row{grid-template-columns:1fr 95px;gap:8px}.sun-field #sunClock{width:95px;font-size:18px}.sun-graph{height:70px}.sun-play-row{margin-top:8px}.sun-readouts{margin-top:12px;padding-top:12px}.sun-stat strong{font-size:21px}.sun-events{margin:10px 0 2px;padding:9px 0}body.info-open #view,body.info-open #measure,body.info-open #sunOverlay{width:100%;height:calc(100dvh - min(72dvh,570px) - 16px)}body.sun-open #view,body.sun-open #measure,body.sun-open #sunOverlay{width:100%;height:calc(100dvh - min(68dvh,570px) - 16px)}body.info-open .bottom{left:50%;bottom:calc(min(72dvh,570px) + 13px)}body.sun-open .bottom{left:50%;bottom:calc(min(68dvh,570px) + 13px)}body.info-open .compass,body.sun-open .compass{display:none}body.info-open header .sub,body.sun-open header .sub{display:none}#sunStamp{left:18px;top:87px;font-size:9px}#webStatus{left:10px;bottom:5px;font-size:7px}body.info-open .topright,body.sun-open .topright{z-index:8}}
@media(max-width:520px){.topright .btn{padding:7px 8px}.topright #infoButton{font-size:0;width:33px;height:32px;position:relative}.topright #infoButton:after{content:'i';font-size:14px;font-weight:600}.topright #sunToggle .toggle-word{display:none}.topright #sunToggle{width:34px;justify-content:center;padding:0}.topright .btn.primary{font-size:9px}.bottom{max-width:calc(100% - 8px);bottom:8px}.toolbar .tool{padding:8px 6px;font-size:9px}.toolbar .site-switch{padding:7px 5px;font-size:8px}.toolbar #savePng{display:none}.toolbar{max-width:100vw}.help{display:none}#sunPanel{max-height:72dvh}.sun-hint{margin:5px 0 8px}.sun-advanced{margin-top:10px;padding-top:9px}}
@media(max-width:390px){.topright{gap:3px}.topright .btn.primary{padding:7px}.toolbar .tool{padding:8px 5px}.toolbar .divider{display:none}.toolbar .site-switch{padding:6px 4px}.toolbar{gap:0}}
+24
View File
@@ -0,0 +1,24 @@
/* Deployment-specific UI; the original configurator layout stays independent. */
#webStatus{position:fixed;left:32px;bottom:13px;z-index:5;font-size:9px;color:#5e6b5b;pointer-events:none;background:#fafcf5d9;padding:3px 6px;border-radius:5px}
#projectToggle{position:fixed;left:32px;top:291px;z-index:5;font-size:11px;padding:9px 13px}
body.interior-active #projectToggle{top:76px;left:25px}
#projectDialog{width:min(920px,calc(100% - 32px));max-height:90dvh;padding:0;border:1px solid #dce4d5;border-radius:17px;color:#263b2c;background:#fafcf6;box-shadow:0 20px 85px #0a221740}
#projectDialog::backdrop{background:#13271988;backdrop-filter:blur(4px)}
.web-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:23px 27px;border-bottom:1px solid #dae2d5;position:sticky;top:0;background:#fafcf6;z-index:1}
.web-dialog-head small{font-size:9px;letter-spacing:2px;color:#6b7f64}.web-dialog-head h2{font-weight:500;font-size:25px;margin:7px 0 0}
.web-dialog-body{padding:8px 27px 25px;font-size:13px;line-height:1.65}.web-dialog-body h3{font-weight:600;font-size:16px;margin:24px 0 13px}.web-lead{font-size:14px;color:#657461}
.web-note{font-size:11px;color:#6c7865}.web-photo-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.web-photo-grid button{padding:0;border:1px solid #d9e0d1;border-radius:10px;overflow:hidden;background:white;text-align:left;line-height:1.5}.web-photo-grid img{display:block;width:100%;height:210px;object-fit:cover}.web-photo-grid span{display:block;padding:10px 12px;font-size:12px}
.web-stat-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.web-stat-grid>div{background:#edf2e9;padding:14px;border-radius:9px}.web-stat-grid strong{display:block;font-size:19px;font-weight:500}.web-stat-grid span{display:block;font-size:10px;margin-top:4px;color:#6d7e65;line-height:1.5}
.web-actions{display:flex;flex-wrap:wrap;gap:9px;margin:15px 0}.web-actions a{display:inline-block;text-decoration:none}.web-actions .btn{line-height:1.4}
#surveyOverlay{position:fixed;top:0;left:0;pointer-events:none;z-index:2;overflow:hidden}#surveyOverlay circle{fill:#d97028;stroke:white;stroke-width:1}#surveyOverlay text{font:10px Arial;fill:#293e2b;stroke:#ffffffe6;stroke-width:3;paint-order:stroke;stroke-linejoin:round}
.web-fatal{position:fixed;inset:20%;z-index:1000;background:white;padding:40px;font:18px Arial;color:#26342c}
@media(max-width:850px){#projectToggle{top:206px;left:18px;font-size:10px;padding:8px 11px}body.interior-active #projectToggle{top:65px;left:16px}#webStatus{bottom:7px;left:12px;font-size:8px}.web-dialog-head{padding:17px}.web-dialog-body{padding:8px 17px 20px}.web-stat-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.web-photo-grid img{height:130px}.web-dialog-head h2{font-size:21px}#projectDialog{width:calc(100% - 18px);max-height:94dvh}body.std-open #projectToggle{display:none}}
@media(max-width:420px){.web-photo-grid{grid-template-columns:1fr}.web-photo-grid img{height:175px}.web-actions .btn{flex:1;min-width:130px;text-align:center}.web-stat-grid{gap:7px}.web-stat-grid>div{padding:11px}}
@media(max-height:550px){#projectToggle{top:auto;bottom:120px}}
@media(max-width:850px){body.std-open #webStatus{display:none}}
/* Výška plotu: druhá řada pod přepínači okolí, jen při zapnutém plotu. */
.fence-heights[hidden]{display:none}.fence-heights{gap:3px}.fence-heights-label{font-size:10px;color:#6e8066;padding:0 6px 0 5px;white-space:nowrap}.fence-heights button.site-switch{background:transparent;font-family:inherit;line-height:1.1}.fence-heights button.site-switch.selected{background:#e1ecdc}
@media(max-width:520px){.fence-heights-label{font-size:9px;padding:0 3px}.fence-heights button.site-switch{padding:8px 5px}}
+19
View File
@@ -0,0 +1,19 @@
{
"project": "Klabava",
"parcel": "66/17",
"parcelAreaM2": 1037,
"buildingFootprintM2": 178.75,
"coveredEntryAndTerraceM2": 206.54,
"usableFloorAreaM2": 153.74,
"footprintEnvelopeM": [17.4, 13.05],
"eaveM": 3.046,
"ridgeM": 5.832,
"chimneyM": 6.5,
"surveyGroundNearRoadBpv": 349.48,
"surveyGroundRearBpv": 347.2,
"latitude": 49.7536,
"longitude": 13.5394,
"latitudeLongitudeNote": "Přibližný střed obce Klabava; nikoli souřadnice parcely.",
"standards": null,
"modelNote": "Vizualizační model podle PDF podkladů; hranice a výšky terénu jsou převzaté a interpolované orientačně."
}
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="#e7ebe3">
<title>Klabava | Rodinný dům</title>
<link rel="stylesheet" href="./css/app.css?v=20261031">
<link rel="stylesheet" href="./css/web.css?v=20261031">
<link rel="stylesheet" href="./css/klabava.css?v=20261031">
<script type="importmap">
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"}}
</script>
</head>
<body>
<canvas id="view" aria-label="Interaktivní trojrozměrný model rodinného domu v Klabavě"></canvas>
<svg id="measure" aria-hidden="true"></svg>
<header>
<div class="eyebrow"><span class="dot"></span>RODINNÝ DŮM · KLABAVA</div>
<h1>Klabava</h1>
<div class="sub">Rodinný dům · 3D studie a simulace slunce</div>
<div class="metrics">
<div class="metric"><strong>178,75 m²</strong><small>Zastavěná plocha</small></div>
<div class="metric"><strong>153,74 m²</strong><small>Užitná plocha</small></div>
<div class="metric"><strong>5,83 m</strong><small>Hřeben střechy</small></div>
</div>
</header>
<div class="topright">
<button class="btn" id="infoButton" type="button" aria-expanded="false" title="Projekt a podklady">Projekt</button>
<button class="btn" id="sunToggle" type="button" aria-controls="sunPanel" aria-expanded="false" title="Ovládání slunce"><span class="solar-mark">☼</span><span class="toggle-word">Slunce</span></button>
<button class="btn primary" id="saveGlb" type="button">Stáhnout <span class="downloadText">model</span> GLB</button>
</div>
<aside class="panel" id="panel" aria-label="Informace o projektu">
<div class="panel-head">
<div><div class="panel-kicker">RODINNÝ DŮM · KLABAVA</div><h2>Dům a pozemek</h2></div>
<button class="panel-close" id="infoClose" type="button" aria-label="Zavřít panel">×</button>
</div>
<p>Pracovní 3D vizualizace podle dodaného půdorysu, pohledů, řezu, koordinační situace a výškopisu.</p>
<h3>Základní údaje</h3>
<div class="row"><span>Parcela</span><strong>66/17 · 1 037 m²</strong></div>
<div class="row"><span>Zastavěná plocha domu</span><strong>178,75 m²</strong></div>
<div class="row"><span>Včetně krytého vstupu a terasy</span><strong>206,54 m²</strong></div>
<div class="row"><span>Užitná plocha</span><strong>153,74 m²</strong></div>
<div class="row"><span>Rozměry obálky v půdorysu</span><strong>17,40 × 13,05 m</strong></div>
<div class="row"><span>Hřeben / komín</span><strong>+5,832 / +6,500 m</strong></div>
<h3>Vzhled a vybavení</h3>
<p>Barvy fasády, krytiny, oken a další standardy zůstávají otevřené. Model používá neutrální dočasné materiály, aby bylo možné ověřit hmotu domu, umístění a stíny.</p>
<h3>Podklady</h3>
<div class="web-actions document-links">
<a class="btn" href="./references/KLABAVA_66_17.pdf" target="_blank" rel="noopener">Výškopis</a>
<a class="btn" href="./references/Situace.pdf" target="_blank" rel="noopener">Situace</a>
<a class="btn" href="./references/Pudorys%20prizemi%20(1).pdf" target="_blank" rel="noopener">Půdorys</a>
<a class="btn" href="./references/Pohled%20JV%20a%20JZ.pdf" target="_blank" rel="noopener">Pohledy JV/JZ</a>
<a class="btn" href="./references/Pohled%20SZ%20a%20SV.pdf" target="_blank" rel="noopener">Pohledy SZ/SV</a>
<a class="btn" href="./references/Rez.pdf" target="_blank" rel="noopener">Řez</a>
</div>
<details class="site-note" open>
<summary>Rozsah modelu a přesnost</summary>
<p>Půdorysná hmota, hlavní otvory a výšky vycházejí z dodaných výkresů. Střecha je schematicky složená z valbových ploch. Terén navazuje na zaměřené body přibližně od 349,5 m n. m. u komunikace k 347,2 m n. m. v zadní části parcely; mezi body je interpolovaný.</p>
<p>Hranice parcely je převzatá graficky z PDF situace, ne ze souřadnic pro vytyčení. Okolí, výsadba, parkovací plocha, okna a materiály jsou zjednodušené pro prostorovou orientaci. Model slouží k prezentaci a studii oslunění, nikoli jako BIM, geodetický nebo stavební podklad.</p>
</details>
<div class="status"><span class="dot"></span>Pracovní vizualizace · standardy doplníme po jejich výběru</div>
</aside>
<div id="direction"><span class="tag">AKTUÁLNÍ POHLED</span><span id="directionText">Dům / příjezd</span></div>
<div class="bottom">
<nav class="toolbar" aria-label="Ovládání modelu">
<button class="tool active" data-preset="entry" type="button">Příjezd</button>
<button class="tool" data-preset="garden" type="button">Zahrada</button>
<button class="tool" data-preset="top" type="button">Shora</button>
<span class="divider"></span>
<label class="site-switch"><input type="checkbox" id="siteTerrain" checked><span>Terén</span></label>
<label class="site-switch"><input type="checkbox" id="siteBoundary" checked><span>Parcela</span></label>
<label class="site-switch"><input type="checkbox" id="siteParking" checked><span>Parkování</span></label>
<span class="divider"></span>
<button class="tool" id="savePng" type="button">Uložit PNG</button>
<button class="tool" id="resetView" type="button">Reset</button>
</nav>
<div class="help">Tažením otáčej · kolečkem přibliž · pravým tlačítkem posuň</div>
</div>
<div class="compass" aria-label="Severka">
<svg viewBox="0 0 84 84">
<circle cx="42" cy="42" r="27" fill="none" stroke="#bec8b7" />
<g id="compassGroup"><line x1="42" y1="13" x2="42" y2="71"/><line x1="13" y1="42" x2="71" y2="42"/>
<path id="northArrow" d="M42 18 L37 32 L42 29 L47 32 Z"/><text x="42" y="8" text-anchor="middle">S</text><text x="42" y="81" text-anchor="middle">J</text><text x="3" y="45" text-anchor="middle">Z</text><text x="80" y="45" text-anchor="middle">V</text>
</g>
</svg>
</div>
<svg id="sunOverlay" aria-hidden="true"></svg>
<div id="sunStamp"><span>SLUNCE NAD DOMEM</span><br><strong id="sceneTime">21. 6. 2026 · 10:00</strong></div>
<section id="sunPanel" aria-label="Simulace slunce">
<div class="sun-panel-head"><div><div class="sun-kicker">STUDIE OSVĚTLENÍ</div><h2>Slunce a stíny</h2></div><button class="close-panel" id="sunClose" type="button" aria-label="Skrýt panel slunce">×</button></div>
<div class="sun-date-row"><label class="sun-field">Datum<input id="sunDate" type="date" min="2000-01-01" max="2100-12-31" value="2026-06-21"></label><label class="sun-field">Místní čas<input id="sunClock" type="text" inputmode="numeric" value="10:00" maxlength="5" placeholder="HH:MM" aria-label="Místní čas ve formátu HH:MM"></label></div>
<div class="sun-seasons" aria-label="Typické dny roku"><button type="button" data-season="03-20">Jaro</button><button type="button" data-season="06-21" class="active">Léto</button><button type="button" data-season="09-23">Podzim</button><button type="button" data-season="12-21">Zima</button></div>
<div class="sun-status-line"><span id="dayState">Slunce nad obzorem</span><span id="zoneLabel">SELČ · UTC+2</span></div>
<svg id="sunGraph" class="sun-graph" viewBox="0 0 304 86" role="img" aria-label="Výška slunce během dne. Kliknutím změníš čas."></svg>
<label class="sr-only" for="sunTime">Posun času</label><input class="sun-scrub" id="sunTime" type="range" min="0" max="1439" value="600" step="1" aria-valuetext="10:00">
<div class="sun-ticks"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
<div class="sun-play-row"><button id="sunPlay" type="button">▶ Přehrát den</button><select id="sunSpeed" aria-label="Rychlost simulace"><option value="15">15 min / s</option><option value="30" selected>30 min / s</option><option value="60">60 min / s</option></select><button id="sunNow" type="button" title="Aktuální datum a čas v Praze">Teď</button></div>
<div id="solarMessage" role="status"></div>
<div class="sun-readouts"><div class="sun-stat">Výška nad obzorem<strong id="sunElevation">—</strong><small>Střed slunečního kotouče</small></div><div class="sun-stat">Azimut slunce<strong id="sunAzimuth">—</strong><small id="sunDirection">Od severu po směru hodin</small></div></div>
<div class="sun-events"><span>Východ <b id="sunRise">—</b></span><span>Poledne <b id="sunNoon">—</b></span><span>Západ <b id="sunSet">—</b></span></div>
<div class="sun-options"><label><input type="checkbox" id="sunShadows" checked>Vržené stíny</label><label><input type="checkbox" id="sunPath">Dráha ve 3D</label></div>
<p class="sun-hint">Posouvej čas a sleduj, kam dopadá slunce na fasády a zahradu. Sever modelu vychází ze severky v situaci.</p>
<button class="sun-export" id="sunSavePng" type="button">Uložit obrázek s datem a časem</button>
<details class="sun-advanced"><summary>Poloha a orientace domu</summary>
<div class="fields"><label class="sun-field">Zeměpisná šířka (° N)<input id="sunLat" type="number" min="-89" max="89" step="0.0001" value="49.7536"></label><label class="sun-field">Zeměpisná délka (° E)<input id="sunLon" type="number" min="-180" max="180" step="0.0001" value="13.5394"></label><label class="sun-field full-row">Natočení pozemku vůči severu (°)<input id="siteBearing" type="number" min="0" max="359.9" step="0.1" value="0"></label></div>
<p>Výchozí poloha odpovídá přibližnému středu obce Klabava. Natočení je odečtené orientačně ze severky v situačním výkresu. Přesné souřadnice parcely a azimut stavby nebyly ve vstupních podkladech uvedeny.</p>
<p>Časové pásmo zůstává <b>Europe/Prague</b>. Letní čas se přepočítává podle zvoleného data a pravidel prohlížeče.</p>
<button class="mini-btn" id="sunResetSite" type="button">Obnovit polohu Klabavy</button>
</details>
<details class="sun-advanced"><summary>Metoda výpočtu a přesnost</summary><p>Sluneční poloha se počítá z data, času a zeměpisných souřadnic podle astronomických rovnic Meeus / NOAA. Model zobrazuje přímé sluneční světlo a geometrické stíny. Nezahrnuje oblačnost, odrazy okolních budov, profil horizontu ani skutečné vlastnosti zasklení.</p></details>
</section>
<div id="loading" role="status"><span class="loader-dot"></span><span>Načítám 3D model…</span></div>
<div id="toast" role="status" aria-live="polite"></div>
<div id="webStatus">Pracovní model · Klabava 66/17</div>
<script type="module" src="./js/klabava.js?v=20261031"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
<?php
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
$page = __DIR__ . '/index.html';
if (!is_file($page)) {
http_response_code(500);
echo 'Web Klabava není kompletní.';
exit;
}
readfile($page);
+807
View File
@@ -0,0 +1,807 @@
let THREE, OrbitControls, GLTFExporter, solarPosition;
try {
const modules = await Promise.all([
import('three'),
import('three/addons/controls/OrbitControls.js'),
import('three/addons/exporters/GLTFExporter.js'),
import('./solar.js')
]);
THREE = modules[0];
OrbitControls = modules[1].OrbitControls;
GLTFExporter = modules[2].GLTFExporter;
solarPosition = modules[3].solarPosition;
} catch (error) {
const loading = document.querySelector('#loading');
if (loading) {
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a zkontroluj připojení k CDN Three.js.</small>`;
}
throw error;
}
const $ = (selector) => document.querySelector(selector);
const RAD = Math.PI / 180;
const DAY_MS = 86400000;
const mod = (x, n) => ((x % n) + n) % n;
const clamp = (x, a, b) => Math.max(a, Math.min(b, x));
const smooth = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const mix = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
const czNum = (v, n = 1) => Number(v).toFixed(n).replace('.', ',');
const canvas = $('#view');
const loading = $('#loading');
const toastEl = $('#toast');
const site = { latitude: 49.7536, longitude: 13.5394, bearing: 0 };
const solar = {
date: '2026-06-21', minute: 600, playing: false, speed: 30, shadows: true, path: false,
start: 0, end: 0, length: 1440, rise: null, set: null, noon: null, samples: [],
position: null, direct: 0.8, dayFactor: 1, sky: [0.89, 0.91, 0.87], accumulator: 0, lastTick: 0
};
const scene = new THREE.Scene();
scene.background = new THREE.Color('#e7ebe3');
const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 220);
camera.position.set(28, 21, 36);
let renderer;
try {
renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: false, preserveDrawingBuffer: true,
powerPreference: 'high-performance'
});
} catch (error) {
loading.classList.add('error');
loading.innerHTML = `3D grafiku nelze spustit.<br>${error.message}<br><small>Zkontroluj podporu WebGL v prohlížeči.</small>`;
throw error;
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.075;
controls.minDistance = 15;
controls.maxDistance = 95;
controls.minPolarAngle = 0.08;
controls.maxPolarAngle = 1.52;
controls.target.set(0, 1.2, 8.2);
scene.add(new THREE.HemisphereLight(0xf7f8ee, 0x6c705f, 1.55));
scene.add(new THREE.AmbientLight(0xffffff, 0.35));
const sunLight = new THREE.DirectionalLight(0xfff5df, 2.25);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(1536, 1536);
sunLight.shadow.camera.left = -32;
sunLight.shadow.camera.right = 32;
sunLight.shadow.camera.top = 36;
sunLight.shadow.camera.bottom = -36;
sunLight.shadow.camera.near = 0.1;
sunLight.shadow.camera.far = 110;
sunLight.shadow.bias = -0.00015;
sunLight.shadow.normalBias = 0.025;
const sunTarget = new THREE.Object3D();
sunTarget.position.set(0, 1.2, 8.2);
scene.add(sunLight, sunTarget);
sunLight.target = sunTarget;
const modelRoot = new THREE.Group();
modelRoot.name = 'Klabava – dům a pozemek';
scene.add(modelRoot);
const groundGroup = new THREE.Group(); groundGroup.name = 'Terén a zahrada'; modelRoot.add(groundGroup);
const boundaryGroup = new THREE.Group(); boundaryGroup.name = 'Orientační hranice parcely'; modelRoot.add(boundaryGroup);
const parkingGroup = new THREE.Group(); parkingGroup.name = 'Příjezd a parkování'; modelRoot.add(parkingGroup);
const houseGroup = new THREE.Group(); houseGroup.name = 'Rodinný dům – pracovní hmota'; houseGroup.position.set(0, 0, 8.4); modelRoot.add(houseGroup);
const mats = {
grass: new THREE.MeshStandardMaterial({ color: 0x9ba78f, roughness: 1 }),
road: new THREE.MeshStandardMaterial({ color: 0x676d6b, roughness: 0.98 }),
gravel: new THREE.MeshStandardMaterial({ color: 0xc5c1b4, roughness: 1 }),
path: new THREE.MeshStandardMaterial({ color: 0xb8b7a9, roughness: 0.9 }),
wall: new THREE.MeshStandardMaterial({ color: 0xdedbd2, roughness: 0.86 }),
foundation: new THREE.MeshStandardMaterial({ color: 0xb9b9b1, roughness: 0.9, side: THREE.DoubleSide }),
roof: new THREE.MeshStandardMaterial({ color: 0x555c59, roughness: 0.82, metalness: 0.02, side: THREE.DoubleSide }),
glass: new THREE.MeshStandardMaterial({ color: 0x9aafb2, roughness: 0.2, metalness: 0.05, transparent: true, opacity: 0.78, side: THREE.DoubleSide }),
frame: new THREE.MeshStandardMaterial({ color: 0x626966, roughness: 0.7 }),
door: new THREE.MeshStandardMaterial({ color: 0x958d7d, roughness: 0.8 }),
terrace: new THREE.MeshStandardMaterial({ color: 0xaaa492, roughness: 0.88 }),
metal: new THREE.MeshStandardMaterial({ color: 0x545b58, roughness: 0.6, metalness: 0.18 }),
trunk: new THREE.MeshStandardMaterial({ color: 0x735d46, roughness: 1 }),
leaf: new THREE.MeshStandardMaterial({ color: 0x657c5c, roughness: 1 }),
leaf2: new THREE.MeshStandardMaterial({ color: 0x839273, roughness: 1 }),
car1: new THREE.MeshStandardMaterial({ color: 0x777e7b, roughness: 0.45, metalness: 0.08 }),
car2: new THREE.MeshStandardMaterial({ color: 0xa59f91, roughness: 0.45, metalness: 0.08 }),
tire: new THREE.MeshStandardMaterial({ color: 0x333635, roughness: 0.95 }),
pavingLine: new THREE.MeshStandardMaterial({ color: 0xe4e0d2, roughness: 0.9 })
};
function mesh(geometry, material, parent, name = '') {
const object = new THREE.Mesh(geometry, material);
object.name = name;
object.castShadow = true;
object.receiveShadow = true;
parent.add(object);
return object;
}
function box(parent, name, x, y, z, sx, sy, sz, material, rotationY = 0) {
const object = mesh(new THREE.BoxGeometry(sx, sy, sz), material, parent, name);
object.position.set(x, y, z);
object.rotation.y = rotationY;
return object;
}
function poly(parent, name, points, material) {
const positions = [];
for (const p of points) positions.push(p[0], p[1], p[2]);
const indices = [];
for (let i = 1; i < points.length - 1; i++) indices.push(0, i, i + 1);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
return mesh(geometry, material, parent, name);
}
function line(parent, name, points, color = 0x657361, opacity = 0.85) {
const geometry = new THREE.BufferGeometry().setFromPoints(points.map(p => new THREE.Vector3(...p)));
const material = new THREE.LineBasicMaterial({ color, transparent: opacity < 1, opacity });
const object = new THREE.Line(geometry, material);
object.name = name;
parent.add(object);
return object;
}
function group(parent, name) { const g = new THREE.Group(); g.name = name; parent.add(g); return g; }
// Survey-led ground slope: about 349.5 m Bpv by the road and 347.2 m Bpv at the rear.
function rawGround(x, z) { return 0.12 - (20 - z) * (2.25 / 40) + x * 0.004; }
function groundHeight(x, z) {
const dx = Math.max(Math.abs(x) - 9.5, 0);
const dz = z > 15.8 ? z - 15.8 : z < 0.4 ? 0.4 - z : 0;
const blend = smooth(0, 4.2, Math.hypot(dx, dz));
return -0.13 * (1 - blend) + rawGround(x, z) * blend;
}
function buildGround() {
const nw = [-13, 20], ne = [13, 20], se = [13.5, -19.5], sw = [-12.5, -20];
const nx = 34, nz = 54;
const positions = [], indices = [];
for (let j = 0; j <= nz; j++) {
const v = j / nz;
const leftX = nw[0] * (1 - v) + sw[0] * v, leftZ = nw[1] * (1 - v) + sw[1] * v;
const rightX = ne[0] * (1 - v) + se[0] * v, rightZ = ne[1] * (1 - v) + se[1] * v;
for (let i = 0; i <= nx; i++) {
const u = i / nx;
const x = leftX * (1 - u) + rightX * u;
const z = leftZ * (1 - u) + rightZ * u;
positions.push(x, groundHeight(x, z), z);
if (i < nx && j < nz) {
const a = j * (nx + 1) + i, b = a + 1, c = a + (nx + 1), d = c + 1;
indices.push(a, b, c, b, d, c);
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
mesh(geometry, mats.grass, groundGroup, 'Interpolovaný terén parcely 66/17');
// The northern road and its edge follow the topographic fall across the site.
const roadY = (z) => rawGround(0, z) + 0.01;
const roadPoints = [[-17, roadY(26), 26], [17, roadY(26), 26], [17, roadY(20.4), 20.4], [-17, roadY(20.4), 20.4]];
poly(parkingGroup, 'Místní komunikace', roadPoints, mats.road);
line(parkingGroup, 'Okraj komunikace', [[-17, roadY(20.3) + 0.02, 20.3], [17, roadY(20.3) + 0.02, 20.3]], 0xbfc4bc, 0.8);
// Parcel outline is intentionally thin and diagrammatic; the supplied survey has no parcel staking coordinates.
const border = [nw, ne, se, sw].map(([x, z]) => [x, groundHeight(x, z) + 0.045, z]);
line(boundaryGroup, 'Grafická hranice parcely 66/17', [...border, border[0]], 0x4f694f, 0.95);
for (let i = 0; i < border.length; i++) {
const [x, y, z] = border[i];
const post = mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.12, 6), mats.frame, boundaryGroup, `Orientační lom hranice ${i + 1}`);
post.position.set(x, y + 0.05, z);
post.castShadow = false;
}
buildParking(roadY);
buildGarden();
}
function buildParking(roadY) {
const y = (x, z) => groundHeight(x, z) + 0.035;
const drivewayPts = [[-9.2, y(-9.2, 20.2), 20.2], [-5.3, y(-5.3, 20.2), 20.2], [-5.3, y(-5.3, 17.8), 17.8], [-9.2, y(-9.2, 17.8), 17.8]];
poly(parkingGroup, 'Příjezd na pozemek', drivewayPts, mats.gravel);
const pad = [[-10.3, y(-10.3, 17.7), 17.7], [-3.7, y(-3.7, 17.7), 17.7], [-3.7, y(-3.7, 14.9), 14.9], [-10.3, y(-10.3, 14.9), 14.9]];
poly(parkingGroup, 'Orientační parkovací plocha', pad, mats.gravel);
line(parkingGroup, 'Osa příjezdu', [[-7.25, roadY(20) + 0.03, 20], [-7.25, y(-7.25, 17.8) + 0.04, 17.8]], 0x8f958b, 0.65);
function addCar(cx, cz, material, label) {
const car = group(parkingGroup, label);
car.position.set(cx, groundHeight(cx, cz) + 0.12, cz);
box(car, `${label} karoserie`, 0, 0.42, 0, 2.05, 0.72, 4.35, material);
box(car, `${label} kabina`, 0, 0.93, -0.1, 1.55, 0.62, 2.45, mats.glass);
for (const x of [-1.02, 1.02]) for (const z of [-1.42, 1.42]) {
const wheel = mesh(new THREE.CylinderGeometry(0.36, 0.36, 0.19, 12), mats.tire, car, `${label} kolo`);
wheel.rotation.z = Math.PI / 2;
wheel.position.set(x, 0.35, z);
}
box(car, `${label} přední sklo`, 0, 0.95, 1.12, 1.38, 0.43, 0.08, mats.glass, 0.18);
}
addCar(-8.15, 16.35, mats.car1, 'Orientační automobil 1');
addCar(-5.75, 16.35, mats.car2, 'Orientační automobil 2');
// A short walk connects the front door and the gravel parking area.
const walkway = [[-0.9, y(-0.9, 16.5), 16.5], [0.55, y(0.55, 16.5), 16.5], [0.55, y(0.55, 14.7), 14.7], [-0.9, y(-0.9, 14.7), 14.7]];
poly(parkingGroup, 'Pěší vstup', walkway, mats.path);
}
function addTree(x, z, height, variant = 0) {
const g = group(groundGroup, 'Orientační strom');
g.position.set(x, groundHeight(x, z), z);
const trunkHeight = height * 0.42;
const trunk = mesh(new THREE.CylinderGeometry(0.11, 0.17, trunkHeight, 7), mats.trunk, g, 'Kmen');
trunk.position.y = trunkHeight / 2;
const material = variant % 2 ? mats.leaf2 : mats.leaf;
for (let i = 0; i < 3; i++) {
const radius = height * (0.27 - i * 0.04);
const cone = mesh(new THREE.ConeGeometry(radius, height * 0.42, 8), material, g, 'Koruna');
cone.position.set(0, trunkHeight + height * (0.17 + i * 0.18), 0);
}
}
function buildGarden() {
const trees = [[-10.7, -3.7, 3.7], [10.7, -3.4, 3.4], [-10.1, -10.2, 3.6], [11.0, -11.3, 4.1], [-7.4, -16.9, 3.4], [3.5, -17.1, 3.8], [9.6, 4.3, 3.1]];
trees.forEach(([x, z, h], i) => addTree(x, z, h, i));
for (const [x, z] of [[-9.5, -6.5], [10.3, -6.4], [-5.9, -18.1], [6.1, -18.2]]) {
const shrub = mesh(new THREE.SphereGeometry(0.65, 10, 8), mats.leaf2, groundGroup, 'Orientační keř');
shrub.scale.set(1.2, 0.8, 0.9);
shrub.position.set(x, groundHeight(x, z) + 0.44, z);
}
}
const footprint = [
[-1.575, 6.525], [8.7, 6.525], [8.7, -2.775], [-1.575, -2.775],
[-1.575, -6.525], [-8.7, -6.525], [-8.7, 5.145], [-1.575, 5.145]
];
function makeFootprintSlab() {
const shape = new THREE.Shape();
shape.moveTo(footprint[0][0], footprint[0][1]);
footprint.slice(1).forEach(([x, z]) => shape.lineTo(x, z));
shape.closePath();
const geometry = new THREE.ShapeGeometry(shape);
geometry.rotateX(Math.PI / 2);
const slab = mesh(geometry, mats.foundation, houseGroup, 'Základová deska domu');
slab.position.y = -0.19;
return slab;
}
function makeWallEdge(p1, p2, openings = []) {
const dx = p2[0] - p1[0], dz = p2[1] - p1[1];
const length = Math.hypot(dx, dz);
const angle = Math.atan2(-dz, dx);
const thickness = 0.22, height = 3.046;
const positionAt = (distance) => [p1[0] + dx * distance / length, p1[1] + dz * distance / length];
const wallSegment = (from, to, bottom, top, name = 'Obvodová stěna') => {
if (to - from < 0.02 || top - bottom < 0.02) return;
const [x, z] = positionAt((from + to) / 2);
box(houseGroup, name, x, (bottom + top) / 2, z, to - from, top - bottom, thickness, mats.wall, angle);
};
const ordered = [...openings].sort((a, b) => a.center - b.center);
let cursor = 0;
for (const opening of ordered) {
const left = clamp(opening.center - opening.width / 2, 0.05, length - 0.05);
const right = clamp(opening.center + opening.width / 2, 0.05, length - 0.05);
if (left <= cursor || right <= left) continue;
wallSegment(cursor, left, 0, height);
if (opening.bottom > 0) wallSegment(left, right, 0, opening.bottom, 'Parapet pod oknem');
const top = Math.min(height, opening.bottom + opening.height);
wallSegment(left, right, top, height, 'Nadpraží otvoru');
const [x, z] = positionAt((left + right) / 2);
const centerY = opening.bottom + (top - opening.bottom) / 2;
const paneMaterial = opening.kind === 'door' ? mats.door : mats.glass;
box(houseGroup, opening.kind === 'door' ? 'Vstupní dveře – zjednodušené' : 'Zasklení – zjednodušené', x, centerY, z, right - left - 0.10, Math.max(0.12, top - opening.bottom - 0.10), 0.055, paneMaterial, angle);
const frameWidth = 0.055;
const jambHeight = top - opening.bottom;
for (const offset of [-1, 1]) {
const [jx, jz] = positionAt((left + right) / 2 + offset * (right - left - frameWidth) / 2);
box(houseGroup, 'Rám otvoru', jx, opening.bottom + jambHeight / 2, jz, frameWidth, jambHeight, 0.09, mats.frame, angle);
}
box(houseGroup, 'Horní rám otvoru', x, top - frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
if (opening.bottom > 0) box(houseGroup, 'Parapet okna', x, opening.bottom + frameWidth / 2, z, right - left, frameWidth, 0.09, mats.frame, angle);
cursor = right;
}
wallSegment(cursor, length, 0, height);
}
function hipRoof(x0, x1, z0, z1, name, ridgeHeight = 5.832) {
const overhang = 0.38, eave = 3.06;
x0 -= overhang; x1 += overhang; z0 -= overhang; z1 += overhang;
const xm = (x0 + x1) / 2, zm = (z0 + z1) / 2;
const width = x1 - x0, depth = z1 - z0;
const v = (x, y, z) => [x, y, z];
if (width >= depth) {
const inset = depth / 2;
const a = v(x0 + inset, ridgeHeight, zm), b = v(x1 - inset, ridgeHeight, zm);
poly(houseGroup, `${name} – severní střešní rovina`, [v(x0, eave, z1), v(x1, eave, z1), b, a], mats.roof);
poly(houseGroup, `${name} – jižní střešní rovina`, [v(x1, eave, z0), v(x0, eave, z0), a, b], mats.roof);
poly(houseGroup, `${name} – západní valba`, [v(x0, eave, z1), v(x0, eave, z0), a], mats.roof);
poly(houseGroup, `${name} – východní valba`, [v(x1, eave, z0), v(x1, eave, z1), b], mats.roof);
box(houseGroup, `${name} – hřeben`, (a[0] + b[0]) / 2, ridgeHeight, zm, Math.max(0.18, b[0] - a[0]), 0.12, 0.12, mats.roof);
} else {
const inset = width / 2;
const a = v(xm, ridgeHeight, z0 + inset), b = v(xm, ridgeHeight, z1 - inset);
poly(houseGroup, `${name} – východní střešní rovina`, [v(x1, eave, z0), v(x1, eave, z1), b, a], mats.roof);
poly(houseGroup, `${name} – západní střešní rovina`, [v(x0, eave, z1), v(x0, eave, z0), a, b], mats.roof);
poly(houseGroup, `${name} – severní valba`, [v(x0, eave, z1), v(x1, eave, z1), b], mats.roof);
poly(houseGroup, `${name} – jižní valba`, [v(x1, eave, z0), v(x0, eave, z0), a], mats.roof);
box(houseGroup, `${name} – hřeben`, xm, ridgeHeight, (a[2] + b[2]) / 2, 0.12, 0.12, Math.max(0.18, b[2] - a[2]), mats.roof);
}
// Eaves and downpipes make the roof silhouette readable at presentation scale.
box(houseGroup, `${name} – okap sever`, xm, eave - 0.04, z1, width, 0.12, 0.12, mats.frame);
box(houseGroup, `${name} – okap jih`, xm, eave - 0.04, z0, width, 0.12, 0.12, mats.frame);
box(houseGroup, `${name} – okap západ`, x0, eave - 0.04, zm, 0.12, 0.12, depth, mats.frame);
box(houseGroup, `${name} – okap východ`, x1, eave - 0.04, zm, 0.12, 0.12, depth, mats.frame);
}
function buildHouse() {
makeFootprintSlab();
const openings = [
[{ center: 2.0, width: 1.0, bottom: 0, height: 2.18, kind: 'door' }, { center: 6.2, width: 2.05, bottom: 0.85, height: 1.38 }],
[{ center: 2.5, width: 1.35, bottom: 0.9, height: 1.25 }, { center: 6.5, width: 1.1, bottom: 0.9, height: 1.25 }],
[{ center: 2.5, width: 1.65, bottom: 0.9, height: 1.35 }, { center: 7.4, width: 3.25, bottom: 0, height: 2.22, kind: 'door' }],
[{ center: 1.7, width: 1.55, bottom: 0.85, height: 1.4 }],
[{ center: 3.25, width: 3.4, bottom: 0, height: 2.2, kind: 'door' }],
[{ center: 2.2, width: 1.4, bottom: 0.9, height: 1.2 }, { center: 8.4, width: 1.7, bottom: 0.9, height: 1.2 }],
[{ center: 2.3, width: 1.45, bottom: 0.9, height: 1.25 }, { center: 5.4, width: 1.25, bottom: 0.9, height: 1.25 }],
[]
];
for (let i = 0; i < footprint.length; i++) makeWallEdge(footprint[i], footprint[(i + 1) % footprint.length], openings[i]);
hipRoof(-1.575, 8.7, -2.775, 6.525, 'Hlavní valbová střecha');
hipRoof(-8.7, -1.575, -6.525, 5.145, 'Střecha západního křídla');
// Chimney shown in the elevations, with a simple cap.
box(houseGroup, 'Komín', 5.7, 5.78, 1.8, 0.64, 1.25, 0.64, mats.foundation);
box(houseGroup, 'Hlavice komína', 5.7, 6.42, 1.8, 0.82, 0.12, 0.82, mats.frame);
// Covered terrace is included in the site sheets; its posts and canopy are deliberately simple.
const deck = group(modelRoot, 'Krytá terasa – schematicky');
box(deck, 'Podlaha terasy', -4.0, -0.02, 0.05, 5.1, 0.18, 3.15, mats.terrace);
for (const [x, z] of [[-6.35, -1.38], [-1.65, -1.38], [-6.35, 1.48], [-1.65, 1.48]]) {
const ground = groundHeight(x, z);
box(deck, 'Sloupek kryté terasy', x, (2.92 + ground) / 2, z, 0.14, 2.92 - ground, 0.14, mats.wall);
}
box(deck, 'Stříška terasy', -4.0, 3.02, 0.05, 5.35, 0.16, 3.35, mats.terrace);
box(deck, 'Schod z terasy', -4.0, -0.19, -1.7, 2.5, 0.18, 0.45, mats.path);
}
buildGround();
buildHouse();
function viewportSize() {
let w = window.innerWidth, h = window.innerHeight;
const infoOpen = document.body.classList.contains('info-open');
const sunOpen = document.body.classList.contains('sun-open');
if (w <= 850 && (infoOpen || sunOpen)) {
const panelHeight = Math.min((infoOpen ? 0.72 : 0.68) * h, 570);
h = Math.max(120, h - panelHeight - 16);
} else if (sunOpen) {
w = Math.max(240, w - 380);
} else if (infoOpen) {
w = Math.max(240, w - 390);
}
return [w, h];
}
function resize() {
const [w, h] = viewportSize();
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(Math.round(w), Math.round(h), false);
}
window.addEventListener('resize', resize);
resize();
function toast(message) {
toastEl.textContent = message;
toastEl.style.opacity = '1';
clearTimeout(toast.timer);
toast.timer = setTimeout(() => { toastEl.style.opacity = '0'; }, 2600);
}
function saveBlob(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
const presets = {
entry: { position: [24, 18, 31], target: [0, 1.15, 9.3], label: 'Dům / příjezd' },
garden: { position: [-2, 15, -22], target: [0, 1.25, 7], label: 'Dům / zahrada' },
top: { position: [0, 77, 8], target: [0, 0.1, 8], label: 'Pozemek / shora' }
};
function setPreset(name) {
const p = presets[name] || presets.entry;
camera.position.fromArray(p.position);
controls.target.fromArray(p.target);
controls.update();
$('#directionText').textContent = p.label;
document.querySelectorAll('[data-preset]').forEach(button => button.classList.toggle('active', button.dataset.preset === name));
}
document.querySelectorAll('[data-preset]').forEach(button => button.addEventListener('click', () => setPreset(button.dataset.preset)));
$('#resetView').addEventListener('click', () => setPreset('entry'));
setPreset('entry');
$('#siteTerrain').addEventListener('change', (event) => { groundGroup.visible = event.target.checked; });
$('#siteBoundary').addEventListener('change', (event) => { boundaryGroup.visible = event.target.checked; });
$('#siteParking').addEventListener('change', (event) => { parkingGroup.visible = event.target.checked; });
function toggleInfo(force) {
const on = force ?? !$('#panel').classList.contains('open');
$('#panel').classList.toggle('open', on);
document.body.classList.toggle('info-open', on);
$('#infoButton').classList.toggle('active', on);
$('#infoButton').setAttribute('aria-expanded', String(on));
if (on) toggleSun(false);
resize();
}
function toggleSun(force) {
const on = force ?? !$('#sunPanel').classList.contains('open');
$('#sunPanel').classList.toggle('open', on);
document.body.classList.toggle('sun-open', on);
$('#sunToggle').classList.toggle('active', on);
$('#sunToggle').setAttribute('aria-expanded', String(on));
if (on) toggleInfo(false);
resize();
}
$('#infoButton').addEventListener('click', () => toggleInfo());
$('#infoClose').addEventListener('click', () => toggleInfo(false));
$('#sunToggle').addEventListener('click', () => toggleSun());
$('#sunClose').addEventListener('click', () => toggleSun(false));
// Local civil-time helpers keep the sun controls aligned with Europe/Prague and DST.
const localFormatter = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Prague', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
});
function localParts(ms) {
const out = {};
for (const part of localFormatter.formatToParts(new Date(ms))) if (part.type !== 'literal') out[part.type] = +part.value;
return out;
}
function utcOffset(ms) {
const p = localParts(ms);
return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ms / 1000) * 1000) / 60000);
}
function localMidnight(iso) {
const [y, m, d] = iso.split('-').map(Number);
const naive = Date.UTC(y, m - 1, d);
let utc = naive;
for (let i = 0; i < 4; i++) utc = naive - utcOffset(utc) * 60000;
return utc;
}
function clockText(ms) {
if (ms === null || !Number.isFinite(ms)) return '—';
const p = localParts(ms);
return `${String(p.hour).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}`;
}
function czDate(iso) {
const [y, m, d] = iso.split('-').map(Number);
return `${d}. ${m}. ${y}`;
}
function zoneText(ms) {
const offset = utcOffset(ms);
return `${offset === 120 ? 'SELČ' : offset === 60 ? 'SEČ' : 'Praha'} · UTC${offset >= 0 ? '+' : ''}${offset / 60}`;
}
function timeForClock(text) {
if (!/^\d{1,2}:\d{2}$/.test(text)) return null;
const [h, m] = text.split(':').map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return null;
const normalized = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
const [y, mo, d] = solar.date.split('-').map(Number);
const naive = Date.UTC(y, mo - 1, d, h, m);
const offsets = [...new Set([utcOffset(solar.start - 12 * 3600000), utcOffset(solar.start), utcOffset(solar.end - 1), utcOffset(solar.end + 12 * 3600000)])];
const candidates = offsets.map(offset => naive - offset * 60000)
.filter(utc => utc >= solar.start && utc < solar.end && clockText(utc) === normalized).sort((a, b) => a - b);
if (candidates.length) return candidates[0];
for (let i = 0; i < solar.length; i++) {
const utc = solar.start + i * 60000, p = localParts(utc);
if (p.hour * 60 + p.minute >= h * 60 + m) return utc;
}
return solar.end - 60000;
}
function refineCrossing(a, b, threshold) {
let fa = solarPosition(a, site.latitude, site.longitude).elevation - threshold;
for (let i = 0; i < 25; i++) {
const mid = (a + b) / 2;
const fm = solarPosition(mid, site.latitude, site.longitude).elevation - threshold;
if ((fa < 0) === (fm < 0)) { a = mid; fa = fm; } else b = mid;
}
return (a + b) / 2;
}
let graphBase = '';
function rebuildSolarDay(preserveClock = true) {
const previousClock = preserveClock && solar.start ? clockText(solar.start + solar.minute * 60000) : ($('#sunClock').value || '10:00');
solar.start = localMidnight(solar.date);
const [y, m, d] = solar.date.split('-').map(Number);
const nextDate = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
solar.end = localMidnight(nextDate);
solar.length = Math.round((solar.end - solar.start) / 60000);
solar.rise = null; solar.set = null; solar.samples = [];
let previous = null, max = { altitude: -999, ms: solar.start };
for (let utc = solar.start; utc <= solar.end; utc += 600000) {
const pos = solarPosition(utc, site.latitude, site.longitude);
solar.samples.push({ ms: utc, ...pos });
if (pos.elevation > max.altitude) max = { altitude: pos.elevation, ms: utc };
if (previous) {
if (previous.elevation < -0.833 && pos.elevation >= -0.833) solar.rise = refineCrossing(previous.ms, utc, -0.833);
if (previous.elevation >= -0.833 && pos.elevation < -0.833) solar.set = refineCrossing(previous.ms, utc, -0.833);
}
previous = { ms: utc, ...pos };
}
let lo = Math.max(solar.start, max.ms - 600000), hi = Math.min(solar.end, max.ms + 600000);
for (let i = 0; i < 36; i++) {
const a = lo + (hi - lo) / 3, b = hi - (hi - lo) / 3;
if (solarPosition(a, site.latitude, site.longitude).elevation < solarPosition(b, site.latitude, site.longitude).elevation) lo = a; else hi = b;
}
solar.noon = (lo + hi) / 2;
solar.minute = clamp((timeForClock(previousClock) - solar.start) / 60000, 0, solar.length - 1);
$('#sunDate').value = solar.date;
$('#sunTime').max = solar.length - 1;
$('#sunRise').textContent = clockText(solar.rise === null ? null : solar.rise + 30000);
$('#sunSet').textContent = clockText(solar.set === null ? null : solar.set + 30000);
$('#sunNoon').textContent = clockText(solar.noon + 30000);
document.querySelectorAll('[data-season]').forEach(button => button.classList.toggle('active', solar.date.endsWith(button.dataset.season)));
const ticks = $('.sun-ticks');
ticks.style.position = 'relative'; ticks.style.height = '12px';
[...ticks.children].forEach((el, i) => {
const minutes = i === 4 ? solar.length : (timeForClock(el.textContent) - solar.start) / 60000;
el.style.cssText = `position:absolute;left:${100 * minutes / solar.length}%;transform:translateX(${i === 0 ? 0 : i === 4 ? -100 : -50}%)`;
});
$('#solarMessage').textContent = solar.length === 1440 ? '' : `Tento den má ${solar.length / 60} hodin. Časová osa respektuje změnu letního času.`;
drawSolarGraphBase();
updateSolar();
}
function drawSolarGraphBase() {
const y = altitude => 70 - clamp(altitude, 0, 90) * 0.65;
const points = solar.samples.map(p => `${8 + 288 * (p.ms - solar.start) / (solar.end - solar.start)},${y(p.elevation)}`);
const curve = `M${points.join(' L')}`;
graphBase = `<defs><linearGradient id="solarFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d4b465" stop-opacity=".34"/><stop offset="1" stop-color="#d4b465" stop-opacity=".04"/></linearGradient></defs><line x1="8" y1="70" x2="296" y2="70" stroke="#dce2d3"/><line x1="8" y1="40.75" x2="296" y2="40.75" stroke="#e4e8dd" stroke-dasharray="3 4"/><text x="294" y="37" text-anchor="end" fill="#a1a891" font-size="8">45°</text><path d="${curve} L296,70 L8,70 Z" fill="url(#solarFill)"/><path d="${curve}" fill="none" stroke="#c29b4b" stroke-width="1.5"/>`;
}
function drawSolarGraph() {
if (!solar.position) return;
const x = 8 + 288 * solar.minute / solar.length;
const y = 70 - clamp(solar.position.elevation, 0, 90) * 0.65;
$('#sunGraph').innerHTML = graphBase + `<line x1="${x}" y1="10" x2="${x}" y2="75" stroke="#a97731" stroke-width="1" stroke-dasharray="2 3"/><circle cx="${x}" cy="${y}" r="4" fill="#bc8933" stroke="#fafbf7" stroke-width="2"/>`;
}
function updateSolar() {
solar.minute = clamp(solar.minute, 0, solar.length - 1);
const utc = solar.start + solar.minute * 60000;
solar.position = solarPosition(utc, site.latitude, site.longitude);
const { elevation, azimuth } = solar.position;
solar.dayFactor = smooth(-10, 4, elevation);
solar.direct = elevation > 0 ? (1.02 - 0.32 * Math.exp(-Math.max(elevation, 0) / 9)) * smooth(0, 1.2, elevation) : 0;
const daylight = solar.dayFactor;
solar.sky = mix([0.075, 0.112, 0.157], [0.906, 0.922, 0.890], daylight);
if (elevation > -7 && elevation < 9) solar.sky = mix(solar.sky, [0.89, 0.83, 0.74], 0.16 * Math.sin((elevation + 7) / 16 * Math.PI));
scene.background.setRGB(...solar.sky);
const az = azimuth * RAD, alt = elevation * RAD;
const distance = 65;
modelRoot.updateMatrixWorld(true);
sunTarget.position.set(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
sunLight.position.set(Math.sin(az) * Math.cos(alt), Math.sin(alt), Math.cos(az) * Math.cos(alt)).multiplyScalar(distance).add(sunTarget.position);
sunLight.intensity = Math.max(0, solar.direct) * 2.55;
sunLight.color.setRGB(1, elevation < 12 ? 0.87 + elevation / 100 : 0.96, elevation < 12 ? 0.68 + elevation / 55 : 0.88);
sunLight.castShadow = solar.shadows && elevation > -0.5;
solar.lastTick = utc;
$('#sunClock').value = clockText(utc);
$('#sunTime').value = Math.floor(solar.minute);
$('#sunTime').setAttribute('aria-valuetext', clockText(utc));
$('#zoneLabel').textContent = zoneText(utc);
$('#sunElevation').textContent = `${czNum(elevation)}°`;
$('#sunAzimuth').textContent = `${czNum(azimuth)}°`;
const dirs = ['sever', 'severovýchod', 'východ', 'jihovýchod', 'jih', 'jihozápad', 'západ', 'severozápad'];
$('#sunDirection').textContent = `${dirs[Math.round(azimuth / 45) % 8]} · od severu`;
$('#dayState').textContent = elevation > 0 ? 'Slunce nad obzorem' : elevation > -0.833 ? 'Slunce na obzoru' : elevation > -6 ? 'Soumrak' : 'Noc · bez přímého slunce';
$('#sceneTime').textContent = `${czDate(solar.date)} · ${clockText(utc)} ${utcOffset(utc) === 120 ? 'SELČ' : 'SEČ'}`;
document.body.classList.toggle('night', elevation < -4);
document.body.style.backgroundColor = `rgb(${solar.sky.map(v => Math.round(v * 255)).join(',')})`;
drawSolarGraph();
}
function solarFrame(dt) {
if (!solar.playing) return;
solar.minute += dt * solar.speed;
if (solar.minute >= solar.length) solar.minute = 0;
solar.accumulator += dt;
if (solar.accumulator >= 0.075) { solar.accumulator = 0; updateSolar(); }
}
function setSolarPlaying(on) {
solar.playing = on;
$('#sunPlay').innerHTML = on ? 'Ⅱ Pozastavit' : '▶ Přehrát den';
$('#sunPlay').setAttribute('aria-pressed', String(on));
}
$('#sunDate').addEventListener('change', (event) => {
if (!event.target.value) return;
setSolarPlaying(false); solar.date = event.target.value; rebuildSolarDay(true);
});
$('#sunClock').addEventListener('change', (event) => {
const utc = timeForClock(event.target.value);
if (utc === null) { event.target.value = clockText(solar.start + solar.minute * 60000); return; }
setSolarPlaying(false); solar.minute = (utc - solar.start) / 60000; updateSolar();
});
$('#sunTime').addEventListener('input', (event) => { setSolarPlaying(false); solar.minute = +event.target.value; updateSolar(); });
document.querySelectorAll('[data-season]').forEach(button => button.addEventListener('click', () => {
solar.date = `${solar.date.slice(0, 4)}-${button.dataset.season}`; setSolarPlaying(false); rebuildSolarDay(true);
}));
$('#sunPlay').addEventListener('click', () => setSolarPlaying(!solar.playing));
$('#sunSpeed').addEventListener('change', (event) => { solar.speed = +event.target.value; });
$('#sunNow').addEventListener('click', () => {
const now = new Date();
solar.date = `${localParts(now.getTime()).year}-${String(localParts(now.getTime()).month).padStart(2, '0')}-${String(localParts(now.getTime()).day).padStart(2, '0')}`;
rebuildSolarDay(false);
solar.minute = (now.getTime() - solar.start) / 60000;
updateSolar();
});
$('#sunShadows').addEventListener('change', (event) => { solar.shadows = event.target.checked; updateSolar(); });
$('#sunPath').addEventListener('change', (event) => { solar.path = event.target.checked; });
function readSiteNumbers() {
const lat = +$('#sunLat').value, lon = +$('#sunLon').value, bearing = +$('#siteBearing').value;
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(bearing) || !$('#sunLat').checkValidity() || !$('#sunLon').checkValidity() || !$('#siteBearing').checkValidity()) {
toast('Zadej hodnoty v povoleném rozsahu.');
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
return;
}
site.latitude = lat; site.longitude = lon; site.bearing = bearing;
modelRoot.rotation.y = bearing * RAD;
rebuildSolarDay(true);
}
['sunLat', 'sunLon', 'siteBearing'].forEach(id => $(`#${id}`).addEventListener('change', readSiteNumbers));
$('#sunResetSite').addEventListener('click', () => {
site.latitude = 49.7536; site.longitude = 13.5394; site.bearing = 0;
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
modelRoot.rotation.y = 0;
rebuildSolarDay(true);
});
$('#compassGroup').insertAdjacentHTML('beforeend', '<circle id="compassSun" cx="42" cy="16" r="3" fill="#c89439" stroke="#fff7d7" stroke-width="1"/>');
const sunGraph = $('#sunGraph');
function scrubGraph(event) {
const rect = sunGraph.getBoundingClientRect();
solar.minute = clamp(((event.clientX - rect.left) / rect.width * 304 - 8) / 288 * solar.length, 0, solar.length - 1);
setSolarPlaying(false); updateSolar();
}
sunGraph.addEventListener('pointerdown', event => { sunGraph.setPointerCapture(event.pointerId); scrubGraph(event); });
sunGraph.addEventListener('pointermove', event => { if (sunGraph.hasPointerCapture(event.pointerId)) scrubGraph(event); });
sunGraph.addEventListener('pointerup', event => { if (sunGraph.hasPointerCapture(event.pointerId)) sunGraph.releasePointerCapture(event.pointerId); });
async function saveSunImage() {
setSolarPlaying(false); updateSolar();
controls.update();
drawSunPath();
renderer.render(scene, camera);
const source = renderer.domElement;
const out = document.createElement('canvas');
const [viewWidth] = viewportSize();
const ratio = source.width / viewWidth;
const footer = Math.round(66 * ratio);
out.width = source.width; out.height = source.height + footer;
const ctx = out.getContext('2d');
ctx.fillStyle = '#f8f9f3'; ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(source, 0, 0);
const scale = out.width / viewWidth;
if (solar.path && $('#sunOverlay').innerHTML) {
const svgText = `<svg xmlns="http://www.w3.org/2000/svg" width="${viewWidth}" height="${source.height / ratio}" viewBox="0 0 ${viewWidth} ${source.height / ratio}"><style>text{font:10px Arial;fill:#96712e;paint-order:stroke;stroke:#f3f4e9;stroke-width:3px;stroke-linejoin:round}</style>${$('#sunOverlay').innerHTML}</svg>`;
const url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' }));
const overlay = new Image();
await new Promise((resolve, reject) => { overlay.onload = resolve; overlay.onerror = reject; overlay.src = url; });
ctx.drawImage(overlay, 0, 0, source.width, source.height);
URL.revokeObjectURL(url);
}
ctx.fillStyle = '#2d3c31'; ctx.font = `${12 * scale}px Arial`;
ctx.fillText(`Klabava · ${czDate(solar.date)} · ${clockText(solar.start + solar.minute * 60000)} · ${zoneText(solar.start + solar.minute * 60000)}`, 24 * scale, source.height + 28 * scale);
ctx.fillStyle = '#7b8372'; ctx.font = `${9 * scale}px Arial`;
ctx.fillText('Orientační studie oslunění · souřadnice obce Klabava', 24 * scale, source.height + 46 * scale);
out.toBlob(blob => { if (blob) saveBlob(blob, 'klabava-slunce.png'); });
}
$('#sunSavePng').addEventListener('click', saveSunImage);
$('#savePng').addEventListener('click', saveSunImage);
$('#saveGlb').addEventListener('click', () => {
const exporter = new GLTFExporter();
exporter.parse(modelRoot, (result) => {
saveBlob(new Blob([result], { type: 'model/gltf-binary' }), 'klabava-pracovni-model.glb');
toast('Model Klabava GLB je připravený ke stažení.');
}, (error) => { console.error(error); toast('Model se nepodařilo exportovat.'); }, { binary: true, onlyVisible: true, trs: false });
});
function drawSunPath() {
const svg = $('#sunOverlay');
const [width, height] = viewportSize();
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
const dot = $('#compassSun');
const azimuth = solar.position?.azimuth ?? 0;
dot.setAttribute('cx', 42 + 26 * Math.sin(azimuth * RAD));
dot.setAttribute('cy', 42 - 26 * Math.cos(azimuth * RAD));
dot.style.display = solar.position?.elevation > 0 ? 'block' : 'none';
if (!solar.path) { svg.innerHTML = ''; return; }
modelRoot.updateMatrixWorld(true);
const center = new THREE.Vector3(0, 1.2, houseGroup.position.z + 1).applyMatrix4(modelRoot.matrixWorld);
const pointAt = (az, alt, distance = 15.5) => new THREE.Vector3(
center.x + Math.sin(az * RAD) * Math.cos(alt * RAD) * distance,
center.y + Math.sin(alt * RAD) * distance,
center.z + Math.cos(az * RAD) * Math.cos(alt * RAD) * distance
);
let path = '', started = false;
for (const sample of solar.samples) {
if (sample.elevation < 0) { started = false; continue; }
const v = pointAt(sample.azimuth, sample.elevation).project(camera);
if (v.z < -1 || v.z > 1) { started = false; continue; }
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
path += `${started ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)} `;
started = true;
}
let out = `<path d="${path}" fill="none" stroke="#b28b40" stroke-width="1.5" stroke-dasharray="5 5" opacity=".8"/>`;
for (const hour of [6, 9, 12, 15, 18, 21]) {
const utc = timeForClock(`${String(hour).padStart(2, '0')}:00`);
if (utc === null) continue;
const pos = solarPosition(utc, site.latitude, site.longitude);
if (pos.elevation < 0) continue;
const v = pointAt(pos.azimuth, pos.elevation).project(camera);
if (v.z < -1 || v.z > 1) continue;
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
out += `<circle cx="${x}" cy="${y}" r="2.5" fill="#bd984f"/><text x="${x + 6}" y="${y - 7}">${hour}:00</text>`;
}
if (solar.position?.elevation >= 0) {
const v = pointAt(solar.position.azimuth, solar.position.elevation).project(camera);
if (v.z >= -1 && v.z <= 1) {
const x = (v.x + 1) * width / 2, y = (1 - v.y) * height / 2;
out += `<circle cx="${x}" cy="${y}" r="18" fill="#e2bc5c" opacity=".13"/><circle cx="${x}" cy="${y}" r="6.5" fill="#e7bd59" stroke="#fff5cf" stroke-width="2"/>`;
}
}
svg.innerHTML = out;
}
function animate(now = 0) {
requestAnimationFrame(animate);
const dt = Math.min(0.1, Math.max(0, (now - (animate.last || now)) / 1000));
animate.last = now;
solarFrame(dt);
controls.update();
const cameraHeading = Math.atan2(camera.position.x - controls.target.x, camera.position.z - controls.target.z) / RAD;
$('#compassGroup').setAttribute('transform', `rotate(${cameraHeading},42,42)`);
renderer.render(scene, camera);
drawSunPath();
}
window.addEventListener('keydown', event => {
if (event.key === 'Escape') { toggleInfo(false); toggleSun(false); }
if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'SUMMARY'].includes(event.target.tagName)) return;
if (event.code === 'Space') { event.preventDefault(); setSolarPlaying(!solar.playing); }
if (event.key.toLowerCase() === 's') toggleSun();
if (event.key === '1') setPreset('entry');
if (event.key === '2') setPreset('garden');
if (event.key === '3') setPreset('top');
});
document.addEventListener('visibilitychange', () => { if (document.hidden) setSolarPlaying(false); });
window.SUN_SIM = {
position: solarPosition,
getState: () => ({ date: solar.date, time: clockText(solar.start + solar.minute * 60000), utc: new Date(solar.start + solar.minute * 60000).toISOString(), lat: site.latitude, lon: site.longitude, bearing: site.bearing, azimuth: solar.position.azimuth, elevation: solar.position.elevation, rise: clockText(solar.rise), set: clockText(solar.set), noon: clockText(solar.noon), shadows: solar.shadows, playing: solar.playing }),
setDate: date => { solar.date = date; rebuildSolarDay(true); },
setTime: time => { solar.minute = (timeForClock(time) - solar.start) / 60000; updateSolar(); },
pause: () => setSolarPlaying(false)
};
try {
rebuildSolarDay(false);
$('#sunLat').value = site.latitude; $('#sunLon').value = site.longitude; $('#siteBearing').value = site.bearing;
toggleSun(false); toggleInfo(false);
loading.classList.add('done');
setTimeout(() => { loading.style.display = 'none'; }, 300);
requestAnimationFrame(animate);
} catch (error) {
console.error(error);
loading.classList.add('error');
loading.innerHTML = `3D model se nepodařilo načíst.<br>${error.message}<br><small>Spusť web přes místní server a zkontroluj připojení k CDN Three.js.</small>`;
$('#webStatus').textContent = '3D není dostupné';
}
+23
View File
@@ -0,0 +1,23 @@
// Pure function retained from the supplied Meeus / NOAA implementation.
// Arguments: UTC Unix milliseconds, degrees latitude, degrees longitude.
const RAD=Math.PI/180,DAY_MS=86400000;
const mod=(x,n)=>((x%n)+n)%n,clamp=(x,a,b)=>Math.max(a,Math.min(b,x));
export function solarPosition(ms, lat, lon) {
const jd = ms / DAY_MS + 2440587.5, t = (jd - 2451545) / 36525;
const L = mod(280.46646 + t * (36000.76983 + t * .0003032), 360) * RAD;
const M = (357.52911 + t * (35999.05029 - .0001537 * t)) * RAD;
const e = .016708634 - t * (.000042037 + .0000001267 * t);
const C = Math.sin(M) * (1.914602 - t * (.004817 + .000014 * t)) + Math.sin(2 * M) * (.019993 - .000101 * t) + Math.sin(3 * M) * .000289;
const omega = (125.04 - 1934.136 * t) * RAD;
const lambda = L + (C - .00569 - .00478 * Math.sin(omega)) * RAD;
const mean = 23 + (26 + (21.448 - t * (46.815 + t * (.00059 - t * .001813))) / 60) / 60;
const obliq = (mean + .00256 * Math.cos(omega)) * RAD;
const dec = Math.asin(Math.sin(obliq) * Math.sin(lambda));
const y = Math.tan(obliq / 2) ** 2;
const eq = 4 / RAD * (y * Math.sin(2 * L) - 2 * e * Math.sin(M) + 4 * e * y * Math.sin(M) * Math.cos(2 * L) - .5 * y * y * Math.sin(4 * L) - 1.25 * e * e * Math.sin(2 * M));
const solarMinutes = mod(mod(ms, DAY_MS) / 60000 + eq + 4 * lon, 1440);
const H = (solarMinutes / 4 - 180) * RAD, phi = lat * RAD;
const elevation = Math.asin(clamp(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H), -1, 1)) / RAD;
const azimuth = mod(Math.atan2(Math.sin(H), Math.cos(H) * Math.sin(phi) - Math.tan(dec) * Math.cos(phi)) / RAD + 180, 360);
return { elevation, azimuth, declination: dec / RAD, equationOfTime: eq };
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.