diff --git a/components/chart/chart.js b/components/chart/chart.js index 7a90b87c943a9fbdb40375f25db977371068255c..49ddaa1a29c01a805b4fc45dba3116525f5423be 100644 --- a/components/chart/chart.js +++ b/components/chart/chart.js @@ -1,24 +1,14 @@ import React, { Component } from 'react' -import { Text as ReactNativeText, View, FlatList } from 'react-native' +import { View, FlatList } from 'react-native' import range from 'date-range' -import Svg,{ - G, - Rect, - Text, - Circle, - Line, - Path -} from 'react-native-svg' import { LocalDate } from 'js-joda' -import { getCycleDay, getOrCreateCycleDay, cycleDaysSortedByDate } from '../../db' -import cycleModule from '../../lib/cycle' +import { yAxis, normalizeToScale } from './y-axis' +import DayColumn from './day-column' +import { getCycleDay, cycleDaysSortedByDate } from '../../db' import styles from './styles' import config from './config' -import { getCycleStatusForDay } from '../../lib/sympto-adapter' -const getCycleDayNumber = cycleModule().getCycleDayNumber - -const yAxis = makeYAxis(config) +const yAxisView = <View {...styles.yAxis}>{yAxis.labels}</View> export default class CycleChart extends Component { constructor(props) { @@ -40,15 +30,11 @@ export default class CycleChart extends Component { cycleDaysSortedByDate.removeListener(this.reCalculateChartInfo) } - passDateToDayView(dateString) { - const cycleDay = getOrCreateCycleDay(dateString) - this.props.navigation.navigate('cycleDay', { cycleDay }) - } render() { return ( <View style={{flexDirection: 'row'}}> - <View {...styles.yAxis}>{yAxis.labels}</View> + { yAxisView } <FlatList horizontal={true} inverted={true} @@ -62,11 +48,12 @@ export default class CycleChart extends Component { index={index} rightNeighbor = { index > 0 ? cols[index - 1] : undefined } leftNeighbor = {index < cols.length - 1 ? cols[index + 1] : undefined } + navigate={this.props.navigation.navigate} /> ) }} keyExtractor={item => item.dateString} - initialNumToRender={20} + initialNumToRender={15} > </FlatList> </View> @@ -74,137 +61,6 @@ export default class CycleChart extends Component { } } -class DayColumn extends Component { - makeDayColumn({ dateString, cycleDay, y }, index) { - const cycleDayNumber = getCycleDayNumber(dateString) - const label = styles.column.label - const dateLabel = dateString.split('-').slice(1).join('-') - const getFhmAndLtlInfo = setUpFertilityStatusFunc() - const nfpLineInfo = getFhmAndLtlInfo(dateString, cycleDay) - - return ( - <G onPress={() => this.passDateToDayView(dateString)}> - <Rect {...styles.column.rect} /> - {nfpLineInfo.drawFhmLine ? - <Line - x1={0 + styles.nfpLine.strokeWidth / 2} - y1="20" - x2={0 + styles.nfpLine.strokeWidth / 2} - y2={config.chartHeight - 20} - {...styles.nfpLine} - /> : null} - - {this.placeHorizontalGrid()} - - <Text {...label.number} y={config.cycleDayNumberRowY}> - {cycleDayNumber} - </Text> - <Text {...label.date} y={config.dateRowY}> - {dateLabel} - </Text> - - {cycleDay && cycleDay.bleeding ? - <Path {...styles.bleedingIcon} - d="M15 3 - Q16.5 6.8 25 18 - A12.8 12.8 0 1 1 5 18 - Q13.5 6.8 15 3z" /> - : null} - - {nfpLineInfo.drawLtlAt ? - <Line - x1="0" - y1={nfpLineInfo.drawLtlAt} - x2={config.columnWidth} - y2={nfpLineInfo.drawLtlAt} - {...styles.nfpLine} - /> : null} - - {y ? - this.drawDotAndLines(y, cycleDay.temperature.exclude, index) - : null - } - {cycleDay && cycleDay.mucus ? - <Circle - {...styles.mucusIcon} - fill={styles.mucusIconShades[cycleDay.mucus.value]} - /> : null} - - {y ? - this.drawDotAndLines(y, cycleDay.temperature.exclude) - : null} - </G> - ) - } - - drawDotAndLines(currY, exclude) { - let lineToRight - let lineToLeft - - function makeLine(otherColY, x, excludeLine) { - const middleY = ((otherColY - currY) / 2) + currY - const target = [x, middleY] - const lineStyle = excludeLine ? styles.curveExcluded : styles.curve - - return <Line - x1={config.columnMiddle} - y1={currY} - x2={target[0]} - y2={target[1]} - {...lineStyle} - /> - } - - const thereIsADotToTheRight = this.props.rightNeighbor && this.props.rightNeighbor.y - const thereIsADotToTheLeft = this.props.leftNeighbor && this.props.leftNeighbor.y - - if (thereIsADotToTheRight) { - const neighbor = this.props.rightNeighbor - const excludedLine = neighbor.cycleDay.temperature.exclude || exclude - lineToRight = makeLine(neighbor.y, config.columnWidth, excludedLine) - } - if (thereIsADotToTheLeft) { - const neighbor = this.props.leftNeighbor - const excludedLine = neighbor.cycleDay.temperature.exclude || exclude - lineToLeft = makeLine(neighbor.y, 0, excludedLine) - } - - const dotStyle = exclude ? styles.curveDotsExcluded : styles.curveDots - return (<G> - {lineToRight} - {lineToLeft} - <Circle - cx={config.columnMiddle} - cy={currY} - {...dotStyle} - /> - </G>) - } - - placeHorizontalGrid() { - return yAxis.tickPositions.map(tick => { - return ( - <Line - x1={0} - y1={tick} - x2={config.columnWidth} - y2={tick} - {...styles.horizontalGrid} - key={tick} - /> - ) - }) - } - - render() { - return ( - <Svg width={config.columnWidth} height={config.chartHeight}> - {this.makeDayColumn(this.props.item, this.props.index)} - </Svg> - ) - } -} - function makeColumnInfo(n) { const xAxisDates = getPreviousDays(n).map(jsDate => { return LocalDate.of( @@ -234,116 +90,4 @@ function getPreviousDays(n) { const earlierDate = new Date(today - (range.DAY * n)) return range(earlierDate, today).reverse() -} - -function normalizeToScale(temp) { - const scale = config.temperatureScale - const valueRelativeToScale = (scale.high - temp) / (scale.high - scale.low) - const scaleHeight = config.chartHeight - return scaleHeight * valueRelativeToScale -} - -function makeYAxis() { - const scaleMin = config.temperatureScale.low - const scaleMax = config.temperatureScale.high - const numberOfTicks = (scaleMax - scaleMin) * 2 - const tickDistance = config.chartHeight / numberOfTicks - - const tickPositions = [] - const labels = [] - // for style reasons, we don't want the first and last tick - for (let i = 1; i < numberOfTicks - 1; i++) { - const y = tickDistance * i - const style = styles.yAxisLabel - // 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 - style.top = y - 8 - labels.push( - <ReactNativeText - style={{...style}} - key={i}> - {scaleMax - i * 0.5} - </ReactNativeText> - ) - tickPositions.push(y) - } - - return {labels, tickPositions} -} - -function setUpFertilityStatusFunc() { - let cycleStatus - let cycleStartDate - let noMoreCycles = false - - function updateCurrentCycle(dateString) { - cycleStatus = getCycleStatusForDay(dateString) - if(!cycleStatus) { - noMoreCycles = true - return - } - if (cycleStatus.phases.preOvulatory) { - cycleStartDate = cycleStatus.phases.preOvulatory.start.date - } else { - cycleStartDate = cycleStatus.phases.periOvulatory.start.date - } - } - - function dateIsInPeriOrPostPhase(dateString) { - return ( - dateString >= cycleStatus.phases.periOvulatory.start.date - ) - } - - function precededByAnotherTempValue(dateString) { - return ( - // we are only interested in days that have a preceding - // temp - Object.keys(cycleStatus.phases).some(phaseName => { - return cycleStatus.phases[phaseName].cycleDays.some(day => { - return day.temperature && day.date < dateString - }) - }) - // and also a following temp, so we don't draw the line - // longer than necessary - && - cycleStatus.phases.postOvulatory.cycleDays.some(day => { - return day.temperature && day.date > dateString - }) - ) - } - - function isInTempMeasuringPhase(cycleDay, dateString) { - return ( - cycleDay && cycleDay.temperature - || precededByAnotherTempValue(dateString) - ) - } - - return function(dateString, cycleDay) { - const ret = {} - if (!cycleStatus && !noMoreCycles) updateCurrentCycle(dateString) - if (noMoreCycles) return ret - - if (dateString < cycleStartDate) updateCurrentCycle(dateString) - if (noMoreCycles) return ret - - const tempShift = cycleStatus.temperatureShift - - if (tempShift) { - if (tempShift.firstHighMeasurementDay.date === dateString) { - ret.drawFhmLine = true - } - - if ( - dateIsInPeriOrPostPhase(dateString) && - isInTempMeasuringPhase(cycleDay, dateString) - ) { - ret.drawLtlAt = normalizeToScale(tempShift.ltl) - } - } - - return ret - } } \ No newline at end of file diff --git a/components/chart/day-column.js b/components/chart/day-column.js new file mode 100644 index 0000000000000000000000000000000000000000..1b9b2f1818362ca3a0761b48d8db46603987fa41 --- /dev/null +++ b/components/chart/day-column.js @@ -0,0 +1,145 @@ +import React, { Component } from 'react' +import Svg,{ + G, + Rect, + Text, + Circle, + Line, + Path +} from 'react-native-svg' +import styles from './styles' +import config from './config' +import { getOrCreateCycleDay } from '../../db' +import cycleModule from '../../lib/cycle' +import setUpFertilityStatusFunc from './nfp-lines' +import { horizontalGrid } from './y-axis' + +const getCycleDayNumber = cycleModule().getCycleDayNumber + +export default class DayColumn extends Component { + makeDayColumn({ dateString, cycleDay, y }, index) { + const cycleDayNumber = getCycleDayNumber(dateString) + const label = styles.column.label + const dateLabel = dateString.split('-').slice(1).join('-') + const getFhmAndLtlInfo = setUpFertilityStatusFunc() + const nfpLineInfo = getFhmAndLtlInfo(dateString, cycleDay) + + return ( + <G onPress={() => this.passDateToDayView(dateString)}> + <Rect {...styles.column.rect} /> + {horizontalGrid} + {nfpLineInfo.drawFhmLine ? + <Line + x1={0 + styles.nfpLine.strokeWidth / 2} + y1="20" + x2={0 + styles.nfpLine.strokeWidth / 2} + y2={config.chartHeight - 20} + {...styles.nfpLine} + /> : null} + + + <Text {...label.number} y={config.cycleDayNumberRowY}> + {cycleDayNumber} + </Text> + <Text {...label.date} y={config.dateRowY}> + {dateLabel} + </Text> + + {cycleDay && cycleDay.bleeding ? + <Path {...styles.bleedingIcon} + d="M15 3 + Q16.5 6.8 25 18 + A12.8 12.8 0 1 1 5 18 + Q13.5 6.8 15 3z" /> + : null} + + {nfpLineInfo.drawLtlAt ? + <Line + x1="0" + y1={nfpLineInfo.drawLtlAt} + x2={config.columnWidth} + y2={nfpLineInfo.drawLtlAt} + {...styles.nfpLine} + /> : null} + + {y ? + this.drawDotAndLines(y, cycleDay.temperature.exclude, index) + : null + } + {cycleDay && cycleDay.mucus ? + <Circle + {...styles.mucusIcon} + fill={styles.mucusIconShades[cycleDay.mucus.value]} + /> : null} + + {y ? + this.drawDotAndLines(y, cycleDay.temperature.exclude) + : null} + </G> + ) + } + + drawDotAndLines(currY, exclude) { + let lineToRight + let lineToLeft + + function makeLine(otherColY, x, excludeLine) { + const middleY = ((otherColY - currY) / 2) + currY + const target = [x, middleY] + const lineStyle = excludeLine ? styles.curveExcluded : styles.curve + + return <Line + x1={config.columnMiddle} + y1={currY} + x2={target[0]} + y2={target[1]} + {...lineStyle} + /> + } + + const thereIsADotToTheRight = this.props.rightNeighbor && this.props.rightNeighbor.y + const thereIsADotToTheLeft = this.props.leftNeighbor && this.props.leftNeighbor.y + + if (thereIsADotToTheRight) { + const neighbor = this.props.rightNeighbor + const excludedLine = neighbor.cycleDay.temperature.exclude || exclude + lineToRight = makeLine(neighbor.y, config.columnWidth, excludedLine) + } + if (thereIsADotToTheLeft) { + const neighbor = this.props.leftNeighbor + const excludedLine = neighbor.cycleDay.temperature.exclude || exclude + lineToLeft = makeLine(neighbor.y, 0, excludedLine) + } + + const dotStyle = exclude ? styles.curveDotsExcluded : styles.curveDots + return (<G> + {lineToRight} + {lineToLeft} + <Circle + cx={config.columnMiddle} + cy={currY} + {...dotStyle} + /> + </G>) + } + + + passDateToDayView(dateString) { + const cycleDay = getOrCreateCycleDay(dateString) + this.props.navigate('cycleDay', { cycleDay }) + } + + shouldComponentUpdate() { + // for now, until we've solved the mysterious re-rendering + return false + } + + render() { + console.log(this.props.index) + return ( + <Svg width={config.columnWidth} height={config.chartHeight}> + {this.makeDayColumn(this.props.item, this.props.index)} + </Svg> + ) + } +} \ No newline at end of file diff --git a/components/chart/nfp-lines.js b/components/chart/nfp-lines.js new file mode 100644 index 0000000000000000000000000000000000000000..e091597be3a4c64640774e98efcaf68cc917e3ad --- /dev/null +++ b/components/chart/nfp-lines.js @@ -0,0 +1,78 @@ +import { getCycleStatusForDay } from '../../lib/sympto-adapter' +import { normalizeToScale } from './y-axis' + +export default function () { + let cycleStatus + let cycleStartDate + let noMoreCycles = false + + function updateCurrentCycle(dateString) { + cycleStatus = getCycleStatusForDay(dateString) + if(!cycleStatus) { + noMoreCycles = true + return + } + if (cycleStatus.phases.preOvulatory) { + cycleStartDate = cycleStatus.phases.preOvulatory.start.date + } else { + cycleStartDate = cycleStatus.phases.periOvulatory.start.date + } + } + + function dateIsInPeriOrPostPhase(dateString) { + return ( + dateString >= cycleStatus.phases.periOvulatory.start.date + ) + } + + function precededByAnotherTempValue(dateString) { + return ( + // we are only interested in days that have a preceding + // temp + Object.keys(cycleStatus.phases).some(phaseName => { + return cycleStatus.phases[phaseName].cycleDays.some(day => { + return day.temperature && day.date < dateString + }) + }) + // and also a following temp, so we don't draw the line + // longer than necessary + && + cycleStatus.phases.postOvulatory.cycleDays.some(day => { + return day.temperature && day.date > dateString + }) + ) + } + + function isInTempMeasuringPhase(cycleDay, dateString) { + return ( + cycleDay && cycleDay.temperature + || precededByAnotherTempValue(dateString) + ) + } + + return function(dateString, cycleDay) { + const ret = {} + if (!cycleStatus && !noMoreCycles) updateCurrentCycle(dateString) + if (noMoreCycles) return ret + + if (dateString < cycleStartDate) updateCurrentCycle(dateString) + if (noMoreCycles) return ret + + const tempShift = cycleStatus.temperatureShift + + if (tempShift) { + if (tempShift.firstHighMeasurementDay.date === dateString) { + ret.drawFhmLine = true + } + + if ( + dateIsInPeriOrPostPhase(dateString) && + isInTempMeasuringPhase(cycleDay, dateString) + ) { + ret.drawLtlAt = normalizeToScale(tempShift.ltl) + } + } + + return ret + } +} \ No newline at end of file diff --git a/components/chart/y-axis.js b/components/chart/y-axis.js new file mode 100644 index 0000000000000000000000000000000000000000..0e9179a6e092f2ea6fd3c55d50ab4e86ecfc84bb --- /dev/null +++ b/components/chart/y-axis.js @@ -0,0 +1,56 @@ +import React from 'react' +import { Text as ReactNativeText } from 'react-native' +import { Line } from 'react-native-svg' +import config from './config' +import styles from './styles' + +function makeYAxis() { + const scaleMin = config.temperatureScale.low + const scaleMax = config.temperatureScale.high + const numberOfTicks = (scaleMax - scaleMin) * 2 + const tickDistance = config.chartHeight / numberOfTicks + + const tickPositions = [] + const labels = [] + // for style reasons, we don't want the first and last tick + for (let i = 1; i < numberOfTicks - 1; i++) { + const y = tickDistance * i + const style = styles.yAxisLabel + // 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 + style.top = y - 8 + labels.push( + <ReactNativeText + style={{...style}} + key={i}> + {scaleMax - i * 0.5} + </ReactNativeText> + ) + tickPositions.push(y) + } + + return {labels, tickPositions} +} + +export const yAxis = makeYAxis() + +export const horizontalGrid = yAxis.tickPositions.map(tick => { + return ( + <Line + x1={0} + y1={tick} + x2={config.columnWidth} + y2={tick} + {...styles.horizontalGrid} + key={tick} + /> + ) +}) + +export function normalizeToScale(temp) { + const scale = config.temperatureScale + const valueRelativeToScale = (scale.high - temp) / (scale.high - scale.low) + const scaleHeight = config.chartHeight + return scaleHeight * valueRelativeToScale +} \ No newline at end of file