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:
@ -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).
|
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é
|
Interpolation bilinéaire des coins de la dalle — le quadrilatère projeté
|
||||||
est quasi un parallélogramme à cette échelle (erreur écran < 1 px).
|
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
|
sw, se, ne, nw = corners
|
||||||
|
|
||||||
def lerp(p, q, u):
|
def lerp(p, q, u):
|
||||||
return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]
|
return [p[0] + (q[0] - p[0]) * u, p[1] + (q[1] - p[1]) * u]
|
||||||
|
|
||||||
def at(u, v):
|
# Bords partagés : les points de la grille (k+1)×(k+1) sont calculés UNE
|
||||||
return lerp(lerp(sw, se, u), lerp(nw, ne, u), v)
|
# 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
|
# Coins SW, SE, NE, NW de la sous-tuile (i, j).
|
||||||
v0, v1 = j / k, (j + 1) / k
|
return [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]
|
||||||
return [at(u0, v0), at(u1, v0), at(u1, v1), at(u0, v1)]
|
|
||||||
|
|
||||||
|
|
||||||
def _fallback_full_dalle(entries, viz_key, info):
|
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-anchor { width: 0; height: 0; }
|
||||||
.ltile {
|
.ltile {
|
||||||
position: absolute; left: 0; top: 0; overflow: hidden;
|
position: absolute; left: 0; top: 0; overflow: hidden;
|
||||||
/* Grille dessinée en outline (par-dessus le bord des images) : une
|
/* Pas de contour ni d'outline : au dézoom les tuiles ne sont plus jointives
|
||||||
bordure classique insetterait l'image de 1 px (box-sizing: border-box)
|
à l'écran (arrondi du marqueur, homographie) et un cadre dessinait une
|
||||||
et creuserait un espace vide entre deux tuiles jointives. */
|
ligne claire entre deux images voisines. La grille se lit déjà sur le
|
||||||
outline: 1px solid rgba(255, 255, 255, 0.10);
|
contenu ; le survol garde son halo bleu. */
|
||||||
/* Fond transparent obligatoire : l'opacité des couches doit laisser voir
|
/* Fond transparent obligatoire : l'opacité des couches doit laisser voir
|
||||||
les couches du dessous et le fond de carte, pas un rectangle noir. */
|
les couches du dessous et le fond de carte, pas un rectangle noir. */
|
||||||
background: transparent;
|
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) */
|
/* Évite le drag natif des images qui avale le mouseup (dessin de zones) */
|
||||||
pointer-events: none; -webkit-user-drag: none; user-select: none;
|
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:hover { 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.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
|
/* 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
|
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
|
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
|
l'écran : le navigateur pourrait sauter sa peinture) et l'échange de
|
||||||
palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle
|
palier (overview ↔ vignettes) fond l'ancienne image une fois la nouvelle
|
||||||
affichée : jamais de trou ni de flash au dézoom. */
|
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; }
|
.ltile img.fading { transition: opacity 300ms ease; opacity: 0 !important; }
|
||||||
|
|
||||||
/* --- Fiche tuile (onglet Tuile) --- */
|
/* --- 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.
|
// (maxNativeZoom) au lieu de demander des tuiles inexistantes.
|
||||||
maxZoom: 20,
|
maxZoom: 20,
|
||||||
maxNativeZoom: 19,
|
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);
|
}).addTo(map);
|
||||||
L.control.scale({ metric: true, imperial: false, position: 'bottomleft' }).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
|
// 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 à
|
// 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
|
// 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() {
|
function overviewWanted() {
|
||||||
const items = ovItems();
|
const items = ovItems();
|
||||||
if (!items.length || !ovEntries.length || !map._loaded) return false;
|
if (!items.length || !ovEntries.length || !map._loaded) return false;
|
||||||
const zoom = map.getZoom();
|
const zoom = map.getZoom();
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
const sw = map.project(it.corners[0], zoom), se = map.project(it.corners[1], zoom);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@ -2684,8 +2705,11 @@ function syncTileLayers() {
|
|||||||
// aucun reflow.
|
// aucun reflow.
|
||||||
const PRELOAD_MARGIN_PX = 300; // anneau de vignettes autour de l'écran
|
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 MID_MARGIN_PX = 150; // anneau du palier intermédiaire (pan fluide)
|
||||||
const BIG_PX = 220; // en dessous, la vignette suffit
|
// La pleine résolution ne se charge que pour les sous-tuiles qui dépassent
|
||||||
const FULL_RES_PX = 900; // en dessous, la vignette intermédiaire
|
// 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
|
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
|
// 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 (big && state.layers.opacity[k] > 0.15) {
|
||||||
if (!onScreen) {
|
if (!onScreen) {
|
||||||
want = v.mid || v.thumb; // anneau : préchargement intermédiaire
|
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 {
|
} else {
|
||||||
fullWant.push({ img: img, v: v, dist: dist });
|
fullWant.push({ img: img, v: v, dist: dist });
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -770,6 +770,50 @@ def test_overview_skipped_for_small_grids(tmp_path):
|
|||||||
assert data['overview'] == {}
|
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):
|
def test_urls_versioned_by_served_file_mtime(tmp_path):
|
||||||
"""?v= suit la mtime du fichier SERVI — prérequis du cache immutable.
|
"""?v= suit la mtime du fichier SERVI — prérequis du cache immutable.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user