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
Vendored
BIN
View File
Binary file not shown.
+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.
BIN
View File
Binary file not shown.