diff --git a/components/chart/chart-legend.js b/components/chart/chart-legend.js new file mode 100644 index 0000000000000000000000000000000000000000..df6f8a60f77f36e6fb648ed2369cad9af5d0ebfa --- /dev/null +++ b/components/chart/chart-legend.js @@ -0,0 +1,27 @@ +import React from 'react' +import { View } from 'react-native' + +import AppText from '../app-text' +import DripHomeIcon from '../../assets/drip-home-icons' + +import styles from './styles' +import { cycleDayColor } from '../../styles' + +import { shared as labels } from '../../i18n/en/labels' + +const ChartLegend = () => { + return ( + <View style={[styles.yAxis, styles.chartLegend]}> + <DripHomeIcon + name="circle" + size={styles.yAxis.width - 7} + color={cycleDayColor} + /> + <AppText style={styles.yAxisLabels.dateLabel}> + {labels.date.toLowerCase()} + </AppText> + </View> + ) +} + +export default ChartLegend diff --git a/components/chart/chart.js b/components/chart/chart.js index 4106938772caff868af0cfe819e23e630ccf8a7c..8f8a3f5d37018b81edc4ccfa9d9c0c1b58f06ce0 100644 --- a/components/chart/chart.js +++ b/components/chart/chart.js @@ -1,31 +1,20 @@ import React, { Component } from 'react' import { View, FlatList, ActivityIndicator } from 'react-native' import { LocalDate } from 'js-joda' -import { makeYAxisLabels, makeHorizontalGrid } from './y-axis' + +import YAxis from './y-axis' import nfpLines from './nfp-lines' import DayColumn from './day-column' +import HorizontalGrid from './horizontal-grid' + import { getCycleDaysSortedByDate, getAmountOfCycleDays } from '../../db' import styles from './styles' -import { cycleDayColor } from '../../styles' import { scaleObservable } from '../../local-storage' import config from '../../config' -import AppText from '../app-text' + import AppLoadingView from '../app-loading' -import { shared as labels } from '../../i18n/en/labels' -import DripIcon from '../../assets/drip-icons' -import DripHomeIcon from '../../assets/drip-home-icons' -import nothingChanged from '../../db/db-unchanged' -const symptomIcons = { - bleeding: <DripIcon size={16} name='drip-icon-bleeding' color={styles.iconShades.bleeding[3]}/>, - mucus: <DripIcon size={16} name='drip-icon-mucus' color={styles.iconShades.mucus[4]}/>, - cervix: <DripIcon size={16} name='drip-icon-cervix' color={styles.iconShades.cervix[3]}/>, - desire: <DripIcon size={16} name='drip-icon-desire' color={styles.iconShades.desire[2]}/>, - sex: <DripIcon size={16} name='drip-icon-sex' color={styles.iconShades.sex[2]}/>, - pain: <DripIcon size={16} name='drip-icon-pain' color={styles.iconShades.pain[0]}/>, - mood: <DripIcon size={16} name='drip-icon-mood' color={styles.iconShades.mood[0]}/>, - note: <DripIcon size={16} name='drip-icon-note' color={styles.iconShades.note[0]}/> -} +import nothingChanged from '../../db/db-unchanged' export default class CycleChart extends Component { constructor(props) { @@ -129,49 +118,30 @@ export default class CycleChart extends Component { } render() { + const { chartHeight, chartLoaded } = this.state return ( <View onLayout={this.onLayout} style={{ flexDirection: 'row', flex: 1 }} > - {!this.state.chartLoaded && <AppLoadingView />} - - {this.state.chartHeight && this.state.chartLoaded && - <View> - <View style={[styles.yAxis, {height: this.symptomRowHeight}]}> - {this.symptomRowSymptoms.map(symptomName => { - return <View - style={{ alignItems: 'center', justifyContent: 'center' }} - key={symptomName} - width={styles.yAxis.width} - height={this.symptomRowHeight / - this.symptomRowSymptoms.length} - > - {symptomIcons[symptomName]} - </View> - })} - </View> - <View style={[styles.yAxis, {height: this.columnHeight}]}> - {makeYAxisLabels(this.columnHeight)} - </View> - <View style={[styles.yAxis, { alignItems: 'center', justifyContent: 'center' }]}> - <DripHomeIcon - name="circle" - size={styles.yAxis.width - 7} - color={cycleDayColor} - /> - <AppText style={[styles.yAxisLabels.dateLabel]}> - {labels.date.toLowerCase()} - </AppText> - </View> - </View>} - - - {this.state.chartHeight && this.state.chartLoaded && - makeHorizontalGrid(this.columnHeight, this.symptomRowHeight) + {!chartLoaded && <AppLoadingView />} + + {chartHeight && chartLoaded && ( + <YAxis + height={this.columnHeight} + symptomsToDisplay={this.symptomRowSymptoms} + symptomsSectionHeight={this.symptomRowHeight} + /> + )} + + {chartHeight && chartLoaded && ( + <HorizontalGrid + height={this.columnHeight} + startPosition={this.symptomRowHeight} + />) } - {this.state.chartHeight && + {chartHeight && <FlatList horizontal={true} inverted={true} diff --git a/components/chart/day-column.js b/components/chart/day-column.js index c49f9dbf8bac752c999477c71ccba832f8e0e69c..1597697fc14e5d042f0a2dfb9ea27b4199907277 100644 --- a/components/chart/day-column.js +++ b/components/chart/day-column.js @@ -18,8 +18,11 @@ import styles from './styles' import config from '../../config' import cycleModule from '../../lib/cycle' import { getCycleDay } from '../../db' + import DotAndLine from './dot-and-line' -import { normalizeToScale } from './y-axis' +import SymptomCell from './symptom-cell' + +import { normalizeToScale } from '../helpers/chart' const label = styles.column.label @@ -139,41 +142,6 @@ class DayColumn extends Component { return false } - drawSymptom = (symptom) => { - - const { symptomHeight } = this.props - const shouldDrawSymptom = this.data.hasOwnProperty(symptom) - const styleParent = [styles.symptomRow, {height: symptomHeight}] - - if (shouldDrawSymptom) { - const styleSymptom = styles.iconShades[symptom] - const symptomData = this.data[symptom] - - const dataIsComplete = this.isSymptomDataComplete(symptom) - const isMucusOrCervix = (symptom === 'mucus') || (symptom === 'cervix') - - const backgroundColor = (isMucusOrCervix && !dataIsComplete) ? - 'white' : styleSymptom[symptomData] - const borderWidth = (isMucusOrCervix && !dataIsComplete) ? 2 : 0 - const borderColor = styleSymptom[0] - const styleChild = [styles.symptomIcon, { - backgroundColor, - borderColor, - borderWidth - }] - - return ( - <View style={styleParent} key={symptom}> - <View style={styleChild} /> - </View> - ) - } else { - return ( - <View style={styleParent} key={symptom} /> - ) - } - } - render() { const columnElements = [] const { dateString, @@ -263,9 +231,21 @@ class DayColumn extends Component { onPress={() => this.onDaySelect(dateString)} activeOpacity={1} > - <View> - {symptomRowSymptoms.map(symptom => this.drawSymptom(symptom))} - </View> + + { symptomRowSymptoms.map(symptom => { + const hasSymptomData = this.data.hasOwnProperty(symptom) + return ( + <SymptomCell + key={symptom} + symptom={symptom} + symptomValue={hasSymptomData && this.data[symptom]} + isSymptomDataComplete={ + hasSymptomData && this.isSymptomDataComplete(symptom) + } + height={this.props.symptomHeight} + />) + } + )} <Surface width={config.columnWidth} height={columnHeight}> {column} diff --git a/components/chart/horizontal-grid.js b/components/chart/horizontal-grid.js new file mode 100644 index 0000000000000000000000000000000000000000..e7cf7cc1b7fe7ee6f6d1cd72f60d998028121a35 --- /dev/null +++ b/components/chart/horizontal-grid.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { View } from 'react-native' + +import { getTickPositions } from '../helpers/chart' + +import styles from './styles' + +const HorizontalGrid = ({ height, startPosition }) => { + return getTickPositions(height).map(tick => { + return ( + <View + top={startPosition + tick} + {...styles.horizontalGrid} + key={tick} + /> + ) + }) +} + +HorizontalGrid.propTypes = { + height: PropTypes.number, + startPosition: PropTypes.number, +} + +export default HorizontalGrid diff --git a/components/chart/nfp-lines.js b/components/chart/nfp-lines.js index cf73c52234c75521369ce6a7dda7cd2fcf778231..853dde54f634dc27c330be9d05a3fbf31b88a977 100644 --- a/components/chart/nfp-lines.js +++ b/components/chart/nfp-lines.js @@ -1,5 +1,5 @@ import { getCycleStatusForDay } from '../../lib/sympto-adapter' -import { normalizeToScale } from './y-axis' +import { normalizeToScale } from '../helpers/chart' export default function () { const cycle = { diff --git a/components/chart/styles.js b/components/chart/styles.js index d2a95d70367f227c0d1257a9caa30de3d0fec054..dc3a69be97429e7cdf93b590ca0c01a28003e234 100644 --- a/components/chart/styles.js +++ b/components/chart/styles.js @@ -11,6 +11,19 @@ const gridLineWidthVertical = 0.6 const gridLineWidthHorizontal = 0.3 const numberLabelFontSize = 13 +const redColor = '#c3000d' +const violetColor = '#7689a9' +const shadesOfViolet = ['#e3e7ed', '#c8cfdc', '#acb8cb', '#91a0ba', violetColor] // light to dark +const yellowColor = '#dbb40c' +const shadesOfYellow = ['#f0e19d', '#e9d26d', '#e2c33c', yellowColor] // light to dark +const magentaColor = '#6f2565' +const shadesOfMagenta = ['#a87ca2', '#8b5083', magentaColor] // light to dark +const pinkColor = '#9e346c' +const shadesOfPink = ['#c485a6', '#b15c89', pinkColor] // light to dark +const lightGreenColor = '#bccd67' +const orangeColor = '#bc6642' +const mintColor = '#6ca299' + const styles = { curve: { stroke: colorTemperature, @@ -48,39 +61,44 @@ const styles = { width: gridLineWidthVertical, } }, - symptomIcon: { + symptomDot: { width: 12, height: 12, borderRadius: 50, }, - iconShades: { - 'bleeding': shadesOfRed, - 'mucus': [ - '#e3e7ed', - '#c8cfdc', - '#acb8cb', - '#91a0ba', - '#7689a9' - ], - 'cervix': [ - '#f0e19d', - '#e9d26d', - '#e2c33c', - '#dbb40c', - ], - 'sex': [ - '#a87ca2', - '#8b5083', - '#6f2565', - ], - 'desire': [ - '#c485a6', - '#b15c89', - '#9e346c', - ], - 'pain': ['#bccd67'], - 'mood': ['#bc6642'], - 'note': ['#6ca299'] + iconColors: { + 'bleeding': { + color: redColor, + shades: shadesOfRed, + }, + 'mucus': { + color: violetColor, + shades: shadesOfViolet, + }, + 'cervix': { + color: yellowColor, + shades: shadesOfYellow, + }, + 'sex': { + color: magentaColor, + shades: shadesOfMagenta, + }, + 'desire': { + color: pinkColor, + shades: shadesOfPink, + }, + 'pain': { + color: lightGreenColor, + shades: [lightGreenColor], + }, + 'mood': { + color: orangeColor, + shades: [orangeColor], + }, + 'note': { + color: mintColor, + shades: [mintColor], + }, }, yAxis: { width: 27, @@ -109,6 +127,18 @@ const styles = { fontWeight: '100', } }, + symptomIcon: { + alignItems: 'center', + justifyContent: 'center', + }, + chartLegend: { + alignItems: 'center', + justifyContent: 'center', + }, + boldTick: { + fontWeight: 'bold', + fontSize: 11, + }, horizontalGrid: { position:'absolute', borderStyle: 'solid', diff --git a/components/chart/symptom-cell.js b/components/chart/symptom-cell.js new file mode 100644 index 0000000000000000000000000000000000000000..fa760422235383ed9a1ed753d549f5482f470a36 --- /dev/null +++ b/components/chart/symptom-cell.js @@ -0,0 +1,52 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { View } from 'react-native' + +import styles from './styles' + +const SymptomCell = ({ + height, + symptom, + symptomValue, + isSymptomDataComplete +}) => { + + const shouldDrawDot = symptomValue !== false + const styleParent = [styles.symptomRow, { height }] + let styleChild + + if (shouldDrawDot) { + const styleSymptom = styles.iconColors[symptom] + const symptomColor = styleSymptom.shades[symptomValue] + + const isMucusOrCervix = (symptom === 'mucus') || (symptom === 'cervix') + + const backgroundColor = (isMucusOrCervix && !isSymptomDataComplete) ? + 'white' : symptomColor + const borderWidth = (isMucusOrCervix && !isSymptomDataComplete) ? 2 : 0 + const borderColor = symptomColor + styleChild = [styles.symptomDot, { + backgroundColor, + borderColor, + borderWidth + }] + } + + return ( + <View style={styleParent} key={symptom}> + {shouldDrawDot && <View style={styleChild} />} + </View> + ) +} + +SymptomCell.propTypes = { + height: PropTypes.number, + symptom: PropTypes.string, + symptomValue: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.number, + ]), + isSymptomDataComplete: PropTypes.bool, +} + +export default SymptomCell diff --git a/components/chart/symptom-icon.js b/components/chart/symptom-icon.js new file mode 100644 index 0000000000000000000000000000000000000000..3236c3b044009c998bff77c8f065beb769aa20e2 --- /dev/null +++ b/components/chart/symptom-icon.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { View } from 'react-native' + +import DripIcon from '../../assets/drip-icons' + +import styles from './styles' + +const SymptomIcon = ({ symptom, height }) => { + return ( + <View style={styles.symptomIcon} width={styles.yAxis.width} height={height}> + <DripIcon + size={16} + name={`drip-icon-${symptom}`} + color={styles.iconColors[symptom].color} + /> + </View> + ) +} + +SymptomIcon.propTypes = { + height: PropTypes.number, + symptom: PropTypes.string, +} + +export default SymptomIcon diff --git a/components/chart/tick-list.js b/components/chart/tick-list.js new file mode 100644 index 0000000000000000000000000000000000000000..16fba0af4065148a95ae0d743748d25e15e42cf7 --- /dev/null +++ b/components/chart/tick-list.js @@ -0,0 +1,34 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { View } from 'react-native' + +import Tick from './tick' + +import { getTickList } from '../helpers/chart' + +import styles from './styles' + +const TickList = ({ height }) => { + return ( + <View style={[styles.yAxis, { height }]}>{ + getTickList(height) + .map(({ label, position, isBold, shouldShowLabel}) => { + return ( + <Tick + key={label} + yPosition={position} + isBold={isBold} + shouldShowLabel={shouldShowLabel} + label={label} + /> + ) + }) + }</View> + ) +} + +TickList.propTypes = { + height: PropTypes.number, +} + +export default TickList diff --git a/components/chart/tick.js b/components/chart/tick.js new file mode 100644 index 0000000000000000000000000000000000000000..173cfbe6036f47a1005b3fe6f9fa8f88cd3f6a40 --- /dev/null +++ b/components/chart/tick.js @@ -0,0 +1,29 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import AppText from '../app-text' + +import styles from './styles' + +const Tick = ({ yPosition, isBold, shouldShowLabel, label }) => { + // this eyeballing is sadly necessary because RN does not + // support percentage values for transforms, which we'd need + // to reliably place the label vertically centered to the grid + const topPosition = yPosition - 8 + const style = [ + styles.yAxisLabels.tempScale, + {top: topPosition}, + isBold && styles.boldTick + ] + + return <AppText style={style}>{shouldShowLabel && label}</AppText> +} + +Tick.propTypes = { + yPosition: PropTypes.number, + isBold: PropTypes.bool, + shouldShowLabel: PropTypes.bool, + label: PropTypes.string, +} + +export default Tick diff --git a/components/chart/y-axis.js b/components/chart/y-axis.js index 1f337368db7253856d74a2bdd44b529000070650..acd3ccf75e8b3594cf096997ae54cc918bd69742 100644 --- a/components/chart/y-axis.js +++ b/components/chart/y-axis.js @@ -1,75 +1,37 @@ import React from 'react' +import PropTypes from 'prop-types' import { View } from 'react-native' -import config from '../../config' -import styles from './styles' -import { scaleObservable, unitObservable } from '../../local-storage' -import AppText from '../app-text' - -export function makeYAxisLabels(columnHeight) { - const units = unitObservable.value - const scaleMax = scaleObservable.value.max - const style = styles.yAxisLabels.tempScale - return getTickPositions(columnHeight).map((y, i) => { - const tick = scaleMax - i * units - const tickLabel = tick * 10 % 10 ? tick.toString() : tick.toString() + '.0' - let showTick - let tickBold - if (units === 0.1) { - showTick = (tick * 10 % 2) ? false : true - tickBold = tick * 10 % 5 ? {} : {fontWeight: 'bold', fontSize: 11} - } else { - showTick = (tick * 10 % 5) ? false : true - tickBold = tick * 10 % 10 ? {} : {fontWeight: 'bold', fontSize: 11} - } - // this eyeballing is sadly necessary because RN does not - // support percentage values for transforms, which we'd need - // to reliably place the label vertically centered to the grid - return ( - <AppText - style={[style, {top: y - 8}, tickBold]} - key={i}> - {showTick && tickLabel} - </AppText> - ) - }) -} +import SymptomIcon from './symptom-icon' +import TickList from './tick-list' +import ChartLegend from './chart-legend' -export function makeHorizontalGrid(columnHeight, symptomRowHeight) { - return getTickPositions(columnHeight).map(tick => { - return ( - <View - top={tick + symptomRowHeight} - {...styles.horizontalGrid} - key={tick} - /> - ) - }) -} +import styles from './styles' -function getTickPositions(columnHeight) { - const units = unitObservable.value - const scaleMin = scaleObservable.value.min - const scaleMax = scaleObservable.value.max - const numberOfTicks = (scaleMax - scaleMin) * (1 / units) + 1 - const tickDistance = 1 / (numberOfTicks - 1) - const tickPositions = [] - for (let i = 0; i < numberOfTicks; i++) { - const position = getAbsoluteValue(tickDistance * i, columnHeight) - tickPositions.push(position) - } - return tickPositions +const YAxis = ({ height, symptomsToDisplay, symptomsSectionHeight }) => { + const symptomIconHeight = symptomsSectionHeight / symptomsToDisplay.length + return ( + <View> + <View style={[styles.yAxis, {height: symptomsSectionHeight}]}> + {symptomsToDisplay.map(symptom => ( + <SymptomIcon + key={symptom} + symptom={symptom} + height={symptomIconHeight} + /> + ) + )} + </View> + <TickList height={height} /> + <ChartLegend /> + </View> + ) } -export function normalizeToScale(temp, columnHeight) { - const scale = scaleObservable.value - const valueRelativeToScale = (scale.max - temp) / (scale.max - scale.min) - return getAbsoluteValue(valueRelativeToScale, columnHeight) +YAxis.propTypes = { + height: PropTypes.number, + symptomsToDisplay: PropTypes.array, + symptomsSectionHeight: PropTypes.number, } -function getAbsoluteValue(relative, columnHeight) { - // we add some height to have some breathing room - const verticalPadding = columnHeight * config.temperatureScale.verticalPadding - const scaleHeight = columnHeight - 2 * verticalPadding - return scaleHeight * relative + verticalPadding -} \ No newline at end of file +export default YAxis diff --git a/components/helpers/chart.js b/components/helpers/chart.js new file mode 100644 index 0000000000000000000000000000000000000000..24a563cb1ab291bd0606a9b1ff813293f2d12fde --- /dev/null +++ b/components/helpers/chart.js @@ -0,0 +1,67 @@ +import { scaleObservable, unitObservable } from '../../local-storage' +import config from '../../config' + +export function normalizeToScale(temp, columnHeight) { + const scale = scaleObservable.value + const valueRelativeToScale = (scale.max - temp) / (scale.max - scale.min) + return getAbsoluteValue(valueRelativeToScale, columnHeight) +} + +function getAbsoluteValue(relative, columnHeight) { + // we add some height to have some breathing room + const verticalPadding = columnHeight * config.temperatureScale.verticalPadding + const scaleHeight = columnHeight - 2 * verticalPadding + return scaleHeight * relative + verticalPadding +} + +export function getTickPositions(columnHeight) { + const units = unitObservable.value + const scaleMin = scaleObservable.value.min + const scaleMax = scaleObservable.value.max + const numberOfTicks = (scaleMax - scaleMin) * (1 / units) + 1 + const tickDistance = 1 / (numberOfTicks - 1) + const tickPositions = [] + for (let i = 0; i < numberOfTicks; i++) { + const position = getAbsoluteValue(tickDistance * i, columnHeight) + tickPositions.push(position) + } + return tickPositions +} + +export function getTickList(columnHeight) { + + const units = unitObservable.value + const scaleMax = scaleObservable.value.max + + return getTickPositions(columnHeight).map((tickPosition, i) => { + + const tick = scaleMax - i * units + let isBold, label, shouldShowLabel + + if (Number.isInteger(tick)) { + isBold = true + label = tick.toString() + '.0' + } else { + isBold = false + label = tick.toString() + } + + // when temp range <= 3, units === 0.1 we show temp values with step 0.2 + // when temp range > 3, units === 0.5 we show temp values with step 0.5 + + if (units === 0.1) { + // show label with step 0.2 + shouldShowLabel = !(tick * 10 % 2) + } else { + // show label with step 0.5 + shouldShowLabel = !(tick * 10 % 5) + } + + return { + position: tickPosition, + label, + isBold, + shouldShowLabel, + } + }) +}