UNPKG

psychart

Version:

View air conditions on a psychrometric chart

597 lines (596 loc) 30.9 kB
import { Color, Palette } from 'viridis'; import { PsyState } from './psystate.js'; import * as SMath from 'smath'; import { defaultDataOptions, defaultOptions, regions } from './defaults.js'; import { Chart } from '../chart.js'; /** * Generates an interactive psychrometric chart with plotting capabilities. */ export class Psychart extends Chart { /** * Defines the string representations of the current unit system. */ units = { temp: '', hr: '', vp: '', h: '', v: '', }; /** * Defines a scaling factor for humidity ratio. */ scaleFactor; /** * Represents the legend for Psychart. */ legend = document.createElementNS(this.NS, 'svg'); /** * Legend definitions, which contains linear gradients. */ legendDefs = document.createElementNS(this.NS, 'defs'); /** * The base element for adding lines in the legend. */ legendg = document.createElementNS(this.NS, 'g'); /** * Defines all the groups in the SVG ordered by layer. */ 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'), hilites: 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. */ series = {}; /** * The serial number for identified points. */ pointId = 0; /** * Helper function to return an array of region names and their corresponding tooltips. */ static getRegionNamesAndTips() { return Object.entries(regions).map(([name, region]) => [name, region.tooltip]); } /** * Convert from Celsius to Fahrenheit. */ static CtoF(C) { return SMath.translate(C, 0, 100, 32, 212); } /** * Get a range of numbers used for an axis. */ static getRange(min, max, step) { const padding = 0.9, stepMin = SMath.round2(min + step * padding, step), stepMax = SMath.round2(max - step * padding, step), range = []; for (let i = stepMin; i <= stepMax; i += step) { range.push(i); } return [min, ...range, max]; } /** * Construct a new instance of `Psychart` given various configuration properties. */ constructor(options = {}) { super(options, defaultOptions); // 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 }, Color.hex(this.options.colors.font), 1 /* TextAnchor.NW */, 0)); this.legend.appendChild(this.legendg); // Attach elements to the base element. const 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), }; // Append all the layers into the chart and clear highlights on click. Object.values(this.g).forEach(group => this.svg.appendChild(group)); this.svg.addEventListener('click', () => Chart.clearChildren(this.g.hilites)); // Write the axis titles. if (this.options.showAxisNames) { const fontColor = Color.hex(this.options.colors.font); const yAxisText = (this.options.yAxis === 'hr') ? 'Humidity Ratio' : 'Dew Point'; let xAxisLabel; let yAxisLabel; if (this.options.flipXY) { xAxisLabel = super.createLabel(yAxisText, { x: this.options.size.x / 2, y: 0 }, fontColor, 2 /* TextAnchor.N */, 0); yAxisLabel = super.createLabel('Dry Bulb', { x: 0, y: this.options.size.y / 2 }, fontColor, 2 /* TextAnchor.N */, -90); } else { xAxisLabel = super.createLabel('Dry Bulb', { x: this.options.size.x / 2, y: this.options.size.y }, fontColor, 6 /* TextAnchor.S */, 0); yAxisLabel = super.createLabel(yAxisText, { x: this.options.size.x, y: this.options.size.y / 2 }, fontColor, 2 /* TextAnchor.N */, 90); } this.g.text.append(xAxisLabel, yAxisLabel); } // Draw constant dry bulb vertical lines. Psychart.getRange(this.options.dbMin, this.options.dbMax, this.options.major.temp).forEach(db => { const data = []; // The lower point is on the X-axis (rh = 0%) data.push(new PsyState({ db: db, other: 0, measurement: 'dbrh' }, this.options)); // The upper point is on the saturation line (rh = 100%) data.push(new 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 */, 0, '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(dp => { const data = []; // The left point is on the saturation line (db = dp) data.push(new PsyState({ db: dp, other: dp, measurement: 'dbdp' }, this.options)); // The right point is at the maximum dry bulb temperature data.push(new 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 */, 0, 'Dew Point' + (this.options.showUnits.tooltip ? ' [' + this.units.temp + ']' : '')); }); break; } case ('hr'): { // Draw constant humidity ratio horizontal lines. const maxHr = new 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(hr => { const data = []; // The right point is at the maximum dry bulb temperature data.push(new PsyState({ db: this.options.dbMax, other: hr, measurement: 'dbhr' }, this.options)); // The left point is on the saturation line const dp = data[data.length - 1].dp; data.push(new 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 */, 0, 'Humidity Ratio' + (this.options.showUnits.tooltip ? ' [' + this.units.hr + ']' : '')); }); break; } default: { throw new Error('Invalid y-axis type: ' + this.options.yAxis); } } switch (this.options.dAxis) { case ('wb'): { // Draw constant wet bulb diagonal lines. Psychart.getRange(this.options.dbMin, this.options.dpMax, this.options.major.temp).forEach(wb => { const data = []; // Dry bulb is always equal or greater than wet bulb. for (let db = wb; db <= this.options.dbMax; db += this.options.resolution) { data.push(new 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 */, 15, 'Wet Bulb' + (this.options.showUnits.tooltip ? ' [' + this.units.temp + ']' : '')); }); break; } case ('h'): { // Calculate the minimum and maximum enthalpy bounds const minH = new PsyState({ db: this.options.dbMin, other: 0, measurement: 'dbrh' }, this.options).h; const maxH = new PsyState({ db: this.options.dpMax, other: 1, measurement: 'dbrh' }, this.options).h; // The first iteration is at the point (dbMin, 0%rh) which essentially is a single point at the lower-left of Psychart // We don't want the first iteration to be at (dbMin, 100%rh) because in that case we lose any possible enthalpy lines // generated lower than (dbMin, 100%rh). I'll probably forget why I did this, so try this with a high dbMin and 100%rh // and notice that a large chunk of the constant enthalpy lines are "missing". This is purely a visual decision. let first = true; // Draw constant enthalpy diagonal lines. Psychart.getRange(minH, maxH, this.options.major.enthalpy / this.scaleFactor.h).forEach(h => { // Skip the first iteration if (first) { first = false; return; } const data = []; // Calculate the dry bulb for dry air with the current enthalpy const dbDry = SMath.clamp(PsyState.getDryBulbWithEnthalpy(h), this.options.dbMin, this.options.dbMax); // Compute the diagonal line representing the current enthalpy axis for (let db = dbDry; db >= this.options.dbMin; db -= this.options.resolution) { try { data.unshift(new PsyState({ db: db, other: h, measurement: 'dbh' }, this.options)); } catch { // Stop computation when reaching saturation line, or when new PsyState fails } } // Draw the axis and the label this.drawAxis(data); this.drawLabel(SMath.round2(h * this.scaleFactor.h, 0.1) + (this.options.showUnits.axis ? this.units.h : ''), data[0], 5 /* TextAnchor.SE */, 15, 'Enthalpy' + (this.options.showUnits.tooltip ? ' [' + this.units.h + ']' : '')); }); break; } default: { throw new Error(`Invalid diagonal axis type: "${this.options.dAxis}"`); } } // Draw constant relative humidity lines. Psychart.getRange(0, 100, this.options.major.relHum).forEach(rh => { const data = []; let preferredAnchor = 3 /* TextAnchor.NE */; let rotation = 0; // Must iterate through all dry bulb temperatures to calculate each Y-coordinate for (let db = this.options.dbMin; db <= this.options.dbMax; db += this.options.resolution) { data.push(new 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 = 7 /* TextAnchor.SW */; rotation = -30; 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, rotation, 'Relative Humidity' + (this.options.showUnits.tooltip ? ' [%]' : '')); } }); // Draw any regions, if applicable let regionIndex = 0; Object.entries(regions) .filter(([name,]) => this.options.regions?.includes(name)) .forEach(([, region]) => { // Force region gradient to remain within subrange of full span to improve visual impact in light/dark themes const 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.deepCopy(region.data); if (this.options.unitSystem === 'IP') { // Convert from SI to US units data.forEach(datum => { datum.db = Psychart.CtoF(datum.db); if (datum.measurement === 'dbdp' || datum.measurement === 'dbwb') { datum.other = Psychart.CtoF(datum.other); } }); } this.drawRegion(data, Palette[this.options.colors.regionGradient].getColor(regionIndex, minSpan, maxSpan), region.tooltip); regionIndex++; }); } /** * Interpolate between "corner" psychrometric states. */ interpolate(states, crop) { // Cannot interpolate with only 1 point! if (states.length < 2) { return states; } // Create array containing interpolated points, starting with the first state. const data = [states[0]]; for (let i = 1; i < states.length; i++) { // Determine the start and end states. const start = states[i - 1]; const 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 const startDb = crop ? SMath.clamp(start.db, this.options.dbMin, this.options.dbMax) : start.db; const endDb = crop ? SMath.clamp(end.db, this.options.dbMin, this.options.dbMax) : end.db; const nPoints = Math.abs((startDb - endDb) / this.options.resolution) | 0; const dbRange = SMath.linspace(startDb, endDb, nPoints); // Compute several intermediate states with a step of `resolution` for (const db of dbRange) { data.push(new 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. */ setPathData(path, psystates, closePath) { path.setAttribute('d', 'M ' + psystates.map(psy => { const xy = psy.toXY(); return xy.x + ',' + xy.y; }).join(' ') + (closePath ? ' z' : '')); } /** * Create an SVG circle element. */ createCircle(color, location, radius) { const circle = document.createElementNS(this.NS, 'circle'); const point = location.toXY(); circle.setAttribute('fill', color.toString()); circle.setAttribute('cx', `${point.x}px`); circle.setAttribute('cy', `${point.y}px`); circle.setAttribute('r', `${radius}px`); return circle; } /** * Draw an axis line given an array of psychrometric states. */ drawAxis(data) { this.g.axes.appendChild(this.createLine(data, Color.hex(this.options.colors.axis), 1.0)); } /** * Create a line to append onto a parent element. */ createLine(data, color, weight) { const 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. */ drawLabel(text, location, anchor, rotation, tooltip) { // 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; } } } const fontColor = Color.hex(this.options.colors.font); const label = this.createLabel(text, location.toXY(), fontColor, anchor, rotation); this.g.text.appendChild(label); if (tooltip) { label.addEventListener('mouseover', e => this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, fontColor, this.g.tooltips)); label.addEventListener('mouseleave', () => Chart.clearChildren(this.g.tooltips)); } } /** * Add a line to the legend. */ addToLegend(seriesName, color, gradient) { this.legend.setAttribute('viewBox', '0 0 ' + this.options.size.x + ' ' + this.getLegendHeight()); this.legend.setAttribute('height', this.getLegendHeight() + 'px'); const 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) { const uniqueGradientID = 'psy_' + this.id + '_grad_' + this.legendDefs.children.length; this.legendDefs.appendChild(Palette[gradient].toSVG(uniqueGradientID)); icon.setAttribute('fill', 'url(#' + uniqueGradientID + ')'); } else { throw new Error('Error in ' + seriesName + '. Must have color or gradient defined.'); } const fontColor = Color.hex(this.options.colors.font); const legendText = this.createLabel(seriesName, { x: this.options.font.size * 1.5, y: this.getLegendHeight() - this.options.font.size }, fontColor, 8 /* TextAnchor.W */, 0); g.addEventListener('click', () => { 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. */ getLegendHeight() { return (this.legendg.children.length + 2.5) * this.options.font.size * this.options.lineHeight; } /** * Plot one psychrometric state onto the psychrometric chart. */ plot(state, config = {}) { // Skip series that are missing a measurement point. if (!Number.isFinite(state.db) || !Number.isFinite(state.other)) { return; } // Set default data options. const options = Chart.setDefaults(config, defaultDataOptions); // Determine whether this is time-dependent. const 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; } const currentState = new PsyState(state, this.options), location = currentState.toXY(); // Compute the current color to plot const 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 ? Palette[options.gradient].getColor(tNow, tMin, tMax) : Color.hex(options.color); // Define a circle element and assign its attributes. const point = this.createCircle(color, currentState, options.pointRadius); // Determine whether to draw a line from another point. let lineFrom = null; // Options for data series: const seriesName = options.showId ? `[#${++this.pointId}] ${options.name}` : options.name; if (options.name) { // Add an item in the legend, if not previously added. if (!this.series[seriesName]) { this.series[seriesName] = { 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[seriesName].pointGroup); this.g.trends.appendChild(this.series[seriesName].lineGroup); if (options.legend) { this.addToLegend(seriesName, timeSeries ? undefined : color, timeSeries ? options.gradient : undefined); } } else if (options.line === true) { // Determine whether to connect the states with a line lineFrom = this.series[seriesName].lastState; } // Store the last state in order to draw a line. this.series[seriesName].lastState = currentState; // Plot the new data point onto the series group element. this.series[seriesName].pointGroup.appendChild(point); // Create an ID label for the point if the ID has been assigned. if (options.showId) { const anchors = { I: 7 /* TextAnchor.SW */, II: 5 /* TextAnchor.SE */, III: 3 /* TextAnchor.NE */, IV: 1 /* TextAnchor.NW */, }; const idLabel = super.createLabel(this.pointId.toString(), location, color, anchors[options.idPlacement], 0); this.series[seriesName].pointGroup.appendChild(idLabel); } } 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') { // Divide by 100 if relHumType is set to 'percent' if (options.line.measurement === 'dbrh' && options.relHumType === 'percent') { options.line.other /= 100; } lineFrom = new PsyState(options.line, this.options); } // Draw a line. if (lineFrom) { const line = this.createLine(this.interpolate([lineFrom, currentState], true), color, options.pointRadius / 2); if (seriesName) { this.series[seriesName].lineGroup.appendChild(line); } else { this.g.trends.appendChild(line); } } // Generate the text to display on mouse hover. const 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', e => this.drawTooltip(tooltipString, { x: e.offsetX, y: e.offsetY }, color, this.g.tooltips)); point.addEventListener('mouseleave', () => Chart.clearChildren(this.g.tooltips)); point.addEventListener('click', e => { e.stopPropagation(); // Stop the base <svg> from capturing this event const highlightColor = Color.hex(this.options.colors.highlight); const highlight = this.createCircle(highlightColor, currentState, options.pointRadius * 2); this.g.hilites.appendChild(highlight); }); } /** * Draw a shaded region on Psychart. */ drawRegion(data, color, tooltip) { // Interpolate to get a set of psychrometric states that make the border of the region const states = this.interpolate(data.map(datum => new PsyState(datum, this.options)), false); // Create the SVG element to render the shaded region const 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', e => this.drawTooltip(tooltip, { x: e.offsetX, y: e.offsetY }, color, this.g.tooltips)); region.addEventListener('mouseleave', () => Chart.clearChildren(this.g.tooltips)); } } /** * Clear all plotted data from Psychart. */ clearData() { this.pointId = 0; this.series = {}; Chart.clearChildren(this.g.points); Chart.clearChildren(this.g.trends); Chart.clearChildren(this.g.hilites); Chart.clearChildren(this.legendDefs); Chart.clearChildren(this.legendg); } /** * Calculate the (x,y) coordinates for any state. * Note: Relative humidity must be in range [0.0-1.0] */ getXY(state) { return new PsyState(state, this.options).toXY(); } }