zero2geospatial3d-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// "Analyze" tab: analysis area, one card per analysis, and the results list.
|
|
3
|
+
import { h, clear, row, select, number, toggle, button, card, stats, download, toast, slider } from './dom.js';
|
|
4
|
+
import { MONTH_NAMES, windRose } from '../core/weather.js';
|
|
5
|
+
import { sendGrid, sendFields, sendRoutes, sendAllResults } from './qgis_link.js';
|
|
6
|
+
import { clearSkyYear, conditionsAt, typicalDoy } from '../core/weather.js';
|
|
7
|
+
import { solarPosition } from '../core/solar.js';
|
|
8
|
+
import { ExtrudedLayer } from '../scene/objects.js';
|
|
9
|
+
import { CRITERIA, PRESET_PROFILES, presetProfile, planningCheck, parseProfile, profileJson, defaultProfile } from '../analysis/compliance.js';
|
|
10
|
+
import { planningSheet, planningCsv } from '../export/check_pdf.js';
|
|
11
|
+
import { SURFACE_MATERIALS } from '../core/microclimate.js';
|
|
12
|
+
|
|
13
|
+
const MONTHS = MONTH_NAMES.map((m, i) => [i + 1, m]);
|
|
14
|
+
const DATE_PRESETS = [['3-21', '21 Mar (equinox)'], ['6-21', '21 Jun (summer solstice)'], ['12-21', '21 Dec (winter solstice)'], ['custom', 'Custom date']];
|
|
15
|
+
const PERIODS = [['year', 'Whole year'], ['summer', 'Summer (Jun–Aug)'], ['winter', 'Winter (Dec–Feb)'], ['heating', 'Heating season (Oct–Apr)']];
|
|
16
|
+
const PERIOD_MONTHS = { year: null, summer: [6, 7, 8], winter: [12, 1, 2], heating: [10, 11, 12, 1, 2, 3, 4] };
|
|
17
|
+
|
|
18
|
+
export function defaultParams(app) {
|
|
19
|
+
const rose = app.weather?.windSpeed ? windRose(app.weather) : null;
|
|
20
|
+
return {
|
|
21
|
+
sun: { preset: '3-21', month: 3, day: 21, start: 0, end: 24, step: 10, facades: false },
|
|
22
|
+
svf: { directions: 192, facades: false },
|
|
23
|
+
solar: { period: 'year', tilt: 0, azimuth: 180, efficiency: 0.2, pr: 0.8, coverage: 0.6, minShare: 0.7, facades: false },
|
|
24
|
+
view: { height: 1.6, radius: 600 },
|
|
25
|
+
wind: { from: rose ? rose.prevailingDirection : 270, speed: rose ? Math.max(1, rose.prevailingSpeed) : 4, cell: 0 },
|
|
26
|
+
comfort: { month: 7, day: 21, hour: 15, manual: !app.weather?.dryBulb, airTemp: 32, rh: 45, windSpeed: 2.5, windDir: rose ? rose.prevailingDirection : 270, material: 'concrete', lai: 3.0, treeHeight: 10, treeRadius: 3.5 },
|
|
27
|
+
vent: { from: rose ? rose.prevailingDirection : 270, speed: rose ? Math.max(1, rose.prevailingSpeed) : 3.5, emission: 60, background: 18, deposition: 0.25 },
|
|
28
|
+
noise: { power: 82, receiverHeight: 1.5, ground: 0.5 },
|
|
29
|
+
form: { eyeHeight: 1.6, radius: 80, preset: '12-21', month: 12, day: 21, start: 9, end: 15, minHours: 2, maxH: 72, floorH: 3.2 },
|
|
30
|
+
walk: { coolBias: 2.8, startXY: null, endXY: null },
|
|
31
|
+
morph: { block: 100, from: rose ? rose.prevailingDirection : 270 },
|
|
32
|
+
climate: { from: 7, to: 22, sectors: 8, ring: 15 },
|
|
33
|
+
check: { scope: 'selected', preset: 'defaults', criteria: CRITERIA.map((c) => c.id), thresholds: Object.fromEntries(CRITERIA.map((c) => [c.id, c.threshold])) }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildAnalyzePanel(app, root) {
|
|
38
|
+
app.params = defaultParams(app);
|
|
39
|
+
const resultsBox = h('div', { class: 'results' });
|
|
40
|
+
const render = () => {
|
|
41
|
+
clear(root);
|
|
42
|
+
root.appendChild(areaCard(app));
|
|
43
|
+
root.appendChild(sunCard(app));
|
|
44
|
+
root.appendChild(svfCard(app));
|
|
45
|
+
root.appendChild(vscCard(app));
|
|
46
|
+
root.appendChild(solarCard(app));
|
|
47
|
+
root.appendChild(viewCard(app));
|
|
48
|
+
root.appendChild(windCard(app));
|
|
49
|
+
root.appendChild(comfortCard(app));
|
|
50
|
+
root.appendChild(walkabilityCard(app));
|
|
51
|
+
root.appendChild(morphCard(app));
|
|
52
|
+
root.appendChild(ventilationCard(app));
|
|
53
|
+
root.appendChild(noiseCard(app));
|
|
54
|
+
root.appendChild(urbanFormCard(app));
|
|
55
|
+
root.appendChild(climateCard(app));
|
|
56
|
+
root.appendChild(checkCard(app));
|
|
57
|
+
root.appendChild(h('h3', { class: 'section', text: 'Results' }));
|
|
58
|
+
root.appendChild(resultsBox);
|
|
59
|
+
renderResults(app, resultsBox);
|
|
60
|
+
};
|
|
61
|
+
app.on((what) => {
|
|
62
|
+
if (what === 'results' || what === 'active') renderResults(app, resultsBox);
|
|
63
|
+
if (what === 'weather') { app.params = defaultParams(app); render(); }
|
|
64
|
+
if (what === 'params' || what === 'trees') render();
|
|
65
|
+
if (what === 'busy') root.querySelectorAll('button.run').forEach((b) => { /** @type {any} */ (b).disabled = app.busy; });
|
|
66
|
+
});
|
|
67
|
+
render();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function runButton(app, label, fn) {
|
|
71
|
+
return button(label, fn, { kind: 'primary run' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------- area
|
|
75
|
+
function areaCard(app) {
|
|
76
|
+
const a = app.analysisArea;
|
|
77
|
+
const info = h('div', { class: 'muted small' });
|
|
78
|
+
const update = () => {
|
|
79
|
+
const ar = app.area();
|
|
80
|
+
const cell = app.cell();
|
|
81
|
+
const cols = Math.ceil((ar.maxX - ar.minX) / cell), rows = Math.ceil((ar.maxY - ar.minY) / cell);
|
|
82
|
+
info.textContent = `${((ar.maxX - ar.minX) / 1000).toFixed(2)} × ${((ar.maxY - ar.minY) / 1000).toFixed(2)} km at ${cell} m → ${(cols * rows / 1e6).toFixed(2)} M cells`;
|
|
83
|
+
app.showAreaOutline();
|
|
84
|
+
};
|
|
85
|
+
const radius = number(a.radius, { min: 50, step: 50, onChange: (v) => { a.radius = v; update(); } });
|
|
86
|
+
const body = h('div', {},
|
|
87
|
+
row('Area', select([['scene', 'Whole scene'], ['view', 'Around the view centre'], ['rect', 'Drawn rectangle']], a.mode, (v) => {
|
|
88
|
+
a.mode = v;
|
|
89
|
+
if (v === 'rect' && !a.rect) drawRect();
|
|
90
|
+
update();
|
|
91
|
+
})),
|
|
92
|
+
row('Radius (m)', radius, 'For "Around the view centre"'),
|
|
93
|
+
row('Cell size', select([[0, 'Automatic'], [0.5, '0.5 m'], [1, '1 m'], [2, '2 m'], [4, '4 m'], [8, '8 m']], app.cellSize, (v) => { app.cellSize = Number(v); update(); }),
|
|
94
|
+
'Smaller cells are sharper and slower. Large areas are split into tiles automatically.'),
|
|
95
|
+
h('div', { class: 'btn-row' }, button('Draw rectangle', () => drawRect()), button('Show area', () => update())),
|
|
96
|
+
info);
|
|
97
|
+
async function drawRect() {
|
|
98
|
+
const rect = await app.tools.drawRect('Draw the analysis area');
|
|
99
|
+
if (rect) { a.rect = rect; a.mode = 'rect'; update(); }
|
|
100
|
+
}
|
|
101
|
+
requestAnimationFrame(update);
|
|
102
|
+
return card('Analysis area', body, { open: true });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------- sun
|
|
106
|
+
function dateRows(p, onChange = () => {}) {
|
|
107
|
+
const wrap = h('div', {});
|
|
108
|
+
const draw = () => {
|
|
109
|
+
clear(wrap);
|
|
110
|
+
wrap.appendChild(row('Date', select(DATE_PRESETS, p.preset, (v) => {
|
|
111
|
+
p.preset = v;
|
|
112
|
+
if (v !== 'custom') { const [m, d] = v.split('-').map(Number); p.month = m; p.day = d; }
|
|
113
|
+
draw(); onChange();
|
|
114
|
+
})));
|
|
115
|
+
if (p.preset === 'custom') {
|
|
116
|
+
wrap.appendChild(row('Month / day', h('span', { class: 'inline' },
|
|
117
|
+
select(MONTHS, p.month, (v) => { p.month = Number(v); onChange(); }),
|
|
118
|
+
number(p.day, { min: 1, max: 31, step: 1, width: '4em', onChange: (v) => { p.day = v; onChange(); } }))));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
draw();
|
|
122
|
+
return wrap;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sunCard(app) {
|
|
126
|
+
const p = app.params.sun;
|
|
127
|
+
const body = h('div', {},
|
|
128
|
+
h('p', { class: 'muted small', text: 'Direct sun hours on the ground and roofs (and optionally facades) for one day, and the share of the day each place spends in shade.' }),
|
|
129
|
+
dateRows(p),
|
|
130
|
+
row('From – to (h)', h('span', { class: 'inline' },
|
|
131
|
+
number(p.start, { min: 0, max: 24, step: 0.5, width: '4.5em', onChange: (v) => { p.start = v; } }),
|
|
132
|
+
number(p.end, { min: 0, max: 24, step: 0.5, width: '4.5em', onChange: (v) => { p.end = v; } }))),
|
|
133
|
+
row('Time step', select([[5, '5 min'], [10, '10 min'], [15, '15 min'], [30, '30 min']], p.step, (v) => { p.step = Number(v); })),
|
|
134
|
+
row('Facades too', toggle(p.facades, (v) => { p.facades = v; })),
|
|
135
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run sun hours', () => app.run('Sun hours', (prog) => app.analyst.sunHours({
|
|
136
|
+
area: app.area(), cell: app.cell(), date: { month: p.month, day: p.day }, startHour: p.start, endHour: p.end,
|
|
137
|
+
stepMinutes: p.step, facades: p.facades, onProgress: prog })))),
|
|
138
|
+
h('div', { class: 'btn-row' },
|
|
139
|
+
runButton(app, 'Animate shadows', async () => {
|
|
140
|
+
const res = await app.run('Shadow study', (prog) => app.analyst.shadowStudy({
|
|
141
|
+
area: app.area(), cell: app.cell(800), date: { month: p.month, day: p.day },
|
|
142
|
+
startHour: Math.max(p.start, 4), endHour: Math.min(p.end, 21), stepMinutes: 30, onProgress: prog }));
|
|
143
|
+
if (res?.[0]) {
|
|
144
|
+
app.date = { month: p.month, day: p.day };
|
|
145
|
+
app.time = res[0].hours[0];
|
|
146
|
+
app.updateSun();
|
|
147
|
+
toast('Press ▶ on the sun clock (or drag it) to play the day.');
|
|
148
|
+
}
|
|
149
|
+
}),
|
|
150
|
+
runButton(app, 'Summer ⇆ winter', async () => {
|
|
151
|
+
const run = (month) => app.analyst.sunHours({ area: app.area(), cell: app.cell(), date: { month, day: 21 }, startHour: 0, endHour: 24,
|
|
152
|
+
stepMinutes: p.step, onProgress: () => {} });
|
|
153
|
+
const out = await app.run('Summer and winter sun', async (prog) => {
|
|
154
|
+
prog(0.1);
|
|
155
|
+
const summer = await run(6);
|
|
156
|
+
prog(0.55);
|
|
157
|
+
const winter = await run(12);
|
|
158
|
+
return [summer[0], winter[0], summer[1], winter[1]];
|
|
159
|
+
});
|
|
160
|
+
if (out && app.compare) {
|
|
161
|
+
app.compare.enable('swipe');
|
|
162
|
+
app.compare.states[0].result = out[0].id;
|
|
163
|
+
app.compare.states[1].result = out[1].id;
|
|
164
|
+
app.compare._buildDom();
|
|
165
|
+
app.viewer.requestRender();
|
|
166
|
+
}
|
|
167
|
+
}, { title: 'Sun hours on 21 June and 21 December, side by side' })));
|
|
168
|
+
return card('Sun hours & shadows', body, { open: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function svfCard(app) {
|
|
172
|
+
const p = app.params.svf;
|
|
173
|
+
const body = h('div', {},
|
|
174
|
+
h('p', { class: 'muted small', text: 'How much of the sky each point sees (1 = open sky). Low values mean deep street canyons, warmer nights and less daylight.' }),
|
|
175
|
+
row('Sky directions', select([[96, '96 (fast)'], [192, '192'], [384, '384 (fine)']], p.directions, (v) => { p.directions = Number(v); })),
|
|
176
|
+
row('Facades too', toggle(p.facades, (v) => { p.facades = v; })),
|
|
177
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run sky view factor', () => app.run('Sky view factor', (prog) => app.analyst.skyView({
|
|
178
|
+
area: app.area(), cell: app.cell(), directions: p.directions, facades: p.facades, onProgress: prog })))));
|
|
179
|
+
return card('Sky view factor', body);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function vscCard(app) {
|
|
183
|
+
app.params.vsc ??= { directions: 192 };
|
|
184
|
+
const p = app.params.vsc;
|
|
185
|
+
const body = h('div', {},
|
|
186
|
+
h('p', { class: 'muted small', text: 'Vertical sky component on every wall (BRE 209): how much daylight from an overcast sky reaches the facade. 39.6 % is an open wall; 27 % or more keeps a window well lit.' }),
|
|
187
|
+
row('Sky directions', select([[96, '96 (fast)'], [192, '192'], [384, '384 (fine)']], p.directions, (v) => { p.directions = Number(v); })),
|
|
188
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run daylight (VSC)', () => app.run('Daylight (VSC)', (prog) => app.analyst.daylightVsc({
|
|
189
|
+
area: app.area(), directions: p.directions, onProgress: prog })))));
|
|
190
|
+
return card('Daylight on facades (VSC)', body);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function solarCard(app) {
|
|
194
|
+
const p = app.params.solar;
|
|
195
|
+
const source = app.weather?.dryBulb ? `Weather: ${app.weather.location.city || app.weatherSource}` : 'No EPW: clear-sky estimate (an upper bound)';
|
|
196
|
+
const body = h('div', {},
|
|
197
|
+
h('p', { class: 'muted small', text: `Irradiation on roofs and ground (and facades) with shading, plus rooftop PV yield per building. ${source}.` }),
|
|
198
|
+
row('Period', select(PERIODS, p.period, (v) => { p.period = v; })),
|
|
199
|
+
row('Panel tilt (°)', number(p.tilt, { min: 0, max: 90, step: 5, onChange: (v) => { p.tilt = v; } }), '0 = flat; analysed surfaces are tilted towards the azimuth'),
|
|
200
|
+
row('Panel azimuth (°)', number(p.azimuth, { min: 0, max: 360, step: 5, onChange: (v) => { p.azimuth = v; } }), '180 = south'),
|
|
201
|
+
row('Module efficiency', number(p.efficiency, { min: 0.05, max: 0.3, step: 0.01, onChange: (v) => { p.efficiency = v; } })),
|
|
202
|
+
row('Performance ratio', number(p.pr, { min: 0.5, max: 0.95, step: 0.01, onChange: (v) => { p.pr = v; } })),
|
|
203
|
+
row('Panel coverage', number(p.coverage, { min: 0.1, max: 1, step: 0.05, onChange: (v) => { p.coverage = v; } }), 'Share of a suitable roof that can hold panels'),
|
|
204
|
+
row('Suitable if ≥', number(p.minShare, { min: 0.3, max: 1, step: 0.05, onChange: (v) => { p.minShare = v; } }), 'Share of the unshaded potential a roof cell must keep'),
|
|
205
|
+
row('Facades too', toggle(p.facades, (v) => { p.facades = v; })),
|
|
206
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run solar & PV', () => app.run('Solar radiation', (prog) => app.analyst.solarRadiation({
|
|
207
|
+
...solarOptions(app), facades: p.facades, onProgress: prog })))));
|
|
208
|
+
return card('Solar radiation & PV', body);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The solar card's settings as runner options (also used by Compare). */
|
|
212
|
+
export function solarOptions(app) {
|
|
213
|
+
const p = app.params.solar;
|
|
214
|
+
return { area: app.area(), cell: app.cell(), months: PERIOD_MONTHS[p.period], tiltDeg: p.tilt, azimuthDeg: p.azimuth,
|
|
215
|
+
efficiency: p.efficiency, performanceRatio: p.pr, coverage: p.coverage, minShare: p.minShare };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The comfort card's weather for its date and hour: the EPW hour, or the
|
|
220
|
+
* typed values with clear-sky radiation (also used by Compare).
|
|
221
|
+
*/
|
|
222
|
+
export function comfortConditions(app) {
|
|
223
|
+
const p = app.params.comfort;
|
|
224
|
+
if (!p.manual && app.weather?.dryBulb) return conditionsAt(app.weather, p.month, p.day, p.hour);
|
|
225
|
+
const s = app.site;
|
|
226
|
+
const sky = clearSkyYear(s.lat, s.lon, s.utcOffset);
|
|
227
|
+
const i = (typicalDoy(p.month, p.day) - 1) * 24 + (p.hour - 1);
|
|
228
|
+
return { airTemp: p.airTemp, rh: p.rh, windSpeed: p.windSpeed, windDir: p.windDir, ghi: sky.ghi[i], dni: sky.dni[i], dhi: sky.dhi[i], ir: NaN };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Points along the top edge of a building, 1 m outside the walls and just
|
|
233
|
+
* above the roof: a viewshed from them is where the building can be seen
|
|
234
|
+
* from (its own roof would hide almost everything from the roof's centre).
|
|
235
|
+
*/
|
|
236
|
+
function roofEdgePoints(app, layer, f, count = 8) {
|
|
237
|
+
const g = layer.features[f].geometry;
|
|
238
|
+
const polys = g?.type === 'Polygon' ? [g.coordinates] : g?.type === 'MultiPolygon' ? g.coordinates : [];
|
|
239
|
+
const ring = polys.map((pp) => pp[0]).filter(Boolean).sort((a, b) => b.length - a.length)[0] || [];
|
|
240
|
+
const top = app.terrain.toY(layer.baseZ[f]) + layer.heights[f] + 0.3;
|
|
241
|
+
const cx = layer.centroid[f * 2], cy = layer.centroid[f * 2 + 1];
|
|
242
|
+
const seg = [];
|
|
243
|
+
let total = 0;
|
|
244
|
+
for (let i = 0; i + 1 < ring.length; i++) {
|
|
245
|
+
const len = Math.hypot(ring[i + 1][0] - ring[i][0], ring[i + 1][1] - ring[i][1]);
|
|
246
|
+
seg.push([ring[i], ring[i + 1], total, len]);
|
|
247
|
+
total += len;
|
|
248
|
+
}
|
|
249
|
+
if (!total) return [{ x: cx, y: top + 1.3, z: -cy }];
|
|
250
|
+
const out = [];
|
|
251
|
+
for (let k = 0; k < count; k++) {
|
|
252
|
+
const d = (k + 0.5) * total / count;
|
|
253
|
+
const [a, b, s0, len] = seg.find(([, , s0, len]) => d <= s0 + len) || seg[seg.length - 1];
|
|
254
|
+
const t = len ? (d - s0) / len : 0;
|
|
255
|
+
let x = a[0] + t * (b[0] - a[0]), y = a[1] + t * (b[1] - a[1]);
|
|
256
|
+
const r = Math.hypot(x - cx, y - cy) || 1;
|
|
257
|
+
x += (x - cx) / r; y += (y - cy) / r; // 1 m outwards
|
|
258
|
+
out.push({ x, y: top, z: -y });
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function viewCard(app) {
|
|
264
|
+
const p = app.params.view;
|
|
265
|
+
const fromSelected = async () => {
|
|
266
|
+
for (const layer of app.layers) {
|
|
267
|
+
if (!(layer instanceof ExtrudedLayer)) continue;
|
|
268
|
+
const sel = app.selectedIndices(layer);
|
|
269
|
+
if (!sel.length) continue;
|
|
270
|
+
return app.run('Where the building is seen from', (prog) => app.analyst.viewshed({
|
|
271
|
+
area: app.area(), cell: app.cell(), eyes: roofEdgePoints(app, layer, sel[0]), radius: p.radius, onProgress: prog }));
|
|
272
|
+
}
|
|
273
|
+
toast('Select a building first (click it).');
|
|
274
|
+
return null;
|
|
275
|
+
};
|
|
276
|
+
const pick = async () => {
|
|
277
|
+
const pt = await app.tools.pickPoint('Click the observer position (ground or roof)');
|
|
278
|
+
if (pt) run(pt.world, 'from the picked point');
|
|
279
|
+
};
|
|
280
|
+
const run = (eye, how) => app.run(`Viewshed ${how}`, (prog) => app.analyst.viewshed({
|
|
281
|
+
area: app.area(), cell: app.cell(), eye, observerHeight: p.height, radius: p.radius, onProgress: prog }));
|
|
282
|
+
const body = h('div', {},
|
|
283
|
+
h('p', { class: 'muted small', text: 'Which surfaces an observer can see. From the selected building: the ground and roofs that see its top edge, which is where the building can be seen from.' }),
|
|
284
|
+
row('Eye height (m)', number(p.height, { min: 0, step: 0.1, onChange: (v) => { p.height = v; } })),
|
|
285
|
+
row('Radius (m)', number(p.radius, { min: 50, step: 50, onChange: (v) => { p.radius = v; } })),
|
|
286
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Pick observer…', pick), runButton(app, 'From selected building', fromSelected)));
|
|
287
|
+
return card('Visibility', body);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function windCard(app) {
|
|
291
|
+
const p = app.params.wind;
|
|
292
|
+
const rose = app.weather?.windSpeed ? windRose(app.weather) : null;
|
|
293
|
+
const p95 = rose ? percentile(app.weather.windSpeed, 0.95) : null;
|
|
294
|
+
const body = h('div', {},
|
|
295
|
+
h('p', { class: 'muted small', text: 'Screening of pedestrian wind: wakes behind buildings, channelling along streets and corner streams of tall buildings. Not CFD.' }),
|
|
296
|
+
row('Wind from (°)', number(p.from, { min: 0, max: 359, step: 5, onChange: (v) => { p.from = v; } })),
|
|
297
|
+
row('Speed at 10 m', number(p.speed, { min: 0.5, max: 30, step: 0.5, onChange: (v) => { p.speed = v; } })),
|
|
298
|
+
rose ? h('div', { class: 'btn-row' },
|
|
299
|
+
button('Prevailing', () => { p.from = rose.prevailingDirection; p.speed = Math.round(rose.prevailingSpeed * 10) / 10; app.emit('params'); }),
|
|
300
|
+
button('95th percentile', () => { p.from = rose.prevailingDirection; p.speed = Math.round(p95 * 10) / 10; app.emit('params'); },
|
|
301
|
+
{ title: 'Use with the comfort classes, which are defined on the speed exceeded 5 % of the time' })) : null,
|
|
302
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run wind', () => app.run('Wind screening', (prog) => app.analyst.wind({
|
|
303
|
+
area: app.area(), cell: Math.max(2, app.cell(600)), windFromDeg: p.from, speed10: p.speed, onProgress: prog })))));
|
|
304
|
+
return card('Wind', body);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function comfortCard(app) {
|
|
308
|
+
const p = app.params.comfort;
|
|
309
|
+
const condBox = h('div', {});
|
|
310
|
+
const conditions = () => comfortConditions(app);
|
|
311
|
+
const drawCond = () => {
|
|
312
|
+
clear(condBox);
|
|
313
|
+
const c = conditions();
|
|
314
|
+
const pos = solarPosition(typicalDoy(p.month, p.day), p.hour - 0.5, app.site.lat, app.site.lon, app.site.utcOffset);
|
|
315
|
+
if (p.manual || !app.weather?.dryBulb) {
|
|
316
|
+
condBox.appendChild(row('Air temperature (°C)', number(p.airTemp, { step: 0.5, onChange: (v) => { p.airTemp = v; } })));
|
|
317
|
+
condBox.appendChild(row('Humidity (%)', number(p.rh, { min: 0, max: 100, step: 5, onChange: (v) => { p.rh = v; } })));
|
|
318
|
+
condBox.appendChild(row('Wind at 10 m (m/s)', number(p.windSpeed, { min: 0, step: 0.5, onChange: (v) => { p.windSpeed = v; } })));
|
|
319
|
+
condBox.appendChild(row('Wind from (°)', number(p.windDir, { min: 0, max: 359, step: 5, onChange: (v) => { p.windDir = v; } })));
|
|
320
|
+
}
|
|
321
|
+
condBox.appendChild(stats([
|
|
322
|
+
['Sun elevation', `${(pos.elevation * 180 / Math.PI).toFixed(1)}°`],
|
|
323
|
+
['Air / humidity', `${c.airTemp.toFixed(1)} °C / ${Math.round(c.rh)} %`],
|
|
324
|
+
['Wind', `${c.windSpeed.toFixed(1)} m/s from ${Math.round(c.windDir)}°`],
|
|
325
|
+
['Direct / diffuse', `${Math.round(c.dni)} / ${Math.round(c.dhi)} W/m²`]
|
|
326
|
+
]));
|
|
327
|
+
};
|
|
328
|
+
const treeCountText = h('span', { class: 'muted small', text: `${app.plantedTrees?.length || 0} planted` });
|
|
329
|
+
const body = h('div', {},
|
|
330
|
+
h('p', { class: 'muted small', text: 'Outdoor heat stress (UTCI) and Physiological Equivalent Temperature (PET) at pedestrian level with surface energy balance.' }),
|
|
331
|
+
row('Month / day', h('span', { class: 'inline' },
|
|
332
|
+
select(MONTHS, p.month, (v) => { p.month = Number(v); drawCond(); }),
|
|
333
|
+
number(p.day, { min: 1, max: 31, step: 1, width: '4em', onChange: (v) => { p.day = v; drawCond(); } }))),
|
|
334
|
+
row('Hour ending', select(Array.from({ length: 24 }, (_, i) => [i + 1, `${String(i).padStart(2, '0')}:00–${String(i + 1).padStart(2, '0')}:00`]), p.hour, (v) => { p.hour = Number(v); drawCond(); })),
|
|
335
|
+
row('Surface material', select(Object.entries(SURFACE_MATERIALS).map(([k, v]) => [k, v.label]), p.material, (v) => { p.material = v; })),
|
|
336
|
+
row('Tree canopy LAI', number(p.lai, { min: 0.5, max: 6, step: 0.5, onChange: (v) => { p.lai = v; } })),
|
|
337
|
+
app.weather?.dryBulb ? row('Conditions', select([['epw', 'From the weather file'], ['manual', 'Enter manually']], p.manual ? 'manual' : 'epw', (v) => { p.manual = v === 'manual'; drawCond(); })) : null,
|
|
338
|
+
condBox,
|
|
339
|
+
row('What-if greening', h('span', { class: 'inline' },
|
|
340
|
+
button('Plant tree…', async () => {
|
|
341
|
+
const pt = await app.tools.pickPoint('Click ground to plant a tree', { roofs: false });
|
|
342
|
+
if (pt) app.addPlantedTree(pt.local[0], pt.local[1], p.treeHeight, p.treeRadius);
|
|
343
|
+
}),
|
|
344
|
+
button('Clear trees', () => app.clearPlantedTrees()),
|
|
345
|
+
treeCountText)),
|
|
346
|
+
h('div', { class: 'btn-row' },
|
|
347
|
+
runButton(app, 'Run UTCI & PET', () => {
|
|
348
|
+
const c = conditions();
|
|
349
|
+
return app.run('Thermal comfort', (prog) => app.analyst.comfort({ area: app.area(), cell: Math.max(1, app.cell(700)),
|
|
350
|
+
month: p.month, day: p.day, hour: p.hour, conditions: c, material: p.material, lai: p.lai, onProgress: prog }));
|
|
351
|
+
}),
|
|
352
|
+
runButton(app, '24 h diurnal study', async () => {
|
|
353
|
+
const res = await app.run('Diurnal thermal study', (prog) => app.analyst.diurnalStudy({
|
|
354
|
+
area: app.area(), cell: Math.max(2, app.cell(600)), date: { month: p.month, day: p.day },
|
|
355
|
+
material: p.material, lai: p.lai, onProgress: prog,
|
|
356
|
+
manual: p.manual || !app.weather?.dryBulb
|
|
357
|
+
? { airTemp: p.airTemp, rh: p.rh, windSpeed: p.windSpeed, windDir: p.windDir, hour: p.hour } : null }));
|
|
358
|
+
if (res?.[0]) {
|
|
359
|
+
app.date = { month: p.month, day: p.day };
|
|
360
|
+
app.time = res[0].hours ? res[0].hours[0] : 12;
|
|
361
|
+
app.updateSun();
|
|
362
|
+
toast('Press ▶ on the sun clock (or drag it) to play the day.');
|
|
363
|
+
}
|
|
364
|
+
})));
|
|
365
|
+
drawCond();
|
|
366
|
+
return card('Thermal comfort (UTCI & PET)', body);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function walkabilityCard(app) {
|
|
370
|
+
app.params.walk ??= { coolBias: 2.8, startXY: null, endXY: null };
|
|
371
|
+
const p = app.params.walk;
|
|
372
|
+
const routeNote = h('span', { class: 'muted small' });
|
|
373
|
+
const updateNote = () => {
|
|
374
|
+
routeNote.textContent = p.startXY && p.endXY
|
|
375
|
+
? `Start (${p.startXY[0].toFixed(0)}, ${p.startXY[1].toFixed(0)}) → End (${p.endXY[0].toFixed(0)}, ${p.endXY[1].toFixed(0)})`
|
|
376
|
+
: 'Default diagonal across the analysis area (or pick start & end)';
|
|
377
|
+
};
|
|
378
|
+
updateNote();
|
|
379
|
+
const body = h('div', {},
|
|
380
|
+
h('p', { class: 'muted small', text: 'Pedestrian Cool Walkability Index (0–100) combining UTCI thermal comfort, shade, Green View Index and traffic noise, plus A* pathfinding comparing the shortest route vs. the coolest & shaded route.' }),
|
|
381
|
+
row('Comfort weight', number(p.coolBias, { min: 0.5, max: 8, step: 0.2, onChange: (v) => { p.coolBias = v; } }),
|
|
382
|
+
'Higher values allow a slightly longer detour to stay in shade, green streets and lower heat stress'),
|
|
383
|
+
row('Route endpoints', h('span', { class: 'inline' },
|
|
384
|
+
button('Pick start & end…', async () => {
|
|
385
|
+
const sPt = await app.tools.pickPoint('Click pedestrian start point on open ground', { roofs: false });
|
|
386
|
+
if (!sPt) return;
|
|
387
|
+
const ePt = await app.tools.pickPoint('Click pedestrian destination point on open ground', { roofs: false });
|
|
388
|
+
if (!ePt) return;
|
|
389
|
+
p.startXY = [sPt.local[0], sPt.local[1]];
|
|
390
|
+
p.endXY = [ePt.local[0], ePt.local[1]];
|
|
391
|
+
updateNote();
|
|
392
|
+
}),
|
|
393
|
+
button('Reset', () => {
|
|
394
|
+
p.startXY = null;
|
|
395
|
+
p.endXY = null;
|
|
396
|
+
app.tools?.outline?.('route_shortest', null);
|
|
397
|
+
app.tools?.outline?.('route_coolest', null);
|
|
398
|
+
updateNote();
|
|
399
|
+
}))),
|
|
400
|
+
routeNote,
|
|
401
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run walkability & cool route', () => {
|
|
402
|
+
const c = comfortConditions(app);
|
|
403
|
+
const cp = app.params.comfort;
|
|
404
|
+
return app.run('Walkability & cool route', (prog) => app.analyst.walkability({
|
|
405
|
+
area: app.area(), cell: Math.max(2, app.cell(600)),
|
|
406
|
+
month: cp.month, day: cp.day, hour: cp.hour, conditions: c,
|
|
407
|
+
material: cp.material, lai: cp.lai, soundPowerDb: app.params.noise.power,
|
|
408
|
+
startXY: p.startXY, endXY: p.endXY, coolBias: p.coolBias, onProgress: prog
|
|
409
|
+
}));
|
|
410
|
+
})));
|
|
411
|
+
return card('Pedestrian walkability & cool route', body);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function morphCard(app) {
|
|
415
|
+
const p = app.params.morph;
|
|
416
|
+
const body = h('div', {},
|
|
417
|
+
h('p', { class: 'muted small', text: 'Heat-island indicators per block: built fraction, heights, street canyon ratio, frontal area, roughness and an approximate Local Climate Zone. Run the sky view factor first to include it.' }),
|
|
418
|
+
row('Block size (m)', select([[50, '50 m'], [100, '100 m'], [200, '200 m'], [250, '250 m']], p.block, (v) => { p.block = Number(v); })),
|
|
419
|
+
row('Wind from (°)', number(p.from, { min: 0, max: 359, step: 5, onChange: (v) => { p.from = v; } }), 'For the frontal area index'),
|
|
420
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run morphology', () => app.run('Urban morphology', (prog) => app.analyst.morphology({
|
|
421
|
+
area: app.area(), cell: Math.max(1, app.cell(800)), blockSize: p.block, windFromDeg: p.from, onProgress: prog })))));
|
|
422
|
+
return card('Heat island & morphology', body);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function ventilationCard(app) {
|
|
426
|
+
const p = app.params.vent;
|
|
427
|
+
const roadsInfo = h('span', { class: 'muted small', text: `${app.drawnRoads?.length || 0} drawn` });
|
|
428
|
+
const roadsNote = roadSourcesNote(app);
|
|
429
|
+
const body = h('div', {},
|
|
430
|
+
h('p', { class: 'muted small', text: 'Street canyon ventilation efficiency (VEI), local air age and line-source pollutant dispersion with vegetative canopy filtration.' }),
|
|
431
|
+
row('Wind from (°)', number(p.from, { min: 0, max: 359, step: 5, onChange: (v) => { p.from = v; } })),
|
|
432
|
+
row('Speed at 10 m (m/s)', number(p.speed, { min: 0.5, max: 25, step: 0.5, onChange: (v) => { p.speed = v; } })),
|
|
433
|
+
row('Traffic emission (µg/(m·s))', number(p.emission, { min: 1, max: 2000, step: 5, onChange: (v) => { p.emission = v; } }),
|
|
434
|
+
'NO2 per metre of a road with traffic 1: about 60 for a busy two-lane road (20,000 vehicles a day)'),
|
|
435
|
+
row('Background (µg/m³)', number(p.background, { min: 0, max: 200, step: 1, onChange: (v) => { p.background = v; } })),
|
|
436
|
+
row('Tree filtration share', number(p.deposition, { min: 0, max: 0.8, step: 0.05, onChange: (v) => { p.deposition = v; } })),
|
|
437
|
+
roadsNote,
|
|
438
|
+
row('Road line sources', h('span', { class: 'inline' },
|
|
439
|
+
button('Draw road source…', async () => {
|
|
440
|
+
const line = await app.tools.drawLine('Click points along the road source');
|
|
441
|
+
if (line) {
|
|
442
|
+
app.drawnRoads.push(line);
|
|
443
|
+
app.tools.outline(`drawn_road_${app.drawnRoads.length}`, line, 0xff4f2e);
|
|
444
|
+
roadsInfo.textContent = `${app.drawnRoads.length} drawn`;
|
|
445
|
+
}
|
|
446
|
+
}),
|
|
447
|
+
button('Clear roads', () => {
|
|
448
|
+
for (let i = 1; i <= app.drawnRoads.length; i++) app.tools.outline(`drawn_road_${i}`, null);
|
|
449
|
+
app.drawnRoads = [];
|
|
450
|
+
roadsInfo.textContent = '0 drawn';
|
|
451
|
+
}),
|
|
452
|
+
roadsInfo)),
|
|
453
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run ventilation & dispersion', () => app.run('Ventilation & dispersion', (prog) => app.analyst.ventilation({
|
|
454
|
+
area: app.area(), cell: Math.max(2, app.cell(600)), windFromDeg: p.from, speed10: p.speed,
|
|
455
|
+
emissionRateUgMS: p.emission, backgroundUgM3: p.background, treeDepositionFactor: p.deposition,
|
|
456
|
+
onProgress: prog })))));
|
|
457
|
+
return card('Canyon ventilation & air quality', body);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Which roads the noise and dispersion runs will use. */
|
|
461
|
+
function roadSourcesNote(app) {
|
|
462
|
+
const layers = app.layers.filter((l) => l.entry.role === 'roads');
|
|
463
|
+
const text = layers.length
|
|
464
|
+
? `Roads: ${layers.map((l) => l.entry.name).join(', ')} (Traffic expression from QGIS), plus lines drawn here.`
|
|
465
|
+
: 'No layer has the Roads role: draw road lines here, or give a QGIS line layer the Roads role. Without roads, street corridors between buildings are used.';
|
|
466
|
+
return h('p', { class: 'muted small', text });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function noiseCard(app) {
|
|
470
|
+
const p = app.params.noise;
|
|
471
|
+
const roadsInfo = h('span', { class: 'muted small', text: `${app.drawnRoads?.length || 0} drawn` });
|
|
472
|
+
const roadsNote = roadSourcesNote(app);
|
|
473
|
+
const body = h('div', {},
|
|
474
|
+
h('p', { class: 'muted small', text: 'Environmental road traffic noise propagation, Maekawa building edge diffraction and facade quiet-side detection.' }),
|
|
475
|
+
row('Road sound power Lw (dB(A)/m)', number(p.power, { min: 50, max: 110, step: 1, onChange: (v) => { p.power = v; } }),
|
|
476
|
+
'Lden per metre of a road with traffic 1: about 82 for a busy two-lane road at 50 km/h'),
|
|
477
|
+
row('Receiver height (m)', number(p.receiverHeight, { min: 0.5, max: 10, step: 0.5, onChange: (v) => { p.receiverHeight = v; } })),
|
|
478
|
+
row('Ground absorption', number(p.ground, { min: 0, max: 1, step: 0.1, onChange: (v) => { p.ground = v; } }), '0 = hard paved ground, 1 = soft grass / soil'),
|
|
479
|
+
roadsNote,
|
|
480
|
+
row('Road line sources', h('span', { class: 'inline' },
|
|
481
|
+
button('Draw road source…', async () => {
|
|
482
|
+
const line = await app.tools.drawLine('Click points along the road source');
|
|
483
|
+
if (line) {
|
|
484
|
+
app.drawnRoads.push(line);
|
|
485
|
+
app.tools.outline(`drawn_road_${app.drawnRoads.length}`, line, 0xff4f2e);
|
|
486
|
+
roadsInfo.textContent = `${app.drawnRoads.length} drawn`;
|
|
487
|
+
}
|
|
488
|
+
}),
|
|
489
|
+
button('Clear roads', () => {
|
|
490
|
+
for (let i = 1; i <= app.drawnRoads.length; i++) app.tools.outline(`drawn_road_${i}`, null);
|
|
491
|
+
app.drawnRoads = [];
|
|
492
|
+
roadsInfo.textContent = '0 drawn';
|
|
493
|
+
}),
|
|
494
|
+
roadsInfo)),
|
|
495
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run noise map', () => app.run('Environmental noise', (prog) => app.analyst.noise({
|
|
496
|
+
area: app.area(), cell: Math.max(2, app.cell(600)), soundPowerDb: p.power,
|
|
497
|
+
receiverHeight: p.receiverHeight, groundFactor: p.ground, onProgress: prog })))));
|
|
498
|
+
return card('Acoustic & noise screening', body);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function urbanFormCard(app) {
|
|
502
|
+
const p = app.params.form;
|
|
503
|
+
const body = h('div', {},
|
|
504
|
+
h('p', { class: 'muted small', text: 'Panoramic pedestrian Green View Index (GVI) and Knowles / BRE right-to-light 3D solar envelope volume.' }),
|
|
505
|
+
row('Eye height (m)', number(p.eyeHeight, { min: 0.5, max: 3, step: 0.1, onChange: (v) => { p.eyeHeight = v; } })),
|
|
506
|
+
row('Sight radius (m)', number(p.radius, { min: 20, max: 300, step: 10, onChange: (v) => { p.radius = v; } })),
|
|
507
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run Green View Index (GVI)', () => app.run('Green View Index', (prog) => app.analyst.greenView({
|
|
508
|
+
area: app.area(), cell: Math.max(2, app.cell(600)), eyeHeight: p.eyeHeight, maxRadius: p.radius, onProgress: prog })))),
|
|
509
|
+
h('div', { class: 'insp-sub', text: '3D Solar Envelope (right to light)' }),
|
|
510
|
+
dateRows(p),
|
|
511
|
+
row('Sun window From–To (h)', h('span', { class: 'inline' },
|
|
512
|
+
number(p.start, { min: 6, max: 18, step: 0.5, width: '4.5em', onChange: (v) => { p.start = v; } }),
|
|
513
|
+
number(p.end, { min: 6, max: 18, step: 0.5, width: '4.5em', onChange: (v) => { p.end = v; } }))),
|
|
514
|
+
row('Min sun hours (h)', number(p.minHours, { min: 1, max: 6, step: 0.5, onChange: (v) => { p.minHours = v; } })),
|
|
515
|
+
row('Max height cap (m)', number(p.maxH, { min: 10, max: 200, step: 5, onChange: (v) => { p.maxH = v; } })),
|
|
516
|
+
row('Floor height (m)', number(p.floorH, { min: 2.5, max: 5, step: 0.1, onChange: (v) => { p.floorH = v; } })),
|
|
517
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run solar envelope', () => app.run('Solar envelope', (prog) => app.analyst.solarEnvelope({
|
|
518
|
+
area: app.area(), cell: Math.max(2, app.cell(600)), date: { month: p.month, day: p.day },
|
|
519
|
+
startHour: p.start, endHour: p.end, minSunHours: p.minHours, maxBuildableHeight: p.maxH, floorHeight: p.floorH, onProgress: prog })))));
|
|
520
|
+
return card('Green view & solar envelope', body);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function climateCard(app) {
|
|
524
|
+
const p = app.params.climate;
|
|
525
|
+
const has = !!app.weather?.dryBulb;
|
|
526
|
+
const body = h('div', {},
|
|
527
|
+
h('p', { class: 'muted small', text: 'A whole year of weather around every building: comfortable hours (UTCI 9–26 °C), heat- and cold-stress hours, pedestrian wind and a wind rose per building, from the local sky view, sun and shelter.' }),
|
|
528
|
+
has ? null : h('p', { class: 'note', text: 'Needs an EPW weather file: load one in Layers > Scene, or pick the nearest station in QGIS.' }),
|
|
529
|
+
row('Hours of the day', h('span', { class: 'inline' },
|
|
530
|
+
number(p.from, { min: 0, max: 23, step: 1, width: '4em', onChange: (v) => { p.from = v; } }),
|
|
531
|
+
number(p.to, { min: 1, max: 24, step: 1, width: '4em', onChange: (v) => { p.to = v; } }))),
|
|
532
|
+
row('Wind directions', select([[8, '8'], [16, '16 (slower)']], p.sectors, (v) => { p.sectors = Number(v); })),
|
|
533
|
+
row('Surroundings (m)', select([[10, '10 m'], [15, '15 m'], [25, '25 m']], p.ring, (v) => { p.ring = Number(v); }), 'Open ground this close to a building counts as its surroundings'),
|
|
534
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run climate year', () => {
|
|
535
|
+
if (!app.weather?.dryBulb) { toast('Load an EPW weather file first.'); return null; }
|
|
536
|
+
return app.run('Climate around buildings', (prog) => app.analyst.climateYear({
|
|
537
|
+
area: app.area(), cell: Math.max(2, app.cell(700)), sectors: p.sectors, hourFrom: p.from, hourTo: p.to, ringMetres: p.ring, onProgress: prog }));
|
|
538
|
+
})));
|
|
539
|
+
return card('Climate around buildings (year)', body, { badge: has ? 'EPW' : 'needs EPW' });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const PROFILE_KEY = 'g3d.planningProfile';
|
|
543
|
+
|
|
544
|
+
function applyProfile(p, prof) {
|
|
545
|
+
p.name = prof.name;
|
|
546
|
+
p.criteria = [...prof.criteria];
|
|
547
|
+
p.thresholds = { ...prof.thresholds };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function checkCard(app) {
|
|
551
|
+
const p = app.params.check;
|
|
552
|
+
if (!p.restored) {
|
|
553
|
+
// The last profile used in this browser (a convenience; files are the record).
|
|
554
|
+
p.restored = true;
|
|
555
|
+
try {
|
|
556
|
+
const saved = localStorage.getItem(PROFILE_KEY);
|
|
557
|
+
if (saved) applyProfile(p, parseProfile(saved));
|
|
558
|
+
} catch { /* storage blocked or an old file: keep the defaults */ }
|
|
559
|
+
}
|
|
560
|
+
p.name ??= defaultProfile().name;
|
|
561
|
+
const remember = () => { try { localStorage.setItem(PROFILE_KEY, profileJson(p)); } catch { /* storage blocked */ } };
|
|
562
|
+
const hasB = app.layers.some((l) => l.entry.scenario === 'B');
|
|
563
|
+
const crit = h('div', { class: 'checks' });
|
|
564
|
+
const nameInput = h('input', { type: 'text', value: p.name, title: 'Name of this set of thresholds; shown on the planning sheet',
|
|
565
|
+
onchange: () => { p.name = nameInput.value.trim() || 'Planning profile'; remember(); } });
|
|
566
|
+
const renderCriteria = () => {
|
|
567
|
+
clear(crit);
|
|
568
|
+
nameInput.value = p.name;
|
|
569
|
+
for (const c of CRITERIA) {
|
|
570
|
+
const on = p.criteria.includes(c.id);
|
|
571
|
+
crit.appendChild(h('div', { class: 'check-row', title: c.help },
|
|
572
|
+
toggle(on, (v) => { p.criteria = v ? CRITERIA.map((x) => x.id).filter((id) => id === c.id || p.criteria.includes(id)) : p.criteria.filter((x) => x !== c.id); remember(); }, c.short),
|
|
573
|
+
h('span', { class: 'muted small', text: c.test === 'min' ? '≥' : '≤' }),
|
|
574
|
+
number(p.thresholds[c.id], { step: c.unit === '' || c.unit === '×' ? 0.01 : 1, width: '5em', onChange: (v) => { if (Number.isFinite(v)) { p.thresholds[c.id] = v; remember(); } } }),
|
|
575
|
+
h('span', { class: 'muted small', text: c.unit })));
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
renderCriteria();
|
|
579
|
+
const file = h('input', { type: 'file', accept: '.json,application/json', hidden: true, onchange: async () => {
|
|
580
|
+
const f = file.files?.[0];
|
|
581
|
+
file.value = '';
|
|
582
|
+
if (!f) return;
|
|
583
|
+
try {
|
|
584
|
+
const prof = parseProfile(await f.text());
|
|
585
|
+
applyProfile(p, prof);
|
|
586
|
+
remember();
|
|
587
|
+
renderCriteria();
|
|
588
|
+
toast(`Profile "${prof.name}" loaded${prof.ignored.length ? ` (ignored: ${prof.ignored.join(', ')})` : ''}.`);
|
|
589
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
590
|
+
} });
|
|
591
|
+
const fileStemOf = (name) => String(name).normalize('NFKD').replace(/[^\w-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 50) || 'planning_profile';
|
|
592
|
+
const body = h('div', {},
|
|
593
|
+
h('p', { class: 'muted small', text: 'Pass or fail for each building: sun on the open ground around it, facade daylight, daylight kept after scenario B, windy and summer heat-stress hours, noise, air quality, canyon ventilation and rooftop solar. Missing analyses run on their own.' }),
|
|
594
|
+
row('Buildings', select([['selected', 'Selected buildings'], ['area', 'All in the analysis area'], ...(hasB ? [['scenarioB', 'Scenario B buildings']] : [])], p.scope, (v) => { p.scope = v; })),
|
|
595
|
+
row('Preset', select(PRESET_PROFILES.map((pr) => [pr.key, pr.name]), p.preset || 'defaults', (v) => {
|
|
596
|
+
p.preset = v;
|
|
597
|
+
applyProfile(p, presetProfile(v));
|
|
598
|
+
remember();
|
|
599
|
+
renderCriteria();
|
|
600
|
+
})),
|
|
601
|
+
row('Profile', nameInput),
|
|
602
|
+
crit,
|
|
603
|
+
h('div', { class: 'btn-row' },
|
|
604
|
+
button('Save profile', () => download(`${fileStemOf(p.name)}.json`, profileJson(p), 'application/json'), { title: 'Save these criteria and thresholds as a JSON file to reuse or share' }),
|
|
605
|
+
button('Load…', () => file.click(), { title: 'Load a saved profile' }),
|
|
606
|
+
button('Defaults', () => { p.preset = 'defaults'; applyProfile(p, defaultProfile()); remember(); renderCriteria(); }, { title: 'Back to the default thresholds' }),
|
|
607
|
+
file),
|
|
608
|
+
h('div', { class: 'btn-row' }, runButton(app, 'Run planning check', () => app.run('Planning check', (prog) => planningCheck(app, {
|
|
609
|
+
scope: p.scope, criteria: p.criteria, thresholds: p.thresholds, profile: p.name, onProgress: prog })))));
|
|
610
|
+
return card('Planning check', body);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ---------------------------------------------------------------- results
|
|
614
|
+
function renderResults(app, box) {
|
|
615
|
+
clear(box);
|
|
616
|
+
if (!app.results.length) {
|
|
617
|
+
box.appendChild(h('p', { class: 'muted', text: 'Results appear here. Each can be shown on the map, sent to QGIS (GeoTIFF + building fields) or saved.' }));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
box.appendChild(row('Overlay opacity', slider(0.92, { min: 0.2, max: 1, step: 0.02, format: (v) => `${Math.round(v * 100)} %`, onInput: (v) => app.setOverlayOpacity(v) })));
|
|
621
|
+
if (app.qgis && app.results.length > 1) {
|
|
622
|
+
box.appendChild(h('div', { class: 'btn-row' },
|
|
623
|
+
button(`Send all to QGIS (${app.results.length})`, async () => {
|
|
624
|
+
try {
|
|
625
|
+
const info = await sendAllResults(app.results);
|
|
626
|
+
toast(`Sent ${info.grids} grid(s)${info.routeSets ? `, ${info.routeSets} route layer(s)` : ''}${info.layers.length ? ` and fields on ${info.layers.join(', ')}` : ''} to QGIS.`);
|
|
627
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
628
|
+
}, { kind: 'primary', title: 'Send every raster grid, route layer and building result field to QGIS in one batch' })));
|
|
629
|
+
}
|
|
630
|
+
for (const r of app.results) {
|
|
631
|
+
const active = app.activeResult === r;
|
|
632
|
+
const actions = h('div', { class: 'btn-row' },
|
|
633
|
+
button(active ? 'Hide' : 'Show', () => (active ? app.hideResult() : app.showResult(r)), { kind: active ? 'on' : '' }),
|
|
634
|
+
r.facade ? toggle(r.showFacade !== false, (v) => { r.showFacade = v; if (app.activeResult === r) app.showResult(r); }, 'Facades') : null,
|
|
635
|
+
r.kind === 'wind' ? toggle(r.showStreamlines !== false, (v) => { r.showStreamlines = v; if (app.activeResult === r) app.showResult(r); }, 'Streamlines') : null,
|
|
636
|
+
app.qgis ? button('To QGIS', async () => {
|
|
637
|
+
try {
|
|
638
|
+
const key = `${r.kind}_${r.created.toISOString().slice(0, 16).replace(/[-:T]/g, '')}`;
|
|
639
|
+
if (r.grid) await sendGrid(r, key);
|
|
640
|
+
if (r.routes?.length) await sendRoutes(r, key);
|
|
641
|
+
const layers = r.fields?.length ? await sendFields(r.fields, r.qgisStyle || String(r.display?.ramp || 'viridis')) : [];
|
|
642
|
+
toast(`Sent to QGIS${layers.length ? ` (+ fields on ${layers.join(', ')})` : ''}${r.routes?.length ? ' (+ routes)' : ''}.`);
|
|
643
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
644
|
+
}, { kind: 'primary', title: 'GeoTIFF layer plus per-building fields in a results GeoPackage' }) : null,
|
|
645
|
+
r.grid ? button('.asc', () => download(`${r.kind}.asc`, asciiGrid(app, r)), { title: 'ESRI ASCII grid in the scene CRS' }) : null,
|
|
646
|
+
r.fields?.length ? button('CSV', () => download(`${r.kind}_buildings.csv`, fieldsCsv(r.fields), 'text/csv')) : null,
|
|
647
|
+
button('×', () => {
|
|
648
|
+
app.results.splice(app.results.indexOf(r), 1);
|
|
649
|
+
if (active) app.hideResult();
|
|
650
|
+
if (r._tex) { r._tex.dispose(); delete r._tex; } // its drape texture on the GPU
|
|
651
|
+
app.emit('results');
|
|
652
|
+
}, { title: 'Remove' }));
|
|
653
|
+
let studyControls = null;
|
|
654
|
+
if (r.kind === 'shadowstudy' || r.kind === 'diurnalstudy') {
|
|
655
|
+
const label = (k) => { const hr = r.hours[k]; return `${String(Math.floor(hr)).padStart(2, '0')}:${String(Math.round((hr % 1) * 60)).padStart(2, '0')}`; };
|
|
656
|
+
const go = (k) => { app.date = { ...r.date }; app.time = r.hours[k]; if (app.activeResult !== r) app.showResult(r); app.updateSun(); };
|
|
657
|
+
const sl = slider(r.frame, { min: 0, max: r.hours.length - 1, step: 1, format: label, onInput: go });
|
|
658
|
+
let timer = 0;
|
|
659
|
+
const play = button('▶', () => {
|
|
660
|
+
if (timer) { clearInterval(timer); timer = 0; play.textContent = '▶'; return; }
|
|
661
|
+
play.textContent = '❚❚';
|
|
662
|
+
let k = r.frame;
|
|
663
|
+
timer = window.setInterval(() => {
|
|
664
|
+
k = (k + 1) % r.hours.length;
|
|
665
|
+
go(k);
|
|
666
|
+
sl.set(k);
|
|
667
|
+
}, 450);
|
|
668
|
+
}, { title: 'Play the day' });
|
|
669
|
+
studyControls = h('div', { class: 'btn-row study' }, play, sl);
|
|
670
|
+
}
|
|
671
|
+
let seasonControls = null;
|
|
672
|
+
if (r.seasonal) {
|
|
673
|
+
const names = [['year', 'Year'], ['djf', 'Winter'], ['mam', 'Spring'], ['jja', 'Summer'], ['son', 'Autumn']];
|
|
674
|
+
seasonControls = h('div', { class: 'btn-row chips' }, ...names.map(([code, label]) => button(label, () => {
|
|
675
|
+
r.season = code;
|
|
676
|
+
r.values = r.seasonal[code];
|
|
677
|
+
r.title = `Comfortable hours around buildings (${label.toLowerCase()})`;
|
|
678
|
+
if (r._tex) { r._tex.dispose(); delete r._tex; }
|
|
679
|
+
app.showResult(r);
|
|
680
|
+
app.emit('results');
|
|
681
|
+
}, { kind: r.season === code ? 'on' : '' })));
|
|
682
|
+
}
|
|
683
|
+
let checkTable = null;
|
|
684
|
+
if (r.kind === 'compliance') {
|
|
685
|
+
const head = h('tr', {}, h('th', { text: 'Building' }), ...r.criteria.map((c) => h('th', { text: c.short, title: c.help })));
|
|
686
|
+
const body = r.rows.slice(0, 40).map((row2) => h('tr', { onclick: () => { app.select(row2.layer, [row2.f]); app.inspect(row2.layer, row2.f); } },
|
|
687
|
+
h('td', { text: row2.name }),
|
|
688
|
+
...row2.checks.map((ch, i) => h('td', { class: ch.pass === null ? 'na' : ch.pass ? 'pass' : 'fail',
|
|
689
|
+
title: Number.isFinite(ch.value) ? fmtValue(ch.value, r.criteria[i].unit) : 'not available',
|
|
690
|
+
text: ch.pass === null ? '–' : ch.pass ? '✓' : '✗' }))));
|
|
691
|
+
checkTable = h('div', {},
|
|
692
|
+
h('div', { class: 'check-table-wrap' }, h('table', { class: 'check-table' }, head, ...body)),
|
|
693
|
+
r.rows.length > 40 ? h('div', { class: 'muted small', text: `${r.rows.length - 40} more in the CSV and the PDF order (worst first).` }) : null,
|
|
694
|
+
h('div', { class: 'btn-row' },
|
|
695
|
+
button('Planning sheet (PDF)', () => download('planning_check.pdf', planningSheet(app, r)), { kind: 'primary' }),
|
|
696
|
+
button('CSV', () => download('planning_check.csv', planningCsv(r), 'text/csv'))));
|
|
697
|
+
}
|
|
698
|
+
const el = h('div', { class: `result ${active ? 'active' : ''}` },
|
|
699
|
+
h('div', { class: 'result-title', text: r.title }),
|
|
700
|
+
h('div', { class: 'muted small', text: `${r.subtitle || ''}${r.seconds ? ` · ${r.seconds.toFixed(1)} s` : ''}` }),
|
|
701
|
+
actions,
|
|
702
|
+
studyControls,
|
|
703
|
+
seasonControls,
|
|
704
|
+
checkTable,
|
|
705
|
+
r.stats?.length ? stats(r.stats) : null,
|
|
706
|
+
r.fields?.length ? h('div', { class: 'muted small', text: `Building fields: ${[...new Set(r.fields.map((f) => f.name))].join(', ')} (colour by them in Layers)` }) : null,
|
|
707
|
+
r.notes ? h('div', { class: 'note', text: r.notes }) : null);
|
|
708
|
+
box.appendChild(el);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function fmtValue(v, unit) {
|
|
713
|
+
const s = Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 10 ? v.toFixed(1) : v.toFixed(2);
|
|
714
|
+
return unit ? `${s} ${unit}` : s;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function asciiGrid(app, r) {
|
|
718
|
+
const g = r.grid;
|
|
719
|
+
const [ox, oy] = app.manifest.origin;
|
|
720
|
+
const lines = [`ncols ${g.cols}`, `nrows ${g.rows}`, `xllcorner ${(ox + g.minX - g.cell / 2).toFixed(3)}`,
|
|
721
|
+
`yllcorner ${(oy + g.maxY - (g.rows - 0.5) * g.cell).toFixed(3)}`, `cellsize ${g.cell}`, 'NODATA_value -9999'];
|
|
722
|
+
for (let r0 = 0; r0 < g.rows; r0++) {
|
|
723
|
+
const vals = [];
|
|
724
|
+
for (let c = 0; c < g.cols; c++) {
|
|
725
|
+
const v = r.values[r0 * g.cols + c];
|
|
726
|
+
vals.push(Number.isFinite(v) ? v.toFixed(3) : '-9999');
|
|
727
|
+
}
|
|
728
|
+
lines.push(vals.join(' '));
|
|
729
|
+
}
|
|
730
|
+
return lines.join('\n');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function fieldsCsv(fields) {
|
|
734
|
+
const out = ['layer,feature_id,field,value'];
|
|
735
|
+
for (const f of fields) f.values.forEach((v, i) => { if (Number.isFinite(v)) out.push(`"${f.layer.entry.name}",${i + 1},${f.name},${v}`); });
|
|
736
|
+
return out.join('\n');
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function percentile(arr, q) {
|
|
740
|
+
const s = Array.from(arr).sort((a, b) => a - b);
|
|
741
|
+
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
|
742
|
+
}
|