Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ guide in the documentation says what to change in your code.

## [Unreleased]

### Added
- `Chart.add_line` and `Chart.remove_line` (issue #32): single lines of
constant value drawn on top of the families, each with its own name,
legend entry, label and style, such as a highlighted isotherm among
the default ones. Lines of an axis quantity are offered too, straight
across the plot (the pressure at which the burst disks of a CO2 plant
open, in a log(p)-h chart), and the axes widen to show them. Without a
style a line takes its family's at twice the width. The lines are
stored in `DiagramSpec.lines` (`coolplot.LineSpec`, the spec record's
`lines`), drawn with the scene roles `line` and `line_label`, and
composed the same by coolplot-core 1.6; an older core leaves charts
with single lines to the Python composer.

### Changed
- `cycle_states`, `state`, `state_at` and `mix` say which way `units=`
works: on `cycle_states` it converts the states returned (as dicts),
Expand Down
2 changes: 1 addition & 1 deletion cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
# Catch2 is fetched for the tests; set FETCHCONTENT_SOURCE_DIR_CATCH2 to a
# local checkout to build offline.
cmake_minimum_required(VERSION 3.16)
project(coolplot_core VERSION 1.5.0 LANGUAGES CXX)
project(coolplot_core VERSION 1.6.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
Expand Down
5 changes: 3 additions & 2 deletions cpp/include/coolplot/compose.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// "domain": {"tp_box": [T_min, T_max, p_min, p_max], "ranges_SI": {key: [min, max]}},
// "curves": [{"request": {"item_id", "key", "value_SI", "points", "labels", "anchor_SI"}, "curve": <curve record>}],
// (anchor_SI, since 1.1: the saturation temperature or pressure the line passes through when its
// family is placed by saturation states, null otherwise; the label shows it)
// family is placed by saturation states, null otherwise; the label shows it. Since 1.6 the curves
// of the single lines of the spec come here too, with the item id "line/<name>")
// "cycles": [{"states": {"1": {key: value}}, "chains": [[{key: value}, ...]]}],
// "plot_size_pt": [width, height] or null,
// "previous_labels": [<text item>] or null,
Expand All @@ -30,7 +31,7 @@

namespace coolplot {

extern const double Z_ISOLINE, Z_SATURATION, Z_LABEL, Z_PROCESS, Z_STATES;
extern const double Z_ISOLINE, Z_SATURATION, Z_LINE, Z_LABEL, Z_PROCESS, Z_STATES;

/// The scene of a chart from the compose record above.
Scene compose(const json& input);
Expand Down
4 changes: 4 additions & 0 deletions cpp/include/coolplot/core.h
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ struct DiagramType
}
/// Whether both axes may show their quantities in this kind of chart.
bool valid() const;
/// Whether the quantity key `key` is on one of the two axes, e.g. "p" or "h" in a ph diagram.
bool is_axis(const std::string& key) const {
return key == x || key == y;
}
bool x_log() const;
bool y_log() const;
/// The families this chart can draw, in their usual order.
Expand Down
20 changes: 20 additions & 0 deletions cpp/include/coolplot/spec.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ struct CycleSpec
MarkerStyle marker_style;
};

/// A single line of constant `key` at `value_SI` (coolplot.spec.LineSpec), drawn on top of the families.
/// `key` has a family in the diagram, or is one of its axes: such a line is straight and spans the plot.
struct LineSpec
{
std::string name;
std::string key;
double value_SI = 0.0;
bool has_label = true; ///< false: no label
bool label_is_value = true; ///< true: the label is the line's value ("p=130 bar"), else `label`
std::string label;
bool has_line_style = false;
LineStyle line_style;

/// The id of the line in the scene and of its curve request: "line/<name>".
std::string item_id() const {
return "line/" + name;
}
};

struct DiagramSpec
{
DiagramType diagram;
Expand All @@ -115,6 +134,7 @@ struct DiagramSpec
std::vector<IsolineSet> isolines;
std::vector<ProcessSpec> processes;
std::vector<CycleSpec> cycles;
std::vector<LineSpec> lines;
std::map<std::string, json> style_overrides; ///< LineStyle field changes per family, and for "saturation"
bool has_x_view = false;
Range x_view_SI;
Expand Down
194 changes: 162 additions & 32 deletions cpp/src/compose.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

namespace coolplot {

const double Z_ISOLINE = 1.0, Z_SATURATION = 2.0, Z_LABEL = 4.0, Z_PROCESS = 5.0, Z_STATES = 6.0;
const double Z_ISOLINE = 1.0, Z_SATURATION = 2.0, Z_LINE = 3.0, Z_LABEL = 4.0, Z_PROCESS = 5.0, Z_STATES = 6.0;

namespace {

Expand Down Expand Up @@ -256,6 +256,8 @@ AxesSpec axes_spec(const DiagramSpec& spec, const Domain& domain, const Theme& t
const std::vector<double>& v = axis == 0 ? item.x : item.y;
values.insert(values.end(), v.begin(), v.end());
}
for (const LineSpec& line : spec.lines)
if (line.key == key) values.push_back(unit.from_SI(line.value_SI)); // a single line of this axis
lo_hi = include(lo_hi, values, log);
}
// What the backends will draw, so that labels are laid out in that frame
Expand Down Expand Up @@ -297,6 +299,14 @@ std::vector<Item> isoline_items(const DiagramSpec& spec, const Theme& theme, con
return items;
}

/// The style of a label on a line of colour `line_color`: the theme's, in the colour of the line when the
/// theme asks for that.
TextStyle label_style(const Theme& theme, const Color& line_color) {
TextStyle style = theme.isoline_label;
if (theme.isoline_label_color == "line" && line_color.present) style.color = line_color;
return style;
}

/// The lines that get a label, most important first.
std::vector<LabelRequest> label_requests(const DiagramSpec& spec, const Theme& theme, const std::vector<CurveInput>& curves,
const std::vector<Item>& lines) {
Expand Down Expand Up @@ -339,9 +349,7 @@ std::vector<LabelRequest> label_requests(const DiagramSpec& spec, const Theme& t
if (spec.settings.labels.mode == "rails")
r.text = spec.settings.labels.format_value(r.value); // the bare value; symbol and unit are in the legend
r.line_color = line->line_style.color;
r.style = theme.isoline_label;
if (theme.isoline_label_color == "line" && line->line_style.color.present)
r.style.color = line->line_style.color; // the label in its line's colour
r.style = label_style(theme, line->line_style.color);
r.x = line->x;
r.y = line->y;
requests.push_back(r);
Expand All @@ -350,35 +358,58 @@ std::vector<LabelRequest> label_requests(const DiagramSpec& spec, const Theme& t
return requests;
}

/// Where a label sits on a line, in display units, and the direction of the line there.
struct Spot
{
double x = 0.0, y = 0.0;
bool has_direction = false;
double direction_x = 0.0, direction_y = 0.0;
};

/// The point at `position` (0 to 1) along the visible points of a line, with the direction of the line
/// there; false when fewer than `min_points` are visible.
bool label_spot(const std::vector<double>& x, const std::vector<double>& y, const AxesSpec& axes, double position, Spot& spot,
size_t min_points = 3) {
double x_lo = axes.x_limits.lo, x_hi = axes.x_limits.hi, y_lo = axes.y_limits.lo, y_hi = axes.y_limits.hi;
std::vector<size_t> inside;
for (size_t i = 0; i < x.size(); ++i)
if (std::isfinite(x[i]) && std::isfinite(y[i]) && x[i] >= x_lo && x[i] <= x_hi && y[i] >= y_lo && y[i] <= y_hi)
inside.push_back(i);
if (inside.size() < min_points) return false;
size_t pick = static_cast<size_t>(position * inside.size());
size_t i = inside[std::min(pick, inside.size() - 1)];
size_t i0 = i > 0 ? i - 1 : 0, i1 = std::min(i + 1, x.size() - 1);
double dx = x[i1] - x[i0], dy = y[i1] - y[i0];
spot = {x[i], y[i], std::isfinite(dx) && std::isfinite(dy), dx, dy};
return true;
}

/// A label on a line, at `spot`.
Item label_item(const std::string& id, const std::string& group, const std::string& role, const Spot& spot, const std::string& text,
const TextStyle& style) {
Item t;
t.type = ItemType::Text;
t.id = id;
t.group = group;
t.role = role;
t.text_x = spot.x;
t.text_y = spot.y;
t.text = text;
t.has_direction = spot.has_direction;
t.direction_x = spot.direction_x;
t.direction_y = spot.direction_y;
t.text_style = style;
t.z_order = Z_LABEL;
return t;
}

/// Labels at Settings.labels.position along the visible part of each line.
std::vector<Item> inline_labels(const DiagramSpec& spec, const std::vector<LabelRequest>& requests, const AxesSpec& axes) {
double x_lo = axes.x_limits.lo, x_hi = axes.x_limits.hi, y_lo = axes.y_limits.lo, y_hi = axes.y_limits.hi;
double position = spec.settings.labels.position;
std::vector<Item> labels;
for (const LabelRequest& r : requests) {
std::vector<size_t> inside;
for (size_t i = 0; i < r.x.size(); ++i)
if (std::isfinite(r.x[i]) && std::isfinite(r.y[i]) && r.x[i] >= x_lo && r.x[i] <= x_hi && r.y[i] >= y_lo && r.y[i] <= y_hi)
inside.push_back(i);
if (inside.size() < 3) continue;
size_t pick = static_cast<size_t>(position * inside.size());
size_t i = inside[std::min(pick, inside.size() - 1)];
size_t i0 = i > 0 ? i - 1 : 0, i1 = std::min(i + 1, r.x.size() - 1);
double dx = r.x[i1] - r.x[i0], dy = r.y[i1] - r.y[i0];
Item t;
t.type = ItemType::Text;
t.id = r.id;
t.group = r.group;
t.role = "isoline_label";
t.text_x = r.x[i];
t.text_y = r.y[i];
t.text = r.text;
t.has_direction = std::isfinite(dx) && std::isfinite(dy);
t.direction_x = dx;
t.direction_y = dy;
t.text_style = r.style;
t.z_order = Z_LABEL;
labels.push_back(t);
Spot spot;
if (label_spot(r.x, r.y, axes, spec.settings.labels.position, spot))
labels.push_back(label_item(r.id, r.group, "isoline_label", spot, r.text, r.style));
}
return labels;
}
Expand All @@ -401,6 +432,97 @@ std::vector<Item> labels_for(const DiagramSpec& spec, const std::vector<LabelReq
return place_edge_labels(requests, frame, keep_out, settings.keep_out_pt, edges, previous, settings.fallback, occupied);
}

// Single lines (coolplot.compose.builder.line_items)

/// (style, role, legend text, z order) of a single line, as isoline_look for a family. The style is the
/// line's own, or else its family's at twice the width. A line of an axis quantity has no family; it takes
/// the colour of the saturation curve (with the changes of set_line_style("saturation")), the darkest line
/// of the theme.
Look line_look(const DiagramSpec& spec, const Theme& theme, const LineSpec& line) {
LineStyle style = line.line_style;
if (!line.has_line_style) {
if (spec.diagram.is_axis(line.key)) {
json overrides = spec.style_override("saturation");
LineStyle saturation = overrides.empty() ? theme.saturation : with_overrides(theme.saturation, overrides);
style = theme.isoline;
style.color = saturation.color;
} else {
style = isoline_look(spec, theme, line.key, line.value_SI).style;
}
style.width_pt = 2.0 * style.width_pt;
}
return {style, "line", line.name, Z_LINE};
}

/// A single line of an axis quantity in display units, straight from one limit of the other axis to the
/// other. Its label spot sits at Settings.labels.position along it, evenly on a logarithmic axis too;
/// has_spot is false when the line lies outside the view.
struct AxisLine
{
std::vector<double> x, y;
bool has_spot = false;
Spot spot;
};

AxisLine axis_line(const DiagramSpec& spec, const LineSpec& line, const AxesSpec& axes) {
double value = spec.units.at(line.key).from_SI(line.value_SI);
double position = spec.settings.labels.position;
bool vertical = line.key == spec.diagram.x;
Range limits = vertical ? axes.y_limits : axes.x_limits;
Range own = vertical ? axes.x_limits : axes.y_limits;
bool log = vertical ? axes.y_log : axes.x_log;
double lo = limits.lo, hi = limits.hi;
double along = log ? lo * std::pow(hi / lo, position) : lo + position * (hi - lo);
AxisLine out;
out.has_spot = own.lo <= value && value <= own.hi;
if (vertical) {
out.x = {value, value};
out.y = {lo, hi};
out.spot = {value, along, true, 0.0, 1.0};
} else {
out.x = {lo, hi};
out.y = {value, value};
out.spot = {along, value, true, 1.0, 0.0};
}
return out;
}

/// The single lines of the spec and their labels. A line of an axis quantity is straight (axis_line); any
/// other line follows its curve and is left out while it has none. The label sits at
/// Settings.labels.position along the visible part of the line, whatever the label mode.
std::vector<Item> line_items(const DiagramSpec& spec, const Theme& theme, const std::vector<CurveInput>& curves, const AxesSpec& axes) {
const Unit& ux = spec.units.at(spec.diagram.x);
const Unit& uy = spec.units.at(spec.diagram.y);
std::map<std::string, const CurveInput*> by_id;
for (const CurveInput& c : curves) by_id[c.request.item_id] = &c;
std::vector<Item> items;
for (const LineSpec& line : spec.lines) {
std::vector<double> x, y;
Spot spot;
bool has_spot = false;
if (spec.diagram.is_axis(line.key)) {
AxisLine straight = axis_line(spec, line, axes);
x = straight.x;
y = straight.y;
spot = straight.spot;
has_spot = straight.has_spot;
} else {
auto found = by_id.find(line.item_id());
if (found == by_id.end()) continue;
x = converted(ux, found->second->x);
y = converted(uy, found->second->y);
has_spot = label_spot(x, y, axes, spec.settings.labels.position, spot, 2); // a line may have only 2 points
}
Look look = line_look(spec, theme, line);
items.push_back(line_item(line.item_id(), line.item_id(), look.role, true, look.legend, x, y, look.style, look.z_order));
Comment thread
jowr marked this conversation as resolved.
Resolved
if (!line.has_label || !has_spot) continue;
std::string text = line.label_is_value ? label_text(spec, line.key, line.value_SI) : line.label;
items.push_back(label_item(line.item_id() + "/label", line.item_id() + "/label", "line_label", spot, text,
Comment thread
jowr marked this conversation as resolved.
Resolved
label_style(theme, look.style.color)));
}
return items;
}

// Corners for a legend inside the plot area and for the badge (coolplot.compose.corners)

const char* const AUTO_CORNERS[] = {"lower right", "upper left", "upper right", "lower left"};
Expand Down Expand Up @@ -680,7 +802,10 @@ Scene compose(const json& input) {
const json& ranges = input.at("domain").at("ranges_SI");
for (auto it = ranges.begin(); it != ranges.end(); ++it)
domain.ranges_SI[quantity_key(it.key())] = {it.value().at(0).get<double>(), it.value().at(1).get<double>()};
std::vector<CurveInput> curves;
// The curves of the single lines come with those of the families; they are drawn apart
std::set<std::string> single_ids;
for (const LineSpec& line : spec.lines) single_ids.insert(line.item_id());
std::vector<CurveInput> curves, line_curves;
for (const json& entry : input.value("curves", json::array())) {
const json& r = entry.at("request");
CurveInput c;
Expand All @@ -693,7 +818,7 @@ Scene compose(const json& input) {
c.x = numbers_from(entry.at("curve").at("x"));
c.y = numbers_from(entry.at("curve").at("y"));
if (c.x.size() != c.y.size()) throw Error("Curve '" + c.request.item_id + "': x and y need the same length.");
curves.push_back(c);
(single_ids.count(c.request.item_id) ? line_curves : curves).push_back(c);
}
std::vector<CycleInput> cycles;
for (const json& entry : input.value("cycles", json::array())) {
Expand Down Expand Up @@ -730,11 +855,15 @@ Scene compose(const json& input) {
Frame frame{axes.x_limits, axes.y_limits, axes.x_log, axes.y_log, size.first, size.second};
process_parts = placed_state_numbers(process_parts, frame);
std::vector<Item> lines = isoline_items(spec, theme, curves);
std::vector<Item> single_lines = line_items(spec, theme, line_curves, axes);
std::vector<Item> all_lines = lines;
for (const Item& item : single_lines)
if (item.type == ItemType::Line) all_lines.push_back(item);
std::vector<std::string> sources;
for (const json& line : input.value("sources", json::array())) sources.push_back(line.get<std::string>());
Badge badge;
bool has_badge = make_badge(spec, theme, sources, badge);
std::vector<TakenBox> boxes = place_corners(axes, spec, theme, frame, lines, process_parts, keep_out, has_badge ? &badge : nullptr,
std::vector<TakenBox> boxes = place_corners(axes, spec, theme, frame, all_lines, process_parts, keep_out, has_badge ? &badge : nullptr,
spec.settings.branding.location);
if (has_badge) {
axes.has_badge = true;
Expand All @@ -749,6 +878,7 @@ Scene compose(const json& input) {
std::vector<Item> labels = labels_for(spec, requests, axes, keep_out, size, previous, boxes);
scene.items.insert(scene.items.end(), labels.begin(), labels.end());
}
scene.items.insert(scene.items.end(), single_lines.begin(), single_lines.end());
scene.items.insert(scene.items.end(), process_parts.begin(), process_parts.end());
return scene;
}
Expand Down
28 changes: 28 additions & 0 deletions cpp/src/spec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,34 @@ DiagramSpec DiagramSpec::from_json(const json& data) {
}
spec.cycles.push_back(cycle);
}
for (const json& l : data.value("lines", json::array())) {
LineSpec line;
line.name = l.at("name").get<std::string>();
const std::string suffix = "/label"; // the id of its label would be that of another line's label
if (line.name.size() >= suffix.size() && line.name.compare(line.name.size() - suffix.size(), suffix.size(), suffix) == 0)
throw Error("line '" + line.name + "': a line name may not end in '/label'");
for (const LineSpec& other : spec.lines)
if (other.name == line.name) throw Error("line '" + line.name + "': line names have to be unique");
line.key = quantity_key(l.at("key").get<std::string>());
line.value_SI = l.at("value_SI").get<double>();
if (!spec.diagram.family(line.key) && !spec.diagram.is_axis(line.key))
throw Error("line '" + line.name + "': lines of constant '" + line.key + "' cannot be drawn in a " + spec.diagram.name() +
" diagram");
const json label = l.contains("label") ? l.at("label") : json(true);
if (label.is_boolean()) {
line.has_label = label.get<bool>();
} else if (label.is_string()) {
line.label_is_value = false;
line.label = label.get<std::string>();
} else {
throw Error("line '" + line.name + "': label has to be true, false or a text, not " + label.dump());
}
if (l.contains("line_style") && !l.at("line_style").is_null()) {
line.has_line_style = true;
line.line_style = LineStyle::from_json(l.at("line_style"));
}
spec.lines.push_back(line);
}
json overrides = data.value("style_overrides", json::object());
for (auto it = overrides.begin(); it != overrides.end(); ++it) spec.style_overrides[override_key(it.key())] = it.value();
if (data.contains("view_SI")) {
Expand Down
Loading
Loading