fix(data): light info (LITCHR/range/height) en boyas y balizas de orilla

frontend/js/map.js:
- Reemplaza merge exacto por merge de proximidad (~50m) en loadChartFeatures
  para capturar pares LIGHTS/BOYLAT con coordenadas no exactamente iguales
- Guard null-canvas en encStyle con fallback visible + console.warn
- Mejora JS de debug: log layer/aidType cuando usa fallback

backend/services/chart_manager.py:
- Expande extraccion de light_desc a category buoy+beacon+landmark
  (antes solo BOYLAT/BOYCAR; BCNLAT/BCNWTW/LNDMRK perdian LITCHR silenciosamente)
This commit is contained in:
2026-04-30 13:55:08 -04:00
parent 025e5e5213
commit 293b0c45ef
2 changed files with 56 additions and 18 deletions
+4 -1
View File
@@ -271,7 +271,10 @@ def _parse_cell(enc_path: Path) -> list[dict]:
"colours": colours, # list of S-57 colour codes
"colour_code": colours[0] if colours else None, # primary colour
}
if category == "light" or layer_name in ("BOYLAT","BOYCAR","BOYISD","BOYSPP","BOYSAW"):
# Extract light description for ALL navigational aids and lights.
# Previously only BOY* layers were included, so BCNLAT/BCNWTW/LNDMRK
# silently lost their LITCHR/SIGPER/VALNMR — fixed here.
if category in ("light", "buoy", "beacon", "landmark"):
props["light_desc"] = _light_desc(row)
if layer_name.startswith("BOY") or layer_name.startswith("BCN"):
shape = _safe(row.get("BOYSHP"))
+52 -17
View File
@@ -1645,6 +1645,18 @@ function encStyle(feature, resolution) {
}
}
// Safety net: if any draw function threw, canvas may be null. Fall back to
// a small colored circle so the feature is still visible and clickable.
if (!canvas) {
const fc = document.createElement('canvas'); fc.width = fc.height = 16;
const fx = fc.getContext('2d');
fx.beginPath(); fx.arc(8, 8, 7, 0, Math.PI * 2);
fx.fillStyle = colours[0] ? _s57css(colours[0]) : '#607d8b'; fx.fill();
fx.strokeStyle = '#fff'; fx.lineWidth = 1.5; fx.stroke();
canvas = fc;
console.warn('[ENC] canvas fallback for', layer, aidType, colours);
}
// Compute zoom from resolution (deterministic, no map object access needed).
// OL WebMercator: resolution ≈ 156543 / 2^zoom → zoom = log2(156543/res)
const _zr = resolution ? Math.log2(156543.03392 / Math.max(resolution, 0.001)) : 14;
@@ -2005,46 +2017,69 @@ window.loadChartFeatures = async function() {
const fc = await r.json();
if (!fc.features?.length) return;
// Merge LIGHTS into co-located structures. NOAA codifies a buoy and its
// light as two features at the same coords. The structure often lacks
// COLOUR / CATLAM while the light carries it — we backfill the structure
// with the light's colour, then drop the (now redundant) LIGHT_POINT.
const structByKey = new Map(); // coordKey → structure feature
// ── Merge LIGHTS into nearby structure features ────────────────────────
// S-57 encodes each buoy/beacon and its light as TWO separate objects.
// They may be at exactly the same coordinates (NOAA pattern) or up to
// ~50 m apart (≈0.00045°). We try exact match first, then proximity.
// After merging: light attrs copied into host, LIGHT_POINT dropped.
const MERGE_DEG = 0.00045; // ~50 m — matches backend proximity radius
const structList = []; // [{ f, x, y }] — all structure features
for (const f of fc.features) {
const lyr = (f.properties?.layer || '').toUpperCase();
if (lyr.startsWith('BOY') || lyr.startsWith('BCN') || lyr === 'LNDMRK') {
const c = f.geometry.coordinates;
structByKey.set(`${c[0].toFixed(6)}_${c[1].toFixed(6)}`, f);
const [x, y] = f.geometry.coordinates;
structList.push({ f, x, y });
}
}
// Fast exact-coord lookup (grid key rounded to 6 dp ≈ 0.11 m)
const structByKey = new Map();
for (const s of structList) {
structByKey.set(`${s.x.toFixed(6)}_${s.y.toFixed(6)}`, s.f);
}
const before = fc.features.length;
fc.features = fc.features.filter(f => {
if (f.properties?.aid_type !== 'LIGHT_POINT') return true;
const c = f.geometry.coordinates;
const host = structByKey.get(`${c[0].toFixed(6)}_${c[1].toFixed(6)}`);
if (!host) return true; // standalone light — keep
// Backfill host colour from light if missing
const [lx, ly] = f.geometry.coordinates;
// 1) Try exact coordinate match
let host = structByKey.get(`${lx.toFixed(6)}_${ly.toFixed(6)}`);
// 2) Proximity fallback (~50 m radius)
if (!host) {
let bestDist = MERGE_DEG;
for (const s of structList) {
const d = Math.sqrt((lx - s.x) ** 2 + (ly - s.y) ** 2);
if (d < bestDist) { bestDist = d; host = s.f; }
}
}
if (!host) return true; // standalone light with no nearby structure — keep
const hp = host.properties;
const lp = f.properties;
// Backfill colour from light if host has none
if (!hp.colours?.length && lp.colours?.length) {
hp.colours = lp.colours.slice();
// Re-classify host now that we have colour info → infer CATLAM
// Infer CATLAM from colour when missing
const region = hp.cell_region || 'B';
const ialaB = region === 'B';
const c0 = lp.colours[0];
if (!hp.catlam) {
if (c0 === 4) hp.catlam = ialaB ? 1 : 2; // green → port in B, stbd in A
else if (c0 === 3) hp.catlam = ialaB ? 2 : 1; // red → stbd in B, port in A
if (c0 === 4) hp.catlam = ialaB ? 1 : 2;
else if (c0 === 3) hp.catlam = ialaB ? 2 : 1;
}
// Update aid_type to reflect inferred lateral significance
if (hp.catlam === 1) hp.aid_type = 'LATERAL_PORT';
else if (hp.catlam === 2) hp.aid_type = 'LATERAL_STBD';
}
// Carry light attributes (character, range, height) into the host for the popup
// Copy light character, range, height → these appear in the tooltip
if (lp.light_desc && !hp.light_desc) hp.light_desc = lp.light_desc;
if (lp.range_nm && !hp.range_nm) hp.range_nm = lp.range_nm;
if (lp.height_m && !hp.height_m) hp.height_m = lp.height_m;
return false; // drop the light, host now represents it
// Copy orient so the buoy/beacon can anchor its leading line correctly
if (lp.orient != null && hp.orient == null) hp.orient = lp.orient;
return false; // drop the now-redundant LIGHT_POINT
});
const dropped = before - fc.features.length;