├── docs ├── .nojekyll ├── distchart.png ├── timechart.png ├── assets │ ├── fontello │ │ ├── font │ │ │ ├── fontello.eot │ │ │ ├── fontello.ttf │ │ │ ├── fontello.woff │ │ │ ├── fontello.woff2 │ │ │ └── fontello.svg │ │ ├── LICENSE.txt │ │ ├── config.json │ │ └── fontello.css │ ├── css │ │ └── overrides.css │ ├── fonts │ │ └── et-book │ │ │ ├── et-book-bold-line-figures │ │ │ ├── et-book-bold-line-figures.eot │ │ │ ├── et-book-bold-line-figures.ttf │ │ │ └── et-book-bold-line-figures.woff │ │ │ ├── et-book-roman-line-figures │ │ │ ├── et-book-roman-line-figures.eot │ │ │ ├── et-book-roman-line-figures.ttf │ │ │ └── et-book-roman-line-figures.woff │ │ │ ├── et-book-roman-old-style-figures │ │ │ ├── et-book-roman-old-style-figures.eot │ │ │ ├── et-book-roman-old-style-figures.ttf │ │ │ └── et-book-roman-old-style-figures.woff │ │ │ ├── et-book-semi-bold-old-style-figures │ │ │ ├── et-book-semi-bold-old-style-figures.eot │ │ │ ├── et-book-semi-bold-old-style-figures.ttf │ │ │ └── et-book-semi-bold-old-style-figures.woff │ │ │ └── et-book-display-italic-old-style-figures │ │ │ ├── et-book-display-italic-old-style-figures.eot │ │ │ ├── et-book-display-italic-old-style-figures.ttf │ │ │ └── et-book-display-italic-old-style-figures.woff │ └── script.js └── index.org ├── .npmignore ├── .ignore ├── .travis.yml ├── src ├── styles │ ├── components │ │ ├── _position.scss │ │ ├── time-chart │ │ │ ├── _onset.scss │ │ │ ├── _actual.scss │ │ │ ├── _observed.scss │ │ │ ├── _peak.scss │ │ │ ├── _baseline.scss │ │ │ ├── _history.scss │ │ │ ├── _prediction.scss │ │ │ └── _overlay.scss │ │ ├── _control-panel.scss │ │ ├── _distribution-chart.scss │ │ ├── _font.scss │ │ ├── _axes.scss │ │ ├── distribution-chart │ │ │ ├── _time-pointer.scss │ │ │ └── _prediction.scss │ │ ├── _time-chart.scss │ │ ├── _overlay.scss │ │ ├── control-panel │ │ │ ├── _controls.scss │ │ │ └── _drawer.scss │ │ └── _tooltips.scss │ ├── main.scss │ └── modules │ │ ├── colors.json │ │ ├── _colors.scss │ │ └── _mixins.scss ├── index.ts ├── utilities │ ├── style.ts │ ├── errors.ts │ ├── data │ │ ├── verify.js │ │ ├── domains.ts │ │ ├── time-chart-model.schema.json │ │ ├── config.ts │ │ └── timepoints.ts │ ├── colors.ts │ ├── misc.ts │ └── tooltip.ts ├── components │ ├── s-component.js │ ├── distribution-chart │ │ ├── no-pred-text.js │ │ ├── pointer.js │ │ ├── overlay.js │ │ ├── distribution-panel.js │ │ └── prediction.js │ ├── common │ │ ├── control-panel │ │ │ ├── search-box.js │ │ │ ├── toggle-buttons.js │ │ │ ├── index.js │ │ │ ├── control-buttons.js │ │ │ ├── drawer-row.js │ │ │ └── legend-drawer.js │ │ ├── tooltip.js │ │ ├── axis-y.js │ │ └── axis-x.js │ ├── time-chart │ │ ├── timezero-line.js │ │ ├── timerect.js │ │ ├── actual.js │ │ ├── historical-lines.js │ │ ├── observed.js │ │ ├── baseline.js │ │ ├── prediction │ │ │ ├── line-marker.js │ │ │ ├── onset-marker.js │ │ │ ├── index.js │ │ │ └── peak-marker.js │ │ ├── additional-line.js │ │ └── overlay.js │ └── component.js ├── interfaces.ts ├── events.ts ├── chart.ts ├── distribution-chart.js └── time-chart.js ├── assets └── fontello │ ├── font │ ├── fontello.eot │ ├── fontello.ttf │ ├── fontello.woff │ ├── fontello.woff2 │ └── fontello.svg │ ├── LICENSE.txt │ ├── config.json │ └── fontello.css ├── tsconfig.json ├── test ├── styles.spec.ts └── utilities.spec.ts ├── README.md ├── .gitignore ├── LICENSE ├── webpack.config.js └── package.json /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | *.png -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | /docs 2 | yarn.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "9" 5 | 6 | cache: yarn 7 | -------------------------------------------------------------------------------- /src/styles/components/_position.scss: -------------------------------------------------------------------------------- 1 | .d3f-chart { 2 | position: relative; 3 | } 4 | -------------------------------------------------------------------------------- /docs/distchart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/distchart.png -------------------------------------------------------------------------------- /docs/timechart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/timechart.png -------------------------------------------------------------------------------- /assets/fontello/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/assets/fontello/font/fontello.eot -------------------------------------------------------------------------------- /assets/fontello/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/assets/fontello/font/fontello.ttf -------------------------------------------------------------------------------- /assets/fontello/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/assets/fontello/font/fontello.woff -------------------------------------------------------------------------------- /assets/fontello/font/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/assets/fontello/font/fontello.woff2 -------------------------------------------------------------------------------- /src/styles/components/time-chart/_onset.scss: -------------------------------------------------------------------------------- 1 | .onset-group { 2 | .onset-mark { 3 | stroke-width: 6px; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/assets/fontello/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fontello/font/fontello.eot -------------------------------------------------------------------------------- /docs/assets/fontello/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fontello/font/fontello.ttf -------------------------------------------------------------------------------- /docs/assets/fontello/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fontello/font/fontello.woff -------------------------------------------------------------------------------- /docs/assets/fontello/font/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fontello/font/fontello.woff2 -------------------------------------------------------------------------------- /src/styles/components/_control-panel.scss: -------------------------------------------------------------------------------- 1 | .d3f-controls { 2 | @import 'control-panel/controls'; 3 | @import 'control-panel/drawer'; 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/components/_distribution-chart.scss: -------------------------------------------------------------------------------- 1 | .d3f-distribution-chart { 2 | @import 'distribution-chart/time-pointer'; 3 | @import 'distribution-chart/prediction'; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "target": "es5" 5 | }, 6 | "exclude": [ 7 | "node_modules", 8 | "**/*.spec.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /docs/assets/css/overrides.css: -------------------------------------------------------------------------------- 1 | /* Some minor tweaks over the basic css */ 2 | 3 | .page-header { 4 | margin-left: 0; 5 | } 6 | 7 | .page-meta a { 8 | text-decoration: none; 9 | color: gray; 10 | } 11 | -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.woff -------------------------------------------------------------------------------- /src/styles/components/_font.scss: -------------------------------------------------------------------------------- 1 | .d3f-chart { 2 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, Helvetica, Arial, sans-serif; 3 | font-weight: normal; 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/components/time-chart/_actual.scss: -------------------------------------------------------------------------------- 1 | .actual-group { 2 | pointer-events: none; 3 | 4 | .line-actual { 5 | @include svg-line($actual); 6 | } 7 | 8 | .point-actual { 9 | @include svg-point($actual); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.eot -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-semi-bold-old-style-figures/et-book-semi-bold-old-style-figures.woff -------------------------------------------------------------------------------- /assets/fontello/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## Font Awesome 5 | 6 | Copyright (C) 2016 by Dave Gandy 7 | 8 | Author: Dave Gandy 9 | License: SIL () 10 | Homepage: http://fortawesome.github.com/Font-Awesome/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './styles/main.scss' 2 | import { JUMP_TO_INDEX } from './events' 3 | 4 | export { default as TimeChart } from './time-chart' 5 | export { default as DistributionChart } from './distribution-chart' 6 | export const events = { JUMP_TO_INDEX } 7 | -------------------------------------------------------------------------------- /src/styles/components/_axes.scss: -------------------------------------------------------------------------------- 1 | .d3f-chart { 2 | .axis { 3 | path, 4 | line { 5 | fill: none; 6 | stroke: $axis-ticks; 7 | } 8 | 9 | text { 10 | fill: $gray; 11 | font-size: 10px; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/components/distribution-chart/_time-pointer.scss: -------------------------------------------------------------------------------- 1 | .time-pointer-group { 2 | .pointer-triangle { 3 | fill: $gray; 4 | } 5 | 6 | .pointer-overlay { 7 | cursor: pointer; 8 | fill: $gray; 9 | fill-opacity: 0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/components/time-chart/_observed.scss: -------------------------------------------------------------------------------- 1 | .observed-group { 2 | pointer-events: none; 3 | 4 | .line-observed { 5 | @include svg-line($observed); 6 | } 7 | 8 | .point-observed { 9 | @include svg-point($observed); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/components/time-chart/_peak.scss: -------------------------------------------------------------------------------- 1 | .peak-group { 2 | .peak-mark { 3 | opacity: .9; 4 | stroke-width: 8px; 5 | 6 | &:hover { 7 | opacity: 1; 8 | } 9 | } 10 | 11 | line { 12 | stroke-width: 0.5px; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/assets/fontello/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## Font Awesome 5 | 6 | Copyright (C) 2016 by Dave Gandy 7 | 8 | Author: Dave Gandy 9 | License: SIL () 10 | Homepage: http://fortawesome.github.com/Font-Awesome/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/styles/components/time-chart/_baseline.scss: -------------------------------------------------------------------------------- 1 | .baseline-group { 2 | .baseline { 3 | stroke: $gray; 4 | stroke-dasharray: 5, 5; 5 | stroke-width: .5px; 6 | } 7 | 8 | text { 9 | fill: $gray; 10 | font-size: 10px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/styles/components/distribution-chart/_prediction.scss: -------------------------------------------------------------------------------- 1 | .prediction-group { 2 | pointer-events: none; 3 | 4 | .line-prediction { 5 | fill: none; 6 | stroke-width: 1.5px; 7 | } 8 | 9 | .area-prediction { 10 | opacity: .05; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf -------------------------------------------------------------------------------- /docs/assets/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reichlab/d3-foresight/HEAD/docs/assets/fonts/et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff -------------------------------------------------------------------------------- /src/styles/components/time-chart/_history.scss: -------------------------------------------------------------------------------- 1 | .history-group { 2 | .line-history { 3 | fill: none; 4 | stroke: $history; 5 | stroke-width: 2px; 6 | 7 | &.highlight { 8 | pointer-events: none; 9 | stroke: $history-highlight; 10 | stroke-width: 3px; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/components/time-chart/_prediction.scss: -------------------------------------------------------------------------------- 1 | .prediction-group { 2 | pointer-events: none; 3 | 4 | .line-prediction { 5 | fill: none; 6 | stroke-width: 1.5px; 7 | } 8 | 9 | .point-prediction { 10 | fill: $white; 11 | stroke-width: 1.5px; 12 | } 13 | 14 | .area-prediction { 15 | opacity: .2; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/components/_time-chart.scss: -------------------------------------------------------------------------------- 1 | .d3f-time-chart { 2 | @import 'time-chart/overlay'; 3 | @import 'time-chart/onset'; 4 | @import 'time-chart/peak'; 5 | @import 'time-chart/prediction'; 6 | @import 'time-chart/observed'; 7 | @import 'time-chart/actual'; 8 | @import 'time-chart/baseline'; 9 | @import 'time-chart/history'; 10 | } 11 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 'modules/colors'; 2 | @import 'modules/mixins'; 3 | 4 | @import 'components/axes'; 5 | @import 'components/font'; 6 | @import 'components/position'; 7 | @import 'components/overlay'; 8 | @import 'components/tooltips'; 9 | @import 'components/control-panel'; 10 | @import 'components/time-chart'; 11 | @import 'components/distribution-chart'; 12 | -------------------------------------------------------------------------------- /src/utilities/style.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Apply css styles on d3 selection. Allow allow few non-css visual properties 3 | * to be set. 4 | */ 5 | export function applyStyle (d3Selection, style) { 6 | for (let key in style || {}) { 7 | if (key === 'r') { 8 | d3Selection.attr(key, style[key]) 9 | } else { 10 | d3Selection.style(key, style[key]) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/utilities/errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom error for case when point type (the type of x axis) can't be handled 3 | */ 4 | export class UnknownPointType extends Error {} 5 | 6 | /** 7 | * Custom error for situation when data provided for plotting is not quite right 8 | */ 9 | export class IncorrectData extends Error {} 10 | 11 | /** 12 | * DOM related error 13 | */ 14 | export class DocumentError extends Error {} 15 | -------------------------------------------------------------------------------- /src/styles/components/_overlay.scss: -------------------------------------------------------------------------------- 1 | .d3f-chart { 2 | .overlay { 3 | fill: none; 4 | pointer-events: all; 5 | } 6 | 7 | .hover-line { 8 | fill: none; 9 | stroke: $hover-line; 10 | stroke-width: 1px; 11 | } 12 | 13 | .no-pred-text { 14 | font-size: 10px; 15 | letter-spacing: 1px; 16 | line-height: 25px; 17 | fill: $gray-light; 18 | text-transform: uppercase; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/modules/colors.json: -------------------------------------------------------------------------------- 1 | { 2 | "accent": "#5c6bc0", 3 | "actual": "#66d600", 4 | "observed": "#18817f", 5 | "history": "#ddd", 6 | "history-highlight": "#ccc", 7 | 8 | "axis-ticks": "#bbb", 9 | "hover-line": "#bbb", 10 | "disabled": "#bbb", 11 | 12 | "black": "#000", 13 | "gray": "#333", 14 | "gray-light": "#666", 15 | "white": "#fff", 16 | "white-shade": "#f5f5f5", 17 | "shadow": "#ccc" 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/modules/_colors.scss: -------------------------------------------------------------------------------- 1 | // Gray shades 2 | $c1: #000; 3 | $c2: #333; 4 | $c3: #666; 5 | $c4: #bbb; 6 | $c5: #ccc; 7 | $c6: #ddd; 8 | $c7: #eee; 9 | $c8: #f5f5f5; 10 | $c9: #fff; 11 | 12 | // Used palette 13 | $accent: #5c6bc0; 14 | $actual: #66d600; 15 | $observed: #18817f; 16 | $history: $c6; 17 | $history-highlight: $c5; 18 | 19 | $axis-ticks: $c4; 20 | $hover-line: $c4; 21 | $disabled: $c4; 22 | 23 | $black: $c1; 24 | $gray: $c2; 25 | $gray-light: $c3; 26 | $white: $c9; 27 | $white-shade: $c8; 28 | $shadow: $c5; 29 | -------------------------------------------------------------------------------- /src/components/s-component.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import Component from './component' 3 | 4 | /** 5 | * Generic class for representing SVG components 6 | */ 7 | export default class SComponent extends Component { 8 | constructor () { 9 | super(document.createElementNS(d3.namespaces.svg, 'g')) 10 | } 11 | 12 | /** 13 | * Remove all subelements of the selection 14 | */ 15 | clear () { 16 | this.selection.selectAll('*') 17 | .transition() 18 | .duration(200) 19 | .remove() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/styles/components/time-chart/_overlay.scss: -------------------------------------------------------------------------------- 1 | .overlay { 2 | cursor: pointer; 3 | } 4 | 5 | .timerect { 6 | fill: $white-shade; 7 | } 8 | 9 | .timezero-line { 10 | stroke: $hover-line; 11 | stroke-dasharray: 10 5; 12 | stroke-width: 1px; 13 | } 14 | 15 | .data-version-text, .timezero-text { 16 | fill: $gray-light; 17 | font-size: 10px; 18 | } 19 | 20 | .now-line { 21 | stroke: $black; 22 | stroke-dasharray: 10 5; 23 | stroke-width: 1px; 24 | } 25 | 26 | .now-text { 27 | fill: $gray-light; 28 | font-size: 10px; 29 | text-transform: uppercase; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/distribution-chart/no-pred-text.js: -------------------------------------------------------------------------------- 1 | import SComponent from '../s-component' 2 | 3 | export default class NoPredText extends SComponent { 4 | constructor () { 5 | super() 6 | this.text = this.selection.append('text') 7 | .attr('class', 'no-pred-text') 8 | .attr('transform', `translate(30 , 30)`) 9 | 10 | this.text.append('tspan') 11 | .text('Predictions not available') 12 | .attr('x', 0) 13 | 14 | this.text.append('tspan') 15 | .text('for selected time') 16 | .attr('x', 0) 17 | .attr('dy', '2em') 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/common/control-panel/search-box.js: -------------------------------------------------------------------------------- 1 | import Component from '../../component' 2 | 3 | export default class SearchBox extends Component { 4 | constructor () { 5 | super() 6 | this.selection.classed('row', true) 7 | 8 | this.input = this.selection.append('input') 9 | .attr('class', 'search-input') 10 | .attr('type', 'text') 11 | .attr('placeholder', 'Filter models') 12 | 13 | this.hidden = true 14 | } 15 | 16 | addKeyup (fn) { 17 | this.input.keyup = null 18 | this.input.on('keyup', function () { 19 | fn({ text: this.value.toLowerCase() }) 20 | }) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/styles.spec.ts: -------------------------------------------------------------------------------- 1 | import * as scssToJson from 'scss-to-json' 2 | import * as fs from 'fs-extra' 3 | import { expect } from 'chai' 4 | import 'mocha' 5 | 6 | 7 | describe('Color palette', () => { 8 | it('should be same in json and scss', async () => { 9 | let jsonPalette = JSON.parse(await fs.readFile('./src/styles/modules/colors.json')) 10 | let scssPalette = scssToJson('./src/styles/modules/_colors.scss') 11 | 12 | for (let item in scssPalette) { 13 | if (!item.startsWith('$c') || (item.length !== 3)) { 14 | expect(jsonPalette[item.slice(1)]).to.equal(scssPalette[item]) 15 | } 16 | } 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/styles/components/control-panel/_controls.scss: -------------------------------------------------------------------------------- 1 | .control-btns { 2 | background-color: $white; 3 | position: absolute; 4 | right: 20px; 5 | top: 10px; 6 | 7 | .btn { 8 | @include btn(); 9 | @include shadow(); 10 | @include border(rgba($accent, 0.5)); 11 | color: $accent; 12 | padding: 2px 6px; 13 | margin-bottom: 5px; 14 | font-size: 11px; 15 | 16 | i::before { 17 | margin: 0; 18 | } 19 | 20 | &.active { 21 | background-color: $accent; 22 | color: $white; 23 | } 24 | 25 | &:hover { 26 | background-color: $accent; 27 | color: $white; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/styles/components/_tooltips.scss: -------------------------------------------------------------------------------- 1 | .d3f-tooltip { 2 | background-color: $white; 3 | @include shadow(); 4 | @include border($shadow); 5 | font-size: 11px; 6 | position: fixed; 7 | max-width: 250px; 8 | z-index: 100; 9 | 10 | .tooltip-title { 11 | @include cell-padding; 12 | font-size: 13px; 13 | margin-bottom: 0 !important; 14 | margin-top: 5px; 15 | } 16 | 17 | .tooltip-text { 18 | @include cell-padding; 19 | margin-top: 0; 20 | } 21 | 22 | .tooltip-row { 23 | @include cell-padding; 24 | background-color: $white; 25 | color: $gray; 26 | } 27 | 28 | .bold { 29 | float: right; 30 | margin-left: 5px; 31 | font-weight: bold; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interfaces and types 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import * as d3 from 'd3' 9 | 10 | 11 | /** 12 | * A timepoint. We only use weeks as of now. 13 | */ 14 | export type Timepoint = { 15 | year: number 16 | week?: number, 17 | biweek?: number, 18 | month?: number 19 | } 20 | 21 | /** 22 | * Type of time point 23 | */ 24 | export type TimepointId = 'week' | 'mmwr-week' | 'biweek' | 'month' 25 | 26 | /** 27 | * Range of numbers 28 | */ 29 | export type Range = [number, number] | [string, string] | any[] 30 | 31 | 32 | /** 33 | * X, Y position as tuple 34 | */ 35 | export type Position = [number, number] 36 | 37 | /** 38 | * Event 39 | */ 40 | export type Event = string 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3-foresight 2 | 3 | [![Build Status](https://img.shields.io/travis/reichlab/d3-foresight/master.svg?style=flat-square)](https://travis-ci.org/reichlab/d3-foresight) 4 | [![npm](https://img.shields.io/npm/v/d3-foresight.svg?style=flat-square)](https://www.npmjs.com/package/d3-foresight) 5 | [![npm](https://img.shields.io/npm/l/d3-foresight.svg?style=flat-square)](https://www.npmjs.com/package/d3-foresight) 6 | [![GitHub issues](https://img.shields.io/github/issues/reichlab/d3-foresight.svg?style=flat-square)](https://github.com/reichlab/d3-foresight/issues) 7 | 8 | `d3-foresight` is a [d3](https://github.com/d3/d3) based library for visualizing 9 | time series forecasts. 10 | 11 | Check out [flusight](http://reichlab.io/flusight) for a demo. Documentation is 12 | [here](http://reichlab.io/d3-foresight). 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | dist 40 | .fuse* 41 | /src/.tern-port 42 | 43 | # MacOS system files 44 | .DS_Store 45 | -------------------------------------------------------------------------------- /src/components/common/tooltip.js: -------------------------------------------------------------------------------- 1 | import Component from '../component' 2 | 3 | /** 4 | * Tooltip 5 | */ 6 | export default class Tooltip extends Component { 7 | constructor () { 8 | super() 9 | 10 | this.selection 11 | .attr('class', `d3f-tooltip`) 12 | .style('display', 'none') 13 | 14 | this.offset = 15 15 | } 16 | 17 | get width () { 18 | return this.node.getBoundingClientRect().width 19 | } 20 | 21 | move (position, direction = 'right') { 22 | this.selection 23 | .style('top', (position.y + this.offset) + 'px') 24 | .style('left', (position.x + (direction === 'right' ? this.offset : -this.width - this.offset)) + 'px') 25 | } 26 | 27 | render (html) { 28 | if (html === '') { 29 | this.hidden = true 30 | } else { 31 | this.hidden = false 32 | this.selection.html(html) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/common/control-panel/toggle-buttons.js: -------------------------------------------------------------------------------- 1 | import Component from '../../component' 2 | 3 | /** 4 | * Set of horizontally arranged buttons with only one active at a time 5 | */ 6 | export default class ToggleButtons extends Component { 7 | constructor (texts) { 8 | super() 9 | this.selection.classed('toggle-group', true) 10 | 11 | this.buttons = texts.map(txt => { 12 | return this.selection.append('span') 13 | .classed('toggle-button', true) 14 | .text(txt) 15 | }) 16 | } 17 | 18 | addOnClick (fn) { 19 | super.addOnClick() 20 | let that = this 21 | this.buttons.forEach((btn, idx) => { 22 | btn.on('click', function () { 23 | fn({ idx }) 24 | that.reset() 25 | that.set(idx) 26 | }) 27 | }) 28 | } 29 | 30 | set (idx) { 31 | this.buttons[idx].classed('selected', true) 32 | } 33 | 34 | unset (idx) { 35 | this.buttons[idx].classed('selected', false) 36 | } 37 | 38 | reset () { 39 | this.buttons.forEach((btn, idx) => this.unset(idx)) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/utilities.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import 'mocha' 3 | import { orArrays } from '../src/utilities/misc' 4 | import * as arrayEqual from 'array-equal' 5 | 6 | 7 | describe('Array ORing', () => { 8 | it('should work for plain arrays', () => { 9 | let arrays = [ 10 | [1, 2, 3], 11 | [1, 2, 3], 12 | [1, 2, 3], 13 | [1, 2, 3] 14 | ] 15 | 16 | expect(arrayEqual(orArrays(arrays), [1, 2, 3])) 17 | }) 18 | 19 | it('should work for arrays with nulls', () => { 20 | let arrays = [ 21 | [1, null, 3], 22 | [1, 2, 3], 23 | [1, 2, 3] 24 | ] 25 | 26 | expect(arrayEqual(orArrays(arrays), [1, 2, 3])) 27 | 28 | arrays = [ 29 | [null, null, null], 30 | [1, 2, 3], 31 | [1, 2, 3] 32 | ] 33 | 34 | expect(arrayEqual(orArrays(arrays), [1, 2, 3])) 35 | }) 36 | 37 | it('Should throw error with non equal items', () => { 38 | let arrays = [ 39 | [1, null, 3], 40 | [1, 2, 3], 41 | [1, 2.3, 3] 42 | ] 43 | 44 | expect(() => { orArrays(arrays) }).to.throw('Non equal items in arrays') 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/components/time-chart/timezero-line.js: -------------------------------------------------------------------------------- 1 | import SComponent from '../s-component' 2 | 3 | export default class TimezeroLine extends SComponent { 4 | constructor (layout) { 5 | super() 6 | this.line = this.selection.append('line') 7 | .attr('class', 'timezero-line') 8 | .attr('x1', 0) 9 | .attr('y1', 0) 10 | .attr('x2', 0) 11 | .attr('y2', layout.totalHeight) 12 | 13 | this.text = this.selection.append('text') 14 | .attr('class', 'timezero-text') 15 | .attr('transform', 'translate(-10, 10) rotate(-90)') 16 | .style('text-anchor', 'end') 17 | .text('Timezero') 18 | } 19 | 20 | set x (x) { 21 | this.line 22 | .transition() 23 | .duration(200) 24 | .attr('x1', x) 25 | .attr('x2', x) 26 | 27 | this.text 28 | .transition() 29 | .duration(200) 30 | .attr('dy', x) 31 | } 32 | 33 | set textHidden (state) { 34 | this.text.style('display', state ? 'none' : null) 35 | } 36 | 37 | plot (scales) { 38 | this.xScale = scales.xScale 39 | } 40 | 41 | update (idx) { 42 | this.x = this.xScale(idx) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 The Reich Lab at UMass-Amherst 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /assets/fontello/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "icon-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "ad6b3fbb5324abe71a9c0b6609cbb9f1", 11 | "css": "right-big", 12 | "code": 59392, 13 | "src": "fontawesome" 14 | }, 15 | { 16 | "uid": "555ef8c86832e686fef85f7af2eb7cde", 17 | "css": "left-big", 18 | "code": 59393, 19 | "src": "fontawesome" 20 | }, 21 | { 22 | "uid": "130380e481a7defc690dfb24123a1f0c", 23 | "css": "circle", 24 | "code": 61713, 25 | "src": "fontawesome" 26 | }, 27 | { 28 | "uid": "422e07e5afb80258a9c4ed1706498f8a", 29 | "css": "circle-empty", 30 | "code": 61708, 31 | "src": "fontawesome" 32 | }, 33 | { 34 | "uid": "559647a6f430b3aeadbecd67194451dd", 35 | "css": "menu", 36 | "code": 61641, 37 | "src": "fontawesome" 38 | }, 39 | { 40 | "uid": "e15f0d620a7897e2035c18c80142f6d9", 41 | "css": "link-ext", 42 | "code": 61582, 43 | "src": "fontawesome" 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const PROD = JSON.parse(process.env.PROD_ENV || '0') 4 | 5 | module.exports = { 6 | entry: path.resolve(__dirname, 'src/index.ts'), 7 | 8 | mode: PROD ? 'production' : 'development', 9 | 10 | output: { 11 | filename: PROD ? 'd3-foresight.min.js' : 'd3-foresight.js', 12 | path: path.resolve(__dirname, 'dist'), 13 | library: 'd3Foresight', 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true 16 | }, 17 | 18 | externals: { 19 | 'd3': 'd3', 20 | 'moment': 'moment' 21 | }, 22 | 23 | resolve: { 24 | extensions: ['.ts', '.tsx', '.js'] 25 | }, 26 | 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.ts$/, 31 | loader: 'ts-loader' 32 | }, 33 | { 34 | test: /\.js$/, 35 | loader: 'babel-loader', 36 | exclude: /node_modules/, 37 | options: { 38 | presets: ['env'], 39 | plugins: ['transform-object-rest-spread'] 40 | } 41 | }, 42 | { 43 | test: /\.scss$/, 44 | loaders: ['style-loader', 'css-loader', 'sass-loader?sourceMap'] 45 | } 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /docs/assets/fontello/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "css_prefix_text": "icon-", 4 | "css_use_suffix": false, 5 | "hinting": true, 6 | "units_per_em": 1000, 7 | "ascent": 850, 8 | "glyphs": [ 9 | { 10 | "uid": "ad6b3fbb5324abe71a9c0b6609cbb9f1", 11 | "css": "right-big", 12 | "code": 59392, 13 | "src": "fontawesome" 14 | }, 15 | { 16 | "uid": "555ef8c86832e686fef85f7af2eb7cde", 17 | "css": "left-big", 18 | "code": 59393, 19 | "src": "fontawesome" 20 | }, 21 | { 22 | "uid": "130380e481a7defc690dfb24123a1f0c", 23 | "css": "circle", 24 | "code": 61713, 25 | "src": "fontawesome" 26 | }, 27 | { 28 | "uid": "422e07e5afb80258a9c4ed1706498f8a", 29 | "css": "circle-empty", 30 | "code": 61708, 31 | "src": "fontawesome" 32 | }, 33 | { 34 | "uid": "559647a6f430b3aeadbecd67194451dd", 35 | "css": "menu", 36 | "code": 61641, 37 | "src": "fontawesome" 38 | }, 39 | { 40 | "uid": "e15f0d620a7897e2035c18c80142f6d9", 41 | "css": "link-ext", 42 | "code": 61582, 43 | "src": "fontawesome" 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /src/components/common/control-panel/index.js: -------------------------------------------------------------------------------- 1 | import * as ev from '../../../events' 2 | import ControlButtons from './control-buttons' 3 | import LegendDrawer from './legend-drawer' 4 | import Component from '../../component' 5 | 6 | /** 7 | * Chart controls 8 | * nav-drawers and buttons 9 | */ 10 | export default class ControlPanel extends Component { 11 | constructor (config) { 12 | super() 13 | this.selection.attr('class', 'd3f-controls') 14 | this.config = config 15 | 16 | // Add legend drawer 17 | this.legendDrawer = this.append(new LegendDrawer(config)) 18 | 19 | // Buttons on the side of panel 20 | let sideButtons = this.append(new ControlButtons(config.tooltip, config.uuid)) 21 | 22 | ev.addSub(config.uuid, ev.PANEL_TOGGLE, (msg, data) => { 23 | this.legendDrawer.hidden = !this.legendDrawer.hidden 24 | sideButtons.legendBtnState = !sideButtons.legendBtnState 25 | }) 26 | 27 | // Turn on legend by default 28 | sideButtons.legendBtnState = true 29 | } 30 | 31 | plot (predictions, additional, config) { 32 | this.legendDrawer.plot(predictions, additional, config) 33 | } 34 | 35 | update (predictions) { 36 | this.legendDrawer.update(predictions) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/styles/modules/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin no-select() { 2 | -webkit-touch-callout: none; /* iOS Safari */ 3 | -webkit-user-select: none; /* Safari */ 4 | -khtml-user-select: none; /* Konqueror HTML */ 5 | -moz-user-select: none; /* Firefox */ 6 | -ms-user-select: none; /* Internet Explorer/Edge */ 7 | user-select: none; /* Non-prefixed version */ 8 | } 9 | 10 | @mixin cell-padding() { 11 | padding: 5px 10px; 12 | } 13 | 14 | @mixin btn() { 15 | cursor: pointer; 16 | @include no-select(); 17 | letter-spacing: normal; 18 | font-weight: normal; 19 | display: block; 20 | line-height: normal; 21 | } 22 | 23 | @mixin shadow() { 24 | box-shadow: 0 0 2px $shadow; 25 | } 26 | 27 | @mixin border($color) { 28 | border-style: solid; 29 | border-width: 1px; 30 | border-color: $color; 31 | border-radius: 0; 32 | } 33 | 34 | @mixin svg-point($color) { 35 | fill: $color; 36 | opacity: 0.4; 37 | stroke: $color; 38 | } 39 | 40 | @mixin svg-line($color) { 41 | fill: none; 42 | opacity: 0.4; 43 | stroke: $color; 44 | stroke-width: 2.5px; 45 | } 46 | 47 | @mixin icon() { 48 | display: inline-block; 49 | font-size: 9px; 50 | font-style: normal; 51 | vertical-align: middle; 52 | text-decoration: none; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/time-chart/timerect.js: -------------------------------------------------------------------------------- 1 | import SComponent from '../s-component' 2 | 3 | /** 4 | * Time rectangle for navigation guidance 5 | */ 6 | export default class TimeRect extends SComponent { 7 | constructor (layout) { 8 | super() 9 | this.rect = this.selection.append('rect') 10 | .attr('x', 0) 11 | .attr('y', 0) 12 | .attr('width', 0) 13 | .attr('height', layout.height) 14 | .attr('class', 'timerect') 15 | 16 | this.text = this.selection.append('text') 17 | .attr('class', 'data-version-text') 18 | .attr('transform', `translate(15, 0) rotate(-90)`) 19 | .style('text-anchor', 'end') 20 | .text('Data as of') 21 | } 22 | 23 | plot (scales) { 24 | this.xScaleDate = scales.xScaleDate 25 | } 26 | 27 | update (time) { 28 | if (time === null) { 29 | // We don't know the data version time 30 | this.hidden = true 31 | } else { 32 | this.hidden = false 33 | this.rect 34 | .transition() 35 | .duration(200) 36 | .attr('width', this.xScaleDate(time)) 37 | 38 | this.text 39 | .transition() 40 | .duration(200) 41 | .attr('transform', `translate(${this.xScaleDate(time) + 15}, 10) rotate(-90)`) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/utilities/data/verify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for verifying if the data object for plotting is valid or not 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import { IncorrectData } from '../errors' 9 | import Ajv from 'ajv' 10 | import timeChartModelSchema from './time-chart-model.schema.json' 11 | 12 | /** 13 | * Verify data for time chart 14 | */ 15 | export function verifyTimeChartData (data) { 16 | if (!('timePoints' in data)) { 17 | throw new IncorrectData('No timePoints key found in provided data') 18 | } 19 | 20 | if (!('models' in data)) { 21 | throw new IncorrectData('No models in data') 22 | } 23 | 24 | let ajv = new Ajv() 25 | let validate = ajv.compile(timeChartModelSchema) 26 | 27 | // Check if all models have data in correct structure 28 | if (!data.models.every(m => validate(m))) { 29 | console.log(validate.errors) 30 | throw new IncorrectData('Model data not in approprate structure') 31 | } 32 | } 33 | 34 | /** 35 | * Verify data for distribution chart 36 | */ 37 | export function verifyDistChartData (data) { 38 | if (!('timePoints' in data)) { 39 | throw new IncorrectData('No timePoints key found in provided data') 40 | } 41 | 42 | if (!('models' in data)) { 43 | throw new IncorrectData('No models in data') 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Module specifying events for pubsub 3 | */ 4 | 5 | import * as PubSub from 'pubsub-js' 6 | import { Event } from './interfaces' 7 | 8 | // Internal events 9 | export const PANEL_MOVE_NEXT : Event = 'PANEL_MOVE_NEXT' 10 | export const PANEL_MOVE_PREV : Event = 'PANEL_MOVE_PREV' 11 | export const PANEL_TOGGLE : Event = 'PANEL_TOGGLE' 12 | export const LEGEND_ITEM : Event = 'LEGEND_ITEM' 13 | export const LEGEND_CI : Event = 'LEGEND_CI' 14 | export const LEGEND_ALL : Event = 'LEGEND_ALL' 15 | export const JUMP_TO_INDEX_INTERNAL : Event = 'JUMP_TO_INDEX_INTERNAL' 16 | 17 | // Exposed events 18 | export const JUMP_TO_INDEX : Event = 'JUMP_TO_INDEX' 19 | 20 | /** 21 | * Reset all subscriptions for an event 22 | */ 23 | export function resetSub (prefix: string, event: Event) { 24 | PubSub.unsubscribe(`${prefix}.${event}`) 25 | } 26 | 27 | /** 28 | * Remove subscription for a token 29 | */ 30 | export function removeSub (token) { 31 | PubSub.unsubscribe(token) 32 | } 33 | 34 | /** 35 | * Function to subscribe an object with an event 36 | */ 37 | export function addSub (prefix: string, event: Event, fn) { 38 | return PubSub.subscribe(`${prefix}.${event}`, fn) 39 | } 40 | 41 | /** 42 | * Publish an event 43 | */ 44 | export function publish (prefix: string, event: Event, data) { 45 | PubSub.publish(`${prefix}.${event}`, data) 46 | } 47 | -------------------------------------------------------------------------------- /src/components/common/axis-y.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as tt from '../../utilities/tooltip' 3 | import SComponent from '../s-component' 4 | import { selectUncle } from '../../utilities/misc' 5 | 6 | /** 7 | * Simple linear Y axis with informative label 8 | */ 9 | export class YAxis extends SComponent { 10 | constructor (layout, { tooltip, title, description, url }) { 11 | super() 12 | this.selection.attr('class', 'axis axis-y') 13 | 14 | let yText = this.selection.append('text') 15 | .attr('transform', `translate(-45 , ${layout.height / 2}) rotate(-90)`) 16 | .attr('dy', '.71em') 17 | .style('text-anchor', 'middle') 18 | .text(title) 19 | .on('mouseover', () => { tooltip.hidden = false }) 20 | .on('mouseout', () => { tooltip.hidden = true }) 21 | .on('mousemove', function () { 22 | tooltip.render(tt.parseText({ text: description })) 23 | tt.moveTooltip(tooltip, selectUncle(this, '.overlay')) 24 | }) 25 | 26 | if (url) { 27 | yText 28 | .style('cursor', 'pointer') 29 | .on('click', () => { 30 | window.open(url, '_blank') 31 | }) 32 | } 33 | } 34 | 35 | plot (scales, maxTicks) { 36 | let yAxis = d3.axisLeft(scales.yScale).tickFormat(d3.format('.2f')) 37 | if (maxTicks) yAxis.ticks(maxTicks) 38 | this.selection 39 | .transition().duration(200).call(yAxis) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/component.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as tt from '../utilities/tooltip' 3 | 4 | /** 5 | * Generic class for a component 6 | */ 7 | export default class Component { 8 | constructor (elem = document.createElement('div')) { 9 | this.selection = d3.select(elem) 10 | } 11 | 12 | /** 13 | * Return HTML node for d3.append 14 | */ 15 | get node () { 16 | return this.selection.node() 17 | } 18 | 19 | /** 20 | * General css display based hidden prop 21 | */ 22 | get hidden () { 23 | return this.selection.style('display') === 'none' 24 | } 25 | 26 | set hidden (state) { 27 | this.selection.style('display', state ? 'none' : null) 28 | } 29 | 30 | /** 31 | * Add an on hover tooltip 32 | */ 33 | addTooltip (tooltip, html, direction = 'right') { 34 | this.selection 35 | .on('mouseover', () => { tooltip.hidden = false }) 36 | .on('mouseout', () => { tooltip.hidden = true }) 37 | .on('mousemove', function () { 38 | tooltip.render(html) 39 | tt.moveTooltip(tooltip, d3.select(this), direction) 40 | }) 41 | } 42 | 43 | /** 44 | * Make pointer cursor. Rest is handled by subclasses. 45 | */ 46 | addOnClick () { 47 | this.selection.style('cursor', 'pointer') 48 | } 49 | 50 | /** 51 | * Append another component to this 52 | */ 53 | append (component) { 54 | this.selection.append(() => component.node) 55 | return component 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/time-chart/actual.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import SComponent from '../s-component' 3 | 4 | /** 5 | * Actual line 6 | */ 7 | export default class Actual extends SComponent { 8 | constructor () { 9 | super() 10 | this.selection.attr('class', 'actual-group') 11 | this.line = this.selection.append('path') 12 | .attr('class', 'line-actual') 13 | this.id = 'Actual' 14 | } 15 | 16 | plot (scales, actualData) { 17 | let line = d3.line() 18 | .x(d => scales.xScale(d.x)) 19 | .y(d => scales.yScale(d.y)) 20 | 21 | // Save data for queries 22 | this.data = actualData.map((data, idx) => { 23 | return { 24 | x: idx, 25 | y: data 26 | } 27 | }) 28 | 29 | this.line 30 | .datum(this.data.filter(d => d.y)) 31 | .transition() 32 | .duration(200) 33 | .attr('d', line) 34 | 35 | // Only plot non nulls 36 | let circles = this.selection.selectAll('.point-actual') 37 | .data(this.data.filter(d => d.y)) 38 | 39 | circles.exit().remove() 40 | 41 | circles.enter().append('circle') 42 | .merge(circles) 43 | .attr('class', 'point-actual') 44 | .transition(200) 45 | .ease(d3.easeQuadOut) 46 | .attr('cx', d => scales.xScale(d.x)) 47 | .attr('cy', d => scales.yScale(d.y)) 48 | .attr('r', 2) 49 | } 50 | 51 | query (idx) { 52 | if (this.hidden) { 53 | return false 54 | } else { 55 | try { 56 | return this.data[idx].y 57 | } catch (e) { 58 | return false 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /assets/fontello/fontello.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fontello'; 3 | src: url('./font/fontello.eot?33356274'); 4 | src: url('./font/fontello.eot?33356274#iefix') format('embedded-opentype'), 5 | url('./font/fontello.woff2?33356274') format('woff2'), 6 | url('./font/fontello.woff?33356274') format('woff'), 7 | url('./font/fontello.ttf?33356274') format('truetype'), 8 | url('./font/fontello.svg?33356274#fontello') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | [class^="icon-"]:before, [class*=" icon-"]:before { 14 | font-family: "fontello"; 15 | font-style: normal; 16 | font-weight: normal; 17 | speak: none; 18 | 19 | display: inline-block; 20 | text-decoration: inherit; 21 | width: 1em; 22 | margin-right: .2em; 23 | text-align: center; 24 | 25 | /* For safety - reset parent styles, that can break glyph codes*/ 26 | font-variant: normal; 27 | text-transform: none; 28 | 29 | /* fix buttons height, for twitter bootstrap */ 30 | line-height: 1em; 31 | 32 | /* Animation center compensation - margins should be symmetric */ 33 | /* remove if not needed */ 34 | margin-left: .2em; 35 | 36 | /* you can be more comfortable with increased icons size */ 37 | /* font-size: 120%; */ 38 | 39 | /* Font smoothing. That was taken from TWBS */ 40 | -webkit-font-smoothing: antialiased; 41 | -moz-osx-font-smoothing: grayscale; 42 | } 43 | 44 | .icon-right-big:before { content: '\e800'; } /* '' */ 45 | .icon-left-big:before { content: '\e801'; } /* '' */ 46 | .icon-link-ext:before { content: '\f08e'; } /* '' */ 47 | .icon-menu:before { content: '\f0c9'; } /* '' */ 48 | .icon-circle-empty:before { content: '\f10c'; } /* '' */ 49 | .icon-circle:before { content: '\f111'; } /* '' */ 50 | -------------------------------------------------------------------------------- /docs/assets/fontello/fontello.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fontello'; 3 | src: url('./font/fontello.eot?33356274'); 4 | src: url('./font/fontello.eot?33356274#iefix') format('embedded-opentype'), 5 | url('./font/fontello.woff2?33356274') format('woff2'), 6 | url('./font/fontello.woff?33356274') format('woff'), 7 | url('./font/fontello.ttf?33356274') format('truetype'), 8 | url('./font/fontello.svg?33356274#fontello') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | [class^="icon-"]:before, [class*=" icon-"]:before { 14 | font-family: "fontello"; 15 | font-style: normal; 16 | font-weight: normal; 17 | speak: none; 18 | 19 | display: inline-block; 20 | text-decoration: inherit; 21 | width: 1em; 22 | margin-right: .2em; 23 | text-align: center; 24 | 25 | /* For safety - reset parent styles, that can break glyph codes*/ 26 | font-variant: normal; 27 | text-transform: none; 28 | 29 | /* fix buttons height, for twitter bootstrap */ 30 | line-height: 1em; 31 | 32 | /* Animation center compensation - margins should be symmetric */ 33 | /* remove if not needed */ 34 | margin-left: .2em; 35 | 36 | /* you can be more comfortable with increased icons size */ 37 | /* font-size: 120%; */ 38 | 39 | /* Font smoothing. That was taken from TWBS */ 40 | -webkit-font-smoothing: antialiased; 41 | -moz-osx-font-smoothing: grayscale; 42 | } 43 | 44 | .icon-right-big:before { content: '\e800'; } /* '' */ 45 | .icon-left-big:before { content: '\e801'; } /* '' */ 46 | .icon-link-ext:before { content: '\f08e'; } /* '' */ 47 | .icon-menu:before { content: '\f0c9'; } /* '' */ 48 | .icon-circle-empty:before { content: '\f10c'; } /* '' */ 49 | .icon-circle:before { content: '\f111'; } /* '' */ 50 | -------------------------------------------------------------------------------- /src/components/distribution-chart/pointer.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import SComponent from '../s-component' 3 | import * as ev from '../../events' 4 | 5 | /** 6 | * Return triangle points for drawing polyline centered at origin 7 | */ 8 | const generateTrianglePoints = origin => { 9 | let side = 15 10 | return [ 11 | [origin[0] - side / 2, origin[1] - side / Math.sqrt(2)], 12 | [origin[0] + side / 2, origin[1] - side / Math.sqrt(2)], 13 | [origin[0], origin[1] - 2] 14 | ].map(p => p[0] + ',' + p[1]).join(' ') 15 | } 16 | 17 | /** 18 | * Pointer over current position in time axis 19 | */ 20 | export default class Pointer extends SComponent { 21 | constructor (layout, { uuid }) { 22 | super() 23 | this.selection.attr('class', 'time-pointer-group') 24 | 25 | // Save fixed y position 26 | this.yPos = layout.height 27 | 28 | this.selection.append('polyline') 29 | .attr('class', 'pointer-triangle') 30 | .attr('points', generateTrianglePoints([0, this.yPos])) 31 | 32 | // Add overlay over axis to allow clicks 33 | this.selection.append('rect') 34 | .attr('class', 'pointer-overlay') 35 | .attr('height', 80) 36 | .attr('width', layout.width) 37 | .attr('x', 0) 38 | .attr('y', layout.height - 30) 39 | 40 | this.uuid = uuid 41 | } 42 | 43 | plot (scales, currentIdx) { 44 | let uuid = this.uuid 45 | this.selection.select('.pointer-triangle') 46 | .transition() 47 | .duration(200) 48 | .attr('points', generateTrianglePoints([scales.xScale(currentIdx), this.yPos])) 49 | 50 | this.selection.select('.pointer-overlay').on('click', function () { 51 | let clickIndex = Math.round(scales.xScale.invert(d3.mouse(this)[0])) 52 | ev.publish(uuid, ev.JUMP_TO_INDEX, clickIndex) 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-foresight", 3 | "version": "0.10.1", 4 | "description": "Time series forecasts visualizations using d3", 5 | "author": "Abhinav Tushar ", 6 | "main": "./dist/d3-foresight.js", 7 | "unpkg": "./dist/d3-foresight.min.js", 8 | "repository": "git@github.com:reichlab/d3-foresight", 9 | "license": "MIT", 10 | "scripts": { 11 | "compile": "webpack", 12 | "build": "PROD_ENV=1 webpack && yes | cp ./dist/d3-foresight.min.js ./docs/assets/", 13 | "prepare": "npm run test && npm run compile && npm run build", 14 | "test": "mocha --require ts-node/register ./test/*.spec.ts && standard" 15 | }, 16 | "standard": { 17 | "ignore": [ 18 | "/docs/" 19 | ] 20 | }, 21 | "devDependencies": { 22 | "@types/chai": "^4.1.2", 23 | "@types/d3": "4.5.0", 24 | "@types/mocha": "^2.2.48", 25 | "array-equal": "^1.0.0", 26 | "babel-core": "^6.26.0", 27 | "babel-loader": "^7.1.4", 28 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 29 | "babel-preset-env": "^1.6.1", 30 | "chai": "^4.1.2", 31 | "css-loader": "^0.28.10", 32 | "d3": "4.5.0", 33 | "fs-extra": "^5.0.0", 34 | "mocha": "^5.0.4", 35 | "node-sass": "^4.7.2", 36 | "node-sass-json-importer": "^3.1.5", 37 | "sass-loader": "^6.0.7", 38 | "scss-to-json": "^2.0.0", 39 | "standard": "^8.6.0", 40 | "style-loader": "^0.20.3", 41 | "ts-loader": "^4.0.1", 42 | "ts-node": "^5.0.1", 43 | "typescript": "^2.7.2", 44 | "webpack": "^4.1.1", 45 | "webpack-cli": "^2.0.11" 46 | }, 47 | "dependencies": { 48 | "ajv": "^6.3.0", 49 | "colormap": "^2.3.0", 50 | "mmwr-week": "^1.3.2", 51 | "pubsub-js": "^1.6.0", 52 | "textures": "^1.2.0", 53 | "tinycolor2": "^1.4.1", 54 | "uuid": "^3.2.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/components/time-chart/historical-lines.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as tt from '../../utilities/tooltip' 3 | import { selectUncle } from '../../utilities/misc' 4 | import SComponent from '../s-component' 5 | 6 | /** 7 | * Historical lines 8 | */ 9 | export default class HistoricalLines extends SComponent { 10 | constructor ({ tooltip }) { 11 | super() 12 | this.selection.attr('class', 'history-group') 13 | this.tooltip = tooltip 14 | this.id = 'History' 15 | } 16 | 17 | plot (scales, historicalData) { 18 | this.clear() 19 | 20 | let line = d3.line() 21 | .x(d => scales.xScale(d.x)) 22 | .y(d => scales.yScale(d.y)) 23 | 24 | historicalData.map(hd => { 25 | let plottingData = hd.actual.map((data, idx) => { 26 | return { 27 | x: idx, 28 | y: data 29 | } 30 | }) 31 | 32 | let path = this.selection.append('path') 33 | .attr('class', 'line-history') 34 | .attr('id', hd.id + '-history') 35 | 36 | path.datum(plottingData) 37 | .transition() 38 | .duration(200) 39 | .attr('d', line) 40 | 41 | let tooltip = this.tooltip 42 | path.on('mouseover', function () { 43 | d3.select('.line-history.highlight') 44 | .datum(plottingData) 45 | .attr('d', line) 46 | tooltip.hidden = false 47 | }).on('mouseout', function () { 48 | d3.select('.line-history.highlight') 49 | .datum([]) 50 | .attr('d', line) 51 | tooltip.hidden = true 52 | }).on('mousemove', function (event) { 53 | tooltip.render(tt.parseText({ text: hd.id })) 54 | tt.moveTooltip(tooltip, selectUncle(this, '.overlay')) 55 | }) 56 | }) 57 | 58 | // Add highlight overlay 59 | this.selection.append('path') 60 | .attr('class', 'line-history highlight') 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/styles/components/control-panel/_drawer.scss: -------------------------------------------------------------------------------- 1 | .legend-drawer { 2 | @include no-select(); 3 | @include shadow(); 4 | @include border($shadow); 5 | background-color: $white; 6 | font-size: 11px; 7 | min-width: 200px; 8 | position: absolute; 9 | right: 60px; 10 | top: 10px; 11 | 12 | .legend-middle-container { 13 | background-color: $white-shade; 14 | padding: 5px 0; 15 | 16 | .control-row { 17 | align-items: center; 18 | display: flex; 19 | justify-content: space-between; 20 | margin: 5px 0; 21 | } 22 | 23 | .control-row > span:first-child { 24 | margin-right: 5px; 25 | } 26 | 27 | .toggle-button { 28 | @include border($shadow); 29 | background-color: $white; 30 | padding: 2px 6px; 31 | 32 | &.selected { 33 | background-color: $accent; 34 | border-color: $accent; 35 | color: $white; 36 | } 37 | } 38 | 39 | .search-input { 40 | border: 1px solid $shadow; 41 | font-size: 11px; 42 | padding: 5px; 43 | box-shadow: inset 0 0 1px $shadow; 44 | min-width: 90%; 45 | } 46 | } 47 | 48 | .legend-bottom-container { 49 | max-height: 200px; 50 | overflow-y: auto; 51 | } 52 | 53 | .row { 54 | padding: 4px 10px; 55 | margin: 0; 56 | 57 | &::before, &::after { 58 | content: none; 59 | } 60 | 61 | i { 62 | @include icon(); 63 | } 64 | 65 | .row-icon { 66 | margin-right: 3px; 67 | } 68 | 69 | .row-title { 70 | display: inline-block; 71 | font-size: 11px; 72 | vertical-align: middle; 73 | } 74 | 75 | .row-url { 76 | float: right; 77 | color: $gray-light; 78 | i { 79 | margin: 0; 80 | } 81 | } 82 | 83 | &.na { 84 | i { 85 | color: $disabled !important; 86 | } 87 | 88 | color: $disabled; 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/common/control-panel/control-buttons.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as ev from '../../../events' 3 | import * as tt from '../../../utilities/tooltip' 4 | import Component from '../../component' 5 | 6 | /** 7 | * Side buttons in control panel 8 | */ 9 | export default class ControlButtons extends Component { 10 | constructor (tooltip, uuid) { 11 | super() 12 | 13 | this.selection.classed('control-btns', true) 14 | 15 | let buttonData = [ 16 | { 17 | name: 'legendBtn', 18 | iconClass: 'icon-menu', 19 | tooltipText: 'Toggle Legend', 20 | event: ev.PANEL_TOGGLE 21 | }, 22 | { 23 | name: 'backBtn', 24 | iconClass: 'icon-left-big', 25 | tooltipText: 'Move backward', 26 | event: ev.PANEL_MOVE_PREV 27 | }, 28 | { 29 | name: 'nextBtn', 30 | iconClass: 'icon-right-big', 31 | tooltipText: 'Move forward', 32 | event: ev.PANEL_MOVE_NEXT 33 | } 34 | ] 35 | 36 | // Save all the buttons for toggling state and stuff 37 | let buttons = buttonData.map(data => { 38 | let btnDiv = this.selection.append('div') 39 | .classed('btn', true) 40 | .on('mouseover', () => { tooltip.hidden = false }) 41 | .on('mouseout', () => { tooltip.hidden = true }) 42 | .on('mousemove', function () { 43 | tooltip.render(tt.parseText({ text: data.tooltipText })) 44 | tt.moveTooltip(tooltip, d3.select(this), 'left') 45 | }) 46 | .on('click', () => ev.publish(uuid, data.event, {})) 47 | 48 | btnDiv.append('i') 49 | .classed(data.iconClass, true) 50 | return btnDiv 51 | }) 52 | 53 | this.legendBtn = buttons[0] 54 | } 55 | 56 | get legendBtnState () { 57 | return this.legendBtn.classed('active') 58 | } 59 | 60 | set legendBtnState (state) { 61 | this.legendBtn.classed('active', state) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/distribution-chart/overlay.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as tt from '../../utilities/tooltip' 3 | import SComponent from '../s-component' 4 | 5 | export default class Overlay extends SComponent { 6 | constructor (layout, { tooltip }) { 7 | super() 8 | 9 | // Add mouse hover line 10 | this.line = this.selection.append('line') 11 | .attr('class', 'hover-line') 12 | .attr('x1', 0) 13 | .attr('y1', 0) 14 | .attr('x2', 0) 15 | .attr('y2', layout.height) 16 | .style('display', 'none') 17 | 18 | this.overlay = this.selection.append('rect') 19 | .attr('class', 'overlay') 20 | .attr('height', layout.height) 21 | .attr('width', layout.width) 22 | .on('mouseover', () => { 23 | this.line.style('display', null) 24 | tooltip.hidden = false 25 | }) 26 | .on('mouseout', () => { 27 | this.line.style('display', 'none') 28 | tooltip.hidden = true 29 | }) 30 | this.tooltip = tooltip 31 | } 32 | 33 | plot (scales, predictions) { 34 | let line = this.line 35 | let tooltip = this.tooltip 36 | this.overlay 37 | .on('mousemove', function () { 38 | let mouse = d3.mouse(this) 39 | // Snap x to nearest tick 40 | let index = Math.round(mouse[0] / scales.xScale.range()[1] * scales.xScale.domain().length) 41 | let snappedX = scales.xScale(scales.xScale.domain()[index]) 42 | 43 | // Move the cursor 44 | line 45 | .transition() 46 | .duration(50) 47 | .attr('x1', snappedX) 48 | .attr('x2', snappedX) 49 | 50 | // Format bin value to display 51 | let binVal = tt.formatBin(scales.xScale.domain(), index) 52 | 53 | tooltip.render(tt.parsePredictions({ 54 | title: `Bin: ${binVal}`, 55 | predictions: predictions, 56 | index 57 | })) 58 | 59 | // Tooltip position 60 | tt.moveTooltip(tooltip, d3.select(this)) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/time-chart/observed.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import SComponent from '../s-component' 3 | 4 | /** 5 | * Observed (at the time of prediction) line 6 | */ 7 | export default class Observed extends SComponent { 8 | constructor () { 9 | super() 10 | this.selection.attr('class', 'observed-group') 11 | this.line = this.selection.append('path') 12 | .attr('class', 'line-observed') 13 | this.id = 'Observed' 14 | } 15 | 16 | plot (scales, observedData) { 17 | // Save data for queries and updates 18 | this.observedData = observedData 19 | this.xScale = scales.xScale 20 | this.yScale = scales.yScale 21 | } 22 | 23 | update (idx) { 24 | let filteredData = [] 25 | 26 | try { 27 | for (let i = 0; i <= idx; i++) { 28 | let yLags = this.observedData[idx - i].slice().filter(d => d.lag <= i) 29 | filteredData.push({ 30 | x: idx - i, 31 | y: yLags.sort((a, b) => (b.lag - a.lag))[0].value 32 | }) 33 | } 34 | } catch (e) { 35 | filteredData = [] 36 | } 37 | 38 | let circles = this.selection.selectAll('.point-observed') 39 | .data(filteredData) 40 | 41 | circles.exit().remove() 42 | 43 | circles.enter().append('circle') 44 | .merge(circles) 45 | .attr('class', 'point-observed') 46 | .transition() 47 | .duration(200) 48 | .ease(d3.easeQuadOut) 49 | .attr('cx', d => this.xScale(d.x)) 50 | .attr('cy', d => this.yScale(d.y)) 51 | .attr('r', 2) 52 | 53 | let line = d3.line() 54 | .x(d => this.xScale(d.x)) 55 | .y(d => this.yScale(d.y)) 56 | 57 | this.line 58 | .datum(filteredData) 59 | .transition() 60 | .duration(200) 61 | .attr('d', line) 62 | 63 | filteredData.reverse() 64 | this.filteredData = filteredData 65 | } 66 | 67 | query (idx) { 68 | if (this.hidden) { 69 | return false 70 | } else { 71 | try { 72 | return this.filteredData[idx].y 73 | } catch (e) { 74 | return false 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/time-chart/baseline.js: -------------------------------------------------------------------------------- 1 | import * as tt from '../../utilities/tooltip' 2 | import { selectUncle } from '../../utilities/misc' 3 | import SComponent from '../s-component' 4 | 5 | /** 6 | * Baseline 7 | */ 8 | export default class Baseline extends SComponent { 9 | constructor (layout, { tooltip, text, description, url }) { 10 | super() 11 | this.selection.attr('class', 'baseline-group') 12 | 13 | this.line = this.selection.append('line') 14 | .attr('x1', 0) 15 | .attr('y1', 0) 16 | .attr('x2', layout.width) 17 | .attr('y2', 0) 18 | .attr('class', 'baseline') 19 | 20 | this.text = this.selection.append('text') 21 | .attr('transform', `translate(${layout.width + 10}, 0)`) 22 | 23 | // Setup multiline text 24 | if (Array.isArray(text)) { 25 | this.text.append('tspan') 26 | .text(text[0]) 27 | .attr('x', 0) 28 | text.slice(1).forEach(txt => { 29 | this.text.append('tspan') 30 | .text(txt) 31 | .attr('x', 0) 32 | .attr('dy', '1em') 33 | }) 34 | } else { 35 | this.text.append('tspan') 36 | .text(text) 37 | .attr('x', 0) 38 | } 39 | 40 | this.text 41 | .on('mouseover', () => { tooltip.hidden = false }) 42 | .on('mouseout', () => { tooltip.hidden = true }) 43 | .on('mousemove', function () { 44 | tooltip.render(tt.parseText({ text: description })) 45 | tt.moveTooltip(tooltip, selectUncle(this, '.overlay'), 'left') 46 | }) 47 | 48 | if (url) { 49 | this.text 50 | .style('cursor', 'pointer') 51 | .on('click', () => { 52 | window.open(url, '_blank') 53 | }) 54 | } 55 | } 56 | 57 | plot (scales, baseline) { 58 | if (baseline) { 59 | this.hidden = false 60 | } else { 61 | this.hidden = true 62 | return 63 | } 64 | 65 | this.line 66 | .transition() 67 | .duration(200) 68 | .attr('y1', scales.yScale(baseline)) 69 | .attr('y2', scales.yScale(baseline)) 70 | 71 | this.text 72 | .transition() 73 | .duration(200) 74 | .attr('dy', scales.yScale(baseline)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/common/control-panel/drawer-row.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Row class in control panel 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import * as d3 from 'd3' 9 | import * as tt from '../../../utilities/tooltip' 10 | import Component from '../../component' 11 | 12 | /** 13 | * An item in the legend drawer. 14 | */ 15 | export default class DrawerRow extends Component { 16 | constructor (name, color) { 17 | super() 18 | 19 | this.id = name 20 | this.selection.attr('class', `row`) 21 | 22 | this.icon = this.selection.append('i') 23 | .style('color', color) 24 | .classed('row-icon', true) 25 | 26 | this.selection.append('span') 27 | .attr('class', 'row-title') 28 | .text(name) 29 | } 30 | 31 | get active () { 32 | return this.icon.classed('icon-circle') 33 | } 34 | 35 | /** 36 | * Activate the row. Expected outcome is that the corresponding item 37 | * will be visible now. 38 | */ 39 | set active (state) { 40 | this.icon.classed('icon-circle', state) 41 | this.icon.classed('icon-circle-empty', !state) 42 | } 43 | 44 | get na () { 45 | this.selection.classed('na') 46 | } 47 | 48 | /** 49 | * Not applicable, there is no data to show. The row is grayed out. 50 | */ 51 | set na (state) { 52 | this.selection.classed('na', state) 53 | } 54 | 55 | addLink (url, tooltip) { 56 | let urlAnchor = this.selection.append('a') 57 | .attr('href', url) 58 | .attr('target', '_blank') 59 | .classed('row-url', true) 60 | 61 | urlAnchor.append('i') 62 | .classed('icon-link-ext', true) 63 | 64 | urlAnchor 65 | .on('mousemove', function () { 66 | d3.event.stopPropagation() 67 | tooltip.render(tt.parseText({ text: 'Show details' })) 68 | tt.moveTooltip(tooltip, d3.select(this), 'left') 69 | }) 70 | .on('click', () => d3.event.stopPropagation()) 71 | } 72 | 73 | addOnClick (fn) { 74 | super.addOnClick() 75 | this.selection.on('click', () => { 76 | this.active = !this.active 77 | fn({ id: this.id, state: this.active }) 78 | }) 79 | } 80 | 81 | click () { 82 | this.selection.on('click')() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/time-chart/prediction/line-marker.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import SComponent from '../../s-component' 3 | import { applyStyle } from '../../../utilities/style' 4 | import { kebabCase } from '../../../utilities/misc' 5 | 6 | export default class LineMarker extends SComponent { 7 | constructor (id, style) { 8 | super() 9 | this.selection 10 | .attr('class', 'prediction-group') 11 | .attr('id', kebabCase(id) + '-marker') 12 | 13 | this.selection.append('path') 14 | .attr('class', 'area-prediction') 15 | .style('fill', style.color) 16 | applyStyle(this.selection.select('.area-prediction'), style.area) 17 | 18 | this.selection.append('path') 19 | .attr('class', 'line-prediction') 20 | .style('stroke', style.color) 21 | applyStyle(this.selection.select('.line-prediction'), style.line) 22 | 23 | this.selection.selectAll('.point-prediction') 24 | .enter() 25 | .append('circle') 26 | .attr('class', 'point-prediction') 27 | } 28 | 29 | move (cfg, series) { 30 | let circles = this.selection.selectAll('.point-prediction') 31 | .data(series) 32 | 33 | circles.exit().remove() 34 | 35 | circles.enter().append('circle') 36 | .merge(circles) 37 | .attr('class', 'point-prediction') 38 | .transition() 39 | .duration(200) 40 | .ease(d3.easeQuadOut) 41 | .attr('cx', d => cfg.scales.xScale(d.index)) 42 | .attr('cy', d => cfg.scales.yScale(d.point)) 43 | .attr('r', 3) 44 | .style('stroke', cfg.style.color) 45 | applyStyle(circles, cfg.style.point) 46 | 47 | let line = d3.line() 48 | .x(d => cfg.scales.xScale(d.index)) 49 | .y(d => cfg.scales.yScale(d.point)) 50 | 51 | this.selection.select('.line-prediction') 52 | .datum(series) 53 | .transition() 54 | .duration(200) 55 | .attr('d', line) 56 | 57 | let area = d3.area() 58 | .x(d => cfg.scales.xScale(d.index)) 59 | .y1(d => cfg.scales.yScale(d.low)) 60 | .y0(d => cfg.scales.yScale(d.high)) 61 | 62 | this.selection.select('.area-prediction') 63 | .datum(series) 64 | .transition() 65 | .duration(200) 66 | .attr('d', area) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /assets/fontello/font/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2018 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/assets/fontello/font/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2018 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/utilities/colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Color related functions 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import * as d3 from 'd3' 9 | import * as tinycolor from 'tinycolor2' 10 | 11 | /** 12 | * Some pre generated palettes from http://tools.medialab.sciences-po.fr/iwanthue/ 13 | */ 14 | export const colors30 = [ 15 | '#eb9491', 16 | '#e26d67', 17 | '#f0574b', 18 | '#e3845e', 19 | '#e76b2e', 20 | '#c6925e', 21 | '#d88a2f', 22 | '#d2aa3b', 23 | '#d3c179', 24 | '#bfc83c', 25 | '#909d37', 26 | '#abd077', 27 | '#75c142', 28 | '#6c9e5b', 29 | '#4dc968', 30 | '#62c793', 31 | '#61daca', 32 | '#3cb8c0', 33 | '#5daade', 34 | '#568ced', 35 | '#9999dc', 36 | '#9c78ef', 37 | '#be87d4', 38 | '#db6dd8', 39 | '#e29cce', 40 | '#e165b7', 41 | '#ef5297', 42 | '#dc77a0', 43 | '#ec5778', 44 | '#cb7478' 45 | ] 46 | 47 | export const colors50 = [ 48 | '#d27878', 49 | '#de7a58', 50 | '#e96735', 51 | '#ed9d7d', 52 | '#d48232', 53 | '#b8844c', 54 | '#e5b06f', 55 | '#df9b2a', 56 | '#debf2e', 57 | '#d5c056', 58 | '#a59229', 59 | '#ccc17d', 60 | '#9a914f', 61 | '#94a231', 62 | '#b2c834', 63 | '#bdd461', 64 | '#b9cf84', 65 | '#8ca259', 66 | '#74a530', 67 | '#96dc5b', 68 | '#6e984c', 69 | '#69b92e', 70 | '#95cf73', 71 | '#3bab40', 72 | '#61d96a', 73 | '#77b97c', 74 | '#90da99', 75 | '#46a459', 76 | '#50d885', 77 | '#4a9a74', 78 | '#42dcaa', 79 | '#7fdcbf', 80 | '#4aba9e', 81 | '#46c9d2', 82 | '#5cb4e1', 83 | '#499ae1', 84 | '#4f8af1', 85 | '#8b94d4', 86 | '#8982e6', 87 | '#c4a8ef', 88 | '#b171ed', 89 | '#c87dd6', 90 | '#e364d2', 91 | '#eea7e0', 92 | '#c57dae', 93 | '#e464ae', 94 | '#eb5a88', 95 | '#e88ba1', 96 | '#f14c55', 97 | '#ea6368' 98 | ] 99 | 100 | /** 101 | * Convert hex to rgba 102 | */ 103 | export function hexToRgba (hex: string, alpha: number): string { 104 | return tinycolor(hex).setAlpha(alpha).toRgbString() 105 | } 106 | 107 | /** 108 | * Return colormap of given size 109 | */ 110 | export function getColorMap (size: number): string[] { 111 | if (size > 30) { 112 | return colors50 113 | } else if (size > 20) { 114 | return colors30 115 | } else if (size > 10) { 116 | // @ts-ignore 117 | return d3.schemeCategory20 as string[] 118 | } else { 119 | // @ts-ignore 120 | return d3.schemeCategory10 as string[] 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/utilities/misc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Module for miscellaneous functions 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import * as d3 from 'd3' 9 | import { Position } from '../interfaces' 10 | import { DocumentError } from './errors' 11 | 12 | export function kebabCase(text) { 13 | return text.toLowerCase().replace(/ /g, '-') 14 | } 15 | 16 | /** 17 | * Return mouse position as absolute value for current view using the provided 18 | * d3Selection. The selection here matters because many of the elements with 19 | * mouse events are translated with respect to original svg. Most of the calls 20 | * to this function use .overlay as reference 21 | */ 22 | export function getMousePosition (d3Selection): Position { 23 | let [x, y] = d3.mouse(d3Selection.node()) 24 | let bb = d3Selection.node().getBoundingClientRect() 25 | return [x + bb.left, y + bb.top] 26 | } 27 | 28 | /** 29 | * Return line objects which are present in lines. Clear the ones 30 | * which are absent. 31 | */ 32 | export function filterActiveLines (lineList, lines) { 33 | let lineIds = lines.map(l => l.id) 34 | return lineList.filter(l => { 35 | if (lineIds.indexOf(l.id) === -1) { 36 | l.clear() 37 | return false 38 | } else { 39 | return true 40 | } 41 | }) 42 | } 43 | 44 | /** 45 | * Return uncle d3 selection 46 | */ 47 | export function selectUncle (currentSelector, uncleSelector: string) { 48 | let currentNode = d3.select(currentSelector).node() 49 | 50 | let walkUp = (cNode, pNode) => { 51 | if (pNode === null) { 52 | // We have reached the top level 53 | throw new DocumentError(`Selector ${uncleSelector} not found`) 54 | } else { 55 | let selection = d3.select(pNode).select(uncleSelector) 56 | if (selection.node() === null) { 57 | return walkUp(pNode, pNode.parentNode) 58 | } else { 59 | return selection 60 | } 61 | } 62 | } 63 | 64 | return walkUp(currentNode, currentNode.parentNode) 65 | } 66 | 67 | function allEqual (array: number[], eqFn): boolean { 68 | // @ts-ignore 69 | return !!array.reduce((acc, it) => eqFn(acc, it) ? acc : false) 70 | } 71 | 72 | /** 73 | * Take or of arrays, assume values at same indices to be the same 74 | */ 75 | export function orArrays (arrays: number[][], eqFn = (a, b) => a === b): number[] { 76 | let len = arrays[0].length 77 | 78 | // We can always take the largest array but lets not do that right now 79 | if (arrays.some(arr => arr.length !== len)) { 80 | throw new Error('Arrays of unequal length passed while oring') 81 | } 82 | 83 | return arrays[0].map((it, idx) => { 84 | let nonNulls = arrays.map(arr => arr[idx]).filter(d => d !== null) 85 | if (nonNulls.length > 0) { 86 | if (allEqual(nonNulls, eqFn)) { 87 | return nonNulls[0] 88 | } else { 89 | throw new Error('Non equal items in arrays') 90 | } 91 | } else { 92 | return null 93 | } 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /src/components/distribution-chart/distribution-panel.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import { XAxis } from '../common/axis-x' 3 | import { YAxis } from '../common/axis-y' 4 | import Prediction from './prediction' 5 | import * as domains from '../../utilities/data/domains' 6 | import Overlay from './overlay' 7 | import NoPredText from './no-pred-text' 8 | import * as colors from '../../utilities/colors' 9 | import { filterActiveLines } from '../../utilities/misc' 10 | import SComponent from '../s-component' 11 | 12 | /** 13 | * A panel displaying distributions for one curve 14 | */ 15 | export default class DistributionPanel extends SComponent { 16 | constructor (layout, { tooltip }) { 17 | super() 18 | this.xScale = d3.scalePoint().range([0, layout.width]) 19 | this.yScale = d3.scaleLinear().range([layout.height, 0]) 20 | this.xAxis = this.append(new XAxis(layout, { tooltip })) 21 | this.yAxis = this.append(new YAxis(layout, { 22 | title: 'Probability', 23 | description: 'Probability assigned to x-axis bins', 24 | tooltip 25 | })) 26 | 27 | this.predictions = [] 28 | this.selectedCurveIdx = null 29 | this.tooltip = tooltip 30 | this.layout = layout 31 | this.overlay = this.append(new Overlay(layout, { tooltip })) 32 | this.noPredText = this.append(new NoPredText()) 33 | } 34 | 35 | get scales () { 36 | return { 37 | xScale: this.xScale, 38 | yScale: this.yScale 39 | } 40 | } 41 | 42 | plot (data, yLimits) { 43 | this.xScale.domain(domains.xCurve(data, this.selectedCurveIdx)) 44 | this.yScale.domain([0, yLimits[this.selectedCurveIdx]]) 45 | 46 | this.xAxis.plot(this.scales, 10) 47 | this.yAxis.plot(this.scales, 5) 48 | 49 | // Setup colormap 50 | this.colors = colors.getColorMap(data.models.length) 51 | 52 | // Clear markers not needed 53 | this.predictions = filterActiveLines(this.predictions, data.models) 54 | 55 | // Generate markers for predictions if not already there 56 | // Assume unique model ids 57 | data.models.forEach((m, idx) => { 58 | let predMarker 59 | let markerIndex = this.predictions.findIndex(p => p.id === m.id) 60 | if (markerIndex === -1) { 61 | // The marker is not present from previous calls to plot 62 | predMarker = new Prediction({ 63 | id: m.id, 64 | meta: m.meta, 65 | style: { color: this.colors[idx], ...m.style } 66 | }) 67 | this.append(predMarker) 68 | this.predictions.push(predMarker) 69 | } else { 70 | predMarker = this.predictions[markerIndex] 71 | } 72 | predMarker.plot(this.scales, m.curves[this.selectedCurveIdx]) 73 | }) 74 | 75 | this.overlay.plot(this.scales, this.predictions) 76 | 77 | // Check if all markers have noData. That means we can show NA text. 78 | this.noPredText.hidden = (this.predictions.filter(p => p.noData).length !== this.predictions.length) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/distribution-chart/prediction.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import SComponent from '../s-component' 3 | import { applyStyle } from '../../utilities/style' 4 | import { kebabCase } from '../../utilities/misc' 5 | 6 | /** 7 | * Prediction marker for distribution chart 8 | */ 9 | export default class Prediction extends SComponent { 10 | constructor ({ id, meta, style }) { 11 | super() 12 | this.selection 13 | .attr('class', 'prediction-group') 14 | .attr('id', kebabCase(id) + '-marker') 15 | 16 | this.selection.append('path') 17 | .attr('class', 'area-prediction') 18 | .style('fill', style.color) 19 | applyStyle(this.selection.select('.area-prediction'), style.area) 20 | 21 | this.selection.append('path') 22 | .attr('class', 'line-prediction') 23 | .style('stroke', style.color) 24 | applyStyle(this.selection.select('.line-prediction'), style.line) 25 | 26 | this.style = style 27 | this.id = id 28 | this.meta = meta 29 | 30 | // Tells if the prediction is hidden by some other component 31 | this._hidden = false 32 | // Tells if data is available to be shown for current time 33 | this.noData = true 34 | } 35 | 36 | plot (scales, curveData) { 37 | if (curveData.data === null) { 38 | // There is no data for current point, hide the markers without 39 | // setting exposed hidden flag 40 | this.noData = true 41 | this.hideMarkers() 42 | } else { 43 | this.noData = false 44 | if (!this.hidden) { 45 | // No one is hiding me 46 | this.showMarkers() 47 | } 48 | 49 | let line = d3.line() 50 | .x(d => scales.xScale(d[0])) 51 | .y(d => scales.yScale(d[1])) 52 | 53 | this.selection.select('.line-prediction') 54 | .datum(curveData.data) 55 | .transition() 56 | .duration(200) 57 | .attr('d', line) 58 | 59 | let area = d3.area() 60 | .x(d => scales.xScale(d[0])) 61 | .y1(d => scales.yScale(0)) 62 | .y0(d => scales.yScale(d[1])) 63 | 64 | this.selection.select('.area-prediction') 65 | .datum(curveData.data) 66 | .transition() 67 | .duration(200) 68 | .attr('d', area) 69 | } 70 | this.displayedData = curveData.data 71 | } 72 | 73 | query (index) { 74 | return (!this.noData && !this.hidden && this.displayedData[index][1]) 75 | } 76 | 77 | /** 78 | * Check if we are hidden 79 | */ 80 | get hidden () { 81 | return this._hidden 82 | } 83 | 84 | set hidden (hide) { 85 | if (hide) { 86 | this.hideMarkers() 87 | } else { 88 | if (!this.noData) { 89 | this.showMarkers() 90 | } 91 | } 92 | this._hidden = hide 93 | } 94 | 95 | hideMarkers () { 96 | super.hidden = true 97 | } 98 | 99 | showMarkers () { 100 | super.hidden = false 101 | } 102 | 103 | /** 104 | * Remove the markers 105 | */ 106 | clear () { 107 | super.clear() 108 | this.selection.remove() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/utilities/data/domains.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for finding scale domains from data object 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import * as d3 from 'd3' 9 | import * as errors from '../errors' 10 | import { Range } from '../../interfaces' 11 | import { getDateTime } from './timepoints' 12 | 13 | /** 14 | * Return max of a pred object { point, high?, low? } 15 | */ 16 | function predMax(pred): number { 17 | let max = pred.point 18 | if (pred.high) { 19 | max = Math.max(max, ...pred.high) 20 | } 21 | return max 22 | } 23 | 24 | /** 25 | * Return domain for y axis using limits of data 26 | */ 27 | export function y (data, dataConfig): Range { 28 | let min = 0 29 | let max = 0 30 | 31 | if (dataConfig.actual) { 32 | max = Math.max(max, ...data.actual.filter(d => d)) 33 | } 34 | 35 | if (dataConfig.observed) { 36 | data.observed.forEach(d => { 37 | max = Math.max(max, ...d.map(lagD => lagD.value)) 38 | }) 39 | } 40 | 41 | if (dataConfig.history) { 42 | data.history.forEach(h => { 43 | max = Math.max(max, ...h.actual) 44 | }) 45 | } 46 | 47 | data.models.forEach(md => { 48 | md.predictions.forEach(p => { 49 | if (p) { 50 | max = Math.max(max, ...p.series.map(predMax)) 51 | if (dataConfig.predictions.peak) { 52 | max = Math.max(max, predMax(p.peakValue)) 53 | } 54 | } 55 | }) 56 | }) 57 | 58 | return [min, 1.1 * max] 59 | } 60 | 61 | /** 62 | * Return domain of x 63 | */ 64 | export function x (data, dataConfig): Range { 65 | return [0, data.timePoints.length - 1] 66 | } 67 | 68 | /** 69 | * Return domain for xdate 70 | */ 71 | export function xDate (data, dataConfig): Range { 72 | return d3.extent(data.timePoints.map(tp => { 73 | return getDateTime(tp, dataConfig.pointType) 74 | })) 75 | } 76 | 77 | /** 78 | * Return point scale domain 79 | */ 80 | export function xPoint (data, dataConfig): Range { 81 | return dataConfig.ticks 82 | } 83 | 84 | /** 85 | * Return domain for given curveid 86 | */ 87 | export function xCurve (data, curveIdx: number): Range { 88 | // This assumes an ordinal scale 89 | for (let i = 0; i < data.models.length; i++) { 90 | let curveData = data.models[i].curves[curveIdx].data 91 | if (curveData) { 92 | // Return the x series directly 93 | return curveData.map(d => d[0]) 94 | } 95 | } 96 | return [0, 0] 97 | } 98 | 99 | /** 100 | * Get shared y limits for type of data 101 | */ 102 | export function yCurveMaxima (data) { 103 | let modelMaxes = data.models 104 | .filter(m => { 105 | // NOTE: Filtering based on the assumption that one model will have 106 | // /all/ the curves or none of them 107 | return m.curves.filter(c => c.data).length === m.curves.length 108 | }) 109 | .map(m => { 110 | return m.curves.map(c => { 111 | return [c.data.length, Math.max(...c.data.map(d => d[1]))] 112 | }) 113 | }) 114 | 115 | // HACK: Simplify this 116 | // Identify curve type using the length of values in them 117 | let lengthToLimit = modelMaxes.reduce((acc, mm) => { 118 | mm.forEach(c => { 119 | acc[c[0]] = acc[c[0]] ? Math.max(acc[c[0]], c[1]) : c[1] 120 | }) 121 | return acc 122 | }, {}) 123 | 124 | let lengths = modelMaxes[0].map(c => c[0]) 125 | return lengths.map(l => lengthToLimit[l]) 126 | } 127 | -------------------------------------------------------------------------------- /src/components/time-chart/additional-line.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import SComponent from '../s-component' 3 | import { applyStyle } from '../../utilities/style' 4 | 5 | /** 6 | * Additional line. Other components actually can inherit from this after a while. 7 | * Will also factor this properly. 8 | */ 9 | export default class AdditionalLine extends SComponent { 10 | constructor (layout, { id, meta, style, legend, tooltip }) { 11 | super() 12 | this.selection.attr('class', 'additional-group') 13 | 14 | this.line = this.selection.append('line') 15 | .attr('x1', 0) 16 | .attr('y1', 0) 17 | .attr('x2', layout.width) 18 | .attr('y2', 0) 19 | .style('stroke', style.color) 20 | .style('fill', 'transparent') 21 | 22 | applyStyle(this.line, style.line) 23 | 24 | this.path = this.selection.append('path') 25 | .style('stroke', style.color) 26 | .style('fill', 'transparent') 27 | 28 | applyStyle(this.path, style.line) 29 | 30 | this.id = id 31 | this.meta = meta 32 | this.style = style 33 | this.legend = legend 34 | this.tooltip = tooltip 35 | 36 | this.dataType = 'array' // 'array' or 'scalar' 37 | } 38 | 39 | plotScalar (scales, value) { 40 | this.line 41 | .transition() 42 | .duration(200) 43 | .attr('y1', scales.yScale(value)) 44 | .attr('y2', scales.yScale(value)) 45 | this.data = value 46 | } 47 | 48 | plotArray (scales, array) { 49 | // Save data for queries 50 | this.data = array.map((val, idx) => { 51 | return { 52 | x: idx, 53 | y: val 54 | } 55 | }) 56 | 57 | let path = d3.line() 58 | .x(d => scales.xScale(d.x)) 59 | .y(d => scales.yScale(d.y)) 60 | 61 | this.path 62 | .datum(this.data.filter(d => d.y)) 63 | .transition() 64 | .duration(200) 65 | .attr('d', path) 66 | 67 | let r 68 | try { 69 | r = this.style.point.r 70 | } catch (e) { 71 | r = 2 72 | } 73 | 74 | // Only plot non nulls 75 | let circles = this.selection.selectAll('.point-additional') 76 | .data(this.data.filter(d => d.y)) 77 | 78 | circles.exit().remove() 79 | 80 | circles.enter().append('circle') 81 | .merge(circles) 82 | .attr('class', 'point-additional') 83 | .transition(200) 84 | .ease(d3.easeQuadOut) 85 | .attr('cx', d => scales.xScale(d.x)) 86 | .attr('cy', d => scales.yScale(d.y)) 87 | .attr('r', r) 88 | .style('stroke', this.style.color) 89 | .style('fill', this.style.color) 90 | 91 | applyStyle(circles, this.style.point) 92 | } 93 | 94 | plot (scales, data) { 95 | if (typeof data === 'number') { 96 | this.dataType = 'scalar' 97 | this.path.attr('display', 'none') 98 | this.line.attr('display', null) 99 | this.plotScalar(scales, data) 100 | } else { 101 | this.dataType = 'array' 102 | this.line.attr('display', 'none') 103 | this.path.attr('display', null) 104 | this.plotArray(scales, data) 105 | } 106 | } 107 | 108 | query (idx) { 109 | if (this.hidden) { 110 | return false 111 | } else { 112 | try { 113 | if (this.dataType === 'scalar') { 114 | return this.data 115 | } else { 116 | return this.data[idx].y 117 | } 118 | } catch (e) { 119 | return false 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/utilities/data/time-chart-model.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | 4 | "definitions": { 5 | "pred": { 6 | "type": "object", 7 | "description": "Prediction for season onset", 8 | "properties": { 9 | "point": { 10 | "description": "Point value of the prediction", 11 | "type": "number" 12 | }, 13 | "high": { 14 | "description": "High values for the confidence interval ranges", 15 | "type": "array", 16 | "items": { "type": "number" } 17 | }, 18 | "low": { 19 | "description": "Low values for the confidence interval ranges", 20 | "type": "array", 21 | "items": { "type": "number" } 22 | } 23 | }, 24 | "required": ["point"] 25 | } 26 | }, 27 | 28 | "title": "Model", 29 | "description": "A model with data for timeChart", 30 | "type": "object", 31 | "properties": { 32 | "id": { 33 | "description": "Short unique identifier for the model", 34 | "type": "string" 35 | }, 36 | "pinned": { 37 | "description": "Whether to pin this model in legend", 38 | "type": "boolean" 39 | }, 40 | "meta": { 41 | "description": "Metadata for the model", 42 | "type": "object", 43 | "properties": { 44 | "description": { 45 | "description": "Text with description of the model to show on tooltips", 46 | "type": "string" 47 | }, 48 | "name": { 49 | "description": "Full name of the model", 50 | "type": "string" 51 | }, 52 | "url": { 53 | "description": "Url for getting more details about the model", 54 | "type": "string", 55 | "format": "uri" 56 | } 57 | }, 58 | "required": ["description", "name"] 59 | }, 60 | "style": { 61 | "description": "Style settings for the model", 62 | "type": "object", 63 | "properties": { 64 | "color": { 65 | "description": "Base color for the model, by default this comes from the internal palette", 66 | "type": "string" 67 | }, 68 | "point": { 69 | "description": "CSS styles for dots in the line", 70 | "type": "object" 71 | }, 72 | "area": { 73 | "description": "CSS styles for the confidence area", 74 | "type": "object" 75 | }, 76 | "line": { 77 | "description": "CSS styles for the main line", 78 | "type": "object" 79 | } 80 | } 81 | }, 82 | "predictions": { 83 | "description": "Prediction array for each time point", 84 | "type": "array", 85 | "items": { 86 | "description": "Set of predictions for one time point", 87 | "type": ["object", "null"], 88 | "properties": { 89 | "onsetTime": { "$ref": "#/definitions/pred" }, 90 | "peakTime": { "$ref": "#/definitions/pred" }, 91 | "peakValue": { "$ref": "#/definitions/pred" }, 92 | "dataVersionTime": { 93 | "description": "Time specifying the data available for that prediction" 94 | }, 95 | "series": { 96 | "description": "Time ahead predictions", 97 | "type": "array", 98 | "items": { "$ref": "#/definitions/pred" } 99 | } 100 | }, 101 | "required": ["series"] 102 | } 103 | } 104 | }, 105 | "required": ["id", "meta", "predictions"] 106 | } 107 | -------------------------------------------------------------------------------- /src/utilities/data/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for parsing data object for information 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import { Timepoint } from '../../interfaces' 9 | import * as errors from '../errors' 10 | import { getTick } from './timepoints' 11 | 12 | /** 13 | * Tell if onset predictions are present in the data 14 | */ 15 | function isOnsetPresent (modelsData): boolean { 16 | let model = modelsData[0] 17 | let nonNullPreds = model.predictions.filter(p => p !== null) 18 | if (nonNullPreds.length === 0) { 19 | // Just to be safe 20 | return false 21 | } else { 22 | return 'onsetTime' in nonNullPreds[0] 23 | } 24 | } 25 | 26 | /** 27 | * Tell if peak predictions are present in the data 28 | */ 29 | function isPeakPresent (modelsData): boolean { 30 | let model = modelsData[0] 31 | let nonNullPreds = model.predictions.filter(p => p !== null) 32 | if (nonNullPreds.length === 0) { 33 | // Just to be safe 34 | return false 35 | } else { 36 | return ('peakTime' in nonNullPreds[0]) && ('peakValue' in nonNullPreds[0]) 37 | } 38 | } 39 | 40 | /** 41 | * Return list of model ids that are to be pinned 42 | */ 43 | function pinnedModelIds (modelsData): string[] { 44 | return modelsData.filter(model => { 45 | return 'pinned' in model ? model.pinned : false 46 | }).map(model => model.id) 47 | } 48 | 49 | /** 50 | * Tell if we have data version date present in the predictions data 51 | */ 52 | function isVersionTimePresent (modelsData): boolean { 53 | let model = modelsData[0] 54 | let nonNullPreds = model.predictions.filter(p => p !== null) 55 | if (nonNullPreds.length === 0) { 56 | // Keeping the default behavior simple 57 | return false 58 | } else { 59 | return nonNullPreds.every(p => 'dataVersionTime' in p) 60 | } 61 | } 62 | 63 | /** 64 | * Whether to show the timezeroLine 65 | */ 66 | function showTimezeroLine (data, config): boolean { 67 | if ('timezeroLine' in config) { 68 | // key takes priority 69 | return config.timezeroLine 70 | } else if ('timezeroLine' in data) { 71 | return data.timezeroLine 72 | } else { 73 | // If version time is present, we show the timezero by default 74 | return isVersionTimePresent(data.models) 75 | } 76 | } 77 | 78 | 79 | /** 80 | * Parse time chart data and provide information about it 81 | */ 82 | export function getTimeChartDataConfig (data, config) { 83 | return { 84 | actual: 'actual' in data, 85 | observed: 'observed' in data, 86 | history: 'history' in data, 87 | baseline: 'baseline' in data, 88 | timezeroLine: showTimezeroLine(data, config), 89 | predictions: { 90 | peak: isPeakPresent(data.models), 91 | onset: config.onset && isOnsetPresent(data.models), 92 | versionTime: isVersionTimePresent(data.models) 93 | }, 94 | pinnedModels: pinnedModelIds(data.models), 95 | additionalLines: 'additionalLines' in data, 96 | ticks: data.timePoints.map(tp => getTick(tp, config.pointType)), 97 | pointType: config.pointType 98 | } 99 | } 100 | 101 | /** 102 | * Parse distribution chart data and provide information about it 103 | */ 104 | export function getDistChartDataConfig (data, config) { 105 | return { 106 | actual: false, 107 | observed: false, 108 | history: false, 109 | pinnedModels: pinnedModelIds(data.models), 110 | ticks: data.timePoints.map(tp => getTick(tp, config.pointType)), 111 | pointType: config.pointType, 112 | curveNames: data.models[0].curves.map(c => c.name) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/utilities/tooltip.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for working with tooltips. 3 | * As of now, we generate html strings and render with d3Selection.html 4 | */ 5 | 6 | /** 7 | * Doc guard 8 | */ 9 | import { getMousePosition } from './misc' 10 | import Tooltip from '../components/common/tooltip' 11 | 12 | /** 13 | * Return a formatted string representing a bin at index from series 14 | */ 15 | export function formatBin (series: number[], index: number): string { 16 | let start = series[index] 17 | let end 18 | 19 | // Figure out if we are working with integers 20 | let diff = series[1] - series[0] 21 | 22 | if (index === (series.length - 1)) { 23 | // We are at the end, use the diff 24 | end = start + diff 25 | } else { 26 | end = series[index + 1] 27 | } 28 | 29 | if (diff < 1) { 30 | // These are floats 31 | return `${start.toFixed(2)}-${end.toFixed(2)}` 32 | } else { 33 | return `${start}-${end}` 34 | } 35 | } 36 | 37 | /** 38 | * Move tooltip to the position of the selection 39 | */ 40 | export function moveTooltip (tooltip: Tooltip, selection, direction = 'right') { 41 | let [x, y] = getMousePosition(selection) 42 | tooltip.move({ x, y }, direction) 43 | } 44 | 45 | /** 46 | * Generate text for simple { title, text } data 47 | */ 48 | export function parseText ({ title, text }): string { 49 | let html = '' 50 | if (title) { 51 | html += `
${title}
` 52 | } 53 | if (text) { 54 | html += `
${text}
` 55 | } 56 | return html 57 | } 58 | 59 | /** 60 | * Generate text for point prediction values 61 | * `title` is shown in `color`-ed background 62 | * `values` go as rows below the title 63 | */ 64 | export function parsePoint ({ title, values, color }): string { 65 | let html = `
${title}
` 66 | values.forEach(v => { 67 | html += `
68 | ${v.key} 69 | ${v.value.toFixed(2)} 70 |
` 71 | }) 72 | return html 73 | } 74 | 75 | /** 76 | * Generate text for a list of predictions 77 | * `title` is shown in italics first 78 | * Each of the `predictions` at `index` provide the data for rows 79 | */ 80 | export function parsePredictions ({ title, predictions, index }): string { 81 | let maxPreds = 10 82 | let html = '' 83 | 84 | if (title) { 85 | html += `
86 | ${title} 87 |
` 88 | } 89 | 90 | // Show only those items which have some value to be shown at index 91 | let visiblePreds = predictions.filter(p => { 92 | let data = p.query(index) 93 | return data === 0 || data 94 | }) 95 | 96 | visiblePreds.slice(0, maxPreds).forEach(p => { 97 | let color = p.style ? p.style.color : null 98 | 99 | let style = `background:${color};color:${color ? 'white' : ''}` 100 | html += `
101 | ${p.id} 102 | 103 | ${p.query(index).toFixed(2)} 104 | 105 |
` 106 | }) 107 | 108 | // Notify in case of overflow 109 | if (visiblePreds.length > maxPreds) { 110 | html += `
111 | Truncated list. Please
112 | select fewer than
113 | ${maxPreds + 1} predictions
114 |
` 115 | } 116 | 117 | return html 118 | } 119 | -------------------------------------------------------------------------------- /src/components/time-chart/prediction/onset-marker.js: -------------------------------------------------------------------------------- 1 | import * as tt from '../../../utilities/tooltip' 2 | import * as colors from '../../../utilities/colors' 3 | import { selectUncle, kebabCase } from '../../../utilities/misc' 4 | import SComponent from '../../s-component' 5 | 6 | export default class OnsetMarker extends SComponent { 7 | constructor (id, onsetY, style) { 8 | super() 9 | this.selection 10 | .attr('class', 'onset-group') 11 | .attr('id', kebabCase(id) + '-marker') 12 | 13 | let color = style.color 14 | let stp = 6 15 | let colorPoint = colors.hexToRgba(color, 0.8) 16 | let colorRange = colors.hexToRgba(color, 0.6) 17 | 18 | this.selection.append('line') 19 | .attr('y1', onsetY) 20 | .attr('y2', onsetY) 21 | .attr('class', 'range onset-range') 22 | .style('stroke', colorRange) 23 | 24 | this.selection.append('line') 25 | .attr('y1', onsetY - stp / 2) 26 | .attr('y2', onsetY + stp / 2) 27 | .attr('class', 'stopper onset-stopper onset-low') 28 | .style('stroke', colorRange) 29 | 30 | this.selection.append('line') 31 | .attr('y1', onsetY - stp / 2) 32 | .attr('y2', onsetY + stp / 2) 33 | .attr('class', 'stopper onset-stopper onset-high') 34 | .style('stroke', colorRange) 35 | 36 | this.point = this.selection.append('circle') 37 | .attr('r', 3) 38 | .attr('cy', onsetY) 39 | .attr('class', 'onset-mark') 40 | .style('stroke', 'transparent') 41 | .style('fill', colorPoint) 42 | 43 | this.color = color 44 | } 45 | 46 | set highlight (state) { 47 | let colorHover = colors.hexToRgba(this.color, 0.3) 48 | 49 | this.point 50 | .transition() 51 | .duration(200) 52 | .style('stroke', state ? colorHover : 'transparent') 53 | 54 | this.selection.selectAll('line') 55 | .transition() 56 | .duration(200) 57 | .style('stroke-width', state ? '2px' : '1px') 58 | } 59 | 60 | move (cfg, onset) { 61 | this.point 62 | .transition() 63 | .duration(200) 64 | .attr('cx', cfg.scales.xScale(onset.point)) 65 | 66 | this.point 67 | .on('mouseover', () => { 68 | this.highlight = true 69 | cfg.tooltip.hidden = false 70 | cfg.tooltip.render(tt.parsePoint({ 71 | title: cfg.id, 72 | values: [{ key: 'Onset Time', value: cfg.scales.ticks[onset.point] }], 73 | color: this.color 74 | })) 75 | }) 76 | .on('mouseout', () => { 77 | this.highlight = false 78 | cfg.tooltip.hidden = true 79 | }) 80 | .on('mousemove', function () { 81 | tt.moveTooltip(cfg.tooltip, selectUncle(this, '.overlay')) 82 | }) 83 | 84 | if (cfg.cid === -1) { 85 | this.selection.selectAll('line') 86 | .attr('display', 'none') 87 | } else { 88 | this.selection.selectAll('line') 89 | .attr('display', null) 90 | 91 | this.selection.select('.onset-range') 92 | .transition() 93 | .duration(200) 94 | .attr('x1', cfg.scales.xScale(onset.low[cfg.cid])) 95 | .attr('x2', cfg.scales.xScale(onset.high[cfg.cid])) 96 | 97 | this.selection.select('.onset-low') 98 | .transition() 99 | .duration(200) 100 | .attr('x1', cfg.scales.xScale(onset.low[cfg.cid])) 101 | .attr('x2', cfg.scales.xScale(onset.low[cfg.cid])) 102 | 103 | this.selection.select('.onset-high') 104 | .transition() 105 | .duration(200) 106 | .attr('x1', cfg.scales.xScale(onset.high[cfg.cid])) 107 | .attr('x2', cfg.scales.xScale(onset.high[cfg.cid])) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/chart.ts: -------------------------------------------------------------------------------- 1 | import * as errors from './utilities/errors' 2 | import Tooltip from './components/common/tooltip' 3 | import { Event } from './interfaces' 4 | import * as ev from './events' 5 | import * as uuid from 'uuid/v4' 6 | 7 | /** 8 | * Chart superclass 9 | */ 10 | export default class Chart { 11 | config: any 12 | width: number 13 | height: number 14 | svg: any 15 | tooltip: Tooltip 16 | selection: any 17 | onsetHeight: number 18 | xScale: any 19 | xScaleDate: any 20 | xScalePoint: any 21 | yScale: any 22 | currentIdx: number 23 | ticks: number[] 24 | uuid: string 25 | hooks: { [name: string]: any[] } 26 | 27 | constructor (selection, options = {}) { 28 | let defaultConfig = { 29 | axes: { 30 | x: { 31 | title: 'X', 32 | description: 'X axis', 33 | url: '#' 34 | }, 35 | y: { 36 | title: 'Y', 37 | description: 'Y axis', 38 | url: '#' 39 | } 40 | }, 41 | margin: { 42 | top: 5, 43 | right: 60, 44 | bottom: 70, 45 | left: 55 46 | }, 47 | onset: false 48 | } 49 | this.config = (Object).assign({}, defaultConfig, options) 50 | 51 | // Add space for onset 52 | this.onsetHeight = this.config.onset ? 30 : 0 53 | this.config.margin.bottom += this.onsetHeight 54 | 55 | let chartBB = selection.node().getBoundingClientRect() 56 | let divWidth = chartBB.width 57 | let divHeight = 480 58 | 59 | // Create blank chart 60 | this.width = divWidth - this.config.margin.left - this.config.margin.right 61 | this.height = divHeight - this.config.margin.top - this.config.margin.bottom 62 | 63 | // Add svg 64 | this.svg = selection.append('svg') 65 | .attr('width', this.width + this.config.margin.left + this.config.margin.right) 66 | .attr('height', this.height + this.config.margin.top + this.config.margin.bottom) 67 | .append('g') 68 | .attr('transform', `translate(${this.config.margin.left},${this.config.margin.top})`) 69 | 70 | this.tooltip = new Tooltip() 71 | selection.append(() => this.tooltip.node) 72 | 73 | this.selection = selection 74 | 75 | // Create a uuid for this instance 76 | this.uuid = uuid() 77 | 78 | // Current position in the time series 79 | this.currentIdx = -1 80 | } 81 | 82 | /** 83 | * Return layout related parameters 84 | */ 85 | get layout () { 86 | return { 87 | width: this.width, 88 | height: this.height, 89 | totalHeight: this.height + this.onsetHeight 90 | } 91 | } 92 | 93 | get scales () { 94 | return { 95 | xScale: this.xScale, 96 | xScaleDate: this.xScaleDate, 97 | xScalePoint: this.xScalePoint, 98 | ticks: this.ticks, 99 | yScale: this.yScale 100 | } 101 | } 102 | 103 | /** 104 | * Return the value of currentIdx + delta as defined by the ticks 105 | */ 106 | deltaIndex (delta) { 107 | return Math.max(Math.min(this.currentIdx + delta, this.scales.ticks.length - 1), 0) 108 | } 109 | 110 | plot (data) {} 111 | 112 | update (idx) {} 113 | 114 | /** 115 | * Append hook function if the hookName is supported and return subId 116 | */ 117 | addHook (hookName: Event, fn): number { 118 | return ev.addSub(this.uuid, hookName, (msg, data) => fn(data)) 119 | } 120 | 121 | /** 122 | * Remove specified subscription 123 | */ 124 | removeHook (token) { 125 | ev.removeSub(token) 126 | } 127 | 128 | /** 129 | * Append another component to svg 130 | */ 131 | append (component) { 132 | this.svg.append(() => component.node) 133 | return component 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/utilities/data/timepoints.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions for working with various timepoint types 3 | */ 4 | 5 | /** 6 | * Doc guard 7 | */ 8 | import * as mmwr from 'mmwr-week' 9 | import * as moment from 'moment' 10 | import * as d3 from 'd3' 11 | import { Timepoint, TimepointId } from '../../interfaces' 12 | import * as errors from '../errors' 13 | import { parseText } from '../tooltip' 14 | import { orArrays } from '../misc' 15 | 16 | function parseWeek(d: Date): number { 17 | return parseInt(d3.timeFormat('%W')(d)) 18 | } 19 | 20 | function parseMonth(d: Date): number { 21 | return parseInt(d3.timeFormat('%m')(d)) 22 | } 23 | 24 | function isTimepoint(tp: any): boolean { 25 | return (tp !== null) && (typeof tp === 'object') && ('year' in tp) 26 | } 27 | 28 | function isDate(tp: any): boolean { 29 | return (tp instanceof Date) 30 | } 31 | 32 | function isString(tp: any): boolean { 33 | return (typeof tp === 'string' || tp instanceof String) 34 | } 35 | 36 | function stringToDate(tp: string): Date { 37 | return moment(tp).toDate() 38 | } 39 | 40 | /** 41 | * Parse timepoint to standard dictionary format 42 | */ 43 | function parseTimepoint(tp: Timepoint | Date | string, pointType: TimepointId): Timepoint { 44 | if (isTimepoint(tp)) { 45 | return tp as Timepoint 46 | } 47 | 48 | if (isString(tp)) { 49 | tp = stringToDate(tp as string) 50 | } 51 | 52 | if (isDate(tp)) { 53 | tp = tp as Date 54 | if (pointType === 'week') { 55 | return { year: tp.getFullYear(), week: parseWeek(tp) } 56 | } else if (pointType === 'mmwr-week') { 57 | let mdate = new mmwr.MMWRDate(0) 58 | mdate.fromJSDate(tp) 59 | return { year: mdate.year, week: mdate.week } 60 | } else if (pointType === 'biweek') { 61 | return { year: tp.getFullYear(), week: Math.floor(parseWeek(tp) / 2) } 62 | } else if (pointType === 'month') { 63 | return { year: tp.getFullYear(), month: parseMonth(tp) } 64 | } else { 65 | throw new errors.UnknownPointType() 66 | } 67 | } else { 68 | throw new errors.UnknownPointType() 69 | } 70 | } 71 | 72 | /** 73 | * Return ticks to show for the timepoints 74 | */ 75 | export function getTick(tp: Timepoint | Date | string, pointType: TimepointId): string | number { 76 | tp = parseTimepoint(tp, pointType) 77 | if ((pointType === 'week') || (pointType === 'mmwr-week') ) { 78 | return tp.week 79 | } if (pointType === 'biweek') { 80 | return tp.biweek 81 | } if (pointType === 'month') { 82 | return tp.month 83 | } else { 84 | throw new errors.UnknownPointType() 85 | } 86 | } 87 | 88 | /** 89 | * Return date time value for the timepoint 90 | */ 91 | export function getDateTime(tp: Timepoint | Date | string, pointType: TimepointId): Date { 92 | if (isString(tp)) { 93 | return stringToDate(tp as string) 94 | } else if (isDate(tp)) { 95 | return tp as Date 96 | } else if (isTimepoint(tp)) { 97 | tp = tp as Timepoint 98 | if (pointType === 'week') { 99 | return d3.timeParse('%Y-%W')(`${tp.year}-${tp.week}`) 100 | } else if (pointType === 'mmwr-week') { 101 | return (new mmwr.MMWRDate(tp.year, (tp as Timepoint).week)).toJSDate() 102 | } else if (pointType === 'biweek') { 103 | return d3.timeParse('%Y-%W')(`${tp.year}-${tp.biweek * 2}`) 104 | } else if (pointType === 'month') { 105 | return d3.timeParse('%Y-%m')(`${tp.year}-${tp.month}`) 106 | } else { 107 | throw new errors.UnknownPointType() 108 | } 109 | } else { 110 | throw new errors.UnknownPointType() 111 | } 112 | } 113 | 114 | /** 115 | * Parse data version times as JS datetimes 116 | */ 117 | export function parseDataVersionTimes(data, dataConfig) { 118 | if (dataConfig.predictions.versionTime) { 119 | return orArrays(data.models.map(m => { 120 | let preds = m.predictions.map(p => { 121 | if ((p === null) || (p.dataVersionTime === null)) { 122 | return null 123 | } else { 124 | return getDateTime(p.dataVersionTime, dataConfig.pointType) 125 | } 126 | }) 127 | return preds 128 | }), (a, b) => a.valueOf() === b.valueOf()) 129 | } else { 130 | // Otherwise use time from regular timepoints 131 | return data.timePoints.map(t => getDateTime(t, dataConfig.pointType)) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/components/time-chart/prediction/index.js: -------------------------------------------------------------------------------- 1 | import SComponent from '../../s-component' 2 | import LineMarker from './line-marker' 3 | import OnsetMarker from './onset-marker' 4 | import PeakMarker from './peak-marker' 5 | 6 | /** 7 | * Prediction marker with following components 8 | * - Area 9 | * - Line and dots 10 | * - Onset 11 | * - Peak 12 | */ 13 | export default class Prediction extends SComponent { 14 | constructor ({ id, meta, onsetY, cid, tooltip, onset, peak, style }) { 15 | super() 16 | 17 | this.lineMarker = this.append(new LineMarker(id, style)) 18 | if (onset) { 19 | this.onsetMarker = this.append(new OnsetMarker(id, onsetY, style)) 20 | } 21 | if (peak) { 22 | this.peakMarker = this.append(new PeakMarker(id, style)) 23 | } 24 | 25 | this.style = style 26 | this.id = id 27 | this.meta = meta 28 | this.cid = cid 29 | this.tooltip = tooltip 30 | this.show = { 31 | onset: onset, 32 | peak: peak 33 | } 34 | 35 | // Tells if the prediction is hidden by some other component 36 | this._hidden = false 37 | // Tells if data is available to be shown for current time 38 | this.noData = true 39 | } 40 | 41 | plot (scales, modelData) { 42 | this.modelData = modelData 43 | this.displayedData = Array(this.modelData.length).fill(false) 44 | this.scales = scales 45 | } 46 | 47 | get config () { 48 | return { 49 | scales: this.scales, 50 | id: this.id, 51 | meta: this.meta, 52 | style: this.style, 53 | cid: this.cid, 54 | tooltip: this.tooltip 55 | } 56 | } 57 | 58 | update (idx) { 59 | let currData = this.modelData[idx] 60 | if (currData === null) { 61 | // There is no data for current point, hide the markers without 62 | // setting exposed hidden flag 63 | this.noData = true 64 | this.hideMarkers() 65 | } else { 66 | this.noData = false 67 | if (!this.hidden) { 68 | // No one is hiding me 69 | this.showMarkers() 70 | } 71 | 72 | if (this.show.onset) { 73 | this.onsetMarker.move(this.config, currData.onsetTime) 74 | } 75 | if (this.show.peak) { 76 | this.peakMarker.move(this.config, currData.peakTime, currData.peakValue) 77 | } 78 | 79 | // Move main pointers 80 | let series = [] 81 | let idxOverflow = Math.min(0, this.modelData.length - (idx + currData.series.length)) 82 | let displayLimit = currData.series.length - idxOverflow 83 | 84 | for (let i = 0; i < displayLimit; i++) { 85 | series.push({ 86 | index: i + idx + 1, 87 | point: currData.series[i].point, 88 | low: this.cid === -1 ? currData.series[i].point : currData.series[i].low[this.cid], 89 | high: this.cid === -1 ? currData.series[i].point : currData.series[i].high[this.cid] 90 | }) 91 | } 92 | 93 | // Save indexed data for query 94 | this.displayedData = Array(this.modelData.length).fill(false) 95 | series.forEach(d => { 96 | this.displayedData[d.index] = d.point 97 | }) 98 | 99 | this.lineMarker.move(this.config, series) 100 | } 101 | } 102 | 103 | /** 104 | * Check if we are hidden 105 | */ 106 | get hidden () { 107 | return this._hidden 108 | } 109 | 110 | set hidden (hide) { 111 | if (hide) { 112 | this.hideMarkers() 113 | } else { 114 | if (!this.noData) { 115 | this.showMarkers() 116 | } 117 | } 118 | this._hidden = hide 119 | } 120 | 121 | hideMarkers () { 122 | if (this.show.onset) { 123 | this.onsetMarker.hidden = true 124 | } 125 | if (this.show.peak) { 126 | this.peakMarker.hidden = true 127 | } 128 | this.lineMarker.hidden = true 129 | } 130 | 131 | showMarkers () { 132 | if (this.show.onset) { 133 | this.onsetMarker.hidden = false 134 | } 135 | if (this.show.peak) { 136 | this.peakMarker.hidden = false 137 | } 138 | this.lineMarker.hidden = false 139 | } 140 | 141 | /** 142 | * Remove the markers 143 | */ 144 | clear () { 145 | super.clear() 146 | this.selection.remove() 147 | } 148 | 149 | /** 150 | * Ask if we have something to show at the index 151 | */ 152 | query (idx) { 153 | // Don't show anything if predictions are hidden 154 | return (!this.noData && !this.hidden && this.displayedData[idx]) 155 | } 156 | 157 | /** 158 | * Return index of asked idx among displayedData items 159 | */ 160 | displayedIdx (idx) { 161 | for (let i = 0; i < this.displayedData.length; i++) { 162 | if (this.displayedData[i] !== false) return (idx - i) 163 | } 164 | return null 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/components/time-chart/overlay.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as tt from '../../utilities/tooltip' 3 | import * as ev from '../../events' 4 | import SComponent from '../s-component' 5 | import AdditionLine from '../time-chart/additional-line' 6 | import Prediction from '../time-chart/prediction' 7 | 8 | class NoPredText extends SComponent { 9 | constructor () { 10 | super() 11 | this.selection.append('text') 12 | .attr('class', 'no-pred-text') 13 | .attr('transform', 'translate(20, 20)') 14 | .text('Predictions not available') 15 | 16 | this.selection.append('text') 17 | .attr('class', 'no-pred-text') 18 | .attr('transform', 'translate(20, 40)') 19 | .text('for selected time') 20 | } 21 | } 22 | 23 | class HoverLine extends SComponent { 24 | constructor (layout) { 25 | super() 26 | this.line = this.selection.append('line') 27 | .attr('class', 'hover-line') 28 | .attr('x1', 0) 29 | .attr('y1', 0) 30 | .attr('x2', 0) 31 | .attr('y2', layout.totalHeight) 32 | } 33 | 34 | set x (x) { 35 | this.line 36 | .transition() 37 | .duration(50) 38 | .attr('x1', x) 39 | .attr('x2', x) 40 | } 41 | } 42 | 43 | class TodayLine extends SComponent { 44 | constructor (layout) { 45 | super() 46 | this.line = this.selection.append('line') 47 | .attr('class', 'now-line') 48 | .attr('x1', 0) 49 | .attr('y1', 0) 50 | .attr('x2', 0) 51 | .attr('y2', layout.totalHeight) 52 | 53 | this.text = this.selection.append('text') 54 | .attr('class', 'now-text') 55 | .attr('transform', 'translate(15, 10) rotate(-90)') 56 | .style('text-anchor', 'end') 57 | .text('Today') 58 | } 59 | 60 | set x (x) { 61 | this.line 62 | .attr('x1', x) 63 | .attr('x2', x) 64 | 65 | this.text 66 | .attr('dy', x) 67 | } 68 | } 69 | 70 | export default class Overlay extends SComponent { 71 | constructor (layout, { tooltip, uuid }) { 72 | super() 73 | 74 | this.noPredText = this.append(new NoPredText(layout)) 75 | this.todayLine = this.append(new TodayLine(layout)) 76 | this.hoverLine = this.append(new HoverLine(layout)) 77 | this.hoverLine.hidden = true 78 | 79 | this.overlay = this.selection.append('rect') 80 | .attr('class', 'overlay') 81 | .attr('height', layout.totalHeight) 82 | .attr('width', layout.width) 83 | .on('mouseover', () => { 84 | this.hoverLine.hidden = false 85 | tooltip.hidden = false 86 | }) 87 | .on('mouseout', () => { 88 | this.hoverLine.hidden = true 89 | tooltip.hidden = true 90 | }) 91 | 92 | this.tooltip = tooltip 93 | this.uuid = uuid 94 | } 95 | 96 | plot (scales, queryObjects) { 97 | // Check if `today` lies within the plotting range 98 | let todayX = scales.xScaleDate(new Date()) 99 | if ((todayX >= scales.xScalePoint(scales.ticks[0])) && 100 | (todayX <= scales.xScalePoint(scales.ticks[scales.ticks.length - 1]))) { 101 | this.todayLine.x = todayX 102 | this.todayLine.hidden = false 103 | } else { 104 | this.todayLine.hidden = true 105 | } 106 | 107 | let objects = { 108 | static: queryObjects.filter(q => ['Actual', 'Observed'].indexOf(q.id) > -1), 109 | models: queryObjects.filter(q => q instanceof Prediction), 110 | additional: queryObjects.filter(q => { 111 | return q instanceof AdditionLine && q.tooltip 112 | }) 113 | } 114 | 115 | // Add mouse move and click events 116 | let that = this 117 | this.overlay 118 | .on('mousemove', function () { 119 | let mouse = d3.mouse(this) 120 | // Snap x to nearest tick 121 | let index = Math.round(scales.xScale.invert(mouse[0])) 122 | let snappedX = scales.xScale(index) 123 | 124 | that.hoverLine.x = snappedX 125 | 126 | let visibleModels = objects.models.filter(q => { 127 | // Take only model predictions which have data at index 128 | return q.query(index) !== false 129 | }) 130 | 131 | let ttTitle = '' 132 | if (visibleModels.length > 0) { 133 | // Add note regarding which prediction is getting displayed 134 | let aheadIndex = visibleModels[0].displayedIdx(index) 135 | if (aheadIndex !== null) { 136 | ttTitle = `${aheadIndex + 1} ahead` 137 | } 138 | } 139 | 140 | that.tooltip.render(tt.parsePredictions({ 141 | title: ttTitle, 142 | predictions: [...objects.static, ...objects.additional, ...objects.models], 143 | index 144 | })) 145 | 146 | tt.moveTooltip(that.tooltip, d3.select(this)) 147 | }) 148 | .on('click', function () { 149 | ev.publish(that.uuid, ev.JUMP_TO_INDEX_INTERNAL, Math.round(scales.xScale.invert(d3.mouse(this)[0]))) 150 | }) 151 | } 152 | 153 | update (predictions) { 154 | this.noPredText.hidden = !predictions.every(p => p.noData) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/components/time-chart/prediction/peak-marker.js: -------------------------------------------------------------------------------- 1 | import * as tt from '../../../utilities/tooltip' 2 | import * as colors from '../../../utilities/colors' 3 | import { selectUncle, kebabCase } from '../../../utilities/misc' 4 | import SComponent from '../../s-component' 5 | 6 | export default class PeakMarker extends SComponent { 7 | constructor (id, style) { 8 | super() 9 | 10 | this.selection 11 | .attr('class', 'peak-group') 12 | .attr('id', kebabCase(id) + '-marker') 13 | 14 | let color = style.color 15 | let colorPoint = colors.hexToRgba(color, 0.8) 16 | let colorRange = colors.hexToRgba(color, 0.6) 17 | 18 | this.selection.append('line') 19 | .attr('class', 'range peak-range peak-range-x') 20 | .style('stroke', colorRange) 21 | .style('stroke-dasharray', '5, 5') 22 | 23 | this.selection.append('line') 24 | .attr('class', 'range peak-range peak-range-y') 25 | .style('stroke', colorRange) 26 | .style('stroke-dasharray', '5, 5') 27 | 28 | this.selection.append('line') 29 | .attr('class', 'stopper peak-stopper peak-low-x') 30 | .style('stroke', colorRange) 31 | 32 | this.selection.append('line') 33 | .attr('class', 'stopper peak-stopper peak-high-x') 34 | .style('stroke', colorRange) 35 | 36 | this.selection.append('line') 37 | .attr('class', 'stopper peak-stopper peak-low-y') 38 | .style('stroke', colorRange) 39 | 40 | this.selection.append('line') 41 | .attr('class', 'stopper peak-stopper peak-high-y') 42 | .style('stroke', colorRange) 43 | 44 | this.point = this.selection.append('circle') 45 | .attr('r', 5) 46 | .attr('class', 'peak-mark') 47 | .style('stroke', 'transparent') 48 | .style('fill', colorPoint) 49 | 50 | this.color = color 51 | } 52 | 53 | set highlight (state) { 54 | let colorHover = colors.hexToRgba(this.color, 0.3) 55 | 56 | this.point 57 | .transition() 58 | .duration(200) 59 | .style('stroke', state ? colorHover : 'transparent') 60 | 61 | this.selection.selectAll('line') 62 | .transition() 63 | .duration(200) 64 | .style('stroke-width', state ? '2px' : '0.5px') 65 | 66 | this.selection.selectAll('.range') 67 | .style('stroke-dasharray', state ? null : '5, 5') 68 | } 69 | 70 | move (cfg, peakTime, peakValue) { 71 | let leftW = cfg.scales.xScale(peakTime.point) 72 | let leftP = cfg.scales.yScale(peakValue.point) 73 | 74 | this.point 75 | .transition() 76 | .duration(200) 77 | .attr('cx', leftW) 78 | .attr('cy', leftP) 79 | 80 | this.point 81 | .on('mouseover', () => { 82 | this.highlight = true 83 | cfg.tooltip.hidden = false 84 | cfg.tooltip.render(tt.parsePoint({ 85 | title: cfg.id, 86 | values: [ 87 | { key: 'Peak Percent', value: peakValue.point }, 88 | { key: 'Peak Time', value: cfg.scales.ticks[peakTime.point] } 89 | ], 90 | color: this.color 91 | })) 92 | }) 93 | .on('mouseout', () => { 94 | this.highlight = false 95 | cfg.tooltip.hidden = true 96 | }) 97 | .on('mousemove', function () { 98 | tt.moveTooltip(cfg.tooltip, selectUncle(this, '.overlay')) 99 | }) 100 | 101 | if (cfg.cid === -1) { 102 | this.selection.selectAll('line') 103 | .attr('visibility', 'hidden') 104 | } else { 105 | this.selection.selectAll('line') 106 | .attr('visibility', null) 107 | 108 | this.selection.select('.peak-range-x') 109 | .transition() 110 | .duration(200) 111 | .attr('x1', cfg.scales.xScale(peakTime.low[cfg.cid])) 112 | .attr('x2', cfg.scales.xScale(peakTime.high[cfg.cid])) 113 | .attr('y1', cfg.scales.yScale(peakValue.point)) 114 | .attr('y2', cfg.scales.yScale(peakValue.point)) 115 | 116 | this.selection.select('.peak-range-y') 117 | .transition() 118 | .duration(200) 119 | .attr('x1', cfg.scales.xScale(peakTime.point)) 120 | .attr('x2', cfg.scales.xScale(peakTime.point)) 121 | .attr('y1', cfg.scales.yScale(peakValue.low[cfg.cid])) 122 | .attr('y2', cfg.scales.yScale(peakValue.high[cfg.cid])) 123 | 124 | this.selection.select('.peak-low-x') 125 | .transition() 126 | .duration(200) 127 | .attr('x1', cfg.scales.xScale(peakTime.low[cfg.cid])) 128 | .attr('x2', cfg.scales.xScale(peakTime.low[cfg.cid])) 129 | .attr('y1', cfg.scales.yScale(peakValue.point) - 5) 130 | .attr('y2', cfg.scales.yScale(peakValue.point) + 5) 131 | 132 | this.selection.select('.peak-high-x') 133 | .transition() 134 | .duration(200) 135 | .attr('x1', cfg.scales.xScale(peakTime.high[cfg.cid])) 136 | .attr('x2', cfg.scales.xScale(peakTime.high[cfg.cid])) 137 | .attr('y1', cfg.scales.yScale(peakValue.point) - 5) 138 | .attr('y2', cfg.scales.yScale(peakValue.point) + 5) 139 | 140 | leftW = cfg.scales.xScale(peakTime.point) 141 | this.selection.select('.peak-low-y') 142 | .transition() 143 | .duration(200) 144 | .attr('x1', (!leftW ? 0 : leftW) - 5) 145 | .attr('x2', (!leftW ? 0 : leftW) + 5) 146 | .attr('y1', cfg.scales.yScale(peakValue.low[cfg.cid])) 147 | .attr('y2', cfg.scales.yScale(peakValue.low[cfg.cid])) 148 | 149 | this.selection.select('.peak-high-y') 150 | .transition() 151 | .duration(200) 152 | .attr('x1', (!leftW ? 0 : leftW) - 5) 153 | .attr('x2', (!leftW ? 0 : leftW) + 5) 154 | .attr('y1', cfg.scales.yScale(peakValue.high[cfg.cid])) 155 | .attr('y2', cfg.scales.yScale(peakValue.high[cfg.cid])) 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/components/common/axis-x.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import textures from 'textures' 3 | import * as tt from '../../utilities/tooltip' 4 | import { selectUncle } from '../../utilities/misc' 5 | import SComponent from '../s-component' 6 | 7 | /** 8 | * Simple linear X axis with informative label 9 | */ 10 | export class XAxis extends SComponent { 11 | constructor (layout, { tooltip, title, description, url }) { 12 | super() 13 | this.selection 14 | .attr('class', 'axis axis-x') 15 | .attr('transform', `translate(0, ${layout.height})`) 16 | 17 | let xText = this.selection 18 | .append('text') 19 | .attr('text-anchor', 'start') 20 | .attr('transform', `translate(${layout.width + 10}, -15)`) 21 | 22 | // Setup multiline text 23 | if (Array.isArray(title)) { 24 | xText.append('tspan') 25 | .text(title[0]) 26 | .attr('x', 0) 27 | title.slice(1).forEach(txt => { 28 | xText.append('tspan') 29 | .text(txt) 30 | .attr('x', 0) 31 | .attr('dy', '1em') 32 | }) 33 | } else { 34 | xText.append('tspan') 35 | .text(title) 36 | .attr('x', 0) 37 | } 38 | 39 | xText 40 | .on('mouseover', () => { tooltip.hidden = false }) 41 | .on('mouseout', () => { tooltip.hidden = true }) 42 | .on('mousemove', function () { 43 | tooltip.render(tt.parseText({ text: description })) 44 | tt.moveTooltip(tooltip, selectUncle(this, '.overlay'), 'left') 45 | }) 46 | 47 | if (url) { 48 | xText 49 | .style('cursor', 'pointer') 50 | .on('click', () => { 51 | window.open(url, '_blank') 52 | }) 53 | } 54 | 55 | this.layout = layout 56 | } 57 | 58 | plot (scales, maxTicks) { 59 | let xAxis = d3.axisBottom(scales.xScale) 60 | let totalTicks = scales.xScale.domain().length 61 | if (maxTicks && (maxTicks < totalTicks / 2)) { 62 | // Show upto maxTicks ticks 63 | let showAt = parseInt(totalTicks / maxTicks) 64 | xAxis.tickValues(scales.xScale.domain().filter((d, i) => !(i % showAt))) 65 | } 66 | this.selection 67 | .transition().duration(200).call(xAxis) 68 | } 69 | } 70 | 71 | /** 72 | * X axis with week numbers, time and onset panel 73 | */ 74 | export class XAxisDate extends SComponent { 75 | constructor (layout, { tooltip, title, description, url }) { 76 | super() 77 | // Main axis with ticks below the onset panel 78 | this.selection.append('g') 79 | .attr('class', 'axis axis-x') 80 | .attr('transform', `translate(0,${layout.totalHeight})`) 81 | 82 | let axisXDate = this.selection.append('g') 83 | .attr('class', 'axis axis-x-date') 84 | .attr('transform', `translate(0,${layout.totalHeight + 25})`) 85 | 86 | let xText = axisXDate 87 | .append('text') 88 | .attr('text-anchor', 'start') 89 | .attr('transform', `translate(${layout.width + 10},-15)`) 90 | 91 | // Setup multiline text 92 | if (Array.isArray(title)) { 93 | xText.append('tspan') 94 | .text(title[0]) 95 | .attr('x', 0) 96 | title.slice(1).forEach(txt => { 97 | xText.append('tspan') 98 | .text(txt) 99 | .attr('x', 0) 100 | .attr('dy', '1em') 101 | }) 102 | } else { 103 | xText.append('tspan') 104 | .text(title) 105 | .attr('x', 0) 106 | } 107 | 108 | xText 109 | .on('mouseover', () => { tooltip.hidden = false }) 110 | .on('mouseout', () => { tooltip.hidden = true }) 111 | .on('mousemove', function () { 112 | tooltip.render(tt.parseText({ text: description })) 113 | tt.moveTooltip(tooltip, selectUncle(this, '.overlay'), 'left') 114 | }) 115 | 116 | if (url) { 117 | xText 118 | .style('cursor', 'pointer') 119 | .on('click', () => { 120 | window.open(url, '_blank') 121 | }) 122 | } 123 | 124 | // Setup reverse axis (over onset offset) 125 | // Clone of axis above onset panel, without text 126 | this.selection.append('g') 127 | .attr('class', 'axis axis-x-ticks') 128 | .attr('transform', `translate(0, ${layout.height})`) 129 | 130 | // Create onset panel 131 | let onsetTexture = textures.lines() 132 | .lighter() 133 | .strokeWidth(0.5) 134 | .size(8) 135 | .stroke('#ccc') 136 | this.selection.call(onsetTexture) 137 | 138 | this.selection.append('rect') 139 | .attr('class', 'onset-texture') 140 | .attr('height', layout.totalHeight - layout.height) 141 | .attr('width', layout.width) 142 | .attr('x', 0) 143 | .attr('y', layout.height) 144 | .style('fill', onsetTexture.url()) 145 | 146 | this.layout = layout 147 | } 148 | 149 | plot (scales) { 150 | let xAxis = d3.axisBottom(scales.xScalePoint) 151 | .tickValues(scales.xScalePoint.domain().filter((d, i) => !(i % 2))) 152 | 153 | let xAxisReverseTick = d3.axisTop(scales.xScalePoint) 154 | .tickValues(scales.xScalePoint.domain().filter((d, i) => !(i % 2))) 155 | 156 | let xAxisDate = d3.axisBottom(scales.xScaleDate) 157 | .ticks(d3.timeMonth) 158 | .tickFormat(d3.timeFormat('%b %y')) 159 | 160 | // Mobile view fix 161 | if (this.width < 420) { 162 | xAxisDate.ticks(2) 163 | xAxis.tickValues(scales.xScalePoint.domain().filter((d, i) => !(i % 10))) 164 | } 165 | 166 | this.selection.select('.axis-x') 167 | .transition().duration(200).call(xAxis) 168 | 169 | // Copy over ticks above the onsetpanel 170 | let tickOnlyAxis = this.selection.select('.axis-x-ticks') 171 | .transition().duration(200).call(xAxisReverseTick) 172 | 173 | tickOnlyAxis.selectAll('text').remove() 174 | 175 | this.selection.select('.axis-x-date') 176 | .transition().duration(200).call(xAxisDate) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/distribution-chart.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import { XAxisDate } from './components/common/axis-x' 3 | import ControlPanel from './components/common/control-panel' 4 | import DistributionPanel from './components/distribution-chart/distribution-panel' 5 | import Pointer from './components/distribution-chart/pointer' 6 | import * as domains from './utilities/data/domains' 7 | import Chart from './chart' 8 | import { verifyDistChartData } from './utilities/data/verify' 9 | import { getDistChartDataConfig } from './utilities/data/config' 10 | import * as ev from './events' 11 | 12 | export default class DistributionChart extends Chart { 13 | constructor (element, options = {}) { 14 | let defaultConfig = { 15 | pointType: 'week', 16 | confidenceIntervals: [], 17 | margin: { 18 | top: 5, 19 | right: 60, 20 | bottom: 80, 21 | left: 5 22 | } 23 | } 24 | 25 | let selection = d3.select(element) 26 | .attr('class', 'd3f-chart d3f-distribution-chart') 27 | super(selection, Object.assign({}, defaultConfig, options)) 28 | 29 | // Initialize scales 30 | this.xScale = d3.scaleLinear().range([0, this.width]) 31 | this.xScaleDate = d3.scaleTime().range([0, this.width]) 32 | this.xScalePoint = d3.scalePoint().range([0, this.width]) 33 | 34 | // Time axis for indicating current position 35 | this.xAxis = this.append(new XAxisDate(this.layout, { 36 | ...this.config.axes.x, 37 | tooltip: this.tooltip 38 | })) 39 | 40 | // create 4 panels and assign new svgs to them 41 | let panelMargin = { 42 | top: 5, right: 10, bottom: 70, left: 50 43 | } 44 | let panelHeight = this.height / 2 45 | let panelWidth = this.width / 2 46 | let panelPositions = [ 47 | [0, 0], 48 | [0, panelHeight], 49 | [panelWidth, 0], 50 | [panelWidth, panelHeight] 51 | ] 52 | 53 | this.panels = panelPositions.map(pos => { 54 | let svg = this.svg.append('svg') 55 | .attr('x', pos[0]) 56 | .attr('y', pos[1]) 57 | .attr('width', panelWidth) 58 | .attr('height', panelHeight) 59 | .append('g') 60 | .attr('transform', `translate(${panelMargin.left}, ${panelMargin.top})`) 61 | 62 | let panel = new DistributionPanel({ 63 | width: panelWidth - panelMargin.left - panelMargin.right, 64 | height: panelHeight - panelMargin.top - panelMargin.bottom 65 | }, { tooltip: this.tooltip }) 66 | svg.append(() => panel.node) 67 | return panel 68 | }) 69 | 70 | // Add dropdowns for curve selection 71 | this.dropdowns = panelPositions.map(pos => { 72 | let wrapperWrapper = this.selection.append('div') 73 | wrapperWrapper.style('text-align', 'center') 74 | 75 | let wrapper = wrapperWrapper.append('span') 76 | wrapper.attr('class', 'select is-small') 77 | let dd = wrapper.append('select') 78 | 79 | wrapperWrapper.style('position', 'absolute') 80 | wrapperWrapper.style('left', (pos[0] + panelMargin.left / 2) + 'px') 81 | wrapperWrapper.style('width', panelWidth + 'px') 82 | wrapperWrapper.style('top', (pos[1] + panelHeight - panelMargin.bottom + 30) + 'px') 83 | 84 | return dd 85 | }) 86 | 87 | this.pointer = this.append(new Pointer(this.layout, { uuid: this.uuid })) 88 | this.controlPanel = new ControlPanel({ ci: false, tooltip: this.tooltip, uuid: this.uuid }) 89 | this.selection.append(() => this.controlPanel.node) 90 | 91 | ev.addSub(this.uuid, ev.PANEL_MOVE_NEXT, (msg, data) => { 92 | // Since we can't do anything for the next index, we just send a notification 93 | let oldIdx = this.currentIdx 94 | let newIdx = this.deltaIndex(1) 95 | if (newIdx !== oldIdx) { 96 | ev.publish(this.uuid, ev.JUMP_TO_INDEX, newIdx) 97 | } 98 | }) 99 | 100 | ev.addSub(this.uuid, ev.PANEL_MOVE_PREV, (msg, data) => { 101 | let oldIdx = this.currentIdx 102 | let newIdx = this.deltaIndex(-1) 103 | if (newIdx !== oldIdx) { 104 | ev.publish(this.uuid, ev.JUMP_TO_INDEX, newIdx) 105 | } 106 | }) 107 | 108 | ev.addSub(this.uuid, ev.LEGEND_ITEM, (msg, { id, state }) => { 109 | this.panels.forEach(p => { 110 | let predMarker = p.predictions.find(p => p.id === id) 111 | if (predMarker) { 112 | predMarker.hidden = !state 113 | } 114 | }) 115 | }) 116 | } 117 | 118 | // plot data 119 | plot (data) { 120 | verifyDistChartData(data) 121 | let dataConfig = getDistChartDataConfig(data, this.config) 122 | this.ticks = dataConfig.ticks 123 | this.currentIdx = data.currentIdx 124 | 125 | this.dropdowns.forEach(dd => { 126 | dd.selectAll('*').remove() 127 | dataConfig.curveNames.forEach((cn, idx) => { 128 | let option = dd.append('option') 129 | option.text(cn) 130 | option.attr('value', idx) 131 | }) 132 | }) 133 | 134 | this.xScaleDate.domain(domains.xDate(data, dataConfig)) 135 | this.xScalePoint.domain(domains.xPoint(data, dataConfig)) 136 | this.xScale.domain(domains.x(data, dataConfig)) 137 | 138 | this.xAxis.plot(this.scales) 139 | this.pointer.plot(this.scales, this.currentIdx) 140 | 141 | let yMaxima = domains.yCurveMaxima(data) 142 | 143 | // Provide curve data to the panels 144 | this.panels.forEach((p, idx) => { 145 | if (!p.selectedCurveIdx) { 146 | p.selectedCurveIdx = idx 147 | this.dropdowns[idx].property('value', idx) 148 | } else { 149 | this.dropdowns[idx].property('value', p.selectedCurveIdx) 150 | } 151 | p.plot(data, yMaxima) 152 | }) 153 | 154 | // Add event listeners to dropdown 155 | this.dropdowns.forEach((dd, idx) => { 156 | let currentPanel = this.panels[idx] 157 | dd.on('change', function () { 158 | let selectedIdx = parseInt(d3.select(this).property('value')) 159 | currentPanel.selectedCurveIdx = selectedIdx 160 | currentPanel.plot(data, yMaxima) 161 | }) 162 | }) 163 | 164 | // Update models shown in control panel 165 | this.controlPanel.plot(this.panels[0].predictions, [], dataConfig) 166 | 167 | // Fade out models with no predictions 168 | this.controlPanel.update(this.panels[0].predictions) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/components/common/control-panel/legend-drawer.js: -------------------------------------------------------------------------------- 1 | import * as ev from '../../../events' 2 | import colors from '../../../styles/modules/colors.json' 3 | import DrawerRow from './drawer-row' 4 | import ToggleButtons from './toggle-buttons' 5 | import Component from '../../component' 6 | import SearchBox from './search-box' 7 | import * as tt from '../../../utilities/tooltip' 8 | 9 | function makePredictionRow (p, tooltip) { 10 | let drawerRow = new DrawerRow(p.id, p.style.color) 11 | 12 | let ttText 13 | if (p.meta) { 14 | if (p.meta.url) { 15 | drawerRow.addLink(p.meta.url, tooltip) 16 | } 17 | ttText = tt.parseText({ title: p.meta.name, text: p.meta.description }) 18 | } else { 19 | ttText = tt.parseText({ title: p.id, text: '' }) 20 | } 21 | 22 | drawerRow.addTooltip(tooltip, ttText, 'left') 23 | drawerRow.active = !p.hidden 24 | return drawerRow 25 | } 26 | 27 | /** 28 | * Legend nav drawer 29 | */ 30 | export default class LegendDrawer extends Component { 31 | constructor (config) { 32 | super() 33 | 34 | this.selection.classed('legend-drawer', true) 35 | 36 | // Items above the controls (actual, observed, history) 37 | let topContainer = this.selection.append('div') 38 | 39 | let topItems = [ 40 | { 41 | color: colors.actual, 42 | text: 'Actual', 43 | tooltipData: { 44 | title: 'Actual Data', 45 | text: 'Latest data available for the week' 46 | } 47 | }, 48 | { 49 | color: colors.observed, 50 | text: 'Observed', 51 | tooltipData: { 52 | title: 'Observed Data', 53 | text: 'Data available for weeks when the predictions were made' 54 | } 55 | }, 56 | { 57 | color: colors['history'], 58 | text: 'History', 59 | tooltipData: { 60 | title: 'Historical Data', 61 | text: 'Toggle historical data lines' 62 | } 63 | } 64 | ] 65 | 66 | // Add rows for top items 67 | this.topRowsMap = {} 68 | topItems.forEach(data => { 69 | let drawerRow = new DrawerRow(data.text, data.color) 70 | drawerRow.addOnClick(({ id, state }) => { 71 | ev.publish(config.uuid, ev.LEGEND_ITEM, { id, state }) 72 | }) 73 | drawerRow.addTooltip(config.tooltip, tt.parseText(data.tooltipData), 'left') 74 | drawerRow.active = true 75 | topContainer.append(() => drawerRow.node) 76 | this.topRowsMap[data.text.toLowerCase()] = drawerRow 77 | }) 78 | 79 | // Control buttons (CI, show/hide, search) 80 | let middleContainer = this.selection.append('div') 81 | .attr('class', 'legend-middle-container') 82 | 83 | if (config.ci) { 84 | let ciRow = middleContainer.append('div') 85 | .attr('class', 'row control-row') 86 | ciRow.append('span').text('CI') 87 | 88 | let ciValues = [...config.ci.values, 'none'] 89 | this.ciButtons = new ToggleButtons(ciValues) 90 | this.ciButtons.addTooltip( 91 | config.tooltip, 92 | tt.parseText({ 93 | title: 'Confidence Interval', 94 | text: 'Select confidence interval for prediction markers' 95 | }), 'left') 96 | 97 | this.ciButtons.addOnClick(({ idx }) => { 98 | ev.publish(config.uuid, ev.LEGEND_CI, { idx: (ciValues.length - 1) === idx ? -1 : idx }) 99 | }) 100 | this.ciButtons.set(config.ci.idx) 101 | ciRow.append(() => this.ciButtons.node) 102 | } 103 | 104 | // Show / hide all 105 | let showHideRow = middleContainer.append('div') 106 | .attr('class', 'row control-row') 107 | showHideRow.append('span').text('Show') 108 | 109 | this.showHideButtons = new ToggleButtons(['all', 'none']) 110 | this.showHideButtons.addTooltip( 111 | config.tooltip, 112 | tt.parseText({ 113 | title: 'Toggle visibility', 114 | text: 'Show / hide all predictions' 115 | }), 'left') 116 | 117 | this.showHideButtons.addOnClick(({ idx }) => { 118 | this.showHideAllItems(idx === 0) 119 | }) 120 | showHideRow.append(() => this.showHideButtons.node) 121 | 122 | // Add search box 123 | this.searchBox = new SearchBox() 124 | middleContainer.append(() => this.searchBox.node) 125 | 126 | // Pinned model rows 127 | this.pinnedContainer = topContainer.append('div') 128 | 129 | // Model rows 130 | this.bottomContainer = this.selection.append('div') 131 | .attr('class', 'legend-bottom-container') 132 | 133 | this.tooltip = config.tooltip 134 | this.uuid = config.uuid 135 | } 136 | 137 | // Show / hide the "row items divs" while filtering with the search box 138 | showRows (states) { 139 | this.bottomRows.forEach((row, idx) => { 140 | row.hidden = !states[idx] 141 | }) 142 | } 143 | 144 | // Show / hide all the items markers 145 | showHideAllItems (show) { 146 | this.bottomRows.forEach(row => { 147 | if (row.active !== show) { 148 | row.click() 149 | } 150 | }) 151 | } 152 | 153 | plot (predictions, additional, config) { 154 | // Update the top items except pinned models 155 | for (let topId in this.topRowsMap) { 156 | this.topRowsMap[topId].hidden = !config[topId] 157 | } 158 | 159 | // Don't show search bar if predictions are less than or equal to maxNPreds 160 | let maxNPreds = 10 161 | if (predictions.length > maxNPreds) { 162 | this.searchBox.hidden = false 163 | 164 | // Bind search event 165 | this.searchBox.addKeyup(({ text }) => { 166 | let searchBase = predictions.map(p => { 167 | return `${p.id} ${p.meta.name} ${p.meta.description}`.toLowerCase() 168 | }) 169 | this.showRows(searchBase.map(sb => sb.includes(text))) 170 | }) 171 | } else { 172 | this.searchBox.hidden = true 173 | } 174 | 175 | // Plot models which are unpinned in the bottom section 176 | this.bottomContainer.selectAll('*').remove() 177 | this.bottomRows = predictions 178 | .filter(p => config.pinnedModels.indexOf(p.id) === -1) 179 | .map(p => { 180 | let drawerRow = makePredictionRow(p, this.tooltip) 181 | drawerRow.addOnClick(({ id, state }) => { 182 | this.showHideButtons.reset() 183 | ev.publish(this.uuid, ev.LEGEND_ITEM, { id, state }) 184 | }) 185 | 186 | this.bottomContainer.append(() => drawerRow.node) 187 | return drawerRow 188 | }) 189 | 190 | // Handle pinned models separately 191 | this.pinnedContainer.selectAll('*').remove() 192 | this.pinnedRows = [ 193 | ...predictions.filter(p => config.pinnedModels.indexOf(p.id) > -1), 194 | ...additional.filter(ad => ad.legend) 195 | ] 196 | .map(it => { 197 | let drawerRow = makePredictionRow(it, this.tooltip) 198 | drawerRow.addOnClick(({ id, state }) => { 199 | ev.publish(this.uuid, ev.LEGEND_ITEM, { id, state }) 200 | }) 201 | 202 | this.pinnedContainer.append(() => drawerRow.node) 203 | return drawerRow 204 | }) 205 | } 206 | 207 | update (predictions) { 208 | predictions.forEach(p => { 209 | let row = this.bottomRows.find(r => r.id === p.id) || this.pinnedRows.find(r => r.id === p.id) 210 | if (row) { 211 | row.na = p.noData 212 | } 213 | }) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /docs/assets/script.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | 3 | let config = { 4 | pointType: 'mmwr-week', // Default is week 5 | axes: { 6 | y: { 7 | title: 'Random numbers' // Title for the y axis 8 | } 9 | } 10 | } 11 | 12 | let timePoints = [...Array(51).keys()].map(w => { 13 | return { week: w + 1, year: 2016 } 14 | }) 15 | 16 | // Random sequence generator 17 | function rseq (n) { 18 | let seq = [Math.random()] 19 | for (let i = 1; i < n; i++) { 20 | seq.push(Math.random() * (1 + seq[i - 1])) 21 | } 22 | return seq 23 | } 24 | 25 | // Predictions look like [{ series: [{ point: 0.5 }, { point: 1.2 } ...] }, ..., null, null] 26 | let predictions = timePoints.map(tp => { 27 | if (tp.week > 30) { 28 | // We only predict upto week 30 29 | return null 30 | } else { 31 | // Provide 10 week ahead predictions 32 | return { 33 | series: rseq(10).map(r => { return { point: r } }) 34 | } 35 | } 36 | }) 37 | 38 | let data = { 39 | timePoints, 40 | models: [ 41 | { 42 | id: 'mod', 43 | meta: { 44 | name: 'Name', 45 | description: 'Model description here', 46 | url: 'http://github.com' 47 | }, 48 | pinned: false, // Setting true shows the model in top section of the legend 49 | // In case of absence of `pinned` key (or false), the model 50 | // goes in the bottom section 51 | predictions, 52 | style: { // Optional parameter for applying custom css on svg elements 53 | color: '#4682b4', // Defaults to values from the internal palette 54 | point: { 55 | // Style for the dots in prediction 56 | }, 57 | area: { 58 | // Style for the confidence area (shaded region around the line) 59 | }, 60 | line: { 61 | // Style for the main line 62 | } 63 | } 64 | } 65 | ] 66 | } 67 | 68 | // 1. Initialize 69 | // Setup the id of div where we are going to plot 70 | // Also pass in config options 71 | let timeChart = new d3Foresight.TimeChart('#timechart', config) 72 | 73 | // 2. Plot 74 | // Provide the data for the complete year 75 | timeChart.plot(data) 76 | 77 | // 3. Update 78 | // Move to the given index in the set of timePoints 79 | timeChart.update(10) 80 | // Or simply use 81 | // timeChart.moveForward() 82 | // timeChart.moveBackward() 83 | 84 | // Lets also save the timechart object in global namespace 85 | window.timeChart = timeChart 86 | 87 | let copy = it => Object.assign({}, it) 88 | function getRandomInt(max) { 89 | return Math.floor(Math.random() * Math.floor(max)) 90 | } 91 | 92 | let tcBaseline = new d3Foresight.TimeChart('#tc-baseline', Object.assign(copy(config), { 93 | baseline: { 94 | text: 'Baseline', // To show multiline text, pass an array of strings, 95 | description: 'This is a sample baseline', 96 | url: 'https://github.com' 97 | } 98 | })) 99 | tcBaseline.plot(Object.assign(copy(data), { 100 | baseline: 0.3 101 | })) 102 | tcBaseline.update(10) 103 | 104 | // Suppose we have actual data for 20 time steps only. We give null for other points 105 | let actual = rseq(20).concat(timePoints.slice(20).map(tp => null)) 106 | 107 | let tcActual = new d3Foresight.TimeChart('#tc-actual', config) 108 | tcActual.plot(Object.assign(copy(data), { actual: actual })) 109 | tcActual.update(10) 110 | 111 | // Lets only show 20 time steps. 112 | let observed = rseq(20).map((r, idx) => { 113 | let delta = 0.05 114 | let lags = [] 115 | for (let l = 20; l >= 0; l--) { 116 | lags.push({ lag: l, value: r + (delta * (20 - l)) }) 117 | } 118 | return lags 119 | }) 120 | 121 | // Add [] for other points 122 | observed = observed.concat(timePoints.slice(20).map(tp => [])) 123 | 124 | let tcObserved = new d3Foresight.TimeChart('#tc-observed', config) 125 | tcObserved.plot(Object.assign(copy(data), { observed: observed })) 126 | tcObserved.update(10) 127 | 128 | let historicalData = [ 129 | { 130 | id: 'some-past-series', 131 | actual: rseq(51) 132 | }, 133 | { 134 | id: 'another-past-series', 135 | actual: rseq(51) 136 | } 137 | ] 138 | 139 | let tcHistory = new d3Foresight.TimeChart('#tc-history', config) 140 | tcHistory.plot(Object.assign(copy(data), { history: historicalData })) 141 | tcHistory.update(10) 142 | 143 | // Predictions now look like [{ series: [ 144 | // { point: 0.5, low: [0.3, 0.4], high: [0.7, 0.6] }, 145 | // { point: 1.2, low: [1.0, 1.1], high: [1.4, 1.3] } 146 | // ...] }, ..., null, null] 147 | let predictionsWithCI = timePoints.map(tp => { 148 | if (tp.week > 30) { 149 | // We only predict upto week 30 150 | return null 151 | } else { 152 | // Provide 10 week ahead predictions adding a dummy 0.2 and 0.1 spacing 153 | // to show the confidence interval 154 | return { 155 | series: rseq(10).map(r => { 156 | return { 157 | point: r, 158 | low: [Math.max(0, r - 0.2), Math.max(0, r - 0.1)], 159 | high: [r + 0.2, r + 0.1] 160 | } 161 | }) 162 | } 163 | } 164 | }) 165 | 166 | let dataWithCI = { 167 | timePoints, 168 | models: [ 169 | { 170 | id: 'mod', 171 | meta: { 172 | name: 'Name', 173 | description: 'Model description here', 174 | url: 'https://github.com' 175 | }, 176 | predictions: predictionsWithCI 177 | } 178 | ] 179 | } 180 | 181 | let configCI = Object.assign(copy(config), { confidenceIntervals: ['90%', '50%'] }) 182 | let tcCI = new d3Foresight.TimeChart('#tc-ci', configCI) 183 | tcCI.plot(dataWithCI) 184 | tcCI.update(10) 185 | 186 | let predictionsWithPeakOnset = timePoints.map(tp => { 187 | if (tp.week > 30) { 188 | // We only predict upto week 30 189 | return null 190 | } else { 191 | return { 192 | series: rseq(10).map(r => { return { point: r } }), 193 | peakTime: { point: 12 + getRandomInt(5) }, 194 | onsetTime: { point: 8 + getRandomInt(5) }, 195 | peakValue: { point: Math.random() } 196 | } 197 | } 198 | }) 199 | 200 | let dataWithPeakOnset = { 201 | timePoints, 202 | models: [ 203 | { 204 | id: 'mod', 205 | meta: { 206 | name: 'Name', 207 | description: 'Model description here', 208 | url: 'https://github.com' 209 | }, 210 | predictions: predictionsWithPeakOnset 211 | } 212 | ] 213 | } 214 | 215 | let configOnset = Object.assign(copy(config), { onset: true }) 216 | let tcPeakOnset = new d3Foresight.TimeChart('#tc-peak-onset', configOnset) 217 | tcPeakOnset.plot(dataWithPeakOnset) 218 | tcPeakOnset.update(10) 219 | 220 | let tcAdditional = new d3Foresight.TimeChart('#timechart-additional', config) 221 | 222 | let additionalLines = [ 223 | { 224 | id: 'Extra 1', 225 | data: 1.53, // Scalar makes it show up as horizontal line 226 | style: { // Optional style parameter 227 | color: 'red', 228 | point: { 229 | // Optional parameter for styling the dots 230 | }, 231 | line: { 232 | // Style for the main line 233 | 'stroke-dasharray': '5,5' 234 | } 235 | }, 236 | meta: { 237 | // Similar to what is used in models, all optional 238 | name: 'Extra baseline', 239 | description: 'This is an additional baseline', 240 | url: 'https://github.com' 241 | }, 242 | tooltip: false, // Should the value show up in tooltip (false by default or when absent) 243 | legend: true // Should the value show up in legend (true by default or when absent) 244 | }, 245 | { 246 | id: 'Extra 2', 247 | data: rseq(51), // Structure similar to like the actual array 248 | style: { 249 | color: '#9b59b6', 250 | point: { 251 | r: 0 252 | } 253 | }, 254 | tooltip: true 255 | } 256 | ] 257 | 258 | tcAdditional.plot(Object.assign(copy(data), { additionalLines })) 259 | tcAdditional.update(10) 260 | 261 | // Lets just add 2 to the timezeros for dvds 262 | function addDvts (data) { 263 | let dvts = data.timePoints.map(tp => { 264 | return { week: tp.week + 2, year: tp.year } 265 | }) 266 | 267 | data.models.forEach(m => { 268 | m.predictions.forEach((p, idx) => { 269 | if (p) { 270 | p.dataVersionTime = dvts[idx] 271 | } 272 | }) 273 | }) 274 | 275 | return data 276 | } 277 | 278 | let tcDvd = new d3Foresight.TimeChart('#timechart-dvt-plot', config) 279 | 280 | tcDvd.plot(addDvts(copy(data))) 281 | tcDvd.update(10) 282 | 283 | let options = { 284 | baseline: { 285 | text: ['CDC', 'Baseline'], // A list of strings creates multiline text 286 | description: `Baseline ILI value as defined by CDC. 287 |

Click to know more`, 288 | url: 'http://www.cdc.gov/flu/weekly/overview.htm' // url is optional 289 | }, 290 | axes: { 291 | x: { 292 | title: ['Epidemic', 'Week'], 293 | description: `Week of the calendar year, as measured by the CDC. 294 |

Click to know more`, 295 | url: 'https://wwwn.cdc.gov/nndss/document/MMWR_Week_overview.pdf' 296 | }, 297 | y: { 298 | title: 'Weighted ILI (%)', 299 | description: `Percentage of outpatient doctor visits for 300 | influenza-like illness, weighted by state population. 301 |

Click to know more`, 302 | url: 'http://www.cdc.gov/flu/weekly/overview.htm', 303 | domain: [0, 13] // For explicitly clipping the y values 304 | } 305 | }, 306 | pointType: 'mmwr-week', 307 | confidenceIntervals: ['90%', '50%'], // List of ci labels 308 | onset: true, // Whether to show onset panel or not 309 | timezeroLine: false // Whether to show the timezeroLine, skipping this makes us fall back to the 310 | // behavior based presence of data version time 311 | } 312 | 313 | }) 314 | -------------------------------------------------------------------------------- /src/time-chart.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3' 2 | import * as domains from './utilities/data/domains' 3 | import * as tpUtils from './utilities/data/timepoints' 4 | import * as colors from './utilities/colors' 5 | import { XAxisDate } from './components/common/axis-x' 6 | import { YAxis } from './components/common/axis-y' 7 | import ControlPanel from './components/common/control-panel' 8 | import Actual from './components/time-chart/actual' 9 | import Baseline from './components/time-chart/baseline' 10 | import HistoricalLines from './components/time-chart/historical-lines' 11 | import Observed from './components/time-chart/observed' 12 | import Overlay from './components/time-chart/overlay' 13 | import TimezeroLine from './components/time-chart/timezero-line' 14 | import Prediction from './components/time-chart/prediction' 15 | import AdditionalLine from './components/time-chart/additional-line' 16 | import TimeRect from './components/time-chart/timerect' 17 | import Chart from './chart' 18 | import { verifyTimeChartData } from './utilities/data/verify' 19 | import { getTimeChartDataConfig } from './utilities/data/config' 20 | import { filterActiveLines } from './utilities/misc' 21 | import * as ev from './events' 22 | 23 | export default class TimeChart extends Chart { 24 | constructor (element, options = {}) { 25 | let defaultConfig = { 26 | baseline: { 27 | text: 'Baseline', 28 | description: 'Baseline value', 29 | url: '#' 30 | }, 31 | pointType: 'week', 32 | confidenceIntervals: [] 33 | } 34 | 35 | let selection = d3.select(element) 36 | .attr('class', 'd3f-chart d3f-time-chart') 37 | super(selection, Object.assign({}, defaultConfig, options)) 38 | 39 | // Initialize scales 40 | this.xScale = d3.scaleLinear().range([0, this.width]) 41 | this.xScaleDate = d3.scaleTime().range([0, this.width]) 42 | this.xScalePoint = d3.scalePoint().range([0, this.width]) 43 | this.yScale = d3.scaleLinear().range([this.height, 0]) 44 | 45 | this.yAxis = this.append(new YAxis(this.layout, { 46 | ...this.config.axes.y, 47 | tooltip: this.tooltip 48 | })) 49 | 50 | this.xAxis = this.append(new XAxisDate(this.layout, { 51 | ...this.config.axes.x, 52 | tooltip: this.tooltip 53 | })) 54 | 55 | this.timerect = this.append(new TimeRect(this.layout)) 56 | this.timezeroLine = this.append(new TimezeroLine(this.layout)) 57 | this.overlay = this.append(new Overlay(this.layout, { tooltip: this.tooltip, uuid: this.uuid })) 58 | this.history = this.append(new HistoricalLines({ tooltip: this.tooltip })) 59 | this.baseline = this.append(new Baseline(this.layout, { ...this.config.baseline, tooltip: this.tooltip })) 60 | this.actual = this.append(new Actual()) 61 | this.observed = this.append(new Observed()) 62 | this.predictions = [] 63 | this.additional = [] 64 | this.cid = this.config.confidenceIntervals.length - 1 65 | 66 | let panelConfig = { 67 | ci: this.cid === -1 ? false : { 68 | idx: this.cid, 69 | values: this.config.confidenceIntervals 70 | }, 71 | tooltip: this.tooltip, 72 | uuid: this.uuid 73 | } 74 | 75 | // Control panel 76 | this.controlPanel = new ControlPanel(panelConfig) 77 | this.selection.append(() => this.controlPanel.node) 78 | 79 | // Event subscriptions for control panel 80 | ev.addSub(this.uuid, ev.PANEL_MOVE_NEXT, (msg, data) => { 81 | let oldIdx = this.currentIdx 82 | this.moveForward() 83 | if (this.currentIdx !== oldIdx) { 84 | ev.publish(this.uuid, ev.JUMP_TO_INDEX, this.currentIdx) 85 | } 86 | }) 87 | 88 | ev.addSub(this.uuid, ev.PANEL_MOVE_PREV, (msg, data) => { 89 | let oldIdx = this.currentIdx 90 | this.moveBackward() 91 | if (this.currentIdx !== oldIdx) { 92 | ev.publish(this.uuid, ev.JUMP_TO_INDEX, this.currentIdx) 93 | } 94 | }) 95 | 96 | ev.addSub(this.uuid, ev.JUMP_TO_INDEX_INTERNAL, (msg, idx) => { 97 | let oldIdx = this.currentIdx 98 | this.update(idx) 99 | if (this.currentIdx !== oldIdx) { 100 | ev.publish(this.uuid, ev.JUMP_TO_INDEX, idx) 101 | } 102 | }) 103 | 104 | ev.addSub(this.uuid, ev.LEGEND_ITEM, (msg, { id, state }) => { 105 | if (id === 'History') { 106 | this.history.hidden = !this.history.hidden 107 | } else if (id === 'Actual') { 108 | this.actual.hidden = !this.actual.hidden 109 | } else if (id === 'Observed') { 110 | this.observed.hidden = !this.observed.hidden 111 | } else { 112 | let marker = [...this.predictions, ...this.additional].find(m => m.id === id) 113 | if (marker) { 114 | marker.hidden = !state 115 | } 116 | } 117 | }) 118 | 119 | ev.addSub(this.uuid, ev.LEGEND_CI, (msg, { idx }) => { 120 | this.predictions.forEach(p => { 121 | this.cid = p.cid = idx 122 | p.update(this.currentIdx) 123 | }) 124 | }) 125 | } 126 | 127 | // plot data 128 | plot (data) { 129 | verifyTimeChartData(data) 130 | 131 | this.dataConfig = getTimeChartDataConfig(data, this.config) 132 | this.dataVersionTimes = tpUtils.parseDataVersionTimes(data, this.dataConfig) 133 | this.ticks = this.dataConfig.ticks 134 | 135 | if (this.config.axes.y.domain) { 136 | this.yScale.domain(this.config.axes.y.domain) 137 | } else { 138 | this.yScale.domain(domains.y(data, this.dataConfig)) 139 | } 140 | this.xScale.domain(domains.x(data, this.dataConfig)) 141 | this.xScaleDate.domain(domains.xDate(data, this.dataConfig)) 142 | this.xScalePoint.domain(domains.xPoint(data, this.dataConfig)) 143 | 144 | this.xAxis.plot(this.scales) 145 | this.yAxis.plot(this.scales) 146 | 147 | this.timerect.plot(this.scales) 148 | this.timezeroLine.plot(this.scales) 149 | this.timezeroLine.hidden = !this.dataConfig.timezeroLine 150 | 151 | this.baseline.hidden = !this.dataConfig.baseline 152 | if (this.dataConfig.baseline) { 153 | this.baseline.plot(this.scales, data.baseline) 154 | } 155 | if (this.dataConfig.actual) { 156 | this.actual.plot(this.scales, data.actual) 157 | } 158 | if (this.dataConfig.observed) { 159 | this.observed.plot(this.scales, data.observed) 160 | } 161 | if (this.dataConfig.history) { 162 | this.history.plot(this.scales, data.history) 163 | } 164 | 165 | // Plot additional lines 166 | if (this.dataConfig.additionalLines) { 167 | this.additional = filterActiveLines(this.additional, data.additionalLines) 168 | data.additionalLines.forEach((ad, idx) => { 169 | let addMarker 170 | let markerIndex = this.additional.findIndex(a => a.id === ad.id) 171 | if (markerIndex === -1) { 172 | addMarker = new AdditionalLine(this.layout, { 173 | id: ad.id, 174 | meta: ad.meta, 175 | tooltip: 'tooltip' in ad ? ad.tooltip : false, 176 | legend: 'legend' in ad ? ad.legend : true, 177 | style: { color: 'gray', ...ad.style } 178 | }) 179 | this.append(addMarker) 180 | this.additional.push(addMarker) 181 | } else { 182 | addMarker = this.additional[markerIndex] 183 | } 184 | addMarker.plot(this.scales, ad.data) 185 | }) 186 | } 187 | 188 | let totalModels = data.models.length 189 | let onsetDiff = (this.onsetHeight - 2) / (totalModels + 1) 190 | 191 | // Setup colors 192 | this.colors = colors.getColorMap(data.models.length) 193 | 194 | // Clear markers not needed 195 | this.predictions = filterActiveLines(this.predictions, data.models) 196 | 197 | // Generate markers for predictions if not already there 198 | // Assume unique model ids 199 | data.models.forEach((m, idx) => { 200 | let predMarker 201 | let markerIndex = this.predictions.findIndex(p => p.id === m.id) 202 | if (markerIndex === -1) { 203 | // The marker is not present from previous calls to plot 204 | predMarker = new Prediction({ 205 | id: m.id, 206 | meta: m.meta, 207 | onsetY: (idx + 1) * onsetDiff + this.height + 1, 208 | cid: this.cid, 209 | tooltip: this.tooltip, 210 | ...this.dataConfig.predictions, 211 | style: { color: this.colors[idx], ...m.style } 212 | }) 213 | this.append(predMarker) 214 | this.predictions.push(predMarker) 215 | } else { 216 | predMarker = this.predictions[markerIndex] 217 | } 218 | predMarker.plot(this.scales, m.predictions) 219 | }) 220 | 221 | this.controlPanel.plot(this.predictions, this.additional, this.dataConfig) 222 | 223 | this.overlay.plot(this.scales, [ 224 | this.actual, this.observed, 225 | ...this.predictions, ...this.additional 226 | ]) 227 | 228 | // Hot start the chart 229 | this.currentIdx = 0 230 | this.update(this.currentIdx) 231 | } 232 | 233 | /** 234 | * Update marker position 235 | */ 236 | update (idx) { 237 | if (idx !== this.currentIdx) { 238 | this.currentIdx = idx 239 | 240 | // Use data versions to update the timerect 241 | this.timerect.update(this.dataVersionTimes[idx]) 242 | 243 | this.predictions.forEach(p => { p.update(idx) }) 244 | this.overlay.update(this.predictions) 245 | if (this.dataConfig.observed) { 246 | this.observed.update(idx) 247 | } 248 | this.controlPanel.update(this.predictions) 249 | 250 | // Since version times are present (and might be different) 251 | // we show the time zero line separately 252 | this.timezeroLine.textHidden = this.predictions.every(p => p.noData) 253 | 254 | this.timezeroLine.update(idx) 255 | } 256 | } 257 | 258 | /** 259 | * Move chart one step ahead 260 | */ 261 | moveForward () { 262 | this.update(this.deltaIndex(1)) 263 | } 264 | 265 | /** 266 | * Move chart one step back 267 | */ 268 | moveBackward () { 269 | this.update(this.deltaIndex(-1)) 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /docs/index.org: -------------------------------------------------------------------------------- 1 | #+TITLE: D3-foresight documentation 2 | 3 | #+OPTIONS: toc:nil title:nil num:nil html-postamble:nil 4 | #+OPTIONS: html5-fancy:t 5 | #+HTML_DOCTYPE: html5 6 | #+MACRO: js #+HTML_HEAD: 7 | #+MACRO: css #+HTML_HEAD: 8 | #+MACRO: badge @@html:@@ 9 | 10 | {{{js(https://d3js.org/d3.v4.min.js)}}} 11 | {{{js(https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js)}}} 12 | {{{js(./assets/d3-foresight.min.js)}}} 13 | {{{js(./assets/script.js)}}} 14 | 15 | {{{css(https://fonts.googleapis.com/css?family=Merriweather:900\,900italic\,300\,300italic)}}} 16 | {{{css(https://fonts.googleapis.com/css?family=Open+Sans:400\,300\,800)}}} 17 | {{{css(https://fonts.googleapis.com/css?family=Fira+Mono)}}} 18 | {{{css(./assets/css/main.css)}}} 19 | {{{css(./assets/css/overrides.css)}}} 20 | {{{css(./assets/fontello/fontello.css)}}} 21 | 22 | #+HTML: 26 | 27 | {{{badge(https://travis-ci.org/reichlab/d3-foresight,https://img.shields.io/travis/reichlab/d3-foresight/master.svg?style=for-the-badge)}}} 28 | {{{badge(https://www.npmjs.com/package/d3-foresight,https://img.shields.io/npm/v/d3-foresight.svg?style=for-the-badge)}}} 29 | {{{badge(https://www.npmjs.com/package/d3-foresight,https://img.shields.io/npm/l/d3-foresight.svg?style=for-the-badge)}}} 30 | {{{badge(https://github.com/reichlab/d3-foresight/issues,https://img.shields.io/github/issues/reichlab/d3-foresight.svg?style=for-the-badge)}}} 31 | 32 | {{{badge(https://github.com/feross/standard,https://cdn.rawgit.com/feross/standard/master/badge.svg)}}} 33 | 34 | D3 Foresight is a [[https://github.com/d3/d3][d3]] based library for visualizing time series forecasts 35 | interactively. At a /time point/, a general time series model trying to predict a 36 | single variable series (like temperature) makes forecasts for some time points 37 | in the future with some uncertainty described by probability distributions. 38 | Other than these predictions, it might also provide an estimate of /peak/ and some 39 | /onset/outbreak/ point (as defined by a baseline). The visualizations in this 40 | library try to cover these cases. See [[http://reichlab.io/flusight][reichlab/flusight]] for a demo. 41 | 42 | #+TOC: headlines 2 43 | 44 | * Setting up 45 | :PROPERTIES: 46 | :CUSTOM_ID: setting-up 47 | :END: 48 | 49 | The library requires [[https://d3js.org/][d3]] and [[https://momentjs.com][momentjs]] as external dependencies. To build 50 | foresight itself, use ~npm compile~ (for ~./dist/d3-foresight.js~) or ~npm build~ (for 51 | ~./dist/d3-foresight.min.js~). The library is also available on npm as 52 | [[https://www.npmjs.com/package/d3-foresight][d3-foresight]]. For browser, include these in your html: 53 | 54 | #+BEGIN_EXAMPLE 55 | 56 | 57 | 58 | ;; Or use the unpkg url 59 | 60 | #+END_EXAMPLE 61 | 62 | Additionally, a few icons (in the legend) have an icon font dependency. The css 63 | for that can be added using: 64 | 65 | #+BEGIN_EXAMPLE 66 | 67 | #+END_EXAMPLE 68 | 69 | #+BEGIN_SRC js :tangle ./assets/script.js :exports none 70 | document.addEventListener("DOMContentLoaded", function () { 71 | #+END_SRC 72 | 73 | * TimeChart 74 | :PROPERTIES: 75 | :CUSTOM_ID: timechart-section 76 | :END: 77 | 78 | A ~TimeChart~ displays the time series to be predicted and the models' 79 | predictions. Beyond the very minimal plots involving only model forecasts, it 80 | can show the following items: 81 | 82 | - The actual time series to be predicted. 83 | - The /observed/ time series. This might be different from the actual series if 84 | the truth is revised (e.g. due to reporting delays in the incident count for 85 | certain disease). 86 | - Baseline value for the year/season. 87 | - History of the series over some past years/seasons. 88 | - Additional prediction information from models like 89 | + Confidence intervals 90 | + Peak and onset prediction 91 | 92 | #+CAPTION: ~TimeChart~ shows model predictions and the actual time series 93 | [[file:./timechart.png]] 94 | 95 | ** Basic plot 96 | :PROPERTIES: 97 | :CUSTOM_ID: timechart-basic-plot 98 | :END: 99 | In this section, we will create a very basic visualization involving one model 100 | providing random numbers as forecasts for a year. 101 | 102 | *** Configuration 103 | :PROPERTIES: 104 | :CUSTOM_ID: timechart-basic-plot-configuration 105 | :END: 106 | 107 | Time in foresight is represented using ~timePoints~ which is an array mapping to 108 | discrete date/time values. As of now, foresight supports the following three 109 | types of time points: 110 | 111 | 1. ~week~ 112 | 2. ~mmwr-week~ based on [[https://wwwn.cdc.gov/nndss/document/MMWR_Week_overview.pdf][MMWR definitions]] 113 | 3. ~biweek~ denoting a unit of two weeks 114 | 4. ~month~ denoting a month 115 | 116 | All points can be represented using either standard JS Date objects, a string 117 | readable by momentjs (like ~YYYYMMDD~) or using simple objects like shown: 118 | 119 | #+BEGIN_SRC js 120 | { 121 | week: 20, // biweek/month 122 | year: 2016 123 | } 124 | #+END_SRC 125 | 126 | Lets work on mmwr weeks for the year 2016. Our week choice can be passed to 127 | foresight charts using a config object which is the following in our case: 128 | 129 | #+BEGIN_SRC js :tangle ./assets/script.js 130 | let config = { 131 | pointType: 'mmwr-week', // Default is week 132 | axes: { 133 | y: { 134 | title: 'Random numbers' // Title for the y axis 135 | } 136 | } 137 | } 138 | #+END_SRC 139 | 140 | *** Data 141 | :PROPERTIES: 142 | :CUSTOM_ID: timechart-basic-plot-data 143 | :END: 144 | 145 | At minimum, ~TimeChart~ expects an array of time points and an array of model 146 | data. The time points in our case go from week 1 to week 52 of 2016 and can be 147 | represented as: 148 | 149 | #+BEGIN_SRC js :tangle ./assets/script.js 150 | let timePoints = [...Array(51).keys()].map(w => { 151 | return { week: w + 1, year: 2016 } 152 | }) 153 | #+END_SRC 154 | 155 | At each time point, our model provides predictions for the next 10 time points. 156 | These predictions are represented in an array of same size as the time points. 157 | For when the model has no predictions, we put in ~null~. 158 | 159 | #+BEGIN_SRC js :tangle ./assets/script.js 160 | // Random sequence generator 161 | function rseq (n) { 162 | let seq = [Math.random()] 163 | for (let i = 1; i < n; i++) { 164 | seq.push(Math.random() * (1 + seq[i - 1])) 165 | } 166 | return seq 167 | } 168 | 169 | // Predictions look like [{ series: [{ point: 0.5 }, { point: 1.2 } ...] }, ..., null, null] 170 | let predictions = timePoints.map(tp => { 171 | if (tp.week > 30) { 172 | // We only predict upto week 30 173 | return null 174 | } else { 175 | // Provide 10 week ahead predictions 176 | return { 177 | series: rseq(10).map(r => { return { point: r } }) 178 | } 179 | } 180 | }) 181 | #+END_SRC 182 | 183 | Finally we put everything together in a single object. Notice the extra metadata 184 | involved in putting together the values for the model: 185 | 186 | #+BEGIN_SRC js :tangle ./assets/script.js 187 | let data = { 188 | timePoints, 189 | models: [ 190 | { 191 | id: 'mod', 192 | meta: { 193 | name: 'Name', 194 | description: 'Model description here', 195 | url: 'http://github.com' 196 | }, 197 | pinned: false, // Setting true shows the model in top section of the legend 198 | // In case of absence of `pinned` key (or false), the model 199 | // goes in the bottom section 200 | predictions, 201 | style: { // Optional parameter for applying custom css on svg elements 202 | color: '#4682b4', // Defaults to values from the internal palette 203 | point: { 204 | // Style for the dots in prediction 205 | }, 206 | area: { 207 | // Style for the confidence area (shaded region around the line) 208 | }, 209 | line: { 210 | // Style for the main line 211 | } 212 | } 213 | } 214 | ] 215 | } 216 | #+END_SRC 217 | 218 | *** Plotting 219 | :PROPERTIES: 220 | :CUSTOM_ID: timechart-basic-plot-plotting 221 | :END: 222 | 223 | The life cycle of ~TimeChart~ involves the following stages: 224 | 225 | 1. Initialization 226 | 2. Plotting 227 | 3. Updating 228 | 229 | #+BEGIN_SRC js :tangle ./assets/script.js 230 | // 1. Initialize 231 | // Setup the id of div where we are going to plot 232 | // Also pass in config options 233 | let timeChart = new d3Foresight.TimeChart('#timechart', config) 234 | 235 | // 2. Plot 236 | // Provide the data for the complete year 237 | timeChart.plot(data) 238 | 239 | // 3. Update 240 | // Move to the given index in the set of timePoints 241 | timeChart.update(10) 242 | // Or simply use 243 | // timeChart.moveForward() 244 | // timeChart.moveBackward() 245 | 246 | // Lets also save the timechart object in global namespace 247 | window.timeChart = timeChart 248 | #+END_SRC 249 | 250 | #+HTML:

251 | #+HTML:
252 | 253 | If you are able to see the plot above (which you should be, else file an [[https://github.com/reichlab/d3-foresight/issues][issue]]), 254 | you should be able to move around by clicking the arrow buttons in legend or 255 | clicking on the chart itself. These mouse click events can trigger user defined 256 | functions too. See the section on [[Hooks]] for more description. 257 | 258 | ** Adding components 259 | :PROPERTIES: 260 | :CUSTOM_ID: timechart-adding-components 261 | :END: 262 | 263 | This section builds up on the chart above to add more information 264 | 265 | *** Baseline 266 | :PROPERTIES: 267 | :CUSTOM_ID: timechart-adding-components-baseline 268 | :END: 269 | 270 | A baseline is a horizontal line specifying some sort of baseline. To plot it, 271 | pass a ~baseline~ item in data. Optionally, set a label for the baseline by 272 | providing it in the ~config~. 273 | 274 | #+BEGIN_SRC js :tangle ./assets/script.js :exports none 275 | let copy = it => Object.assign({}, it) 276 | function getRandomInt(max) { 277 | return Math.floor(Math.random() * Math.floor(max)) 278 | } 279 | #+END_SRC 280 | 281 | #+BEGIN_SRC js :tangle ./assets/script.js 282 | let tcBaseline = new d3Foresight.TimeChart('#tc-baseline', Object.assign(copy(config), { 283 | baseline: { 284 | text: 'Baseline', // To show multiline text, pass an array of strings, 285 | description: 'This is a sample baseline', 286 | url: 'https://github.com' 287 | } 288 | })) 289 | tcBaseline.plot(Object.assign(copy(data), { 290 | baseline: 0.3 291 | })) 292 | tcBaseline.update(10) 293 | #+END_SRC 294 | 295 | #+HTML:

296 | #+HTML:
297 | 298 | *** Actual 299 | :PROPERTIES: 300 | :CUSTOM_ID: timechart-adding-components-actual 301 | :END: 302 | 303 | Another important component to show is the actual line that we are trying to 304 | predict. The ~actual~ series is an array of the same length as the ~timePoints~ and 305 | can be something like this 306 | 307 | #+BEGIN_SRC js :tangle ./assets/script.js 308 | // Suppose we have actual data for 20 time steps only. We give null for other points 309 | let actual = rseq(20).concat(timePoints.slice(20).map(tp => null)) 310 | #+END_SRC 311 | 312 | #+BEGIN_SRC js :tangle ./assets/script.js 313 | let tcActual = new d3Foresight.TimeChart('#tc-actual', config) 314 | tcActual.plot(Object.assign(copy(data), { actual: actual })) 315 | tcActual.update(10) 316 | #+END_SRC 317 | 318 | #+HTML:

319 | #+HTML:
320 | 321 | *** Observed 322 | :PROPERTIES: 323 | :CUSTOM_ID: timechart-adding-components-observed 324 | :END: 325 | 326 | Observed data series refers to the time series /as observed/ at a certain time 327 | point. Observed lines are useful (only) when there are updates in actual data, 328 | resulting in different /versions/ based on when the data was released. 329 | 330 | We formalize these versions using /lags/. When we are at a time point $t$ what 331 | we get as truth (the value that creates the actual series) is a lag $0$ truth, 332 | $l_0(t)$. At the same time, we also get $l_1(t - 1)$ truth for time point $t - 333 | 1$, $l_2(t - 2)$ truth for $t - 2$ and so on. In this case, even if we have 334 | higher lag truths for time $t$, the observed series at time $t$ will be made up 335 | of the series $[l_i(t - i), \forall i \in [t - 1, t - 2, \ldots 0]]$ 336 | 337 | To display the observe data thus we need to provide the required lag truths for 338 | a time point. We do this by providing a list of lists. The outer list is over 339 | all the time points. The inner lists represent decreasing lag values (like ~{ 340 | lag: 2, value: 0.2}~) for that time point. 341 | 342 | #+BEGIN_aside 343 | Current observed line API is not really nice. Follow [[https://github.com/reichlab/d3-foresight/issues/54][this issue]] for a better 344 | way. 345 | #+END_aside 346 | 347 | A simple example follows. Notice that the third time point is the latest one 348 | and so we only have lag 0 value for that. 349 | 350 | #+BEGIN_SRC js 351 | // Assume there are 3 timepoints 352 | let observedExample = [ 353 | [ { lag: 2, value: 0.88 }, { lag: 1, value: 0.88 }, { lag: 0, value: 0.93 }], 354 | [ { lag: 1, value: 1.11 }, { lag: 0, value: 1.32 } ], 355 | [ { lag: 0, value: 1.13 } ] 356 | ] 357 | #+END_SRC 358 | 359 | The next snippet generates some random data programmatically for demoing 360 | purpose. 361 | 362 | #+BEGIN_SRC js :tangle ./assets/script.js 363 | // Lets only show 20 time steps. 364 | let observed = rseq(20).map((r, idx) => { 365 | let delta = 0.05 366 | let lags = [] 367 | for (let l = 20; l >= 0; l--) { 368 | lags.push({ lag: l, value: r + (delta * (20 - l)) }) 369 | } 370 | return lags 371 | }) 372 | 373 | // Add [] for other points 374 | observed = observed.concat(timePoints.slice(20).map(tp => [])) 375 | #+END_SRC 376 | 377 | #+BEGIN_SRC js :tangle ./assets/script.js 378 | let tcObserved = new d3Foresight.TimeChart('#tc-observed', config) 379 | tcObserved.plot(Object.assign(copy(data), { observed: observed })) 380 | tcObserved.update(10) 381 | #+END_SRC 382 | 383 | #+HTML:

384 | #+HTML:
385 | 386 | *** History 387 | :PROPERTIES: 388 | :CUSTOM_ID: timechart-adding-components-history 389 | :END: 390 | 391 | Historical data lines (similar to ~actual~ series) can be shown by passing an 392 | array of historical actual series like the following: 393 | 394 | #+BEGIN_SRC js :tangle ./assets/script.js 395 | let historicalData = [ 396 | { 397 | id: 'some-past-series', 398 | actual: rseq(51) 399 | }, 400 | { 401 | id: 'another-past-series', 402 | actual: rseq(51) 403 | } 404 | ] 405 | #+END_SRC 406 | 407 | #+BEGIN_SRC js :tangle ./assets/script.js 408 | let tcHistory = new d3Foresight.TimeChart('#tc-history', config) 409 | tcHistory.plot(Object.assign(copy(data), { history: historicalData })) 410 | tcHistory.update(10) 411 | #+END_SRC 412 | 413 | #+HTML:

414 | #+HTML:
415 | 416 | One possible issue with showing history is that the number of time units might 417 | not line up perfectly. For example, the current year might have 52 weeks but 418 | some older year might have had 53 weeks. Since we expect all the actual series 419 | passed as history to have the same length, the user is supposed to pad/clip all 420 | the series to match the current season's length. 421 | 422 | *** Confidence Intervals 423 | :PROPERTIES: 424 | :CUSTOM_ID: timechart-adding-components-ci 425 | :END: 426 | 427 | Confidence intervals show a region of uncertainty around the model predictions 428 | (peak, onset and the regular time step predictions). These involve users to 429 | specify: 430 | 431 | 1. Label for the confidence intervals to be shown in legend. For example `90%` 432 | etc. 433 | 2. Additional ~low~ and ~high~ values along with ~point~ values in predictions. 434 | 435 | The legend label can be specified in the main chart option by passing the 436 | following key/value pair (say we want to show ~90%~ and ~50%~ CIs): 437 | 438 | #+BEGIN_SRC js 439 | ... 440 | confidenceIntervals: ['90%', '50%'] 441 | ... 442 | #+END_SRC 443 | 444 | Corresponding to the values specified above (and in the same order), we now 445 | attach a list of ~low~ and ~high~ values as shown below: 446 | 447 | #+BEGIN_SRC js :tangle ./assets/script.js 448 | // Predictions now look like [{ series: [ 449 | // { point: 0.5, low: [0.3, 0.4], high: [0.7, 0.6] }, 450 | // { point: 1.2, low: [1.0, 1.1], high: [1.4, 1.3] } 451 | // ...] }, ..., null, null] 452 | let predictionsWithCI = timePoints.map(tp => { 453 | if (tp.week > 30) { 454 | // We only predict upto week 30 455 | return null 456 | } else { 457 | // Provide 10 week ahead predictions adding a dummy 0.2 and 0.1 spacing 458 | // to show the confidence interval 459 | return { 460 | series: rseq(10).map(r => { 461 | return { 462 | point: r, 463 | low: [Math.max(0, r - 0.2), Math.max(0, r - 0.1)], 464 | high: [r + 0.2, r + 0.1] 465 | } 466 | }) 467 | } 468 | } 469 | }) 470 | #+END_SRC 471 | 472 | Putting everything together now: 473 | 474 | #+BEGIN_SRC js :tangle ./assets/script.js 475 | let dataWithCI = { 476 | timePoints, 477 | models: [ 478 | { 479 | id: 'mod', 480 | meta: { 481 | name: 'Name', 482 | description: 'Model description here', 483 | url: 'https://github.com' 484 | }, 485 | predictions: predictionsWithCI 486 | } 487 | ] 488 | } 489 | 490 | let configCI = Object.assign(copy(config), { confidenceIntervals: ['90%', '50%'] }) 491 | let tcCI = new d3Foresight.TimeChart('#tc-ci', configCI) 492 | tcCI.plot(dataWithCI) 493 | tcCI.update(10) 494 | #+END_SRC 495 | 496 | #+HTML:

497 | #+HTML:
498 | 499 | *** Peak and Onset 500 | :PROPERTIES: 501 | :CUSTOM_ID: timechart-adding-components-peak-and-onset 502 | :END: 503 | 504 | Just like a ~series~ key in predictions, we can also add ~onsetTime~, ~peakTime~ and 505 | ~peakValue~ keys to show the respective predictions. Each of these have a 506 | /mandatory/ ~point~ key and can have ~low~ and ~high~ ranges to show confidence 507 | intervals. Here is an example for a model's prediction at a certain timepoint 508 | with the onset and peak values specified (along with a confidence interval): 509 | 510 | #+BEGIN_SRC js 511 | // Consider the confidence intervals ['90%', '50%'] 512 | { 513 | onsetTime: { 514 | high: [15, 17], 515 | low: [9, 11], 516 | point: 13 517 | }, 518 | peakTime: { 519 | high: [25, 27], 520 | low: [19, 21], 521 | point: 23 522 | }, 523 | peakValue: { 524 | high: [3.6, 3.8], 525 | low: [3.0, 3.2], 526 | point: 3.4 527 | }, 528 | series: [ 529 | { 530 | high: [1.4, 1.6], 531 | low: [0.8, 1.0], 532 | point: 1.2 533 | }, 534 | ... 535 | ] 536 | } 537 | #+END_SRC 538 | 539 | Note that the values for ~peakTime~ and ~onsetTime~ are indices for the time points 540 | instead of actual week values. For example, suppose the time points actually 541 | refer to weeks from 5 to 15 (inclusive) for a year. An ~onsetTime~ value of 3 will 542 | now refer to week 9 (0 based index starting at 5). 543 | 544 | By not using the actual week value here, we localize the /meaning/ of time point 545 | in a single place, the series ~timePoints~ itself. 546 | 547 | Lets recreate the season data now with added peak and onset predictions. We will 548 | not be adding confidence intervals here to keep things simple. 549 | 550 | #+BEGIN_SRC js :tangle ./assets/script.js 551 | let predictionsWithPeakOnset = timePoints.map(tp => { 552 | if (tp.week > 30) { 553 | // We only predict upto week 30 554 | return null 555 | } else { 556 | return { 557 | series: rseq(10).map(r => { return { point: r } }), 558 | peakTime: { point: 12 + getRandomInt(5) }, 559 | onsetTime: { point: 8 + getRandomInt(5) }, 560 | peakValue: { point: Math.random() } 561 | } 562 | } 563 | }) 564 | 565 | #+END_SRC 566 | 567 | For showing the onset value, we also need to pass a config option ~{ onset: true 568 | }~ to the timeChart so that the onset panel is displayed just above the x axis. 569 | 570 | #+BEGIN_SRC js :tangle ./assets/script.js 571 | let dataWithPeakOnset = { 572 | timePoints, 573 | models: [ 574 | { 575 | id: 'mod', 576 | meta: { 577 | name: 'Name', 578 | description: 'Model description here', 579 | url: 'https://github.com' 580 | }, 581 | predictions: predictionsWithPeakOnset 582 | } 583 | ] 584 | } 585 | 586 | let configOnset = Object.assign(copy(config), { onset: true }) 587 | let tcPeakOnset = new d3Foresight.TimeChart('#tc-peak-onset', configOnset) 588 | tcPeakOnset.plot(dataWithPeakOnset) 589 | tcPeakOnset.update(10) 590 | #+END_SRC 591 | 592 | #+HTML:

593 | #+HTML:
594 | 595 | *** Additional lines 596 | :PROPERTIES: 597 | :CUSTOM_ID: timechart-adding-components-additional-lines 598 | :END: 599 | 600 | Starting from ~v0.10.0~, you can add extra lines to be shown in the plot. To keep 601 | the library backward compatible, you need to provide the extra data as another 602 | key in the data object that you send to the ~plot~ function. Here is an example 603 | and specification of the structure that we expect: 604 | 605 | #+BEGIN_SRC js :tangle ./assets/script.js 606 | let tcAdditional = new d3Foresight.TimeChart('#timechart-additional', config) 607 | 608 | let additionalLines = [ 609 | { 610 | id: 'Extra 1', 611 | data: 1.53, // Scalar makes it show up as horizontal line 612 | style: { // Optional style parameter 613 | color: 'red', 614 | point: { 615 | // Optional parameter for styling the dots 616 | }, 617 | line: { 618 | // Style for the main line 619 | 'stroke-dasharray': '5,5' 620 | } 621 | }, 622 | meta: { 623 | // Similar to what is used in models, all optional 624 | name: 'Extra baseline', 625 | description: 'This is an additional baseline', 626 | url: 'https://github.com' 627 | }, 628 | tooltip: false, // Should the value show up in tooltip (false by default or when absent) 629 | legend: true // Should the value show up in legend (true by default or when absent) 630 | }, 631 | { 632 | id: 'Extra 2', 633 | data: rseq(51), // Structure similar to like the actual array 634 | style: { 635 | color: '#9b59b6', 636 | point: { 637 | r: 0 638 | } 639 | }, 640 | tooltip: true 641 | } 642 | ] 643 | 644 | tcAdditional.plot(Object.assign(copy(data), { additionalLines })) 645 | tcAdditional.update(10) 646 | #+END_SRC 647 | 648 | #+HTML:

649 | #+HTML:
650 | 651 | ** Data Version Time and Timezero 652 | :PROPERTIES: 653 | :CUSTOM_ID: timechart-dvt 654 | :END: 655 | 656 | There are two possible reference times for a prediction by a particular model: 657 | 658 | 1. /Timezero/: The time with respect to which the forecasts are made. For example, 659 | if a model predicts 3 steps ahead values of ~[1.0, 1.2, 0.5]~ with a timezero 660 | of ~t~, then we say that the predicted values are for time steps ~t + 1~, ~t + 2~, 661 | and ~t + 3~. 662 | 2. /Data Version Time/: Specifies the data at a particular version (given by the 663 | /time/ value) the model looked at while making its prediction. 664 | 665 | In a usual prediction task, when we are a time ~t~, our predictions are made by 666 | considering ~t~ as the /timezero/ *and* the /data version time/. The /data version time/ 667 | is displayed using a gray shaded region (covering all the data that the model 668 | looked at) and a boundary text 'Data as of'. The timezero line is shown as a 669 | separate dashed vertical line with text 'Timezero'. 670 | 671 | When both times are the same, only data version time is displayed. In case the 672 | user provides data version times separately for each prediction, both times are 673 | shown since they /might/ be different. You can also override the display of the 674 | /timezero/ line by passing a Boolean key in either the /options/ parameter (passed 675 | when initializing the plot) or the data parameter (passed when calling the /plot/ 676 | function): 677 | 678 | #+BEGIN_SRC js 679 | ... 680 | timezeroLine: true 681 | ... 682 | #+END_SRC 683 | 684 | Here is an example plot where user passes in additional data version. Note that 685 | the ~dataVersionTime~ values are /not/ indices for the ~timePoints~ array but date 686 | time values themselves. We first define the function that adds dummy data 687 | version time to all the predictions. 688 | 689 | #+BEGIN_SRC js :tangle ./assets/script.js 690 | // Lets just add 2 to the timezeros for dvds 691 | function addDvts (data) { 692 | let dvts = data.timePoints.map(tp => { 693 | return { week: tp.week + 2, year: tp.year } 694 | }) 695 | 696 | data.models.forEach(m => { 697 | m.predictions.forEach((p, idx) => { 698 | if (p) { 699 | p.dataVersionTime = dvts[idx] 700 | } 701 | }) 702 | }) 703 | 704 | return data 705 | } 706 | 707 | let tcDvd = new d3Foresight.TimeChart('#timechart-dvt-plot', config) 708 | 709 | tcDvd.plot(addDvts(copy(data))) 710 | tcDvd.update(10) 711 | #+END_SRC 712 | 713 | #+HTML:

714 | #+HTML:
715 | 716 | ** TODO All config and data options 717 | :PROPERTIES: 718 | :CUSTOM_ID: timechart-config 719 | :END: 720 | 721 | Possible options to the constructor are described below: 722 | 723 | #+BEGIN_SRC js :tangle ./assets/script.js 724 | let options = { 725 | baseline: { 726 | text: ['CDC', 'Baseline'], // A list of strings creates multiline text 727 | description: `Baseline ILI value as defined by CDC. 728 |

Click to know more`, 729 | url: 'http://www.cdc.gov/flu/weekly/overview.htm' // url is optional 730 | }, 731 | axes: { 732 | x: { 733 | title: ['Epidemic', 'Week'], 734 | description: `Week of the calendar year, as measured by the CDC. 735 |

Click to know more`, 736 | url: 'https://wwwn.cdc.gov/nndss/document/MMWR_Week_overview.pdf' 737 | }, 738 | y: { 739 | title: 'Weighted ILI (%)', 740 | description: `Percentage of outpatient doctor visits for 741 | influenza-like illness, weighted by state population. 742 |

Click to know more`, 743 | url: 'http://www.cdc.gov/flu/weekly/overview.htm', 744 | domain: [0, 13] // For explicitly clipping the y values 745 | } 746 | }, 747 | pointType: 'mmwr-week', 748 | confidenceIntervals: ['90%', '50%'], // List of ci labels 749 | onset: true, // Whether to show onset panel or not 750 | timezeroLine: false // Whether to show the timezeroLine, skipping this makes us fall back to the 751 | // behavior based presence of data version time 752 | } 753 | #+END_SRC 754 | 755 | /Options for plotting go here/ 756 | 757 | * TODO DistributionChart 758 | :PROPERTIES: 759 | :CUSTOM_ID: distributionchart 760 | :END: 761 | 762 | #+CAPTION: ~DistributionChart~ displays probability distributions for the 763 | #+CAPTION: prediction targets 764 | [[file:./distchart.png]] 765 | 766 | * Hooks 767 | :PROPERTIES: 768 | :CUSTOM_ID: hooks 769 | :END: 770 | 771 | Charts can call user defined functions when movement events are triggered inside 772 | (e.g. by clicking on movement buttons or clicking on the overlay). To register 773 | your functions to be called on these events, you can use ~addHook~. 774 | 775 | #+BEGIN_SRC js 776 | timeChart.addHook(d3Foresight.events.JUMP_TO_INDEX, index => { 777 | // This is triggered when an event moves the 778 | // visualization to certain `index` in `timePoints` 779 | 780 | // Current index is `timeChart.currentIdx` 781 | console.log('chart moved to ' + index) 782 | }) 783 | #+END_SRC 784 | 785 | ~addHook~ returns a subscription token which can then be used to revoke that 786 | hook using ~removeHook~. 787 | 788 | #+BEGIN_SRC js 789 | let token = timeChart.addHook( 790 | d3Foresight.events.JUMP_TO_INDEX, 791 | index => console.log(`Now at ${index}`) 792 | ) 793 | timeChart.removeHook(token) 794 | #+END_SRC 795 | 796 | 797 | #+BEGIN_SRC js :tangle ./assets/script.js :exports none 798 | }) 799 | #+END_SRC 800 | 801 | #+HTML:

802 | --------------------------------------------------------------------------------