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,1644 @@
1
+ // @ts-check
2
+ // The analyses. Each returns a Result:
3
+ // { kind, title, subtitle, unit, grid, values, display, facade?, fields, stats, notes }
4
+ // grid: { cols, rows, cell, minX, maxY } in local metres (x east, y north),
5
+ // values: Float32Array row-major from the north-west, NaN = no data.
6
+ // fields: per-feature values written onto layers ([{ layer, name, values }]).
7
+ import * as THREE from 'three';
8
+ import { GpuEngine, planTiles } from './gpu.js';
9
+ import { sunPath, skyHemisphere, solarPosition, sunVector } from '../core/solar.js';
10
+ import { annualSunBins, clearSkyYear, typicalDoy, conditionsAt, windRose } from '../core/weather.js';
11
+ import { meanRadiantTemperature, skyTemperature, utci, UTCI_CATEGORIES, pet, PET_CATEGORIES } from '../core/comfort.js';
12
+ import { SURFACE_MATERIALS, surfaceTemperature, canopyCoolingGrid } from '../core/microclimate.js';
13
+ import { ventilationEfficiency, VENTILATION_CLASSES, ventilationClass, pollutantDispersion, AIR_QUALITY_CLASSES, airQualityClass } from '../core/dispersion.js';
14
+ import { noisePropagation, buildingFacadeNoise, NOISE_CLASSES, noiseClass } from '../core/acoustics.js';
15
+ import { greenViewIndex, GVI_CLASSES, gviClass, solarEnvelope as calcSolarEnvelope } from '../core/urban_form.js';
16
+ import { windRatio, OPEN_PEDESTRIAN_FACTOR, lawsonClass } from '../core/wind.js';
17
+ import { blockIndicators, LCZ_LABELS, LCZ_COLORS } from '../core/morphology.js';
18
+ import { gridStats } from '../core/terrain_math.js';
19
+ import { splitKey, ExtrudedLayer, PointLayer, SurfaceLayer, LineLayer } from '../scene/objects.js';
20
+ import { climateAroundBuildings, surroundings } from './climate.js';
21
+ import { vscDirections, VSC_OPEN, VSC_TARGET } from '../core/daylight_rule.js';
22
+ import { coverIndex, coverAt, isOpen } from '../core/party_walls.js';
23
+ import { pedestrianWalkabilityIndex, findCoolRoute, WALKABILITY_CLASSES, walkabilityClass } from '../core/walkability.js';
24
+
25
+ const DEG = Math.PI / 180;
26
+
27
+ export class Analyst {
28
+ constructor(app) {
29
+ this.app = app;
30
+ this.engine = new GpuEngine(app.viewer.renderer);
31
+ this.cache = new Map(); // svf / geometry grids by area+cell
32
+ }
33
+
34
+ // ------------------------------------------------------------ geometry helpers
35
+ sceneBounds() {
36
+ const box = new THREE.Box3().setFromObject(this.app.viewer.world);
37
+ return { minY: Number.isFinite(box.min.y) ? box.min.y - 5 : -50, maxY: Number.isFinite(box.max.y) ? box.max.y + 5 : 200 };
38
+ }
39
+
40
+ occluderMargin(minElevationDeg = 8) {
41
+ const b = this.sceneBounds();
42
+ const rise = Math.max(20, b.maxY - b.minY);
43
+ return Math.max(80, Math.min(900, rise / Math.tan(minElevationDeg * DEG)));
44
+ }
45
+
46
+ defaultCell(area, perSide = 1024) {
47
+ const side = Math.max(area.maxX - area.minX, area.maxY - area.minY);
48
+ const raw = side / perSide;
49
+ return Math.max(1, Math.ceil(raw * 2) / 2);
50
+ }
51
+
52
+ // ------------------------------------------------------------ core runs
53
+ /** Height-field run over an area, tiled. */
54
+ async gridRun({ area, cell, directions = [], cosMode = 0, normal = [0, 1, 0], lift = 0.3, eye = null, maxDistance = 1000, withIds = false,
55
+ depthResolution = 4096, lock = true, onProgress = null }) {
56
+ // Terrain tiles in the area are drawn at the analysis resolution while it
57
+ // runs (lock: false when the caller holds the lock over many runs).
58
+ if (lock) this.app.terrain.lockForAnalysis(area, cell);
59
+ try {
60
+ return await this._gridRun({ area, cell, directions, cosMode, normal, lift, eye, maxDistance, withIds, depthResolution, onProgress });
61
+ } finally {
62
+ if (lock) this.app.terrain.lockForAnalysis(null);
63
+ }
64
+ }
65
+
66
+ async _gridRun({ area, cell, directions, cosMode, normal, lift, eye, maxDistance, withIds, depthResolution = 4096, onProgress }) {
67
+ const { grid, tiles } = planTiles(area, cell, Math.min(1024, this.engine.maxTexture));
68
+ const n = grid.cols * grid.rows;
69
+ const values = new Float32Array(n).fill(NaN);
70
+ const potential = new Float32Array(n).fill(NaN);
71
+ const heights = new Float32Array(n).fill(NaN);
72
+ const ids = withIds ? new Int32Array(n) : null;
73
+ const b = this.sceneBounds();
74
+ const margin = eye ? 0 : this.occluderMargin();
75
+ const scene = this.app.viewer.scene;
76
+ for (let t = 0; t < tiles.length; t++) {
77
+ const tile = tiles[t];
78
+ const bounds = { ...tile.bounds, minY: b.minY, maxY: b.maxY };
79
+ const res = await this.engine.run({
80
+ scene, tile: bounds, cols: tile.cols, rows: tile.rows, directions, cosMode, normal, lift, eye, maxDistance,
81
+ margin, depthResolution,
82
+ onProgress: onProgress ? (f) => onProgress((t + f) / tiles.length) : undefined
83
+ });
84
+ const tileIds = withIds ? this.engine.idGrid(scene, bounds, tile.cols, tile.rows) : null;
85
+ for (let r = 0; r < tile.rows; r++) {
86
+ for (let c = 0; c < tile.cols; c++) {
87
+ const src = r * tile.cols + c;
88
+ const dst = (tile.r0 + r) * grid.cols + tile.c0 + c;
89
+ if (!Number.isFinite(res.heights[src])) continue;
90
+ values[dst] = res.values[src];
91
+ potential[dst] = res.potential[src];
92
+ heights[dst] = res.heights[src];
93
+ if (ids && tileIds) ids[dst] = tileIds[src];
94
+ }
95
+ }
96
+ if (onProgress && !directions.length && !eye) onProgress((t + 1) / tiles.length);
97
+ }
98
+ return { grid, values, potential, heights, ids };
99
+ }
100
+
101
+ /** Facade sample points of buildings whose centre lies in the area. */
102
+ facadeSamples(area, spacing = 2, maxPoints = 1_500_000) {
103
+ const layers = this.app.layers.filter((l) => l instanceof ExtrudedLayer && !l.isWall && l.visible);
104
+ let totalArea = 0;
105
+ for (const layer of layers) {
106
+ for (let f = 0; f < layer.count; f++) {
107
+ if (!inArea(layer.centroid[f * 2], layer.centroid[f * 2 + 1], area)) continue;
108
+ totalArea += perimeter(layer.features[f].geometry) * layer.heights[f];
109
+ }
110
+ }
111
+ const s = Math.max(spacing, Math.sqrt(totalArea / maxPoints));
112
+ const pos = [], nrm = [], keys = [], areas = [], levels = [], open = [];
113
+ const terrain = this.app.terrain;
114
+ // Buildings that can stand against a sampled wall: party walls are marked
115
+ // (open = 0) so window-based measures such as VSC can leave them out.
116
+ const near = { minX: area.minX - 60, maxX: area.maxX + 60, minY: area.minY - 60, maxY: area.maxY + 60 };
117
+ const cover = [];
118
+ for (const layer of layers) {
119
+ for (let f = 0; f < layer.count; f++) {
120
+ if (!inArea(layer.centroid[f * 2], layer.centroid[f * 2 + 1], near)) continue;
121
+ cover.push({ id: layer.index * (1 << 20) + f + 1, rings: polygons(layer.features[f].geometry).map((p) => p[0]),
122
+ bottom: layer.baseZ[f] + (layer.minH?.[f] || 0), top: layer.baseZ[f] + layer.heights[f] });
123
+ }
124
+ }
125
+ const index = coverIndex(cover);
126
+ for (const layer of layers) {
127
+ for (let f = 0; f < layer.count; f++) {
128
+ if (!inArea(layer.centroid[f * 2], layer.centroid[f * 2 + 1], area)) continue;
129
+ const h = layer.heights[f];
130
+ if (h < 1) continue;
131
+ const baseY = terrain.toY(layer.baseZ[f]);
132
+ const key = layer.index * (1 << 20) + f + 1;
133
+ const nv = Math.max(1, Math.round(h / s));
134
+ for (const poly of polygons(layer.features[f].geometry)) {
135
+ poly.forEach((ring, ri) => {
136
+ const ccw = ringArea(ring) > 0;
137
+ const flip = ri === 0 ? !ccw : ccw;
138
+ for (let i = 0; i < ring.length; i++) {
139
+ const [x0, y0] = ring[i];
140
+ const [x1, y1] = ring[(i + 1) % ring.length];
141
+ const len = Math.hypot(x1 - x0, y1 - y0);
142
+ if (len < 0.5) continue;
143
+ let nx = (y1 - y0) / len, ny = -(x1 - x0) / len;
144
+ if (flip) { nx = -nx; ny = -ny; }
145
+ const nh = Math.max(1, Math.round(len / s));
146
+ const cellArea = (len / nh) * (h / nv);
147
+ for (let a = 0; a < nh; a++) {
148
+ const t = (a + 0.5) / nh;
149
+ const x = x0 + (x1 - x0) * t, y = y0 + (y1 - y0) * t;
150
+ const spans = coverAt(index, x + nx * 0.6, y + ny * 0.6, key);
151
+ for (let v = 0; v < nv; v++) {
152
+ pos.push(x, baseY + ((v + 0.5) * h) / nv, -y);
153
+ nrm.push(nx, 0, -ny);
154
+ keys.push(key);
155
+ areas.push(cellArea);
156
+ levels.push(((v + 0.5) * h) / nv);
157
+ open.push(spans.length && !isOpen(layer.baseZ[f] + ((v + 0.5) * h) / nv, spans) ? 0 : 1);
158
+ }
159
+ }
160
+ }
161
+ });
162
+ }
163
+ }
164
+ }
165
+ return { positions: Float32Array.from(pos), normals: Float32Array.from(nrm), keys: Int32Array.from(keys),
166
+ areas: Float32Array.from(areas), levels: Float32Array.from(levels), open: Uint8Array.from(open), count: keys.length, spacing: s };
167
+ }
168
+
169
+ async facadeRun(samples, { directions, cosMode, lift = 0.35, area, onProgress = null }) {
170
+ this.app.terrain.lockForAnalysis(area, samples.spacing);
171
+ try {
172
+ return await this._facadeRun(samples, { directions, cosMode, lift, area, onProgress });
173
+ } finally {
174
+ this.app.terrain.lockForAnalysis(null);
175
+ }
176
+ }
177
+
178
+ async _facadeRun(samples, { directions, cosMode, lift, area, onProgress }) {
179
+ const b = this.sceneBounds();
180
+ const tile = { minX: area.minX, maxX: area.maxX, minZ: -area.maxY, maxZ: -area.minY, minY: b.minY, maxY: b.maxY };
181
+ const res = await this.engine.run({ scene: this.app.viewer.scene, tile, cols: 1, rows: 1, directions, cosMode, lift,
182
+ points: samples, margin: this.occluderMargin(), depthResolution: 4096, onProgress });
183
+ return res;
184
+ }
185
+
186
+ /** Per-feature means (and area-weighted sums) of a grid over each feature's roof cells. */
187
+ aggregateGrid(ids, values, cellArea) {
188
+ const acc = new Map();
189
+ for (let i = 0; i < ids.length; i++) {
190
+ const k = ids[i];
191
+ if (!k || !Number.isFinite(values[i])) continue;
192
+ let a = acc.get(k);
193
+ if (!a) { a = { sum: 0, n: 0 }; acc.set(k, a); }
194
+ a.sum += values[i];
195
+ a.n++;
196
+ }
197
+ for (const a of acc.values()) { a.mean = a.sum / a.n; a.area = a.n * cellArea; a.total = a.sum * cellArea; }
198
+ return acc;
199
+ }
200
+
201
+ aggregateSamples(keys, values, areas) {
202
+ const acc = new Map();
203
+ for (let i = 0; i < keys.length; i++) {
204
+ const v = values[i];
205
+ if (!Number.isFinite(v)) continue;
206
+ let a = acc.get(keys[i]);
207
+ if (!a) { a = { sum: 0, w: 0, total: 0, n: 0 }; acc.set(keys[i], a); }
208
+ a.sum += v * areas[i]; a.w += areas[i]; a.total += v * areas[i]; a.n++;
209
+ }
210
+ for (const a of acc.values()) { a.mean = a.sum / a.w; a.area = a.w; }
211
+ return acc;
212
+ }
213
+
214
+ /** Write aggregated per-key values into result fields on their layers. */
215
+ toFields(acc, name, pick = (a) => a.mean) {
216
+ const byLayer = new Map();
217
+ for (const [key, a] of acc) {
218
+ const { layerIndex, id } = splitKey(key);
219
+ const layer = this.app.layers.find((l) => l.index === layerIndex);
220
+ if (!layer) continue;
221
+ let arr = byLayer.get(layer);
222
+ if (!arr) { arr = new Float32Array(layer.count).fill(NaN); byLayer.set(layer, arr); }
223
+ arr[id - 1] = pick(a);
224
+ }
225
+ return [...byLayer].map(([layer, values]) => ({ layer, name, values }));
226
+ }
227
+
228
+ openMask(ids) {
229
+ return (i) => !ids || ids[i] === 0;
230
+ }
231
+
232
+ site() {
233
+ return this.app.site;
234
+ }
235
+
236
+ // ------------------------------------------------------------ sun hours + shadow
237
+ async sunHours({ area, cell, date, startHour = 0, endHour = 24, stepMinutes = 10, facades = false, onProgress }) {
238
+ const s = this.site();
239
+ const doy = typicalDoy(date.month, date.day);
240
+ const dirs = sunPath(doy, s.lat, s.lon, s.utcOffset, { startHour, endHour, stepMinutes });
241
+ if (!dirs.length) throw new Error('The sun does not rise above the horizon in this period.');
242
+ const run = await this.gridRun({ area, cell, directions: dirs, cosMode: 0, lift: 0.3, withIds: true,
243
+ onProgress: (f) => onProgress?.(facades ? f * 0.6 : f) });
244
+ const possible = dirs.reduce((a, d) => a + d.weight, 0);
245
+ const shade = new Float32Array(run.values.length).fill(NaN);
246
+ for (let i = 0; i < shade.length; i++) if (Number.isFinite(run.values[i])) shade[i] = 1 - run.values[i] / possible;
247
+ const fields = this.toFields(this.aggregateGrid(run.ids, run.values, cell * cell), 'sun_h_roof');
248
+ let facade = null;
249
+ if (facades) {
250
+ const samples = this.facadeSamples(area);
251
+ if (samples.count) {
252
+ const res = await this.facadeRun(samples, { directions: dirs, cosMode: 2, area, onProgress: (f) => onProgress?.(0.6 + 0.4 * f) });
253
+ facade = { ...samples, values: res.values, unit: 'h', min: 0, max: possible, ramp: 'inferno' };
254
+ fields.push(...this.toFields(this.aggregateSamples(samples.keys, res.values, samples.areas), 'sun_h_facade'));
255
+ }
256
+ }
257
+ const open = [];
258
+ let twoHours = 0;
259
+ for (let i = 0; i < run.values.length; i++) {
260
+ if (!Number.isFinite(run.values[i]) || run.ids[i] !== 0) continue;
261
+ open.push(run.values[i]);
262
+ if (run.values[i] >= 2) twoHours++;
263
+ }
264
+ const st = gridStats(open);
265
+ const label = `${date.day} ${monthName(date.month)}`;
266
+ return [{
267
+ kind: 'sun', title: `Sun hours, ${label}`, subtitle: `${fmtHour(startHour)}–${fmtHour(endHour)}, every ${stepMinutes} min`,
268
+ unit: 'h', grid: run.grid, values: run.values, ids: run.ids, display: { min: 0, max: Math.ceil(possible), ramp: 'inferno' },
269
+ facade, fields,
270
+ stats: [
271
+ ['Possible sunshine', `${possible.toFixed(1)} h`],
272
+ ['Open ground, mean', `${st.mean.toFixed(2)} h`],
273
+ ['Open ground with ≥ 2 h', `${open.length ? ((100 * twoHours) / open.length).toFixed(0) : 0} %`]
274
+ ],
275
+ notes: 'BRE 209 guidance: at least half of an amenity area should receive 2 hours of sun on 21 March.'
276
+ }, {
277
+ kind: 'shadow', title: `Hours in shade, ${label}`, subtitle: `Of ${possible.toFixed(1)} h with the sun up, ${fmtHour(startHour)}–${fmtHour(endHour)}`,
278
+ unit: 'h', grid: run.grid, values: run.values.map((v) => (Number.isFinite(v) ? possible - v : NaN)), ids: run.ids,
279
+ display: { min: 0, max: Math.ceil(possible), ramp: 'blues' },
280
+ fields: [
281
+ ...this.toFields(this.aggregateGrid(run.ids, run.values.map((v) => possible - v), cell * cell), 'shade_h_roof'),
282
+ ...this.toFields(this.aggregateGrid(run.ids, shade, cell * cell), 'shade_pct_roof', (a) => a.mean * 100)
283
+ ],
284
+ stats: [
285
+ ['Open ground, mean time in shade', `${(possible - st.mean).toFixed(2)} h`],
286
+ ['Open ground, mean shade share', `${(100 * gridStats(shade.filter((_, i) => run.ids[i] === 0)).mean).toFixed(0)} %`],
287
+ ['Open ground in shade all day', `${open.length ? ((100 * open.filter((v) => v < 0.01).length) / open.length).toFixed(0) : 0} %`]
288
+ ]
289
+ }];
290
+ }
291
+
292
+ // ------------------------------------------------------------ shadow study (animation frames)
293
+ /**
294
+ * Sun or shade at each time step of one day: one frame per step, played in
295
+ * step with the sun clock (and recorded by the "sun through the day" video).
296
+ */
297
+ async shadowStudy({ area, cell, date, startHour = 6, endHour = 20, stepMinutes = 30, onProgress }) {
298
+ const s = this.site();
299
+ const doy = typicalDoy(date.month, date.day);
300
+ const steps = [];
301
+ for (let hh = startHour; hh <= endHour + 1e-9; hh += stepMinutes / 60) {
302
+ const p = solarPosition(doy, hh, s.lat, s.lon, s.utcOffset);
303
+ if (p.elevation > 0.5 * DEG) steps.push({ hour: hh, dir: sunVector(p.elevation, p.azimuth) });
304
+ }
305
+ if (!steps.length) throw new Error('The sun does not rise above the horizon in this period.');
306
+ let grid = null, ids = null;
307
+ const frames = [];
308
+ for (let k = 0; k < steps.length; k++) {
309
+ const run = await this.gridRun({ area, cell, directions: [{ dir: steps[k].dir, weight: 1 }], cosMode: 0, lift: 0.3, withIds: k === 0,
310
+ onProgress: (f) => onProgress?.((k + f) / steps.length) });
311
+ if (!grid) { grid = run.grid; ids = run.ids; }
312
+ // 1 sunlit, 0 shade, 255 no surface.
313
+ const frame = new Uint8Array(run.values.length);
314
+ for (let i = 0; i < frame.length; i++) frame[i] = Number.isFinite(run.values[i]) ? (run.values[i] > 0.5 ? 1 : 0) : 255;
315
+ frames.push(frame);
316
+ }
317
+ const label = `${date.day} ${monthName(date.month)}`;
318
+ const result = {
319
+ kind: 'shadowstudy', title: `Shadow study, ${label}`, subtitle: `${steps.length} moments, every ${stepMinutes} min`,
320
+ unit: '', grid, ids, frames, hours: steps.map((x) => x.hour), frame: 0, date,
321
+ values: new Float32Array(frames[0].length), display: { min: 0, max: 1, categorical: { 0: '#26346b', 1: '#ffd36b' } },
322
+ legend: [{ color: '#ffd36b', label: 'In the sun' }, { color: '#26346b', label: 'In shade' }], fields: [],
323
+ stats: [['Moments', String(steps.length)], ['From – to', `${fmtHour(steps[0].hour)}–${fmtHour(steps[steps.length - 1].hour)}`]],
324
+ notes: 'Move the sun clock or press play: the map follows the time. The "Sun through the day" video records it.'
325
+ };
326
+ setShadowFrame(result, 0);
327
+ return [result];
328
+ }
329
+
330
+ // ------------------------------------------------------------ sky view factor
331
+ async skyView({ area, cell, directions = 192, facades = false, onProgress }) {
332
+ const dirs = skyHemisphere(directions);
333
+ const run = await this.gridRun({ area, cell, directions: dirs, cosMode: 1, lift: 0.3, withIds: true,
334
+ onProgress: (f) => onProgress?.(facades ? f * 0.6 : f) });
335
+ this.cache.set(cacheKey('svf', area, cell), run);
336
+ const fields = this.toFields(this.aggregateGrid(run.ids, run.values, cell * cell), 'svf_roof');
337
+ let facade = null;
338
+ if (facades) {
339
+ const samples = this.facadeSamples(area);
340
+ if (samples.count) {
341
+ const res = await this.facadeRun(samples, { directions: dirs, cosMode: 1, area, onProgress: (f) => onProgress?.(0.6 + 0.4 * f) });
342
+ facade = { ...samples, values: res.values, unit: '', min: 0, max: 0.5, ramp: 'viridis' };
343
+ fields.push(...this.toFields(this.aggregateSamples(samples.keys, res.values, samples.areas), 'svf_facade'));
344
+ }
345
+ }
346
+ const open = run.values.filter((v, i) => Number.isFinite(v) && run.ids[i] === 0);
347
+ const st = gridStats(open);
348
+ return [{
349
+ kind: 'svf', title: 'Sky view factor', subtitle: `${directions} sky directions, cosine-weighted`, unit: '',
350
+ grid: run.grid, values: run.values, ids: run.ids, display: { min: 0, max: 1, ramp: 'viridis' }, facade, fields,
351
+ stats: [['Open ground, mean SVF', st.mean.toFixed(2)], ['Open ground, min SVF', st.min.toFixed(2)]],
352
+ notes: 'SVF = 1 for an open horizontal surface, 0.5 for an unobstructed wall.'
353
+ }];
354
+ }
355
+
356
+ // ------------------------------------------------------------ daylight (VSC)
357
+ /**
358
+ * Vertical sky component on every wall (BRE 209): the share of the
359
+ * illuminance an unobstructed horizontal surface gets from the CIE overcast
360
+ * sky that reaches the wall point, with the buildings, trees and terrain in
361
+ * the way. 39.6 % on an open wall; BRE 209 looks for 27 % at a window, or
362
+ * at least 0.8 times the former value where something new is built.
363
+ */
364
+ async daylightVsc({ area, directions = 192, onProgress }) {
365
+ const samples = this.facadeSamples(area);
366
+ if (!samples.count) throw new Error('No building walls in the analysis area.');
367
+ const dirs = vscDirections(skyHemisphere(directions));
368
+ const res = await this.facadeRun(samples, { directions: dirs, cosMode: 1, area, onProgress });
369
+ // Divide by the unobstructed sum and scale to the exact open value: the
370
+ // finite set of directions then cancels out of the result.
371
+ // Party walls (against another building) have no windows: left out.
372
+ const vsc = new Float32Array(samples.count);
373
+ let partyArea = 0, allArea = 0;
374
+ for (let i = 0; i < samples.count; i++) {
375
+ allArea += samples.areas[i];
376
+ if (!samples.open[i]) { vsc[i] = NaN; partyArea += samples.areas[i]; continue; }
377
+ const p = res.potential[i];
378
+ vsc[i] = Number.isFinite(res.values[i]) && p > 0 ? (100 * VSC_OPEN * res.values[i]) / p : NaN;
379
+ }
380
+ // Per building: the mean, the lowest storey (the first 3 m, where BRE 209
381
+ // looks first) and the share of wall at the 27 % target.
382
+ const all = this.aggregateSamples(samples.keys, vsc, samples.areas);
383
+ const lowKeys = [], lowVals = [], lowAreas = [], hitVals = new Float32Array(samples.count);
384
+ for (let i = 0; i < samples.count; i++) {
385
+ hitVals[i] = Number.isFinite(vsc[i]) ? (vsc[i] >= 100 * VSC_TARGET ? 100 : 0) : NaN;
386
+ if (samples.levels[i] <= 3) { lowKeys.push(samples.keys[i]); lowVals.push(vsc[i]); lowAreas.push(samples.areas[i]); }
387
+ }
388
+ const fields = [
389
+ ...this.toFields(all, 'vsc_facade'),
390
+ ...this.toFields(this.aggregateSamples(Int32Array.from(lowKeys), Float32Array.from(lowVals), Float32Array.from(lowAreas)), 'vsc_ground'),
391
+ ...this.toFields(this.aggregateSamples(samples.keys, hitVals, samples.areas), 'vsc27_share')
392
+ ];
393
+ let wallArea = 0, okArea = 0, sum = 0;
394
+ for (let i = 0; i < samples.count; i++) {
395
+ if (!Number.isFinite(vsc[i])) continue;
396
+ wallArea += samples.areas[i]; sum += vsc[i] * samples.areas[i];
397
+ if (vsc[i] >= 100 * VSC_TARGET) okArea += samples.areas[i];
398
+ }
399
+ let buildings = 0, okBuildings = 0;
400
+ for (const a of all.values()) { buildings++; if (a.mean >= 100 * VSC_TARGET) okBuildings++; }
401
+ return [{
402
+ kind: 'vsc', title: 'Daylight on facades (VSC)', subtitle: `${directions} sky directions, CIE overcast sky`, unit: '%',
403
+ facade: { ...samples, values: vsc, unit: '%', min: 0, max: 100 * VSC_OPEN, ramp: 'viridis' },
404
+ display: { min: 0, max: 100 * VSC_OPEN, ramp: 'viridis' }, fields,
405
+ stats: [['Mean VSC on walls', `${(sum / Math.max(1e-9, wallArea)).toFixed(1)} %`],
406
+ ['Wall area at 27 % or more', `${((100 * okArea) / Math.max(1e-9, wallArea)).toFixed(0)} %`],
407
+ ['Buildings with a mean of 27 % or more', `${okBuildings} / ${buildings}`],
408
+ ['Party walls left out', `${((100 * partyArea) / Math.max(1e-9, allArea)).toFixed(0)} % of the wall area`]],
409
+ notes: 'Vertical sky component (BRE 209) at points on every wall: 39.6 % for an open wall; 27 % or more at a window keeps enough daylight, and a new building should not leave a window with less than 0.8 times its former VSC. Walls built against another building have no windows and are left out. Fields: vsc_facade (mean), vsc_ground (lowest 3 m), vsc27_share (% of wall at 27 %).'
410
+ }];
411
+ }
412
+
413
+ // ------------------------------------------------------------ radiation + PV
414
+ async solarRadiation({ area, cell, months = null, tiltDeg = 0, azimuthDeg = 180, efficiency = 0.2, performanceRatio = 0.8,
415
+ coverage = 0.6, minShare = 0.7, albedo = 0.2, facades = false, onProgress }) {
416
+ const s = this.site();
417
+ const weather = this.app.weather || clearSkyYear(s.lat, s.lon, s.utcOffset);
418
+ const bins = annualSunBins(weather, { lat: s.lat, lon: s.lon, utcOffset: s.utcOffset, months });
419
+ // Accumulate in kWh so the sums stay small enough for half-float targets.
420
+ const sunDirs = bins.directions.map((d) => ({ dir: d.dir, weight: d.weight / 1000 }));
421
+ const dhi = bins.dhiSum / 1000;
422
+ const ghi = bins.ghiSum / 1000;
423
+ const sky = skyHemisphere(144);
424
+ const tilt = tiltDeg * DEG;
425
+ const az = azimuthDeg * DEG;
426
+ const n = [Math.sin(az) * Math.sin(tilt), Math.cos(tilt), -Math.cos(az) * Math.sin(tilt)];
427
+ const split = facades ? 0.55 : 1;
428
+ const direct = await this.gridRun({ area, cell, directions: sunDirs, cosMode: 1, normal: n, lift: 0.3, withIds: true,
429
+ onProgress: (f) => onProgress?.(f * 0.6 * split) });
430
+ const diffuse = await this.gridRun({ area, cell, directions: sky, cosMode: 1, normal: n, lift: 0.3,
431
+ onProgress: (f) => onProgress?.((0.6 + 0.4 * f) * split) });
432
+ const reflected = albedo * ghi * (1 - Math.cos(tilt)) / 2;
433
+ const irr = new Float32Array(direct.values.length).fill(NaN);
434
+ const potentialIrr = new Float32Array(direct.values.length).fill(NaN);
435
+ for (let i = 0; i < irr.length; i++) {
436
+ if (!Number.isFinite(direct.values[i])) continue;
437
+ irr[i] = direct.values[i] + dhi * diffuse.values[i] + reflected;
438
+ potentialIrr[i] = direct.potential[i] + dhi * diffuse.potential[i] + reflected;
439
+ }
440
+ // Roof PV: cells that keep at least minShare of their unshaded potential.
441
+ const yieldPerCell = new Float32Array(irr.length).fill(NaN);
442
+ for (let i = 0; i < irr.length; i++) {
443
+ if (!direct.ids[i] || !Number.isFinite(irr[i])) continue;
444
+ if (irr[i] >= minShare * potentialIrr[i]) yieldPerCell[i] = irr[i] * efficiency * performanceRatio * coverage;
445
+ }
446
+ const cellArea = cell * cell;
447
+ const roofIrr = this.aggregateGrid(direct.ids, irr, cellArea);
448
+ const pv = this.aggregateGrid(direct.ids, yieldPerCell, cellArea);
449
+ const fields = [
450
+ ...this.toFields(roofIrr, 'roof_kwh_m2'),
451
+ ...this.toFields(pv, 'pv_kwh_yr', (a) => a.total),
452
+ ...this.toFields(pv, 'pv_area_m2', (a) => a.area * coverage)
453
+ ];
454
+ let facade = null;
455
+ if (facades) {
456
+ const samples = this.facadeSamples(area);
457
+ if (samples.count) {
458
+ const d = await this.facadeRun(samples, { directions: sunDirs, cosMode: 1, area, onProgress: (f) => onProgress?.(0.55 + 0.25 * f) });
459
+ const k = await this.facadeRun(samples, { directions: sky, cosMode: 1, area, onProgress: (f) => onProgress?.(0.8 + 0.2 * f) });
460
+ const fv = new Float32Array(samples.count);
461
+ for (let i = 0; i < samples.count; i++) fv[i] = d.values[i] + dhi * k.values[i] + albedo * ghi * 0.5;
462
+ facade = { ...samples, values: fv, unit: 'kWh/m²', min: 0, max: 0, ramp: 'magma' };
463
+ const facadeAcc = this.aggregateSamples(samples.keys, fv, samples.areas);
464
+ fields.push(...this.toFields(facadeAcc, 'facade_kwh_m2'));
465
+ // Facade PV only where a wall keeps at least minShare of what the best
466
+ // unshaded wall in the scene receives (north walls never qualify).
467
+ let best = 0;
468
+ for (let i = 0; i < samples.count; i++) {
469
+ const pot = d.potential[i] + dhi * k.potential[i] + albedo * ghi * 0.5;
470
+ if (pot > best) best = pot;
471
+ }
472
+ const fy = fv.map((v) => (v >= minShare * best ? v * efficiency * performanceRatio : NaN));
473
+ const facadePv = this.aggregateSamples(samples.keys, fy, samples.areas);
474
+ // Yield is summed over suitable tiles only; buildings with none get 0.
475
+ const pvByKey = new Map();
476
+ for (const [key, a] of facadePv) pvByKey.set(key, a.total * coverage);
477
+ for (const key of facadeAcc.keys()) if (!pvByKey.has(key)) pvByKey.set(key, 0);
478
+ fields.push(...this.toFields(new Map([...pvByKey].map(([key, v]) => [key, { v }])), 'facade_pv_kwh_yr', (a) => a.v));
479
+ }
480
+ }
481
+ let total = 0, area2 = 0;
482
+ for (const a of pv.values()) { total += a.total; area2 += a.area * coverage; }
483
+ const st = gridStats(irr);
484
+ const display = { min: 0, max: Math.ceil(st.max / 100) * 100, ramp: 'magma' };
485
+ if (facade) facade.max = display.max;
486
+ const source = weather.location?.clearSky ? 'clear-sky estimate (upper bound, no clouds)' : `weather: ${weather.location.city || 'EPW'}`;
487
+ return [{
488
+ kind: 'radiation', title: months ? `Solar irradiation, ${months.map(monthName).join(', ')}` : 'Annual solar irradiation',
489
+ subtitle: `${tiltDeg ? `${tiltDeg}° tilt facing ${azimuthDeg}°, ` : ''}${source}`, unit: 'kWh/m²',
490
+ grid: direct.grid, values: irr, ids: direct.ids, display, facade, fields,
491
+ stats: [
492
+ ['Roof PV yield (all buildings)', `${fmtBig(total)} kWh/yr`],
493
+ ['Suitable panel area', `${fmtBig(area2)} m²`],
494
+ ['Horizontal, unshaded', `${ghi.toFixed(0)} kWh/m²`],
495
+ ['Highest surface value', `${st.max.toFixed(0)} kWh/m²`]
496
+ ],
497
+ notes: `PV = irradiation x efficiency ${efficiency} x performance ratio ${performanceRatio} x panel coverage ${coverage}, on roof cells keeping at least ${Math.round(minShare * 100)} % of their unshaded potential; facade panels only on walls receiving at least ${Math.round(minShare * 100)} % of what the best unshaded wall gets. Diffuse light: isotropic sky.`
498
+ }];
499
+ }
500
+
501
+ // ------------------------------------------------------------ visibility
502
+ /**
503
+ * Viewshed from one observer (eye + observerHeight), or, with `eyes`, the
504
+ * ground that sees any of several points (the top edge of a building:
505
+ * visibility is mutual, so this is where that building can be seen from).
506
+ */
507
+ async viewshed({ area, cell, eye, eyes = null, observerHeight = 1.6, radius = 600, onProgress }) {
508
+ const points = eyes ? eyes.map((p) => new THREE.Vector3(p.x, p.y, p.z)) : [new THREE.Vector3(eye.x, eye.y + observerHeight, eye.z)];
509
+ let cx = 0, cz = 0;
510
+ for (const p of points) { cx += p.x / points.length; cz += p.z / points.length; }
511
+ const e = new THREE.Vector3(cx, Math.max(...points.map((p) => p.y)), cz);
512
+ const sub = clipArea({ minX: e.x - radius, maxX: e.x + radius, minY: -e.z - radius, maxY: -e.z + radius }, area);
513
+ let run = null;
514
+ let vis = null;
515
+ for (let k = 0; k < points.length; k++) {
516
+ const one = await this.gridRun({ area: sub, cell, eye: points[k], maxDistance: radius, lift: 0.3, withIds: true,
517
+ onProgress: (f) => onProgress?.((k + f) / points.length) });
518
+ const v1 = one.values.map((v, i) => (Number.isFinite(v) && one.potential[i] > 0 ? (v > 0 ? 1 : 0) : NaN));
519
+ if (!vis) { run = one; vis = v1; continue; }
520
+ for (let i = 0; i < vis.length; i++) {
521
+ if (v1[i] === 1) vis[i] = 1;
522
+ else if (!Number.isFinite(vis[i]) && Number.isFinite(v1[i])) vis[i] = v1[i];
523
+ }
524
+ }
525
+ let seen = 0, total = 0;
526
+ vis.forEach((v) => { if (Number.isFinite(v)) { total++; if (v > 0) seen++; } });
527
+ const visibleRoofs = this.aggregateGrid(run.ids, vis, cell * cell);
528
+ return [{
529
+ kind: 'viewshed', title: eyes ? 'Where the building is seen from' : 'Viewshed',
530
+ subtitle: eyes ? `Ground that sees its top edge (${points.length} points), radius ${radius} m` : `Observer ${observerHeight} m above the surface, radius ${radius} m`,
531
+ unit: '', grid: run.grid, values: vis, ids: run.ids, display: { min: 0, max: 1, categorical: { 0: '#3b4a6b', 1: '#f6c945' } },
532
+ fields: this.toFields(visibleRoofs, 'visible_roof_pct', (a) => a.mean * 100),
533
+ stats: [['Visible share of the surface', `${total ? ((100 * seen) / total).toFixed(0) : 0} %`], ['Visible area', `${fmtBig(seen * cell * cell)} m²`]],
534
+ marker: { x: e.x, y: e.y, z: e.z },
535
+ legend: [{ color: '#f6c945', label: 'Visible' }, { color: '#3b4a6b', label: 'Hidden' }]
536
+ }];
537
+ }
538
+
539
+ treeObstacles() {
540
+ const out = [];
541
+ for (const layer of this.app.layers) {
542
+ if (layer instanceof PointLayer && layer.visible && layer.isTree) out.push(...layer.obstacles());
543
+ }
544
+ if (Array.isArray(this.app.plantedTrees)) out.push(...this.app.plantedTrees);
545
+ return out;
546
+ }
547
+
548
+ greenMask(grid) {
549
+ const mask = new Uint8Array(grid.cols * grid.rows);
550
+ for (const [x, y, rad] of this.treeObstacles()) {
551
+ const c0 = Math.floor((x - rad - grid.minX) / grid.cell);
552
+ const c1 = Math.ceil((x + rad - grid.minX) / grid.cell);
553
+ const r0 = Math.floor((grid.maxY - y - rad) / grid.cell);
554
+ const r1 = Math.ceil((grid.maxY - y + rad) / grid.cell);
555
+ for (let r = Math.max(0, r0); r <= Math.min(grid.rows - 1, r1); r++) {
556
+ for (let c = Math.max(0, c0); c <= Math.min(grid.cols - 1, c1); c++) {
557
+ if (Math.hypot(grid.minX + c * grid.cell - x, grid.maxY - r * grid.cell - y) <= rad) {
558
+ mask[r * grid.cols + c] = 1;
559
+ }
560
+ }
561
+ }
562
+ }
563
+ const greenPattern = /green|park|grass|veg|lawn|garden|tree|forest|meadow/i;
564
+ const greenLayers = this.app.layers.filter(
565
+ (l) => l instanceof SurfaceLayer && l.visible && greenPattern.test(l.entry?.name || '')
566
+ );
567
+ if (greenLayers.length > 0) {
568
+ for (let r = 0; r < grid.rows; r++) {
569
+ for (let c = 0; c < grid.cols; c++) {
570
+ const idx = r * grid.cols + c;
571
+ if (mask[idx] === 1) continue;
572
+ const x = grid.minX + c * grid.cell;
573
+ const y = grid.maxY - r * grid.cell;
574
+ for (const layer of greenLayers) {
575
+ if (layer.featureAt(x, y) >= 0) {
576
+ mask[idx] = 1;
577
+ break;
578
+ }
579
+ }
580
+ }
581
+ }
582
+ }
583
+ return mask;
584
+ }
585
+
586
+ /**
587
+ * Road sources for noise and dispersion: lines drawn in the viewer, then
588
+ * every visible layer with the Roads role. weights holds each line's
589
+ * relative traffic (the layer's "Traffic" expression, 1 when drawn).
590
+ * @returns {{ lines: Array<Array<[number, number]>>, weights: number[] }}
591
+ */
592
+ roadLines() {
593
+ const lines = [], weights = [];
594
+ if (Array.isArray(this.app.drawnRoads)) {
595
+ for (const line of this.app.drawnRoads) {
596
+ if (Array.isArray(line) && line.length >= 2) { lines.push(line); weights.push(1); }
597
+ }
598
+ }
599
+ for (const layer of this.app.layers) {
600
+ if (!(layer instanceof LineLayer) || layer.entry.role !== 'roads' || !layer.visible) continue;
601
+ layer.lines.forEach((parts, f) => {
602
+ const w = layer.heights[f];
603
+ for (const line of parts) {
604
+ if (Array.isArray(line) && line.length >= 2) { lines.push(line); weights.push(Number.isFinite(w) ? w : 1); }
605
+ }
606
+ });
607
+ }
608
+ return { lines, weights };
609
+ }
610
+
611
+ surroundingFields(ids, heights, cols, rows, cell, values, fieldName, ringMetres = 15) {
612
+ if (!ids) return [];
613
+ const buildingLayers = new Set(this.app.layers.filter((l) => l instanceof ExtrudedLayer && !l.isWall).map((l) => l.index));
614
+ const label = surroundings(ids, heights, cols, rows, Math.max(1, Math.round(ringMetres / cell)),
615
+ (key) => buildingLayers.has(splitKey(key).layerIndex));
616
+ const acc = new Map();
617
+ for (let i = 0; i < label.length; i++) {
618
+ const k = label[i];
619
+ if (!k || !Number.isFinite(values[i])) continue;
620
+ let a = acc.get(k);
621
+ if (!a) { a = { sum: 0, n: 0 }; acc.set(k, a); }
622
+ a.sum += values[i];
623
+ a.n++;
624
+ }
625
+ for (const a of acc.values()) a.mean = a.sum / a.n;
626
+ return this.toFields(acc, fieldName);
627
+ }
628
+
629
+ // ------------------------------------------------------------ geometry grid (wind, morphology)
630
+ async obstacleGrid({ area, cell, onProgress }) {
631
+ const key = cacheKey('geom', area, cell);
632
+ if (this.cache.has(key)) return this.cache.get(key);
633
+ const run = await this.gridRun({ area, cell, withIds: true, onProgress });
634
+ const terrain = this.app.terrain;
635
+ const { grid } = run;
636
+ const heights = new Float32Array(grid.cols * grid.rows);
637
+ const porosity = new Float32Array(grid.cols * grid.rows);
638
+ for (let r = 0; r < grid.rows; r++) {
639
+ for (let c = 0; c < grid.cols; c++) {
640
+ const i = r * grid.cols + c;
641
+ if (!run.ids[i]) continue;
642
+ const ground = terrain.groundY(grid.minX + c * cell, grid.maxY - r * cell);
643
+ heights[i] = Math.max(0, (run.heights[i] - ground) / terrain.exaggeration);
644
+ }
645
+ }
646
+ // Trees: porous obstacles rasterised from their crowns (including planted trees).
647
+ for (const [x, y, rad, h] of this.treeObstacles()) {
648
+ const c0 = Math.floor((x - rad - grid.minX) / cell), c1 = Math.ceil((x + rad - grid.minX) / cell);
649
+ const r0 = Math.floor((grid.maxY - y - rad) / cell), r1 = Math.ceil((grid.maxY - y + rad) / cell);
650
+ for (let r = Math.max(0, r0); r <= Math.min(grid.rows - 1, r1); r++) {
651
+ for (let c = Math.max(0, c0); c <= Math.min(grid.cols - 1, c1); c++) {
652
+ const i = r * grid.cols + c;
653
+ if (heights[i] > 0 && !porosity[i]) continue;
654
+ if (Math.hypot(grid.minX + c * cell - x, grid.maxY - r * cell - y) > rad) continue;
655
+ heights[i] = Math.max(heights[i], h);
656
+ porosity[i] = 0.5;
657
+ }
658
+ }
659
+ }
660
+ const out = { grid, heights, porosity, ids: run.ids };
661
+ this.cache.set(key, out);
662
+ return out;
663
+ }
664
+
665
+ async wind({ area, cell, windFromDeg, speed10, onProgress }) {
666
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.3 * f) });
667
+ const { grid } = og;
668
+ const ratio = await windRatio({ heights: og.heights, porosity: og.porosity, cols: grid.cols, rows: grid.rows, cell,
669
+ windFromDeg, onProgress: (f) => onProgress?.(0.3 + 0.7 * f) });
670
+ const speed = ratio.map((r) => r * speed10 * OPEN_PEDESTRIAN_FACTOR);
671
+ const open = speed.filter(Number.isFinite);
672
+ const st = gridStats(open);
673
+ const sheltered = open.filter((v) => v < 0.5 * speed10 * OPEN_PEDESTRIAN_FACTOR).length;
674
+ const counts = new Map();
675
+ for (const v of open) { const c = lawsonClass(v); if (c) counts.set(c.label, (counts.get(c.label) || 0) + 1); }
676
+ return [{
677
+ kind: 'wind', title: `Pedestrian wind, from ${windFromDeg}°`, subtitle: `${speed10.toFixed(1)} m/s at 10 m; screening estimate, not CFD`,
678
+ unit: 'm/s', grid, values: speed, ids: og.ids, display: { min: 0, max: Math.max(1, Math.ceil(st.max)), ramp: 'spectral-r' },
679
+ fields: this.surroundingFields(og.ids, og.heights, grid.cols, grid.rows, cell, speed, 'ped_wind_ms'),
680
+ stats: [
681
+ ['Open-terrain pedestrian wind', `${(speed10 * OPEN_PEDESTRIAN_FACTOR).toFixed(1)} m/s`],
682
+ ['Mean at pedestrian level', `${st.mean.toFixed(1)} m/s`],
683
+ ['Sheltered (< half of open)', `${open.length ? ((100 * sheltered) / open.length).toFixed(0) : 0} %`],
684
+ ...[...counts].map(([k, v]) => [`Comfort: ${k}`, `${((100 * v) / open.length).toFixed(0)} %`])
685
+ ],
686
+ wind: { fromDeg: windFromDeg, speed10 },
687
+ ratio,
688
+ notes: 'Geometric screening (wakes, channelling, tall-building corners). Confirm critical places with CFD or a wind-tunnel study. Comfort classes use LDDC (Lawson) thresholds, which apply to the speed exceeded 5 % of the time.'
689
+ }];
690
+ }
691
+
692
+ // ------------------------------------------------------------ thermal comfort
693
+ async comfort({ area, cell, month, day, hour, conditions, material = 'concrete', lai = 3.0, onProgress }) {
694
+ const s = this.site();
695
+ const doy = typicalDoy(month, day);
696
+ const p = solarPosition(doy, hour - 0.5, s.lat, s.lon, s.utcOffset);
697
+ const elevDeg = p.elevation / DEG;
698
+ const svfKey = cacheKey('svf', area, cell);
699
+ let svfRun = this.cache.get(svfKey);
700
+ if (!svfRun) {
701
+ svfRun = await this.gridRun({ area, cell, directions: skyHemisphere(96), cosMode: 1, lift: 1.1, withIds: true,
702
+ onProgress: (f) => onProgress?.(0.45 * f) });
703
+ }
704
+ let sunlit = null;
705
+ if (elevDeg > 0.5) {
706
+ const dir = sunVector(p.elevation, p.azimuth);
707
+ const sun = await this.gridRun({ area, cell, directions: [{ dir, weight: 1 }], cosMode: 0, lift: 1.1,
708
+ onProgress: (f) => onProgress?.(0.45 + 0.15 * f) });
709
+ sunlit = sun.values;
710
+ }
711
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.6 + 0.1 * f) });
712
+ const ratio = await windRatio({ heights: og.heights, porosity: og.porosity, cols: og.grid.cols, rows: og.grid.rows, cell,
713
+ windFromDeg: conditions.windDir ?? 270, onProgress: (f) => onProgress?.(0.7 + 0.3 * f) });
714
+ const grid = svfRun.grid;
715
+ const n = grid.cols * grid.rows;
716
+
717
+ const green = this.greenMask(grid);
718
+ const dTa = canopyCoolingGrid({
719
+ heights: og.heights,
720
+ porosity: og.porosity,
721
+ greenMask: green,
722
+ cols: grid.cols,
723
+ rows: grid.rows,
724
+ cell,
725
+ windFromDeg: conditions.windDir ?? 270,
726
+ windSpeed: conditions.windSpeed ?? 2,
727
+ airTemp: conditions.airTemp,
728
+ rh: conditions.rh ?? 50,
729
+ lai,
730
+ svf: svfRun.values
731
+ });
732
+
733
+ const tSky = skyTemperature(conditions.airTemp, conditions.ir);
734
+ const mrt = new Float32Array(n).fill(NaN);
735
+ const index = new Float32Array(n).fill(NaN);
736
+ const petGrid = new Float32Array(n).fill(NaN);
737
+ const tsurf = new Float32Array(n).fill(NaN);
738
+
739
+ let lastYield = performance.now();
740
+ for (let i = 0; i < n; i++) {
741
+ // PET is solved iteratively per cell: keep the page responsive.
742
+ if ((i & 4095) === 0 && performance.now() - lastYield > 50) {
743
+ await new Promise((res) => setTimeout(res, 0));
744
+ lastYield = performance.now();
745
+ }
746
+ if (svfRun.ids[i] !== 0 || !Number.isFinite(svfRun.values[i])) continue; // open ground only
747
+
748
+ const localTa = conditions.airTemp + (Number.isFinite(dTa[i]) ? dTa[i] : 0);
749
+ const v10 = Math.max(0.5, Math.min(17, (conditions.windSpeed ?? 2) * (Number.isFinite(ratio[i]) ? ratio[i] : 1)));
750
+ const vPed = Math.max(0.1, v10 * OPEN_PEDESTRIAN_FACTOR);
751
+ const treeShade = og.porosity[i] > 0;
752
+ const matKey = green[i] ? 'grass' : (SURFACE_MATERIALS[material] ? material : 'concrete');
753
+
754
+ const ts = surfaceTemperature({
755
+ airTemp: localTa,
756
+ skyTemp: tSky,
757
+ svf: svfRun.values[i],
758
+ sunlit: sunlit ? sunlit[i] : 0,
759
+ dni: conditions.dni,
760
+ dhi: conditions.dhi,
761
+ ghi: conditions.ghi,
762
+ elevationDeg: elevDeg,
763
+ windSpeed: vPed,
764
+ material: matKey,
765
+ treeShade,
766
+ lai
767
+ });
768
+ tsurf[i] = ts;
769
+
770
+ const tr = meanRadiantTemperature({
771
+ airTemp: localTa,
772
+ skyTemp: tSky,
773
+ svf: svfRun.values[i],
774
+ sunlit: sunlit ? sunlit[i] : 0,
775
+ dni: conditions.dni,
776
+ dhi: conditions.dhi,
777
+ ghi: conditions.ghi,
778
+ elevationDeg: elevDeg,
779
+ groundTemp: ts,
780
+ wallTemp: localTa
781
+ });
782
+ mrt[i] = tr;
783
+
784
+ index[i] = utci(localTa, tr, v10, conditions.rh ?? 50);
785
+ petGrid[i] = pet(localTa, tr, vPed, conditions.rh ?? 50);
786
+ }
787
+
788
+ const st = gridStats(index);
789
+ const counts = new Map();
790
+ for (const v of index) {
791
+ if (!Number.isFinite(v)) continue;
792
+ const c = UTCI_CATEGORIES.find((k) => v < k.max);
793
+ if (c) counts.set(c.label, (counts.get(c.label) || 0) + 1);
794
+ }
795
+ const total = [...counts.values()].reduce((a, b) => a + b, 0);
796
+
797
+ const petStats = gridStats(petGrid);
798
+ const petCounts = new Map();
799
+ for (const v of petGrid) {
800
+ if (!Number.isFinite(v)) continue;
801
+ const c = PET_CATEGORIES.find((k) => v < k.max) || PET_CATEGORIES[PET_CATEGORIES.length - 1];
802
+ if (c) petCounts.set(c.label, (petCounts.get(c.label) || 0) + 1);
803
+ }
804
+ const petTotal = [...petCounts.values()].reduce((a, b) => a + b, 0);
805
+
806
+ const when = `${day} ${monthName(month)} ${String(hour - 1).padStart(2, '0')}:00–${String(hour).padStart(2, '0')}:00`;
807
+ const cond = `air ${conditions.airTemp.toFixed(1)} °C, RH ${Math.round(conditions.rh ?? 50)} %, wind ${(conditions.windSpeed ?? 2).toFixed(1)} m/s`;
808
+ const utciMin = Math.floor(Math.min(st.min, conditions.airTemp - 5));
809
+ const utciMax = Math.ceil(Math.max(st.max, conditions.airTemp + 5));
810
+
811
+ const utciFields = this.surroundingFields(svfRun.ids, og.heights, grid.cols, grid.rows, cell, index, 'utci_c');
812
+ const petFields = this.surroundingFields(svfRun.ids, og.heights, grid.cols, grid.rows, cell, petGrid, 'pet_c');
813
+ const tsurfFields = this.surroundingFields(svfRun.ids, og.heights, grid.cols, grid.rows, cell, tsurf, 'tsurf_c');
814
+ const dtCoolFields = this.surroundingFields(svfRun.ids, og.heights, grid.cols, grid.rows, cell, dTa, 'dt_cool_c');
815
+
816
+ return [
817
+ {
818
+ kind: 'utci',
819
+ title: `UTCI, ${when}`,
820
+ subtitle: cond,
821
+ unit: '°C',
822
+ grid,
823
+ values: index,
824
+ ids: svfRun.ids,
825
+ display: { min: utciMin, max: utciMax, ramp: 'rdylbu-r' },
826
+ fields: [...utciFields, ...petFields, ...tsurfFields, ...dtCoolFields],
827
+ stats: [
828
+ ['Mean UTCI on open ground', `${st.mean.toFixed(1)} °C`],
829
+ ['Range', `${st.min.toFixed(1)} – ${st.max.toFixed(1)} °C`],
830
+ ...[...counts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, total)).toFixed(0)} %`])
831
+ ],
832
+ notes: 'UTCI (Bröde et al. 2012) from air temperature, humidity, sheltered wind and a SolarCal mean radiant temperature; the ground at its modelled surface temperature, walls at the local air temperature.'
833
+ },
834
+ {
835
+ kind: 'pet',
836
+ title: `PET, ${when}`,
837
+ subtitle: cond,
838
+ unit: '°C',
839
+ grid,
840
+ values: petGrid,
841
+ ids: svfRun.ids,
842
+ display: {
843
+ min: Math.floor(Math.min(petStats.min, conditions.airTemp - 5)),
844
+ max: Math.ceil(Math.max(petStats.max, conditions.airTemp + 5)),
845
+ ramp: 'rdylbu-r'
846
+ },
847
+ fields: petFields,
848
+ stats: [
849
+ ['Mean PET on open ground', `${petStats.mean.toFixed(1)} °C`],
850
+ ['Range', `${petStats.min.toFixed(1)} – ${petStats.max.toFixed(1)} °C`],
851
+ ...[...petCounts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, petTotal)).toFixed(0)} %`])
852
+ ],
853
+ notes: 'Physiological Equivalent Temperature (Höppe 1999; Walther & Goestchel 2018 MEMI balance) at 1.1 m.'
854
+ },
855
+ {
856
+ kind: 'mrt',
857
+ title: `Mean radiant temperature, ${when}`,
858
+ subtitle: cond,
859
+ unit: '°C',
860
+ grid,
861
+ values: mrt,
862
+ ids: svfRun.ids,
863
+ display: { min: Math.floor(gridStats(mrt).min), max: Math.ceil(gridStats(mrt).max), ramp: 'inferno' },
864
+ fields: [],
865
+ stats: [['Mean MRT on open ground', `${gridStats(mrt).mean.toFixed(1)} °C`]]
866
+ },
867
+ {
868
+ kind: 'tsurf',
869
+ title: `Surface temperature, ${when}`,
870
+ subtitle: `${SURFACE_MATERIALS[material]?.label || material}, LAI ${lai.toFixed(1)}`,
871
+ unit: '°C',
872
+ grid,
873
+ values: tsurf,
874
+ ids: svfRun.ids,
875
+ display: { min: Math.floor(gridStats(tsurf).min), max: Math.ceil(gridStats(tsurf).max), ramp: 'magma' },
876
+ fields: tsurfFields,
877
+ stats: [
878
+ ['Mean surface temp.', `${gridStats(tsurf).mean.toFixed(1)} °C`],
879
+ ['Max surface temp.', `${gridStats(tsurf).max.toFixed(1)} °C`]
880
+ ],
881
+ notes: 'Steady-state radiative-convective surface energy balance with Bowen ratio partitioning and tree canopy attenuation.'
882
+ },
883
+ {
884
+ kind: 'dt_cool',
885
+ title: `Canopy & canyon air anomaly (ΔTa), ${when}`,
886
+ subtitle: cond,
887
+ unit: '°C',
888
+ grid,
889
+ values: dTa,
890
+ ids: svfRun.ids,
891
+ display: { min: -3.5, max: 2.0, ramp: 'rdbu-r' },
892
+ fields: dtCoolFields,
893
+ stats: [
894
+ ['Mean ΔTa', `${gridStats(dTa).mean.toFixed(2)} °C`],
895
+ ['Max cooling', `${gridStats(dTa).min.toFixed(2)} °C`]
896
+ ],
897
+ notes: 'Local air temperature anomaly from tree/green evapotranspiration advected downwind and deep-canyon heat trapping.'
898
+ }
899
+ ];
900
+ }
901
+
902
+ // ------------------------------------------------------------ morphology / heat island
903
+ async morphology({ area, cell, blockSize = 100, windFromDeg = 270, onProgress }) {
904
+ const og = await this.obstacleGrid({ area, cell, onProgress });
905
+ const { grid } = og;
906
+ const buildingHeights = og.heights.map((h, i) => (og.ids[i] ? h : 0));
907
+ const svfRun = this.cache.get(cacheKey('svf', area, cell));
908
+ const blockCells = Math.max(2, Math.round(blockSize / cell));
909
+ const ind = blockIndicators({ heights: buildingHeights, cols: grid.cols, rows: grid.rows, cell, blockCells, windFromDeg,
910
+ svf: svfRun ? svfRun.values : null, featureIds: og.ids });
911
+ const bgrid = { cols: ind.cols, rows: ind.rows, cell: ind.cell, minX: grid.minX - cell / 2 + ind.cell / 2, maxY: grid.maxY + cell / 2 - ind.cell / 2 };
912
+ // Per building: the indicators of the block containing its centre.
913
+ const fields = [];
914
+ for (const layer of this.app.layers) {
915
+ if (!(layer instanceof ExtrudedLayer) || layer.isWall) continue;
916
+ const pick = (arr) => {
917
+ const out = new Float32Array(layer.count).fill(NaN);
918
+ for (let f = 0; f < layer.count; f++) {
919
+ const c = Math.floor((layer.centroid[f * 2] - (bgrid.minX - bgrid.cell / 2)) / bgrid.cell);
920
+ const r = Math.floor(((bgrid.maxY + bgrid.cell / 2) - layer.centroid[f * 2 + 1]) / bgrid.cell);
921
+ if (c >= 0 && r >= 0 && c < bgrid.cols && r < bgrid.rows) out[f] = arr[r * bgrid.cols + c];
922
+ }
923
+ return out;
924
+ };
925
+ fields.push({ layer, name: 'lambda_p', values: pick(ind.lambdaP) }, { layer, name: 'h_w', values: pick(ind.hw) },
926
+ { layer, name: 'lcz', values: pick(ind.lcz) });
927
+ }
928
+ const lczValues = Float32Array.from(ind.lcz);
929
+ const results = [
930
+ { key: 'lambdaP', title: 'Plan area fraction λp', unit: '', ramp: 'reds', values: ind.lambdaP, max: 1 },
931
+ { key: 'meanHeight', title: 'Mean building height', unit: 'm', ramp: 'viridis', values: ind.meanHeight },
932
+ { key: 'hw', title: 'Canyon aspect ratio H/W', unit: '', ramp: 'magma-r', values: ind.hw, max: 3 },
933
+ { key: 'lambdaF', title: `Frontal area index λf (wind from ${windFromDeg}°)`, unit: '', ramp: 'blues', values: ind.lambdaF },
934
+ { key: 'z0', title: 'Roughness length z0', unit: 'm', ramp: 'greens', values: ind.z0 }
935
+ ].map((r) => ({
936
+ kind: 'morph', title: r.title, subtitle: `${blockSize} m blocks`, unit: r.unit, grid: bgrid, values: r.values,
937
+ display: { min: 0, max: r.max ?? Math.max(0.01, gridStats(r.values).max), ramp: r.ramp }, fields: [], stats: [['Mean', gridStats(r.values).mean.toFixed(2)]]
938
+ }));
939
+ const lczCounts = new Map();
940
+ for (const v of ind.lcz) lczCounts.set(v, (lczCounts.get(v) || 0) + 1);
941
+ results.unshift({
942
+ kind: 'lcz', title: 'Local Climate Zones (approximate)', subtitle: `${blockSize} m blocks, built types from geometry`,
943
+ unit: '', grid: bgrid, values: lczValues, display: { min: 0, max: 10, categorical: LCZ_COLORS, labels: LCZ_LABELS }, fields,
944
+ legend: [...lczCounts.keys()].sort((a, b) => a - b).map((k) => ({ color: LCZ_COLORS[k], label: LCZ_LABELS[k] })),
945
+ stats: [...lczCounts].sort((a, b) => b[1] - a[1]).map(([k, v]) => [LCZ_LABELS[k], `${((100 * v) / lczValues.length).toFixed(0)} %`]),
946
+ notes: 'Classes from plan fraction, mean height and footprint size (Stewart and Oke 2012). LCZ 7 and 10 need material and land-use information.'
947
+ });
948
+ return results;
949
+ }
950
+
951
+ /** A year of outdoor comfort and wind around each building (EPW). */
952
+ async climateYear(opts) {
953
+ return climateAroundBuildings(this, opts);
954
+ }
955
+
956
+ windStats() {
957
+ return this.app.weather?.windSpeed ? windRose(this.app.weather) : null;
958
+ }
959
+
960
+ conditionsFor(month, day, hour) {
961
+ if (this.app.weather?.dryBulb) return conditionsAt(this.app.weather, month, day, hour);
962
+ return null;
963
+ }
964
+
965
+ /**
966
+ * manual: { airTemp, rh, windSpeed, windDir, hour } typed on the comfort
967
+ * card; it replaces the weather file, and the air temperature follows a
968
+ * day curve (amplitude 5 °C, warmest mid-afternoon) through the typed
969
+ * value at its hour. Radiation then comes from a clear sky.
970
+ */
971
+ async diurnalStudy({ area, cell, date, material = 'concrete', lai = 3.0, startHour = 6, endHour = 21, manual = null, onProgress }) {
972
+ const s = this.site();
973
+ const doy = typicalDoy(date.month, date.day);
974
+ const dayCurve = (hh) => 5 * Math.sin(((hh - 9) / 24) * 2 * Math.PI);
975
+ let clearSky = null;
976
+ const svfKey = cacheKey('svf', area, cell);
977
+ let svfRun = this.cache.get(svfKey);
978
+ if (!svfRun) {
979
+ svfRun = await this.gridRun({ area, cell, directions: skyHemisphere(96), cosMode: 1, lift: 1.1, withIds: true,
980
+ onProgress: (f) => onProgress?.(0.2 * f) });
981
+ }
982
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.2 + 0.1 * f) });
983
+ const { grid } = og;
984
+ const n = grid.cols * grid.rows;
985
+ const green = this.greenMask(grid);
986
+
987
+ const hours = [];
988
+ const frames = [];
989
+ let minVal = Infinity;
990
+ let maxVal = -Infinity;
991
+
992
+ const totalHours = Math.floor(endHour - startHour) + 1;
993
+ let hourIdx = 0;
994
+
995
+ for (let hh = startHour; hh <= endHour + 1e-9; hh += 1) {
996
+ const p = solarPosition(doy, hh - 0.5, s.lat, s.lon, s.utcOffset);
997
+ const elevDeg = p.elevation / DEG;
998
+
999
+ let cond = manual ? null : this.conditionsFor(date.month, date.day, hh);
1000
+ if (!cond) {
1001
+ clearSky ??= clearSkyYear(s.lat, s.lon, s.utcOffset);
1002
+ const k = (doy - 1) * 24 + Math.max(0, Math.min(23, Math.round(hh) - 1));
1003
+ cond = {
1004
+ airTemp: manual ? manual.airTemp + dayCurve(hh) - dayCurve(manual.hour ?? 15) : 26 + dayCurve(hh),
1005
+ rh: manual?.rh ?? 50,
1006
+ windSpeed: manual?.windSpeed ?? 2.5,
1007
+ windDir: manual?.windDir ?? 270,
1008
+ dni: clearSky.dni[k],
1009
+ dhi: clearSky.dhi[k],
1010
+ ghi: clearSky.ghi[k],
1011
+ ir: null
1012
+ };
1013
+ }
1014
+
1015
+ let sunlit = null;
1016
+ if (elevDeg > 0.5) {
1017
+ const dir = sunVector(p.elevation, p.azimuth);
1018
+ const sun = await this.gridRun({ area, cell, directions: [{ dir, weight: 1 }], cosMode: 0, lift: 1.1,
1019
+ onProgress: (f) => onProgress?.(0.3 + 0.7 * ((hourIdx + f) / totalHours)) });
1020
+ sunlit = sun.values;
1021
+ }
1022
+
1023
+ const ratio = await windRatio({ heights: og.heights, porosity: og.porosity, cols: grid.cols, rows: grid.rows, cell,
1024
+ windFromDeg: cond.windDir ?? 270 });
1025
+
1026
+ const dTa = canopyCoolingGrid({
1027
+ heights: og.heights,
1028
+ porosity: og.porosity,
1029
+ greenMask: green,
1030
+ cols: grid.cols,
1031
+ rows: grid.rows,
1032
+ cell,
1033
+ windFromDeg: cond.windDir ?? 270,
1034
+ windSpeed: cond.windSpeed ?? 2.5,
1035
+ airTemp: cond.airTemp,
1036
+ rh: cond.rh ?? 50,
1037
+ lai,
1038
+ svf: svfRun.values
1039
+ });
1040
+
1041
+ const tSky = skyTemperature(cond.airTemp, cond.ir);
1042
+ const frame = new Float32Array(n).fill(NaN);
1043
+
1044
+ let lastYield = performance.now();
1045
+ for (let i = 0; i < n; i++) {
1046
+ if ((i & 4095) === 0 && performance.now() - lastYield > 50) {
1047
+ await new Promise((res) => setTimeout(res, 0));
1048
+ lastYield = performance.now();
1049
+ }
1050
+ if (svfRun.ids[i] !== 0 || !Number.isFinite(svfRun.values[i])) continue;
1051
+
1052
+ const localTa = cond.airTemp + (Number.isFinite(dTa[i]) ? dTa[i] : 0);
1053
+ const v10 = Math.max(0.5, Math.min(17, (cond.windSpeed ?? 2) * (Number.isFinite(ratio[i]) ? ratio[i] : 1)));
1054
+ const vPed = Math.max(0.1, v10 * OPEN_PEDESTRIAN_FACTOR);
1055
+ const treeShade = og.porosity[i] > 0;
1056
+ const matKey = green[i] ? 'grass' : (SURFACE_MATERIALS[material] ? material : 'concrete');
1057
+
1058
+ const ts = surfaceTemperature({
1059
+ airTemp: localTa,
1060
+ skyTemp: tSky,
1061
+ svf: svfRun.values[i],
1062
+ sunlit: sunlit ? sunlit[i] : 0,
1063
+ dni: cond.dni,
1064
+ dhi: cond.dhi,
1065
+ ghi: cond.ghi,
1066
+ elevationDeg: elevDeg,
1067
+ windSpeed: vPed,
1068
+ material: matKey,
1069
+ treeShade,
1070
+ lai
1071
+ });
1072
+
1073
+ const tr = meanRadiantTemperature({
1074
+ airTemp: localTa,
1075
+ skyTemp: tSky,
1076
+ svf: svfRun.values[i],
1077
+ sunlit: sunlit ? sunlit[i] : 0,
1078
+ dni: cond.dni,
1079
+ dhi: cond.dhi,
1080
+ ghi: cond.ghi,
1081
+ elevationDeg: elevDeg,
1082
+ groundTemp: ts,
1083
+ wallTemp: localTa
1084
+ });
1085
+
1086
+ const u = utci(localTa, tr, v10, cond.rh ?? 50);
1087
+ frame[i] = u;
1088
+ if (Number.isFinite(u)) {
1089
+ if (u < minVal) minVal = u;
1090
+ if (u > maxVal) maxVal = u;
1091
+ }
1092
+ }
1093
+
1094
+ hours.push(hh);
1095
+ frames.push(frame);
1096
+ hourIdx++;
1097
+ }
1098
+
1099
+ if (!Number.isFinite(minVal)) minVal = 10;
1100
+ if (!Number.isFinite(maxVal)) maxVal = 35;
1101
+
1102
+ const result = {
1103
+ kind: 'diurnalstudy',
1104
+ title: `Diurnal UTCI study, ${date.day} ${monthName(date.month)}`,
1105
+ subtitle: `${hours.length} hourly frames (${fmtHour(hours[0])}–${fmtHour(hours[hours.length - 1])})`,
1106
+ unit: '°C',
1107
+ grid,
1108
+ ids: svfRun.ids,
1109
+ frames,
1110
+ hours,
1111
+ frame: 0,
1112
+ date,
1113
+ values: new Float32Array(frames[0].length),
1114
+ display: { min: Math.floor(minVal), max: Math.ceil(maxVal), ramp: 'rdylbu-r' },
1115
+ fields: [],
1116
+ stats: [
1117
+ ['Hourly frames', String(hours.length)],
1118
+ ['Period', `${fmtHour(hours[0])}–${fmtHour(hours[hours.length - 1])}`],
1119
+ ['UTCI range', `${minVal.toFixed(1)} – ${maxVal.toFixed(1)} °C`]
1120
+ ],
1121
+ notes: 'Continuous diurnal outdoor thermal comfort animation (UTCI) accounting for surface energy balance, tree canopy shading, and evapotranspirative cooling advection.'
1122
+ };
1123
+ setDiurnalFrame(result, 0);
1124
+ return [result];
1125
+ }
1126
+
1127
+ async ventilation({ area, cell, windFromDeg = 270, speed10 = 4, emissionRateUgMS = 60, backgroundUgM3 = 18, treeDepositionFactor = 0.25, roads = null, roadWeights = null, onProgress }) {
1128
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.25 * f) });
1129
+ const { grid } = og;
1130
+ const ratio = await windRatio({ heights: og.heights, porosity: og.porosity, cols: grid.cols, rows: grid.rows, cell,
1131
+ windFromDeg, onProgress: (f) => onProgress?.(0.25 + 0.25 * f) });
1132
+ const svfKey = cacheKey('svf', area, cell);
1133
+ let svfRun = this.cache.get(svfKey);
1134
+ if (!svfRun) {
1135
+ svfRun = await this.gridRun({ area, cell, directions: skyHemisphere(96), cosMode: 1, lift: 1.1, withIds: true,
1136
+ onProgress: (f) => onProgress?.(0.5 + 0.2 * f) });
1137
+ }
1138
+ const { vei, airAge } = ventilationEfficiency({
1139
+ windRatioGrid: ratio,
1140
+ heights: og.heights,
1141
+ porosity: og.porosity,
1142
+ svf: svfRun.values,
1143
+ cols: grid.cols,
1144
+ rows: grid.rows,
1145
+ cell
1146
+ });
1147
+
1148
+ const src = roads ? { lines: roads, weights: roadWeights } : this.roadLines();
1149
+ const conc = await pollutantDispersion({
1150
+ heights: og.heights,
1151
+ porosity: og.porosity,
1152
+ windRatioGrid: ratio,
1153
+ svf: svfRun.values,
1154
+ cols: grid.cols,
1155
+ rows: grid.rows,
1156
+ cell,
1157
+ minX: grid.minX,
1158
+ maxY: grid.maxY,
1159
+ windFromDeg,
1160
+ windSpeed10: speed10,
1161
+ roads: src.lines,
1162
+ roadWeights: src.weights,
1163
+ emissionRateUgMS,
1164
+ backgroundUgM3,
1165
+ treeDepositionFactor,
1166
+ onProgress: (f) => onProgress?.(0.7 + 0.3 * f)
1167
+ });
1168
+
1169
+ const ventFields = this.surroundingFields(og.ids, og.heights, grid.cols, grid.rows, cell, vei, 'vent_index');
1170
+ const no2Fields = this.surroundingFields(og.ids, og.heights, grid.cols, grid.rows, cell, conc, 'air_no2_ugm3');
1171
+
1172
+ const openVei = vei.filter(Number.isFinite);
1173
+ const stVei = gridStats(openVei);
1174
+ const vCounts = new Map();
1175
+ for (const v of openVei) {
1176
+ const c = ventilationClass(v);
1177
+ if (c) vCounts.set(c.label, (vCounts.get(c.label) || 0) + 1);
1178
+ }
1179
+
1180
+ const openAirAge = airAge.filter(Number.isFinite);
1181
+ const stAge = gridStats(openAirAge);
1182
+
1183
+ const openConc = conc.filter(Number.isFinite);
1184
+ const stConc = gridStats(openConc);
1185
+ const aqCounts = new Map();
1186
+ for (const v of openConc) {
1187
+ const c = airQualityClass(v);
1188
+ if (c) aqCounts.set(c.label, (aqCounts.get(c.label) || 0) + 1);
1189
+ }
1190
+
1191
+ return [
1192
+ {
1193
+ kind: 'vei',
1194
+ title: `Street canyon ventilation (VEI), from ${windFromDeg}°`,
1195
+ subtitle: `${speed10.toFixed(1)} m/s at 10 m`,
1196
+ unit: '',
1197
+ grid,
1198
+ values: vei,
1199
+ ids: og.ids,
1200
+ display: { min: 0, max: 1.2, ramp: 'spectral-r' },
1201
+ fields: ventFields,
1202
+ stats: [
1203
+ ['Open ground, mean VEI', stVei.mean.toFixed(2)],
1204
+ ['Open ground, min VEI', stVei.min.toFixed(2)],
1205
+ ...[...vCounts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, openVei.length)).toFixed(0)} %`])
1206
+ ],
1207
+ notes: 'Ventilation Efficiency Index (VEI) combining pedestrian wind ratio and sky view factor. Low values indicate stagnant street canyons.'
1208
+ },
1209
+ {
1210
+ kind: 'airage',
1211
+ title: `Normalized air age (tau), from ${windFromDeg}°`,
1212
+ subtitle: `${speed10.toFixed(1)} m/s at 10 m`,
1213
+ unit: '',
1214
+ grid,
1215
+ values: airAge,
1216
+ ids: og.ids,
1217
+ display: { min: 1, max: 8, ramp: 'spectral' },
1218
+ fields: [],
1219
+ stats: [
1220
+ ['Mean normalized air age', stAge.mean.toFixed(1)],
1221
+ ['Max air age (stagnant pockets)', stAge.max.toFixed(1)]
1222
+ ],
1223
+ notes: 'Dimensionless mean age of air relative to open flow. Higher values signify air stagnation and poor flush rates.'
1224
+ },
1225
+ {
1226
+ kind: 'dispersion',
1227
+ title: `Traffic pollutant concentration (NO2), from ${windFromDeg}°`,
1228
+ subtitle: `Emission ${emissionRateUgMS} µg/(m·s), background ${backgroundUgM3} µg/m³; ${roadSourceText(src.lines)}`,
1229
+ unit: 'µg/m³',
1230
+ grid,
1231
+ values: conc,
1232
+ ids: og.ids,
1233
+ display: { min: backgroundUgM3, max: Math.min(150, Math.ceil(stConc.max)), ramp: 'spectral-r' },
1234
+ fields: no2Fields,
1235
+ stats: [
1236
+ ['Open ground, mean concentration', `${stConc.mean.toFixed(1)} µg/m³`],
1237
+ ['Highest local concentration', `${stConc.max.toFixed(1)} µg/m³`],
1238
+ ...[...aqCounts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, openConc.length)).toFixed(0)} %`])
1239
+ ],
1240
+ notes: 'Street-canyon pollutant dispersion based on OSPM Gaussian plume and canyon vortex trapping with tree canopy filtration.'
1241
+ }
1242
+ ];
1243
+ }
1244
+
1245
+ async noise({ area, cell, soundPowerDb = 82, receiverHeight = 1.5, groundFactor = 0.5, roads = null, roadWeights = null, onProgress }) {
1246
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.4 * f) });
1247
+ const { grid } = og;
1248
+ const src = roads ? { lines: roads, weights: roadWeights } : this.roadLines();
1249
+
1250
+ const noiseGrid = await noisePropagation({
1251
+ heights: og.heights,
1252
+ porosity: og.porosity,
1253
+ cols: grid.cols,
1254
+ rows: grid.rows,
1255
+ cell,
1256
+ minX: grid.minX,
1257
+ maxY: grid.maxY,
1258
+ roads: src.lines,
1259
+ roadWeights: src.weights,
1260
+ soundPowerDb,
1261
+ receiverHeight,
1262
+ groundFactor,
1263
+ onProgress: (f) => onProgress?.(0.4 + 0.6 * f)
1264
+ });
1265
+
1266
+ const facadeMap = buildingFacadeNoise(noiseGrid, og.ids, grid.cols, grid.rows);
1267
+ const fields = [
1268
+ ...this.toFields(facadeMap, 'noise_dba_max', (a) => a.max),
1269
+ ...this.toFields(facadeMap, 'noise_dba_mean', (a) => a.mean),
1270
+ ...this.toFields(facadeMap, 'quiet_facade', (a) => a.quietFacade)
1271
+ ];
1272
+
1273
+ const openNoise = noiseGrid.filter(Number.isFinite);
1274
+ const st = gridStats(openNoise);
1275
+ const loudCount = openNoise.filter((v) => v >= 65).length;
1276
+
1277
+ const nCounts = new Map();
1278
+ for (const v of openNoise) {
1279
+ const c = noiseClass(v);
1280
+ if (c) nCounts.set(c.label, (nCounts.get(c.label) || 0) + 1);
1281
+ }
1282
+
1283
+ return [{
1284
+ kind: 'noise',
1285
+ title: 'Environmental noise (Lden)',
1286
+ subtitle: `Sound power ${soundPowerDb} dB(A)/m, receiver at ${receiverHeight.toFixed(1)} m; ${roadSourceText(src.lines)}`,
1287
+ unit: 'dB(A)',
1288
+ grid,
1289
+ values: noiseGrid,
1290
+ ids: og.ids,
1291
+ display: { min: 40, max: 80, ramp: 'magma' },
1292
+ fields,
1293
+ stats: [
1294
+ ['Mean open-ground noise', `${st.mean.toFixed(1)} dB(A)`],
1295
+ ['Max noise level', `${st.max.toFixed(1)} dB(A)`],
1296
+ ['Share of area ≥ 65 dB(A)', `${openNoise.length ? ((100 * loudCount) / openNoise.length).toFixed(0) : 0} %`],
1297
+ ...[...nCounts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, openNoise.length)).toFixed(0)} %`])
1298
+ ],
1299
+ notes: '3D acoustic propagation (ISO 9613-2 / CNOSSOS-EU proxy) with Maekawa barrier diffraction and tree attenuation.'
1300
+ }];
1301
+ }
1302
+
1303
+ async greenView({ area, cell, eyeHeight = 1.6, maxRadius = 80, onProgress }) {
1304
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.3 * f) });
1305
+ const { grid } = og;
1306
+ const buildingHeights = og.heights.map((h, i) => (og.ids[i] ? h : 0));
1307
+ const treeObs = this.treeObstacles();
1308
+ const green = this.greenMask(grid);
1309
+
1310
+ const gvi = await greenViewIndex({
1311
+ buildingHeights,
1312
+ treeObstacles: treeObs,
1313
+ greenMask: green,
1314
+ cols: grid.cols,
1315
+ rows: grid.rows,
1316
+ cell,
1317
+ minX: grid.minX - 0.5 * cell,
1318
+ maxY: grid.maxY + 0.5 * cell,
1319
+ eyeHeight,
1320
+ maxRadius,
1321
+ onProgress: (f) => onProgress?.(0.3 + 0.7 * f)
1322
+ });
1323
+
1324
+ const fields = this.surroundingFields(og.ids, og.heights, grid.cols, grid.rows, cell, gvi, 'gvi_pct');
1325
+
1326
+ const openGvi = gvi.filter(Number.isFinite);
1327
+ const st = gridStats(openGvi);
1328
+ const highGreen = openGvi.filter((v) => v >= 20).length;
1329
+
1330
+ const gCounts = new Map();
1331
+ for (const v of openGvi) {
1332
+ const c = gviClass(v);
1333
+ if (c) gCounts.set(c.label, (gCounts.get(c.label) || 0) + 1);
1334
+ }
1335
+
1336
+ return [{
1337
+ kind: 'gvi',
1338
+ title: 'Green View Index (GVI)',
1339
+ subtitle: `Panoramic eye-level green visibility at ${eyeHeight.toFixed(1)} m, radius ${maxRadius} m`,
1340
+ unit: '%',
1341
+ grid,
1342
+ values: gvi,
1343
+ ids: og.ids,
1344
+ display: { min: 0, max: 50, ramp: 'greens' },
1345
+ fields,
1346
+ stats: [
1347
+ ['Mean GVI on open ground', `${st.mean.toFixed(1)} %`],
1348
+ ['Share of area ≥ 20 % green view', `${openGvi.length ? ((100 * highGreen) / openGvi.length).toFixed(0) : 0} %`],
1349
+ ...[...gCounts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, openGvi.length)).toFixed(0)} %`])
1350
+ ],
1351
+ notes: 'Panoramic Green View Index (Treepedia / MIT Senseable City Lab proxy) evaluating eye-level visible green canopy and surface vegetation.'
1352
+ }];
1353
+ }
1354
+
1355
+ async solarEnvelope({ area, cell, date = { month: 12, day: 21 }, startHour = 9, endHour = 15, minSunHours = 2, maxBuildableHeight = 72, floorHeight = 3.2, onProgress }) {
1356
+ const s = this.site();
1357
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.3 * f) });
1358
+ const { grid } = og;
1359
+ const buildingHeights = og.heights.map((h, i) => (og.ids[i] ? h : 0));
1360
+ const sunVectors = sunPath(typicalDoy(date.month, date.day), s.lat, s.lon, s.utcOffset, { startHour, endHour, stepMinutes: 30 });
1361
+
1362
+ const env = calcSolarEnvelope({
1363
+ buildingHeights,
1364
+ ids: og.ids,
1365
+ cols: grid.cols,
1366
+ rows: grid.rows,
1367
+ cell,
1368
+ sunVectors,
1369
+ minSunHours,
1370
+ stepHours: 0.5,
1371
+ maxBuildableHeight,
1372
+ floorHeight
1373
+ });
1374
+
1375
+ const fields = this.toFields(this.aggregateGrid(og.ids, env.maxHeight, cell * cell), 'env_max_h');
1376
+ const stH = gridStats(env.maxHeight);
1377
+ const stF = gridStats(env.maxFloors);
1378
+
1379
+ return [
1380
+ {
1381
+ kind: 'solarenvelope',
1382
+ title: 'Solar envelope height limit',
1383
+ subtitle: `${date.day} ${monthName(date.month)}, ${minSunHours} h sun window (${fmtHour(startHour)}–${fmtHour(endHour)})`,
1384
+ unit: 'm',
1385
+ grid,
1386
+ values: env.maxHeight,
1387
+ ids: og.ids,
1388
+ display: { min: 0, max: maxBuildableHeight, ramp: 'viridis' },
1389
+ fields,
1390
+ stats: [
1391
+ ['Mean height limit', `${stH.mean.toFixed(1)} m`],
1392
+ ['Max allowed height', `${stH.max.toFixed(1)} m`]
1393
+ ],
1394
+ notes: 'Solar envelope (Knowles 1981): maximum buildable height that preserves neighbours\' direct solar access.'
1395
+ },
1396
+ {
1397
+ kind: 'solarfloors',
1398
+ title: 'Solar envelope floor capacity',
1399
+ subtitle: `${floorHeight.toFixed(1)} m per floor, cap ${Math.floor(maxBuildableHeight / floorHeight)} floors`,
1400
+ unit: 'floors',
1401
+ grid,
1402
+ values: env.maxFloors,
1403
+ ids: og.ids,
1404
+ display: { min: 0, max: Math.floor(maxBuildableHeight / floorHeight), ramp: 'viridis' },
1405
+ fields: [],
1406
+ stats: [
1407
+ ['Mean floor capacity', `${stF.mean.toFixed(0)} floors`]
1408
+ ]
1409
+ }
1410
+ ];
1411
+ }
1412
+
1413
+ // ------------------------------------------------------------ pedestrian walkability & cool route
1414
+ async walkability({
1415
+ area,
1416
+ cell,
1417
+ month = 7,
1418
+ day = 21,
1419
+ hour = 15,
1420
+ conditions = null,
1421
+ material = 'concrete',
1422
+ lai = 3.0,
1423
+ soundPowerDb = 82,
1424
+ startXY = null,
1425
+ endXY = null,
1426
+ coolBias = 2.8,
1427
+ onProgress
1428
+ }) {
1429
+ const s = this.site();
1430
+ const cond = conditions || this.conditionsFor(month, day, hour) || {
1431
+ airTemp: 32, rh: 45, windSpeed: 2.5, windDir: 270, dni: 750, dhi: 120, ghi: 800, ir: 380
1432
+ };
1433
+ const doy = typicalDoy(month, day);
1434
+ const p = solarPosition(doy, hour - 0.5, s.lat, s.lon, s.utcOffset);
1435
+ const elevDeg = p.elevation / DEG;
1436
+
1437
+ const svfKey = cacheKey('svf', area, cell);
1438
+ let svfRun = this.cache.get(svfKey);
1439
+ if (!svfRun) {
1440
+ svfRun = await this.gridRun({
1441
+ area, cell, directions: skyHemisphere(96), cosMode: 1, lift: 1.1, withIds: true,
1442
+ onProgress: (f) => onProgress?.(0.25 * f)
1443
+ });
1444
+ }
1445
+ let sunlit = null;
1446
+ if (elevDeg > 0.5) {
1447
+ const dir = sunVector(p.elevation, p.azimuth);
1448
+ const sun = await this.gridRun({
1449
+ area, cell, directions: [{ dir, weight: 1 }], cosMode: 0, lift: 1.1,
1450
+ onProgress: (f) => onProgress?.(0.25 + 0.1 * f)
1451
+ });
1452
+ sunlit = sun.values;
1453
+ }
1454
+
1455
+ const og = await this.obstacleGrid({ area, cell, onProgress: (f) => onProgress?.(0.35 + 0.1 * f) });
1456
+ const { grid } = og;
1457
+ const n = grid.cols * grid.rows;
1458
+ const ratio = await windRatio({
1459
+ heights: og.heights, porosity: og.porosity, cols: grid.cols, rows: grid.rows, cell,
1460
+ windFromDeg: cond.windDir ?? 270, onProgress: (f) => onProgress?.(0.45 + 0.15 * f)
1461
+ });
1462
+ const green = this.greenMask(grid);
1463
+ const dTa = canopyCoolingGrid({
1464
+ heights: og.heights, porosity: og.porosity, greenMask: green, cols: grid.cols, rows: grid.rows, cell,
1465
+ windFromDeg: cond.windDir ?? 270, windSpeed: cond.windSpeed ?? 2, airTemp: cond.airTemp, rh: cond.rh ?? 50,
1466
+ lai, svf: svfRun.values
1467
+ });
1468
+
1469
+ const tSky = skyTemperature(cond.airTemp, cond.ir);
1470
+ const utciGrid = new Float32Array(n).fill(NaN);
1471
+ const shadeGrid = new Float32Array(n).fill(NaN);
1472
+ for (let i = 0; i < n; i++) {
1473
+ if (svfRun.ids[i] !== 0 || !Number.isFinite(svfRun.values[i])) continue;
1474
+ const sunVal = sunlit ? sunlit[i] : 0;
1475
+ const treeShade = og.porosity[i] > 0;
1476
+ shadeGrid[i] = treeShade ? Math.max(0.75, 1 - sunVal) : (1 - sunVal);
1477
+ const localTa = cond.airTemp + (Number.isFinite(dTa[i]) ? dTa[i] : 0);
1478
+ const v10 = Math.max(0.5, Math.min(17, (cond.windSpeed ?? 2) * (Number.isFinite(ratio[i]) ? ratio[i] : 1)));
1479
+ const vPed = Math.max(0.1, v10 * OPEN_PEDESTRIAN_FACTOR);
1480
+ const matKey = green[i] ? 'grass' : (SURFACE_MATERIALS[material] ? material : 'concrete');
1481
+ const ts = surfaceTemperature({
1482
+ airTemp: localTa, skyTemp: tSky, svf: svfRun.values[i], sunlit: sunVal,
1483
+ dni: cond.dni, dhi: cond.dhi, ghi: cond.ghi, elevationDeg: elevDeg, windSpeed: vPed,
1484
+ material: matKey, treeShade, lai
1485
+ });
1486
+ const tr = meanRadiantTemperature({
1487
+ airTemp: localTa, skyTemp: tSky, svf: svfRun.values[i], sunlit: sunVal,
1488
+ dni: cond.dni, dhi: cond.dhi, ghi: cond.ghi, elevationDeg: elevDeg, groundTemp: ts, wallTemp: localTa
1489
+ });
1490
+ utciGrid[i] = utci(localTa, tr, v10, cond.rh ?? 50);
1491
+ }
1492
+
1493
+ const buildingHeights = og.heights.map((h, i) => (og.ids[i] ? h : 0));
1494
+ const gviGrid = await greenViewIndex({
1495
+ buildingHeights, treeObstacles: this.treeObstacles(), greenMask: green,
1496
+ cols: grid.cols, rows: grid.rows, cell, minX: grid.minX - 0.5 * cell, maxY: grid.maxY + 0.5 * cell,
1497
+ eyeHeight: 1.6, maxRadius: 80, onProgress: (f) => onProgress?.(0.6 + 0.2 * f)
1498
+ });
1499
+
1500
+ const src = this.roadLines();
1501
+ const noiseGrid = await noisePropagation({
1502
+ heights: og.heights, porosity: og.porosity, cols: grid.cols, rows: grid.rows, cell,
1503
+ minX: grid.minX, maxY: grid.maxY, roads: src.lines, roadWeights: src.weights,
1504
+ soundPowerDb, receiverHeight: 1.5, groundFactor: 0.5, onProgress: (f) => onProgress?.(0.8 + 0.2 * f)
1505
+ });
1506
+
1507
+ const walkScore = pedestrianWalkabilityIndex({
1508
+ ids: og.ids, utciGrid, shadeGrid, gviGrid, noiseGrid, cols: grid.cols, rows: grid.rows
1509
+ });
1510
+
1511
+ const fields = this.surroundingFields(og.ids, og.heights, grid.cols, grid.rows, cell, walkScore, 'walk_score');
1512
+ const open = walkScore.filter(Number.isFinite);
1513
+ const st = gridStats(open);
1514
+ const counts = new Map();
1515
+ for (const v of open) {
1516
+ const c = walkabilityClass(v);
1517
+ if (c) counts.set(c.label, (counts.get(c.label) || 0) + 1);
1518
+ }
1519
+
1520
+ const fromPt = startXY || [grid.minX + grid.cols * 0.15 * cell, grid.maxY - grid.rows * 0.85 * cell];
1521
+ const toPt = endXY || [grid.minX + grid.cols * 0.85 * cell, grid.maxY - grid.rows * 0.15 * cell];
1522
+ const routeComparison = findCoolRoute({
1523
+ ids: og.ids, walkScore, utciGrid, shadeGrid, gviGrid, noiseGrid,
1524
+ cols: grid.cols, rows: grid.rows, cell, minX: grid.minX, maxY: grid.maxY,
1525
+ startXY: fromPt, endXY: toPt, coolBias
1526
+ });
1527
+
1528
+ const statsRows = [
1529
+ ['Mean walkability score', `${st.mean.toFixed(1)} / 100`],
1530
+ ['Range', `${st.min.toFixed(0)} – ${st.max.toFixed(0)} / 100`],
1531
+ ...[...counts].map(([k, v]) => [k, `${((100 * v) / Math.max(1, open.length)).toFixed(0)} %`])
1532
+ ];
1533
+ if (routeComparison) {
1534
+ const sh = routeComparison.shortest;
1535
+ const co = routeComparison.coolest;
1536
+ statsRows.push(
1537
+ ['Shortest path', `${sh.length_m.toFixed(0)} m · ${sh.mean_utci_c.toFixed(1)} °C UTCI · ${sh.shade_pct.toFixed(0)} % shade`],
1538
+ ['Coolest path', `${co.length_m.toFixed(0)} m (+${routeComparison.detourPct.toFixed(0)} %) · ${co.mean_utci_c.toFixed(1)} °C UTCI · ${co.shade_pct.toFixed(0)} % shade`],
1539
+ ['Thermal relief (coolest − shortest)', `${routeComparison.utciDeltaC >= 0 ? '+' : ''}${routeComparison.utciDeltaC.toFixed(1)} °C UTCI, +${routeComparison.shadeGainPct.toFixed(0)} % shade`]
1540
+ );
1541
+ }
1542
+
1543
+ const when = `${day} ${monthName(month)} ${String(hour - 1).padStart(2, '0')}:00–${String(hour).padStart(2, '0')}:00`;
1544
+ const result = {
1545
+ kind: 'walkability',
1546
+ title: `Pedestrian walkability & cool route, ${when}`,
1547
+ subtitle: `UTCI, shade, GVI & noise composite; ${routeComparison ? 'shortest vs. coolest path' : 'index map'}`,
1548
+ unit: '/100',
1549
+ grid,
1550
+ values: walkScore,
1551
+ ids: og.ids,
1552
+ display: { min: 0, max: 100, ramp: 'greens' },
1553
+ fields,
1554
+ routes: routeComparison ? [routeComparison.shortest, routeComparison.coolest] : null,
1555
+ routeComparison,
1556
+ stats: statsRows,
1557
+ notes: 'Pedestrian Cool Walkability Index (0–100) combining thermal comfort (UTCI, 35 %), shade (25 %), Green View Index (15 %) and acoustic comfort (15 %). When shown, the green line marks the coolest/shaded path and the orange line marks the shortest path.'
1558
+ };
1559
+ if (routeComparison && this.app?.tools?.outline) {
1560
+ result.onShow = () => {
1561
+ this.app.tools.outline('route_shortest', routeComparison.shortest.coords, 0xe4572e);
1562
+ this.app.tools.outline('route_coolest', routeComparison.coolest.coords, 0x1d8f5f);
1563
+ };
1564
+ result.onHide = () => {
1565
+ this.app.tools.outline('route_shortest', null);
1566
+ this.app.tools.outline('route_coolest', null);
1567
+ };
1568
+ }
1569
+ return [result];
1570
+ }
1571
+ }
1572
+
1573
+ // ---------------------------------------------------------------- helpers
1574
+ /** Where the road sources of a noise or dispersion run came from. */
1575
+ function roadSourceText(lines) {
1576
+ return lines.length ? `${lines.length} road line${lines.length === 1 ? '' : 's'}` : 'no roads given: the streets between buildings, as residential streets';
1577
+ }
1578
+
1579
+ function cacheKey(kind, a, cell) {
1580
+ return `${kind}:${a.minX.toFixed(1)},${a.maxX.toFixed(1)},${a.minY.toFixed(1)},${a.maxY.toFixed(1)}@${cell}`;
1581
+ }
1582
+
1583
+ function inArea(x, y, a) {
1584
+ return x >= a.minX && x <= a.maxX && y >= a.minY && y <= a.maxY;
1585
+ }
1586
+
1587
+ function clipArea(a, b) {
1588
+ return { minX: Math.max(a.minX, b.minX), maxX: Math.min(a.maxX, b.maxX), minY: Math.max(a.minY, b.minY), maxY: Math.min(a.maxY, b.maxY) };
1589
+ }
1590
+
1591
+ function polygons(geom) {
1592
+ const polys = geom?.type === 'MultiPolygon' ? geom.coordinates : geom?.type === 'Polygon' ? [geom.coordinates] : [];
1593
+ return polys.map((p) => p.map((ring) => {
1594
+ const n = ring.length;
1595
+ return n > 1 && ring[0][0] === ring[n - 1][0] && ring[0][1] === ring[n - 1][1] ? ring.slice(0, n - 1) : ring;
1596
+ }));
1597
+ }
1598
+
1599
+ function ringArea(ring) {
1600
+ let s = 0;
1601
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) s += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
1602
+ return s / 2;
1603
+ }
1604
+
1605
+ function perimeter(geom) {
1606
+ let p = 0;
1607
+ for (const poly of polygons(geom)) for (const ring of poly) for (let i = 0; i < ring.length; i++) {
1608
+ const [x0, y0] = ring[i], [x1, y1] = ring[(i + 1) % ring.length];
1609
+ p += Math.hypot(x1 - x0, y1 - y0);
1610
+ }
1611
+ return p;
1612
+ }
1613
+
1614
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
1615
+ export function monthName(m) { return MONTHS[m - 1]; }
1616
+
1617
+ /** Show frame k of a shadow study (values + a fresh drape texture). */
1618
+ export function setShadowFrame(result, k) {
1619
+ const frame = result.frames[Math.max(0, Math.min(result.frames.length - 1, k))];
1620
+ const v = result.values;
1621
+ for (let i = 0; i < v.length; i++) v[i] = frame[i] === 255 ? NaN : frame[i];
1622
+ result.frame = k;
1623
+ if (result._tex) { result._tex.dispose(); delete result._tex; }
1624
+ }
1625
+
1626
+ /** Show frame k of a continuous diurnal study (Float32Array values + fresh drape texture). */
1627
+ export function setDiurnalFrame(result, k) {
1628
+ const frame = result.frames[Math.max(0, Math.min(result.frames.length - 1, k))];
1629
+ result.values.set(frame);
1630
+ result.frame = k;
1631
+ if (result._tex) { result._tex.dispose(); delete result._tex; }
1632
+ }
1633
+
1634
+ /** The frame closest to a clock time, or -1 outside the study. */
1635
+ export function frameForHour(result, hour) {
1636
+ const hs = result.hours;
1637
+ const step = hs.length > 1 ? hs[1] - hs[0] : 1;
1638
+ if (hour < hs[0] - step / 2 || hour > hs[hs.length - 1] + step / 2) return -1;
1639
+ let best = 0;
1640
+ for (let i = 1; i < hs.length; i++) if (Math.abs(hs[i] - hour) < Math.abs(hs[best] - hour)) best = i;
1641
+ return best;
1642
+ }
1643
+ function fmtHour(h) { return `${String(Math.floor(h)).padStart(2, '0')}:${String(Math.round((h % 1) * 60)).padStart(2, '0')}`; }
1644
+ function fmtBig(v) { return Math.round(v).toLocaleString('en-US'); }