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,362 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Compare views: a swipe between two states, or 2, 4, 6 or 8 synced panels.
|
|
3
|
+
// Every panel shares the one camera; before drawing a panel its state is
|
|
4
|
+
// applied (analysis drape and facade tiles, scenario A or B), so panels cost
|
|
5
|
+
// one render each and nothing is duplicated in memory.
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
import { h, clear } from './dom.js';
|
|
8
|
+
import { resultTexture, useOverlay, hideOverlay } from '../scene/overlay.js';
|
|
9
|
+
import { rampStops } from '../core/classify.js';
|
|
10
|
+
import { fmt } from '../core/classify.js';
|
|
11
|
+
|
|
12
|
+
const LAYOUTS = { 2: [2, 1], 4: [2, 2], 6: [3, 2], 8: [4, 2] };
|
|
13
|
+
|
|
14
|
+
export class Compare {
|
|
15
|
+
constructor(app) {
|
|
16
|
+
this.app = app;
|
|
17
|
+
this.mode = null; // null | 'swipe' | 2 | 4 | 6 | 8
|
|
18
|
+
this.states = [];
|
|
19
|
+
this.split = 0.5;
|
|
20
|
+
this.layer = h('div', { class: 'compare-layer', hidden: true });
|
|
21
|
+
/** @type {HTMLElement} */ (document.getElementById('view')).appendChild(this.layer);
|
|
22
|
+
app.on((what) => { if (this.mode && (what === 'results' || what === 'weather')) this._buildDom(); });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
hasScenarios() {
|
|
26
|
+
return this.app.layers.some((l) => l.entry.scenario === 'A' || l.entry.scenario === 'B');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
options() {
|
|
30
|
+
const out = [['base', 'No analysis']];
|
|
31
|
+
for (const r of this.app.results) if (r.grid || r.facade) out.push([r.id, r.title]);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toggle(mode) {
|
|
36
|
+
if (this.mode === mode) { this.disable(); return; }
|
|
37
|
+
this.enable(mode);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
enable(mode) {
|
|
41
|
+
const app = this.app;
|
|
42
|
+
const n = mode === 'swipe' ? 2 : Number(mode);
|
|
43
|
+
const res = app.results.filter((r) => r.grid || r.facade);
|
|
44
|
+
const prev = this.states;
|
|
45
|
+
this.states = Array.from({ length: n }, (_, i) => prev[i] || {
|
|
46
|
+
result: i === 0 ? (app.activeResult?.id || res[0]?.id || 'base') : (res[i]?.id || res[0]?.id || 'base'),
|
|
47
|
+
scenario: this.hasScenarios() ? (i % 2 ? 'B' : 'A') : null
|
|
48
|
+
});
|
|
49
|
+
if (!this.mode) app.activeResult?.onHide?.(); // e.g. planning-check colours on the buildings
|
|
50
|
+
this.mode = mode;
|
|
51
|
+
this.layer.hidden = false;
|
|
52
|
+
const legend = document.getElementById('legend');
|
|
53
|
+
if (legend) legend.hidden = true; // each panel has its own
|
|
54
|
+
this.layer.className = `compare-layer ${mode === 'swipe' ? 'swipe' : 'grid'}`;
|
|
55
|
+
this._buildDom();
|
|
56
|
+
app.viewer.renderHook = () => this.render();
|
|
57
|
+
app.viewer.requestRender();
|
|
58
|
+
app.emit('compare');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Open the smallest panel layout that holds these results (up to 8), one result per panel. */
|
|
62
|
+
showResults(results) {
|
|
63
|
+
const list = results.filter((r) => r.grid || r.facade).slice(0, 8);
|
|
64
|
+
if (!list.length) return;
|
|
65
|
+
const n = list.length;
|
|
66
|
+
const mode = n <= 2 ? 2 : n <= 4 ? 4 : n <= 6 ? 6 : 8;
|
|
67
|
+
const scenario = this.hasScenarios() ? 'A' : null;
|
|
68
|
+
this.states = Array.from({ length: mode }, (_, i) => ({ result: list[i]?.id || 'base', scenario }));
|
|
69
|
+
this.enable(mode);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
disable() {
|
|
73
|
+
const app = this.app;
|
|
74
|
+
this.mode = null;
|
|
75
|
+
this.layer.hidden = true;
|
|
76
|
+
clear(this.layer);
|
|
77
|
+
app.viewer.renderHook = null;
|
|
78
|
+
this._restore();
|
|
79
|
+
app.renderLegend();
|
|
80
|
+
app.viewer.requestRender();
|
|
81
|
+
app.emit('compare');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_restore() {
|
|
85
|
+
const app = this.app;
|
|
86
|
+
app.showResult(app.activeResult);
|
|
87
|
+
for (const l of app.layers) {
|
|
88
|
+
if (l.entry.scenario === 'A') l.group.visible = l.visible && app.scenario !== 'B';
|
|
89
|
+
else if (l.entry.scenario === 'B') l.group.visible = l.visible && app.scenario === 'B';
|
|
90
|
+
else l.group.visible = l.visible;
|
|
91
|
+
}
|
|
92
|
+
app.viewer.renderer.shadowMap.autoUpdate = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Panel rectangles (top-left origin) for a W x H view; screen size by default. */
|
|
96
|
+
_rects(W, H) {
|
|
97
|
+
if (W === undefined) {
|
|
98
|
+
const el = this.app.viewer.renderer.domElement;
|
|
99
|
+
W = el.clientWidth; H = el.clientHeight;
|
|
100
|
+
}
|
|
101
|
+
if (this.mode === 'swipe') {
|
|
102
|
+
const x = Math.round(W * this.split);
|
|
103
|
+
return [{ x: 0, y: 0, w: x, h: H, full: true }, { x, y: 0, w: W - x, h: H, full: true }];
|
|
104
|
+
}
|
|
105
|
+
const [cols, rows] = LAYOUTS[this.mode];
|
|
106
|
+
const out = [];
|
|
107
|
+
for (let r = 0; r < rows; r++) {
|
|
108
|
+
for (let c = 0; c < cols; c++) {
|
|
109
|
+
const x0 = Math.round((W * c) / cols), x1 = Math.round((W * (c + 1)) / cols);
|
|
110
|
+
const y0 = Math.round((H * r) / rows), y1 = Math.round((H * (r + 1)) / rows);
|
|
111
|
+
out.push({ x: x0, y: y0, w: x1 - x0 - (c < cols - 1 ? 1 : 0), h: y1 - y0 - (r < rows - 1 ? 1 : 0) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_apply(state) {
|
|
118
|
+
const app = this.app;
|
|
119
|
+
const r = app.results.find((x) => x.id === state.result) || null;
|
|
120
|
+
if (r?.grid) useOverlay(resultTexture(r), r.grid, { roofs: !r.groundOnly }); else hideOverlay();
|
|
121
|
+
if (app.facadeMesh) app.facadeMesh.visible = false;
|
|
122
|
+
for (const res of app.results) if (res.facade?._mesh) res.facade._mesh.visible = false;
|
|
123
|
+
if (r?.facade && r.showFacade !== false) {
|
|
124
|
+
const mesh = app.facadeMeshFor(r.facade);
|
|
125
|
+
if (!mesh.parent) app.viewer.helpers.add(mesh);
|
|
126
|
+
mesh.visible = true;
|
|
127
|
+
}
|
|
128
|
+
if (state.scenario) {
|
|
129
|
+
for (const l of app.layers) {
|
|
130
|
+
if (l.entry.scenario === 'A') l.group.visible = l.visible && state.scenario === 'A';
|
|
131
|
+
else if (l.entry.scenario === 'B') l.group.visible = l.visible && state.scenario === 'B';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
render() {
|
|
137
|
+
const app = this.app;
|
|
138
|
+
const v = app.viewer;
|
|
139
|
+
const r = v.renderer;
|
|
140
|
+
const cam = v.camera;
|
|
141
|
+
const size = r.getSize(new THREE.Vector2());
|
|
142
|
+
const W = size.x, H = size.y;
|
|
143
|
+
const rects = this._rects(W, H);
|
|
144
|
+
r.setScissorTest(true);
|
|
145
|
+
// Shadows only change when the buildings do (a different scenario).
|
|
146
|
+
r.shadowMap.autoUpdate = false;
|
|
147
|
+
let lastScenario;
|
|
148
|
+
rects.forEach((rect, i) => {
|
|
149
|
+
const state = this.states[i];
|
|
150
|
+
if (!state) return;
|
|
151
|
+
this._apply(state);
|
|
152
|
+
if (i === 0 || state.scenario !== lastScenario) r.shadowMap.needsUpdate = true;
|
|
153
|
+
lastScenario = state.scenario;
|
|
154
|
+
if (rect.full) {
|
|
155
|
+
r.setViewport(0, 0, W, H);
|
|
156
|
+
cam.aspect = W / H;
|
|
157
|
+
} else {
|
|
158
|
+
r.setViewport(rect.x, H - rect.y - rect.h, rect.w, rect.h);
|
|
159
|
+
cam.aspect = rect.w / Math.max(1, rect.h);
|
|
160
|
+
}
|
|
161
|
+
cam.updateProjectionMatrix();
|
|
162
|
+
r.setScissor(rect.x, H - rect.y - rect.h, rect.w, rect.h);
|
|
163
|
+
r.render(v.scene, cam);
|
|
164
|
+
});
|
|
165
|
+
r.setScissorTest(false);
|
|
166
|
+
r.setViewport(0, 0, W, H);
|
|
167
|
+
cam.aspect = W / H;
|
|
168
|
+
cam.updateProjectionMatrix();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ------------------------------------------------------------ labels, legends, divider
|
|
172
|
+
_buildDom() {
|
|
173
|
+
clear(this.layer);
|
|
174
|
+
const rects = this._rects();
|
|
175
|
+
const opts = this.options();
|
|
176
|
+
this._boxes = [];
|
|
177
|
+
this._bar = null;
|
|
178
|
+
rects.forEach((rect, i) => {
|
|
179
|
+
const st = this.states[i];
|
|
180
|
+
const box = h('div', { class: 'cmp-panel', style: { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.w}px`, height: `${rect.h}px` } });
|
|
181
|
+
const head = h('div', { class: 'cmp-head' });
|
|
182
|
+
const sel = h('select', { title: 'What this panel shows', onchange: () => { st.result = sel.value; this._legend(box, st); this.app.viewer.requestRender(); } });
|
|
183
|
+
for (const [k, label] of opts) sel.appendChild(h('option', { value: k, text: label, selected: k === st.result }));
|
|
184
|
+
head.appendChild(sel);
|
|
185
|
+
if (this.hasScenarios()) {
|
|
186
|
+
const sc = h('select', { class: 'cmp-scn', title: 'Scenario', onchange: () => { st.scenario = sc.value; this.app.viewer.requestRender(); } });
|
|
187
|
+
for (const k of ['A', 'B']) sc.appendChild(h('option', { value: k, text: k, selected: k === st.scenario }));
|
|
188
|
+
head.appendChild(sc);
|
|
189
|
+
}
|
|
190
|
+
box.appendChild(head);
|
|
191
|
+
this._legend(box, st);
|
|
192
|
+
if (this.mode === 'swipe') {
|
|
193
|
+
box.classList.add(i === 0 ? 'left' : 'right');
|
|
194
|
+
if (i === 1) { head.style.left = 'auto'; head.style.right = '64px'; } // clear of the icon bar
|
|
195
|
+
}
|
|
196
|
+
this.layer.appendChild(box);
|
|
197
|
+
this._boxes.push(box);
|
|
198
|
+
});
|
|
199
|
+
if (this.mode === 'swipe') {
|
|
200
|
+
const W = this.app.viewer.renderer.domElement.clientWidth;
|
|
201
|
+
const bar = h('div', { class: 'cmp-divider', title: 'Drag to swipe', style: { left: `${Math.round(W * this.split)}px` } }, h('span', { class: 'cmp-grip', text: '⇆' }));
|
|
202
|
+
// Dragging only moves the divider and the panel frames: rebuilding the
|
|
203
|
+
// DOM here would remove the element holding the pointer capture and end
|
|
204
|
+
// the drag after its first move.
|
|
205
|
+
bar.addEventListener('pointerdown', (e) => {
|
|
206
|
+
if (e.button !== 0) return;
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
bar.setPointerCapture(e.pointerId);
|
|
210
|
+
bar.classList.add('dragging');
|
|
211
|
+
const move = (ev) => {
|
|
212
|
+
const rect = this.layer.getBoundingClientRect();
|
|
213
|
+
if (!rect.width) return;
|
|
214
|
+
this.split = Math.max(0.02, Math.min(0.98, (ev.clientX - rect.left) / rect.width));
|
|
215
|
+
this._layoutSwipe();
|
|
216
|
+
this.app.viewer.requestRender();
|
|
217
|
+
};
|
|
218
|
+
const up = () => {
|
|
219
|
+
bar.classList.remove('dragging');
|
|
220
|
+
bar.removeEventListener('pointermove', move);
|
|
221
|
+
bar.removeEventListener('pointerup', up);
|
|
222
|
+
bar.removeEventListener('pointercancel', up);
|
|
223
|
+
};
|
|
224
|
+
bar.addEventListener('pointermove', move);
|
|
225
|
+
bar.addEventListener('pointerup', up);
|
|
226
|
+
bar.addEventListener('pointercancel', up);
|
|
227
|
+
});
|
|
228
|
+
this.layer.appendChild(bar);
|
|
229
|
+
this._bar = bar;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Move the swipe divider and the two panel frames to the current split. */
|
|
234
|
+
_layoutSwipe() {
|
|
235
|
+
const rects = this._rects();
|
|
236
|
+
rects.forEach((rect, i) => {
|
|
237
|
+
const box = this._boxes?.[i];
|
|
238
|
+
if (!box) return;
|
|
239
|
+
box.style.left = `${rect.x}px`;
|
|
240
|
+
box.style.width = `${rect.w}px`;
|
|
241
|
+
});
|
|
242
|
+
if (this._bar) this._bar.style.left = `${rects[1].x}px`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The label a panel shows: what it displays, and its scenario. */
|
|
246
|
+
caption(i) {
|
|
247
|
+
const st = this.states[i];
|
|
248
|
+
if (!st) return '';
|
|
249
|
+
const r = this.app.results.find((x) => x.id === st.result);
|
|
250
|
+
const name = r ? r.title : 'No analysis';
|
|
251
|
+
return st.scenario ? `${name} · ${st.scenario}` : name;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Draw the panel captions, legends and dividers on a 2D canvas of W x H
|
|
256
|
+
* (an image export or a video frame), as the page shows them over the view.
|
|
257
|
+
*/
|
|
258
|
+
drawOverlay(ctx, W, H, { legends = true, reserveRight = 0, reserveBottom = 0, reserveCorner = 0 } = {}) {
|
|
259
|
+
if (!this.mode) return;
|
|
260
|
+
const u = Math.max(1, Math.min(W, H) / 900);
|
|
261
|
+
const rects = this._rects(W, H);
|
|
262
|
+
const swipe = this.mode === 'swipe';
|
|
263
|
+
ctx.save();
|
|
264
|
+
ctx.textBaseline = 'top';
|
|
265
|
+
// Panel borders and the swipe line.
|
|
266
|
+
ctx.fillStyle = 'rgba(255,255,255,0.9)';
|
|
267
|
+
if (swipe) ctx.fillRect(rects[1].x - 1.5 * u, 0, 3 * u, H);
|
|
268
|
+
else for (const rc of rects) {
|
|
269
|
+
if (rc.x > 0) ctx.fillRect(rc.x - 1, rc.y, Math.max(1, u), rc.h);
|
|
270
|
+
if (rc.y > 0) ctx.fillRect(rc.x, rc.y - 1, rc.w, Math.max(1, u));
|
|
271
|
+
}
|
|
272
|
+
rects.forEach((rc, i) => {
|
|
273
|
+
if (!this.states[i]) return;
|
|
274
|
+
const right = swipe && i === 1;
|
|
275
|
+
// Caption.
|
|
276
|
+
ctx.font = `600 ${13 * u}px system-ui, sans-serif`;
|
|
277
|
+
const text = this.caption(i);
|
|
278
|
+
const maxW = (swipe ? rc.w : rc.w) - 40 * u - (right ? reserveRight : 0);
|
|
279
|
+
let t = text;
|
|
280
|
+
while (t.length > 3 && ctx.measureText(t).width > maxW) t = `${t.slice(0, -2)}…`;
|
|
281
|
+
const tw = ctx.measureText(t).width;
|
|
282
|
+
const bx = right ? rc.x + rc.w - tw - 20 * u - 8 * u - reserveRight : rc.x + 8 * u;
|
|
283
|
+
const by = rc.y + 8 * u;
|
|
284
|
+
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
|
285
|
+
ctx.beginPath();
|
|
286
|
+
ctx.roundRect(bx, by, tw + 20 * u, 26 * u, 6 * u);
|
|
287
|
+
ctx.fill();
|
|
288
|
+
ctx.fillStyle = '#1d2430';
|
|
289
|
+
ctx.fillText(t, bx + 10 * u, by + 6 * u);
|
|
290
|
+
// Legend (a ramp, as on the page).
|
|
291
|
+
if (!legends) return;
|
|
292
|
+
const r = this.app.results.find((x) => x.id === this.states[i].result);
|
|
293
|
+
if (!r || !r.display) return;
|
|
294
|
+
const items = r.legend?.length ? r.legend.slice(0, 10) : null;
|
|
295
|
+
if (!items && r.display.categorical) return;
|
|
296
|
+
const lw = Math.min(170 * u, rc.w - 16 * u), lh = items ? (10 + items.length * 16) * u : 40 * u;
|
|
297
|
+
const lx = right ? rc.x + rc.w - lw - 8 * u : rc.x + 8 * u;
|
|
298
|
+
// Clear of the credits line (bottom row) and the scale bar (bottom-right corner).
|
|
299
|
+
const bottomRow = rc.y + rc.h >= H - 1;
|
|
300
|
+
const ly = rc.y + rc.h - lh - 8 * u - (bottomRow ? reserveBottom + (right ? reserveCorner : 0) : 0);
|
|
301
|
+
if (items) {
|
|
302
|
+
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
|
303
|
+
ctx.beginPath();
|
|
304
|
+
ctx.roundRect(lx, ly, lw, lh, 6 * u);
|
|
305
|
+
ctx.fill();
|
|
306
|
+
ctx.font = `${11 * u}px system-ui, sans-serif`;
|
|
307
|
+
items.forEach((it, k) => {
|
|
308
|
+
ctx.fillStyle = it.color;
|
|
309
|
+
ctx.fillRect(lx + 8 * u, ly + (6 + k * 16) * u, 11 * u, 11 * u);
|
|
310
|
+
ctx.fillStyle = '#333a45';
|
|
311
|
+
ctx.fillText(String(it.label), lx + 25 * u, ly + (6 + k * 16) * u);
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
|
316
|
+
ctx.beginPath();
|
|
317
|
+
ctx.roundRect(lx, ly, lw, lh, 6 * u);
|
|
318
|
+
ctx.fill();
|
|
319
|
+
const stops = rampStops(r.display.ramp);
|
|
320
|
+
const grad = ctx.createLinearGradient(lx + 8 * u, 0, lx + lw - 8 * u, 0);
|
|
321
|
+
stops.forEach((s, k) => grad.addColorStop(k / Math.max(1, stops.length - 1), `rgb(${s.map((x) => Math.round(x * 255)).join(',')})`));
|
|
322
|
+
ctx.fillStyle = grad;
|
|
323
|
+
ctx.fillRect(lx + 8 * u, ly + 8 * u, lw - 16 * u, 9 * u);
|
|
324
|
+
ctx.fillStyle = '#4a5260';
|
|
325
|
+
ctx.font = `${11 * u}px system-ui, sans-serif`;
|
|
326
|
+
const lo = fmt(r.display.min, r.unit), hi = fmt(r.display.max, r.unit);
|
|
327
|
+
ctx.fillText(lo, lx + 8 * u, ly + 22 * u);
|
|
328
|
+
ctx.fillText(hi, lx + lw - 8 * u - ctx.measureText(hi).width, ly + 22 * u);
|
|
329
|
+
});
|
|
330
|
+
ctx.restore();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Rows of the layout, for the scale bar of one panel. */
|
|
334
|
+
panelSize(W, H) {
|
|
335
|
+
if (!this.mode || this.mode === 'swipe') return { w: W, h: H };
|
|
336
|
+
const [cols, rows] = LAYOUTS[this.mode];
|
|
337
|
+
return { w: W / cols, h: H / rows };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_legend(box, st) {
|
|
341
|
+
box.querySelector('.cmp-legend')?.remove();
|
|
342
|
+
const r = this.app.results.find((x) => x.id === st.result);
|
|
343
|
+
if (!r || !r.display) return;
|
|
344
|
+
if (r.legend?.length) {
|
|
345
|
+
box.appendChild(h('div', { class: 'cmp-legend cmp-classes' }, ...r.legend.slice(0, 10).map((it) =>
|
|
346
|
+
h('div', { class: 'cmp-class' }, h('span', { class: 'swatch', style: { background: it.color } }), h('span', { text: it.label })))));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (r.display.categorical) return;
|
|
350
|
+
const stops = rampStops(r.display.ramp);
|
|
351
|
+
const css = stops.map((s, i) => `rgb(${s.map((x) => Math.round(x * 255)).join(',')}) ${(100 * i) / (stops.length - 1)}%`).join(',');
|
|
352
|
+
box.appendChild(h('div', { class: 'cmp-legend' },
|
|
353
|
+
h('div', { class: 'cmp-ramp', style: { background: `linear-gradient(90deg, ${css})` } }),
|
|
354
|
+
h('div', { class: 'cmp-scale' }, h('span', { text: fmt(r.display.min, r.unit) }), h('span', { text: fmt(r.display.max, r.unit) }))));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
onResize() {
|
|
358
|
+
if (this.mode) this._buildDom();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export { THREE };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Tiny DOM helpers: no framework, just elements and form rows.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create an element. attrs: class, text, html, style (object), on* handlers,
|
|
6
|
+
* any other attribute or property.
|
|
7
|
+
* @returns {any}
|
|
8
|
+
*/
|
|
9
|
+
export function h(tag, attrs = {}, ...children) {
|
|
10
|
+
const el = document.createElement(tag);
|
|
11
|
+
for (const [k, v] of Object.entries(attrs || {})) {
|
|
12
|
+
if (v === undefined || v === null || v === false) continue;
|
|
13
|
+
if (k === 'class') el.className = v;
|
|
14
|
+
else if (k === 'text') el.textContent = v;
|
|
15
|
+
else if (k === 'html') el.innerHTML = v;
|
|
16
|
+
else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
|
|
17
|
+
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
|
|
18
|
+
else if (k in el && typeof v !== 'string') el[k] = v;
|
|
19
|
+
else el.setAttribute(k, v === true ? '' : String(v));
|
|
20
|
+
}
|
|
21
|
+
for (const c of children.flat()) {
|
|
22
|
+
if (c === null || c === undefined || c === false) continue;
|
|
23
|
+
el.appendChild(typeof c === 'string' || typeof c === 'number' ? document.createTextNode(String(c)) : c);
|
|
24
|
+
}
|
|
25
|
+
return el;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clear(el) {
|
|
29
|
+
while (el.firstChild) el.removeChild(el.firstChild);
|
|
30
|
+
return el;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A labelled row. */
|
|
34
|
+
export function row(label, control, hint = '') {
|
|
35
|
+
return h('label', { class: 'row', title: hint || undefined }, h('span', { class: 'row-label', text: label }), control);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function select(options, value, onChange) {
|
|
39
|
+
const el = h('select', { onchange: () => onChange?.(el.value) });
|
|
40
|
+
for (const o of options) {
|
|
41
|
+
const [v, label] = Array.isArray(o) ? o : [o, o];
|
|
42
|
+
el.appendChild(h('option', { value: String(v), text: String(label), selected: String(v) === String(value) }));
|
|
43
|
+
}
|
|
44
|
+
return el;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function number(value, { min, max, step = 'any', onChange, width } = {}) {
|
|
48
|
+
const el = h('input', { type: 'number', value: String(value), min, max, step, style: width ? { width } : undefined,
|
|
49
|
+
onchange: () => onChange?.(Number(el.value)) });
|
|
50
|
+
return el;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function slider(value, { min, max, step = 1, format = (v) => String(v), onInput, onChange } = {}) {
|
|
54
|
+
const out = h('span', { class: 'slider-value', text: format(value) });
|
|
55
|
+
const el = h('input', { type: 'range', min, max, step, value: String(value),
|
|
56
|
+
oninput: () => { out.textContent = format(Number(el.value)); onInput?.(Number(el.value)); },
|
|
57
|
+
onchange: () => onChange?.(Number(el.value)) });
|
|
58
|
+
const wrap = h('span', { class: 'slider' }, el, out);
|
|
59
|
+
wrap.input = el;
|
|
60
|
+
wrap.set = (v) => { el.value = String(v); out.textContent = format(Number(v)); };
|
|
61
|
+
return wrap;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function toggle(checked, onChange, label = '') {
|
|
65
|
+
const input = h('input', { type: 'checkbox', checked, onchange: () => onChange?.(input.checked) });
|
|
66
|
+
return h('label', { class: 'toggle' }, input, label ? h('span', { text: label }) : null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function button(text, onClick, { kind = '', title = '', icon = '' } = {}) {
|
|
70
|
+
return h('button', { class: `btn ${kind}`, title: title || undefined, onclick: onClick }, icon ? h('span', { class: 'icon', text: icon }) : null, text);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Collapsible card. */
|
|
74
|
+
export function card(title, body, { open = false, subtitle = '', badge = '' } = {}) {
|
|
75
|
+
const el = h('details', { class: 'card', open },
|
|
76
|
+
h('summary', {}, h('span', { class: 'card-title', text: title }), badge ? h('span', { class: 'badge', text: badge }) : null,
|
|
77
|
+
subtitle ? h('span', { class: 'card-sub', text: subtitle }) : null),
|
|
78
|
+
h('div', { class: 'card-body' }, body));
|
|
79
|
+
return el;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function stats(pairs) {
|
|
83
|
+
return h('table', { class: 'stats' }, ...pairs.map(([k, v]) => h('tr', {}, h('td', { text: k }), h('td', { text: v }))));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Save a Blob or data URL as a download. */
|
|
87
|
+
export function download(name, data, type = 'text/plain') {
|
|
88
|
+
const url = typeof data === 'string' && data.startsWith('data:') ? data : URL.createObjectURL(data instanceof Blob ? data : new Blob([data], { type }));
|
|
89
|
+
const a = h('a', { href: url, download: name });
|
|
90
|
+
document.body.appendChild(a);
|
|
91
|
+
a.click();
|
|
92
|
+
a.remove();
|
|
93
|
+
if (!url.startsWith('data:')) setTimeout(() => URL.revokeObjectURL(url), 4000);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let toastTimer = 0;
|
|
97
|
+
export function toast(text, kind = '') {
|
|
98
|
+
const el = document.getElementById('toast');
|
|
99
|
+
if (!el) return;
|
|
100
|
+
el.textContent = text;
|
|
101
|
+
el.className = `toast show ${kind}`;
|
|
102
|
+
clearTimeout(toastTimer);
|
|
103
|
+
toastTimer = window.setTimeout(() => { el.className = 'toast'; }, 4200);
|
|
104
|
+
}
|