Add visualization picker to zone generation and unify tile colors
- Web map: multi-select picker in the generation bar (aspect, slope, positive openness, anisotropic openness, wavelet) passed to the API; the layer panel is restricted to the same shortlist (PANEL_VIZ) and a refresh button rebuilds the index when new layers appear on disk; jobs started outside the UI are now adopted into the visible queue - Uniform colors across tiles: openness/anisotropic/sailore now store local z-scores, and all renderers use fixed ranges (0-3 sigma, SVF 0-1 physical, slope 0-30 deg) instead of per-tile percentile stretches - Ray-tracing falls back to CPU when VRAM is exhausted so openness and SVF no longer fail silently on shared GPUs - build_index merges visualizations available at only one resolution into the displayed tile so in-progress layers stay visible - 11 new tests (142 passing)
This commit is contained in:
@ -54,10 +54,26 @@ VIZ_LABELS = {
|
||||
# Couche activée par défaut à l'ouverture de la carte.
|
||||
DEFAULT_VIZ = 'hillshade_multi'
|
||||
|
||||
# Couches proposées dans le panneau de couches et le sélecteur de génération
|
||||
# (liste restreinte : base aspect + visualisations demandées). Les autres
|
||||
# visualisations restent calculées et stockées mais ne sont pas proposées.
|
||||
PANEL_VIZ = ('aspect', 'slope', 'positive_openness', 'aniso_open', 'wavelet')
|
||||
|
||||
# Visualisations proposées dans la barre de génération de zones (noms
|
||||
# d'étapes --only du pipeline) ; 'aspect' reste le choix par défaut (léger).
|
||||
GEN_VIZ_CHOICES = [
|
||||
('aspect', 'Aspect'),
|
||||
('slope', 'Pente'),
|
||||
('pos_open', 'Openness positive'),
|
||||
('aniso_open', 'Openness anisotropique'),
|
||||
('wavelet', 'Ondelette'),
|
||||
]
|
||||
|
||||
# Visualisations découpées en sous-tuiles (quadrants 500 m) pour alléger la
|
||||
# carte — les autres viz restent disponibles en repli dalle entière.
|
||||
# Liste vide = découper toutes les visualisations disponibles.
|
||||
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi')
|
||||
_CARTO_SUBTILED_VIZ = ('aspect', 'hillshade_multi', 'slope', 'positive_openness',
|
||||
'aniso_open', 'wavelet')
|
||||
|
||||
# Encodage AVIF des sous-tuiles (benchmark sur dalles d'aspect réelles :
|
||||
# q55 ≈ −45 % vs WebP q82, visuellement propre sur rampes de couleur).
|
||||
@ -617,10 +633,22 @@ def build_index(output_dir, output_format='avif'):
|
||||
# superposent exactement sur la carte et celle ajoutée en dernier dans le
|
||||
# DOM (la moins résolue, tri croissant) masque l'autre.
|
||||
best_by_pos = {}
|
||||
tiles_by_pos = {}
|
||||
for t in tiles:
|
||||
key = (t['col'], t['row'])
|
||||
tiles_by_pos.setdefault(key, []).append(t)
|
||||
if key not in best_by_pos or t['resolution'] < best_by_pos[key]['resolution']:
|
||||
best_by_pos[key] = t
|
||||
# Complète chaque dalle affichée (résolution la plus fine) avec les
|
||||
# visualisations produites uniquement à l'autre résolution : sans cela,
|
||||
# une couche en cours de génération (passe 0,5 m faite, 0,2 m pas encore)
|
||||
# reste invisible sur la carte et absente du panneau de couches.
|
||||
for key, best in best_by_pos.items():
|
||||
for other in tiles_by_pos[key]:
|
||||
if other is best:
|
||||
continue
|
||||
for viz_key, viz_info in other['viz'].items():
|
||||
best['viz'].setdefault(viz_key, viz_info)
|
||||
tiles = sorted(best_by_pos.values(),
|
||||
key=lambda t: (t['resolution'], -t['row'], t['col']))
|
||||
|
||||
@ -755,21 +783,34 @@ def _write_assets(output_dir):
|
||||
|
||||
def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
||||
"""Construit la coquille HTML (données intégrées, styles/JS dans assets/)."""
|
||||
# Restriction du panneau de couches à la liste choisie (PANEL_VIZ) :
|
||||
# les autres visualisations existent sur disque mais ne sont pas proposées.
|
||||
panel_keys = set(all_viz_keys)
|
||||
if PANEL_VIZ is not None:
|
||||
panel_keys &= set(PANEL_VIZ)
|
||||
# Ordre des couches dans le panneau (selon ordre préféré puis alpha).
|
||||
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in all_viz_keys]
|
||||
for v in sorted(all_viz_keys):
|
||||
ordered_viz = [v for v in _VIZ_FALLBACK_ORDER if v in panel_keys]
|
||||
for v in sorted(panel_keys):
|
||||
if v not in ordered_viz:
|
||||
ordered_viz.append(v)
|
||||
|
||||
tiles_json = json.dumps(tiles, ensure_ascii=False)
|
||||
|
||||
# Options du sélecteur de visualisations de la barre de génération.
|
||||
gen_viz_options = "\n".join(
|
||||
f' <option value="{name}"{" selected" if name == "aspect" else ""}>'
|
||||
f'{label}</option>'
|
||||
for name, label in GEN_VIZ_CHOICES)
|
||||
|
||||
viz_meta_json = json.dumps({
|
||||
k: {'label': VIZ_LABELS.get(k, k)}
|
||||
for k in ordered_viz
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Couche activée par défaut : DEFAULT_VIZ si proposée, sinon la 1re
|
||||
default_viz = DEFAULT_VIZ if DEFAULT_VIZ in ordered_viz else (ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
|
||||
# Couche activée par défaut : préférence globale, sinon aspect (base
|
||||
# complète), sinon la 1re proposée.
|
||||
default_viz = next((v for v in (DEFAULT_VIZ, 'aspect') if v in ordered_viz),
|
||||
ordered_viz[0] if ordered_viz else DEFAULT_VIZ)
|
||||
stats_json = json.dumps({'default_viz': default_viz, 'n_tiles': len(tiles)},
|
||||
ensure_ascii=False)
|
||||
|
||||
@ -781,6 +822,7 @@ def _render_html(tiles, global_bbox, all_viz_keys, output_format):
|
||||
tiles_json=tiles_json,
|
||||
viz_meta_json=viz_meta_json,
|
||||
stats_json=stats_json,
|
||||
gen_viz_options=gen_viz_options,
|
||||
n_tiles=n_tiles,
|
||||
grid_w=grid_w,
|
||||
grid_h=grid_h,
|
||||
@ -816,6 +858,7 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<aside id="layerPanel" class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">Couches</span>
|
||||
<button id="layerRefresh" class="icon-btn" hidden title="Nouvelles couches disponibles">↻</button>
|
||||
<button id="layerCollapse" class="icon-btn" title="Replier / déplier">▾</button>
|
||||
</div>
|
||||
<div id="layerBody">
|
||||
@ -850,6 +893,12 @@ _HTML_TEMPLATE = """<!DOCTYPE html>
|
||||
<label class="gen-opt">
|
||||
<input type="checkbox" id="genRegen"> Régénérer les tuiles existantes
|
||||
</label>
|
||||
<label class="gen-opt" title="Ctrl+clic pour sélectionner plusieurs visualisations">
|
||||
Visualisations :
|
||||
<select id="genViz" class="gen-select" multiple size="4">
|
||||
{gen_viz_options}
|
||||
</select>
|
||||
</label>
|
||||
<label class="gen-opt">
|
||||
Classification :
|
||||
<select id="genClass" class="gen-select">
|
||||
@ -1635,6 +1684,7 @@ const genCancel = document.getElementById('genCancel');
|
||||
const genRegen = document.getElementById('genRegen');
|
||||
const genClass = document.getElementById('genClass');
|
||||
const genIgnClasses = document.getElementById('genIgnClasses');
|
||||
const genViz = document.getElementById('genViz');
|
||||
const genBareEarth = document.getElementById('genBareEarth');
|
||||
const genQueue = document.getElementById('genQueue');
|
||||
const genQueueBody = document.getElementById('genQueueBody');
|
||||
@ -1719,10 +1769,12 @@ genGo.addEventListener('click', () => {
|
||||
clearGhosts();
|
||||
const nTiles = pendingTiles.length;
|
||||
const isRegen = genRegen.checked || pendingRegen;
|
||||
const vizSel = genViz ? Array.from(genViz.selectedOptions).map(o => o.value) : [];
|
||||
fetch('/api/generate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tiles: pendingTiles,
|
||||
regenerate: isRegen,
|
||||
viz: vizSel.length ? vizSel : ['aspect'],
|
||||
ground_class: genClass ? genClass.value : 'ign',
|
||||
ign_classes: genIgnClasses ? genIgnClasses.value : 'sol',
|
||||
bare_earth: genBareEarth ? genBareEarth.checked : false })
|
||||
@ -1852,8 +1904,19 @@ genQueueClear.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
renderGenQueue();
|
||||
// Reprend le suivi si une tâche tournait avant un rechargement de la page
|
||||
// Reprend le suivi si une tâche tournait avant un rechargement de la page,
|
||||
// ou adopte une génération lancée hors interface (API directe) pour
|
||||
// l'afficher dans la file avec son journal.
|
||||
if (genTasks.some(t => t.state === 'running')) pollGenStatus();
|
||||
else fetch('/api/status').then(r => r.ok ? r.json() : null).then(s => {
|
||||
if (!s || !s.running) return;
|
||||
genTasks.push({
|
||||
id: Date.now(), label: 'Génération en cours (lancée hors interface)',
|
||||
time: new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
|
||||
state: 'running', returncode: null, log: [], logOpen: false,
|
||||
});
|
||||
saveGenTasks(); renderGenQueue(); pollGenStatus();
|
||||
}).catch(() => {});
|
||||
|
||||
// ============================================================
|
||||
// Initialisation
|
||||
@ -1865,5 +1928,52 @@ fitView();
|
||||
layoutTiles();
|
||||
zoomInd.textContent = 'Zoom : ' + map.getZoom().toFixed(1);
|
||||
|
||||
// ============================================================
|
||||
// Détection de nouvelles couches (run de génération en cours)
|
||||
// ============================================================
|
||||
// Le panneau et les URLs par tuile sont figés au rebuild de l'index :
|
||||
// /api/layers liste ce qui existe sur disque, et le bouton ↻ reconstruit
|
||||
// la carte (vignettes + sous-tuiles) puis recharge la page.
|
||||
const layerRefreshBtn = document.getElementById('layerRefresh');
|
||||
function checkNewLayers() {
|
||||
fetch('/api/layers').then(r => r.ok ? r.json() : null).then(d => {
|
||||
if (!d || !layerRefreshBtn) return;
|
||||
const news = Object.keys(d).filter(k => !VIZ_META[k]);
|
||||
layerRefreshBtn.hidden = news.length === 0;
|
||||
if (news.length) {
|
||||
layerRefreshBtn.title = 'Nouvelles couches : ' +
|
||||
news.map(k => d[k]).join(', ') + ' — cliquer pour actualiser la carte';
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
checkNewLayers();
|
||||
setInterval(checkNewLayers, 60000);
|
||||
if (layerRefreshBtn) layerRefreshBtn.addEventListener('click', () => {
|
||||
layerRefreshBtn.textContent = '…';
|
||||
layerRefreshBtn.disabled = true;
|
||||
fetch('/api/rebuild', { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : Promise.reject('rejeté'))
|
||||
.then(() => new Promise(resolve => {
|
||||
const poll = setInterval(() => {
|
||||
fetch('/api/rebuild').then(r => r.json()).then(s => {
|
||||
if (!s.running) { clearInterval(poll); resolve(s.error); }
|
||||
}).catch(() => { clearInterval(poll); resolve(null); });
|
||||
}, 3000);
|
||||
}))
|
||||
.then(err => {
|
||||
if (err) {
|
||||
layerRefreshBtn.textContent = '↻';
|
||||
layerRefreshBtn.disabled = false;
|
||||
layerRefreshBtn.title = 'Erreur rebuild : ' + err;
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
layerRefreshBtn.textContent = '↻';
|
||||
layerRefreshBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
} // fin guard Leaflet
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user