diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e70889..7e955a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - ✨ More kinds of chart zones + CSS for SVG styling - 2023-06-12 + +Define new enclosed areas in chart between constant RH lines and constant volume or enthalpy values, +and enable full potencial for SVG customization by including CSS and/or `` inside it. + +##### Changes + +- ✨ Add new kind of overlay **zones 'enthalpy-rh' and 'volume-rh'**, to define chart areas delimited between 2 constant enthalpy or volume lines, and 2 constant humidity lines +- 🎨 Add `chart.remove_zones()` method, like the existent `.remove_legend()` and `.remove_annotations()`, to clear matplotlib current Axes from each kind of annotated objects +- 🐛 Fix artists registry not mirroring objects in plot, by setting a new one each time `chart.plot()` is invoked +- ♻️ Set gid for 'chart_legend_background' item if frameon is enabled +- ✨ Pass **optional CSS and SVG ** to `chart.make_svg()`, to include extra-styling inside the produced SVG 🌈 +- ✨ Add method `chart.plot_over_saturated_zone()` to create a filled patch in the over-saturated zone of the psychrochart, to colorize that area +- 🦄 tests: Add dynamic effects and CSS rules to splash chart for a show-off of SVG styling capabilities +- 🚀 env: Bump version and update CHANGELOG + ## [0.8.0] - ✨ Lazy generation of chart data, 'Artist' registry, and better SVG export - 2023-06-11 Now all chart-data parameters, including the overlayed zones, are included in the `ChartConfig`, diff --git a/psychrochart/__init__.py b/psychrochart/__init__.py index 4a3b76b..3cd2ff3 100644 --- a/psychrochart/__init__.py +++ b/psychrochart/__init__.py @@ -1,7 +1,7 @@ """A library to make psychrometric charts and overlay information in them.""" from psychrochart.chart import PsychroChart from psychrochart.models.annots import ChartAnnots -from psychrochart.models.config import ChartConfig, ChartZones +from psychrochart.models.config import ChartConfig, ChartZone, ChartZones from psychrochart.models.curves import PsychroCurve, PsychroCurves from psychrochart.models.parsers import load_config, load_zones from psychrochart.models.styles import ( @@ -15,6 +15,7 @@ "ChartAnnots", "ChartConfig", "ChartZones", + "ChartZone", "load_config", "load_zones", "PsychroChart", diff --git a/psychrochart/chart.py b/psychrochart/chart.py index b07679c..3b0240d 100644 --- a/psychrochart/chart.py +++ b/psychrochart/chart.py @@ -20,9 +20,10 @@ make_constant_dry_bulb_v_line, make_saturation_line, ) +from psychrochart.chartzones import make_over_saturated_zone from psychrochart.models.annots import ChartAnnots from psychrochart.models.config import ChartConfig, ChartZones -from psychrochart.models.curves import PsychroChartModel +from psychrochart.models.curves import PsychroChartModel, PsychroCurve from psychrochart.models.parsers import ( ConvexGroupTuple, load_extra_annots, @@ -41,7 +42,7 @@ get_pressure_pa, update_psychrochart_data, ) -from psychrochart.util import mod_color +from psychrochart.util import add_styling_to_svg, mod_color def _select_fig_canvas( @@ -308,6 +309,26 @@ def plot_vertical_dry_bulb_temp_line( self._artists.annotations, ) + def plot_over_saturated_zone( + self, color_fill: str | list[float] = "#0C92F6FF" + ) -> PsychroCurve | None: + """Add a colored zone in chart to fill the over-saturated space.""" + # ensure chart is plotted + current_ax = self.axes + if ( + curve_sat_zone := make_over_saturated_zone( + self.saturation, + dbt_min=self.config.dbt_min, + dbt_max=self.config.dbt_max, + w_min=self.config.w_min, + w_max=self.config.w_max, + color_fill=color_fill, + ) + ) is not None: + plot_curve(curve_sat_zone, current_ax) + self._artists.zones.update() + return curve_sat_zone + def plot_legend( self, loc: str = "upper left", @@ -320,24 +341,25 @@ def plot_legend( **params, ) -> None: """Append a legend to the psychrochart plot.""" - reg_artist( - "chart_legend", - self.axes.legend( - loc=loc, - markerscale=markerscale, - frameon=frameon, - edgecolor=edgecolor, - fontsize=fontsize, - fancybox=fancybox, - labelspacing=labelspacing, - **params, - ), - self._artists.layout, + legend = self.axes.legend( + loc=loc, + markerscale=markerscale, + frameon=frameon, + edgecolor=edgecolor, + fontsize=fontsize, + fancybox=fancybox, + labelspacing=labelspacing, + **params, ) + if frameon: + legend.get_frame().set_gid("chart_legend_background") + reg_artist("chart_legend", legend, self._artists.layout) def plot(self, ax: Axes | None = None) -> Axes: """Plot the psychrochart and return the matplotlib Axes instance.""" self.process_chart() + # instantiate a new artist registry for the new plot + self._artists = ChartRegistry() if ax is not None: self._fig = ax.get_figure() else: @@ -357,6 +379,12 @@ def plot(self, ax: Axes | None = None) -> Axes: plot_chart(self, self._axes, self._artists) return self._axes + def remove_zones(self) -> None: + """Remove the zones in the chart to reuse it.""" + for patch in self._artists.zones.values(): + patch.remove() + self._artists.zones = {} + def remove_annotations(self) -> None: """Remove the annotations made in the chart to reuse it.""" for line in self._artists.annotations.values(): @@ -388,12 +416,17 @@ def save( canvas_use(self._fig).print_figure(path_dest, **params) gc.collect() - def make_svg(self, **params) -> str: + def make_svg( + self, + css_styles: str | Path | None = None, + svg_definitions: str | None = None, + **params, + ) -> str: """Generate chart as SVG and return as text.""" svg_io = StringIO() self.save(svg_io, canvas_cls=FigureCanvasSVG, **params) svg_io.seek(0) - return svg_io.read() + return add_styling_to_svg(svg_io.read(), css_styles, svg_definitions) def close_fig(self) -> None: """Close the figure plot.""" diff --git a/psychrochart/chartzones.py b/psychrochart/chartzones.py index a56ca3e..bb6069b 100644 --- a/psychrochart/chartzones.py +++ b/psychrochart/chartzones.py @@ -1,13 +1,254 @@ +import logging +from typing import Callable + import numpy as np +from psychrolib import ( + GetHumRatioFromVapPres, + GetMoistAirVolume, + GetSatAirEnthalpy, + GetSatVapPres, +) from psychrochart.chart_entities import random_internal_value -from psychrochart.chartdata import gen_points_in_constant_relative_humidity +from psychrochart.chartdata import ( + _factor_out_h, + _factor_out_w, + f_vec_moist_air_enthalpy, + f_vec_moist_air_volume, + gen_points_in_constant_relative_humidity, + make_constant_enthalpy_lines, + make_constant_relative_humidity_lines, + make_constant_specific_volume_lines, + make_saturation_line, +) from psychrochart.models.config import ChartZone -from psychrochart.models.curves import PsychroCurve +from psychrochart.models.curves import PsychroCurve, PsychroCurves +from psychrochart.models.styles import CurveStyle, ZoneStyle + + +def _adjust_temp_range_for_enthalpy( + h_range: tuple[float, float], + dbt_range: tuple[float, float], + pressure: float, + step_temp: float, +) -> float: + assert h_range[1] > h_range[0] + assert dbt_range[1] > dbt_range[0] + dbt_min = dbt_range[0] + while GetSatAirEnthalpy(dbt_min, pressure) * _factor_out_h() > h_range[0]: + dbt_min -= 2 * step_temp + return dbt_min + + +def _adjust_temp_range_for_volume( + v_range: tuple[float, float], + dbt_range: tuple[float, float], + pressure: float, + step_temp: float, +) -> float: + assert v_range[1] > v_range[0] + assert dbt_range[1] > dbt_range[0] + dbt_min = dbt_range[0] + while ( + GetMoistAirVolume( + dbt_min, + _factor_out_w() + * GetHumRatioFromVapPres(GetSatVapPres(dbt_min), pressure), + pressure, + ) + > v_range[0] + ): + dbt_min -= 2 * step_temp + return dbt_min + + +def _crossing_point_between_rect_lines( + segment_1_x: tuple[float, float], + segment_1_y: tuple[float, float], + segment_2_x: tuple[float, float], + segment_2_y: tuple[float, float], +): + s1 = (segment_1_y[1] - segment_1_y[0]) / (segment_1_x[1] - segment_1_x[0]) + b1 = segment_1_y[0] - s1 * segment_1_x[0] + + s2 = (segment_2_y[1] - segment_2_y[0]) / (segment_2_x[1] - segment_2_x[0]) + b2 = segment_2_y[0] - s2 * segment_2_x[0] + + mat_a = np.array([[s1, -1], [s2, -1]]) + v_b = np.array([-b1, -b2]) + return np.linalg.solve(mat_a, v_b) + + +def _cross_rh_curve_with_rect_line( + rh_curve: PsychroCurve, + rect_curve: PsychroCurve, + target_value: float, + func_dbt_w_to_target: Callable[[np.ndarray, np.ndarray], np.ndarray], +) -> tuple[float, float]: + assert len(rect_curve.x_data) == 2 + assert len(rect_curve.y_data) == 2 + + target_in_rh = func_dbt_w_to_target( + rh_curve.x_data, rh_curve.y_data / _factor_out_w() + ) + # dbt_base should include crossing place + assert target_in_rh[0] < target_value + idx_end = (target_in_rh > target_value).argmax() + if target_in_rh[-1] < target_value: + # extrapolate last segment + idx_end = -1 + t_start, t_end = rh_curve.x_data[idx_end - 1], rh_curve.x_data[idx_end] + w_start, w_end = rh_curve.y_data[idx_end - 1], rh_curve.y_data[idx_end] + return _crossing_point_between_rect_lines( + segment_1_x=(rect_curve.x_data[0], rect_curve.x_data[1]), + segment_1_y=(rect_curve.y_data[0], rect_curve.y_data[1]), + segment_2_x=(t_start, t_end), + segment_2_y=(w_start, w_end), + ) + + +def _valid_zone_delimiter_on_plot_limits( + zone: ChartZone, + rect_lines: PsychroCurves | None, + dbt_min: float, + dbt_max: float, + w_min: float, + w_max: float, +) -> bool: + if ( + rect_lines is None + or len(rect_lines.curves) != 2 + or all( + curve.outside_limits(dbt_min, dbt_max, w_min, w_max) + for curve in rect_lines.curves + ) + ): + logging.info("Zone[%s,%s] outside limits", zone.zone_type, zone.label) + return False + return True + + +def _zone_between_rh_and_rects( + zone: ChartZone, + rect_lines: PsychroCurves | None, + rh_lines: PsychroCurves, + func_dbt_w_to_target: Callable[[np.ndarray, np.ndarray], np.ndarray], +) -> PsychroCurve: + assert rect_lines is not None + target_min = rect_lines.curves[0].internal_value + target_max = rect_lines.curves[1].internal_value + assert target_min is not None and target_max is not None + dbt_bottom_left, w_bottom_left = _cross_rh_curve_with_rect_line( + rh_curve=rh_lines.curves[0], + rect_curve=rect_lines.curves[0], + target_value=target_min, + func_dbt_w_to_target=func_dbt_w_to_target, + ) + dbt_bottom_right, w_bottom_right = _cross_rh_curve_with_rect_line( + rh_curve=rh_lines.curves[0], + rect_curve=rect_lines.curves[1], + target_value=target_max, + func_dbt_w_to_target=func_dbt_w_to_target, + ) + dbt_top_left, w_top_left = _cross_rh_curve_with_rect_line( + rh_curve=rh_lines.curves[1], + rect_curve=rect_lines.curves[0], + target_value=target_min, + func_dbt_w_to_target=func_dbt_w_to_target, + ) + dbt_top_right, w_top_right = _cross_rh_curve_with_rect_line( + rh_curve=rh_lines.curves[1], + rect_curve=rect_lines.curves[1], + target_value=target_max, + func_dbt_w_to_target=func_dbt_w_to_target, + ) + + mask_rh_up = (rh_lines.curves[1].y_data < w_top_right) & ( + rh_lines.curves[1].y_data > w_top_left + ) + mask_rh_down = (rh_lines.curves[0].y_data < w_bottom_right) & ( + rh_lines.curves[0].y_data > w_bottom_left + ) + dbt_points = [ + dbt_top_left, + *rh_lines.curves[1].x_data[mask_rh_up], + dbt_top_right, + dbt_bottom_right, + *rh_lines.curves[0].x_data[mask_rh_down][::-1], + dbt_bottom_left, + dbt_top_left, + ] + w_points = [ + w_top_left, + *rh_lines.curves[1].y_data[mask_rh_up], + w_top_right, + w_bottom_right, + *rh_lines.curves[0].y_data[mask_rh_down][::-1], + w_bottom_left, + w_top_left, + ] + return PsychroCurve( + x_data=np.array(dbt_points), + y_data=np.array(w_points), + style=zone.style, + type_curve=zone.zone_type, + label=zone.label, + internal_value=random_internal_value() if zone.label is None else None, + ) + + +def _make_zone_delimited_by_enthalpy_and_rh( + zone: ChartZone, + pressure: float, + *, + step_temp: float, + dbt_min: float, + dbt_max: float, + w_min: float, + w_max: float, +) -> PsychroCurve | None: + assert zone.zone_type == "enthalpy-rh" + h_min, h_max = zone.points_x + rh_min, rh_max = zone.points_y + dbt_min_use = _adjust_temp_range_for_enthalpy( + (h_min, h_max), (dbt_min, dbt_max), pressure, step_temp + ) + h_lines = make_constant_enthalpy_lines( + w_min, + pressure, + np.array([h_min, h_max]), + saturation_curve=make_saturation_line( + dbt_min_use, dbt_max, step_temp, pressure + ), + style=CurveStyle(), + ) + if not _valid_zone_delimiter_on_plot_limits( + zone, h_lines, dbt_min, dbt_max, w_min, w_max + ): + return None + + rh_lines = make_constant_relative_humidity_lines( + dbt_min_use, + dbt_max, + step_temp, + pressure, + [int(rh_min), int(rh_max)], + style=CurveStyle(), + ) + + def _points_to_enthalpy(dbt_values, w_values): + return f_vec_moist_air_enthalpy(dbt_values, w_values) / _factor_out_h() + + return _zone_between_rh_and_rects( + zone, + rect_lines=h_lines, + rh_lines=rh_lines, + func_dbt_w_to_target=_points_to_enthalpy, + ) def _make_zone_delimited_by_vertical_dbt_and_rh( - zone: ChartZone, pressure: float, *, step_temp: float = 1.0 + zone: ChartZone, pressure: float, *, step_temp: float ) -> PsychroCurve: assert zone.zone_type == "dbt-rh" # points for zone between constant dry bulb temps and RH @@ -39,14 +280,65 @@ def _make_zone_delimited_by_vertical_dbt_and_rh( ) +def _make_zone_delimited_by_volume_and_rh( + zone: ChartZone, + pressure: float, + *, + step_temp: float, + dbt_min: float, + dbt_max: float, + w_min: float, + w_max: float, +) -> PsychroCurve | None: + assert zone.zone_type == "volume-rh" + v_min, v_max = zone.points_x + rh_min, rh_max = zone.points_y + dbt_min_use = _adjust_temp_range_for_volume( + (v_min, v_max), (dbt_min, dbt_max), pressure, step_temp + ) + v_lines = make_constant_specific_volume_lines( + w_min, + pressure, + np.array([v_min, v_max]), + saturation_curve=make_saturation_line( + dbt_min_use, dbt_max, step_temp, pressure + ), + style=CurveStyle(), + ) + if not _valid_zone_delimiter_on_plot_limits( + zone, v_lines, dbt_min, dbt_max, w_min, w_max + ): + return None + + rh_lines = make_constant_relative_humidity_lines( + dbt_min_use, + dbt_max, + step_temp, + pressure, + [int(rh_min), int(rh_max)], + style=CurveStyle(), + ) + + def _points_to_volume(dbt_values, w_values): + return f_vec_moist_air_volume(dbt_values, w_values, pressure) + + return _zone_between_rh_and_rects( + zone, + rect_lines=v_lines, + rh_lines=rh_lines, + func_dbt_w_to_target=_points_to_volume, + ) + + def make_zone_curve( zone_conf: ChartZone, *, pressure: float, step_temp: float, - # dbt_min: float = 0.0, - # dbt_max: float = 50.0, - # w_min: float = 0.0, + dbt_min: float, + dbt_max: float, + w_min: float, + w_max: float, ) -> PsychroCurve | None: """Generate plot-points for zone definition.""" if zone_conf.zone_type == "dbt-rh": @@ -55,6 +347,29 @@ def make_zone_curve( zone_conf, pressure, step_temp=step_temp ) + if zone_conf.zone_type == "volume-rh": + # points for zone between constant volume and RH ranges + return _make_zone_delimited_by_volume_and_rh( + zone_conf, + pressure, + step_temp=step_temp, + dbt_min=dbt_min, + dbt_max=dbt_max, + w_min=w_min, + w_max=w_max, + ) + if zone_conf.zone_type == "enthalpy-rh": + # points for zone between constant enthalpy and RH ranges + return _make_zone_delimited_by_enthalpy_and_rh( + zone_conf, + pressure, + step_temp=step_temp, + dbt_min=dbt_min, + dbt_max=dbt_max, + w_min=w_min, + w_max=w_max, + ) + # expect points in plot coordinates! assert zone_conf.zone_type == "xy-points" zone_value = random_internal_value() if zone_conf.label is None else None @@ -66,3 +381,95 @@ def make_zone_curve( label=zone_conf.label, internal_value=zone_value, ) + + +def make_over_saturated_zone( + saturation: PsychroCurve, + *, + dbt_min: float, + dbt_max: float, + w_min: float, + w_max: float, + color_fill: str | list[float] = "#0C92F6FF", +) -> PsychroCurve | None: + """Generate plot-points for a Patch in the over-saturated zone of chart.""" + if saturation.outside_limits(dbt_min, dbt_max, w_min, w_max): + return None + + path_x, path_y = [], [] + if saturation.y_data[0] < w_min: # saturation cuts bottom x-axis + idx_start = (saturation.y_data > w_min).argmax() + t_start, t_end = ( + saturation.x_data[idx_start - 1], + saturation.x_data[idx_start], + ) + w_start, w_end = ( + saturation.y_data[idx_start - 1], + saturation.y_data[idx_start], + ) + t_cut1, _w_cut1 = _crossing_point_between_rect_lines( + segment_1_x=(dbt_min, dbt_max), + segment_1_y=(w_min, w_min), + segment_2_x=(t_start, t_end), + segment_2_y=(w_start, w_end), + ) + path_x.append(t_cut1) + path_y.append(w_min) + + path_x.append(dbt_min) + path_y.append(w_min) + else: # saturation cuts left y-axis + idx_start = 0 + t_cut1, w_cut1 = saturation.x_data[0], saturation.y_data[0] + + path_x.append(t_cut1) + path_y.append(w_cut1) + + # top left corner + path_x.append(dbt_min) + path_y.append(w_max) + + if saturation.y_data[-1] < w_max: # saturation cuts right y-axis + # top right corner + path_x.append(dbt_max) + path_y.append(w_max) + t_cut2, w_cut2 = saturation.x_data[-1], saturation.y_data[-1] + path_x.append(t_cut2) + path_y.append(w_cut2) + + path_x += saturation.x_data[idx_start:].tolist()[::-1] + path_y += saturation.y_data[idx_start:].tolist()[::-1] + else: # saturation cuts top x-axis + idx_end = (saturation.y_data < w_max).argmin() + t_start, t_end = ( + saturation.x_data[idx_end - 1], + saturation.x_data[idx_end], + ) + w_start, w_end = ( + saturation.y_data[idx_end - 1], + saturation.y_data[idx_end], + ) + t_cut2, _w_cut2 = _crossing_point_between_rect_lines( + segment_1_x=(dbt_min, dbt_max), + segment_1_y=(w_max, w_max), + segment_2_x=(t_start, t_end), + segment_2_y=(w_start, w_end), + ) + path_x.append(t_cut2) + path_y.append(w_max) + + path_x += saturation.x_data[idx_start:idx_end].tolist()[::-1] + path_y += saturation.y_data[idx_start:idx_end].tolist()[::-1] + + return PsychroCurve( + x_data=np.array(path_x), + y_data=np.array(path_y), + style=ZoneStyle( + edgecolor=[0, 0, 0, 0], + facecolor=color_fill, + linewidth=0, + linestyle="none", + ), + type_curve="over_saturated", + internal_value=0, + ) diff --git a/psychrochart/models/config.py b/psychrochart/models/config.py index c2b0c6b..264a7dd 100644 --- a/psychrochart/models/config.py +++ b/psychrochart/models/config.py @@ -39,7 +39,7 @@ color=[0.0, 0.125, 0.376], linewidth=0.75, linestyle=":" ) -ZoneKind = Literal["dbt-rh", "xy-points"] +ZoneKind = Literal["dbt-rh", "xy-points", "enthalpy-rh", "volume-rh"] class ChartFigure(BaseConfig): diff --git a/psychrochart/models/curves.py b/psychrochart/models/curves.py index 86d92f9..dbaa2f8 100644 --- a/psychrochart/models/curves.py +++ b/psychrochart/models/curves.py @@ -80,6 +80,17 @@ def __repr__(self) -> str: extra = f" (label: {self.label})" if self.label else "" return f"<{name} {len(self.x_data)} values{extra}>" + def outside_limits( + self, xmin: float, xmax: float, ymin: float, ymax: float + ) -> bool: + """Test if curve is invisible (outside box).""" + return ( + max(self.y_data) < ymin + or max(self.x_data) < xmin + or min(self.y_data) > ymax + or min(self.x_data) > xmax + ) + class PsychroCurves(BaseModel): """Pydantic model to store a list of psychrometric curves for plotting.""" diff --git a/psychrochart/plot_logic.py b/psychrochart/plot_logic.py index 50b026d..3656674 100644 --- a/psychrochart/plot_logic.py +++ b/psychrochart/plot_logic.py @@ -119,19 +119,12 @@ def plot_curve( artists: dict[str, Artist] = {} xmin, xmax = ax.get_xlim() ymin, ymax = ax.get_ylim() - if ( - curve.x_data is None - or curve.y_data is None - or max(curve.y_data) < ymin - or max(curve.x_data) < xmin - or min(curve.y_data) > ymax - or min(curve.x_data) > xmax - ): + if curve.outside_limits(xmin, xmax, ymin, ymax): logging.info( - "%s (label:%s) not between limits ([%.2g, %.2g, %.2g, %.2g]) " + "%s (name:%s) not between limits ([%.2g, %.2g, %.2g, %.2g]) " "-> x:%s, y:%s", curve.type_curve, - curve.label or "unnamed", + curve.label or str(curve.internal_value), xmin, xmax, ymin, diff --git a/psychrochart/process_logic.py b/psychrochart/process_logic.py index f93a4f7..4d8ceae 100644 --- a/psychrochart/process_logic.py +++ b/psychrochart/process_logic.py @@ -47,7 +47,7 @@ def get_pressure_pa(limits: ChartLimits, unit_system_si: bool = True) -> float: return GetStandardAtmPressure(limits.altitude_m) -def _generate_chart_curves(config: ChartConfig, chart: PsychroChartModel): +def _gen_interior_lines(config: ChartConfig, chart: PsychroChartModel) -> None: # check chart limits are not fully above the saturation curve! assert (chart.saturation.y_data > config.w_min).any() # check if sat curve cuts x-axis with T > config.dbt_min @@ -182,6 +182,26 @@ def _generate_chart_curves(config: ChartConfig, chart: PsychroChartModel): chart.constant_wbt_data = None +def _gen_chart_zones(config: ChartConfig, chart: PsychroChartModel) -> None: + # regen all zones + if config.chart_params.with_zones and not config.chart_params.zones: + # add default zones + config.chart_params.zones = DEFAULT_ZONES.zones + zone_curves = [ + make_zone_curve( + zone, + pressure=chart.pressure, + step_temp=config.limits.step_temp, + dbt_min=config.dbt_min, + dbt_max=config.dbt_max, + w_min=config.w_min, + w_max=config.w_max, + ) + for zone in config.chart_params.zones + ] + chart.zones = [zc for zc in zone_curves if zc is not None] + + def update_psychrochart_data( current_chart: PsychroChartModel, config: ChartConfig ) -> None: @@ -200,21 +220,7 @@ def update_psychrochart_data( current_chart.pressure, style=config.saturation, ) - _generate_chart_curves(config, current_chart) + _gen_interior_lines(config, current_chart) # regen all zones - if config.chart_params.with_zones and not config.chart_params.zones: - # add default zones - config.chart_params.zones = DEFAULT_ZONES.zones - zone_curves = [ - make_zone_curve( - zone, - pressure=current_chart.pressure, - step_temp=config.limits.step_temp, - # dbt_min=config.dbt_min, - # dbt_max=config.dbt_max, - # w_min=config.w_min, - ) - for zone in config.chart_params.zones - ] - current_chart.zones = [zc for zc in zone_curves if zc is not None] + _gen_chart_zones(config, current_chart) config.commit_changes() diff --git a/psychrochart/util.py b/psychrochart/util.py index 9a0fb2f..f7b470d 100644 --- a/psychrochart/util.py +++ b/psychrochart/util.py @@ -1,5 +1,6 @@ import logging import os +from pathlib import Path from typing import Callable, Sequence import numpy as np @@ -111,3 +112,24 @@ def mod_color(color: Sequence[float], modification: float) -> list[float]: max(0.0, min(1.0, c * (1 + modification / 100))) for c in color ] return color + + +def add_styling_to_svg( + original: str, + css_styles: str | Path | None = None, + svg_definitions: str | None = None, +) -> str: + """Insert CSS styles and/or SVG definitions under SVG .""" + if css_styles is None or svg_definitions is None: + return original + + insertion_point = original.find("") + text_css = ( + css_styles.read_text() if isinstance(css_styles, Path) else css_styles + ) + return ( + f"{original[:insertion_point]}" + f"{svg_definitions or ''}\n" + f'\n' + f" {original[insertion_point:]}" + ) diff --git a/pyproject.toml b/pyproject.toml index d34d0bc..8ecf756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ log_date_format = "%Y-%m-%d %H:%M:%S" [tool.poetry] name = "psychrochart" -version = "0.8.0" +version = "0.9.0" description = "A python 3 library to make psychrometric charts and overlay information on them" authors = ["Eugenio Panadero "] packages = [ diff --git a/tests/example-charts/chart_overlay_style_minimal.svg b/tests/example-charts/chart_overlay_style_minimal.svg index bd032ca..39396e9 100644 --- a/tests/example-charts/chart_overlay_style_minimal.svg +++ b/tests/example-charts/chart_overlay_style_minimal.svg @@ -17,6 +17,113 @@ + + + + + + + + + + + + + + @@ -119,6 +226,142 @@ L 316.8 580.376047 L 307.2 582.569465 z " clip-path="url(#pc0e6066e52)" style="fill: #7f9fff; fill-opacity: 0.2; stroke-dasharray: 7.4,3.2; stroke-dashoffset: 0; stroke: #7f9fcc; stroke-width: 2; stroke-linejoin: miter"/> + + + + + + + + + @@ -1757,6 +2000,103 @@ z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1908,21 +2248,34 @@ z + + + - - + @@ -1956,15 +2309,15 @@ L 19 34.776563 L 29 34.776563 " style="fill: none; stroke: #400080; stroke-width: 2; stroke-linecap: square"/> - - + @@ -2047,15 +2400,15 @@ L 19 56.454688 L 29 56.454688 " style="fill: none; stroke: #008056; stroke-width: 2; stroke-linecap: square"/> - - + @@ -2147,15 +2500,15 @@ L 19 78.132813 L 29 78.132813 " style="fill: none; stroke: #007fff; stroke-width: 2; stroke-linecap: square"/> - - + @@ -2195,15 +2548,15 @@ L 19 99.810938 L 29 99.810938 " style="fill: none; stroke: #7fdfff; stroke-linecap: square"/> - - + @@ -2332,23 +2685,23 @@ L 19 143.167188 L 29 143.167188 " style="fill: none"/> - - + @@ -2370,14 +2723,14 @@ L 19 164.845313 L 29 164.845313 " style="fill: none"/> - - + @@ -2418,20 +2771,20 @@ L 19 186.523438 L 29 186.523438 " style="fill: none"/> - - + diff --git a/tests/example-charts/splash-chart.css b/tests/example-charts/splash-chart.css new file mode 100644 index 0000000..1b1cbdb --- /dev/null +++ b/tests/example-charts/splash-chart.css @@ -0,0 +1,92 @@ +g, +path { + transition: all 250ms ease-in-out; +} + +/* background gradient */ +#chart_background > path { + fill: url(#grad-background) !important; +} + +/* saturation line effects */ +@keyframes color_blink { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} +#saturation_100 path { + stroke: url(#grad-sat) !important; + animation: color_blink 3s infinite ease; +} +#saturation_100:hover path { + stroke-width: 9 !important; +} + +/* animated points */ +@keyframes scale_anim { + 0% { + opacity: 0.5; + -webkit-transform: scale(0.75); + transform: scale(0.75); + } + 100% { + opacity: 1; + -webkit-transform: scale(1.5); + transform: scale(1.5); + } +} +g[id^="point_"] { + animation: scale_anim 2s infinite ease-in-out; +} + +/* bolder lines on hover */ +g[id^="constant_dry_temp"] > path:hover, +g[id^="absolute_humidity"] > path:hover, +g[id^="dry_bulb_temperature"] > path:hover, +g[id^="constant_enthalpy"] > path:hover, +g[id^="constant_specific_volume"] > path:hover, +g[id^="constant_wet_bulb_temperature"] > path:hover, +g[id^="constant_relative_humidity"] > path:hover { + stroke-width: 5 !important; +} + +/* obscure zones on hover */ +g[id^="zone_"] > path:hover { + fill-opacity: 0.95 !important; + stroke-dasharray: none !important; + stroke-width: 1 !important; +} + +/* specific formatting for lines on hover */ +#constant_relative_humidity_40:hover path, +#constant_relative_humidity_60:hover path { + stroke: #2f2ff1 !important; + stroke-width: 3; +} + +/* bigger points on hover */ +g[id^="point_"], +g[id^="connector_"] { + transform: scale(1); + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + transform-box: fill-box; +} +g[id^="point_"]:hover { + -webkit-transform: scale(1.5); + -webkit-transform-origin: 50% 50%; + transform: scale(1.5); + transform-origin: 50% 50%; +} +g[id^="connector_"]:hover { + -webkit-transform: scale(1.1); + -webkit-transform-origin: 50% 50%; + transform: scale(1.1); + transform-origin: 50% 50%; +} diff --git a/tests/example-charts/test_minimal_psychrochart.svg b/tests/example-charts/test_minimal_psychrochart.svg index ba3b882..8c2cd86 100644 --- a/tests/example-charts/test_minimal_psychrochart.svg +++ b/tests/example-charts/test_minimal_psychrochart.svg @@ -1471,7 +1471,7 @@ z - + = 2 + assert not chart.artists.zones + assert chart.plot_over_saturated_zone() is None diff --git a/tests/test_generate_rsc.py b/tests/test_generate_rsc.py index 46d1aea..d3bb3de 100644 --- a/tests/test_generate_rsc.py +++ b/tests/test_generate_rsc.py @@ -1,7 +1,7 @@ import pytest from psychrochart import PsychroChart -from tests.conftest import store_test_chart +from tests.conftest import RSC_EXAMPLES, store_test_chart @pytest.mark.parametrize( @@ -55,6 +55,28 @@ def test_generate_rsc_splash_chart(): "points_y": [35, 55], "label": "Winter", }, + { + "zone_type": "volume-rh", + "style": { + "edgecolor": "k", + "facecolor": "#00640077", + "linestyle": "--", + }, + "points_x": [0.86, 0.87], + "points_y": [20, 80], + "label": "V-RH zone", + }, + { + "zone_type": "enthalpy-rh", + "style": { + "edgecolor": "k", + "facecolor": "#FFFF0077", + "linestyle": "--", + }, + "points_x": [80, 90], + "points_y": [40, 80], + "label": "H-RH zone", + }, ] } chart.append_zones(zones_conf) @@ -137,11 +159,34 @@ def test_generate_rsc_splash_chart(): }, ] chart.plot_points_dbt_rh(points, connectors) + chart.plot_over_saturated_zone(color_fill="#ffc1ab") # Legend - chart.plot_legend( - markerscale=0.7, frameon=False, fontsize=10, labelspacing=1.2 - ) + chart.plot_legend(markerscale=0.5, fontsize=10, labelspacing=1.2) + + # CSS styling + SVG defs to customize and animate SVG + svg_defs = """ + + + + + + + + + + + +""" # Save to disk - store_test_chart(chart, "chart_overlay_style_minimal.svg", svg_rsc=True) + p_svg = RSC_EXAMPLES / "chart_overlay_style_minimal.svg" + p_svg.write_text( + chart.make_svg( + css_styles=RSC_EXAMPLES / "splash-chart.css", + svg_definitions=svg_defs, + metadata={"Date": None}, + ) + ) + # p_png = RSC_EXAMPLES / "chart_overlay_style_minimal.png" + # chart.save(p_png, facecolor="none")