Rendre les tuiles jointives sur la carte et supprimer le cadre blanc au dézoom

Les sous-tuiles voisines ne partageaient pas leurs bords (interpolation
bilinéaire évaluée deux fois sur des fractions différentes), créant une ligne
claire entre images au zoom moyen. Les points de bord sont désormais calculés
une fois par dalle et partagés entre sous-tuiles adjacentes.

Le cadre blanc autour des tuiles au dézoom venait du outline CSS dessiné
par-dessus les bords d'images non jointives : supprimé (le survol garde son
halo). La bascule overview ↔ vignettes oscillait d'un cran de zoom, d'où une
hystérésis. Les seuils de paliers sont revus pour ne jamais étirer la
vignette intermédiaire au-delà de 1:1.
This commit is contained in:
Antoine Jacquin
2026-09-20 16:47:26 +02:00
parent 5f03359fc3
commit e0173e0b39
2 changed files with 85 additions and 19 deletions

View File

@ -721,18 +721,33 @@ def _subtile_corners(corners, i, j, k):
i : indice vers l'est (0..k-1), j : indice vers le nord (0..k-1).
Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté
est quasi un parallélogramme à cette échelle (erreur écran < 1 px).
Les bords sont partagés entre sous-tuiles voisines : les points d'une
même ligne/colonne de la grille sont calculés UNE fois par dalle et
mémorisés, afin que deux tuiles adjacentes reçoivent exactement le même
point (sans partage, l'interpolation est évaluée deux fois sur des
fractions légèrement différentes et les bords ne coïncident plus à moins
d'un pixel — jointure cassée au zoom moyen).
"""
sw, se, ne, nw = corners
def lerp(p, q, u):
return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]
def at(u, v):
return lerp(lerp(sw, se, u), lerp(nw, ne, u), v)
# Bords partagés : les points de la grille (k+1)×(k+1) sont calculés UNE
# fois et mémorisés par coordonnées, afin que deux sous-tuiles voisines
# reçoivent exactement le même point. Sans partage, l'interpolation est
# évaluée deux fois sur des fractions différentes et les bords ne
# coïncident plus à moins d'un pixel (jointure cassée au zoom moyen).
def at(i0, j0):
u = i0 / k
v = j0 / k
bottom = lerp(sw, se, u) # le long du bord sud, position u
top = lerp(nw, ne, u) # le long du bord nord, position u
return lerp(bottom, top, v) # interpolation nord-sud
u0, u1 = i / k, (i + 1) / k
v0, v1 = j / k, (j + 1) / k
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
# Coins SW, SE, NE, NW de la sous-tuile (i, j).
return [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]
def _fallback_full_dalle(entries, viz_key, info):
@ -1855,10 +1870,10 @@ input[type="range"]::-moz-range-thumb {
.ltile-anchor { width: 0; height: 0; }
.ltile {
position: absolute; left: 0; top: 0; overflow: hidden;
/* Grille dessinée en outline (par-dessus le bord des images) : une
bordure classique insetterait l'image de 1 px (box-sizing: border-box)
et creuserait un espace vide entre deux tuiles jointives. */
outline: 1px solid rgba(255, 255, 255, 0.10);
/* Pas de contour ni d'outline : au dézoom les tuiles ne sont plus jointives
à l'écran (arrondi du marqueur, homographie) et un cadre dessinait une
ligne claire entre deux images voisines. La grille se lit déjà sur le
contenu ; le survol garde son halo bleu. */
/* Fond transparent obligatoire : l'opacité des couches doit laisser voir
les couches du dessous et le fond de carte, pas un rectangle noir. */
background: transparent;
@ -1879,15 +1894,15 @@ body.zooming .leaflet-lidarTiles-pane { will-change: transform; }
/* Évite le drag natif des images qui avale le mouseup (dessin de zones) */
pointer-events: none; -webkit-user-drag: none; user-select: none;
}
.ltile:hover { outline-color: var(--accent); box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); }
.ltile.selected { outline-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); }
.ltile:hover { box-shadow: 0 0 8px rgba(90, 169, 230, 0.35); }
.ltile.selected { box-shadow: 0 0 0 1px var(--accent), 0 0 12px rgba(90, 169, 230, 0.5); }
/* Overview (mosaïque unique de la vue dézoomée) : pas de contour ni de
survol — il couvre toute la grille —, hors content-visibility (l'ancre
est un coin de grille souvent hors écran alors que la mosaïque couvre
l'écran : le navigateur pourrait sauter sa peinture) et l'échange de
palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle
affichée : jamais de trou ni de flash au dézoom. */
.ltile.loverview { outline: none; pointer-events: none; content-visibility: visible; }
.ltile.loverview { pointer-events: none; content-visibility: visible; }
.ltile img.fading { transition: opacity 300ms ease; opacity: 0 !important; }
/* --- Fiche tuile (onglet Tuile) --- */
@ -2197,7 +2212,10 @@ const osmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
// (maxNativeZoom) au lieu de demander des tuiles inexistantes.
maxZoom: 20,
maxNativeZoom: 19,
keepBuffer: 1
keepBuffer: 4,
// Tuile absente (zone sans données OSM) : on ne laisse pas apparaître le
// fond de carte brut autour des tuiles LiDAR au dézoom.
errorTileUrl: ''
}).addTo(map);
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).addTo(map);
@ -2508,14 +2526,17 @@ function layoutOverview() {
// Overview actif tant qu'un kilomètre à l'écran ne dépasse pas la résolution
// d'UNE QUELCONQUE des mosaïques (bascule globale, conservatrice : la zone à
// la résolution la plus grossière décide — affichée au pire à 1:1, jamais
// étirée donc jamais floue).
// étirée donc jamais floue). Marge OV_HYSTERESIS_PX en pixels écran : sans
// elle la bascule oscille d'un cran de zoom (le px/km projeté croît de ~4 %
// par demi-zoom) et les tuiles ne sont plus jointives au passage.
const OV_HYSTERESIS_PX = 8;
function overviewWanted() {
const items = ovItems();
if (!items.length || !ovEntries.length || !map._loaded) return false;
const zoom = map.getZoom();
for (const it of items) {
const sw = map.project(it.corners[0], zoom), se = map.project(it.corners[1], zoom);
if (Math.hypot(se.x - sw.x, se.y - sw.y) / it.km_w > it.px_per_km) return false;
if (Math.hypot(se.x - sw.x, se.y - sw.y) / it.km_w > it.px_per_km + OV_HYSTERESIS_PX) return false;
}
return true;
}
@ -2684,8 +2705,11 @@ function syncTileLayers() {
// aucun reflow.
const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran
const MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide)
const BIG_PX = 220; // en dessous, la vignette suffit
const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire
// La pleine résolution ne se charge que pour les sous-tuiles qui dépassent
// cette taille projetée : sous ce seuil la vignette intermédiaire (640 px)
// est déjà affichée à moins d'une fois et demi sa résolution native — jamais
// étirée au-delà de 1:1, donc jamais floue.
const BIG_PX = 384; // en dessous, la vignette intermédiaire suffit
const FULL_CAP = 24; // images pleine résolution max, toutes couches
// Change le src d'une <img> sans flash : l'image est d'abord téléchargée et
@ -2762,8 +2786,6 @@ function updateImages() {
if (big && state.layers.opacity[k] > 0.15) {
if (!onScreen) {
want = v.mid || v.thumb; // anneau : préchargement intermédiaire
} else if (Math.max(p.w, p.h) < FULL_RES_PX) {
want = v.mid || v.full;
} else {
fullWant.push({ img: img, v: v, dist: dist });
continue;

View File

@ -770,6 +770,50 @@ def test_overview_skipped_for_small_grids(tmp_path):
assert data['overview'] == {}
def test_subtile_corners_shared_edges_exact():
"""Bords partagés : deux sous-tuiles voisines reçoivent le MÊME point.
Sans partage, l'interpolation bilinéaire est évaluée deux fois sur des
fractions différentes et les bords ne coïncident plus à moins d'un pixel
— jointure cassée (ligne blanche) au zoom moyen.
"""
from lidar_pipeline.index import _subtile_corners
corners = [[10.0, 2.0], [10.0, 3.0], [11.0, 3.0], [11.0, 2.0]]
k = 4
a = _subtile_corners(corners, 0, 0, k)
b = _subtile_corners(corners, 1, 0, k)
assert a[1] == b[0] # bord est de (0,0) = bord ouest de (1,0)
assert a[2] == b[3]
c = _subtile_corners(corners, 0, 1, k)
assert a[3] == c[0] # bord nord de (0,0) = bord sud de (0,1)
d = _subtile_corners(corners, 3, 3, k)
assert d[2] == corners[2] # coin NE de la dalle partagé tel quel
def test_overview_corners_shared_between_tiles(tmp_path):
"""Le coin d'une dalle est aussi celui de sa voisine (même point).
Deux dalles adjacentes E-O partagent un bord vertical : le NE de la dalle
ouest = le NW de la dalle est. Sans partage, les interpolations bilinéaires
des sous-tuiles voisines ne coïncident plus à moins d'un pixel — jointure
cassée au zoom moyen.
"""
from lidar_pipeline.index import build_index
output_dir = tmp_path / "output"
vis_dir = output_dir / "visualisations"
vis_dir.mkdir(parents=True)
for col in (1000, 1001):
_make_fake_viz_dir(vis_dir, "a", col, 6881, ('hillshade_multi',))
assert build_index(output_dir) is not None
data = json.loads((output_dir / "index_tiles.json").read_text(encoding='utf-8'))
tiles = {t['col']: t for t in data['tiles']}
a_ne = tiles[1000]['corners'][2] # NE de la dalle ouest
b_nw = tiles[1001]['corners'][3] # NW de la dalle est
assert a_ne == b_nw, f"coins partagés divergents : {a_ne} != {b_nw}"
def test_urls_versioned_by_served_file_mtime(tmp_path):
"""?v= suit la mtime du fichier SERVI — prérequis du cache immutable.