""" HydrostaticsChartWidget — visor de curvas hidrostáticas (QPainter). Distribuye 9 paneles en cuadrícula 3×3. Cada panel muestra una o varias magnitudes hidrostáticas en función del calado T. Un cursor vertical compartido indica el calado activo — clic o arrastre en cualquier panel actualiza el cursor en todos y muestra los valores interpolados. Sin dependencias externas: sólo PySide6 + numpy. Autor: Álvaro Romero Módulo 2 — AR-ShipDesign """ from __future__ import annotations import math from typing import List, Optional, Tuple import numpy as np from PySide6.QtCore import Qt, QPointF, QRectF, Signal from PySide6.QtGui import ( QColor, QFont, QFontMetricsF, QPainter, QPainterPath, QPen, ) from PySide6.QtWidgets import ( QFrame, QGridLayout, QHBoxLayout, QLabel, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, ) try: from arshipdesign.hydrostatics.curves_of_form import HydrostaticCurves except ImportError: HydrostaticCurves = None # type: ignore[assignment,misc] # ── Paleta (misma base que viewer_lines) ────────────────────────────────── _BG = QColor("#1a1d30") _PANEL_BG = QColor("#1e2240") _GRID_C = QColor("#253060") _AXIS_C = QColor("#3a4870") _LABEL_C = QColor("#7a8aaa") _TITLE_C = QColor("#c8d8f0") _CURSOR_C = QColor("#ffd700") _BORDER_C = QColor("#2e3870") _CURVE_COLORS = [ QColor("#4da8ff"), # azul eléctrico QColor("#ffd700"), # dorado QColor("#6ee7b7"), # verde agua QColor("#ff6b6b"), # coral QColor("#a78bfa"), # lavanda ] # ── Fuentes ─────────────────────────────────────────────────────────────── _F_TITLE = QFont("Segoe UI", 8, QFont.Weight.Bold) _F_LABEL = QFont("Consolas", 7) _F_VALUE = QFont("Consolas", 8, QFont.Weight.Bold) # ── Márgenes por panel (píxeles) ───────────────────────────────────────── _ML = 56 # izquierda — etiquetas eje Y _MR = 10 # derecha _MT = 24 # arriba — título del panel _MB = 26 # abajo — etiquetas eje X (calado) # ── Definición de los 9 paneles ─────────────────────────────────────────── # (título, [(attr_HydrostaticCurves, etiqueta_leyenda)]) _PANEL_DEFS: List[Tuple[str, List[Tuple[str, str]]]] = [ ("Desplazamiento Δ [t]", [("displacements", "Δ")]), ("Volumen de Carena V [m³]", [("volumes", "V")]), ("Área de Flotación Awp [m²]", [("awp_values", "Awp")]), ("Centros Longitudinales [m]", [("lcb_values", "LCB"), ("lcf_values", "LCF")]), ("Centros Verticales [m]", [("kb_values", "KB"), ("bmt_values", "BMT")]), ("Metacentro KMT / KML [m]", [("kmt_values", "KMT"), ("kml_values", "KML")]), ("TPC [t/cm]", [("tpc_values", "TPC")]), ("MCT 1cm [t·m/cm]", [("mct_values", "MCT")]), ("Coeficientes de Forma", [("cb_values", "Cb"), ("cw_values", "Cw"), ("cm_values", "Cm")]), ] # ── Utilidad de ticks ───────────────────────────────────────────────────── def _nice_ticks(vmin: float, vmax: float, n: int = 4) -> List[float]: """Valores 'redondos' para los ejes (algoritmo estándar).""" if not (math.isfinite(vmin) and math.isfinite(vmax)) or vmax - vmin < 1e-12: return [vmin] raw = (vmax - vmin) / n mag = 10 ** math.floor(math.log10(max(raw, 1e-15))) r = raw / mag step = (1 if r < 1.5 else 2 if r < 3 else 5 if r < 7 else 10) * mag start = math.ceil(vmin / step) * step ticks: List[float] = [] t = start while t <= vmax + step * 0.01: ticks.append(round(t, 12)) t += step return ticks # ── Panel individual ────────────────────────────────────────────────────── class _CurvePanel(QWidget): """Panel QPainter de una o varias curvas hidrostáticas vs calado T.""" draft_picked = Signal(float) def __init__( self, title: str, curve_defs: List[Tuple[str, str]], parent: Optional[QWidget] = None, ) -> None: super().__init__(parent) self._title = title self._cdefs = curve_defs self._drafts: Optional[np.ndarray] = None self._ys: List[Optional[np.ndarray]] = [] self._active_T: Optional[float] = None self.setMinimumSize(180, 160) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.setMouseTracking(True) # ── API ────────────────────────────────────────────────────────────── def set_data( self, drafts: np.ndarray, ys_list: List[Optional[np.ndarray]], active_T: Optional[float] = None, ) -> None: self._drafts = drafts self._ys = ys_list self._active_T = active_T self.update() def set_active_draft(self, T: float) -> None: self._active_T = T self.update() def clear(self) -> None: self._drafts = None self._ys = [] self._active_T = None self.update() # ── Ratón ──────────────────────────────────────────────────────────── def mousePressEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton: T = self._px_to_T(event.position().x()) if T is not None: self.draft_picked.emit(T) def mouseMoveEvent(self, event) -> None: if event.buttons() & Qt.MouseButton.LeftButton: T = self._px_to_T(event.position().x()) if T is not None: self.draft_picked.emit(T) def _px_to_T(self, px: float) -> Optional[float]: if self._drafts is None or len(self._drafts) < 2: return None x0, x1 = float(_ML), float(self.width() - _MR) if x1 <= x0: return None frac = max(0.0, min(1.0, (px - x0) / (x1 - x0))) T_min, T_max = float(self._drafts[0]), float(self._drafts[-1]) return T_min + frac * (T_max - T_min) # ── Pintura ────────────────────────────────────────────────────────── def paintEvent(self, event) -> None: p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) W, H = self.width(), self.height() p.fillRect(0, 0, W, H, _PANEL_BG) p.setPen(QPen(_BORDER_C, 1)) p.drawRect(0, 0, W - 1, H - 1) # Título p.setFont(_F_TITLE) p.setPen(_TITLE_C) p.drawText( QRectF(_ML, 3, W - _ML - _MR, _MT - 4), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._title, ) if self._drafts is None or len(self._drafts) < 2 or not self._ys: self._no_data(p, W, H) p.end() return x0, x1 = float(_ML), float(W - _MR) y0, y1 = float(_MT), float(H - _MB) pw, ph = x1 - x0, y1 - y0 if pw < 20 or ph < 20: p.end() return T_min = float(self._drafts[0]) T_max = float(self._drafts[-1]) # Rango Y global y_all: List[float] = [] for ys in self._ys: if ys is not None: fin = ys[np.isfinite(ys)] if len(fin): y_all.extend(fin.tolist()) if not y_all: self._no_data(p, W, H) p.end() return Y_min, Y_max = float(np.min(y_all)), float(np.max(y_all)) if Y_max - Y_min < 1e-9: Y_min -= 0.5 Y_max += 0.5 pad = (Y_max - Y_min) * 0.07 Y_min -= pad Y_max += pad def wx(T: float) -> float: return x0 + (T - T_min) / (T_max - T_min) * pw def wy(y: float) -> float: return y1 - (y - Y_min) / (Y_max - Y_min) * ph self._draw_axes(p, x0, x1, y0, y1, T_min, T_max, Y_min, Y_max, wx, wy) for k, (_, leg) in enumerate(self._cdefs): if k >= len(self._ys) or self._ys[k] is None: continue col = _CURVE_COLORS[k % len(_CURVE_COLORS)] self._draw_curve(p, self._drafts, self._ys[k], wx, wy, col) self._draw_legend(p, k, leg, col, x1, y1) if self._active_T is not None: T_c = float(np.clip(self._active_T, T_min, T_max)) self._draw_cursor(p, T_c, x0, x1, y0, y1, wx, wy) p.end() def _no_data(self, p: QPainter, W: int, H: int) -> None: p.setFont(_F_LABEL) p.setPen(_LABEL_C) p.drawText(QRectF(0, _MT, W, H - _MT), Qt.AlignmentFlag.AlignCenter, "Sin datos") def _draw_axes( self, p: QPainter, x0: float, x1: float, y0: float, y1: float, T_min: float, T_max: float, Y_min: float, Y_max: float, wx, wy, ) -> None: fm = QFontMetricsF(_F_LABEL) p.setFont(_F_LABEL) # Grid + etiquetas Y for yv in _nice_ticks(Y_min, Y_max, 4): yp = wy(yv) if not (y0 - 1 <= yp <= y1 + 1): continue p.setPen(QPen(_GRID_C, 1)) p.drawLine(QPointF(x0, yp), QPointF(x1, yp)) lbl = f"{yv:.3g}" lw = fm.horizontalAdvance(lbl) p.setPen(_LABEL_C) p.drawText(QPointF(x0 - lw - 3, yp + fm.ascent() * 0.4), lbl) # Grid + etiquetas X (calado) for Tv in _nice_ticks(T_min, T_max, 4): xp = wx(Tv) if not (x0 - 1 <= xp <= x1 + 1): continue p.setPen(QPen(_GRID_C, 1)) p.drawLine(QPointF(xp, y0), QPointF(xp, y1)) lbl = f"{Tv:.2f}" lw = fm.horizontalAdvance(lbl) p.setPen(_LABEL_C) p.drawText(QPointF(xp - lw / 2, y1 + fm.ascent() + 3), lbl) # Ejes sólidos p.setPen(QPen(_AXIS_C, 1)) p.drawLine(QPointF(x0, y0), QPointF(x0, y1)) p.drawLine(QPointF(x0, y1), QPointF(x1, y1)) def _draw_curve( self, p: QPainter, drafts: np.ndarray, ys: np.ndarray, wx, wy, color: QColor, ) -> None: path = QPainterPath() started = False for T, y in zip(drafts, ys): Tf, yf = float(T), float(y) if not (math.isfinite(Tf) and math.isfinite(yf)): started = False continue xp, yp = wx(Tf), wy(yf) if not started: path.moveTo(xp, yp) started = True else: path.lineTo(xp, yp) pen = QPen(color, 1.8, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin) p.setPen(pen) p.setBrush(Qt.BrushStyle.NoBrush) p.drawPath(path) def _draw_legend( self, p: QPainter, idx: int, label: str, color: QColor, x1: float, y1: float, ) -> None: p.setFont(_F_LABEL) fm = QFontMetricsF(_F_LABEL) line_w = 14 lw = fm.horizontalAdvance(label) total = line_w + 4 + lw x = x1 - total - 4 y = y1 - 6 - idx * (int(fm.height()) + 2) if y < float(_MT) + 4: return pen = QPen(color, 2) p.setPen(pen) p.drawLine(QPointF(x, y), QPointF(x + line_w, y)) p.drawText(QPointF(x + line_w + 4, y + fm.ascent() * 0.4), label) def _draw_cursor( self, p: QPainter, T_c: float, x0: float, x1: float, y0: float, y1: float, wx, wy, ) -> None: xc = wx(T_c) p.setPen(QPen(_CURSOR_C, 1.0, Qt.PenStyle.DashLine)) p.drawLine(QPointF(xc, y0), QPointF(xc, y1)) fm = QFontMetricsF(_F_VALUE) p.setFont(_F_VALUE) for k, (_, leg) in enumerate(self._cdefs): if k >= len(self._ys) or self._ys[k] is None: continue ys = self._ys[k] if self._drafts is None: continue yv = float(np.interp(T_c, self._drafts, ys)) yp = wy(yv) col = _CURVE_COLORS[k % len(_CURVE_COLORS)] # Dot at intersection p.setPen(Qt.PenStyle.NoPen) p.setBrush(col) p.drawEllipse(QPointF(xc, yp), 3.5, 3.5) p.setBrush(Qt.BrushStyle.NoBrush) # Value label (right or left of cursor to avoid clipping) txt = f"{yv:.4g}" tw = fm.horizontalAdvance(txt) tx = xc + 5 if xc + 5 + tw < x1 else xc - tw - 5 p.setPen(_CURSOR_C) p.drawText(QPointF(tx, yp - 4), txt) # ── Barra de información ────────────────────────────────────────────────── class _InfoBar(QFrame): """Franja superior: nombre del casco + valores al calado activo.""" def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.setObjectName("hydroInfoBar") self.setFixedHeight(30) lo = QHBoxLayout(self) lo.setContentsMargins(8, 0, 8, 0) lo.setSpacing(0) self._name_lbl = QLabel("Sin datos") self._name_lbl.setObjectName("hydroInfoName") lo.addWidget(self._name_lbl) sep = self._sep() lo.addWidget(sep) mono = QFont("Consolas", 9) self._fields: dict[str, QLabel] = {} _items = [ ("T", "Calado activo [m]"), ("Δ", "Desplazamiento [t]"), ("LCB", "LCB [m desde AP]"), ("KB", "KB [m]"), ("KMT", "KMT [m]"), ("TPC", "TPC [t/cm]"), ("MCT", "MCT [t·m/cm]"), ("Cb", "Cb"), ("Cw", "Cw"), ("Cm", "Cm"), ] for key, tip in _items: k = QLabel(f" {key} ") k.setObjectName("hydroInfoKey") k.setToolTip(tip) v = QLabel("—") v.setObjectName("hydroInfoVal") v.setFont(mono) v.setMinimumWidth(46) v.setToolTip(tip) self._fields[key] = v lo.addWidget(k) lo.addWidget(v) lo.addWidget(self._sep()) lo.addStretch() @staticmethod def _sep() -> QFrame: f = QFrame() f.setFrameShape(QFrame.Shape.VLine) f.setObjectName("hydroInfoSep") f.setFixedWidth(1) return f def set_hull_name(self, name: str) -> None: self._name_lbl.setText(name) def update_hydrostatics(self, h) -> None: """h: UprightHydrostatics o None.""" if h is None: for v in self._fields.values(): v.setText("—") return mapping = { "T": f"{h.draft:.3f}", "Δ": f"{h.displacement:.1f} t", "LCB": f"{h.lcb:.3f}", "KB": f"{h.kb:.3f}", "KMT": f"{h.kmt:.3f}", "TPC": f"{h.tpc:.4f}", "MCT": f"{h.mct:.4f}", "Cb": f"{h.cb:.4f}", "Cw": f"{h.cw:.4f}", "Cm": f"{h.cm:.4f}", } for key, val in mapping.items(): if key in self._fields: self._fields[key].setText(val) # ── Widget principal ────────────────────────────────────────────────────── class HydrostaticsChartWidget(QWidget): """ Visor de curvas hidrostáticas con 9 paneles en cuadrícula 3×3. Uso: ---- >>> w = HydrostaticsChartWidget() >>> curves = HydrostaticCurves.compute(hull, n_points=30) >>> w.set_curves(curves) >>> w.set_active_draft(hull.draft) # opcional: posición inicial del cursor """ draft_changed = Signal(float) # se emite cuando el cursor se mueve def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.setObjectName("hydrostaticsChart") self._curves: Optional["HydrostaticCurves"] = None self._active_T: Optional[float] = None self._panels: List[_CurvePanel] = [] self._build_ui() # ── Construcción UI ────────────────────────────────────────────────── def _build_ui(self) -> None: lo = QVBoxLayout(self) lo.setContentsMargins(0, 0, 0, 0) lo.setSpacing(0) # Barra informativa superior self._info_bar = _InfoBar() lo.addWidget(self._info_bar) sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) sep.setObjectName("hydroChartSep") lo.addWidget(sep) # Área con scroll para la cuadrícula de paneles scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setObjectName("hydroChartScroll") scroll.setFrameShape(QFrame.Shape.NoFrame) container = QWidget() container.setObjectName("hydroChartContainer") grid = QGridLayout(container) grid.setContentsMargins(4, 4, 4, 4) grid.setSpacing(3) for col in range(3): grid.setColumnStretch(col, 1) for row in range(3): grid.setRowStretch(row, 1) self._panels = [] for i, (title, cdefs) in enumerate(_PANEL_DEFS): row, col = divmod(i, 3) panel = _CurvePanel(title, cdefs) panel.draft_picked.connect(self.set_active_draft) grid.addWidget(panel, row, col) self._panels.append(panel) scroll.setWidget(container) lo.addWidget(scroll, 1) # Mensaje placeholder (oculto cuando hay datos) self._placeholder = QLabel( "Sin curvas hidrostáticas.\n" "Crear un proyecto y ejecutar Análisis → Hidrostática → Calcular." ) self._placeholder.setObjectName("hydroPlaceholder") self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self._placeholder.setWordWrap(True) lo.addWidget(self._placeholder) self._toggle_placeholder(True) def _toggle_placeholder(self, show: bool) -> None: self._placeholder.setVisible(show) # El scroll ya está en el layout, sólo cambiamos su visibilidad scroll = self.findChild(QScrollArea, "hydroChartScroll") if scroll is not None: scroll.setVisible(not show) # ── API pública ────────────────────────────────────────────────────── def set_curves(self, curves: "HydrostaticCurves") -> None: """Carga un objeto HydrostaticCurves y repinta todos los paneles.""" self._curves = curves if curves is None: for panel in self._panels: panel.clear() self._info_bar.set_hull_name("Sin datos") self._info_bar.update_hydrostatics(None) self._toggle_placeholder(True) return # Cursor inicial al calado de diseño self._active_T = float(curves.design_draft) self._info_bar.set_hull_name(curves.hull_name) self._populate_panels() self._update_info_bar() self._toggle_placeholder(False) def set_active_draft(self, T: float) -> None: """Mueve el cursor de calado en todos los paneles y actualiza la barra.""" if self._curves is None: return T_min = float(self._curves.drafts[0]) T_max = float(self._curves.drafts[-1]) self._active_T = max(T_min, min(T_max, float(T))) for panel in self._panels: panel.set_active_draft(self._active_T) self._update_info_bar() self.draft_changed.emit(self._active_T) @property def curves(self) -> Optional["HydrostaticCurves"]: return self._curves @property def active_draft(self) -> Optional[float]: return self._active_T # ── Helpers internos ───────────────────────────────────────────────── def _populate_panels(self) -> None: if self._curves is None: return drafts = self._curves.drafts for i, (_, cdefs) in enumerate(_PANEL_DEFS): ys_list: List[Optional[np.ndarray]] = [] for attr, _ in cdefs: ys_list.append(getattr(self._curves, attr, None)) self._panels[i].set_data(drafts, ys_list, self._active_T) def _update_info_bar(self) -> None: if self._curves is None or self._active_T is None: self._info_bar.update_hydrostatics(None) return try: h = self._curves.at_draft(self._active_T) self._info_bar.update_hydrostatics(h) except Exception: self._info_bar.update_hydrostatics(None)