Regrouper les réglages de la carte dans un panneau unique à onglets, volet en bas sur téléphone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Antoine Jacquin
2026-09-27 18:00:59 +02:00
parent b975ed272a
commit b24ebcd83a
4 changed files with 362 additions and 188 deletions

View File

@ -62,8 +62,8 @@ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(-
border: 1px solid var(--border); border-radius: var(--r-md);
box-shadow: var(--shadow-lg);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
#layercard { left: 12px; bottom: 12px; width: 320px; max-width: calc(100vw - 24px); }
#tilecard { left: 12px; top: 12px; width: 340px; max-width: calc(100vw - 24px);
/* Fiche de dalle : à droite du panneau (ordinateur), en haut (téléphone). */
#tilecard { left: max(var(--sp-3), var(--panel-w)); top: 12px; width: 340px; max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px); overflow-y: auto; }
.info-sec { margin-top: 10px; }
.info-sec h4 { margin: 0 0 4px; font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0.06em;
@ -74,18 +74,11 @@ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(-
.info-dl { display: inline-block; margin-top: 6px; padding: 6px 10px; border-radius: var(--r-sm);
background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--text); text-decoration: none; font-weight: 600; }
.info-dl:hover { background: color-mix(in srgb, var(--accent) 30%, transparent); }
#rose { right: 12px; bottom: 190px; padding: 8px 8px 6px; display: flex; flex-direction: column;
align-items: center; gap: 2px; z-index: var(--z-map-ui); }
#rose canvas { width: 132px; height: 132px; }
.rose-cap { font-size: var(--fs-xs); text-align: center; color: var(--text); line-height: 1.3; }
.rose-cap { font-size: var(--fs-xs); color: var(--text); line-height: 1.3; }
.rose-cap span { color: var(--muted); font-size: var(--fs-xs); }
#usecard { left: 50%; top: 12px; transform: translateX(-50%); width: min(560px, calc(100vw - 24px)); z-index: var(--z-popup); }
.card-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
.card-title { font-size: var(--fs-sm); font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
.card-stat { margin-left: auto; font-size: var(--fs-xs); color: var(--muted); }
.card-body { padding: 6px 8px 8px; max-height: min(60vh, 520px); overflow-y: auto; }
#layercard.collapsed .card-body { display: none; }
#layercard.collapsed #cardToggle { transform: rotate(-90deg); }
.card-foot { display: flex; gap: 8px; padding: 8px 4px 2px; margin-top: 6px; border-top: 1px solid var(--border); }
/* --- composants communs --------------------------------------------------- */
@ -96,6 +89,7 @@ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(-
.btn:disabled, button:disabled { opacity: 0.5; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
.btn-primary:hover { filter: brightness(1.08); }
.btn.accent, .btn.busy { border-color: var(--accent); color: var(--accent); }
.btn-ghost { background: transparent; }
.btn-icon { min-height: 0; padding: var(--sp-1) var(--sp-2); border: none; background: none;
color: var(--muted); font-size: var(--fs-lg); }
@ -150,14 +144,23 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
.eye.on { color: var(--accent); }
.eye svg { width: 16px; height: 16px; }
.layer-name { font-size: var(--fs-sm); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.base-row .layer-name { color: var(--text); cursor: pointer; }
.base-row:not(.on) .layer-name { color: var(--muted); }
.pct { font-size: var(--fs-xs); color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
#tools { position: fixed; right: 12px; bottom: 12px; z-index: var(--z-panel); display: flex; flex-direction: column; gap: 6px; }
#tools { position: fixed; right: var(--sp-3); bottom: calc(var(--sheet-h) + var(--sp-3));
z-index: var(--z-map-ui); display: flex; flex-direction: column; gap: var(--sp-2); }
@media (max-width: 720px) {
#tools { top: var(--sp-3); bottom: auto; }
}
/* L'attribution Leaflet reste en bas à droite : les outils passent au-dessus. */
@media (min-width: 721px) { #tools { bottom: 44px; } }
#tools button { width: 38px; height: 38px; padding: 0; font-size: var(--fs-lg); background: var(--surface);
border: 1px solid var(--border); box-shadow: var(--shadow-sm); }
/* Indicateur de chargement : tuiles restantes pour finir la vue courante
(fond + LiDAR), barre de progression du lot en cours. */
.tileload { position: fixed; left: 50%; top: 12px; transform: translateX(-50%); z-index: var(--z-loading);
.tileload { position: fixed; left: calc(var(--panel-w) + (100vw - var(--panel-w)) / 2); top: auto;
bottom: calc(var(--sheet-h) + var(--sp-3)); transform: translateX(-50%); z-index: var(--z-loading);
display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-radius: var(--r-full);
background: var(--surface); border: 1px solid var(--border); font-size: var(--fs-sm);
box-shadow: var(--shadow-sm); pointer-events: none;
@ -172,7 +175,8 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
overflow: hidden; flex: none; }
.tl-bar span { display: block; height: 100%; width: 0; background: var(--accent);
transition: width 0.2s ease; }
.toast { position: fixed; left: 50%; bottom: 76px; transform: translateX(-50%); z-index: var(--z-toast);
.toast { position: fixed; left: calc(var(--panel-w) + (100vw - var(--panel-w)) / 2);
bottom: calc(var(--sheet-h) + 56px); transform: translateX(-50%); z-index: var(--z-toast);
background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm);
padding: 7px 12px; font-size: var(--fs-sm); color: var(--text); max-width: 80vw; text-align: center; }
@ -186,23 +190,12 @@ input[type="range"]::-moz-range-thumb { width: 10px; height: 10px; border-radius
.leaflet-control-attribution a { color: var(--accent) !important; }
/* --- génération de tuiles ------------------------------------------------ */
#gencard { right: 12px; top: 56px; width: 340px; max-width: calc(100vw - 24px); }
#printcard { right: 60px; bottom: 12px; width: 340px; max-width: calc(100vw - 84px); }
.print-msg { font-size: var(--fs-sm); margin-left: 8px; }
.print-handle { display: flex; align-items: center; justify-content: center; cursor: move;
font-size: var(--fs-lg); color: #1b1b1b; background: var(--print); border: 2px solid #1b1b1b;
border-radius: var(--r-full); box-shadow: 0 1px 6px rgba(0,0,0,0.5); }
body.drawing { cursor: crosshair !important; }
body.drawing .leaflet-container { cursor: crosshair !important; }
/* Barre de génération : en HAUT à droite de l'écran, boutons accentués —
c'est l'entrée principale du traitement, elle ne se confond pas avec les
icônes de navigation (zoom/GPS) du coin bas droit. */
#gentools { position: fixed; top: 12px; right: 12px; z-index: var(--z-panel); display: flex; gap: 8px; }
#gentools button { background: var(--surface); color: var(--accent); border: 1px solid var(--accent);
border-radius: var(--r-md); padding: 8px 14px; font: inherit; font-size: var(--fs-md); font-weight: 600;
box-shadow: var(--shadow-sm); }
#gentools button:hover { background: var(--accent); color: var(--on-accent); }
#gentools button.accent { background: var(--accent); color: var(--on-accent); }
.gen-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px;
align-items: center; margin: 4px 0; font-size: var(--fs-xs); color: var(--muted); }
.chk { display: flex; gap: 7px; align-items: center; font-size: var(--fs-xs);
@ -221,9 +214,54 @@ body.drawing .leaflet-container { cursor: crosshair !important; }
.gen-frame-pending { stroke: var(--gen-pending); fill: color-mix(in srgb, var(--gen-pending) 8%, transparent); stroke-dasharray: 6 4; }
.gen-frame-failed { stroke: var(--gen-failed); fill: color-mix(in srgb, var(--gen-failed) 20%, transparent); }
.gen-sel { stroke: var(--accent); fill: color-mix(in srgb, var(--accent) 12%, transparent); cursor: pointer; }
/* --- panneau unique à onglets ------------------------------------------------
Ordinateur : colonne à gauche, repliable en bande d'icônes.
Téléphone : volet en bas à trois hauteurs (fermé / mi-hauteur / plein). */
#panel { position: fixed; z-index: var(--z-panel); display: flex; flex-direction: column;
background: var(--surface); color: var(--text); border: 1px solid var(--border);
box-shadow: var(--shadow-lg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
left: var(--sp-3); top: var(--sp-3); bottom: var(--sp-3); width: 340px; border-radius: var(--r-md); }
.panel-head { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2);
border-bottom: 1px solid var(--border); }
.panel-tabs { display: flex; gap: var(--sp-1); flex: 1; min-width: 0; }
.panel-tabs button { flex: 1; min-width: 0; flex-direction: column; gap: 0; min-height: 44px;
padding: var(--sp-1); border: none; background: transparent; color: var(--muted); }
.panel-tabs button[aria-selected="true"] { background: var(--surface-2); color: var(--accent); }
.tab-ico { font-size: var(--fs-lg); line-height: 1.1; }
.tab-lbl { font-size: var(--fs-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-3); overscroll-behavior: contain; }
.panel-body section > * + * { margin-top: var(--sp-2); }
.panel-stat { font-size: var(--fs-sm); color: var(--muted); }
.sec-title { font-size: var(--fs-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--muted); margin-top: var(--sp-4); }
.legend { display: flex; align-items: center; gap: var(--sp-3); }
.legend canvas { width: 96px; height: 96px; flex: none; }
.sheet-grip { display: none; }
#panel.collapsed { width: 60px; }
#panel.collapsed .panel-body, #panel.collapsed .tab-lbl { display: none; }
#panel.collapsed .panel-head { flex-direction: column; border-bottom: none; }
#panel.collapsed .panel-tabs { flex-direction: column; align-self: stretch; }
#panel.collapsed #panelCollapse { transform: rotate(180deg); }
/* La carte, l'échelle et les messages se décalent de la largeur du panneau. */
body { --panel-w: 364px; --sheet-h: 0px; }
body.panel-collapsed { --panel-w: 84px; }
.leaflet-bottom.leaflet-left { left: var(--panel-w); }
@media (max-width: 720px) {
#layercard { width: calc(100vw - 24px); }
#tilecard { width: calc(100vw - 24px); }
#gencard { width: calc(100vw - 24px); }
#printcard { width: calc(100vw - 84px); }
body, body.panel-collapsed { --panel-w: 0px; }
#panel, #panel.collapsed { left: 0; right: 0; top: auto; bottom: 0; width: auto;
border-radius: var(--r-md) var(--r-md) 0 0; border-bottom: none; transition: height 0.2s ease; }
#panel[data-sheet="closed"] { height: 72px; }
#panel[data-sheet="half"] { height: 45vh; }
#panel[data-sheet="full"] { height: calc(100vh - var(--sp-4)); }
#panel[data-sheet="closed"] .panel-body { display: none; }
#panelCollapse { display: none; }
.sheet-grip { display: flex; justify-content: center; padding: var(--sp-2) 0 0; cursor: grab; touch-action: none; }
.sheet-grip span { width: 36px; height: 4px; border-radius: var(--r-full); background: var(--muted); }
.panel-head { padding: var(--sp-1) var(--sp-2); }
.leaflet-bottom { bottom: var(--sheet-h); }
/* Attribution sur toute la largeur : l'échelle passe au-dessus d'elle,
l'indicateur de chargement en haut de l'écran (à gauche des outils). */
.leaflet-bottom.leaflet-left .leaflet-control-scale { margin-bottom: 28px; }
.tileload { top: var(--sp-3); bottom: auto; }
}

View File

@ -11,53 +11,106 @@
<body>
<div id="map"></div>
<div id="layercard" class="card">
<div class="card-head">
<button id="cardToggle" class="btn-icon" title="Replier / déplier">▾</button>
<span class="card-title">Couches</span>
<span id="stat" class="card-stat"></span>
<aside id="panel" data-sheet="half" aria-label="Réglages de la carte">
<div id="sheetGrip" class="sheet-grip" title="Glisser pour agrandir ou réduire"><span></span></div>
<div class="panel-head">
<nav id="panelTabs" class="panel-tabs" role="tablist">
<button role="tab" data-tab="view" title="Affichage (1)"><span class="tab-ico">◧</span><span class="tab-lbl">Affichage</span></button>
<button role="tab" data-tab="print" title="Export PDF (2)"><span class="tab-ico">⎙</span><span class="tab-lbl">Export PDF</span></button>
<button role="tab" data-tab="gen" title="Génération (3)" hidden><span class="tab-ico">+</span><span class="tab-lbl">Génération</span></button>
<button role="tab" data-tab="share" title="Partager (4)"><span class="tab-ico">⎘</span><span class="tab-lbl">Partager</span></button>
</nav>
<button id="panelCollapse" class="btn-icon" title="Replier le panneau">‹</button>
</div>
<div class="card-body">
<div class="base-row" id="baseRow">
<button class="eye on" id="baseToggle" title="Afficher / masquer le fond de carte"></button>
<span class="layer-name" id="baseName" title="Cliquer : fond clair / assombri">Fond OpenStreetMap</span>
<span class="pct" id="basePct"></span>
<div class="base-ctl">
<input type="range" id="baseOpacity" min="0" max="100" step="1" title="Opacité du fond">
<div id="panelBody" class="panel-body">
<section id="tab-view" role="tabpanel" hidden>
<div class="base-row" id="baseRow">
<button class="eye on" id="baseToggle" title="Afficher / masquer le fond de carte"></button>
<span class="layer-name" id="baseName" title="Cliquer : fond clair / assombri">Fond OpenStreetMap</span>
<span class="pct" id="basePct"></span>
<div class="base-ctl">
<input type="range" id="baseOpacity" min="0" max="100" step="1" title="Opacité du fond">
</div>
</div>
</div>
<div class="view-row">
<span class="view-label">Affichage</span>
<span class="layer-name" id="mainName"></span>
<select id="mainSel" class="field" title="Couche d'affichage principal" hidden></select>
</div>
<div id="precBlock" hidden>
<div class="seg" id="modeSeg" role="group" aria-label="Relief, précision ou les deux (touche P)">
<button type="button" data-mode="relief" title="Relief seul (touche P : mode suivant)">Relief</button>
<button type="button" data-mode="precision" title="Précision géométrique seule : densité de points sol">Précision</button>
<button type="button" data-mode="both" title="Précision en produit sur le relief : les zones peu fiables s'assombrissent">Les deux</button>
<div class="view-row">
<span class="view-label">Affichage</span>
<span class="layer-name" id="mainName"></span>
<select id="mainSel" class="field" title="Couche d'affichage principal" hidden></select>
</div>
<div class="prec-ctl" id="precCtl" hidden>
<span>Opacité de la précision</span>
<input type="range" id="precOpacity" min="0" max="100" step="1" title="Opacité de la précision sur le relief">
<span class="pct" id="precPct"></span>
<div id="precBlock" hidden>
<div class="seg" id="modeSeg" role="group" aria-label="Relief, précision ou les deux (touche P)">
<button type="button" data-mode="relief" title="Relief seul (touche P : mode suivant)">Relief</button>
<button type="button" data-mode="precision" title="Précision géométrique seule : densité de points sol">Précision</button>
<button type="button" data-mode="both" title="Précision en produit sur le relief : les zones peu fiables s'assombrissent">Les deux</button>
</div>
<div class="prec-ctl" id="precCtl" hidden>
<span>Opacité de la précision</span>
<input type="range" id="precOpacity" min="0" max="100" step="1" title="Opacité de la précision sur le relief">
<span class="pct" id="precPct"></span>
</div>
<div class="prec-legend" id="precLegend" hidden>
<div class="prec-bar" id="precBar"></div>
<div class="prec-ticks"><span>≤ 0,35</span><span>4</span><span>≥ 45 pts/m²</span></div>
<div class="prec-cap">Points sol par m² : sombre = relief interpolé, noir = aucun point</div>
</div>
</div>
<div class="prec-legend" id="precLegend" hidden>
<div class="prec-bar" id="precBar"></div>
<div class="prec-ticks"><span>≤ 0,35</span><span>4</span><span>≥ 45 pts/m²</span></div>
<div class="prec-cap">Points sol par m² : sombre = relief interpolé, noir = aucun point</div>
<div class="legend" id="legendBlock" title="Relief orienté : la teinte donne l'orientation de la pente, la clarté le relief local">
<canvas id="roseCanvas" width="96" height="96"></canvas>
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>
</div>
</div>
<div class="card-foot">
<button id="btnShare" class="btn" title="Copier un lien vers cette vue">⎘ Partager la vue</button>
<button id="btnUse" class="btn" title="Utiliser ces rendus comme fond d'imagerie (JOSM, QGIS, iD…)">⤓ Utiliser dans JOSM / QGIS</button>
</div>
<div class="card-foot">
<button id="btnDefault" class="btn" title="Figer l'affichage courant (couche principale, mode, opacité de la précision, fond) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
<button id="btnReset" class="btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button>
</div>
</section>
<section id="tab-print" role="tabpanel" hidden>
<div class="panel-stat" id="printInfo"></div>
<div class="row"><select id="printPaper" class="field" title="Format papier">
<option value="A4" selected>A4</option><option value="A3">A3</option></select>
<select id="printOrient" class="field" title="Orientation">
<option value="paysage" selected>Paysage</option><option value="portrait">Portrait</option></select>
<select id="printScale" class="field" title="Échelle d'impression">
<option value="1000">1:1 000</option><option value="2000" selected>1:2 000</option>
<option value="5000">1:5 000</option><option value="10000">1:10 000</option></select></div>
<div class="row"><input id="printTitle" class="field" type="text" maxlength="120"
placeholder="Titre (optionnel)" title="Titre imprimé dans le cartouche"></div>
<p class="hint">Faites glisser la poignée ✥ pour placer le cadre jaune sur la zone à imprimer.</p>
<div class="row"><button id="printHere" class="btn"
title="Placer le cadre au centre de la vue">⌖ Centrer ici</button>
<button id="printFit" class="btn" title="Zoomer sur le cadre">⤢ Voir le cadre</button></div>
<div class="card-foot"><button id="printGo" class="btn btn-primary">Exporter le PDF</button>
<span id="printMsg" class="print-msg"></span></div>
</section>
<section id="tab-gen" role="tabpanel" hidden>
<div class="row"><button id="btnZone" class="btn" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement">+ Zone</button>
<button id="btnComplete" class="btn" title="Compléter : traiter toutes les dalles déjà téléchargées qui manquent des couches choisies">⤒ Compléter</button></div>
<div class="panel-stat" id="genBadge"></div>
<div id="genMsg" class="meta">Dessinez une zone (+) ou complétez les dalles existantes (⤒).</div>
<div id="genOpts">
<div class="gen-row"><span class="gen-lbl">Couches</span>
<select id="genViz" class="field" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<div class="meta">Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)</div>
</div>
<div class="card-foot" id="genActions">
<button id="genGo" class="btn btn-primary" title="Télécharge (géoplateforme IGN) puis traite les dalles sélectionnées">Générer</button>
<button id="genClear" title="Vider la sélection de dalles">Effacer la sélection</button>
</div>
<div id="genRun" hidden>
<div class="meta" id="genProgress"></div>
<pre id="genLog" class="gen-log"></pre>
<div class="card-foot">
<button id="genStop" title="SIGTERM au pipeline (nettoyage propre), SIGKILL en repli">Arrêter</button>
<button id="genQueueClear" title="Retirer les demandes en attente (le run en cours continue)">Vider la file</button>
</div>
</div>
</section>
<section id="tab-share" role="tabpanel" hidden>
<div class="row"><button id="btnShare" class="btn btn-primary" title="Copier un lien vers cette vue">⎘ Copier le lien de la vue</button></div>
<h4 class="sec-title">Fond d'imagerie XYZ (JOSM, QGIS, iD…)</h4>
<div id="useBody"></div>
<h4 class="sec-title">Configuration par défaut</h4>
<div class="row"><button id="btnDefault" class="btn" title="Figer l'affichage courant (couche principale, mode, opacité de la précision, fond) comme configuration par défaut de tous les navigateurs">★ Définir par défaut</button>
<button id="btnReset" class="btn" title="Revenir à la configuration par défaut">↺ Réinitialiser</button></div>
</section>
</div>
</div>
</aside>
<div id="tilecard" class="card" hidden>
<div class="card-head">
@ -67,83 +120,11 @@
<div class="card-body" id="tileBody"></div>
</div>
<div id="rose" class="card" hidden title="Relief orienté : la teinte donne l'orientation de la pente, la clarté le relief local">
<canvas id="roseCanvas" width="132" height="132"></canvas>
<div class="rose-cap">Pente orientée vers…<br><span>clair = bosse · sombre = creux</span></div>
</div>
<div id="usecard" class="card" hidden>
<div class="card-head">
<span class="card-title">Fond d'imagerie XYZ</span>
<button id="useClose" class="btn-icon" title="Fermer">×</button>
</div>
<div class="card-body" id="useBody"></div>
</div>
<div id="printcard" class="card" hidden>
<div class="card-head">
<span class="card-title">Export PDF</span>
<span id="printInfo" class="card-stat"></span>
<button id="printClose" class="btn-icon" title="Fermer">×</button>
</div>
<div class="card-body">
<div class="row"><select id="printPaper" class="field" title="Format papier">
<option value="A4" selected>A4</option><option value="A3">A3</option></select>
<select id="printOrient" class="field" title="Orientation">
<option value="paysage" selected>Paysage</option><option value="portrait">Portrait</option></select>
<select id="printScale" class="field" title="Échelle d'impression">
<option value="1000">1:1 000</option><option value="2000" selected>1:2 000</option>
<option value="5000">1:5 000</option><option value="10000">1:10 000</option></select></div>
<div class="row"><input id="printTitle" class="field" type="text" maxlength="120"
placeholder="Titre (optionnel)" title="Titre imprimé dans le cartouche"></div>
<p class="hint">Faites glisser la poignée ✥ pour placer le cadre jaune sur la zone à imprimer.</p>
<div class="row"><button id="printHere" class="btn"
title="Placer le cadre au centre de la vue">⌖ Centrer ici</button>
<button id="printFit" class="btn" title="Zoomer sur le cadre">⤢ Voir le cadre</button></div>
<div class="card-foot"><button id="printGo" class="btn btn-primary">Exporter le PDF</button>
<span id="printMsg" class="print-msg"></span></div>
</div>
</div>
<div id="gencard" class="card" hidden>
<div class="card-head">
<span class="card-title">Génération</span>
<span id="genBadge" class="card-stat"></span>
<button id="genClose" class="btn-icon" title="Fermer">×</button>
</div>
<div class="card-body">
<div id="genMsg" class="meta">Dessinez une zone (+) ou complétez les dalles existantes (⤒).</div>
<div id="genOpts">
<div class="gen-row"><span class="gen-lbl">Couches</span>
<select id="genViz" class="field" multiple size="4" title="Laisser vide pour les couches du panneau"></select></div>
<label class="chk"><input type="checkbox" id="genRegen"> Régénérer les tuiles existantes</label>
<div class="meta">Sol : classification IGN · bords raccordés aux dalles voisines (bande 100 m)</div>
</div>
<div class="card-foot" id="genActions">
<button id="genGo" class="btn btn-primary" title="Télécharge (géoplateforme IGN) puis traite les dalles sélectionnées">Générer</button>
<button id="genClear" title="Vider la sélection de dalles">Effacer la sélection</button>
</div>
<div id="genRun" hidden>
<div class="meta" id="genProgress"></div>
<pre id="genLog" class="gen-log"></pre>
<div class="card-foot">
<button id="genStop" title="SIGTERM au pipeline (nettoyage propre), SIGKILL en repli">Arrêter</button>
<button id="genQueueClear" title="Retirer les demandes en attente (le run en cours continue)">Vider la file</button>
</div>
</div>
</div>
</div>
<nav id="gentools">
<button id="btnZone" title="Générer une nouvelle zone : dessiner un rectangle, téléchargement IGN + traitement" hidden>+ Zone</button>
<button id="btnComplete" title="Compléter : traiter toutes les dalles déjà téléchargées qui manquent des couches choisies" hidden>⤒ Compléter</button>
</nav>
<nav id="tools">
<button id="zoomIn" title="Zoomer">+</button>
<button id="zoomOut" title="Dézoomer">−</button>
<button id="zoomFit" title="Recadrer sur les données">⤢</button>
<button id="gps" title="Centrer sur ma position">⌖</button>
<button id="btnPrint" title="Exporter la zone en PDF (impression terrain)">⎙</button>
<button id="btnTheme" class="btn-icon" title="Thème">◐</button>
</nav>
<div id="toast" class="toast" hidden></div>

View File

@ -65,9 +65,18 @@ function applyTheme(pref) {
const btn = el('btnTheme');
if (btn) { btn.textContent = THEME_ICON[p]; btn.title = THEME_TITLE[p] + ' (cliquer pour changer)'; }
if (typeof applyLayers === 'function' && STATE) applyLayers(); // fond OSM assombri
redrawRose();
return p;
}
// Rose de la légende : ses libellés cardinaux prennent la couleur du texte,
// elle est donc redessinée à chaque changement de thème (si déjà dessinée).
function redrawRose() {
if (!drawRose.done) return;
drawRose.done = false;
drawRose();
}
function cssVar(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
@ -580,15 +589,17 @@ function labToRgb(L, a, b) {
});
}
const ROSE_SIZE = 96; // taille affichée (px CSS), cf. .legend canvas
function drawRose() {
const cv = el('roseCanvas');
if (!cv || drawRose.done) return;
drawRose.done = true;
const dpr = window.devicePixelRatio || 1, size = 132;
const dpr = window.devicePixelRatio || 1, size = ROSE_SIZE, k = size / 132;
cv.width = size * dpr; cv.height = size * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
const c = size / 2, rOut = 50, rIn = 22, L = 64;
const c = size / 2, rOut = 50 * k, rIn = 22 * k, L = 64;
const chroma = 60 * Math.min(1, L * (100 - L) / 2500);
const img = ctx.createImageData(size * dpr, size * dpr);
for (let py = 0; py < size * dpr; py++) {
@ -604,14 +615,15 @@ function drawRose() {
}
ctx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = '600 11px system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const text = cssVar('--text') || '#1d232b';
const labels = [['N', 0], ['NE', 45], ['E', 90], ['SE', 135], ['S', 180], ['SO', 225], ['O', 270], ['NO', 315]];
for (const [t, deg] of labels) {
const a = deg * Math.PI / 180, rr = t.length > 1 ? 36 : 58;
const a = deg * Math.PI / 180, rr = (t.length > 1 ? 36 : 58) * k;
const x = c + rr * Math.sin(a), y = c - rr * Math.cos(a);
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : 'rgba(255,255,255,0.95)';
if (t.length > 1) { ctx.font = '600 9px system-ui, sans-serif'; } else { ctx.font = '700 11px system-ui, sans-serif'; }
// intercardinaux sur l'anneau coloré : sombres ; cardinaux hors anneau : couleur du texte
ctx.fillStyle = t.length > 1 ? 'rgba(20,20,20,0.85)' : text;
ctx.font = t.length > 1 ? '600 8px system-ui, sans-serif' : '700 10px system-ui, sans-serif';
ctx.fillText(t, x, y);
}
}
@ -619,7 +631,7 @@ function drawRose() {
function updateRose() {
const on = !!(STATE && STATE.main === 'relief_oriente' && effectiveMode() !== 'precision'
&& LAYERS.get('relief_oriente'));
el('rose').hidden = !on;
el('legendBlock').hidden = !on;
if (on) drawRose();
}
@ -654,7 +666,6 @@ function renderUseCard() {
} else { window.prompt('URL XYZ :', text); }
});
refresh();
el('usecard').hidden = false;
}
// --- génération de dalles (API /api/status, /api/preview, /api/generate) ---
@ -693,7 +704,8 @@ function genApi(method, url, body) {
function genOpenCard(msg) {
if (msg !== undefined) el('genMsg').textContent = msg;
el('gencard').hidden = false;
// Onglet déjà visible : pas de openTab, qui changerait la hauteur du volet.
if (panelVisibleTab() !== 'gen') openTab('gen');
}
function genFillViz() {
@ -862,6 +874,7 @@ function genAfterRun() {
renderPanel();
renderBaseRow();
applyLayers();
renderUseCard();
}).catch(() => {});
fetch('api/map/background', { method: 'POST' }).catch(() => {});
}
@ -915,10 +928,6 @@ function genPollSoon() {
el('btnZone').addEventListener('click', () => genSetDrawing(!genDrawing));
el('btnComplete').addEventListener('click', genComplete);
el('genClose').addEventListener('click', () => {
el('gencard').hidden = true;
if (genDrawing) genSetDrawing(false);
});
el('genClear').addEventListener('click', () => {
genSel.clear();
genCorners.clear();
@ -971,19 +980,15 @@ function printLoad() {
if (Array.isArray(s.center) && s.center.length === 2) PRINT.center = L.latLng(s.center);
}
// Recadre sur le cadre en laissant libre la bande basse couverte par les
// cartes (couches, export…) : sinon le bas du cadre passe dessous.
// Recadre sur le cadre en laissant libre la place du panneau (colonne à
// gauche sur ordinateur, volet en bas sur téléphone) : sinon il passe dessous.
function printFit() {
if (!PRINT.corners) return;
const box = map.getContainer().getBoundingClientRect();
let bottom = 0;
for (const c of document.querySelectorAll('.card')) {
if (c.hidden || !c.offsetParent) continue;
const r = c.getBoundingClientRect();
if (r.top > box.top + box.height / 2) bottom = Math.max(bottom, box.bottom - r.top);
}
const r = el('panel').getBoundingClientRect();
const panelWidth = isPhone() ? 0 : r.right;
const sheetHeight = isPhone() ? r.height : 0;
map.fitBounds(L.latLngBounds(PRINT.corners),
{ paddingTopLeft: [30, 60], paddingBottomRight: [70, bottom + 20] });
{ paddingTopLeft: [panelWidth + 30, 60], paddingBottomRight: [70, sheetHeight + 20] });
}
function printDraw(corners) {
@ -1046,10 +1051,11 @@ function printCenterHere() {
printRefreshFrame();
}
// Cadre affiché tant que l'onglet Export PDF est visible (événement panel:tab,
// émis à chaque rendu du panneau : on ne fait rien si l'état est inchangé).
function printSetOpen(on) {
if (PRINT.on === on) return;
PRINT.on = on;
el('printcard').hidden = !on;
el('btnPrint').classList.toggle('accent', on);
if (on) {
// cadre gardé s'il est encore dans la vue, sinon posé au centre
if (!PRINT.center || !map.getBounds().contains(PRINT.center)) PRINT.center = map.getCenter();
@ -1089,8 +1095,6 @@ function printExport() {
.finally(() => { btn.disabled = false; });
}
el('btnPrint').addEventListener('click', () => printSetOpen(!PRINT.on));
el('printClose').addEventListener('click', () => printSetOpen(false));
el('printGo').addEventListener('click', printExport);
el('printHere').addEventListener('click', printCenterHere);
el('printFit').addEventListener('click', printFit);
@ -1100,6 +1104,115 @@ for (const id of ['printPaper', 'printOrient', 'printScale']) {
el('printTitle').addEventListener('change', printSave);
printLoad();
document.addEventListener('panel:tab', (e) => {
printSetOpen(e.detail.visible === 'print');
if (e.detail.visible !== 'gen' && genDrawing) genSetDrawing(false);
});
// --- panneau à onglets --------------------------------------------------------
const PANEL_LS = 'lidar-panel';
const PANEL = Object.assign({ tab: 'view', collapsed: false, sheet: 'half' }, lsGet(PANEL_LS, {}));
const phoneQuery = window.matchMedia('(max-width: 720px)');
const isPhone = () => phoneQuery.matches;
// Onglets proposés : Génération seulement si le générateur répond et que ce
// navigateur a le droit de lancer (même règle que les anciens boutons).
function panelTabs() {
return ['view', 'print', 'gen', 'share'].filter(t => t !== 'gen' || (GEN.available && GEN.allowed));
}
// Onglet réellement visible, ou null (panneau replié, volet fermé).
function panelVisibleTab() {
if (isPhone() ? PANEL.sheet === 'closed' : PANEL.collapsed) return null;
return PANEL.tab;
}
function panelSave() { lsSet(PANEL_LS, { tab: PANEL.tab, collapsed: PANEL.collapsed, sheet: PANEL.sheet }); }
function panelRender() {
const tabs = panelTabs();
if (!tabs.includes(PANEL.tab)) PANEL.tab = 'view';
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b => {
b.hidden = !tabs.includes(b.dataset.tab);
b.setAttribute('aria-selected', String(b.dataset.tab === PANEL.tab));
});
for (const t of ['view', 'print', 'gen', 'share']) el('tab-' + t).hidden = t !== PANEL.tab;
const panel = el('panel');
panel.classList.toggle('collapsed', PANEL.collapsed);
panel.dataset.sheet = PANEL.sheet;
document.body.classList.toggle('panel-collapsed', PANEL.collapsed);
// Hauteur couverte par le volet, que l'échelle, l'attribution et les messages
// dégagent ; volet plein : la carte est cachée, rien à dégager (sinon ils
// remonteraient en haut de l'écran, sur les outils).
const sheetPx = isPhone() && PANEL.sheet !== 'full' ? panel.getBoundingClientRect().height : 0;
document.body.style.setProperty('--sheet-h', sheetPx + 'px');
document.dispatchEvent(new CustomEvent('panel:tab', { detail: { tab: PANEL.tab, visible: panelVisibleTab() } }));
}
function openTab(tab) {
if (!panelTabs().includes(tab)) return;
const before = PANEL.sheet;
// Onglet actif retouché sur téléphone : le volet change de hauteur.
if (isPhone() && tab === PANEL.tab && PANEL.sheet !== 'closed') {
PANEL.sheet = PANEL.sheet === 'half' ? 'full' : 'half';
} else if (isPhone() && PANEL.sheet === 'closed') {
PANEL.sheet = 'half';
}
const resized = isPhone() && PANEL.sheet !== before;
PANEL.tab = tab;
PANEL.collapsed = false;
panelSave(); panelRender();
if (resized) setTimeout(panelRender, 220); // hauteur finale après la transition CSS
}
function setCollapsed(on) { PANEL.collapsed = !!on; panelSave(); panelRender(); map.invalidateSize(); }
function setSheet(state) {
if (!['closed', 'half', 'full'].includes(state)) return;
PANEL.sheet = state; panelSave();
panelRender();
setTimeout(panelRender, 220); // hauteur finale après la transition CSS
}
el('panelTabs').querySelectorAll('button[data-tab]').forEach(b =>
b.addEventListener('click', () => openTab(b.dataset.tab)));
el('panelCollapse').addEventListener('click', () => setCollapsed(!PANEL.collapsed));
phoneQuery.addEventListener('change', panelRender);
window.addEventListener('resize', panelRender); // hauteur du volet en vh (rotation)
// Poignée du volet : glisser vers le haut / le bas, relâcher sur la hauteur
// la plus proche ; un simple toucher fait monter d'un cran.
(function sheetDrag() {
const grip = el('sheetGrip');
let start = null;
grip.addEventListener('pointerdown', (e) => {
start = { y: e.clientY, h: el('panel').getBoundingClientRect().height };
grip.setPointerCapture(e.pointerId);
el('panel').style.transition = 'none';
});
grip.addEventListener('pointermove', (e) => {
if (!start) return;
const h = Math.max(72, Math.min(window.innerHeight - 16, start.h + start.y - e.clientY));
el('panel').style.height = h + 'px';
});
const end = (e) => {
if (!start) return;
const moved = Math.abs(e.clientY - start.y) > 6;
const h = el('panel').getBoundingClientRect().height;
el('panel').style.height = ''; el('panel').style.transition = '';
start = null;
if (!moved) { setSheet(PANEL.sheet === 'closed' ? 'half' : PANEL.sheet === 'half' ? 'full' : 'half'); return; }
const stops = { closed: 72, half: window.innerHeight * 0.45, full: window.innerHeight - 16 };
const best = Object.entries(stops).sort((a, b) => Math.abs(a[1] - h) - Math.abs(b[1] - h))[0][0];
setSheet(best);
};
grip.addEventListener('pointerup', end);
grip.addEventListener('pointercancel', end);
})();
// Fiche de dalle fermée par Échap (provisoire : la Task 4 la remplace).
function closeTileViews() { if (el('tilecard').hidden) return false; el('tilecard').hidden = true; return true; }
// --- démarrage ------------------------------------------------------------
function fitData() {
if (META.bounds) {
@ -1119,13 +1232,14 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
// L'état retenu (défauts du serveur, puis lien partagé) est persisté tout de
// suite : ce qui est à l'écran est ce qui sera rechargé.
saveState();
el('stat').textContent = META.layers.length + ' couche(s)';
const hash = parseHash();
if (hash) map.setView([hash.lat, hash.lng], hash.zoom);
else fitData();
buildLayers();
renderPanel();
renderBaseRow();
panelRender();
renderUseCard();
genFillViz();
// Droits de génération : boutons affichés si le serveur peut générer
// (pipeline local ou LIDAR_GENERATION_URL) ET que le navigateur vient du
@ -1135,8 +1249,7 @@ fetch('api/map/meta').then(r => r.json()).then(meta => {
GEN.available = !!d.available;
GEN.allowed = d.regen_allowed !== false;
const show = GEN.available && GEN.allowed;
el('btnZone').hidden = !show;
el('btnComplete').hidden = !show;
panelRender();
if (show) { genUpdateStatus(d); genPollNow(); }
}).catch(() => {});
}).catch(() => toast('Serveur de tuiles injoignable.', 10000));
@ -1166,14 +1279,10 @@ map.on('click', (e) => {
.then(d => showTileInfo(d, e.latlng));
});
el('cardToggle').addEventListener('click', () =>
el('layercard').classList.toggle('collapsed'));
el('tileClose').addEventListener('click', () => {
el('tilecard').hidden = true;
if (tileSel) { map.removeLayer(tileSel); tileSel = null; }
});
el('useClose').addEventListener('click', () => { el('usecard').hidden = true; });
el('btnUse').addEventListener('click', renderUseCard);
el('btnShare').addEventListener('click', () => {
const url = location.origin + location.pathname + viewHash(true);
history.replaceState(null, '', url);
@ -1245,7 +1354,11 @@ el('btnTheme').addEventListener('click', () => {
lsSet(THEME_LS, THEME);
});
if (darkQuery && darkQuery.addEventListener) {
darkQuery.addEventListener('change', () => { if (THEME === 'auto' && STATE) applyLayers(); });
darkQuery.addEventListener('change', () => {
if (THEME !== 'auto') return;
if (STATE) applyLayers();
redrawRose();
});
}
el('zoomIn').addEventListener('click', () => map.zoomIn(1));
el('zoomOut').addEventListener('click', () => map.zoomOut(1));
@ -1273,16 +1386,26 @@ el('gps').addEventListener('click', () => {
}, { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 });
});
document.addEventListener('keydown', (e) => {
// P : relief → précision → les deux → relief (hors saisie de texte)
const typing = e.target && e.target.closest && e.target.closest('input, select, textarea');
if ((e.key === 'p' || e.key === 'P') && !typing && !e.ctrlKey && !e.metaKey && !e.altKey
&& META && STATE && precisionKey()) {
if (typing || e.ctrlKey || e.metaKey || e.altKey) {
if (e.key === 'Escape' && typing) e.target.blur();
return;
}
// P : relief → précision → les deux → relief
if ((e.key === 'p' || e.key === 'P') && META && STATE && precisionKey()) {
const i = VIEW_KEYS.indexOf(STATE.mode);
setMode(VIEW_KEYS[(i + 1) % VIEW_KEYS.length], true);
return;
}
// 1–4 : onglets du panneau (Génération ignorée si masquée)
if (/^[1-4]$/.test(e.key)) {
const tab = ['view', 'print', 'gen', 'share'][Number(e.key) - 1];
if (panelTabs().includes(tab)) openTab(tab);
return;
}
if (e.key === 'Escape') {
el('tilecard').hidden = true; el('usecard').hidden = true;
if (genDrawing) genSetDrawing(false);
if (genDrawing) { genSetDrawing(false); return; }
if (closeTileViews()) return; // bulle / fiche de dalle (Task 4)
if (isPhone()) setSheet('closed'); else setCollapsed(true);
}
});