@jjlmoya/utils-home 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +62 -0
- package/src/category/i18n/en.ts +24 -0
- package/src/category/i18n/es.ts +24 -0
- package/src/category/i18n/fr.ts +24 -0
- package/src/category/index.ts +12 -0
- package/src/category/seo.astro +15 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +11 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +26 -0
- package/src/layouts/PreviewLayout.astro +117 -0
- package/src/pages/[locale]/[slug].astro +146 -0
- package/src/pages/[locale].astro +251 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/faq_count.test.ts +19 -0
- package/src/tests/locale_completeness.test.ts +42 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/seo_length.test.ts +22 -0
- package/src/tests/tool_validation.test.ts +17 -0
- package/src/tool/dewPointCalculator/bibliography.astro +14 -0
- package/src/tool/dewPointCalculator/component.astro +443 -0
- package/src/tool/dewPointCalculator/i18n/en.ts +183 -0
- package/src/tool/dewPointCalculator/i18n/es.ts +183 -0
- package/src/tool/dewPointCalculator/i18n/fr.ts +183 -0
- package/src/tool/dewPointCalculator/index.ts +34 -0
- package/src/tool/dewPointCalculator/logic.ts +16 -0
- package/src/tool/dewPointCalculator/seo.astro +14 -0
- package/src/tool/dewPointCalculator/ui.ts +13 -0
- package/src/tool/ledSavingCalculator/bibliography.astro +14 -0
- package/src/tool/ledSavingCalculator/component.astro +520 -0
- package/src/tool/ledSavingCalculator/i18n/en.ts +217 -0
- package/src/tool/ledSavingCalculator/i18n/es.ts +217 -0
- package/src/tool/ledSavingCalculator/i18n/fr.ts +217 -0
- package/src/tool/ledSavingCalculator/index.ts +34 -0
- package/src/tool/ledSavingCalculator/logic.ts +31 -0
- package/src/tool/ledSavingCalculator/seo.astro +14 -0
- package/src/tool/ledSavingCalculator/ui.ts +32 -0
- package/src/tool/projectorCalculator/bibliography.astro +14 -0
- package/src/tool/projectorCalculator/component.astro +569 -0
- package/src/tool/projectorCalculator/i18n/en.ts +181 -0
- package/src/tool/projectorCalculator/i18n/es.ts +181 -0
- package/src/tool/projectorCalculator/i18n/fr.ts +181 -0
- package/src/tool/projectorCalculator/index.ts +34 -0
- package/src/tool/projectorCalculator/logic.ts +21 -0
- package/src/tool/projectorCalculator/seo.astro +14 -0
- package/src/tool/projectorCalculator/ui.ts +16 -0
- package/src/tool/qrGenerator/bibliography.astro +14 -0
- package/src/tool/qrGenerator/component.astro +499 -0
- package/src/tool/qrGenerator/i18n/en.ts +233 -0
- package/src/tool/qrGenerator/i18n/es.ts +233 -0
- package/src/tool/qrGenerator/i18n/fr.ts +233 -0
- package/src/tool/qrGenerator/index.ts +34 -0
- package/src/tool/qrGenerator/logic.ts +27 -0
- package/src/tool/qrGenerator/seo.astro +14 -0
- package/src/tool/qrGenerator/ui.ts +23 -0
- package/src/tool/solarCalculator/bibliography.astro +14 -0
- package/src/tool/solarCalculator/component.astro +532 -0
- package/src/tool/solarCalculator/i18n/en.ts +176 -0
- package/src/tool/solarCalculator/i18n/es.ts +176 -0
- package/src/tool/solarCalculator/i18n/fr.ts +176 -0
- package/src/tool/solarCalculator/index.ts +34 -0
- package/src/tool/solarCalculator/logic.ts +31 -0
- package/src/tool/solarCalculator/seo.astro +14 -0
- package/src/tool/solarCalculator/ui.ts +11 -0
- package/src/tool/tariffComparator/bibliography.astro +14 -0
- package/src/tool/tariffComparator/component.astro +595 -0
- package/src/tool/tariffComparator/i18n/en.ts +192 -0
- package/src/tool/tariffComparator/i18n/es.ts +192 -0
- package/src/tool/tariffComparator/i18n/fr.ts +192 -0
- package/src/tool/tariffComparator/index.ts +34 -0
- package/src/tool/tariffComparator/logic.ts +47 -0
- package/src/tool/tariffComparator/seo.astro +14 -0
- package/src/tool/tariffComparator/ui.ts +25 -0
- package/src/tools.ts +9 -0
- package/src/types.ts +72 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SolarCalculatorUI } from './ui';
|
|
3
|
+
import { calculateTilt } from './logic';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui = {} } = Astro.props;
|
|
10
|
+
const solarUI = ui as SolarCalculatorUI;
|
|
11
|
+
const initial = calculateTilt(40.41);
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="solar-wrapper">
|
|
15
|
+
<div class="solar-card">
|
|
16
|
+
<div class="solar-left">
|
|
17
|
+
<div class="field-group">
|
|
18
|
+
<label class="field-label" for="lat-input">{solarUI.labelLatitude}</label>
|
|
19
|
+
<div class="lat-row">
|
|
20
|
+
<input
|
|
21
|
+
type="number"
|
|
22
|
+
id="lat-input"
|
|
23
|
+
value="40.41"
|
|
24
|
+
step="0.1"
|
|
25
|
+
min="-90"
|
|
26
|
+
max="90"
|
|
27
|
+
class="lat-input"
|
|
28
|
+
/>
|
|
29
|
+
<span class="deg-unit">°</span>
|
|
30
|
+
</div>
|
|
31
|
+
<button
|
|
32
|
+
id="btn-locate"
|
|
33
|
+
class="locate-btn"
|
|
34
|
+
data-geo-error={solarUI.geoNotAvailable}
|
|
35
|
+
>
|
|
36
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/><path d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
37
|
+
<span>{solarUI.btnLocate}</span>
|
|
38
|
+
</button>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="optimal-card">
|
|
42
|
+
<p class="optimal-label">{solarUI.labelOptimal}</p>
|
|
43
|
+
<div class="optimal-value-row">
|
|
44
|
+
<span id="display-angle" class="optimal-value">{initial.optimal}</span>
|
|
45
|
+
<span class="optimal-deg">°</span>
|
|
46
|
+
</div>
|
|
47
|
+
<p class="efficiency-tag">
|
|
48
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
|
49
|
+
{solarUI.labelEfficiency}
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div class="season-grid">
|
|
54
|
+
<div class="season-card winter">
|
|
55
|
+
<div class="season-header">
|
|
56
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
|
57
|
+
<span class="season-name">{solarUI.labelWinter}</span>
|
|
58
|
+
</div>
|
|
59
|
+
<p id="winter-angle" class="season-value">{initial.winter}°</p>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="season-card summer">
|
|
62
|
+
<div class="season-header">
|
|
63
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
|
|
64
|
+
<span class="season-name">{solarUI.labelSummer}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<p id="summer-angle" class="season-value">{initial.summer}°</p>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div
|
|
71
|
+
id="hemisphere-badge"
|
|
72
|
+
class="hemi-badge"
|
|
73
|
+
data-north={solarUI.hemisphereNorth}
|
|
74
|
+
data-south={solarUI.hemisphereSouth}
|
|
75
|
+
>
|
|
76
|
+
{initial.isNorth ? solarUI.hemisphereNorth : solarUI.hemisphereSouth}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div class="solar-right">
|
|
81
|
+
<div class="viz-dots"></div>
|
|
82
|
+
<svg id="tilt-viz" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet" class="viz-svg">
|
|
83
|
+
<defs>
|
|
84
|
+
<linearGradient id="panel-grad" x1="0" y1="0" x2="1" y2="1">
|
|
85
|
+
<stop offset="0%" stop-color="#3b82f6"></stop>
|
|
86
|
+
<stop offset="100%" stop-color="#1d4ed8"></stop>
|
|
87
|
+
</linearGradient>
|
|
88
|
+
<filter id="glow">
|
|
89
|
+
<feGaussianBlur stdDeviation="4" result="coloredBlur"></feGaussianBlur>
|
|
90
|
+
<feMerge>
|
|
91
|
+
<feMergeNode in="coloredBlur"></feMergeNode>
|
|
92
|
+
<feMergeNode in="SourceGraphic"></feMergeNode>
|
|
93
|
+
</feMerge>
|
|
94
|
+
</filter>
|
|
95
|
+
</defs>
|
|
96
|
+
|
|
97
|
+
<line x1="100" y1="500" x2="700" y2="500" stroke="#475569" stroke-width="2" stroke-dasharray="8 8"></line>
|
|
98
|
+
|
|
99
|
+
<g transform="translate(650, 100)">
|
|
100
|
+
<circle r="40" fill="#fbbf24" fill-opacity="0.2" class="sun-pulse"></circle>
|
|
101
|
+
<circle r="15" fill="#f59e0b" filter="url(#glow)"></circle>
|
|
102
|
+
</g>
|
|
103
|
+
|
|
104
|
+
<g id="panel-assembly" transform="translate(400, 500)">
|
|
105
|
+
<g id="panel-rect-group">
|
|
106
|
+
<line x1="0" y1="0" x2="-30" y2="0" stroke="#64748b" stroke-width="6" stroke-linecap="round"></line>
|
|
107
|
+
<rect x="-220" y="-6" width="220" height="12" rx="4" fill="url(#panel-grad)" stroke="#60a5fa" stroke-width="1"></rect>
|
|
108
|
+
<line x1="-55" y1="-6" x2="-55" y2="6" stroke="white" stroke-opacity="0.2" stroke-width="1"></line>
|
|
109
|
+
<line x1="-110" y1="-6" x2="-110" y2="6" stroke="white" stroke-opacity="0.2" stroke-width="1"></line>
|
|
110
|
+
<line x1="-165" y1="-6" x2="-165" y2="6" stroke="white" stroke-opacity="0.2" stroke-width="1"></line>
|
|
111
|
+
<line x1="-110" y1="-6" x2="-110" y2="-80" stroke="#fbbf24" stroke-width="2" stroke-dasharray="5 5" opacity="0.8"></line>
|
|
112
|
+
<circle cx="-110" cy="-80" r="3" fill="#fbbf24"></circle>
|
|
113
|
+
<text x="-100" y="-90" fill="#fbbf24" font-size="12" font-weight="bold">NORMAL</text>
|
|
114
|
+
</g>
|
|
115
|
+
|
|
116
|
+
<path id="viz-arc" d="M-80,0 A80,80 0 0,1 -64,-48" fill="none" stroke="#f97316" stroke-width="2" stroke-dasharray="4" opacity="0.6"></path>
|
|
117
|
+
<text id="viz-arc-text" x="-120" y="-30" fill="#f97316" font-size="20" font-weight="bold">{initial.optimal}°</text>
|
|
118
|
+
|
|
119
|
+
<g id="ray-container"></g>
|
|
120
|
+
</g>
|
|
121
|
+
</svg>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<script>
|
|
127
|
+
import { calculateTilt, toRad } from './logic';
|
|
128
|
+
|
|
129
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
130
|
+
|
|
131
|
+
function setText(el: HTMLElement | null, v: string) {
|
|
132
|
+
if (el) el.textContent = v;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildRay(x1: number, y1: number, x2: number, y2: number): SVGLineElement {
|
|
136
|
+
const line = document.createElementNS(SVG_NS, 'line');
|
|
137
|
+
line.setAttribute('x1', String(x1));
|
|
138
|
+
line.setAttribute('y1', String(y1));
|
|
139
|
+
line.setAttribute('x2', String(x2));
|
|
140
|
+
line.setAttribute('y2', String(y2));
|
|
141
|
+
line.setAttribute('stroke', '#fbbf24');
|
|
142
|
+
line.setAttribute('stroke-width', '2');
|
|
143
|
+
line.setAttribute('stroke-dasharray', '8 4');
|
|
144
|
+
line.setAttribute('opacity', '0.6');
|
|
145
|
+
const anim = document.createElementNS(SVG_NS, 'animate');
|
|
146
|
+
anim.setAttribute('attributeName', 'stroke-dashoffset');
|
|
147
|
+
anim.setAttribute('from', '12');
|
|
148
|
+
anim.setAttribute('to', '0');
|
|
149
|
+
anim.setAttribute('dur', '1s');
|
|
150
|
+
anim.setAttribute('repeatCount', 'indefinite');
|
|
151
|
+
line.appendChild(anim);
|
|
152
|
+
return line;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function updateRays(angle: number, container: Element) {
|
|
156
|
+
container.innerHTML = '';
|
|
157
|
+
const rad = toRad(angle);
|
|
158
|
+
for (let i = -1; i <= 1; i++) {
|
|
159
|
+
const off = i * 40;
|
|
160
|
+
const sx = -110 + i * 60;
|
|
161
|
+
const ray = buildRay(
|
|
162
|
+
650 + off, 100 - off,
|
|
163
|
+
400 + sx * Math.cos(rad),
|
|
164
|
+
500 + sx * Math.sin(rad)
|
|
165
|
+
);
|
|
166
|
+
container.appendChild(ray);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function updateArc(angle: number, arc: Element, arcText: Element) {
|
|
171
|
+
const r = 80;
|
|
172
|
+
const rad = toRad(angle);
|
|
173
|
+
const endX = -r * Math.cos(rad);
|
|
174
|
+
const endY = -r * Math.sin(rad);
|
|
175
|
+
const large = angle > 180 ? 1 : 0;
|
|
176
|
+
arc.setAttribute('d', `M${-r},0 A${r},${r} 0 ${large},1 ${endX.toFixed(2)},${endY.toFixed(2)}`);
|
|
177
|
+
arcText.textContent = `${angle}°`;
|
|
178
|
+
const mid = rad / 2;
|
|
179
|
+
arcText.setAttribute('x', String((-120 * Math.cos(mid)).toFixed(2)));
|
|
180
|
+
arcText.setAttribute('y', String((-120 * Math.sin(mid)).toFixed(2)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function updateVisuals(angle: number) {
|
|
184
|
+
const panel = document.getElementById('panel-rect-group');
|
|
185
|
+
const arc = document.getElementById('viz-arc');
|
|
186
|
+
const arcText = document.getElementById('viz-arc-text');
|
|
187
|
+
const rays = document.getElementById('ray-container');
|
|
188
|
+
if (panel) panel.setAttribute('transform', `rotate(${angle})`);
|
|
189
|
+
if (arc && arcText) updateArc(angle, arc, arcText);
|
|
190
|
+
if (rays) updateRays(angle, rays);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function update() {
|
|
194
|
+
const latInput = document.getElementById('lat-input') as HTMLInputElement | null;
|
|
195
|
+
if (!latInput || !latInput.value) return;
|
|
196
|
+
const lat = parseFloat(latInput.value);
|
|
197
|
+
if (isNaN(lat)) return;
|
|
198
|
+
const data = calculateTilt(lat);
|
|
199
|
+
setText(document.getElementById('display-angle'), String(data.optimal));
|
|
200
|
+
setText(document.getElementById('winter-angle'), data.winter + '°');
|
|
201
|
+
setText(document.getElementById('summer-angle'), data.summer + '°');
|
|
202
|
+
const badge = document.getElementById('hemisphere-badge');
|
|
203
|
+
if (badge) {
|
|
204
|
+
badge.textContent = data.isNorth ? (badge.dataset.north || '') : (badge.dataset.south || '');
|
|
205
|
+
}
|
|
206
|
+
updateVisuals(data.optimal);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function attachLocate() {
|
|
210
|
+
const btn = document.getElementById('btn-locate');
|
|
211
|
+
const latInput = document.getElementById('lat-input') as HTMLInputElement | null;
|
|
212
|
+
if (!btn || !latInput) return;
|
|
213
|
+
btn.addEventListener('click', () => {
|
|
214
|
+
if (!navigator.geolocation) { alert(btn.getAttribute('data-geo-error') || ''); return; }
|
|
215
|
+
btn.classList.add('loading');
|
|
216
|
+
navigator.geolocation.getCurrentPosition(
|
|
217
|
+
(pos) => { latInput.value = pos.coords.latitude.toFixed(2); update(); btn.classList.remove('loading'); },
|
|
218
|
+
() => { btn.classList.remove('loading'); }
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function init() {
|
|
224
|
+
const latInput = document.getElementById('lat-input') as HTMLInputElement | null;
|
|
225
|
+
if (!latInput) return;
|
|
226
|
+
latInput.addEventListener('input', update);
|
|
227
|
+
attachLocate();
|
|
228
|
+
update();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
document.addEventListener('astro:page-load', init);
|
|
232
|
+
init();
|
|
233
|
+
</script>
|
|
234
|
+
|
|
235
|
+
<style>
|
|
236
|
+
.solar-wrapper {
|
|
237
|
+
--solar-p: #f97316;
|
|
238
|
+
--solar-winter: #3b82f6;
|
|
239
|
+
--solar-summer: #eab308;
|
|
240
|
+
|
|
241
|
+
width: 100%;
|
|
242
|
+
padding: 1rem 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.solar-card {
|
|
246
|
+
background: var(--bg-surface);
|
|
247
|
+
width: calc(100% - 24px);
|
|
248
|
+
max-width: 960px;
|
|
249
|
+
margin: 0 auto;
|
|
250
|
+
border-radius: 24px;
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
display: flex;
|
|
253
|
+
flex-direction: column;
|
|
254
|
+
border: 1px solid var(--border-color);
|
|
255
|
+
color: var(--text-main);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@media (min-width: 768px) {
|
|
259
|
+
.solar-card {
|
|
260
|
+
flex-direction: row;
|
|
261
|
+
min-height: 560px;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.solar-left {
|
|
266
|
+
flex: 0 0 auto;
|
|
267
|
+
width: 100%;
|
|
268
|
+
padding: 32px;
|
|
269
|
+
border-bottom: 1px solid var(--border-color);
|
|
270
|
+
display: flex;
|
|
271
|
+
flex-direction: column;
|
|
272
|
+
gap: 24px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@media (min-width: 768px) {
|
|
276
|
+
.solar-left {
|
|
277
|
+
width: 340px;
|
|
278
|
+
border-bottom: none;
|
|
279
|
+
border-right: 1px solid var(--border-color);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.solar-right {
|
|
284
|
+
flex: 1;
|
|
285
|
+
background: #020617;
|
|
286
|
+
position: relative;
|
|
287
|
+
overflow: hidden;
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
justify-content: center;
|
|
291
|
+
min-height: 380px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.viz-dots {
|
|
295
|
+
position: absolute;
|
|
296
|
+
inset: 0;
|
|
297
|
+
opacity: 0.15;
|
|
298
|
+
pointer-events: none;
|
|
299
|
+
background-image: radial-gradient(#fff 1px, transparent 1px);
|
|
300
|
+
background-size: 24px 24px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.viz-svg {
|
|
304
|
+
width: 100%;
|
|
305
|
+
height: 100%;
|
|
306
|
+
position: relative;
|
|
307
|
+
z-index: 1;
|
|
308
|
+
padding: 1rem;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.sun-pulse {
|
|
312
|
+
animation: pulse 2s ease-in-out infinite;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
@keyframes pulse {
|
|
316
|
+
0%, 100% { opacity: 0.2; }
|
|
317
|
+
50% { opacity: 0.5; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.field-group {
|
|
321
|
+
display: flex;
|
|
322
|
+
flex-direction: column;
|
|
323
|
+
gap: 8px;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.field-label {
|
|
327
|
+
font-size: 11px;
|
|
328
|
+
font-weight: 700;
|
|
329
|
+
color: var(--text-muted);
|
|
330
|
+
text-transform: uppercase;
|
|
331
|
+
letter-spacing: 0.1em;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.lat-row {
|
|
335
|
+
position: relative;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.lat-input {
|
|
339
|
+
width: 100%;
|
|
340
|
+
font-size: 2.5rem;
|
|
341
|
+
font-weight: 900;
|
|
342
|
+
color: var(--text-main);
|
|
343
|
+
background: transparent;
|
|
344
|
+
border: none;
|
|
345
|
+
border-bottom: 2px solid var(--border-color);
|
|
346
|
+
padding: 4px 2.5rem 4px 0;
|
|
347
|
+
outline: none;
|
|
348
|
+
transition: border-color 0.2s;
|
|
349
|
+
box-sizing: border-box;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.lat-input:focus {
|
|
353
|
+
border-color: var(--solar-p);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.deg-unit {
|
|
357
|
+
position: absolute;
|
|
358
|
+
right: 8px;
|
|
359
|
+
bottom: 8px;
|
|
360
|
+
font-size: 1.5rem;
|
|
361
|
+
color: var(--text-muted);
|
|
362
|
+
font-weight: 300;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.locate-btn {
|
|
366
|
+
display: inline-flex;
|
|
367
|
+
align-items: center;
|
|
368
|
+
gap: 6px;
|
|
369
|
+
background: none;
|
|
370
|
+
border: none;
|
|
371
|
+
color: var(--solar-p);
|
|
372
|
+
font-size: 0.8125rem;
|
|
373
|
+
font-weight: 700;
|
|
374
|
+
cursor: pointer;
|
|
375
|
+
padding: 0;
|
|
376
|
+
transition: opacity 0.2s;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.locate-btn:hover {
|
|
380
|
+
opacity: 0.75;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.locate-btn.loading {
|
|
384
|
+
opacity: 0.5;
|
|
385
|
+
pointer-events: none;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.locate-btn svg {
|
|
389
|
+
width: 16px;
|
|
390
|
+
height: 16px;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.optimal-card {
|
|
394
|
+
background: var(--bg-muted);
|
|
395
|
+
border-radius: 16px;
|
|
396
|
+
padding: 20px;
|
|
397
|
+
border: 1px solid var(--border-color);
|
|
398
|
+
position: relative;
|
|
399
|
+
overflow: hidden;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.optimal-card::after {
|
|
403
|
+
content: '';
|
|
404
|
+
position: absolute;
|
|
405
|
+
top: 0;
|
|
406
|
+
right: 0;
|
|
407
|
+
width: 80px;
|
|
408
|
+
height: 80px;
|
|
409
|
+
background: radial-gradient(circle at top right, rgba(249, 115, 22, 0.2), transparent);
|
|
410
|
+
border-radius: 0 0 0 80px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.optimal-label {
|
|
414
|
+
font-size: 0.8125rem;
|
|
415
|
+
color: var(--text-muted);
|
|
416
|
+
font-weight: 500;
|
|
417
|
+
margin: 0 0 4px;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.optimal-value-row {
|
|
421
|
+
display: flex;
|
|
422
|
+
align-items: baseline;
|
|
423
|
+
gap: 4px;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.optimal-value {
|
|
427
|
+
font-size: 3.5rem;
|
|
428
|
+
font-weight: 900;
|
|
429
|
+
color: var(--text-main);
|
|
430
|
+
line-height: 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.optimal-deg {
|
|
434
|
+
font-size: 1.5rem;
|
|
435
|
+
font-weight: 700;
|
|
436
|
+
color: var(--solar-p);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.efficiency-tag {
|
|
440
|
+
display: flex;
|
|
441
|
+
align-items: center;
|
|
442
|
+
gap: 4px;
|
|
443
|
+
font-size: 0.75rem;
|
|
444
|
+
color: #16a34a;
|
|
445
|
+
font-weight: 600;
|
|
446
|
+
margin: 8px 0 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.efficiency-tag svg {
|
|
450
|
+
width: 12px;
|
|
451
|
+
height: 12px;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.season-grid {
|
|
455
|
+
display: grid;
|
|
456
|
+
grid-template-columns: 1fr 1fr;
|
|
457
|
+
gap: 12px;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.season-card {
|
|
461
|
+
border-radius: 12px;
|
|
462
|
+
padding: 14px;
|
|
463
|
+
border: 1px solid transparent;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.season-card.winter {
|
|
467
|
+
background: rgba(59, 130, 246, 0.08);
|
|
468
|
+
border-color: rgba(59, 130, 246, 0.2);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.season-card.summer {
|
|
472
|
+
background: rgba(234, 179, 8, 0.08);
|
|
473
|
+
border-color: rgba(234, 179, 8, 0.2);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.season-header {
|
|
477
|
+
display: flex;
|
|
478
|
+
align-items: center;
|
|
479
|
+
gap: 6px;
|
|
480
|
+
margin-bottom: 8px;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.season-header svg {
|
|
484
|
+
width: 14px;
|
|
485
|
+
height: 14px;
|
|
486
|
+
flex-shrink: 0;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.season-card.winter .season-header svg {
|
|
490
|
+
color: var(--solar-winter);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
.season-card.summer .season-header svg {
|
|
494
|
+
color: var(--solar-summer);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.season-name {
|
|
498
|
+
font-size: 0.6875rem;
|
|
499
|
+
font-weight: 800;
|
|
500
|
+
text-transform: uppercase;
|
|
501
|
+
letter-spacing: 0.08em;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.season-card.winter .season-name {
|
|
505
|
+
color: var(--solar-winter);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.season-card.summer .season-name {
|
|
509
|
+
color: var(--solar-summer);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.season-value {
|
|
513
|
+
font-size: 1.5rem;
|
|
514
|
+
font-weight: 700;
|
|
515
|
+
color: var(--text-main);
|
|
516
|
+
margin: 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.hemi-badge {
|
|
520
|
+
display: inline-block;
|
|
521
|
+
padding: 6px 14px;
|
|
522
|
+
background: var(--bg-muted);
|
|
523
|
+
border: 1px solid var(--border-color);
|
|
524
|
+
border-radius: 8px;
|
|
525
|
+
font-size: 0.6875rem;
|
|
526
|
+
font-weight: 700;
|
|
527
|
+
color: var(--text-muted);
|
|
528
|
+
text-transform: uppercase;
|
|
529
|
+
letter-spacing: 0.08em;
|
|
530
|
+
align-self: flex-start;
|
|
531
|
+
}
|
|
532
|
+
</style>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import type { SolarCalculatorUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
const slug = 'solar-panel-calculator';
|
|
6
|
+
const title = 'Solar Panel Tilt Angle Calculator';
|
|
7
|
+
const description =
|
|
8
|
+
'Calculate the optimal tilt angle for your solar panels based on your geographic latitude. Get values for fixed installations and seasonal adjustments.';
|
|
9
|
+
|
|
10
|
+
const faqData = [
|
|
11
|
+
{
|
|
12
|
+
question: "What if my roof doesn't have the perfect tilt?",
|
|
13
|
+
answer:
|
|
14
|
+
"It's not a major problem. Losses from small deviations (5-10°) are less than 3% of annual production. Avoiding partial shading is far more important than achieving the exact angle.",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
question: 'Is the optimal angle the same everywhere in the world?',
|
|
18
|
+
answer:
|
|
19
|
+
'No. It depends directly on your latitude. A user in Madrid (~40°N) has a different optimal angle than someone in Norway (~60°N) or Argentina (~34°S).',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
question: 'Which direction should my panels face?',
|
|
23
|
+
answer:
|
|
24
|
+
'In the Northern Hemisphere, always face South (azimuth 180°). In the Southern Hemisphere, face North. An East-West orientation can cause losses of 15-20% compared to the optimum.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
question: 'Does this also work for solar water heaters?',
|
|
28
|
+
answer:
|
|
29
|
+
'Yes. The tilt formulas are identical for thermal collectors and photovoltaic panels, since both depend on the same geometric principle.',
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const howToData = [
|
|
34
|
+
{
|
|
35
|
+
name: 'Enter your latitude',
|
|
36
|
+
text: 'Type your latitude in decimal degrees, or use the geolocation button to detect it automatically.',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'Read the optimal angle',
|
|
40
|
+
text: 'The optimal annual angle is the main value for fixed installations. Use it as a reference when briefing your installer.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'Adjust by season (optional)',
|
|
44
|
+
text: 'If your mounting allows it, use the winter and summer angles to maximise production in each season.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'Verify orientation',
|
|
48
|
+
text: 'Check the hemisphere indicator: your panels should face South if you are in the Northern Hemisphere, and North if in the Southern Hemisphere.',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
53
|
+
'@context': 'https://schema.org',
|
|
54
|
+
'@type': 'FAQPage',
|
|
55
|
+
mainEntity: faqData.map((item) => ({
|
|
56
|
+
'@type': 'Question',
|
|
57
|
+
name: item.question,
|
|
58
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
59
|
+
})),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const howToSchema: WithContext<HowTo> = {
|
|
63
|
+
'@context': 'https://schema.org',
|
|
64
|
+
'@type': 'HowTo',
|
|
65
|
+
name: title,
|
|
66
|
+
description,
|
|
67
|
+
step: howToData.map((step) => ({
|
|
68
|
+
'@type': 'HowToStep',
|
|
69
|
+
name: step.name,
|
|
70
|
+
text: step.text,
|
|
71
|
+
})),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
75
|
+
'@context': 'https://schema.org',
|
|
76
|
+
'@type': 'SoftwareApplication',
|
|
77
|
+
name: title,
|
|
78
|
+
description,
|
|
79
|
+
applicationCategory: 'UtilityApplication',
|
|
80
|
+
operatingSystem: 'All',
|
|
81
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
|
|
82
|
+
inLanguage: 'en',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const content: ToolLocaleContent<SolarCalculatorUI> = {
|
|
86
|
+
slug,
|
|
87
|
+
title,
|
|
88
|
+
description,
|
|
89
|
+
faqTitle: 'Frequently Asked Questions',
|
|
90
|
+
faq: faqData,
|
|
91
|
+
bibliographyTitle: 'Bibliography',
|
|
92
|
+
bibliography: [
|
|
93
|
+
{ name: 'NREL PVWatts Calculator', url: 'https://pvwatts.nrel.gov/' },
|
|
94
|
+
{ name: 'PVGIS — European Commission Solar Tool', url: 'https://re.jrc.ec.europa.eu/pvgis/' },
|
|
95
|
+
{ name: 'SEIA — Solar Energy Industry Research Data', url: 'https://www.seia.org/solar-industry-research-data' },
|
|
96
|
+
],
|
|
97
|
+
howTo: howToData,
|
|
98
|
+
schemas: [faqSchema, howToSchema, appSchema],
|
|
99
|
+
seo: [
|
|
100
|
+
{
|
|
101
|
+
type: 'title',
|
|
102
|
+
text: 'The Science of Solar Tilt',
|
|
103
|
+
level: 2,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
type: 'paragraph',
|
|
107
|
+
html: 'The difference between a zero-euro electricity bill and an underperforming investment lies, literally, in the angles. <strong>Solar radiation</strong> is not uniform — it is a dynamic flow that changes with the hour, the day, and the season. Understanding celestial geometry is the first step to turning your roof into a high-performance power plant.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
type: 'stats',
|
|
111
|
+
items: [
|
|
112
|
+
{ value: 'Lat × 0.87', label: 'General Formula', icon: 'mdi:angle-acute' },
|
|
113
|
+
{ value: 'Lat × 0.76 + 3.1', label: 'Latitudes 25°–50°', icon: 'mdi:map-marker' },
|
|
114
|
+
{ value: '± 15°', label: 'Seasonal Adjustment', icon: 'mdi:calendar-sync' },
|
|
115
|
+
],
|
|
116
|
+
columns: 3,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
type: 'comparative',
|
|
120
|
+
items: [
|
|
121
|
+
{
|
|
122
|
+
title: 'The Perpendicularity Principle',
|
|
123
|
+
description: 'A solar panel works like a net catching photons. It is most effective when facing the flow directly, at 90° to the solar rays. Any deviation reduces the effective capture area.',
|
|
124
|
+
icon: 'mdi:solar-panel',
|
|
125
|
+
points: ['Maximises the effective capture surface', 'The basis of all tilt calculations'],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
title: 'Why Latitude Matters',
|
|
129
|
+
description: 'Your location on the globe dictates the maximum height of the sun. The further from the equator, the lower the sun travels and the more vertical the panel needs to be to intercept it.',
|
|
130
|
+
icon: 'mdi:earth',
|
|
131
|
+
points: ['Higher latitude means greater tilt needed', 'Near the equator, panels are nearly flat'],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
columns: 2,
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: 'title',
|
|
138
|
+
text: 'Seasonal Strategies',
|
|
139
|
+
level: 3,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
type: 'paragraph',
|
|
143
|
+
html: '<strong>Summer:</strong> The sun reaches its zenith. Panels should be nearly flat — subtract ~15° from your latitude. Take advantage of long days and intense direct radiation. <strong>Winter:</strong> The sun travels low near the horizon. Tilt panels steeper by adding ~15° to your latitude. A steeper angle also helps snow slide off without blocking the cells.',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: 'diagnostic',
|
|
147
|
+
variant: 'info',
|
|
148
|
+
title: 'Fixed Installation or Seasonal Adjustment?',
|
|
149
|
+
icon: 'mdi:wrench',
|
|
150
|
+
badge: 'Pro Tip',
|
|
151
|
+
html: '<p><strong>Fixed (standard):</strong> Set to the optimal annual angle. Less maintenance and better wind resistance, with only a 5-10% loss compared to solar tracking. <strong>Seasonal adjustment (pro):</strong> Change the angle 2-4 times a year to gain up to +15% performance during winter months.</p>',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: 'summary',
|
|
155
|
+
title: 'Keys to an Efficient Installation',
|
|
156
|
+
items: [
|
|
157
|
+
'The optimal annual angle is your main reference for fixed installations.',
|
|
158
|
+
'Losses from deviations of ±5° are less than 3% of annual production.',
|
|
159
|
+
'Prioritise avoiding partial shade over finding the exact angle.',
|
|
160
|
+
'In the Northern Hemisphere, always orient panels to face South.',
|
|
161
|
+
'Seasonal adjustment 2-4 times a year can improve winter performance by up to +15%.',
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
ui: {
|
|
166
|
+
labelLatitude: 'Geographic Latitude',
|
|
167
|
+
btnLocate: 'Detect my location',
|
|
168
|
+
labelOptimal: 'Optimal Annual Angle',
|
|
169
|
+
labelEfficiency: 'Maximum Efficiency',
|
|
170
|
+
labelWinter: 'Winter',
|
|
171
|
+
labelSummer: 'Summer',
|
|
172
|
+
hemisphereNorth: 'Northern Hemisphere — Face SOUTH',
|
|
173
|
+
hemisphereSouth: 'Southern Hemisphere — Face NORTH',
|
|
174
|
+
geoNotAvailable: 'Geolocation is not available in this browser.',
|
|
175
|
+
},
|
|
176
|
+
};
|