psychart
Version:
View air conditions on a psychrometric chart
535 lines (534 loc) • 28.2 kB
JavaScript
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Psychart = void 0;
var viridis_1 = require("viridis");
var psystate_1 = require("./psystate");
var SMath = require("smath");
var defaults_1 = require("./defaults");
var chart_1 = require("../chart");
/**
* Generates an interactive psychrometric chart with plotting capabilities.
*/
var Psychart = /** @class */ (function (_super) {
__extends(Psychart, _super);
/**
* Construct a new instance of `Psychart` given various configuration properties.
*/
function Psychart(options) {
if (options === void 0) { options = {}; }
var _this = _super.call(this, options, defaults_1.defaultPsychartOptions) || this;
/**
* Defines the string representations of the current unit system.
*/
_this.units = {
temp: '',
hr: '',
vp: '',
h: '',
v: '',
};
/**
* Represents the legend for Psychart.
*/
_this.legend = document.createElementNS(_this.NS, 'svg');
/**
* Legend definitions, which contains linear gradients.
*/
_this.legendDefs = document.createElementNS(_this.NS, 'defs');
/**
* The base element for adding lines in the legend.
*/
_this.legendg = document.createElementNS(_this.NS, 'g');
/**
* Defines all the groups in the SVG ordered by layer.
*/
_this.g = {
regions: document.createElementNS(_this.NS, 'g'),
axes: document.createElementNS(_this.NS, 'g'),
text: document.createElementNS(_this.NS, 'g'),
trends: document.createElementNS(_this.NS, 'g'),
points: document.createElementNS(_this.NS, 'g'),
tooltips: document.createElementNS(_this.NS, 'g'),
};
/**
* The data series plotted on Psychart with each of their last states and visibility toggles.
*/
_this.series = {};
// Check to make sure that dpMax is less than dbMax
if (_this.options.dpMax > _this.options.dbMax) {
throw new Error('Dew point maximum is greater than dry bulb range!');
}
// Set base styling.
_this.base.style.position = 'relative';
// If set, generate the legend.
if (typeof _this.options.legend === 'object') {
// Set the legend's viewport size.
_this.legend.setAttribute('viewBox', '0 0 ' + _this.options.size.x + ' ' + _this.getLegendHeight());
_this.legend.setAttribute('width', _this.options.size.x + 'px');
_this.legend.setAttribute('height', _this.getLegendHeight() + 'px');
_this.legend.appendChild(_this.legendDefs);
_this.legend.appendChild(_this.createLabel(_this.options.legend.title, { x: 0, y: 0 }, viridis_1.Color.hex(_this.options.colors.font), 1 /* TextAnchor.NW */));
_this.legend.appendChild(_this.legendg);
// Attach elements to the base element.
var legendContainer = document.createElement('div');
legendContainer.setAttribute('title', 'Click to toggle data series visibility.');
legendContainer.style.position = 'absolute';
legendContainer.style.left = (_this.options.flipXY ? (_this.options.size.x - _this.options.legend.size.x - _this.options.legend.margin.x) : _this.options.legend.margin.x) + 'px';
legendContainer.style.top = (_this.options.flipXY ? (_this.options.size.y - _this.options.legend.size.y - _this.options.legend.margin.y) : _this.options.legend.margin.y) + 'px';
legendContainer.style.width = _this.options.legend.size.x + 'px';
legendContainer.style.height = _this.options.legend.size.y + 'px';
legendContainer.style.overflowX = 'hidden';
legendContainer.style.overflowY = 'auto';
legendContainer.style.border = '1px solid ' + _this.options.colors.axis;
legendContainer.appendChild(_this.legend);
_this.base.appendChild(legendContainer);
}
// Sets the displayed units based on the unit system.
_this.units.temp = '\u00B0' + (_this.options.unitSystem === 'IP' ? 'F' : 'C');
_this.units.hr = (_this.options.unitSystem === 'IP' ? 'lbw/klba' : 'gw/kga');
_this.units.vp = (_this.options.unitSystem === 'IP' ? 'Psi' : 'Pa');
_this.units.h = (_this.options.unitSystem === 'IP' ? 'Btu/lb' : 'kJ/kg');
_this.units.v = (_this.options.unitSystem === 'IP' ? 'ft\u00B3/lb' : 'm\u00B3/kg');
// Set the scaling factors for different unit systems.
_this.scaleFactor = {
hr: (_this.options.unitSystem === 'IP' ? 1e3 : 1e3),
h: (_this.options.unitSystem === 'IP' ? 1 : 1e-3),
};
// Create new SVG groups, and append all the
// layers into the chart.
Object.values(_this.g).forEach(function (group) { return _this.svg.appendChild(group); });
// Draw constant dry bulb vertical lines.
Psychart.getRange(_this.options.dbMin, _this.options.dbMax, _this.options.major.temp).forEach(function (db) {
var data = [];
// The lower point is on the X-axis (rh = 0%)
data.push(new psystate_1.PsyState({ db: db, other: 0, measurement: 'dbrh' }, _this.options));
// The upper point is on the saturation line (rh = 100%)
data.push(new psystate_1.PsyState({ db: db, other: 1, measurement: 'dbrh' }, _this.options));
// Draw the axis and the label
_this.drawAxis(data);
_this.drawLabel(db + (_this.options.showUnits.axis ? _this.units.temp : ''), data[0], 2 /* TextAnchor.N */, 'Dry Bulb' + (_this.options.showUnits.tooltip ? ' [' + _this.units.temp + ']' : ''));
});
switch (_this.options.yAxis) {
case ('dp'): {
// Draw constant dew point horizontal lines.
Psychart.getRange(0, _this.options.dpMax, _this.options.major.temp).forEach(function (dp) {
var data = [];
// The left point is on the saturation line (db = dp)
data.push(new psystate_1.PsyState({ db: dp, other: dp, measurement: 'dbdp' }, _this.options));
// The right point is at the maximum dry bulb temperature
data.push(new psystate_1.PsyState({ db: _this.options.dbMax, other: dp, measurement: 'dbdp' }, _this.options));
// Draw the axis and the label
_this.drawAxis(data);
_this.drawLabel(dp + (_this.options.showUnits.axis ? _this.units.temp : ''), data[1], 8 /* TextAnchor.W */, 'Dew Point' + (_this.options.showUnits.tooltip ? ' [' + _this.units.temp + ']' : ''));
});
break;
}
case ('hr'): {
// Draw constant humidity ratio horizontal lines.
var maxHr = new psystate_1.PsyState({ db: _this.options.dbMax, measurement: 'dbdp', other: _this.options.dpMax }, _this.options).hr, step = _this.options.major.humRat / _this.scaleFactor.hr;
Psychart.getRange(0, maxHr, step).forEach(function (hr) {
var data = [];
// The right point is at the maximum dry bulb temperature
data.push(new psystate_1.PsyState({ db: _this.options.dbMax, other: hr, measurement: 'dbhr' }, _this.options));
// The left point is on the saturation line
var dp = data[data.length - 1].dp;
data.push(new psystate_1.PsyState({ db: dp, other: dp, measurement: 'dbdp' }, _this.options));
// Draw the axis and the label
_this.drawAxis(data);
_this.drawLabel(SMath.round2(hr * _this.scaleFactor.hr, 1) + (_this.options.showUnits.axis ? _this.units.hr : ''), data[0], 8 /* TextAnchor.W */, 'Humidity Ratio' + (_this.options.showUnits.tooltip ? ' [' + _this.units.hr + ']' : ''));
});
break;
}
default: {
throw new Error('Invalid y-axis type: ' + _this.options.yAxis);
}
}
// Draw constant wet bulb diagonal lines.
Psychart.getRange(_this.options.dbMin, _this.options.dpMax, _this.options.major.temp).forEach(function (wb) {
var data = [];
// Dry bulb is always equal or greater than wet bulb.
for (var db = wb; db <= _this.options.dbMax; db += _this.options.resolution) {
data.push(new psystate_1.PsyState({ db: db, other: wb, measurement: 'dbwb' }, _this.options));
}
// Draw the axis and the label
_this.drawAxis(data);
_this.drawLabel(wb + (_this.options.showUnits.axis ? _this.units.temp : ''), data[0], 5 /* TextAnchor.SE */, 'Wet Bulb' + (_this.options.showUnits.tooltip ? ' [' + _this.units.temp + ']' : ''));
});
// Draw constant relative humidity lines.
Psychart.getRange(0, 100, _this.options.major.relHum).forEach(function (rh) {
var data = [];
var preferredAnchor = 3 /* TextAnchor.NE */;
// Must iterate through all dry bulb temperatures to calculate each Y-coordinate
for (var db = _this.options.dbMin; db <= _this.options.dbMax; db += _this.options.resolution) {
data.push(new psystate_1.PsyState({ db: db, other: rh / 100, measurement: 'dbrh' }, _this.options));
// Stop drawing when the line surpasses the bounds of the chart
if (data[data.length - 1].dp >= _this.options.dpMax) {
preferredAnchor = 6 /* TextAnchor.S */;
break;
}
}
// Draw the axis and the label
_this.drawAxis(data);
if (rh > 0 && rh < 100) {
_this.drawLabel(rh + (_this.options.showUnits.axis ? '%' : ''), data[data.length - 1], preferredAnchor, 'Relative Humidity' + (_this.options.showUnits.tooltip ? ' [%]' : ''));
}
});
// Draw any regions, if applicable
var regionIndex = 0;
Object.entries(defaults_1.regions)
.filter(function (_a) {
var _b;
var name = _a[0];
return (_b = _this.options.regions) === null || _b === void 0 ? void 0 : _b.includes(name);
})
.forEach(function (_a) {
var region = _a[1];
// Force region gradient to remain within subrange of full span to improve visual impact in light/dark themes
var minRegion = 0 + -1, // -1 (arbitrary) Affects minimum span of region
maxRegion = _this.options.regions.length - 1 + 4, // +4 (arbitrary) Affects maximum span of region
minSpan = (_this.options.flipGradients) ? maxRegion : minRegion, maxSpan = (_this.options.flipGradients) ? minRegion : maxRegion, data = chart_1.Chart.deepCopy(region.data);
if (_this.options.unitSystem === 'IP') {
// Convert from SI to US units
data.forEach(function (datum) {
datum.db = Psychart.CtoF(datum.db);
if (datum.measurement === 'dbdp' || datum.measurement === 'dbwb') {
datum.other = Psychart.CtoF(datum.other);
}
});
}
_this.drawRegion(data, viridis_1.Palette[_this.options.colors.regionGradient].getColor(regionIndex, minSpan, maxSpan), region.tooltip);
regionIndex++;
});
return _this;
}
/**
* Helper function to return an array of region names and their corresponding tooltips.
*/
Psychart.getRegionNamesAndTips = function () {
return Object.entries(defaults_1.regions).map(function (_a) {
var name = _a[0], region = _a[1];
return [name, region.tooltip];
});
};
/**
* Convert from Celsius to Fahrenheit.
*/
Psychart.CtoF = function (C) {
return SMath.translate(C, 0, 100, 32, 212);
};
/**
* Get a range of numbers used for an axis.
*/
Psychart.getRange = function (min, max, step) {
var stepMin = SMath.round2(min + step * 1.1, step), stepMax = SMath.round2(max - step * 1.1, step), range = [];
for (var i = stepMin; i <= stepMax; i += step) {
range.push(i);
}
return __spreadArray(__spreadArray([min], range, true), [max], false);
};
/**
* Interpolate between "corner" psychrometric states.
*/
Psychart.prototype.interpolate = function (states, crop) {
// Cannot interpolate with only 1 point!
if (states.length < 2) {
return states;
}
// Create array containing interpolated points, starting with the first state.
var data = [states[0]];
for (var i = 1; i < states.length; i++) {
// Determine the start and end states.
var start = states[i - 1];
var end = states[i];
// Check if iso-rh, iso-wb, or iso-dp curved or straight lines
if (start.state.measurement === end.state.measurement && SMath.approx(start.state.other, end.state.other)) {
// Determine the dry bulb range
var startDb = crop ? SMath.clamp(start.db, this.options.dbMin, this.options.dbMax) : start.db;
var endDb = crop ? SMath.clamp(end.db, this.options.dbMin, this.options.dbMax) : end.db;
var nPoints = Math.abs((startDb - endDb) / this.options.resolution) | 0;
var dbRange = SMath.linspace(startDb, endDb, nPoints);
// Compute several intermediate states with a step of `resolution`
for (var _i = 0, dbRange_1 = dbRange; _i < dbRange_1.length; _i++) {
var db = dbRange_1[_i];
data.push(new psystate_1.PsyState({ db: db, other: start.state.other, measurement: start.state.measurement }, this.options));
// Stop generating if dew point exceeds maximum
if (crop && data[data.length - 1].dp > this.options.dpMax) {
break;
}
}
}
else {
data.push(end);
}
}
return data;
};
/**
* Generate SVG path data from an array of psychrometric states.
*/
Psychart.prototype.setPathData = function (path, psystates, closePath) {
path.setAttribute('d', 'M ' + psystates.map(function (psy) {
var xy = psy.toXY();
return xy.x + ',' + xy.y;
}).join(' ') + (closePath ? ' z' : ''));
};
/**
* Draw an axis line given an array of psychrometric states.
*/
Psychart.prototype.drawAxis = function (data) {
this.g.axes.appendChild(this.createLine(data, viridis_1.Color.hex(this.options.colors.axis), 1.0));
};
/**
* Create a line to append onto a parent element.
*/
Psychart.prototype.createLine = function (data, color, weight) {
var line = document.createElementNS(this.NS, 'path');
line.setAttribute('fill', 'none');
line.setAttribute('stroke', color.toString());
line.setAttribute('stroke-width', weight + 'px');
line.setAttribute('stroke-linecap', 'round');
line.setAttribute('vector-effect', 'non-scaling-stroke');
// Convert the array of psychrometric states into an array of (x,y) points.
this.setPathData(line, data, false);
return line;
};
/**
* Draw an axis label.
*/
Psychart.prototype.drawLabel = function (text, location, anchor, tooltip) {
var _this = this;
// Determine if anchor needs to be mirrored
if (this.options.flipXY) {
switch (anchor) {
case (4 /* TextAnchor.E */): {
anchor = 2 /* TextAnchor.N */;
break;
}
case (2 /* TextAnchor.N */): {
anchor = 4 /* TextAnchor.E */;
break;
}
case (1 /* TextAnchor.NW */): {
anchor = 5 /* TextAnchor.SE */;
break;
}
case (5 /* TextAnchor.SE */): {
anchor = 1 /* TextAnchor.NW */;
break;
}
case (6 /* TextAnchor.S */): {
anchor = 8 /* TextAnchor.W */;
break;
}
case (8 /* TextAnchor.W */): {
anchor = 6 /* TextAnchor.S */;
break;
}
}
}
var fontColor = viridis_1.Color.hex(this.options.colors.font), label = this.createLabel(text, location.toXY(), fontColor, anchor);
this.g.text.appendChild(label);
if (tooltip) {
label.addEventListener('mouseover', function (e) { return _this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, fontColor, _this.g.tooltips); });
label.addEventListener('mouseleave', function () { return chart_1.Chart.clearChildren(_this.g.tooltips); });
}
};
/**
* Add a line to the legend.
*/
Psychart.prototype.addToLegend = function (seriesName, color, gradient) {
var _this = this;
this.legend.setAttribute('viewBox', '0 0 ' + this.options.size.x + ' ' + this.getLegendHeight());
this.legend.setAttribute('height', this.getLegendHeight() + 'px');
var g = document.createElementNS(this.NS, 'g'), icon = document.createElementNS(this.NS, 'rect');
g.setAttribute('cursor', 'pointer');
icon.setAttribute('x', (this.options.font.size / 2).toString());
icon.setAttribute('y', (this.getLegendHeight() - this.options.font.size * 1.5).toString());
icon.setAttribute('width', this.options.font.size.toString());
icon.setAttribute('height', this.options.font.size.toString());
icon.setAttribute('rx', (this.options.font.size * 0.20).toString());
if (color) {
icon.setAttribute('fill', color.toString());
}
else if (gradient) {
var uniqueGradientID = 'psy_' + this.id + '_grad_' + this.legendDefs.children.length;
this.legendDefs.appendChild(viridis_1.Palette[gradient].toSVG(uniqueGradientID));
icon.setAttribute('fill', 'url(#' + uniqueGradientID + ')');
}
else {
throw new Error('Error in ' + seriesName + '. Must have color or gradient defined.');
}
var fontColor = viridis_1.Color.hex(this.options.colors.font), legendText = this.createLabel(seriesName, { x: this.options.font.size * 1.5, y: this.getLegendHeight() - this.options.font.size }, fontColor, 8 /* TextAnchor.W */);
g.addEventListener('click', function () {
_this.series[seriesName].hidden = !_this.series[seriesName].hidden;
if (_this.series[seriesName].hidden) {
g.setAttribute('opacity', '0.5');
legendText.setAttribute('text-decoration', 'line-through');
// Remove elements of this series from Psychart
_this.g.points.removeChild(_this.series[seriesName].pointGroup);
_this.g.trends.removeChild(_this.series[seriesName].lineGroup);
}
else {
g.removeAttribute('opacity');
legendText.removeAttribute('text-decoration');
// Re-add elements of this series to Psychart
_this.g.points.appendChild(_this.series[seriesName].pointGroup);
_this.g.trends.appendChild(_this.series[seriesName].lineGroup);
}
});
g.append(icon, legendText);
this.legendg.appendChild(g);
};
/**
* Compute the height of the legend, in pixels.
*/
Psychart.prototype.getLegendHeight = function () {
return (this.legendg.children.length + 2.5) * this.options.font.size * this.options.lineHeight;
};
/**
* Plot one psychrometric state onto the psychrometric chart.
*/
Psychart.prototype.plot = function (state, config) {
var _this = this;
if (config === void 0) { config = {}; }
// Skip series that are missing a measurement point.
if (!Number.isFinite(state.db) || !Number.isFinite(state.other)) {
return;
}
// Set default data options.
var options = chart_1.Chart.setDefaults(config, defaults_1.defaultDataOptions);
// Determine whether this is time-dependent.
var hasTimeStamp = Number.isFinite(options.time.now), timeSeries = hasTimeStamp && Number.isFinite(options.time.end) && Number.isFinite(options.time.start);
// Divide by 100 if relHumType is set to 'percent'
if (state.measurement === 'dbrh' && options.relHumType === 'percent') {
state.other /= 100;
}
var currentState = new psystate_1.PsyState(state, this.options), location = currentState.toXY();
// Compute the current color to plot
var tMin = (this.options.flipGradients) ? options.time.end : options.time.start, tMax = (this.options.flipGradients) ? options.time.start : options.time.end, tNow = options.time.now, color = timeSeries ? viridis_1.Palette[options.gradient].getColor(tNow, tMin, tMax) : viridis_1.Color.hex(options.color);
// Define a 0-length path element and assign its attributes.
var point = document.createElementNS(this.NS, 'path');
point.setAttribute('fill', 'none');
point.setAttribute('stroke', color.toString());
point.setAttribute('stroke-width', +options.pointRadius + 'px');
point.setAttribute('stroke-linecap', 'round');
point.setAttribute('vector-effect', 'non-scaling-stroke');
point.setAttribute('d', 'M ' + location.x + ',' + location.y + ' h 0');
// Determine whether to draw a line from another point.
var lineFrom = null;
// Options for data series:
if (options.name) {
// Add an item in the legend, if not previously added.
if (!this.series[options.name]) {
this.series[options.name] = {
lastState: currentState,
hidden: false,
pointGroup: document.createElementNS(this.NS, 'g'),
lineGroup: document.createElementNS(this.NS, 'g'),
};
// Add the series-level group elements onto the main groups.
this.g.points.appendChild(this.series[options.name].pointGroup);
this.g.trends.appendChild(this.series[options.name].lineGroup);
if (options.legend) {
this.addToLegend(options.name, timeSeries ? undefined : color, timeSeries ? options.gradient : undefined);
}
}
else if (options.line === true) {
// Determine whether to connect the states with a line
lineFrom = this.series[options.name].lastState;
}
// Store the last state in order to draw a line.
this.series[options.name].lastState = currentState;
// Plot the new data point onto the series group element.
this.series[options.name].pointGroup.appendChild(point);
}
else {
// Plot the new data point onto the base group element.
this.g.points.appendChild(point);
}
// Check for arbitrary origin point to draw a line.
if (typeof options.line === 'object') {
lineFrom = new psystate_1.PsyState(options.line, this.options);
}
// Draw a line.
if (lineFrom) {
var line = this.createLine(this.interpolate([lineFrom, currentState], true), color, options.pointRadius / 2);
if (options.name) {
this.series[options.name].lineGroup.appendChild(line);
}
else {
this.g.trends.appendChild(line);
}
}
// Generate the text to display on mouse hover.
var tooltipString = (options.name ? options.name + '\n' : '') +
(hasTimeStamp ? new Date(tNow).toLocaleString() + '\n' : '') +
currentState.db.toFixed(1) + this.units.temp + ' Dry Bulb\n' +
(currentState.rh * 100).toFixed() + '% Rel. Hum.\n' +
currentState.wb.toFixed(1) + this.units.temp + ' Wet Bulb\n' +
currentState.dp.toFixed(1) + this.units.temp + ' Dew Point' +
(options.advanced ? '\n' +
(currentState.hr * this.scaleFactor.hr).toFixed(2) + ' ' + this.units.hr + ' Hum. Ratio\n' +
currentState.vp.toFixed(1) + ' ' + this.units.vp + ' Vap. Press.\n' +
(currentState.h * this.scaleFactor.h).toFixed(1) + ' ' + this.units.h + ' Enthalpy\n' +
currentState.v.toFixed(2) + ' ' + this.units.v + ' Volume\n' +
(currentState.s * 100).toFixed() + '% Saturation' : '');
// Set the behavior when the user interacts with this point
point.addEventListener('mouseover', function (e) { return _this.drawTooltip(tooltipString, { x: e.offsetX, y: e.offsetY }, color, _this.g.tooltips); });
point.addEventListener('mouseleave', function () { return chart_1.Chart.clearChildren(_this.g.tooltips); });
};
/**
* Draw a shaded region on Psychart.
*/
Psychart.prototype.drawRegion = function (data, color, tooltip) {
var _this = this;
// Interpolate to get a set of psychrometric states that make the border of the region
var states = this.interpolate(data.map(function (datum) { return new psystate_1.PsyState(datum, _this.options); }), false);
// Create the SVG element to render the shaded region
var region = document.createElementNS(this.NS, 'path');
region.setAttribute('fill', color.toString());
this.setPathData(region, states, true);
this.g.regions.appendChild(region);
// Optionally render a tooltip on mouse hover
if (tooltip) {
region.addEventListener('mouseover', function (e) { return _this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, color, _this.g.tooltips); });
region.addEventListener('mouseleave', function () { return chart_1.Chart.clearChildren(_this.g.tooltips); });
}
};
/**
* Clear all plotted data from Psychart.
*/
Psychart.prototype.clearData = function () {
this.series = {};
chart_1.Chart.clearChildren(this.g.points);
chart_1.Chart.clearChildren(this.g.trends);
chart_1.Chart.clearChildren(this.legendDefs);
chart_1.Chart.clearChildren(this.legendg);
};
return Psychart;
}(chart_1.Chart));
exports.Psychart = Psychart;