zero2geospatial3d-sdk 0.1.0__py3-none-any.whl
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.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Small canvas charts: terrain profile, wind rose, monthly climate bars,
|
|
3
|
+
// Pareto scatter, diurnal microclimate and polar sun-path diagrams.
|
|
4
|
+
// Drawn at device resolution; colours follow the CSS theme.
|
|
5
|
+
|
|
6
|
+
import { solarPosition as defSolarPos } from '../core/solar.js';
|
|
7
|
+
import { typicalDoy as defTypicalDoy } from '../core/weather.js';
|
|
8
|
+
|
|
9
|
+
function setup(canvas, height) {
|
|
10
|
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
11
|
+
const w = canvas.clientWidth || 300;
|
|
12
|
+
canvas.width = w * dpr;
|
|
13
|
+
canvas.height = height * dpr;
|
|
14
|
+
canvas.style.height = `${height}px`;
|
|
15
|
+
const ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'));
|
|
16
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
17
|
+
ctx.clearRect(0, 0, w, height);
|
|
18
|
+
ctx.font = '11px system-ui, sans-serif';
|
|
19
|
+
return { ctx, w, h: height };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function niceStep(span, target = 5) {
|
|
23
|
+
const raw = span / target;
|
|
24
|
+
const mag = Math.pow(10, Math.floor(Math.log10(raw || 1)));
|
|
25
|
+
const n = raw / mag;
|
|
26
|
+
return (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * mag;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Terrain profile with building silhouettes and an optional water level.
|
|
31
|
+
* data: { points: [{ d, z, top? }], water?: number, vertical?: number }
|
|
32
|
+
*/
|
|
33
|
+
export function drawProfile(canvas, data, height = 190) {
|
|
34
|
+
const { ctx, w, h } = setup(canvas, height);
|
|
35
|
+
const pts = data.points;
|
|
36
|
+
if (!pts.length) return;
|
|
37
|
+
const pad = { l: 46, r: 10, t: 10, b: 24 };
|
|
38
|
+
const dMax = pts[pts.length - 1].d || 1;
|
|
39
|
+
let zMin = Infinity, zMax = -Infinity;
|
|
40
|
+
for (const p of pts) {
|
|
41
|
+
if (!Number.isFinite(p.z)) continue; // no DEM data there: a gap in the line
|
|
42
|
+
zMin = Math.min(zMin, p.z); zMax = Math.max(zMax, Number.isFinite(p.top) ? p.top : p.z);
|
|
43
|
+
}
|
|
44
|
+
if (Number.isFinite(data.water)) { zMin = Math.min(zMin, data.water); zMax = Math.max(zMax, data.water); }
|
|
45
|
+
if (!(zMin <= zMax)) { zMin = 0; zMax = 1; }
|
|
46
|
+
const span = Math.max(2, zMax - zMin);
|
|
47
|
+
zMin -= span * 0.08; zMax += span * 0.08;
|
|
48
|
+
const X = (d) => pad.l + ((w - pad.l - pad.r) * d) / dMax;
|
|
49
|
+
const Y = (z) => pad.t + ((h - pad.t - pad.b) * (zMax - z)) / (zMax - zMin);
|
|
50
|
+
ctx.strokeStyle = 'rgba(120,130,140,0.25)';
|
|
51
|
+
ctx.fillStyle = '#6b7380';
|
|
52
|
+
const zs = niceStep(zMax - zMin, 4);
|
|
53
|
+
for (let z = Math.ceil(zMin / zs) * zs; z <= zMax; z += zs) {
|
|
54
|
+
ctx.beginPath(); ctx.moveTo(pad.l, Y(z)); ctx.lineTo(w - pad.r, Y(z)); ctx.stroke();
|
|
55
|
+
ctx.fillText(`${Math.round(z)} m`, 4, Y(z) + 4);
|
|
56
|
+
}
|
|
57
|
+
const ds = niceStep(dMax, 6);
|
|
58
|
+
for (let d = 0; d <= dMax + 1e-6; d += ds) ctx.fillText(`${Math.round(d)}`, X(d) - 8, h - 6);
|
|
59
|
+
// Buildings.
|
|
60
|
+
ctx.fillStyle = 'rgba(150,140,128,0.9)';
|
|
61
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
62
|
+
const p = pts[i];
|
|
63
|
+
if (!Number.isFinite(p.top) || p.top <= p.z + 0.2) continue;
|
|
64
|
+
ctx.fillRect(X(p.d), Y(p.top), Math.max(1, X(pts[i + 1].d) - X(p.d)) + 0.5, Y(p.z) - Y(p.top));
|
|
65
|
+
}
|
|
66
|
+
// Ground, one piece per run of points with DEM data.
|
|
67
|
+
const runs = [];
|
|
68
|
+
for (const p of pts) {
|
|
69
|
+
if (!Number.isFinite(p.z)) { if (runs.length && runs[runs.length - 1].length) runs.push([]); continue; }
|
|
70
|
+
if (!runs.length) runs.push([]);
|
|
71
|
+
runs[runs.length - 1].push(p);
|
|
72
|
+
}
|
|
73
|
+
ctx.beginPath();
|
|
74
|
+
for (const run of runs) {
|
|
75
|
+
if (!run.length) continue;
|
|
76
|
+
ctx.moveTo(X(run[0].d), h - pad.b);
|
|
77
|
+
for (const p of run) ctx.lineTo(X(p.d), Y(p.z));
|
|
78
|
+
ctx.lineTo(X(run[run.length - 1].d), h - pad.b);
|
|
79
|
+
ctx.closePath();
|
|
80
|
+
}
|
|
81
|
+
ctx.fillStyle = 'rgba(122,154,96,0.55)';
|
|
82
|
+
ctx.fill();
|
|
83
|
+
ctx.beginPath();
|
|
84
|
+
for (const run of runs) run.forEach((p, i) => (i ? ctx.lineTo(X(p.d), Y(p.z)) : ctx.moveTo(X(p.d), Y(p.z))));
|
|
85
|
+
ctx.strokeStyle = '#3f5e2a';
|
|
86
|
+
ctx.lineWidth = 1.5;
|
|
87
|
+
ctx.stroke();
|
|
88
|
+
if (Number.isFinite(data.water)) {
|
|
89
|
+
ctx.strokeStyle = '#2f7fc1';
|
|
90
|
+
ctx.setLineDash([5, 3]);
|
|
91
|
+
ctx.beginPath(); ctx.moveTo(pad.l, Y(data.water)); ctx.lineTo(w - pad.r, Y(data.water)); ctx.stroke();
|
|
92
|
+
ctx.setLineDash([]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Wind rose from weather.windRose(). */
|
|
97
|
+
export function drawWindRose(canvas, rose, height = 220) {
|
|
98
|
+
const { ctx, w, h } = setup(canvas, height);
|
|
99
|
+
const cx = w / 2, cy = h / 2, R = Math.min(w, h) / 2 - 22;
|
|
100
|
+
const colors = ['#c6dbef', '#9ecae1', '#6baed6', '#3182bd', '#08519c', '#08306b'];
|
|
101
|
+
const maxF = Math.max(...rose.sectorFreq, 0.01);
|
|
102
|
+
ctx.strokeStyle = 'rgba(120,130,140,0.3)';
|
|
103
|
+
for (const f of [0.25, 0.5, 0.75, 1]) { ctx.beginPath(); ctx.arc(cx, cy, R * f, 0, 2 * Math.PI); ctx.stroke(); }
|
|
104
|
+
const width = (2 * Math.PI) / rose.sectors;
|
|
105
|
+
for (let s = 0; s < rose.sectors; s++) {
|
|
106
|
+
let acc = 0;
|
|
107
|
+
const bearing = (s * 360) / rose.sectors;
|
|
108
|
+
const a0 = ((bearing - 90) * Math.PI) / 180 - width / 2 + 0.02;
|
|
109
|
+
const a1 = a0 + width - 0.04;
|
|
110
|
+
for (let c = 0; c < rose.classes.length; c++) {
|
|
111
|
+
const f = rose.freq[s][c];
|
|
112
|
+
if (!f) continue;
|
|
113
|
+
const r0 = (R * acc) / maxF;
|
|
114
|
+
acc += f;
|
|
115
|
+
const r1 = (R * acc) / maxF;
|
|
116
|
+
ctx.beginPath();
|
|
117
|
+
ctx.arc(cx, cy, r1, a0, a1);
|
|
118
|
+
ctx.arc(cx, cy, r0, a1, a0, true);
|
|
119
|
+
ctx.closePath();
|
|
120
|
+
ctx.fillStyle = colors[c % colors.length];
|
|
121
|
+
ctx.fill();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
ctx.fillStyle = '#4a5260';
|
|
125
|
+
for (const [t, a] of [['N', 0], ['E', 90], ['S', 180], ['W', 270]]) {
|
|
126
|
+
const r = R + 12;
|
|
127
|
+
const ang = ((a - 90) * Math.PI) / 180;
|
|
128
|
+
ctx.fillText(t, cx + Math.cos(ang) * r - 4, cy + Math.sin(ang) * r + 4);
|
|
129
|
+
}
|
|
130
|
+
ctx.fillText(`${(maxF * 100).toFixed(0)} %`, cx + 3, cy - R + 11);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Monthly bars (e.g. radiation) with a line (e.g. temperature). */
|
|
134
|
+
export function drawMonthly(canvas, months, height = 160) {
|
|
135
|
+
const { ctx, w, h } = setup(canvas, height);
|
|
136
|
+
const pad = { l: 34, r: 34, t: 10, b: 20 };
|
|
137
|
+
const bw = (w - pad.l - pad.r) / 12;
|
|
138
|
+
const gMax = Math.max(...months.map((m) => m.ghiKwh), 1);
|
|
139
|
+
const tMin = Math.min(...months.map((m) => m.tMin), 0);
|
|
140
|
+
const tMax = Math.max(...months.map((m) => m.tMax), 1);
|
|
141
|
+
const Yg = (v) => h - pad.b - ((h - pad.t - pad.b) * v) / gMax;
|
|
142
|
+
const Yt = (v) => h - pad.b - ((h - pad.t - pad.b) * (v - tMin)) / (tMax - tMin || 1);
|
|
143
|
+
months.forEach((m, i) => {
|
|
144
|
+
ctx.fillStyle = '#f2b134';
|
|
145
|
+
ctx.fillRect(pad.l + i * bw + 3, Yg(m.ghiKwh), bw - 6, h - pad.b - Yg(m.ghiKwh));
|
|
146
|
+
ctx.fillStyle = '#6b7380';
|
|
147
|
+
ctx.fillText(m.name[0], pad.l + i * bw + bw / 2 - 3, h - 6);
|
|
148
|
+
});
|
|
149
|
+
ctx.strokeStyle = '#d6452e';
|
|
150
|
+
ctx.lineWidth = 1.6;
|
|
151
|
+
ctx.beginPath();
|
|
152
|
+
months.forEach((m, i) => { const x = pad.l + i * bw + bw / 2; if (i) ctx.lineTo(x, Yt(m.tMean)); else ctx.moveTo(x, Yt(m.tMean)); });
|
|
153
|
+
ctx.stroke();
|
|
154
|
+
ctx.fillStyle = '#b07a12';
|
|
155
|
+
ctx.fillText(`${Math.round(gMax)}`, 2, pad.t + 8);
|
|
156
|
+
ctx.fillText('kWh', 2, pad.t + 20);
|
|
157
|
+
ctx.fillStyle = '#d6452e';
|
|
158
|
+
ctx.fillText(`${Math.round(tMax)}°`, w - pad.r + 4, pad.t + 8);
|
|
159
|
+
ctx.fillText(`${Math.round(tMin)}°`, w - pad.r + 4, h - pad.b);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Scatter with clickable points. points: [{ x, y, front, id }]; returns a
|
|
164
|
+
* function that maps a click to the nearest point id.
|
|
165
|
+
*/
|
|
166
|
+
export function drawScatter(canvas, points, { xLabel = '', yLabel = '', selected = null } = {}, height = 200) {
|
|
167
|
+
const { ctx, w, h } = setup(canvas, height);
|
|
168
|
+
const pad = { l: 46, r: 10, t: 10, b: 30 };
|
|
169
|
+
if (!points.length) return () => null;
|
|
170
|
+
let x0 = Math.min(...points.map((p) => p.x)), x1 = Math.max(...points.map((p) => p.x));
|
|
171
|
+
let y0 = Math.min(...points.map((p) => p.y)), y1 = Math.max(...points.map((p) => p.y));
|
|
172
|
+
if (x1 - x0 < 1e-9) { x0 -= 1; x1 += 1; }
|
|
173
|
+
if (y1 - y0 < 1e-9) { y0 -= 1; y1 += 1; }
|
|
174
|
+
const X = (v) => pad.l + ((w - pad.l - pad.r) * (v - x0)) / (x1 - x0);
|
|
175
|
+
const Y = (v) => h - pad.b - ((h - pad.t - pad.b) * (v - y0)) / (y1 - y0);
|
|
176
|
+
ctx.strokeStyle = 'rgba(120,130,140,0.35)';
|
|
177
|
+
ctx.strokeRect(pad.l, pad.t, w - pad.l - pad.r, h - pad.t - pad.b);
|
|
178
|
+
ctx.fillStyle = '#6b7380';
|
|
179
|
+
ctx.fillText(xLabel, pad.l, h - 6);
|
|
180
|
+
ctx.save(); ctx.translate(10, h - pad.b); ctx.rotate(-Math.PI / 2); ctx.fillText(yLabel, 0, 0); ctx.restore();
|
|
181
|
+
ctx.fillText(x0.toPrecision(3), pad.l, h - pad.b + 12);
|
|
182
|
+
ctx.fillText(x1.toPrecision(3), w - pad.r - 30, h - pad.b + 12);
|
|
183
|
+
ctx.fillText(y1.toPrecision(3), 14, pad.t + 9);
|
|
184
|
+
for (const p of points) {
|
|
185
|
+
ctx.beginPath();
|
|
186
|
+
if (p.current) {
|
|
187
|
+
ctx.arc(X(p.x), Y(p.y), 6, 0, 2 * Math.PI);
|
|
188
|
+
ctx.strokeStyle = '#1d2430';
|
|
189
|
+
ctx.lineWidth = 2;
|
|
190
|
+
ctx.stroke();
|
|
191
|
+
ctx.fillStyle = '#1d2430';
|
|
192
|
+
ctx.fillText('now', X(p.x) + 8, Y(p.y) + 4);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
ctx.arc(X(p.x), Y(p.y), p.front ? 4.5 : p.infeasible ? 3 : 2.5, 0, 2 * Math.PI);
|
|
196
|
+
if (p.infeasible && p.id !== selected) {
|
|
197
|
+
// Breaks a constraint: an open ring.
|
|
198
|
+
ctx.strokeStyle = 'rgba(200,120,40,0.75)';
|
|
199
|
+
ctx.lineWidth = 1.2;
|
|
200
|
+
ctx.stroke();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
ctx.fillStyle = p.id === selected ? '#ff4f2e' : p.front ? '#1f6feb' : 'rgba(120,130,140,0.45)';
|
|
204
|
+
ctx.fill();
|
|
205
|
+
}
|
|
206
|
+
return (e) => {
|
|
207
|
+
const rect = canvas.getBoundingClientRect();
|
|
208
|
+
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
209
|
+
let best = null, bd = 144;
|
|
210
|
+
for (const p of points) {
|
|
211
|
+
const d = (X(p.x) - mx) ** 2 + (Y(p.y) - my) ** 2;
|
|
212
|
+
if (d < bd) { bd = d; best = p.id; }
|
|
213
|
+
}
|
|
214
|
+
return best;
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 24-hour diurnal microclimate and thermal comfort profile (Ta, Ts, UTCI, PET).
|
|
220
|
+
* @param {HTMLCanvasElement} canvas
|
|
221
|
+
* @param {Array<{ hour: number, airTemp: number, surfaceTemp: number, mrt?: number, utci: number, pet: number, sunlit: number }>} profile
|
|
222
|
+
* @param {number} [height]
|
|
223
|
+
*/
|
|
224
|
+
export function drawDiurnalChart(canvas, profile, height = 165) {
|
|
225
|
+
const { ctx, w, h } = setup(canvas, height);
|
|
226
|
+
if (!profile?.length) return;
|
|
227
|
+
const pad = { l: 36, r: 16, t: 26, b: 20 };
|
|
228
|
+
|
|
229
|
+
let tMin = Infinity;
|
|
230
|
+
let tMax = -Infinity;
|
|
231
|
+
for (const p of profile) {
|
|
232
|
+
for (const k of ['airTemp', 'surfaceTemp', 'utci', 'pet']) {
|
|
233
|
+
const v = p[k];
|
|
234
|
+
if (Number.isFinite(v)) {
|
|
235
|
+
tMin = Math.min(tMin, v);
|
|
236
|
+
tMax = Math.max(tMax, v);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (!Number.isFinite(tMin) || !Number.isFinite(tMax)) return;
|
|
241
|
+
tMin = Math.floor(tMin - 2);
|
|
242
|
+
tMax = Math.ceil(tMax + 2);
|
|
243
|
+
const span = Math.max(8, tMax - tMin);
|
|
244
|
+
|
|
245
|
+
const X = (hour) => pad.l + ((w - pad.l - pad.r) * hour) / 23;
|
|
246
|
+
const Y = (temp) => h - pad.b - ((h - pad.t - pad.b) * (temp - tMin)) / span;
|
|
247
|
+
|
|
248
|
+
// Daytime shading (sunlit > 0)
|
|
249
|
+
ctx.fillStyle = 'rgba(255, 211, 107, 0.14)';
|
|
250
|
+
for (let i = 0; i < profile.length; i++) {
|
|
251
|
+
if (profile[i].sunlit > 0) {
|
|
252
|
+
const x0 = i === 0 ? pad.l : (X(i - 1) + X(i)) / 2;
|
|
253
|
+
const x1 = i === profile.length - 1 ? (w - pad.r) : (X(i) + X(i + 1)) / 2;
|
|
254
|
+
ctx.fillRect(x0, pad.t, Math.max(1, x1 - x0), h - pad.t - pad.b);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Horizontal grid lines and °C labels
|
|
259
|
+
const ts = niceStep(span, 4);
|
|
260
|
+
ctx.strokeStyle = 'rgba(120, 130, 140, 0.2)';
|
|
261
|
+
ctx.fillStyle = '#6b7380';
|
|
262
|
+
for (let t = Math.ceil(tMin / ts) * ts; t <= tMax; t += ts) {
|
|
263
|
+
const y = Y(t);
|
|
264
|
+
if (y < pad.t - 2 || y > h - pad.b + 2) continue;
|
|
265
|
+
ctx.beginPath();
|
|
266
|
+
ctx.moveTo(pad.l, y);
|
|
267
|
+
ctx.lineTo(w - pad.r, y);
|
|
268
|
+
ctx.stroke();
|
|
269
|
+
ctx.fillText(`${Math.round(t)}°`, 4, y + 4);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Hour labels: 00, 06, 12, 18, 23
|
|
273
|
+
ctx.fillStyle = '#6b7380';
|
|
274
|
+
for (const hr of [0, 6, 12, 18, 23]) {
|
|
275
|
+
const label = String(hr).padStart(2, '0');
|
|
276
|
+
ctx.fillText(label, X(hr) - 6, h - 6);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Smooth line series
|
|
280
|
+
const drawSeries = (key, color, dashed = false, lineWidth = 1.4) => {
|
|
281
|
+
ctx.beginPath();
|
|
282
|
+
ctx.strokeStyle = color;
|
|
283
|
+
ctx.lineWidth = lineWidth;
|
|
284
|
+
if (dashed) ctx.setLineDash([3, 2]); else ctx.setLineDash([]);
|
|
285
|
+
let started = false;
|
|
286
|
+
for (let i = 0; i < profile.length; i++) {
|
|
287
|
+
const v = profile[i][key];
|
|
288
|
+
if (!Number.isFinite(v)) continue;
|
|
289
|
+
const x = X(profile[i].hour);
|
|
290
|
+
const y = Y(v);
|
|
291
|
+
if (!started) {
|
|
292
|
+
ctx.moveTo(x, y);
|
|
293
|
+
started = true;
|
|
294
|
+
} else {
|
|
295
|
+
const prev = profile[i - 1];
|
|
296
|
+
const prevX = X(prev.hour);
|
|
297
|
+
const prevY = Y(prev[key]);
|
|
298
|
+
const midX = (prevX + x) / 2;
|
|
299
|
+
ctx.bezierCurveTo(midX, prevY, midX, y, x, y);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
ctx.stroke();
|
|
303
|
+
ctx.setLineDash([]);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
drawSeries('airTemp', '#4a5260', true, 1.4);
|
|
307
|
+
drawSeries('surfaceTemp', '#d6452e', false, 1.4);
|
|
308
|
+
drawSeries('pet', '#2e9e5b', false, 1.4);
|
|
309
|
+
drawSeries('utci', '#1f6feb', false, 1.8);
|
|
310
|
+
|
|
311
|
+
// Colour legend across the top
|
|
312
|
+
const legends = [
|
|
313
|
+
{ label: 'Ta', color: '#4a5260', dashed: true },
|
|
314
|
+
{ label: 'Ts', color: '#d6452e', dashed: false },
|
|
315
|
+
{ label: 'UTCI', color: '#1f6feb', dashed: false },
|
|
316
|
+
{ label: 'PET', color: '#2e9e5b', dashed: false }
|
|
317
|
+
];
|
|
318
|
+
let legX = pad.l;
|
|
319
|
+
for (const leg of legends) {
|
|
320
|
+
ctx.strokeStyle = leg.color;
|
|
321
|
+
ctx.lineWidth = 1.8;
|
|
322
|
+
if (leg.dashed) ctx.setLineDash([3, 2]); else ctx.setLineDash([]);
|
|
323
|
+
ctx.beginPath();
|
|
324
|
+
ctx.moveTo(legX, 12);
|
|
325
|
+
ctx.lineTo(legX + 14, 12);
|
|
326
|
+
ctx.stroke();
|
|
327
|
+
ctx.setLineDash([]);
|
|
328
|
+
ctx.fillStyle = '#4a5260';
|
|
329
|
+
ctx.fillText(leg.label, legX + 18, 15);
|
|
330
|
+
legX += 50;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Polar stereographic sun-path diagram with altitude circles, cardinal ticks,
|
|
336
|
+
* seasonal paths, and current sun position.
|
|
337
|
+
* @param {HTMLCanvasElement} canvas
|
|
338
|
+
* @param {object} opts
|
|
339
|
+
* @param {number} opts.lat
|
|
340
|
+
* @param {number} opts.lon
|
|
341
|
+
* @param {number} [opts.utcOffset]
|
|
342
|
+
* @param {number} [opts.month]
|
|
343
|
+
* @param {number} [opts.day]
|
|
344
|
+
* @param {number} [opts.hour]
|
|
345
|
+
* @param {Function} [opts.solarPosition]
|
|
346
|
+
* @param {Function} [opts.typicalDoy]
|
|
347
|
+
* @param {number} [height]
|
|
348
|
+
*/
|
|
349
|
+
export function drawSunPathDiagram(canvas, { lat, lon, utcOffset = 0, month = 6, day = 21, hour = 12, solarPosition = null, typicalDoy = null } = {}, height = 185) {
|
|
350
|
+
const { ctx, w, h } = setup(canvas, height);
|
|
351
|
+
const cx = w / 2;
|
|
352
|
+
const cy = h / 2 + 2;
|
|
353
|
+
const R = Math.min(w, h) / 2 - 20;
|
|
354
|
+
if (R <= 0) return;
|
|
355
|
+
|
|
356
|
+
const solPos = solarPosition || defSolarPos;
|
|
357
|
+
const doyCalc = typicalDoy || defTypicalDoy;
|
|
358
|
+
|
|
359
|
+
// Concentric altitude circles (0° horizon, 30°, 60°)
|
|
360
|
+
ctx.strokeStyle = 'rgba(120, 130, 140, 0.25)';
|
|
361
|
+
ctx.lineWidth = 1;
|
|
362
|
+
for (const r of [R * (1 / 3), R * (2 / 3), R]) {
|
|
363
|
+
ctx.beginPath();
|
|
364
|
+
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
|
|
365
|
+
ctx.stroke();
|
|
366
|
+
}
|
|
367
|
+
ctx.fillStyle = '#8c95a3';
|
|
368
|
+
ctx.fillText('60°', cx + 3, cy - R * (1 / 3) + 4);
|
|
369
|
+
ctx.fillText('30°', cx + 3, cy - R * (2 / 3) + 4);
|
|
370
|
+
|
|
371
|
+
// Cardinal ticks and labels
|
|
372
|
+
ctx.fillStyle = '#4a5260';
|
|
373
|
+
ctx.strokeStyle = 'rgba(120, 130, 140, 0.5)';
|
|
374
|
+
for (const [t, a] of [['N', 0], ['E', 90], ['S', 180], ['W', 270]]) {
|
|
375
|
+
const ang = (a * Math.PI) / 180;
|
|
376
|
+
const sin = Math.sin(ang);
|
|
377
|
+
const cos = Math.cos(ang);
|
|
378
|
+
ctx.beginPath();
|
|
379
|
+
ctx.moveTo(cx + sin * (R - 3), cy - cos * (R - 3));
|
|
380
|
+
ctx.lineTo(cx + sin * (R + 3), cy - cos * (R + 3));
|
|
381
|
+
ctx.stroke();
|
|
382
|
+
ctx.fillText(t, cx + sin * (R + 12) - 4, cy - cos * (R + 12) + 4);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Polar projection: elevation > 0 (radians), azimuth (radians)
|
|
386
|
+
const polarProject = (elevation, azimuth) => {
|
|
387
|
+
if (elevation <= 0) return null;
|
|
388
|
+
const r = R * (1 - elevation / (Math.PI / 2));
|
|
389
|
+
return {
|
|
390
|
+
x: cx + r * Math.sin(azimuth),
|
|
391
|
+
y: cy - r * Math.cos(azimuth)
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const drawDayPath = (m, d, color, width = 1.3, dashed = false) => {
|
|
396
|
+
const doy = doyCalc(m, d);
|
|
397
|
+
ctx.beginPath();
|
|
398
|
+
ctx.strokeStyle = color;
|
|
399
|
+
ctx.lineWidth = width;
|
|
400
|
+
if (dashed) ctx.setLineDash([3, 2]); else ctx.setLineDash([]);
|
|
401
|
+
let drawing = false;
|
|
402
|
+
for (let step = 0; step <= 24 * 12; step++) {
|
|
403
|
+
const t = step / 12;
|
|
404
|
+
const p = solPos(doy, t, lat, lon, utcOffset);
|
|
405
|
+
const pt = polarProject(p.elevation, p.azimuth);
|
|
406
|
+
if (pt) {
|
|
407
|
+
if (!drawing) { ctx.moveTo(pt.x, pt.y); drawing = true; }
|
|
408
|
+
else { ctx.lineTo(pt.x, pt.y); }
|
|
409
|
+
} else {
|
|
410
|
+
drawing = false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
ctx.stroke();
|
|
414
|
+
ctx.setLineDash([]);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// Seasonal paths
|
|
418
|
+
drawDayPath(6, 21, '#e08b2c', 1.4); // Summer solstice
|
|
419
|
+
drawDayPath(3, 21, '#4a9e5c', 1.4); // Equinox
|
|
420
|
+
drawDayPath(12, 21, '#3182bd', 1.4); // Winter solstice
|
|
421
|
+
|
|
422
|
+
// Current date path
|
|
423
|
+
const isSolsticeOrEquinox = (month === 6 && day === 21) || (month === 3 && day === 21) || (month === 12 && day === 21);
|
|
424
|
+
if (!isSolsticeOrEquinox) {
|
|
425
|
+
drawDayPath(month, day, '#1f6feb', 1.8, true);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Sun dot at current hour
|
|
429
|
+
const curDoy = doyCalc(month, day);
|
|
430
|
+
const curPos = solPos(curDoy, hour, lat, lon, utcOffset);
|
|
431
|
+
const sunPt = polarProject(curPos.elevation, curPos.azimuth);
|
|
432
|
+
if (sunPt) {
|
|
433
|
+
ctx.beginPath();
|
|
434
|
+
ctx.arc(sunPt.x, sunPt.y, 4.5, 0, 2 * Math.PI);
|
|
435
|
+
ctx.fillStyle = '#ff4f2e';
|
|
436
|
+
ctx.fill();
|
|
437
|
+
ctx.strokeStyle = '#ffffff';
|
|
438
|
+
ctx.lineWidth = 1.5;
|
|
439
|
+
ctx.stroke();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Colour and height rules for object layers.
|
|
3
|
+
//
|
|
4
|
+
// rule = { mode: 'source'|'single'|'graduated'|'categorized', field, method,
|
|
5
|
+
// classes, ramp, color, filter: { on, field, min, max } }
|
|
6
|
+
import { breaksFor, classOf, categories, rampStops, rampColor, hexToRgb, QUALITATIVE, fmt } from '../core/classify.js';
|
|
7
|
+
|
|
8
|
+
const DIM = [0.86, 0.86, 0.86];
|
|
9
|
+
const HIGHLIGHT = [1.0, 0.8, 0.1];
|
|
10
|
+
|
|
11
|
+
export function defaultRule() {
|
|
12
|
+
return { mode: 'source', field: '', method: 'jenks', classes: 6, ramp: 'viridis', color: '#d9d4cc',
|
|
13
|
+
filter: { on: false, field: '', min: 0, max: 0, hide: false } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Compute per-feature colours and a legend for a layer.
|
|
18
|
+
* @returns {{ legend: { title: string, items: { color: string, label: string }[] } | null }}
|
|
19
|
+
*/
|
|
20
|
+
export function applyRule(layer, rule, highlight = null) {
|
|
21
|
+
const n = layer.count;
|
|
22
|
+
const out = layer.colors;
|
|
23
|
+
let legend = null;
|
|
24
|
+
if (rule.mode === 'single') {
|
|
25
|
+
const c = hexToRgb(rule.color);
|
|
26
|
+
for (let i = 0; i < n; i++) out.set(c, i * 3);
|
|
27
|
+
legend = { title: layer.entry.name, items: [{ color: rule.color, label: 'All features' }] };
|
|
28
|
+
} else if (rule.mode === 'graduated' && rule.field) {
|
|
29
|
+
const values = layer.values(rule.field);
|
|
30
|
+
const breaks = breaksFor(rule.method, values, rule.classes);
|
|
31
|
+
const stops = rampStops(rule.ramp);
|
|
32
|
+
const k = Math.max(1, breaks.length - 1);
|
|
33
|
+
const classColors = Array.from({ length: k }, (_, i) => rampColor(stops, k === 1 ? 0.5 : i / (k - 1), [0, 0, 0]));
|
|
34
|
+
for (let i = 0; i < n; i++) {
|
|
35
|
+
const cl = classOf(values[i], breaks);
|
|
36
|
+
out.set(cl < 0 ? DIM : classColors[cl], i * 3);
|
|
37
|
+
}
|
|
38
|
+
const unit = unitOf(rule.field);
|
|
39
|
+
legend = { title: `${layer.entry.name}: ${rule.field}`, items: classColors.map((c, i) => ({
|
|
40
|
+
color: rgbHex(c), label: `${fmt(breaks[i], '')} – ${fmt(breaks[i + 1], unit)}` })) };
|
|
41
|
+
} else if (rule.mode === 'categorized' && rule.field) {
|
|
42
|
+
const values = layer.values(rule.field);
|
|
43
|
+
const cats = categories(values, 18);
|
|
44
|
+
const map = new Map(cats.map((c, i) => [c.value, hexToRgb(QUALITATIVE[i % QUALITATIVE.length])]));
|
|
45
|
+
for (let i = 0; i < n; i++) {
|
|
46
|
+
const key = values[i] === null || values[i] === undefined || values[i] === '' ? '(empty)' : String(values[i]);
|
|
47
|
+
out.set(map.get(key) || DIM, i * 3);
|
|
48
|
+
}
|
|
49
|
+
legend = { title: `${layer.entry.name}: ${rule.field}`, items: cats.map((c, i) => ({ color: QUALITATIVE[i % QUALITATIVE.length], label: `${c.value} (${c.count})` })) };
|
|
50
|
+
if (cats.length >= 18) legend.items.push({ color: rgbHex(DIM), label: 'Other values' });
|
|
51
|
+
} else {
|
|
52
|
+
out.set(layer.sourceColors);
|
|
53
|
+
}
|
|
54
|
+
const f = rule.filter;
|
|
55
|
+
if (f?.on && f.field) {
|
|
56
|
+
const values = layer.values(f.field);
|
|
57
|
+
for (let i = 0; i < n; i++) {
|
|
58
|
+
const v = Number(values[i]);
|
|
59
|
+
if (!(Number.isFinite(v) && v >= f.min && v <= f.max)) out.set(DIM, i * 3);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (highlight) for (const i of highlight) if (i >= 0 && i < n) out.set(HIGHLIGHT, i * 3);
|
|
63
|
+
layer.applyColors();
|
|
64
|
+
return { legend };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Numeric range of a field. */
|
|
68
|
+
export function fieldRange(layer, field) {
|
|
69
|
+
let lo = Infinity, hi = -Infinity;
|
|
70
|
+
for (const v of layer.values(field)) {
|
|
71
|
+
const x = Number(v);
|
|
72
|
+
if (v === null || v === '' || !Number.isFinite(x)) continue;
|
|
73
|
+
if (x < lo) lo = x;
|
|
74
|
+
if (x > hi) hi = x;
|
|
75
|
+
}
|
|
76
|
+
return Number.isFinite(lo) ? [lo, hi] : [0, 0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Heights from a rule: { mode: 'source'|'field'|'uniform', field, factor, value }.
|
|
81
|
+
*/
|
|
82
|
+
export function applyHeightRule(layer, rule) {
|
|
83
|
+
const n = layer.count;
|
|
84
|
+
if (rule.mode === 'field' && rule.field) {
|
|
85
|
+
const values = layer.values(rule.field);
|
|
86
|
+
for (let i = 0; i < n; i++) {
|
|
87
|
+
const v = Number(values[i]);
|
|
88
|
+
layer.heights[i] = Number.isFinite(v) && values[i] !== null ? Math.max(0, v * rule.factor) : 0;
|
|
89
|
+
}
|
|
90
|
+
} else if (rule.mode === 'uniform') {
|
|
91
|
+
layer.heights.fill(Math.max(0, rule.value));
|
|
92
|
+
} else {
|
|
93
|
+
layer.heights.set(layer.sourceHeights);
|
|
94
|
+
}
|
|
95
|
+
layer.applyHeights();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const UNITS = { sun_h_roof: 'h', sun_h_facade: 'h', shade_pct_roof: '%', roof_kwh_m2: 'kWh/m²', pv_kwh_yr: 'kWh', pv_area_m2: 'm²',
|
|
99
|
+
facade_kwh_m2: 'kWh/m²', facade_pv_kwh_yr: 'kWh', visible_roof_pct: '%', svf_roof: '', svf_facade: '', vsc_facade: '%', vsc_ground: '%', vsc27_share: '%', vsc_ratio_BA: '×' };
|
|
100
|
+
function unitOf(field) { return UNITS[field] || ''; }
|
|
101
|
+
function rgbHex(c) { return `#${c.map((x) => Math.round(Math.max(0, Math.min(1, x)) * 255).toString(16).padStart(2, '0')).join('')}`; }
|