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.
Files changed (89) hide show
  1. zero2geospatial3d/__init__.py +105 -0
  2. zero2geospatial3d/acoustics.py +284 -0
  3. zero2geospatial3d/classify.py +379 -0
  4. zero2geospatial3d/cli.py +367 -0
  5. zero2geospatial3d/comfort.py +687 -0
  6. zero2geospatial3d/data/epw_stations.json.gz +0 -0
  7. zero2geospatial3d/daylight.py +337 -0
  8. zero2geospatial3d/dispersion.py +249 -0
  9. zero2geospatial3d/epw.py +162 -0
  10. zero2geospatial3d/geo.py +138 -0
  11. zero2geospatial3d/grid.py +119 -0
  12. zero2geospatial3d/microclimate.py +388 -0
  13. zero2geospatial3d/morphology.py +283 -0
  14. zero2geospatial3d/nsga2.py +315 -0
  15. zero2geospatial3d/osm.py +450 -0
  16. zero2geospatial3d/osm_tags.py +180 -0
  17. zero2geospatial3d/py.typed +0 -0
  18. zero2geospatial3d/results.py +231 -0
  19. zero2geospatial3d/road_sources.py +141 -0
  20. zero2geospatial3d/scene.py +555 -0
  21. zero2geospatial3d/server.py +332 -0
  22. zero2geospatial3d/shading.py +443 -0
  23. zero2geospatial3d/solar.py +207 -0
  24. zero2geospatial3d/terrain.py +610 -0
  25. zero2geospatial3d/urban_form.py +326 -0
  26. zero2geospatial3d/walkability.py +380 -0
  27. zero2geospatial3d/weather.py +373 -0
  28. zero2geospatial3d/web/index.html +50 -0
  29. zero2geospatial3d/web/src/analysis/climate.js +278 -0
  30. zero2geospatial3d/web/src/analysis/compliance.js +345 -0
  31. zero2geospatial3d/web/src/analysis/gpu.js +431 -0
  32. zero2geospatial3d/web/src/analysis/optimize.js +208 -0
  33. zero2geospatial3d/web/src/analysis/runners.js +1644 -0
  34. zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
  35. zero2geospatial3d/web/src/app.js +630 -0
  36. zero2geospatial3d/web/src/core/acoustics.js +341 -0
  37. zero2geospatial3d/web/src/core/classify.js +158 -0
  38. zero2geospatial3d/web/src/core/comfort.js +686 -0
  39. zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
  40. zero2geospatial3d/web/src/core/dispersion.js +264 -0
  41. zero2geospatial3d/web/src/core/microclimate.js +372 -0
  42. zero2geospatial3d/web/src/core/morphology.js +153 -0
  43. zero2geospatial3d/web/src/core/nsga2.js +181 -0
  44. zero2geospatial3d/web/src/core/party_walls.js +60 -0
  45. zero2geospatial3d/web/src/core/road_sources.js +127 -0
  46. zero2geospatial3d/web/src/core/solar.js +112 -0
  47. zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
  48. zero2geospatial3d/web/src/core/terrain_math.js +279 -0
  49. zero2geospatial3d/web/src/core/urban_form.js +405 -0
  50. zero2geospatial3d/web/src/core/walkability.js +316 -0
  51. zero2geospatial3d/web/src/core/weather.js +225 -0
  52. zero2geospatial3d/web/src/core/wind.js +131 -0
  53. zero2geospatial3d/web/src/export/check_pdf.js +74 -0
  54. zero2geospatial3d/web/src/export/exporters.js +556 -0
  55. zero2geospatial3d/web/src/export/pdf.js +149 -0
  56. zero2geospatial3d/web/src/export/snapshot.js +234 -0
  57. zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
  58. zero2geospatial3d/web/src/main.js +75 -0
  59. zero2geospatial3d/web/src/scene/objects.js +645 -0
  60. zero2geospatial3d/web/src/scene/overlay.js +145 -0
  61. zero2geospatial3d/web/src/scene/terrain.js +546 -0
  62. zero2geospatial3d/web/src/scene/viewer.js +275 -0
  63. zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
  64. zero2geospatial3d/web/src/style.css +225 -0
  65. zero2geospatial3d/web/src/ui/building_report.js +404 -0
  66. zero2geospatial3d/web/src/ui/charts.js +441 -0
  67. zero2geospatial3d/web/src/ui/colorize.js +101 -0
  68. zero2geospatial3d/web/src/ui/compare.js +362 -0
  69. zero2geospatial3d/web/src/ui/dom.js +104 -0
  70. zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
  71. zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
  72. zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
  73. zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
  74. zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
  75. zero2geospatial3d/web/src/ui/sunclock.js +45 -0
  76. zero2geospatial3d/web/src/ui/tools.js +197 -0
  77. zero2geospatial3d/web/src/ui/viewtools.js +173 -0
  78. zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
  79. zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
  80. zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
  81. zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
  82. zero2geospatial3d/wind.py +178 -0
  83. zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
  84. zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
  85. zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
  86. zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  87. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
  89. 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('')}`; }