Files
coriolis/src/app/components/PowerBands.jsx
2018-10-05 00:06:33 +01:00

281 lines
9.5 KiB
JavaScript

import React from 'react';
import PropTypes from 'prop-types';
import * as d3 from 'd3';
import cn from 'classnames';
import TranslatedComponent from './TranslatedComponent';
import { wrapCtxMenu } from '../utils/UtilityFunctions';
/**
* Round to avoid floating point precision errors
* @param {Boolean} selected Band selected
* @param {number} sum Band power sum
* @param {number} avail Total available power
* @return {string} CSS Class name
*/
function getClass(selected, sum, avail) {
return selected ? 'secondary' : ((Math.round(sum * 100) / 100) >= avail) ? 'warning' : 'primary';
}
/**
* Get the # label for a Priority band
* @param {number} val Priority Band Watt value
* @param {number} index Priority Band index
* @param {Function} wattScale Watt Scale function
* @return {number} label / text
*/
function bandText(val, index, wattScale) {
return (val > 0 && wattScale(val) > 13) ? index + 1 : null;
}
/**
* Power Bands Component
* Renders the SVG to simulate in-game power bands
*/
export default class PowerBands extends TranslatedComponent {
static propTypes = {
bands: PropTypes.array.isRequired,
available: PropTypes.number.isRequired,
width: PropTypes.number.isRequired,
code: PropTypes.string,
};
/**
* Constructor
* @param {Object} props React Component properties
* @param {Object} context React Component context
*/
constructor(props, context) {
super(props);
this.wattScale = d3.scaleLinear();
this.pctScale = d3.scaleLinear().domain([0, 1]);
this.wattAxis = d3.axisTop(this.wattScale).tickSizeOuter(0).tickFormat(context.language.formats.r2);
this.pctAxis = d3.axisBottom(this.pctScale).tickSizeOuter(0).tickFormat(context.language.formats.rPct);
this._updateDimensions = this._updateDimensions.bind(this);
this._updateScales = this._updateScales.bind(this);
this._selectNone = this._selectNone.bind(this);
this._hidetip = () => this.context.tooltip();
let maxBand = props.bands[props.bands.length - 1];
this.state = {
maxPwr: Math.max(props.available, maxBand.retractedSum, maxBand.deployedSum),
ret: {},
dep: {}
};
if (props.width) {
this._updateDimensions(props, context.sizeRatio);
}
}
/**
* Update dimensions based on properties and scale
* @param {Object} props React Component properties
* @param {number} scale size ratio / scale
*/
_updateDimensions(props, scale) {
let barHeight = Math.round(20 * scale);
let innerHeight = (barHeight * 2) + 2;
let mTop = Math.round(25 * scale);
let mBottom = Math.round(25 * scale);
let mLeft = Math.round(45 * scale);
let mRight = Math.round(140 * scale);
let innerWidth = props.width - mLeft - mRight;
this._updateScales(innerWidth, this.state.maxPwr, props.available);
this.setState({
barHeight,
innerHeight,
mTop,
mBottom,
mLeft,
mRight,
innerWidth,
height: innerHeight + mTop + mBottom,
retY: (barHeight / 2),
depY: (barHeight * 1.5) - 1
});
}
/**
* Select no bands
*/
_selectNone() {
this.setState({
ret : {},
dep: {}
});
}
/**
* Select a retracted band
* @param {number} index Band index
*/
_selectRet(index) {
let ret = this.state.ret;
if(ret[index]) {
delete ret[index];
} else {
ret[index] = 1;
}
this.setState({ ret: Object.assign({}, ret) });
}
/**
* Select a deployed band
* @param {number} index Band index
*/
_selectDep(index) {
let dep = this.state.dep;
if(dep[index]) {
delete dep[index];
} else {
dep[index] = 1;
}
this.setState({ dep: Object.assign({}, dep) });
}
/**
* Update scale
* @param {number} innerWidth SVG innerwidth
* @param {number} maxPwr Maximum power level MJ (deployed or available)
* @param {number} available Available power MJ
*/
_updateScales(innerWidth, maxPwr, available) {
this.wattScale.range([0, innerWidth]).domain([0, maxPwr]).clamp(true);
this.pctScale.range([0, innerWidth]).domain([0, maxPwr / available]).clamp(true);
}
/**
* Update state based on property and context changes
* @param {Object} nextProps Incoming/Next properties
* @param {Object} nextContext Incoming/Next context
*/
componentWillReceiveProps(nextProps, nextContext) {
let { innerWidth, maxPwr } = this.state;
let { language, sizeRatio } = this.context;
let maxBand = nextProps.bands[nextProps.bands.length - 1];
let nextMaxPwr = Math.max(nextProps.available, maxBand.retractedSum, maxBand.deployedSum);
if (language !== nextContext.language) {
this.wattAxis.tickFormat(nextContext.language.formats.r2);
this.pctAxis.tickFormat(nextContext.language.formats.rPct);
}
if (maxPwr != nextMaxPwr) { // Update Axes if max power has changed
this._updateScales(innerWidth, nextMaxPwr, nextProps.available);
this.setState({ maxPwr: nextMaxPwr });
} else if (nextProps.width != this.props.width || sizeRatio != nextContext.sizeRatio) {
this._updateDimensions(nextProps, nextContext.sizeRatio);
}
}
/**
* Render the power bands
* @return {React.Component} Power bands
*/
render() {
if (!this.props.width) {
return null;
}
let { wattScale, pctScale, context, props, state } = this;
let { translate, formats } = context.language;
let { f2, pct1 } = formats; // wattFmt, pctFmt
let { available, bands } = props;
let { innerWidth, ret, dep } = state;
let pwrWarningClass = cn('threshold', { exceeded: bands[0].retractedSum > available * 0.4 });
let deployed = [];
let retracted = [];
let retSelected = Object.keys(ret).length > 0;
let depSelected = Object.keys(dep).length > 0;
let retSum = 0;
let depSum = 0;
for (let i = 0; i < bands.length; i++) {
let b = bands[i];
retSum += (!retSelected || ret[i]) ? b.retracted : 0;
depSum += (!depSelected || dep[i]) ? b.deployed + b.retracted : 0;
if (b.retracted > 0) {
let retLbl = bandText(b.retracted, i, wattScale);
retracted.push(<rect
key={'rB' + i}
width={Math.ceil(Math.max(wattScale(b.retracted), 0))}
height={state.barHeight}
x={Math.floor(Math.max(wattScale(b.retractedSum) - wattScale(b.retracted), 0))}
y={1}
onClick={this._selectRet.bind(this, i)}
className={getClass(ret[i], b.retractedSum, available)}
/>);
if (retLbl) {
retracted.push(<text
key={'rT' + i}
dy='0.5em'
textAnchor='middle'
height={state.barHeight}
x={wattScale(b.retractedSum) - (wattScale(b.retracted) / 2)}
y={state.retY}
onClick={this._selectRet.bind(this, i)}
className='primary-bg'>{retLbl}</text>
);
}
}
if (b.retracted > 0 || b.deployed > 0) {
let depLbl = bandText(b.deployed + b.retracted, i, wattScale);
deployed.push(<rect
key={'dB' + i}
width={Math.ceil(Math.max(wattScale(b.deployed + b.retracted), 0))}
height={state.barHeight}
x={Math.floor(Math.max(wattScale(b.deployedSum) - wattScale(b.retracted) - wattScale(b.deployed), 0))}
y={state.barHeight + 1}
onClick={this._selectDep.bind(this, i)}
className={getClass(dep[i], b.deployedSum, available)}
/>);
if (depLbl) {
deployed.push(<text
key={'dT' + i}
dy='0.5em'
textAnchor='middle'
height={state.barHeight}
x={wattScale(b.deployedSum) - ((wattScale(b.retracted) + wattScale(b.deployed)) / 2)}
y={state.depY}
onClick={this._selectDep.bind(this, i)}
className='primary-bg'>{depLbl}</text>
);
}
}
}
return (
<svg style={{ marginTop: '1em', width: '100%', height: state.height }} onContextMenu={wrapCtxMenu(this._selectNone)}>
<g transform={`translate(${state.mLeft},${state.mTop})`}>
<g className='power-band'>{retracted}</g>
<g className='power-band'>{deployed}</g>
<g ref={ (elem) => d3.select(elem).call(this.wattAxis) } className='watt axis'></g>
<g ref={ (elem) => {
let axis = d3.select(elem);
axis.call(this.pctAxis);
axis.select('g:nth-child(6)').selectAll('line, text').attr('class', pwrWarningClass);
}} className='pct axis' transform={`translate(0,${state.innerHeight})`}></g>
<line x1={pctScale(0.4)} x2={pctScale(0.4)} y1='0' y2={state.innerHeight} className={pwrWarningClass} />
<text dy='0.5em' x='-3' y={state.retY} className='primary upp' textAnchor='end' onMouseOver={this.context.termtip.bind(null, 'retracted')} onMouseLeave={this._hidetip}>{translate('ret')}</text>
<text dy='0.5em' x='-3' y={state.depY} className='primary upp' textAnchor='end' onMouseOver={this.context.termtip.bind(null, 'deployed', { orientation: 's', cap: 1 })} onMouseLeave={this._hidetip}>{translate('dep')}</text>
<text dy='0.5em' x={innerWidth + 5} y={state.retY} className={getClass(retSelected, retSum, available)}>{f2(Math.max(0, retSum))} ({pct1(Math.max(0, retSum / available))})</text>
<text dy='0.5em' x={innerWidth + 5} y={state.depY} className={getClass(depSelected, depSum, available)}>{f2(Math.max(0, depSum))} ({pct1(Math.max(0, depSum / available))})</text>
</g>
</svg>
);
}
}