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,341 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 3D urban acoustic and environmental noise propagation module
|
|
3
|
+
// (ISO 9613-2 / CNOSSOS-EU inspired with Maekawa barrier diffraction).
|
|
4
|
+
|
|
5
|
+
import { sampleRoadLines, streetCentrelines } from './road_sources.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute Maekawa barrier diffraction attenuation in dB (0..20 dB, the ISO 9613-2 cap for single diffraction).
|
|
9
|
+
* @param {number} pathDiffMetres path difference delta in metres (<= 0 if unobstructed)
|
|
10
|
+
* @param {number} [freqHz] frequency in Hz (default 500 Hz)
|
|
11
|
+
* @returns {number} attenuation in dB (0..20)
|
|
12
|
+
*/
|
|
13
|
+
export function maekawaDiffraction(pathDiffMetres, freqHz = 500) {
|
|
14
|
+
if (!(pathDiffMetres > 0)) return 0;
|
|
15
|
+
const lambda = 343 / Math.max(20, freqHz);
|
|
16
|
+
const N = (2 / lambda) * pathDiffMetres;
|
|
17
|
+
if (N <= -0.05) return 0;
|
|
18
|
+
const val = 10 * Math.log10(Math.max(1, 3 + 20 * Math.max(0, N)));
|
|
19
|
+
return Math.min(20, Math.max(0, val));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const NOISE_CLASSES = [
|
|
23
|
+
{ min: 0, max: 55, label: 'Quiet', color: '#2b83ba' },
|
|
24
|
+
{ min: 55, max: 65, label: 'Moderate', color: '#abdda4' },
|
|
25
|
+
{ min: 65, max: 70, label: 'Loud', color: '#fdae61' },
|
|
26
|
+
{ min: 70, max: Infinity, label: 'High noise', color: '#d7191c' }
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Categorize a noise level value in dB(A).
|
|
31
|
+
* @param {number} dba
|
|
32
|
+
* @returns {{ min: number, max: number, label: string, color: string } | null}
|
|
33
|
+
*/
|
|
34
|
+
export function noiseClass(dba) {
|
|
35
|
+
if (!Number.isFinite(dba)) return null;
|
|
36
|
+
return NOISE_CLASSES.find((c) => dba < c.max) || NOISE_CLASSES[NOISE_CLASSES.length - 1];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Road noise sources: the given roads sampled every max(3 m, 1.5 cells), or
|
|
41
|
+
* street centrelines between buildings when there are none, or the south
|
|
42
|
+
* edge of a scene without buildings.
|
|
43
|
+
* @returns {Array<{ x: number, y: number, length: number }>}
|
|
44
|
+
*/
|
|
45
|
+
function noiseSources(roads, weights, cols, rows, cell, heights, porosity, minX, maxY) {
|
|
46
|
+
const step = Math.max(3, cell * 1.5);
|
|
47
|
+
const { sources, given } = sampleRoadLines(roads, weights, step);
|
|
48
|
+
if (sources.length || given) return sources;
|
|
49
|
+
const streets = streetCentrelines(heights, porosity, cols, rows, cell, minX, maxY, 25);
|
|
50
|
+
if (streets.length) return streets;
|
|
51
|
+
const edge = [];
|
|
52
|
+
const sy = maxY - (rows - 1) * cell;
|
|
53
|
+
const gridStep = Math.max(1, Math.round(step / cell));
|
|
54
|
+
for (let c = 0; c < cols; c += gridStep) edge.push({ x: minX + c * cell, y: sy, length: step });
|
|
55
|
+
return edge;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Items (sources or clusters) grouped into square clusters of `size` metres,
|
|
60
|
+
* each at the length-weighted centre of its members with their total length.
|
|
61
|
+
* @template {{ x: number, y: number, length: number }} T
|
|
62
|
+
* @param {T[]} items
|
|
63
|
+
* @param {number} size
|
|
64
|
+
* @returns {Array<{ x: number, y: number, length: number, members: T[] }>}
|
|
65
|
+
*/
|
|
66
|
+
function clusterSources(items, size) {
|
|
67
|
+
/** @type {Map<string, { x: number, y: number, length: number, members: T[] }>} */
|
|
68
|
+
const bins = new Map();
|
|
69
|
+
for (const s of items) {
|
|
70
|
+
if (!(s.length > 0)) continue;
|
|
71
|
+
const key = `${Math.floor(s.x / size)},${Math.floor(s.y / size)}`;
|
|
72
|
+
let b = bins.get(key);
|
|
73
|
+
if (!b) { b = { x: 0, y: 0, length: 0, members: [] }; bins.set(key, b); }
|
|
74
|
+
b.x += s.x * s.length;
|
|
75
|
+
b.y += s.y * s.length;
|
|
76
|
+
b.length += s.length;
|
|
77
|
+
b.members.push(s);
|
|
78
|
+
}
|
|
79
|
+
const out = [...bins.values()];
|
|
80
|
+
for (const b of out) { b.x /= b.length; b.y /= b.length; }
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Precompute cells that have a solid building facade within 12 m.
|
|
86
|
+
* @param {Float32Array} heights
|
|
87
|
+
* @param {Float32Array|null} porosity
|
|
88
|
+
* @param {number} cols
|
|
89
|
+
* @param {number} rows
|
|
90
|
+
* @param {number} cell
|
|
91
|
+
* @returns {Uint8Array}
|
|
92
|
+
*/
|
|
93
|
+
function findNearSolidFacades(heights, porosity, cols, rows, cell) {
|
|
94
|
+
const result = new Uint8Array(cols * rows);
|
|
95
|
+
const rad = Math.ceil(12 / cell);
|
|
96
|
+
for (let r = 0; r < rows; r++) {
|
|
97
|
+
for (let c = 0; c < cols; c++) {
|
|
98
|
+
const idx = r * cols + c;
|
|
99
|
+
if (heights[idx] > 0 && !(porosity && porosity[idx] > 0)) continue;
|
|
100
|
+
let hasNear = false;
|
|
101
|
+
for (let dr = -rad; dr <= rad && !hasNear; dr++) {
|
|
102
|
+
for (let dc = -rad; dc <= rad; dc++) {
|
|
103
|
+
const nr = r + dr, nc = c + dc;
|
|
104
|
+
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
|
|
105
|
+
const nidx = nr * cols + nc;
|
|
106
|
+
if (heights[nidx] > 0 && !(porosity && porosity[nidx] > 0)) {
|
|
107
|
+
if (Math.hypot(dc * cell, dr * cell) <= 12) {
|
|
108
|
+
hasNear = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (hasNear) result[idx] = 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compute pedestrian-level environmental noise Lden (in dB(A)).
|
|
123
|
+
* @param {object} p
|
|
124
|
+
* @param {Float32Array} p.heights obstacle height above ground per cell (0 = open)
|
|
125
|
+
* @param {Float32Array|null} [p.porosity] optical porosity per cell (null = solid)
|
|
126
|
+
* @param {number} p.cols
|
|
127
|
+
* @param {number} p.rows
|
|
128
|
+
* @param {number} p.cell metres per cell
|
|
129
|
+
* @param {number} [p.minX]
|
|
130
|
+
* @param {number} [p.maxY]
|
|
131
|
+
* @param {Array<Array<[number, number]>>} [p.roads] road polylines
|
|
132
|
+
* @param {ArrayLike<number>|null} [p.roadWeights] relative traffic per road polyline (1 = the stated level; 2 doubles it)
|
|
133
|
+
* @param {number} [p.soundPowerDb] road sound power level per metre (dB(A))
|
|
134
|
+
* @param {number} [p.receiverHeight] receiver height above ground in metres
|
|
135
|
+
* @param {number} [p.sourceHeight] source height above ground in metres
|
|
136
|
+
* @param {number} [p.groundFactor] ground absorption factor 0..1 (0=hard, 1=porous)
|
|
137
|
+
* @param {number} [p.maxDist] maximum calculation distance in metres
|
|
138
|
+
* @param {((f: number) => void)|null} [p.onProgress] progress 0..1; the page stays responsive
|
|
139
|
+
* @returns {Promise<Float32Array>} noise grid in dB(A), NaN inside solid buildings
|
|
140
|
+
*/
|
|
141
|
+
export async function noisePropagation({
|
|
142
|
+
heights,
|
|
143
|
+
porosity = null,
|
|
144
|
+
cols,
|
|
145
|
+
rows,
|
|
146
|
+
cell,
|
|
147
|
+
minX = 0.5 * cell,
|
|
148
|
+
maxY = (rows - 0.5) * cell,
|
|
149
|
+
roads = [],
|
|
150
|
+
roadWeights = null,
|
|
151
|
+
soundPowerDb = 82,
|
|
152
|
+
receiverHeight = 1.5,
|
|
153
|
+
sourceHeight = 0.5,
|
|
154
|
+
groundFactor = 0.5,
|
|
155
|
+
maxDist = 400,
|
|
156
|
+
onProgress = null
|
|
157
|
+
}) {
|
|
158
|
+
const sources = noiseSources(roads, roadWeights, cols, rows, cell, heights, porosity, minX, maxY);
|
|
159
|
+
// Two cluster levels: road samples within 64 m are traced one by one,
|
|
160
|
+
// 8 m clusters up to 160 m, 32 m clusters beyond, each heard as one source
|
|
161
|
+
// at its centre (a distant cluster spans a small angle).
|
|
162
|
+
const clusterSize = Math.max(8, 2 * Math.max(3, cell * 1.5));
|
|
163
|
+
const bigSize = 4 * clusterSize;
|
|
164
|
+
const clusters = clusterSources(sources, clusterSize);
|
|
165
|
+
const big = clusterSources(clusters, bigSize);
|
|
166
|
+
const near = 8 * clusterSize;
|
|
167
|
+
const mid = 5 * bigSize;
|
|
168
|
+
const nearFacades = findNearSolidFacades(heights, porosity, cols, rows, cell);
|
|
169
|
+
const dz = receiverHeight - sourceHeight;
|
|
170
|
+
|
|
171
|
+
const out = new Float32Array(cols * rows);
|
|
172
|
+
|
|
173
|
+
/** Energy (10^(L/10)) at a receiver from one source, rays marched every stepM metres. */
|
|
174
|
+
const energyFrom = (rx, ry, src, stepM, reflectionBonus) => {
|
|
175
|
+
const dx = rx - src.x;
|
|
176
|
+
const dy = ry - src.y;
|
|
177
|
+
const dist2d = Math.hypot(dx, dy);
|
|
178
|
+
if (dist2d > maxDist) return 0;
|
|
179
|
+
const dDirect = Math.hypot(dx, dy, dz);
|
|
180
|
+
const d = Math.max(2, dDirect);
|
|
181
|
+
|
|
182
|
+
// Ray march from source to receiver to find barriers and tree foliage
|
|
183
|
+
let maxPathDiff = 0;
|
|
184
|
+
let dTree = 0;
|
|
185
|
+
if (dist2d >= 1) {
|
|
186
|
+
const nSteps = Math.max(2, Math.ceil(dist2d / stepM));
|
|
187
|
+
const segStep = dist2d / nSteps;
|
|
188
|
+
for (let stepIdx = 1; stepIdx < nSteps; stepIdx++) {
|
|
189
|
+
const t = stepIdx / nSteps;
|
|
190
|
+
const gc = Math.floor((src.x + t * dx - minX) / cell + 0.5);
|
|
191
|
+
const gr = Math.floor((maxY - (src.y + t * dy)) / cell + 0.5);
|
|
192
|
+
if (gc < 0 || gc >= cols || gr < 0 || gr >= rows) continue;
|
|
193
|
+
const gidx = gr * cols + gc;
|
|
194
|
+
const h = heights[gidx];
|
|
195
|
+
if (porosity && porosity[gidx] > 0) {
|
|
196
|
+
dTree += segStep;
|
|
197
|
+
} else if (h > 0 && h > sourceHeight + t * dz) {
|
|
198
|
+
const d1 = t * dist2d;
|
|
199
|
+
const d2 = (1 - t) * dist2d;
|
|
200
|
+
const pathDiff = Math.hypot(d1, h - sourceHeight) + Math.hypot(d2, h - receiverHeight) - dDirect;
|
|
201
|
+
if (pathDiff > maxPathDiff) maxPathDiff = pathDiff;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const aBar = maekawaDiffraction(maxPathDiff);
|
|
207
|
+
const aTree = Math.min(4, 0.08 * dTree);
|
|
208
|
+
const aDiv = 20 * Math.log10(d) + 8;
|
|
209
|
+
// ISO 9613-2 Table 2 at 500 Hz (10 °C, 70 %): about 1.9 dB/km.
|
|
210
|
+
const aAir = 0.0019 * d;
|
|
211
|
+
const aGround = 1.5 * groundFactor * Math.log10(Math.max(1, d / 2));
|
|
212
|
+
const lwSeg = soundPowerDb + 10 * Math.log10(Math.max(0.1, src.length));
|
|
213
|
+
return Math.pow(10, (lwSeg - aDiv - aAir - aGround - aBar - aTree + reflectionBonus) / 10);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const fineStep = Math.max(1, cell);
|
|
217
|
+
const coarseStep = 2 * fineStep;
|
|
218
|
+
let lastYield = Date.now();
|
|
219
|
+
for (let r = 0; r < rows; r++) {
|
|
220
|
+
for (let c = 0; c < cols; c++) {
|
|
221
|
+
const i = r * cols + c;
|
|
222
|
+
if (heights[i] > 0 && !(porosity && porosity[i] > 0)) {
|
|
223
|
+
out[i] = NaN;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const rx = minX + c * cell;
|
|
227
|
+
const ry = maxY - r * cell;
|
|
228
|
+
const bonus = nearFacades[i] === 1 ? 1.8 : 0;
|
|
229
|
+
let energySum = 0;
|
|
230
|
+
for (const K of big) {
|
|
231
|
+
const dK = Math.hypot(rx - K.x, ry - K.y);
|
|
232
|
+
if (dK > maxDist + bigSize) continue;
|
|
233
|
+
if (dK > mid) {
|
|
234
|
+
// Far: one source, rays sampled about 24 times (barriers of a few
|
|
235
|
+
// metres still register; far roads are faint and mostly screened).
|
|
236
|
+
energySum += energyFrom(rx, ry, K, Math.max(coarseStep, dK / 24), bonus);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
for (const k of K.members) {
|
|
240
|
+
const dk = Math.hypot(rx - k.x, ry - k.y);
|
|
241
|
+
if (dk > maxDist + clusterSize) continue;
|
|
242
|
+
if (dk > near) {
|
|
243
|
+
energySum += energyFrom(rx, ry, k, Math.max(coarseStep, dk / 24), bonus);
|
|
244
|
+
} else {
|
|
245
|
+
for (const src of k.members) energySum += energyFrom(rx, ry, src, fineStep, bonus);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
out[i] = energySum > 0 ? Math.max(35, Math.min(90, 10 * Math.log10(energySum))) : 35;
|
|
250
|
+
}
|
|
251
|
+
if (onProgress && Date.now() - lastYield > 50) {
|
|
252
|
+
onProgress((r + 1) / rows);
|
|
253
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
254
|
+
lastYield = Date.now();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Compute per-building facade noise statistics and quiet facade indicator.
|
|
263
|
+
* @param {Float32Array} noiseGrid noise grid in dB(A)
|
|
264
|
+
* @param {Int32Array|Array<number>} ids building feature keys per cell (0 = none)
|
|
265
|
+
* @param {number} cols
|
|
266
|
+
* @param {number} rows
|
|
267
|
+
* @returns {Map<number, { max: number, min: number, mean: number, quietFacade: number }>}
|
|
268
|
+
*/
|
|
269
|
+
export function buildingFacadeNoise(noiseGrid, ids, cols, rows) {
|
|
270
|
+
/** @type {Map<number, { max: number, min: number, mean: number, quietFacade: number }>} */
|
|
271
|
+
const result = new Map();
|
|
272
|
+
if (!ids || ids.length !== cols * rows) return result;
|
|
273
|
+
|
|
274
|
+
/** @type {Map<number, Array<number>>} */
|
|
275
|
+
const buildingCells = new Map();
|
|
276
|
+
for (let i = 0; i < ids.length; i++) {
|
|
277
|
+
const key = ids[i];
|
|
278
|
+
if (key > 0) {
|
|
279
|
+
let arr = buildingCells.get(key);
|
|
280
|
+
if (!arr) {
|
|
281
|
+
arr = [];
|
|
282
|
+
buildingCells.set(key, arr);
|
|
283
|
+
}
|
|
284
|
+
arr.push(i);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const [key, cells] of buildingCells.entries()) {
|
|
289
|
+
const adjacentOpenIndices = new Set();
|
|
290
|
+
for (const idx of cells) {
|
|
291
|
+
const c = idx % cols;
|
|
292
|
+
const r = Math.floor(idx / cols);
|
|
293
|
+
|
|
294
|
+
for (let dr = -2; dr <= 2; dr++) {
|
|
295
|
+
for (let dc = -2; dc <= 2; dc++) {
|
|
296
|
+
if (dr === 0 && dc === 0) continue;
|
|
297
|
+
const nc = c + dc;
|
|
298
|
+
const nr = r + dr;
|
|
299
|
+
if (nc >= 0 && nc < cols && nr >= 0 && nr < rows) {
|
|
300
|
+
const nidx = nr * cols + nc;
|
|
301
|
+
if (ids[nidx] !== key && Number.isFinite(noiseGrid[nidx])) {
|
|
302
|
+
adjacentOpenIndices.add(nidx);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (adjacentOpenIndices.size === 0) {
|
|
310
|
+
result.set(key, { max: 35, min: 35, mean: 35, quietFacade: 1 });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let max = -Infinity;
|
|
315
|
+
let min = Infinity;
|
|
316
|
+
let sum = 0;
|
|
317
|
+
let count = 0;
|
|
318
|
+
|
|
319
|
+
for (const oIdx of adjacentOpenIndices) {
|
|
320
|
+
const val = noiseGrid[oIdx];
|
|
321
|
+
if (Number.isFinite(val)) {
|
|
322
|
+
if (val > max) max = val;
|
|
323
|
+
if (val < min) min = val;
|
|
324
|
+
sum += val;
|
|
325
|
+
count++;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (count === 0) {
|
|
330
|
+
result.set(key, { max: 35, min: 35, mean: 35, quietFacade: 1 });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const mean = sum / count;
|
|
335
|
+
// quietFacade is 1 if at least one facade side has < 55 dB(A) or max - min >= 6 dB(A)
|
|
336
|
+
const quietFacade = (min < 55 || (max - min) >= 6) ? 1 : 0;
|
|
337
|
+
result.set(key, { max, min, mean, quietFacade });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Colour ramps and classification (equal interval, quantile, natural
|
|
3
|
+
// breaks, categories) for recolouring features and analysis grids.
|
|
4
|
+
|
|
5
|
+
export const RAMPS = {
|
|
6
|
+
viridis: ['#440154', '#482878', '#3e4989', '#31688e', '#26828e', '#1f9e89', '#35b779', '#6ece58', '#b5de2b', '#fde725'],
|
|
7
|
+
magma: ['#000004', '#1c1044', '#4f127b', '#812581', '#b5367a', '#e55964', '#fb8761', '#fec287', '#fcfdbf'],
|
|
8
|
+
inferno: ['#000004', '#1f0c48', '#550f6d', '#88226a', '#ba3655', '#e35933', '#f98e09', '#f9cb35', '#fcffa4'],
|
|
9
|
+
rdylbu: ['#a50026', '#d73027', '#f46d43', '#fdae61', '#fee090', '#ffffbf', '#e0f3f8', '#abd9e9', '#74add1', '#4575b4', '#313695'],
|
|
10
|
+
spectral: ['#9e0142', '#d53e4f', '#f46d43', '#fdae61', '#fee08b', '#ffffbf', '#e6f598', '#abdda4', '#66c2a5', '#3288bd', '#5e4fa2'],
|
|
11
|
+
rdbu: ['#67001f', '#b2182b', '#d6604d', '#f4a582', '#fddbc7', '#f7f7f7', '#d1e5f0', '#92c5de', '#4393c3', '#2166ac', '#053061'],
|
|
12
|
+
blues: ['#f7fbff', '#deebf7', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#08519c', '#08306b'],
|
|
13
|
+
reds: ['#fff5f0', '#fee0d2', '#fcbba1', '#fc9272', '#fb6a4a', '#ef3b2c', '#cb181d', '#a50f15', '#67000d'],
|
|
14
|
+
greens: ['#f7fcf5', '#e5f5e0', '#c7e9c0', '#a1d99b', '#74c476', '#41ab5d', '#238b45', '#006d2c', '#00441b'],
|
|
15
|
+
terrain: ['#2b6b3f', '#5f9a4f', '#a8c47a', '#e9dfa3', '#d2b47c', '#a47b55', '#8a7a72', '#f2f2f2'],
|
|
16
|
+
greys: ['#000000', '#262626', '#4d4d4d', '#737373', '#969696', '#bdbdbd', '#d9d9d9', '#f0f0f0', '#ffffff']
|
|
17
|
+
};
|
|
18
|
+
export const QUALITATIVE = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac',
|
|
19
|
+
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf'];
|
|
20
|
+
export const RAMP_LABELS = {
|
|
21
|
+
viridis: 'Viridis', magma: 'Magma', inferno: 'Inferno', rdylbu: 'Red-Yellow-Blue', spectral: 'Spectral',
|
|
22
|
+
rdbu: 'Red-Blue', blues: 'Blues', reds: 'Reds', greens: 'Greens', terrain: 'Terrain', greys: 'Greys'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function hexToRgb(hex) {
|
|
26
|
+
const h = String(hex || '#cccccc').replace('#', '');
|
|
27
|
+
const v = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h.slice(0, 6), 16);
|
|
28
|
+
return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function rgbToHex([r, g, b]) {
|
|
32
|
+
const c = (x) => Math.round(Math.max(0, Math.min(1, x)) * 255).toString(16).padStart(2, '0');
|
|
33
|
+
return `#${c(r)}${c(g)}${c(b)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Ramp stops as RGB triples; name may end in '-r' for reversed. */
|
|
37
|
+
export function rampStops(name) {
|
|
38
|
+
const reversed = String(name).endsWith('-r');
|
|
39
|
+
const key = reversed ? String(name).slice(0, -2) : String(name);
|
|
40
|
+
const stops = (RAMPS[key] || RAMPS.viridis).map(hexToRgb);
|
|
41
|
+
return reversed ? stops.reverse() : stops;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Colour at t in [0, 1] along a ramp (stops from rampStops). */
|
|
45
|
+
export function rampColor(stops, t, out = [0, 0, 0]) {
|
|
46
|
+
const v = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0)) * (stops.length - 1);
|
|
47
|
+
const i = Math.min(stops.length - 2, Math.floor(v));
|
|
48
|
+
const f = v - i;
|
|
49
|
+
for (let k = 0; k < 3; k++) out[k] = stops[i][k] + (stops[i + 1][k] - stops[i][k]) * f;
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function finiteSorted(values) {
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const v of values) if (v !== null && v !== '' && Number.isFinite(Number(v))) out.push(Number(v));
|
|
56
|
+
return out.sort((a, b) => a - b);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function equalBreaks(values, k) {
|
|
60
|
+
const s = finiteSorted(values);
|
|
61
|
+
if (!s.length) return [];
|
|
62
|
+
const lo = s[0];
|
|
63
|
+
const hi = s[s.length - 1];
|
|
64
|
+
return Array.from({ length: k + 1 }, (_, i) => lo + ((hi - lo) * i) / k);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function quantileBreaks(values, k) {
|
|
68
|
+
const s = finiteSorted(values);
|
|
69
|
+
if (!s.length) return [];
|
|
70
|
+
const out = [s[0]];
|
|
71
|
+
for (let i = 1; i < k; i++) out.push(s[Math.min(s.length - 1, Math.floor((i * s.length) / k))]);
|
|
72
|
+
out.push(s[s.length - 1]);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Jenks natural breaks (Fisher-Jenks dynamic programme) on at most 3000 sampled values. */
|
|
77
|
+
export function jenksBreaks(values, k) {
|
|
78
|
+
let s = finiteSorted(values);
|
|
79
|
+
if (!s.length) return [];
|
|
80
|
+
if (s.length > 3000) {
|
|
81
|
+
const step = s.length / 3000;
|
|
82
|
+
s = Array.from({ length: 3000 }, (_, i) => s[Math.floor(i * step)]);
|
|
83
|
+
s.push(finiteSorted(values).pop());
|
|
84
|
+
}
|
|
85
|
+
const n = s.length;
|
|
86
|
+
k = Math.max(1, Math.min(k, new Set(s).size));
|
|
87
|
+
const lower = Array.from({ length: n + 1 }, () => new Int32Array(k + 1));
|
|
88
|
+
const variance = Array.from({ length: n + 1 }, () => new Float64Array(k + 1).fill(Infinity));
|
|
89
|
+
for (let j = 1; j <= k; j++) { lower[1][j] = 1; variance[1][j] = 0; }
|
|
90
|
+
for (let l = 2; l <= n; l++) {
|
|
91
|
+
let sum = 0;
|
|
92
|
+
let sumSq = 0;
|
|
93
|
+
let w = 0;
|
|
94
|
+
let v = 0;
|
|
95
|
+
for (let m = 1; m <= l; m++) {
|
|
96
|
+
const i3 = l - m + 1;
|
|
97
|
+
const val = s[i3 - 1];
|
|
98
|
+
w++;
|
|
99
|
+
sum += val;
|
|
100
|
+
sumSq += val * val;
|
|
101
|
+
v = sumSq - (sum * sum) / w;
|
|
102
|
+
const i4 = i3 - 1;
|
|
103
|
+
if (i4 !== 0) {
|
|
104
|
+
for (let j = 2; j <= k; j++) {
|
|
105
|
+
if (variance[l][j] >= v + variance[i4][j - 1]) {
|
|
106
|
+
lower[l][j] = i3;
|
|
107
|
+
variance[l][j] = v + variance[i4][j - 1];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
lower[l][1] = 1;
|
|
113
|
+
variance[l][1] = v;
|
|
114
|
+
}
|
|
115
|
+
const breaks = new Array(k + 1);
|
|
116
|
+
breaks[k] = s[n - 1];
|
|
117
|
+
breaks[0] = s[0];
|
|
118
|
+
let idx = n;
|
|
119
|
+
for (let j = k; j >= 2; j--) {
|
|
120
|
+
const id = lower[idx][j] - 2;
|
|
121
|
+
breaks[j - 1] = s[Math.max(0, id)];
|
|
122
|
+
idx = lower[idx][j] - 1;
|
|
123
|
+
}
|
|
124
|
+
return breaks;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function breaksFor(method, values, k) {
|
|
128
|
+
if (method === 'quantile') return quantileBreaks(values, k);
|
|
129
|
+
if (method === 'jenks') return jenksBreaks(values, k);
|
|
130
|
+
return equalBreaks(values, k);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Class index of a value for breaks [b0..bk]: 0..k-1, or -1 if not finite. */
|
|
134
|
+
export function classOf(value, breaks) {
|
|
135
|
+
const v = Number(value);
|
|
136
|
+
if (value === null || value === '' || !Number.isFinite(v) || breaks.length < 2) return -1;
|
|
137
|
+
for (let i = 1; i < breaks.length - 1; i++) if (v < breaks[i]) return i - 1;
|
|
138
|
+
return breaks.length - 2;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Distinct values by frequency, most common first (at most `limit`). */
|
|
142
|
+
export function categories(values, limit = 20) {
|
|
143
|
+
const counts = new Map();
|
|
144
|
+
for (const v of values) {
|
|
145
|
+
const key = v === null || v === undefined || v === '' ? '(empty)' : String(v);
|
|
146
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([value, count]) => ({ value, count }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Short number formatting for legends. */
|
|
152
|
+
export function fmt(v, unit = '') {
|
|
153
|
+
if (!Number.isFinite(v)) return '–';
|
|
154
|
+
if (Number.isInteger(v)) return unit ? `${v.toLocaleString('en-US')} ${unit}` : v.toLocaleString('en-US');
|
|
155
|
+
const a = Math.abs(v);
|
|
156
|
+
const s = a >= 1000 ? Math.round(v).toLocaleString('en-US') : a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : v.toFixed(2);
|
|
157
|
+
return unit ? `${s} ${unit}` : s;
|
|
158
|
+
}
|