diff --git a/codecov.yml b/codecov.yml index c1eff639a8..8e0b16869e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,18 +2,26 @@ coverage: status: patch: default: - target: 40% + target: 0% threshold: 0% + core-packages: + target: 0% + paths: + - packages + plugins: + target: 0% + paths: + - plugins project: default: - target: 90% + target: 0% # send notification if coverage change exceed this threshold - threshold: 5% + threshold: 0% core-packages: target: 100% paths: - packages plugins: - target: 40% + target: 0% paths: - plugins diff --git a/package.json b/package.json index fc7c41884c..a2883c180c 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,14 @@ "globals": { "caches": true }, + "coverageThreshold": { + "global": { + "branches": 25, + "functions": 25, + "lines": 25, + "statements": 25 + } + }, "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", "\\.(css|less)$": "identity-obj-proxy" diff --git a/packages/superset-ui-demo/.storybook/preview.js b/packages/superset-ui-demo/.storybook/preview.js index 3cee8e76f7..f46a497c16 100644 --- a/packages/superset-ui-demo/.storybook/preview.js +++ b/packages/superset-ui-demo/.storybook/preview.js @@ -1,5 +1,11 @@ import { addParameters, addDecorator } from '@storybook/react'; import { jsxDecorator } from 'storybook-addon-jsx'; +import categoricalD3 from '@superset-ui/color/esm/colorSchemes/categorical/d3'; +import sequentialCommon from '@superset-ui/color/esm/colorSchemes/sequential/common'; +import sequentialD3 from '@superset-ui/color/esm/colorSchemes/sequential/d3'; +import { configure } from '@superset-ui/translation'; +import { getCategoricalSchemeRegistry, getSequentialSchemeRegistry } from '@superset-ui/color'; +import { getTimeFormatterRegistry, smartDateFormatter } from '@superset-ui/time-format'; import 'bootstrap/dist/css/bootstrap.min.css'; import './storybook.css'; @@ -26,3 +32,28 @@ addParameters({ }, }, }); + +// Superset setup + +configure(); + +// Register color schemes +const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); +[categoricalD3].forEach(group => { + group.forEach(scheme => { + categoricalSchemeRegistry.registerValue(scheme.id, scheme); + }); +}); +categoricalSchemeRegistry.setDefaultKey('d3Category10'); + +const sequentialSchemeRegistry = getSequentialSchemeRegistry(); +[sequentialCommon, sequentialD3].forEach(group => { + group.forEach(scheme => { + sequentialSchemeRegistry.registerValue(scheme.id, scheme); + }); +}); + +getTimeFormatterRegistry() + .registerValue('smart_date', smartDateFormatter) + .setDefaultKey('smart_date'); + diff --git a/packages/superset-ui-demo/storybook/shared/dummyDatasource.ts b/packages/superset-ui-demo/storybook/shared/dummyDatasource.ts new file mode 100644 index 0000000000..24efcb1f53 --- /dev/null +++ b/packages/superset-ui-demo/storybook/shared/dummyDatasource.ts @@ -0,0 +1 @@ +export default { verboseMap: {} }; diff --git a/packages/superset-ui-demo/storybook/stories/plugins/big-number/BigNumber/BigNunberStories.tsx b/packages/superset-ui-demo/storybook/stories/plugins/big-number/BigNumber/BigNumberStories.tsx similarity index 98% rename from packages/superset-ui-demo/storybook/stories/plugins/big-number/BigNumber/BigNunberStories.tsx rename to packages/superset-ui-demo/storybook/stories/plugins/big-number/BigNumber/BigNumberStories.tsx index 534a159e76..d0efddf0a0 100644 --- a/packages/superset-ui-demo/storybook/stories/plugins/big-number/BigNumber/BigNunberStories.tsx +++ b/packages/superset-ui-demo/storybook/stories/plugins/big-number/BigNumber/BigNumberStories.tsx @@ -55,7 +55,7 @@ function withNulls(origData: object[], nullPosition: number = 3) { } export default { - title: 'Legacy Preset|big-number', + title: 'Legacy Chart Presets|big-number', }; export const basicWithTrendline = () => ( diff --git a/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-calendar/Stories.jsx b/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-calendar/Stories.jsx new file mode 100644 index 0000000000..196598c4e7 --- /dev/null +++ b/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-calendar/Stories.jsx @@ -0,0 +1,35 @@ +/* eslint-disable sort-keys, no-magic-numbers */ +import React from 'react'; +import { SuperChart } from '@superset-ui/chart'; +import CalendarChartPlugin from '../../../../../../plugins/legacy-plugin-chart-calendar'; +import data from './data'; +// eslint-disable-next-line import/extensions +import dummyDatasource from '../../../shared/dummyDatasource'; + +new CalendarChartPlugin().configure({ key: 'calendar' }).register(); + +export default { + title: 'Legacy Chart Plugins|CalendarChartPlugin', +}; + +export const basic = () => ( + +); diff --git a/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-calendar/data.js b/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-calendar/data.js new file mode 100644 index 0000000000..b4a02b996e --- /dev/null +++ b/packages/superset-ui-demo/storybook/stories/plugins/legacy-plugin-chart-calendar/data.js @@ -0,0 +1,100 @@ +/* eslint-disable sort-keys */ +export default { + data: { + count: { + '1518652800.0': 3, + '1518048000.0': 2, + '1518220800.0': 2, + '1523145600.0': 2, + '1529798400.0': 2, + '1534204800.0': 2, + '1541289600.0': 2, + '1542672000.0': 2, + '1543881600.0': 2, + '1517616000.0': 1, + '1517875200.0': 1, + '1517961600.0': 1, + '1518307200.0': 1, + '1518393600.0': 1, + '1519257600.0': 1, + '1519516800.0': 1, + '1519776000.0': 1, + '1520208000.0': 1, + '1520294400.0': 1, + '1520985600.0': 1, + '1521072000.0': 1, + '1521244800.0': 1, + '1521331200.0': 1, + '1521676800.0': 1, + '1522108800.0': 1, + '1522627200.0': 1, + '1522800000.0': 1, + '1522972800.0': 1, + '1523491200.0': 1, + '1524096000.0': 1, + '1524268800.0': 1, + '1524614400.0': 1, + '1524960000.0': 1, + '1525305600.0': 1, + '1525564800.0': 1, + '1525737600.0': 1, + '1525824000.0': 1, + '1525910400.0': 1, + '1526083200.0': 1, + '1526256000.0': 1, + '1526688000.0': 1, + '1527033600.0': 1, + '1527292800.0': 1, + '1527465600.0': 1, + '1527638400.0': 1, + '1528070400.0': 1, + '1528329600.0': 1, + '1529539200.0': 1, + '1529625600.0': 1, + '1529712000.0': 1, + '1529971200.0': 1, + '1530144000.0': 1, + '1530576000.0': 1, + '1531267200.0': 1, + '1531353600.0': 1, + '1531440000.0': 1, + '1532736000.0': 1, + '1533081600.0': 1, + '1533168000.0': 1, + '1533945600.0': 1, + '1534377600.0': 1, + '1534809600.0': 1, + '1535155200.0': 1, + '1535328000.0': 1, + '1535932800.0': 1, + '1536710400.0': 1, + '1537056000.0': 1, + '1537142400.0': 1, + '1537488000.0': 1, + '1537660800.0': 1, + '1538611200.0': 1, + '1538697600.0': 1, + '1539475200.0': 1, + '1540771200.0': 1, + '1541116800.0': 1, + '1541376000.0': 1, + '1541635200.0': 1, + '1542153600.0': 1, + '1542931200.0': 1, + '1543190400.0': 1, + '1545177600.0': 1, + '1545436800.0': 1, + '1545782400.0': 1, + '1545868800.0': 1, + '1546300800.0': 1, + '1546732800.0': 1, + '1547769600.0': 1, + '1547942400.0': 1, + '1548633600.0': 1, + }, + }, + start: 1517270400000.0, + domain: 'month', + range: 13, + subdomain: 'day', +}; diff --git a/packages/superset-ui-demo/storybook/stories/plugins/table/TableStories.tsx b/packages/superset-ui-demo/storybook/stories/plugins/table/TableStories.tsx index c56baff203..e35277db19 100644 --- a/packages/superset-ui-demo/storybook/stories/plugins/table/TableStories.tsx +++ b/packages/superset-ui-demo/storybook/stories/plugins/table/TableStories.tsx @@ -65,7 +65,7 @@ function loadData(props: SuperChartProps, pageSize = 50, targetSize = 2042) { } export default { - title: 'Legacy Plugin|table', + title: 'Legacy Chart Plugins|table', }; export const basic = () => ( diff --git a/plugins/legacy-plugin-chart-calendar/README.md b/plugins/legacy-plugin-chart-calendar/README.md new file mode 100644 index 0000000000..76f165730e --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/README.md @@ -0,0 +1,32 @@ +## @superset-ui/legacy-plugin-chart-calendar + +[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-calendar.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-calendar.svg?style=flat-square) +[![David (path)](https://img.shields.io/david/apache-superset/superset-ui-plugins.svg?path=packages%2Fsuperset-ui-legacy-plugin-chart-calendar&style=flat-square)](https://david-dm.org/apache-superset/superset-ui-plugins?path=packages/superset-ui-legacy-plugin-chart-calendar) + +This plugin provides Calendar Heatmap for Superset. + +### Usage + +Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app. + +```js +import CalendarChartPlugin from '@superset-ui/legacy-plugin-chart-calendar'; + +new CalendarChartPlugin() + .configure({ key: 'calendar' }) + .register(); +``` + +Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-plugins/?selectedKind=plugin-chart-calendar) for more details. + +```js + +``` \ No newline at end of file diff --git a/plugins/legacy-plugin-chart-calendar/package.json b/plugins/legacy-plugin-chart-calendar/package.json new file mode 100644 index 0000000000..e8e96e6da4 --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/package.json @@ -0,0 +1,43 @@ +{ + "name": "@superset-ui/legacy-plugin-chart-calendar", + "version": "0.11.15", + "description": "Superset Legacy Chart - Calendar Heatmap", + "sideEffects": [ + "*.css" + ], + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui-plugins.git" + }, + "keywords": [ + "superset" + ], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache-superset/superset-ui-plugins/issues" + }, + "homepage": "https://github.com/apache-superset/superset-ui-plugins#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "d3-array": "^2.0.3", + "d3-selection": "^1.4.0", + "d3-tip": "^0.9.1", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "@superset-ui/chart": "^0.12.0", + "@superset-ui/color": "^0.12.0", + "@superset-ui/number-format": "^0.12.0", + "@superset-ui/time-format": "^0.12.0", + "@superset-ui/translation": "^0.12.0" + } +} diff --git a/plugins/legacy-plugin-chart-calendar/src/Calendar.css b/plugins/legacy-plugin-chart-calendar/src/Calendar.css new file mode 100644 index 0000000000..5d0d8005fe --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/Calendar.css @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.superset-legacy-chart-calendar { + padding: 10px; + position: static !important; + overflow: auto !important; +} + +.superset-legacy-chart-calendar .ch-tooltip { + margin-left: 20px; + margin-top: 5px; +} diff --git a/plugins/legacy-plugin-chart-calendar/src/Calendar.js b/plugins/legacy-plugin-chart-calendar/src/Calendar.js new file mode 100644 index 0000000000..a66c1b4262 --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/Calendar.js @@ -0,0 +1,163 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import PropTypes from 'prop-types'; +import { extent as d3Extent, range as d3Range } from 'd3-array'; +import { select as d3Select } from 'd3-selection'; +import { getSequentialSchemeRegistry } from '@superset-ui/color'; +import { getNumberFormatter } from '@superset-ui/number-format'; +import { getTimeFormatter } from '@superset-ui/time-format'; +import CalHeatMap from './vendor/cal-heatmap'; +import './vendor/cal-heatmap.css'; +import './Calendar.css'; + +function convertUTC(dttm) { + return new Date( + dttm.getUTCFullYear(), + dttm.getUTCMonth(), + dttm.getUTCDate(), + dttm.getUTCHours(), + dttm.getUTCMinutes(), + dttm.getUTCSeconds(), + ); +} + +const convertUTCTS = uts => convertUTC(new Date(uts)).getTime(); + +const propTypes = { + data: PropTypes.shape({ + // Object hashed by metric name, + // then hashed by timestamp (in seconds, not milliseconds) as float + // the innermost value is count + // e.g. { count_distinct_something: { 1535034236.0: 3 } } + data: PropTypes.object, + domain: PropTypes.string, + range: PropTypes.number, + // timestamp in milliseconds + start: PropTypes.number, + subdomain: PropTypes.string, + }), + height: PropTypes.number, + // eslint-disable-next-line react/sort-prop-types + cellPadding: PropTypes.number, + // eslint-disable-next-line react/sort-prop-types + cellRadius: PropTypes.number, + // eslint-disable-next-line react/sort-prop-types + cellSize: PropTypes.number, + linearColorScheme: PropTypes.string, + showLegend: PropTypes.bool, + showMetricName: PropTypes.bool, + showValues: PropTypes.bool, + steps: PropTypes.number, + timeFormat: PropTypes.string, + valueFormat: PropTypes.string, + verboseMap: PropTypes.object, +}; + +function Calendar(element, props) { + const { + data, + height, + cellPadding = 3, + cellRadius = 0, + cellSize = 10, + linearColorScheme, + showLegend, + showMetricName, + showValues, + steps, + timeFormat, + valueFormat, + verboseMap, + } = props; + + const valueFormatter = getNumberFormatter(valueFormat); + const timeFormatter = getTimeFormatter(timeFormat); + + const container = d3Select(element) + .classed('superset-legacy-chart-calendar', true) + .style('height', height); + container.selectAll('*').remove(); + const div = container.append('div'); + + const subDomainTextFormat = showValues ? (date, value) => valueFormatter(value) : null; + + // Trick to convert all timestamps to UTC + // TODO: Verify if this conversion is really necessary + // since all timestamps should always be in UTC. + const metricsData = {}; + Object.keys(data.data).forEach(metric => { + metricsData[metric] = {}; + Object.keys(data.data[metric]).forEach(ts => { + metricsData[metric][convertUTCTS(ts * 1000) / 1000] = data.data[metric][ts]; + }); + }); + + Object.keys(metricsData).forEach(metric => { + const calContainer = div.append('div'); + if (showMetricName) { + calContainer.text(`Metric: ${verboseMap[metric] || metric}`); + } + const timestamps = metricsData[metric]; + const extents = d3Extent(Object.keys(timestamps), key => timestamps[key]); + const step = (extents[1] - extents[0]) / (steps - 1); + const colorScale = getSequentialSchemeRegistry() + .get(linearColorScheme) + .createLinearScale(extents); + + const legend = d3Range(steps).map(i => extents[0] + step * i); + const legendColors = legend.map(x => colorScale(x)); + + const cal = new CalHeatMap(); + + cal.init({ + start: convertUTCTS(data.start), + data: timestamps, + itemSelector: calContainer.node(), + legendVerticalPosition: 'top', + cellSize, + cellPadding, + cellRadius, + legendCellSize: cellSize, + legendCellPadding: 2, + legendCellRadius: cellRadius, + tooltip: true, + domain: data.domain, + subDomain: data.subdomain, + range: data.range, + browsing: true, + legend, + legendColors: { + colorScale, + min: legendColors[0], + max: legendColors[legendColors.length - 1], + empty: 'white', + }, + displayLegend: showLegend, + itemName: '', + valueFormatter, + timeFormatter, + subDomainTextFormat, + }); + }); +} + +Calendar.displayName = 'Calendar'; +Calendar.propTypes = propTypes; + +export default Calendar; diff --git a/plugins/legacy-plugin-chart-calendar/src/ReactCalendar.js b/plugins/legacy-plugin-chart-calendar/src/ReactCalendar.js new file mode 100644 index 0000000000..b198c8b902 --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/ReactCalendar.js @@ -0,0 +1,22 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { reactify } from '@superset-ui/chart'; +import Component from './Calendar'; + +export default reactify(Component); diff --git a/plugins/legacy-plugin-chart-calendar/src/images/thumbnail.png b/plugins/legacy-plugin-chart-calendar/src/images/thumbnail.png new file mode 100644 index 0000000000..c83db08ee8 Binary files /dev/null and b/plugins/legacy-plugin-chart-calendar/src/images/thumbnail.png differ diff --git a/plugins/legacy-plugin-chart-calendar/src/images/thumbnailLarge.png b/plugins/legacy-plugin-chart-calendar/src/images/thumbnailLarge.png new file mode 100644 index 0000000000..bf79a9e237 Binary files /dev/null and b/plugins/legacy-plugin-chart-calendar/src/images/thumbnailLarge.png differ diff --git a/plugins/legacy-plugin-chart-calendar/src/index.js b/plugins/legacy-plugin-chart-calendar/src/index.js new file mode 100644 index 0000000000..9a9b10d5d4 --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/index.js @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@superset-ui/translation'; +import { ChartMetadata, ChartPlugin } from '@superset-ui/chart'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; + +const metadata = new ChartMetadata({ + credits: ['https://github.com/wa0x6e/cal-heatmap'], + description: '', + name: t('Calendar Heatmap'), + thumbnail, + useLegacyApi: true, +}); + +export default class ChordChartPlugin extends ChartPlugin { + constructor() { + super({ + loadChart: () => import('./ReactCalendar'), + metadata, + transformProps, + }); + } +} diff --git a/plugins/legacy-plugin-chart-calendar/src/transformProps.js b/plugins/legacy-plugin-chart-calendar/src/transformProps.js new file mode 100644 index 0000000000..1bce1603be --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/transformProps.js @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export default function transformProps(chartProps) { + const { height, formData, queryData, datasource } = chartProps; + const { + cellPadding, + cellRadius, + cellSize, + linearColorScheme, + showLegend, + showMetricName, + showValues, + steps, + xAxisTimeFormat, + yAxisFormat, + } = formData; + + const { verboseMap } = datasource; + + return { + height, + data: queryData.data, + cellPadding, + cellRadius, + cellSize, + linearColorScheme, + showLegend, + showMetricName, + showValues, + steps, + timeFormat: xAxisTimeFormat, + valueFormat: yAxisFormat, + verboseMap, + }; +} diff --git a/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.css b/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.css new file mode 100644 index 0000000000..d55251ec27 --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.css @@ -0,0 +1,130 @@ +/* [LICENSE TBD] */ +/* Cal-HeatMap CSS */ + +.cal-heatmap-container { + display: block; +} + +.cal-heatmap-container .graph-label { + fill: #999; + font-size: 10px; +} + +.cal-heatmap-container .graph, +.cal-heatmap-container .graph-legend rect { + shape-rendering: crispedges; +} + +.cal-heatmap-container .graph-rect { + fill: #ededed; +} + +.cal-heatmap-container .graph-subdomain-group rect:hover { + stroke: #000; + stroke-width: 1px; +} + +.cal-heatmap-container .subdomain-text { + font-size: 8px; + fill: #999; + pointer-events: none; +} + +.cal-heatmap-container .hover_cursor:hover { + cursor: pointer; +} + +.cal-heatmap-container .qi { + background-color: #999; + fill: #999; +} + +/* +Remove comment to apply this style to date with value equal to 0 +.q0 +{ + background-color: #fff; + fill: #fff; + stroke: #ededed +} +*/ + +.cal-heatmap-container .q1 { + background-color: #dae289; + fill: #dae289; +} + +.cal-heatmap-container .q2 { + background-color: #cedb9c; + fill: #9cc069; +} + +.cal-heatmap-container .q3 { + background-color: #b5cf6b; + fill: #669d45; +} + +.cal-heatmap-container .q4 { + background-color: #637939; + fill: #637939; +} + +.cal-heatmap-container .q5 { + background-color: #3b6427; + fill: #3b6427; +} + +.cal-heatmap-container rect.highlight { + stroke: #444; + stroke-width: 1; +} + +.cal-heatmap-container text.highlight { + fill: #444; +} + +.cal-heatmap-container rect.highlight-now { + stroke: red; +} + +.cal-heatmap-container text.highlight-now { + fill: red; + font-weight: 800; +} + +.cal-heatmap-container .domain-background { + fill: none; + shape-rendering: crispedges; +} + +.ch-tooltip { + padding: 10px; + background: #222; + color: #bbb; + font-size: 12px; + line-height: 1.4; + width: 140px; + position: absolute; + z-index: 99999; + text-align: center; + border-radius: 2px; + box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.2); + display: none; + box-sizing: border-box; +} + +.ch-tooltip::after { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + content: ''; + padding: 0; + display: block; + bottom: -6px; + left: 50%; + margin-left: -6px; + border-width: 6px 6px 0; + border-top-color: #222; +} diff --git a/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.js b/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.js new file mode 100644 index 0000000000..1e81eb218e --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/vendor/cal-heatmap.js @@ -0,0 +1,3795 @@ +// [LICENSE TBD] +/* Copied and altered from http://cal-heatmap.com/ , alterations around: + * - tuning tooltips + * - supporting multi-colors scales + * - legend format + * - UTC handling + */ + +/* eslint-disable */ + +import d3tip from 'd3-tip'; +import './d3tip.css'; + +var d3 = typeof require === 'function' ? require('d3') : window.d3; + +var d3 = typeof require === 'function' ? require('d3') : window.d3; + +var CalHeatMap = function () { + 'use strict'; + + var self = this; + self.tip = d3tip() + .attr('class', 'd3-tip') + .direction('n') + .offset([-5, 0]) + .html( + d => ` + ${self.options.timeFormatter(d.t)}: ${self.options.valueFormatter(d.v)} + `, + ); + self.legendTip = d3tip() + .attr('class', 'd3-tip') + .direction('n') + .offset([-5, 0]) + .html(d => self.options.valueFormatter(d)); + + this.allowedDataType = ['json', 'csv', 'tsv', 'txt']; + + // Default settings + this.options = { + // selector string of the container to append the graph to + // Accept any string value accepted by document.querySelector or CSS3 + // or an Element object + itemSelector: '#cal-heatmap', + + // Whether to paint the calendar on init() + // Used by testsuite to reduce testing time + paintOnLoad: true, + + // ================================================ + // DOMAIN + // ================================================ + + // Number of domain to display on the graph + range: 12, + + // Size of each cell, in pixel + cellSize: 10, + + // Padding between each cell, in pixel + cellPadding: 2, + + // For rounded subdomain rectangles, in pixels + cellRadius: 0, + + domainGutter: 2, + + domainMargin: [0, 0, 0, 0], + + valueFormatter: d => d, + + timeFormatter: d => d, + + domain: 'hour', + + subDomain: 'min', + + // Number of columns to split the subDomains to + // If not null, will takes precedence over rowLimit + colLimit: null, + + // Number of rows to split the subDomains to + // Will be ignored if colLimit is not null + rowLimit: null, + + // First day of the week is Monday + // 0 to start the week on Sunday + weekStartOnMonday: true, + + // Start date of the graph + // @default now + start: new Date(), + + minDate: null, + + maxDate: null, + + // ================================================ + // DATA + // ================================================ + + // Data source + // URL, where to fetch the original datas + data: '', + + // Data type + // Default: json + dataType: this.allowedDataType[0], + + // Payload sent when using POST http method + // Leave to null (default) for GET request + // Expect a string, formatted like "a=b;c=d" + dataPostPayload: null, + + // Additional headers sent when requesting data + // Expect an object formatted like: + // { 'X-CSRF-TOKEN': 'token' } + dataRequestHeaders: null, + + // Whether to consider missing date:value from the datasource + // as equal to 0, or just leave them as missing + considerMissingDataAsZero: false, + + // Load remote data on calendar creation + // When false, the calendar will be left empty + loadOnInit: true, + + // Calendar orientation + // false: display domains side by side + // true : display domains one under the other + verticalOrientation: false, + + // Domain dynamic width/height + // The width on a domain depends on the number of + domainDynamicDimension: true, + + // Domain Label properties + label: { + // valid: top, right, bottom, left + position: 'bottom', + + // Valid: left, center, right + // Also valid are the direct svg values: start, middle, end + align: 'center', + + // By default, there is no margin/padding around the label + offset: { + x: 0, + y: 0, + }, + + rotate: null, + + // Used only on vertical orientation + width: 100, + + // Used only on horizontal orientation + height: null, + }, + + // ================================================ + // LEGEND + // ================================================ + + // Threshold for the legend + legend: [10, 20, 30, 40], + + // Whether to display the legend + displayLegend: true, + + legendCellSize: 10, + + legendCellPadding: 2, + + legendMargin: [0, 0, 0, 0], + + // Legend vertical position + // top: place legend above calendar + // bottom: place legend below the calendar + legendVerticalPosition: 'bottom', + + // Legend horizontal position + // accepted values: left, center, right + legendHorizontalPosition: 'left', + + // Legend rotation + // accepted values: horizontal, vertical + legendOrientation: 'horizontal', + + // Objects holding all the heatmap different colors + // null to disable, and use the default css styles + // + // Examples: + // legendColors: { + // min: "green", + // max: "red", + // empty: "#ffffff", + // base: "grey", + // overflow: "red", + // colorScaler: null, + // } + legendColors: null, + + // ================================================ + // HIGHLIGHT + // ================================================ + + // List of dates to highlight + // Valid values: + // - []: don't highlight anything + // - "now": highlight the current date + // - an array of Date objects: highlight the specified dates + highlight: [], + + // ================================================ + // TEXT FORMATTING / i18n + // ================================================ + + // Name of the items to represent in the calendar + itemName: ['item', 'items'], + + // Formatting of the domain label + // @default: null, will use the formatting according to domain type + // Accept a string used as specifier by d3.time.format() + // or a function + // + // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting + // for accepted date formatting used by d3.time.format() + domainLabelFormat: null, + + // Formatting of the title displayed when hovering a subDomain cell + subDomainTitleFormat: { + empty: '{date}', + filled: '{count} {name} {connector} {date}', + }, + + // Formatting of the {date} used in subDomainTitleFormat + // @default: null, will use the formatting according to subDomain type + // Accept a string used as specifier by d3.time.format() + // or a function + // + // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting + // for accepted date formatting used by d3.time.format() + subDomainDateFormat: null, + + // Formatting of the text inside each subDomain cell + // @default: null, no text + // Accept a string used as specifier by d3.time.format() + // or a function + // + // Refer to https://github.com/mbostock/d3/wiki/Time-Formatting + // for accepted date formatting used by d3.time.format() + subDomainTextFormat: null, + + // Formatting of the title displayed when hovering a legend cell + legendTitleFormat: { + lower: 'less than {min} {name}', + inner: 'between {down} and {up} {name}', + upper: 'more than {max} {name}', + }, + + // Animation duration, in ms + animationDuration: 500, + + nextSelector: false, + + previousSelector: false, + + itemNamespace: 'cal-heatmap', + + tooltip: false, + + // ================================================ + // EVENTS CALLBACK + // ================================================ + + // Callback when clicking on a time block + onClick: null, + + // Callback after painting the empty calendar + // Can be used to trigger an API call, once the calendar is ready to be filled + afterLoad: null, + + // Callback after loading the next domain in the calendar + afterLoadNextDomain: null, + + // Callback after loading the previous domain in the calendar + afterLoadPreviousDomain: null, + + // Callback after finishing all actions on the calendar + onComplete: null, + + // Callback after fetching the datas, but before applying them to the calendar + // Used mainly to convert the datas if they're not formatted like expected + // Takes the fetched "data" object as argument, must return a json object + // formatted like {timestamp:count, timestamp2:count2}, + afterLoadData: function (data) { + return data; + }, + + // Callback triggered after calling and completing update(). + afterUpdate: null, + + // Callback triggered after calling next(). + // The `status` argument is equal to true if there is no + // more next domain to load + // + // This callback is also executed once, after calling previous(), + // only when the max domain is reached + onMaxDomainReached: null, + + // Callback triggered after calling previous(). + // The `status` argument is equal to true if there is no + // more previous domain to load + // + // This callback is also executed once, after calling next(), + // only when the min domain is reached + onMinDomainReached: null, + }; + + this._domainType = { + min: { + name: 'minute', + level: 10, + maxItemNumber: 60, + defaultRowNumber: 10, + defaultColumnNumber: 6, + row: function (d) { + return self.getSubDomainRowNumber(d); + }, + column: function (d) { + return self.getSubDomainColumnNumber(d); + }, + position: { + x: function (d) { + return Math.floor(d.getMinutes() / self._domainType.min.row(d)); + }, + y: function (d) { + return d.getMinutes() % self._domainType.min.row(d); + }, + }, + format: { + date: '%H:%M, %A %B %-e, %Y', + legend: '', + connector: 'at', + }, + extractUnit: function (d) { + return new Date( + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getHours(), + d.getMinutes(), + ).getTime(); + }, + }, + hour: { + name: 'hour', + level: 20, + maxItemNumber: function (d) { + switch (self.options.domain) { + case 'day': + return 24; + case 'week': + return 24 * 7; + case 'month': + return 24 * (self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31); + } + }, + defaultRowNumber: 6, + defaultColumnNumber: function (d) { + switch (self.options.domain) { + case 'day': + return 4; + case 'week': + return 28; + case 'month': + return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31; + } + }, + row: function (d) { + return self.getSubDomainRowNumber(d); + }, + column: function (d) { + return self.getSubDomainColumnNumber(d); + }, + position: { + x: function (d) { + if (self.options.domain === 'month') { + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor( + (d.getHours() + (d.getDate() - 1) * 24) / self._domainType.hour.row(d), + ); + } + return Math.floor(d.getHours() / self._domainType.hour.row(d)) + (d.getDate() - 1) * 4; + } else if (self.options.domain === 'week') { + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor( + (d.getHours() + self.getWeekDay(d) * 24) / self._domainType.hour.row(d), + ); + } + return Math.floor(d.getHours() / self._domainType.hour.row(d)) + self.getWeekDay(d) * 4; + } + return Math.floor(d.getHours() / self._domainType.hour.row(d)); + }, + y: function (d) { + var p = d.getHours(); + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + switch (self.options.domain) { + case 'month': + p += (d.getDate() - 1) * 24; + break; + case 'week': + p += self.getWeekDay(d) * 24; + break; + } + } + return Math.floor(p % self._domainType.hour.row(d)); + }, + }, + format: { + date: '%Hh, %A %B %-e, %Y', + legend: '%H:00', + connector: 'at', + }, + extractUnit: function (d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()).getTime(); + }, + }, + day: { + name: 'day', + level: 30, + maxItemNumber: function (d) { + switch (self.options.domain) { + case 'week': + return 7; + case 'month': + return self.options.domainDynamicDimension ? self.getDayCountInMonth(d) : 31; + case 'year': + return self.options.domainDynamicDimension ? self.getDayCountInYear(d) : 366; + } + }, + defaultColumnNumber: function (d) { + d = new Date(d); + switch (self.options.domain) { + case 'week': + return 1; + case 'month': + return self.options.domainDynamicDimension && !self.options.verticalOrientation + ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth() + 1, 0)) - + self.getWeekNumber(d) + + 1 + : 6; + case 'year': + return self.options.domainDynamicDimension + ? self.getWeekNumber(new Date(d.getFullYear(), 11, 31)) - + self.getWeekNumber(new Date(d.getFullYear(), 0)) + + 1 + : 54; + } + }, + defaultRowNumber: 7, + row: function (d) { + return self.getSubDomainRowNumber(d); + }, + column: function (d) { + return self.getSubDomainColumnNumber(d); + }, + position: { + x: function (d) { + switch (self.options.domain) { + case 'week': + return Math.floor(self.getWeekDay(d) / self._domainType.day.row(d)); + case 'month': + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor((d.getDate() - 1) / self._domainType.day.row(d)); + } + return ( + self.getWeekNumber(d) - self.getWeekNumber(new Date(d.getFullYear(), d.getMonth())) + ); + case 'year': + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + return Math.floor((self.getDayOfYear(d) - 1) / self._domainType.day.row(d)); + } + return self.getWeekNumber(d); + } + }, + y: function (d) { + var p = self.getWeekDay(d); + if (self.options.colLimit > 0 || self.options.rowLimit > 0) { + switch (self.options.domain) { + case 'year': + p = self.getDayOfYear(d) - 1; + break; + case 'week': + p = self.getWeekDay(d); + break; + case 'month': + p = d.getDate() - 1; + break; + } + } + return Math.floor(p % self._domainType.day.row(d)); + }, + }, + format: { + date: '%A %B %-e, %Y', + legend: '%e %b', + connector: 'on', + }, + extractUnit: function (d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + }, + }, + week: { + name: 'week', + level: 40, + maxItemNumber: 54, + defaultColumnNumber: function (d) { + d = new Date(d); + switch (self.options.domain) { + case 'year': + return self._domainType.week.maxItemNumber; + case 'month': + return self.options.domainDynamicDimension + ? self.getWeekNumber(new Date(d.getFullYear(), d.getMonth() + 1, 0)) - + self.getWeekNumber(d) + : 5; + } + }, + defaultRowNumber: 1, + row: function (d) { + return self.getSubDomainRowNumber(d); + }, + column: function (d) { + return self.getSubDomainColumnNumber(d); + }, + position: { + x: function (d) { + switch (self.options.domain) { + case 'year': + return Math.floor(self.getWeekNumber(d) / self._domainType.week.row(d)); + case 'month': + return Math.floor(self.getMonthWeekNumber(d) / self._domainType.week.row(d)); + } + }, + y: function (d) { + return self.getWeekNumber(d) % self._domainType.week.row(d); + }, + }, + format: { + date: '%B Week #%W', + legend: '%B Week #%W', + connector: 'in', + }, + extractUnit: function (d) { + var dt = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + // According to ISO-8601, week number computation are based on week starting on Monday + var weekDay = dt.getDay() - (self.options.weekStartOnMonday ? 1 : 0); + if (weekDay < 0) { + weekDay = 6; + } + dt.setDate(dt.getDate() - weekDay); + return dt.getTime(); + }, + }, + month: { + name: 'month', + level: 50, + maxItemNumber: 12, + defaultColumnNumber: 12, + defaultRowNumber: 1, + row: function () { + return self.getSubDomainRowNumber(); + }, + column: function () { + return self.getSubDomainColumnNumber(); + }, + position: { + x: function (d) { + return Math.floor(d.getMonth() / self._domainType.month.row(d)); + }, + y: function (d) { + return d.getMonth() % self._domainType.month.row(d); + }, + }, + format: { + date: '%B %Y', + legend: '%B', + connector: 'in', + }, + extractUnit: function (d) { + return new Date(d.getFullYear(), d.getMonth()).getTime(); + }, + }, + year: { + name: 'year', + level: 60, + row: function () { + return self.options.rowLimit || 1; + }, + column: function () { + return self.options.colLimit || 1; + }, + position: { + x: function () { + return 1; + }, + y: function () { + return 1; + }, + }, + format: { + date: '%Y', + legend: '%Y', + connector: 'in', + }, + extractUnit: function (d) { + return new Date(d.getFullYear()).getTime(); + }, + }, + }; + + for (var type in this._domainType) { + if (this._domainType.hasOwnProperty(type)) { + var d = this._domainType[type]; + this._domainType['x_' + type] = { + name: 'x_' + type, + level: d.type, + maxItemNumber: d.maxItemNumber, + defaultRowNumber: d.defaultRowNumber, + defaultColumnNumber: d.defaultColumnNumber, + row: d.column, + column: d.row, + position: { + x: d.position.y, + y: d.position.x, + }, + format: d.format, + extractUnit: d.extractUnit, + }; + } + } + + // Record the address of the last inserted domain when browsing + this.lastInsertedSvg = null; + + this._completed = false; + + // Record all the valid domains + // Each domain value is a timestamp in milliseconds + this._domains = d3.map(); + + this.graphDim = { + width: 0, + height: 0, + }; + + this.legendDim = { + width: 0, + height: 0, + }; + + this.NAVIGATE_LEFT = 1; + this.NAVIGATE_RIGHT = 2; + + // Various update mode when using the update() API + this.RESET_ALL_ON_UPDATE = 0; + this.RESET_SINGLE_ON_UPDATE = 1; + this.APPEND_ON_UPDATE = 2; + + this.DEFAULT_LEGEND_MARGIN = 10; + + this.root = null; + this.tooltip = null; + + this._maxDomainReached = false; + this._minDomainReached = false; + + this.domainPosition = new DomainPosition(); + this.Legend = null; + this.legendScale = null; + + // List of domains that are skipped because of DST + // All times belonging to these domains should be re-assigned to the previous domain + this.DSTDomain = []; + + /** + * Display the graph for the first time + * @return bool True if the calendar is created + */ + this._init = function () { + self + .getDomain(self.options.start) + .map(function (d) { + return d.getTime(); + }) + .map(function (d) { + self._domains.set( + d, + self.getSubDomain(d).map(function (d) { + return { t: self._domainType[self.options.subDomain].extractUnit(d), v: null }; + }), + ); + }); + + self.root = d3 + .select(self.options.itemSelector) + .append('svg') + .attr('class', 'cal-heatmap-container'); + + self.root.attr('x', 0).attr('y', 0).append('svg').attr('class', 'graph'); + + self.Legend = new Legend(self); + + if (self.options.paintOnLoad) { + _initCalendar(); + } + self.root.call(self.tip); + self.root.call(self.legendTip); + + return true; + }; + + function _initCalendar() { + self.verticalDomainLabel = + self.options.label.position === 'top' || self.options.label.position === 'bottom'; + + self.domainVerticalLabelHeight = + self.options.label.height === null + ? Math.max(25, self.options.cellSize * 2) + : self.options.label.height; + self.domainHorizontalLabelWidth = 0; + + if (self.options.domainLabelFormat === '' && self.options.label.height === null) { + self.domainVerticalLabelHeight = 0; + } + + if (!self.verticalDomainLabel) { + self.domainVerticalLabelHeight = 0; + self.domainHorizontalLabelWidth = self.options.label.width; + } + + self.paint(); + + // =========================================================================// + // ATTACHING DOMAIN NAVIGATION EVENT // + // =========================================================================// + if (self.options.nextSelector !== false) { + d3.select(self.options.nextSelector).on('click.' + self.options.itemNamespace, function () { + d3.event.preventDefault(); + return self.loadNextDomain(1); + }); + } + + if (self.options.previousSelector !== false) { + d3.select(self.options.previousSelector).on( + 'click.' + self.options.itemNamespace, + function () { + d3.event.preventDefault(); + return self.loadPreviousDomain(1); + }, + ); + } + + self.Legend.redraw(self.graphDim.width - self.options.domainGutter - self.options.cellPadding); + self.afterLoad(); + + var domains = self.getDomainKeys(); + + // Fill the graph with some datas + if (self.options.loadOnInit) { + self.getDatas( + self.options.data, + new Date(domains[0]), + self.getSubDomain(domains[domains.length - 1]).pop(), + function () { + self.fill(); + self.onComplete(); + }, + ); + } else { + self.onComplete(); + } + + self.checkIfMinDomainIsReached(domains[0]); + self.checkIfMaxDomainIsReached(self.getNextDomain().getTime()); + } + + // Return the width of the domain block, without the domain gutter + // @param int d Domain start timestamp + function w(d, outer) { + var width = + self.options.cellSize * self._domainType[self.options.subDomain].column(d) + + self.options.cellPadding * self._domainType[self.options.subDomain].column(d); + if (arguments.length === 2 && outer === true) { + return (width += + self.domainHorizontalLabelWidth + + self.options.domainGutter + + self.options.domainMargin[1] + + self.options.domainMargin[3]); + } + return width; + } + + // Return the height of the domain block, without the domain gutter + function h(d, outer) { + var height = + self.options.cellSize * self._domainType[self.options.subDomain].row(d) + + self.options.cellPadding * self._domainType[self.options.subDomain].row(d); + if (arguments.length === 2 && outer === true) { + height += + self.options.domainGutter + + self.domainVerticalLabelHeight + + self.options.domainMargin[0] + + self.options.domainMargin[2]; + } + return height; + } + + /** + * + * + * @param int navigationDir + */ + this.paint = function (navigationDir) { + var options = self.options; + + if (arguments.length === 0) { + navigationDir = false; + } + + // Painting all the domains + var domainSvg = self.root + .select('.graph') + .selectAll('.graph-domain') + .data( + function () { + var data = self.getDomainKeys(); + return navigationDir === self.NAVIGATE_LEFT ? data.reverse() : data; + }, + function (d) { + return d; + }, + ); + var enteringDomainDim = 0; + var exitingDomainDim = 0; + + // =========================================================================// + // PAINTING DOMAIN // + // =========================================================================// + + var svg = domainSvg + .enter() + .append('svg') + .attr('width', function (d) { + return w(d, true); + }) + .attr('height', function (d) { + return h(d, true); + }) + .attr('x', function (d) { + if (options.verticalOrientation) { + self.graphDim.width = Math.max(self.graphDim.width, w(d, true)); + return 0; + } else { + return getDomainPosition(d, self.graphDim, 'width', w(d, true)); + } + }) + .attr('y', function (d) { + if (options.verticalOrientation) { + return getDomainPosition(d, self.graphDim, 'height', h(d, true)); + } else { + self.graphDim.height = Math.max(self.graphDim.height, h(d, true)); + return 0; + } + }) + .attr('class', function (d) { + var classname = 'graph-domain'; + var date = new Date(d); + switch (options.domain) { + case 'hour': + classname += ' h_' + date.getHours(); + /* falls through */ + case 'day': + classname += ' d_' + date.getDate() + ' dy_' + date.getDay(); + /* falls through */ + case 'week': + classname += ' w_' + self.getWeekNumber(date); + /* falls through */ + case 'month': + classname += ' m_' + (date.getMonth() + 1); + /* falls through */ + case 'year': + classname += ' y_' + date.getFullYear(); + } + return classname; + }); + self.lastInsertedSvg = svg; + + function getDomainPosition(domainIndex, graphDim, axis, domainDim) { + var tmp = 0; + switch (navigationDir) { + case false: + tmp = graphDim[axis]; + + graphDim[axis] += domainDim; + self.domainPosition.setPosition(domainIndex, tmp); + return tmp; + + case self.NAVIGATE_RIGHT: + self.domainPosition.setPosition(domainIndex, graphDim[axis]); + + enteringDomainDim = domainDim; + exitingDomainDim = self.domainPosition.getPositionFromIndex(1); + + self.domainPosition.shiftRightBy(exitingDomainDim); + return graphDim[axis]; + + case self.NAVIGATE_LEFT: + tmp = -domainDim; + + enteringDomainDim = -tmp; + exitingDomainDim = graphDim[axis] - self.domainPosition.getLast(); + + self.domainPosition.setPosition(domainIndex, tmp); + self.domainPosition.shiftLeftBy(enteringDomainDim); + return tmp; + } + } + + svg + .append('rect') + .attr('width', function (d) { + return w(d, true) - options.domainGutter - options.cellPadding; + }) + .attr('height', function (d) { + return h(d, true) - options.domainGutter - options.cellPadding; + }) + .attr('class', 'domain-background'); + + // =========================================================================// + // PAINTING SUBDOMAINS // + // =========================================================================// + var subDomainSvgGroup = svg + .append('svg') + .attr('x', function () { + if (options.label.position === 'left') { + return self.domainHorizontalLabelWidth + options.domainMargin[3]; + } else { + return options.domainMargin[3]; + } + }) + .attr('y', function () { + if (options.label.position === 'top') { + return self.domainVerticalLabelHeight + options.domainMargin[0]; + } else { + return options.domainMargin[0]; + } + }) + .attr('class', 'graph-subdomain-group'); + var rect = subDomainSvgGroup + .selectAll('g') + .data(function (d) { + return self._domains.get(d); + }) + .enter() + .append('g'); + rect + .append('rect') + .attr('class', function (d) { + return ( + 'graph-rect' + + self.getHighlightClassName(d.t) + + (options.onClick !== null ? ' hover_cursor' : '') + ); + }) + .attr('width', options.cellSize) + .attr('height', options.cellSize) + .attr('x', function (d) { + return self.positionSubDomainX(d.t); + }) + .attr('y', function (d) { + return self.positionSubDomainY(d.t); + }) + .on('click', function (d) { + if (options.onClick !== null) { + return self.onClick(new Date(d.t), d.v); + } + }) + .call(function (selection) { + if (options.cellRadius > 0) { + selection.attr('rx', options.cellRadius).attr('ry', options.cellRadius); + } + + if ( + self.legendScale !== null && + options.legendColors !== null && + options.legendColors.hasOwnProperty('base') + ) { + selection.attr('fill', options.legendColors.base); + } + + if (options.tooltip) { + selection + .on('mouseover', function (d) { + self.tip.show(d, this); + }) + .on('mouseout', function () { + self.tip.hide(d); + }); + } + }); + + // Appending a title to each subdomain + if (!options.tooltip) { + rect.append('title').text(function (d) { + return self.formatDate(new Date(d.t), options.subDomainDateFormat); + }); + } + + // =========================================================================// + // PAINTING LABEL // + // =========================================================================// + if (options.domainLabelFormat !== '') { + svg + .append('text') + .attr('class', 'graph-label') + .attr('y', function (d) { + var y = options.domainMargin[0]; + switch (options.label.position) { + case 'top': + y += self.domainVerticalLabelHeight / 2; + break; + case 'bottom': + y += h(d) + self.domainVerticalLabelHeight / 2; + } + + return ( + y + + options.label.offset.y * + ((options.label.rotate === 'right' && options.label.position === 'right') || + (options.label.rotate === 'left' && options.label.position === 'left') + ? -1 + : 1) + ); + }) + .attr('x', function (d) { + var x = options.domainMargin[3]; + switch (options.label.position) { + case 'right': + x += w(d); + break; + case 'bottom': + case 'top': + x += w(d) / 2; + } + + if (options.label.align === 'right') { + return ( + x + + self.domainHorizontalLabelWidth - + options.label.offset.x * (options.label.rotate === 'right' ? -1 : 1) + ); + } + return x + options.label.offset.x; + }) + .attr('text-anchor', function () { + switch (options.label.align) { + case 'start': + case 'left': + return 'start'; + case 'end': + case 'right': + return 'end'; + default: + return 'middle'; + } + }) + .attr('dominant-baseline', function () { + return self.verticalDomainLabel ? 'middle' : 'top'; + }) + .text(function (d) { + return self.formatDate(new Date(d), options.domainLabelFormat); + }) + .call(domainRotate); + } + + function domainRotate(selection) { + switch (options.label.rotate) { + case 'right': + selection.attr('transform', function (d) { + var s = 'rotate(90), '; + switch (options.label.position) { + case 'right': + s += 'translate(-' + w(d) + ' , -' + w(d) + ')'; + break; + case 'left': + s += 'translate(0, -' + self.domainHorizontalLabelWidth + ')'; + break; + } + + return s; + }); + break; + case 'left': + selection.attr('transform', function (d) { + var s = 'rotate(270), '; + switch (options.label.position) { + case 'right': + s += 'translate(-' + (w(d) + self.domainHorizontalLabelWidth) + ' , ' + w(d) + ')'; + break; + case 'left': + s += + 'translate(-' + + self.domainHorizontalLabelWidth + + ' , ' + + self.domainHorizontalLabelWidth + + ')'; + break; + } + + return s; + }); + break; + } + } + + // =========================================================================// + // PAINTING DOMAIN SUBDOMAIN CONTENT // + // =========================================================================// + if (options.subDomainTextFormat !== null) { + rect + .append('text') + .attr('class', function (d) { + return 'subdomain-text' + self.getHighlightClassName(d.t); + }) + .attr('x', function (d) { + return self.positionSubDomainX(d.t) + options.cellSize / 2; + }) + .attr('y', function (d) { + return self.positionSubDomainY(d.t) + options.cellSize / 2; + }) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .text(function (d) { + return self.formatDate(new Date(d.t), options.subDomainTextFormat); + }); + } + + // =========================================================================// + // ANIMATION // + // =========================================================================// + + if (navigationDir !== false) { + domainSvg + .transition() + .duration(options.animationDuration) + .attr('x', function (d) { + return options.verticalOrientation ? 0 : self.domainPosition.getPosition(d); + }) + .attr('y', function (d) { + return options.verticalOrientation ? self.domainPosition.getPosition(d) : 0; + }); + } + + var tempWidth = self.graphDim.width; + var tempHeight = self.graphDim.height; + + if (options.verticalOrientation) { + self.graphDim.height += enteringDomainDim - exitingDomainDim; + } else { + self.graphDim.width += enteringDomainDim - exitingDomainDim; + } + + // At the time of exit, domainsWidth and domainsHeight already automatically shifted + domainSvg + .exit() + .transition() + .duration(options.animationDuration) + .attr('x', function (d) { + if (options.verticalOrientation) { + return 0; + } else { + switch (navigationDir) { + case self.NAVIGATE_LEFT: + return Math.min(self.graphDim.width, tempWidth); + case self.NAVIGATE_RIGHT: + return -w(d, true); + } + } + }) + .attr('y', function (d) { + if (options.verticalOrientation) { + switch (navigationDir) { + case self.NAVIGATE_LEFT: + return Math.min(self.graphDim.height, tempHeight); + case self.NAVIGATE_RIGHT: + return -h(d, true); + } + } else { + return 0; + } + }) + .remove(); + + // Resize the root container + self.resize(); + }; +}; + +CalHeatMap.prototype = { + /** + * Validate and merge user settings with default settings + * + * @param {object} settings User settings + * @return {bool} False if settings contains error + */ + /* jshint maxstatements:false */ + init: function (settings) { + 'use strict'; + + var parent = this; + + var options = (parent.options = mergeRecursive(parent.options, settings)); + + // Fatal errors + // Stop script execution on error + validateDomainType(); + validateSelector(options.itemSelector, false, 'itemSelector'); + + if (parent.allowedDataType.indexOf(options.dataType) === -1) { + throw new Error("The data type '" + options.dataType + "' is not valid data type"); + } + + if (d3.select(options.itemSelector)[0][0] === null) { + throw new Error( + "The node '" + options.itemSelector + "' specified in itemSelector does not exists", + ); + } + + try { + validateSelector(options.nextSelector, true, 'nextSelector'); + validateSelector(options.previousSelector, true, 'previousSelector'); + } catch (error) { + console.log(error.message); + return false; + } + + // If other settings contains error, will fallback to default + + if (!settings.hasOwnProperty('subDomain')) { + this.options.subDomain = getOptimalSubDomain(settings.domain); + } + + if (typeof options.itemNamespace !== 'string' || options.itemNamespace === '') { + console.log('itemNamespace can not be empty, falling back to cal-heatmap'); + options.itemNamespace = 'cal-heatmap'; + } + + // Don't touch these settings + var s = [ + 'data', + 'onComplete', + 'onClick', + 'afterLoad', + 'afterLoadData', + 'afterLoadPreviousDomain', + 'afterLoadNextDomain', + 'afterUpdate', + ]; + + for (var k in s) { + if (settings.hasOwnProperty(s[k])) { + options[s[k]] = settings[s[k]]; + } + } + + options.subDomainDateFormat = + typeof options.subDomainDateFormat === 'string' || + typeof options.subDomainDateFormat === 'function' + ? options.subDomainDateFormat + : this._domainType[options.subDomain].format.date; + options.domainLabelFormat = + typeof options.domainLabelFormat === 'string' || + typeof options.domainLabelFormat === 'function' + ? options.domainLabelFormat + : this._domainType[options.domain].format.legend; + options.subDomainTextFormat = + (typeof options.subDomainTextFormat === 'string' && options.subDomainTextFormat !== '') || + typeof options.subDomainTextFormat === 'function' + ? options.subDomainTextFormat + : null; + options.domainMargin = expandMarginSetting(options.domainMargin); + options.legendMargin = expandMarginSetting(options.legendMargin); + options.highlight = parent.expandDateSetting(options.highlight); + options.itemName = expandItemName(options.itemName); + options.colLimit = parseColLimit(options.colLimit); + options.rowLimit = parseRowLimit(options.rowLimit); + if (!settings.hasOwnProperty('legendMargin')) { + autoAddLegendMargin(); + } + autoAlignLabel(); + + /** + * Validate that a queryString is valid + * + * @param {Element|string|bool} selector The queryString to test + * @param {bool} canBeFalse Whether false is an accepted and valid value + * @param {string} name Name of the tested selector + * @throws {Error} If the selector is not valid + * @return {bool} True if the selector is a valid queryString + */ + function validateSelector(selector, canBeFalse, name) { + if ( + ((canBeFalse && selector === false) || + selector instanceof Element || + typeof selector === 'string') && + selector !== '' + ) { + return true; + } + throw new Error('The ' + name + ' is not valid'); + } + + /** + * Return the optimal subDomain for the specified domain + * + * @param {string} domain a domain name + * @return {string} the subDomain name + */ + function getOptimalSubDomain(domain) { + switch (domain) { + case 'year': + return 'month'; + case 'month': + return 'day'; + case 'week': + return 'day'; + case 'day': + return 'hour'; + default: + return 'min'; + } + } + + /** + * Ensure that the domain and subdomain are valid + * + * @throw {Error} when domain or subdomain are not valid + * @return {bool} True if domain and subdomain are valid and compatible + */ + function validateDomainType() { + if ( + !parent._domainType.hasOwnProperty(options.domain) || + options.domain === 'min' || + options.domain.substring(0, 2) === 'x_' + ) { + throw new Error("The domain '" + options.domain + "' is not valid"); + } + + if (!parent._domainType.hasOwnProperty(options.subDomain) || options.subDomain === 'year') { + throw new Error("The subDomain '" + options.subDomain + "' is not valid"); + } + + if (parent._domainType[options.domain].level <= parent._domainType[options.subDomain].level) { + throw new Error( + "'" + options.subDomain + "' is not a valid subDomain to '" + options.domain + "'", + ); + } + + return true; + } + + /** + * Fine-tune the label alignement depending on its position + * + * @return void + */ + function autoAlignLabel() { + // Auto-align label, depending on it's position + if ( + !settings.hasOwnProperty('label') || + (settings.hasOwnProperty('label') && !settings.label.hasOwnProperty('align')) + ) { + switch (options.label.position) { + case 'left': + options.label.align = 'right'; + break; + case 'right': + options.label.align = 'left'; + break; + default: + options.label.align = 'center'; + } + + if (options.label.rotate === 'left') { + options.label.align = 'right'; + } else if (options.label.rotate === 'right') { + options.label.align = 'left'; + } + } + + if ( + !settings.hasOwnProperty('label') || + (settings.hasOwnProperty('label') && !settings.label.hasOwnProperty('offset')) + ) { + if (options.label.position === 'left' || options.label.position === 'right') { + options.label.offset = { + x: 10, + y: 15, + }; + } + } + } + + /** + * If not specified, add some margin around the legend depending on its position + * + * @return void + */ + function autoAddLegendMargin() { + switch (options.legendVerticalPosition) { + case 'top': + options.legendMargin[2] = parent.DEFAULT_LEGEND_MARGIN; + break; + case 'bottom': + options.legendMargin[0] = parent.DEFAULT_LEGEND_MARGIN; + break; + case 'middle': + case 'center': + options.legendMargin[options.legendHorizontalPosition === 'right' ? 3 : 1] = + parent.DEFAULT_LEGEND_MARGIN; + } + } + + /** + * Expand a number of an array of numbers to an usable 4 values array + * + * @param {integer|array} value + * @return {array} array + */ + function expandMarginSetting(value) { + if (typeof value === 'number') { + value = [value]; + } + + if (!Array.isArray(value)) { + console.log('Margin only takes an integer or an array of integers'); + value = [0]; + } + + switch (value.length) { + case 1: + return [value[0], value[0], value[0], value[0]]; + case 2: + return [value[0], value[1], value[0], value[1]]; + case 3: + return [value[0], value[1], value[2], value[1]]; + case 4: + return value; + default: + return value.slice(0, 4); + } + } + + /** + * Convert a string to an array like [singular-form, plural-form] + * + * @param {string|array} value Date to convert + * @return {array} An array like [singular-form, plural-form] + */ + function expandItemName(value) { + if (typeof value === 'string') { + return [value, value + (value !== '' ? 's' : '')]; + } + + if (Array.isArray(value)) { + if (value.length === 1) { + return [value[0], value[0] + 's']; + } else if (value.length > 2) { + return value.slice(0, 2); + } + + return value; + } + + return ['item', 'items']; + } + + function parseColLimit(value) { + return value > 0 ? value : null; + } + + function parseRowLimit(value) { + if (value > 0 && options.colLimit > 0) { + console.log('colLimit and rowLimit are mutually exclusive, rowLimit will be ignored'); + return null; + } + return value > 0 ? value : null; + } + + return this._init(); + }, + + /** + * Convert a keyword or an array of keyword/date to an array of date objects + * + * @param {string|array|Date} value Data to convert + * @return {array} An array of Dates + */ + expandDateSetting: function (value) { + 'use strict'; + + if (!Array.isArray(value)) { + value = [value]; + } + + return value + .map(function (data) { + if (data === 'now') { + return new Date(); + } + if (data instanceof Date) { + return data; + } + return false; + }) + .filter(function (d) { + return d !== false; + }); + }, + + /** + * Fill the calendar by coloring the cells + * + * @param array svg An array of html node to apply the transformation to (optional) + * It's used to limit the painting to only a subset of the calendar + * @return void + */ + fill: function (svg) { + 'use strict'; + + var parent = this; + var options = parent.options; + + if (arguments.length === 0) { + svg = parent.root.selectAll('.graph-domain'); + } + + var rect = svg + .selectAll('svg') + .selectAll('g') + .data(function (d) { + return parent._domains.get(d); + }); + /** + * Colorize the cell via a style attribute if enabled + */ + function addStyle(element) { + if (parent.legendScale === null) { + return false; + } + + element.attr('fill', function (d) { + if ( + d.v === null && + options.hasOwnProperty('considerMissingDataAsZero') && + !options.considerMissingDataAsZero + ) { + if (options.legendColors.hasOwnProperty('base')) { + return options.legendColors.base; + } + } + + if ( + options.legendColors !== null && + options.legendColors.hasOwnProperty('empty') && + (d.v === 0 || + (d.v === null && + options.hasOwnProperty('considerMissingDataAsZero') && + options.considerMissingDataAsZero)) + ) { + return options.legendColors.empty; + } + + if ( + d.v < 0 && + options.legend[0] > 0 && + options.legendColors !== null && + options.legendColors.hasOwnProperty('overflow') + ) { + return options.legendColors.overflow; + } + + return parent.legendScale(Math.min(d.v, options.legend[options.legend.length - 1])); + }); + } + + rect + .transition() + .duration(options.animationDuration) + .select('rect') + .attr('class', function (d) { + var htmlClass = parent.getHighlightClassName(d.t).trim().split(' '); + var pastDate = parent.dateIsLessThan(d.t, new Date()); + var sameDate = parent.dateIsEqual(d.t, new Date()); + + if ( + parent.legendScale === null || + (d.v === null && + options.hasOwnProperty('considerMissingDataAsZero') && + !options.considerMissingDataAsZero && + !options.legendColors.hasOwnProperty('base')) + ) { + htmlClass.push('graph-rect'); + } + + if (sameDate) { + htmlClass.push('now'); + } else if (!pastDate) { + htmlClass.push('future'); + } + + if (d.v !== null) { + htmlClass.push(parent.Legend.getClass(d.v, parent.legendScale === null)); + } else if (options.considerMissingDataAsZero && pastDate) { + htmlClass.push(parent.Legend.getClass(0, parent.legendScale === null)); + } + + if (options.onClick !== null) { + htmlClass.push('hover_cursor'); + } + + return htmlClass.join(' '); + }) + .call(addStyle); + + rect + .transition() + .duration(options.animationDuration) + .select('title') + .text(function (d) { + return parent.getSubDomainTitle(d); + }); + + function formatSubDomainText(element) { + if (typeof options.subDomainTextFormat === 'function') { + element.text(function (d) { + return options.subDomainTextFormat(d.t, d.v); + }); + } + } + + /** + * Change the subDomainText class if necessary + * Also change the text, e.g when text is representing the value + * instead of the date + */ + rect + .transition() + .duration(options.animationDuration) + .select('text') + .attr('class', function (d) { + return 'subdomain-text' + parent.getHighlightClassName(d.t); + }) + .call(formatSubDomainText); + }, + + /** + * Sprintf like function. + * Replaces placeholders {0} in string with values from provided object. + * + * @param string formatted String containing placeholders. + * @param object args Object with properties to replace placeholders in string. + * + * @return String + */ + formatStringWithObject: function (formatted, args) { + 'use strict'; + for (var prop in args) { + if (args.hasOwnProperty(prop)) { + var regexp = new RegExp('\\{' + prop + '\\}', 'gi'); + formatted = formatted.replace(regexp, args[prop]); + } + } + return formatted; + }, + + // =========================================================================// + // EVENTS CALLBACK // + // =========================================================================// + + /** + * Helper method for triggering event callback + * + * @param string eventName Name of the event to trigger + * @param array successArgs List of argument to pass to the callback + * @param boolean skip Whether to skip the event triggering + * @return mixed True when the triggering was skipped, false on error, else the callback function + */ + triggerEvent: function (eventName, successArgs, skip) { + 'use strict'; + + if ((arguments.length === 3 && skip) || this.options[eventName] === null) { + return true; + } + + if (typeof this.options[eventName] === 'function') { + if (typeof successArgs === 'function') { + successArgs = successArgs(); + } + return this.options[eventName].apply(this, successArgs); + } else { + console.log('Provided callback for ' + eventName + ' is not a function.'); + return false; + } + }, + + /** + * Event triggered on a mouse click on a subDomain cell + * + * @param Date d Date of the subdomain block + * @param int itemNb Number of items in that date + */ + onClick: function (d, itemNb) { + 'use strict'; + + return this.triggerEvent('onClick', [d, itemNb]); + }, + + /** + * Event triggered after drawing the calendar, byt before filling it with data + */ + afterLoad: function () { + 'use strict'; + + return this.triggerEvent('afterLoad'); + }, + + /** + * Event triggered after completing drawing and filling the calendar + */ + onComplete: function () { + 'use strict'; + + var response = this.triggerEvent('onComplete', [], this._completed); + this._completed = true; + return response; + }, + + /** + * Event triggered after shifting the calendar one domain back + * + * @param Date start Domain start date + * @param Date end Domain end date + */ + afterLoadPreviousDomain: function (start) { + 'use strict'; + + var parent = this; + return this.triggerEvent('afterLoadPreviousDomain', function () { + var subDomain = parent.getSubDomain(start); + return [subDomain.shift(), subDomain.pop()]; + }); + }, + + /** + * Event triggered after shifting the calendar one domain above + * + * @param Date start Domain start date + * @param Date end Domain end date + */ + afterLoadNextDomain: function (start) { + 'use strict'; + + var parent = this; + return this.triggerEvent('afterLoadNextDomain', function () { + var subDomain = parent.getSubDomain(start); + return [subDomain.shift(), subDomain.pop()]; + }); + }, + + /** + * Event triggered after loading the leftmost domain allowed by minDate + * + * @param boolean reached True if the leftmost domain was reached + */ + onMinDomainReached: function (reached) { + 'use strict'; + + this._minDomainReached = reached; + return this.triggerEvent('onMinDomainReached', [reached]); + }, + + /** + * Event triggered after loading the rightmost domain allowed by maxDate + * + * @param boolean reached True if the rightmost domain was reached + */ + onMaxDomainReached: function (reached) { + 'use strict'; + + this._maxDomainReached = reached; + return this.triggerEvent('onMaxDomainReached', [reached]); + }, + + checkIfMinDomainIsReached: function (date, upperBound) { + 'use strict'; + + if (this.minDomainIsReached(date)) { + this.onMinDomainReached(true); + } + + if (arguments.length === 2) { + if (this._maxDomainReached && !this.maxDomainIsReached(upperBound)) { + this.onMaxDomainReached(false); + } + } + }, + + checkIfMaxDomainIsReached: function (date, lowerBound) { + 'use strict'; + + if (this.maxDomainIsReached(date)) { + this.onMaxDomainReached(true); + } + + if (arguments.length === 2) { + if (this._minDomainReached && !this.minDomainIsReached(lowerBound)) { + this.onMinDomainReached(false); + } + } + }, + + afterUpdate: function () { + 'use strict'; + + return this.triggerEvent('afterUpdate'); + }, + + // =========================================================================// + // FORMATTER // + // =========================================================================// + + formatNumber: d3.format(',g'), + + formatDate: function (d, format) { + 'use strict'; + + if (arguments.length < 2) { + format = 'title'; + } + + if (typeof format === 'function') { + return format(d); + } else { + var f = d3.time.format(format); + return f(d); + } + }, + + getSubDomainTitle: function (d) { + 'use strict'; + + if (d.v === null && !this.options.considerMissingDataAsZero) { + return this.formatStringWithObject(this.options.subDomainTitleFormat.empty, { + date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat), + }); + } else { + var value = d.v; + // Consider null as 0 + if (value === null && this.options.considerMissingDataAsZero) { + value = 0; + } + + return this.formatStringWithObject(this.options.subDomainTitleFormat.filled, { + count: this.formatNumber(value), + name: this.options.itemName[value !== 1 ? 1 : 0], + connector: this._domainType[this.options.subDomain].format.connector, + date: this.formatDate(new Date(d.t), this.options.subDomainDateFormat), + }); + } + }, + + // =========================================================================// + // DOMAIN NAVIGATION // + // =========================================================================// + + /** + * Shift the calendar one domain forward + * + * The new domain is loaded only if it's not beyond maxDate + * + * @param int n Number of domains to load + * @return bool True if the next domain was loaded, else false + */ + loadNextDomain: function (n) { + 'use strict'; + + if (this._maxDomainReached || n === 0) { + return false; + } + + var bound = this.loadNewDomains(this.NAVIGATE_RIGHT, this.getDomain(this.getNextDomain(), n)); + + this.afterLoadNextDomain(bound.end); + this.checkIfMaxDomainIsReached(this.getNextDomain().getTime(), bound.start); + + return true; + }, + + /** + * Shift the calendar one domain backward + * + * The previous domain is loaded only if it's not beyond the minDate + * + * @param int n Number of domains to load + * @return bool True if the previous domain was loaded, else false + */ + loadPreviousDomain: function (n) { + 'use strict'; + + if (this._minDomainReached || n === 0) { + return false; + } + + var bound = this.loadNewDomains( + this.NAVIGATE_LEFT, + this.getDomain(this.getDomainKeys()[0], -n).reverse(), + ); + + this.afterLoadPreviousDomain(bound.start); + this.checkIfMinDomainIsReached(bound.start, bound.end); + + return true; + }, + + loadNewDomains: function (direction, newDomains) { + 'use strict'; + + var parent = this; + var backward = direction === this.NAVIGATE_LEFT; + var i = -1; + var total = newDomains.length; + var domains = this.getDomainKeys(); + + function buildSubDomain(d) { + return { t: parent._domainType[parent.options.subDomain].extractUnit(d), v: null }; + } + + // Remove out of bound domains from list of new domains to prepend + while (++i < total) { + if (backward && this.minDomainIsReached(newDomains[i])) { + newDomains = newDomains.slice(0, i + 1); + break; + } + if (!backward && this.maxDomainIsReached(newDomains[i])) { + newDomains = newDomains.slice(0, i); + break; + } + } + + newDomains = newDomains.slice(-this.options.range); + + for (i = 0, total = newDomains.length; i < total; i++) { + this._domains.set( + newDomains[i].getTime(), + this.getSubDomain(newDomains[i]).map(buildSubDomain), + ); + + this._domains.remove(backward ? domains.pop() : domains.shift()); + } + + domains = this.getDomainKeys(); + + if (backward) { + newDomains = newDomains.reverse(); + } + + this.paint(direction); + + this.getDatas( + this.options.data, + newDomains[0], + this.getSubDomain(newDomains[newDomains.length - 1]).pop(), + function () { + parent.fill(parent.lastInsertedSvg); + }, + ); + + return { + start: newDomains[backward ? 0 : 1], + end: domains[domains.length - 1], + }; + }, + + /** + * Return whether a date is inside the scope determined by maxDate + * + * @param int datetimestamp The timestamp in ms to test + * @return bool True if the specified date correspond to the calendar upper bound + */ + maxDomainIsReached: function (datetimestamp) { + 'use strict'; + + return this.options.maxDate !== null && this.options.maxDate.getTime() < datetimestamp; + }, + + /** + * Return whether a date is inside the scope determined by minDate + * + * @param int datetimestamp The timestamp in ms to test + * @return bool True if the specified date correspond to the calendar lower bound + */ + minDomainIsReached: function (datetimestamp) { + 'use strict'; + + return this.options.minDate !== null && this.options.minDate.getTime() >= datetimestamp; + }, + + /** + * Return the list of the calendar's domain timestamp + * + * @return Array a sorted array of timestamp + */ + getDomainKeys: function () { + 'use strict'; + + return this._domains + .keys() + .map(function (d) { + return parseInt(d, 10); + }) + .sort(function (a, b) { + return a - b; + }); + }, + + // =========================================================================// + // POSITIONNING // + // =========================================================================// + + positionSubDomainX: function (d) { + 'use strict'; + + var index = this._domainType[this.options.subDomain].position.x(new Date(d)); + return index * this.options.cellSize + index * this.options.cellPadding; + }, + + positionSubDomainY: function (d) { + 'use strict'; + + var index = this._domainType[this.options.subDomain].position.y(new Date(d)); + return index * this.options.cellSize + index * this.options.cellPadding; + }, + + getSubDomainColumnNumber: function (d) { + 'use strict'; + + if (this.options.rowLimit > 0) { + var i = this._domainType[this.options.subDomain].maxItemNumber; + if (typeof i === 'function') { + i = i(d); + } + return Math.ceil(i / this.options.rowLimit); + } + + var j = this._domainType[this.options.subDomain].defaultColumnNumber; + if (typeof j === 'function') { + j = j(d); + } + return this.options.colLimit || j; + }, + + getSubDomainRowNumber: function (d) { + 'use strict'; + + if (this.options.colLimit > 0) { + var i = this._domainType[this.options.subDomain].maxItemNumber; + if (typeof i === 'function') { + i = i(d); + } + return Math.ceil(i / this.options.colLimit); + } + + var j = this._domainType[this.options.subDomain].defaultRowNumber; + if (typeof j === 'function') { + j = j(d); + } + return this.options.rowLimit || j; + }, + + /** + * Return a classname if the specified date should be highlighted + * + * @param timestamp date Date of the current subDomain + * @return String the highlight class + */ + getHighlightClassName: function (d) { + 'use strict'; + + d = new Date(d); + + if (this.options.highlight.length > 0) { + for (var i in this.options.highlight) { + if (this.dateIsEqual(this.options.highlight[i], d)) { + return this.isNow(this.options.highlight[i]) ? ' highlight-now' : ' highlight'; + } + } + } + return ''; + }, + + /** + * Return whether the specified date is now, + * according to the type of subdomain + * + * @param Date d The date to compare + * @return bool True if the date correspond to a subdomain cell + */ + isNow: function (d) { + 'use strict'; + + return this.dateIsEqual(d, new Date()); + }, + + /** + * Return whether 2 dates are equals + * This function is subdomain-aware, + * and dates comparison are dependent of the subdomain + * + * @param Date dateA First date to compare + * @param Date dateB Secon date to compare + * @return bool true if the 2 dates are equals + */ + /* jshint maxcomplexity: false */ + dateIsEqual: function (dateA, dateB) { + 'use strict'; + + if (!(dateA instanceof Date)) { + dateA = new Date(dateA); + } + + if (!(dateB instanceof Date)) { + dateB = new Date(dateB); + } + + switch (this.options.subDomain) { + case 'x_min': + case 'min': + return ( + dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth() && + dateA.getDate() === dateB.getDate() && + dateA.getHours() === dateB.getHours() && + dateA.getMinutes() === dateB.getMinutes() + ); + case 'x_hour': + case 'hour': + return ( + dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth() && + dateA.getDate() === dateB.getDate() && + dateA.getHours() === dateB.getHours() + ); + case 'x_day': + case 'day': + return ( + dateA.getFullYear() === dateB.getFullYear() && + dateA.getMonth() === dateB.getMonth() && + dateA.getDate() === dateB.getDate() + ); + case 'x_week': + case 'week': + return ( + dateA.getFullYear() === dateB.getFullYear() && + this.getWeekNumber(dateA) === this.getWeekNumber(dateB) + ); + case 'x_month': + case 'month': + return dateA.getFullYear() === dateB.getFullYear() && dateA.getMonth() === dateB.getMonth(); + default: + return false; + } + }, + + /** + * Returns wether or not dateA is less than or equal to dateB. This function is subdomain aware. + * Performs automatic conversion of values. + * @param dateA may be a number or a Date + * @param dateB may be a number or a Date + * @returns {boolean} + */ + dateIsLessThan: function (dateA, dateB) { + 'use strict'; + + if (!(dateA instanceof Date)) { + dateA = new Date(dateA); + } + + if (!(dateB instanceof Date)) { + dateB = new Date(dateB); + } + + function normalizedMillis(date, subdomain) { + switch (subdomain) { + case 'x_min': + case 'min': + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + ).getTime(); + case 'x_hour': + case 'hour': + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + ).getTime(); + case 'x_day': + case 'day': + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + case 'x_week': + case 'week': + case 'x_month': + case 'month': + return new Date(date.getFullYear(), date.getMonth()).getTime(); + default: + return date.getTime(); + } + } + + return ( + normalizedMillis(dateA, this.options.subDomain) < + normalizedMillis(dateB, this.options.subDomain) + ); + }, + + // =========================================================================// + // DATE COMPUTATION // + // =========================================================================// + + /** + * Return the day of the year for the date + * @param Date + * @return int Day of the year [1,366] + */ + getDayOfYear: d3.time.format('%j'), + + /** + * Return the week number of the year + * Monday as the first day of the week + * @return int Week number [0-53] + */ + getWeekNumber: function (d) { + 'use strict'; + + var f = this.options.weekStartOnMonday === true ? d3.time.format('%W') : d3.time.format('%U'); + return f(d); + }, + + /** + * Return the week number, relative to its month + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Week number, relative to the month [0-5] + */ + getMonthWeekNumber: function (d) { + 'use strict'; + + if (typeof d === 'number') { + d = new Date(d); + } + + var monthFirstWeekNumber = this.getWeekNumber(new Date(d.getFullYear(), d.getMonth())); + return this.getWeekNumber(d) - monthFirstWeekNumber - 1; + }, + + /** + * Return the number of weeks in the dates' year + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Number of weeks in the date's year + */ + getWeekNumberInYear: function (d) { + 'use strict'; + + if (typeof d === 'number') { + d = new Date(d); + } + }, + + /** + * Return the number of days in the date's month + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Number of days in the date's month + */ + getDayCountInMonth: function (d) { + 'use strict'; + + return this.getEndOfMonth(d).getDate(); + }, + + /** + * Return the number of days in the date's year + * + * @param int|Date d Date or timestamp in milliseconds + * @return int Number of days in the date's year + */ + getDayCountInYear: function (d) { + 'use strict'; + + if (typeof d === 'number') { + d = new Date(d); + } + return new Date(d.getFullYear(), 1, 29).getMonth() === 1 ? 366 : 365; + }, + + /** + * Get the weekday from a date + * + * Return the week day number (0-6) of a date, + * depending on whether the week start on monday or sunday + * + * @param Date d + * @return int The week day number (0-6) + */ + getWeekDay: function (d) { + 'use strict'; + + if (this.options.weekStartOnMonday === false) { + return d.getDay(); + } + return d.getDay() === 0 ? 6 : d.getDay() - 1; + }, + + /** + * Get the last day of the month + * @param Date|int d Date or timestamp in milliseconds + * @return Date Last day of the month + */ + getEndOfMonth: function (d) { + 'use strict'; + + if (typeof d === 'number') { + d = new Date(d); + } + return new Date(d.getFullYear(), d.getMonth() + 1, 0); + }, + + /** + * + * @param Date date + * @param int count + * @param string step + * @return Date + */ + jumpDate: function (date, count, step) { + 'use strict'; + + var d = new Date(date); + switch (step) { + case 'hour': + d.setHours(d.getHours() + count); + break; + case 'day': + d.setHours(d.getHours() + count * 24); + break; + case 'week': + d.setHours(d.getHours() + count * 24 * 7); + break; + case 'month': + d.setMonth(d.getMonth() + count); + break; + case 'year': + d.setFullYear(d.getFullYear() + count); + } + + return new Date(d); + }, + + // =========================================================================// + // DOMAIN COMPUTATION // + // =========================================================================// + + /** + * Return all the minutes between 2 dates + * + * @param Date d date A date + * @param int|date range Number of minutes in the range, or a stop date + * @return array An array of minutes + */ + getMinuteDomain: function (d, range) { + 'use strict'; + + var start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth(), range.getDate(), range.getHours()); + } else { + stop = new Date(+start + range * 1000 * 60); + } + return d3.time.minutes(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Return all the hours between 2 dates + * + * @param Date d A date + * @param int|date range Number of hours in the range, or a stop date + * @return array An array of hours + */ + getHourDomain: function (d, range) { + 'use strict'; + + var start = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth(), range.getDate(), range.getHours()); + } else { + stop = new Date(start); + stop.setHours(stop.getHours() + range); + } + + var domains = d3.time.hours(Math.min(start, stop), Math.max(start, stop)); + + // Passing from DST to standard time + // If there are 25 hours, let's compress the duplicate hours + var i = 0; + var total = domains.length; + for (i = 0; i < total; i++) { + if (i > 0 && domains[i].getHours() === domains[i - 1].getHours()) { + this.DSTDomain.push(domains[i].getTime()); + domains.splice(i, 1); + break; + } + } + + // d3.time.hours is returning more hours than needed when changing + // from DST to standard time, because there is really 2 hours between + // 1am and 2am! + if (typeof range === 'number' && domains.length > Math.abs(range)) { + domains.splice(domains.length - 1, 1); + } + + return domains; + }, + + /** + * Return all the days between 2 dates + * + * @param Date d A date + * @param int|date range Number of days in the range, or a stop date + * @return array An array of weeks + */ + getDayDomain: function (d, range) { + 'use strict'; + + var start = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth(), range.getDate()); + } else { + stop = new Date(start); + stop = new Date(stop.setDate(stop.getDate() + parseInt(range, 10))); + } + + return d3.time.days(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Return all the weeks between 2 dates + * + * @param Date d A date + * @param int|date range Number of minutes in the range, or a stop date + * @return array An array of weeks + */ + getWeekDomain: function (d, range) { + 'use strict'; + + var weekStart; + + if (this.options.weekStartOnMonday === false) { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay()); + } else { + if (d.getDay() === 1) { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } else if (d.getDay() === 0) { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + weekStart.setDate(weekStart.getDate() - 6); + } else { + weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay() + 1); + } + } + + var endDate = new Date(weekStart); + + var stop = range; + if (typeof range !== 'object') { + stop = new Date(endDate.setDate(endDate.getDate() + range * 7)); + } + + return this.options.weekStartOnMonday === true + ? d3.time.mondays(Math.min(weekStart, stop), Math.max(weekStart, stop)) + : d3.time.sundays(Math.min(weekStart, stop), Math.max(weekStart, stop)); + }, + + /** + * Return all the months between 2 dates + * + * @param Date d A date + * @param int|date range Number of months in the range, or a stop date + * @return array An array of months + */ + getMonthDomain: function (d, range) { + 'use strict'; + + var start = new Date(d.getFullYear(), d.getMonth()); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), range.getMonth()); + } else { + stop = new Date(start); + stop = stop.setMonth(stop.getMonth() + range); + } + + return d3.time.months(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Return all the years between 2 dates + * + * @param Date d date A date + * @param int|date range Number of minutes in the range, or a stop date + * @return array An array of hours + */ + getYearDomain: function (d, range) { + 'use strict'; + + var start = new Date(d.getFullYear(), 0); + var stop = null; + if (range instanceof Date) { + stop = new Date(range.getFullYear(), 0); + } else { + stop = new Date(d.getFullYear() + range, 0); + } + + return d3.time.years(Math.min(start, stop), Math.max(start, stop)); + }, + + /** + * Get an array of domain start dates + * + * @param int|Date date A random date included in the wanted domain + * @param int|Date range Number of dates to get, or a stop date + * @return Array of dates + */ + getDomain: function (date, range) { + 'use strict'; + + if (typeof date === 'number') { + date = new Date(date); + } + + if (arguments.length < 2) { + range = this.options.range; + } + + switch (this.options.domain) { + case 'hour': + var domains = this.getHourDomain(date, range); + + // Case where an hour is missing, when passing from standard time to DST + // Missing hour is perfectly acceptabl in subDomain, but not in domains + if (typeof range === 'number' && domains.length < range) { + if (range > 0) { + domains.push(this.getHourDomain(domains[domains.length - 1], 2)[1]); + } else { + domains.shift(this.getHourDomain(domains[0], -2)[0]); + } + } + return domains; + case 'day': + return this.getDayDomain(date, range); + case 'week': + return this.getWeekDomain(date, range); + case 'month': + return this.getMonthDomain(date, range); + case 'year': + return this.getYearDomain(date, range); + } + }, + + /* jshint maxcomplexity: false */ + getSubDomain: function (date) { + 'use strict'; + + if (typeof date === 'number') { + date = new Date(date); + } + + var parent = this; + + /** + * @return int + */ + var computeDaySubDomainSize = function (date, domain) { + switch (domain) { + case 'year': + return parent.getDayCountInYear(date); + case 'month': + return parent.getDayCountInMonth(date); + case 'week': + return 7; + } + }; + + /** + * @return int + */ + var computeMinSubDomainSize = function (date, domain) { + switch (domain) { + case 'hour': + return 60; + case 'day': + return 60 * 24; + case 'week': + return 60 * 24 * 7; + } + }; + + /** + * @return int + */ + var computeHourSubDomainSize = function (date, domain) { + switch (domain) { + case 'day': + return 24; + case 'week': + return 168; + case 'month': + return parent.getDayCountInMonth(date) * 24; + } + }; + + /** + * @return int + */ + var computeWeekSubDomainSize = function (date, domain) { + if (domain === 'month') { + var endOfMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0); + var endWeekNb = parent.getWeekNumber(endOfMonth); + var startWeekNb = parent.getWeekNumber(new Date(date.getFullYear(), date.getMonth())); + + if (startWeekNb > endWeekNb) { + startWeekNb = 0; + endWeekNb++; + } + + return endWeekNb - startWeekNb + 1; + } else if (domain === 'year') { + return parent.getWeekNumber(new Date(date.getFullYear(), 11, 31)); + } + }; + + switch (this.options.subDomain) { + case 'x_min': + case 'min': + return this.getMinuteDomain(date, computeMinSubDomainSize(date, this.options.domain)); + case 'x_hour': + case 'hour': + return this.getHourDomain(date, computeHourSubDomainSize(date, this.options.domain)); + case 'x_day': + case 'day': + return this.getDayDomain(date, computeDaySubDomainSize(date, this.options.domain)); + case 'x_week': + case 'week': + return this.getWeekDomain(date, computeWeekSubDomainSize(date, this.options.domain)); + case 'x_month': + case 'month': + return this.getMonthDomain(date, 12); + } + }, + + /** + * Get the n-th next domain after the calendar newest (rightmost) domain + * @param int n + * @return Date The start date of the wanted domain + */ + getNextDomain: function (n) { + 'use strict'; + + if (arguments.length === 0) { + n = 1; + } + return this.getDomain(this.jumpDate(this.getDomainKeys().pop(), n, this.options.domain), 1)[0]; + }, + + /** + * Get the n-th domain before the calendar oldest (leftmost) domain + * @param int n + * @return Date The start date of the wanted domain + */ + getPreviousDomain: function (n) { + 'use strict'; + + if (arguments.length === 0) { + n = 1; + } + return this.getDomain( + this.jumpDate(this.getDomainKeys().shift(), -n, this.options.domain), + 1, + )[0]; + }, + + // =========================================================================// + // DATAS // + // =========================================================================// + + /** + * Fetch and interpret data from the datasource + * + * @param string|object source + * @param Date startDate + * @param Date endDate + * @param function callback + * @param function|boolean afterLoad function used to convert the data into a json object. Use true to use the afterLoad callback + * @param updateMode + * + * @return mixed + * - True if there are no data to load + * - False if data are loaded asynchronously + */ + getDatas: function (source, startDate, endDate, callback, afterLoad, updateMode) { + 'use strict'; + + var self = this; + if (arguments.length < 5) { + afterLoad = true; + } + if (arguments.length < 6) { + updateMode = this.APPEND_ON_UPDATE; + } + var _callback = function (error, data) { + if (afterLoad !== false) { + if (typeof afterLoad === 'function') { + data = afterLoad(data); + } else if (typeof self.options.afterLoadData === 'function') { + data = self.options.afterLoadData(data); + } else { + console.log('Provided callback for afterLoadData is not a function.'); + } + } else if (self.options.dataType === 'csv' || self.options.dataType === 'tsv') { + data = this.interpretCSV(data); + } + self.parseDatas(data, updateMode, startDate, endDate); + if (typeof callback === 'function') { + callback(); + } + }; + + switch (typeof source) { + case 'string': + if (source === '') { + _callback(null, {}); + return true; + } else { + var url = this.parseURI(source, startDate, endDate); + var requestType = 'GET'; + if (self.options.dataPostPayload !== null) { + requestType = 'POST'; + } + var payload = null; + if (self.options.dataPostPayload !== null) { + payload = this.parseURI(self.options.dataPostPayload, startDate, endDate); + } + + var xhr = null; + switch (this.options.dataType) { + case 'json': + xhr = d3.json(url); + break; + case 'csv': + xhr = d3.csv(url); + break; + case 'tsv': + xhr = d3.tsv(url); + break; + case 'txt': + xhr = d3.text(url, 'text/plain'); + break; + } + + // jshint maxdepth:5 + if (self.options.dataRequestHeaders !== null) { + for (var header in self.options.dataRequestHeaders) { + if (self.options.dataRequestHeaders.hasOwnProperty(header)) { + xhr.header(header, self.options.dataRequestHeaders[header]); + } + } + } + + xhr.send(requestType, payload, _callback); + } + return false; + case 'object': + if (source === Object(source)) { + _callback(null, source); + return false; + } + /* falls through */ + default: + _callback(null, {}); + return true; + } + }, + + /** + * Populate the calendar internal data + * + * @param object data + * @param constant updateMode + * @param Date startDate + * @param Date endDate + * + * @return void + */ + parseDatas: function (data, updateMode, startDate, endDate) { + 'use strict'; + + if (updateMode === this.RESET_ALL_ON_UPDATE) { + this._domains.forEach(function (key, value) { + value.forEach(function (element, index, array) { + array[index].v = null; + }); + }); + } + + var temp = {}; + + var extractTime = function (d) { + return d.t; + }; + + /*jshint forin:false */ + for (var d in data) { + var date = new Date(d * 1000); + var domainUnit = this.getDomain(date)[0].getTime(); + // The current data belongs to a domain that was compressed + // Compress the data for the two duplicate hours into the same hour + if (this.DSTDomain.indexOf(domainUnit) >= 0) { + // Re-assign all data to the first or the second duplicate hours + // depending on which is visible + if (this._domains.has(domainUnit - 3600 * 1000)) { + domainUnit -= 3600 * 1000; + } + } + + // Skip if data is not relevant to current domain + if ( + isNaN(d) || + !data.hasOwnProperty(d) || + !this._domains.has(domainUnit) || + !(domainUnit >= +startDate && domainUnit < +endDate) + ) { + continue; + } + + var subDomainsData = this._domains.get(domainUnit); + + if (!temp.hasOwnProperty(domainUnit)) { + temp[domainUnit] = subDomainsData.map(extractTime); + } + + var index = temp[domainUnit].indexOf( + this._domainType[this.options.subDomain].extractUnit(date), + ); + + if (updateMode === this.RESET_SINGLE_ON_UPDATE) { + subDomainsData[index].v = data[d]; + } else { + if (!isNaN(subDomainsData[index].v)) { + subDomainsData[index].v += data[d]; + } else { + subDomainsData[index].v = data[d]; + } + } + } + }, + + parseURI: function (str, startDate, endDate) { + 'use strict'; + + // Use a timestamp in seconds + str = str.replace(/\{\{t:start\}\}/g, startDate.getTime() / 1000); + str = str.replace(/\{\{t:end\}\}/g, endDate.getTime() / 1000); + + // Use a string date, following the ISO-8601 + str = str.replace(/\{\{d:start\}\}/g, startDate.toISOString()); + str = str.replace(/\{\{d:end\}\}/g, endDate.toISOString()); + + return str; + }, + + interpretCSV: function (data) { + 'use strict'; + + var d = {}; + var keys = Object.keys(data[0]); + var i, total; + for (i = 0, total = data.length; i < total; i++) { + d[data[i][keys[0]]] = +data[i][keys[1]]; + } + return d; + }, + + /** + * Handle the calendar layout and dimension + * + * Expand and shrink the container depending on its children dimension + * Also rearrange the children position depending on their dimension, + * and the legend position + * + * @return void + */ + resize: function () { + 'use strict'; + + var parent = this; + var options = parent.options; + var legendWidth = options.displayLegend + ? parent.Legend.getDim('width') + options.legendMargin[1] + options.legendMargin[3] + : 0; + var legendHeight = options.displayLegend + ? parent.Legend.getDim('height') + options.legendMargin[0] + options.legendMargin[2] + : 0; + + var graphWidth = parent.graphDim.width - options.domainGutter - options.cellPadding; + var graphHeight = parent.graphDim.height - options.domainGutter - options.cellPadding; + + this.root + .transition() + .duration(options.animationDuration) + .attr('width', function () { + if ( + options.legendVerticalPosition === 'middle' || + options.legendVerticalPosition === 'center' + ) { + return graphWidth + legendWidth; + } + return Math.max(graphWidth, legendWidth); + }) + .attr('height', function () { + if ( + options.legendVerticalPosition === 'middle' || + options.legendVerticalPosition === 'center' + ) { + return Math.max(graphHeight, legendHeight); + } + return graphHeight + legendHeight; + }); + + this.root + .select('.graph') + .transition() + .duration(options.animationDuration) + .attr('y', function () { + if (options.legendVerticalPosition === 'top') { + return legendHeight; + } + return 0; + }) + .attr('x', function () { + if ( + (options.legendVerticalPosition === 'middle' || + options.legendVerticalPosition === 'center') && + options.legendHorizontalPosition === 'left' + ) { + return legendWidth; + } + return 0; + }); + }, + + // =========================================================================// + // PUBLIC API // + // =========================================================================// + + /** + * Shift the calendar forward + */ + next: function (n) { + 'use strict'; + + if (arguments.length === 0) { + n = 1; + } + return this.loadNextDomain(n); + }, + + /** + * Shift the calendar backward + */ + previous: function (n) { + 'use strict'; + + if (arguments.length === 0) { + n = 1; + } + return this.loadPreviousDomain(n); + }, + + /** + * Jump directly to a specific date + * + * JumpTo will scroll the calendar until the wanted domain with the specified + * date is visible. Unless you set reset to true, the wanted domain + * will not necessarily be the first (leftmost) domain of the calendar. + * + * @param Date date Jump to the domain containing that date + * @param bool reset Whether the wanted domain should be the first domain of the calendar + * @param bool True of the calendar was scrolled + */ + jumpTo: function (date, reset) { + 'use strict'; + + if (arguments.length < 2) { + reset = false; + } + var domains = this.getDomainKeys(); + var firstDomain = domains[0]; + var lastDomain = domains[domains.length - 1]; + + if (date < firstDomain) { + return this.loadPreviousDomain(this.getDomain(firstDomain, date).length); + } else { + if (reset) { + return this.loadNextDomain(this.getDomain(firstDomain, date).length); + } + + if (date > lastDomain) { + return this.loadNextDomain(this.getDomain(lastDomain, date).length); + } + } + + return false; + }, + + /** + * Navigate back to the start date + * + * @since 3.3.8 + * @return void + */ + rewind: function () { + 'use strict'; + + this.jumpTo(this.options.start, true); + }, + + /** + * Update the calendar with new data + * + * @param object|string dataSource The calendar's datasource, same type as this.options.data + * @param boolean|function afterLoad Whether to execute afterLoad() on the data. Pass directly a function + * if you don't want to use the afterLoad() callback + */ + update: function (dataSource, afterLoad, updateMode) { + 'use strict'; + + if (arguments.length === 0) { + dataSource = this.options.data; + } + if (arguments.length < 2) { + afterLoad = true; + } + if (arguments.length < 3) { + updateMode = this.RESET_ALL_ON_UPDATE; + } + + var domains = this.getDomainKeys(); + var self = this; + this.getDatas( + dataSource, + new Date(domains[0]), + this.getSubDomain(domains[domains.length - 1]).pop(), + function () { + self.fill(); + self.afterUpdate(); + }, + afterLoad, + updateMode, + ); + }, + + /** + * Set the legend + * + * @param array legend an array of integer, representing the different threshold value + * @param array colorRange an array of 2 hex colors, for the minimum and maximum colors + */ + setLegend: function () { + 'use strict'; + + var oldLegend = this.options.legend.slice(0); + if (arguments.length >= 1 && Array.isArray(arguments[0])) { + this.options.legend = arguments[0]; + } + if (arguments.length >= 2) { + if (Array.isArray(arguments[1]) && arguments[1].length >= 2) { + this.options.legendColors = [arguments[1][0], arguments[1][1]]; + } else { + this.options.legendColors = arguments[1]; + } + } + + if ( + (arguments.length > 0 && !arrayEquals(oldLegend, this.options.legend)) || + arguments.length >= 2 + ) { + this.Legend.buildColors(); + this.fill(); + } + + this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding); + }, + + /** + * Remove the legend + * + * @return bool False if there is no legend to remove + */ + removeLegend: function () { + 'use strict'; + + if (!this.options.displayLegend) { + return false; + } + this.options.displayLegend = false; + this.Legend.remove(); + return true; + }, + + /** + * Display the legend + * + * @return bool False if the legend was already displayed + */ + showLegend: function () { + 'use strict'; + + if (this.options.displayLegend) { + return false; + } + this.options.displayLegend = true; + this.Legend.redraw(this.graphDim.width - this.options.domainGutter - this.options.cellPadding); + return true; + }, + + /** + * Highlight dates + * + * Add a highlight class to a set of dates + * + * @since 3.3.5 + * @param array Array of dates to highlight + * @return bool True if dates were highlighted + */ + highlight: function (args) { + 'use strict'; + + if ((this.options.highlight = this.expandDateSetting(args)).length > 0) { + this.fill(); + return true; + } + return false; + }, + + /** + * Destroy the calendar + * + * Usage: cal = cal.destroy(); + * + * @since 3.3.6 + * @param function A callback function to trigger after destroying the calendar + * @return null + */ + destroy: function (callback) { + 'use strict'; + + this.root + .transition() + .duration(this.options.animationDuration) + .attr('width', 0) + .attr('height', 0) + .remove() + .each('end', function () { + if (typeof callback === 'function') { + callback(); + } else if (typeof callback !== 'undefined') { + console.log('Provided callback for destroy() is not a function.'); + } + }); + + return null; + }, + + getSVG: function () { + 'use strict'; + + var styles = { + '.cal-heatmap-container': {}, + '.graph': {}, + '.graph-rect': {}, + 'rect.highlight': {}, + 'rect.now': {}, + 'rect.highlight-now': {}, + 'text.highlight': {}, + 'text.now': {}, + 'text.highlight-now': {}, + '.domain-background': {}, + '.graph-label': {}, + '.subdomain-text': {}, + '.q0': {}, + '.qi': {}, + }; + + for (var j = 1, total = this.options.legend.length + 1; j <= total; j++) { + styles['.q' + j] = {}; + } + + var root = this.root; + + var whitelistStyles = [ + // SVG specific properties + 'stroke', + 'stroke-width', + 'stroke-opacity', + 'stroke-dasharray', + 'stroke-dashoffset', + 'stroke-linecap', + 'stroke-miterlimit', + 'fill', + 'fill-opacity', + 'fill-rule', + 'marker', + 'marker-start', + 'marker-mid', + 'marker-end', + 'alignement-baseline', + 'baseline-shift', + 'dominant-baseline', + 'glyph-orientation-horizontal', + 'glyph-orientation-vertical', + 'kerning', + 'text-anchor', + 'shape-rendering', + + // Text Specific properties + 'text-transform', + 'font-family', + 'font', + 'font-size', + 'font-weight', + ]; + + var filterStyles = function (attribute, property, value) { + if (whitelistStyles.indexOf(property) !== -1) { + styles[attribute][property] = value; + } + }; + + var getElement = function (e) { + return root.select(e)[0][0]; + }; + + /* jshint forin:false */ + for (var element in styles) { + if (!styles.hasOwnProperty(element)) { + continue; + } + + var dom = getElement(element); + + if (dom === null) { + continue; + } + + // The DOM Level 2 CSS way + /* jshint maxdepth: false */ + if ('getComputedStyle' in window) { + var cs = getComputedStyle(dom, null); + if (cs.length !== 0) { + for (var i = 0; i < cs.length; i++) { + filterStyles(element, cs.item(i), cs.getPropertyValue(cs.item(i))); + } + + // Opera workaround. Opera doesn"t support `item`/`length` + // on CSSStyleDeclaration. + } else { + for (var k in cs) { + if (cs.hasOwnProperty(k)) { + filterStyles(element, k, cs[k]); + } + } + } + + // The IE way + } else if ('currentStyle' in dom) { + var css = dom.currentStyle; + for (var p in css) { + filterStyles(element, p, css[p]); + } + } + } + + var string = + ''; + string += new XMLSerializer().serializeToString(this.root[0][0]); + string += ''; + + return string; + }, +}; + +// =========================================================================// +// DOMAIN POSITION COMPUTATION // +// =========================================================================// + +/** + * Compute the position of a domain, relative to the calendar + */ +var DomainPosition = function () { + 'use strict'; + + this.positions = d3.map(); +}; + +DomainPosition.prototype.getPosition = function (d) { + 'use strict'; + + return this.positions.get(d); +}; + +DomainPosition.prototype.getPositionFromIndex = function (i) { + 'use strict'; + + var domains = this.getKeys(); + return this.positions.get(domains[i]); +}; + +DomainPosition.prototype.getLast = function () { + 'use strict'; + + var domains = this.getKeys(); + return this.positions.get(domains[domains.length - 1]); +}; + +DomainPosition.prototype.setPosition = function (d, dim) { + 'use strict'; + + this.positions.set(d, dim); +}; + +DomainPosition.prototype.shiftRightBy = function (exitingDomainDim) { + 'use strict'; + + this.positions.forEach(function (key, value) { + this.set(key, value - exitingDomainDim); + }); + + var domains = this.getKeys(); + this.positions.remove(domains[0]); +}; + +DomainPosition.prototype.shiftLeftBy = function (enteringDomainDim) { + 'use strict'; + + this.positions.forEach(function (key, value) { + this.set(key, value + enteringDomainDim); + }); + + var domains = this.getKeys(); + this.positions.remove(domains[domains.length - 1]); +}; + +DomainPosition.prototype.getKeys = function () { + 'use strict'; + + return this.positions.keys().sort(function (a, b) { + return parseInt(a, 10) - parseInt(b, 10); + }); +}; + +// =========================================================================// +// LEGEND // +// =========================================================================// + +var Legend = function (calendar) { + 'use strict'; + + this.calendar = calendar; + this.computeDim(); + + if (calendar.options.legendColors !== null) { + this.buildColors(); + } +}; + +Legend.prototype.computeDim = function () { + 'use strict'; + + var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying + this.dim = { + width: + options.legendCellSize * (options.legend.length + 1) + + options.legendCellPadding * options.legend.length, + height: options.legendCellSize, + }; +}; + +Legend.prototype.remove = function () { + 'use strict'; + + this.calendar.root.select('.graph-legend').remove(); + this.calendar.resize(); +}; + +Legend.prototype.redraw = function (width) { + 'use strict'; + + if (!this.calendar.options.displayLegend) { + return false; + } + + var parent = this; + var calendar = this.calendar; + var legend = calendar.root; + var legendItem; + var options = calendar.options; // Shorter accessor for variable name mangling when minifying + + this.computeDim(); + + var _legend = options.legend.slice(0); + _legend.push(_legend[_legend.length - 1] + 1); + + var legendElement = calendar.root.select('.graph-legend'); + if (legendElement[0][0] !== null) { + legend = legendElement; + legendItem = legend.select('g').selectAll('rect').data(_legend); + } else { + // Creating the new legend DOM if it doesn't already exist + legend = + options.legendVerticalPosition === 'top' + ? legend.insert('svg', '.graph') + : legend.append('svg'); + + legend.attr('x', getLegendXPosition()).attr('y', getLegendYPosition()); + + legendItem = legend + .attr('class', 'graph-legend') + .attr('height', parent.getDim('height')) + .attr('width', parent.getDim('width')) + .append('g') + .selectAll() + .data(_legend); + } + + legendItem + .enter() + .append('rect') + .call(legendCellLayout) + .attr('class', function (d) { + return calendar.Legend.getClass(d, calendar.legendScale === null); + }) + .attr('fill-opacity', 0) + .call(function (selection) { + if ( + calendar.legendScale !== null && + options.legendColors !== null && + options.legendColors.hasOwnProperty('base') + ) { + selection.attr('fill', options.legendColors.base); + } + }) + .append('title'); + + legendItem + .exit() + .transition() + .duration(options.animationDuration) + .attr('fill-opacity', 0) + .remove(); + + legendItem + .transition() + .delay(function (d, i) { + return (options.animationDuration * i) / 10; + }) + .call(legendCellLayout) + .attr('fill-opacity', 1) + .call(function (element) { + element.attr('fill', function (d, i) { + if (calendar.legendScale === null) { + return ''; + } + + if (i === 0) { + return calendar.legendScale(d - 1); + } + return calendar.legendScale(options.legend[i - 1]); + }); + + element.attr('class', function (d) { + return calendar.Legend.getClass(d, calendar.legendScale === null); + }); + }); + + function legendCellLayout(selection) { + selection + .attr('width', options.legendCellSize) + .attr('height', options.legendCellSize) + .attr('rx', options.legendCellRadius) + .attr('ry', options.legendCellRadius) + .attr('x', function (d, i) { + return i * (options.legendCellSize + options.legendCellPadding); + }); + } + + legendItem.select('title').text(function (d, i) { + if (i === 0) { + return calendar.formatStringWithObject(options.legendTitleFormat.lower, { + min: options.legend[i], + name: options.itemName[1], + }); + } else if (i === _legend.length - 1) { + return calendar.formatStringWithObject(options.legendTitleFormat.upper, { + max: options.legend[i - 1], + name: options.itemName[1], + }); + } else { + return calendar.formatStringWithObject(options.legendTitleFormat.inner, { + down: options.legend[i - 1], + up: options.legend[i], + name: options.itemName[1], + }); + } + }); + legendItem + .on('mouseover', function (d) { + calendar.legendTip.show(d, this); + }) + .on('mouseout', function () { + calendar.legendTip.hide(); + }); + + legend + .transition() + .duration(options.animationDuration) + .attr('x', getLegendXPosition()) + .attr('y', getLegendYPosition()) + .attr('width', parent.getDim('width')) + .attr('height', parent.getDim('height')); + + legend + .select('g') + .transition() + .duration(options.animationDuration) + .attr('transform', function () { + if (options.legendOrientation === 'vertical') { + return 'rotate(90 ' + options.legendCellSize / 2 + ' ' + options.legendCellSize / 2 + ')'; + } + return ''; + }); + + function getLegendXPosition() { + switch (options.legendHorizontalPosition) { + case 'right': + if ( + options.legendVerticalPosition === 'center' || + options.legendVerticalPosition === 'middle' + ) { + return width + options.legendMargin[3]; + } + return width - parent.getDim('width') - options.legendMargin[1]; + case 'middle': + case 'center': + return Math.round(width / 2 - parent.getDim('width') / 2); + default: + return options.legendMargin[3]; + } + } + + function getLegendYPosition() { + if (options.legendVerticalPosition === 'bottom') { + return ( + calendar.graphDim.height + + options.legendMargin[0] - + options.domainGutter - + options.cellPadding + ); + } + return options.legendMargin[0]; + } + + calendar.resize(); +}; + +/** + * Return the dimension of the legend + * + * Takes into account rotation + * + * @param string axis Width or height + * @return int height or width in pixels + */ +Legend.prototype.getDim = function (axis) { + 'use strict'; + + var isHorizontal = this.calendar.options.legendOrientation === 'horizontal'; + + switch (axis) { + case 'width': + return this.dim[isHorizontal ? 'width' : 'height']; + case 'height': + return this.dim[isHorizontal ? 'height' : 'width']; + } +}; + +Legend.prototype.buildColors = function () { + 'use strict'; + + var options = this.calendar.options; // Shorter accessor for variable name mangling when minifying + + if (options.legendColors === null) { + this.calendar.legendScale = null; + return false; + } + + var _colorRange = []; + + if (Array.isArray(options.legendColors)) { + _colorRange = options.legendColors; + } else if ( + options.legendColors.hasOwnProperty('min') && + options.legendColors.hasOwnProperty('max') + ) { + _colorRange = [options.legendColors.min, options.legendColors.max]; + } else { + options.legendColors = null; + return false; + } + + var _legend = options.legend.slice(0); + + if (_legend[0] > 0) { + _legend.unshift(0); + } else if (_legend[0] <= 0) { + // Let's guess the leftmost value, it we have to add one + _legend.unshift(_legend[0] - (_legend[_legend.length - 1] - _legend[0]) / _legend.length); + } + var colorScale; + if (options.legendColors.hasOwnProperty('colorScale')) { + colorScale = options.legendColors.colorScale; + } else { + colorScale = d3.scale + .linear() + .range(_colorRange) + .interpolate(d3.interpolateHcl) + .domain([d3.min(_legend), d3.max(_legend)]); + } + + var legendColors = _legend.map(function (element) { + return colorScale(element); + }); + this.calendar.legendScale = d3.scale.threshold().domain(options.legend).range(legendColors); + + return true; +}; + +/** + * Return the classname on the legend for the specified value + * + * @param integer n Value associated to a date + * @param bool withCssClass Whether to display the css class used to style the cell. + * Disabling will allow styling directly via html fill attribute + * + * @return string Classname according to the legend + */ +Legend.prototype.getClass = function (n, withCssClass) { + 'use strict'; + + if (n === null || isNaN(n)) { + return ''; + } + + var index = [this.calendar.options.legend.length + 1]; + + for (var i = 0, total = this.calendar.options.legend.length - 1; i <= total; i++) { + if (this.calendar.options.legend[0] > 0 && n < 0) { + index = ['1', 'i']; + break; + } + + if (n <= this.calendar.options.legend[i]) { + index = [i + 1]; + break; + } + } + + if (n === 0) { + index.push(0); + } + + index.unshift(''); + return (index.join(' r') + (withCssClass ? index.join(' q') : '')).trim(); +}; + +/** + * #source http://stackoverflow.com/a/383245/805649 + */ +function mergeRecursive(obj1, obj2) { + 'use strict'; + + /*jshint forin:false */ + for (var p in obj2) { + try { + // Property in destination object set; update its value. + if (obj2[p].constructor === Object) { + obj1[p] = mergeRecursive(obj1[p], obj2[p]); + } else { + obj1[p] = obj2[p]; + } + } catch (e) { + // Property in destination object not set; create it and set its value. + obj1[p] = obj2[p]; + } + } + + return obj1; +} + +/** + * Check if 2 arrays are equals + * + * @link http://stackoverflow.com/a/14853974/805649 + * @param array array the array to compare to + * @return bool true of the 2 arrays are equals + */ +function arrayEquals(arrayA, arrayB) { + 'use strict'; + + // if the other array is a falsy value, return + if (!arrayB || !arrayA) { + return false; + } + + // compare lengths - can save a lot of time + if (arrayA.length !== arrayB.length) { + return false; + } + + for (var i = 0; i < arrayA.length; i++) { + // Check if we have nested arrays + if (arrayA[i] instanceof Array && arrayB[i] instanceof Array) { + // recurse into the nested arrays + if (!arrayEquals(arrayA[i], arrayB[i])) { + return false; + } + } else if (arrayA[i] !== arrayB[i]) { + // Warning - two different object instances will never be equal: {x:20} != {x:20} + return false; + } + } + return true; +} + +export default CalHeatMap; diff --git a/plugins/legacy-plugin-chart-calendar/src/vendor/d3tip.css b/plugins/legacy-plugin-chart-calendar/src/vendor/d3tip.css new file mode 100644 index 0000000000..980a5be439 --- /dev/null +++ b/plugins/legacy-plugin-chart-calendar/src/vendor/d3tip.css @@ -0,0 +1,74 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* from d3-tip */ +.d3-tip { + line-height: 1; + padding: 12px; + background: rgba(0, 0, 0, 0.8); + color: #fff; + border-radius: 2px; + pointer-events: none; + z-index: 1000; +} + +/* Creates a small triangle extender for the tooltip */ +.d3-tip:after { + box-sizing: border-box; + display: inline; + font-size: 10px; + width: 100%; + line-height: 1; + color: rgba(0, 0, 0, 0.8); + position: absolute; + pointer-events: none; +} + +/* Northward tooltips */ +.d3-tip.n:after { + content: '\25BC'; + margin: -1px 0 0 0; + top: 100%; + left: 0; + text-align: center; +} + +/* Eastward tooltips */ +.d3-tip.e:after { + content: '\25C0'; + margin: -4px 0 0 0; + top: 50%; + left: -8px; +} + +/* Southward tooltips */ +.d3-tip.s:after { + content: '\25B2'; + margin: 0 0 1px 0; + top: -8px; + left: 0; + text-align: center; +} + +/* Westward tooltips */ +.d3-tip.w:after { + content: '\25B6'; + margin: -4px 0 0 -1px; + top: 50%; + left: 100%; +} diff --git a/yarn.lock b/yarn.lock index ff5153f7d0..31aab9d42d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6699,7 +6699,7 @@ d3-cloud@^1.2.1, d3-cloud@^1.2.5: dependencies: d3-dispatch "^1.0.3" -d3-collection@1: +d3-collection@1, d3-collection@^1.0.4: version "1.0.7" resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.7.tgz#349bd2aa9977db071091c13144d5e4f16b5b310e" integrity sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A== @@ -6815,6 +6815,11 @@ d3-scale@^3.0.0, d3-scale@^3.0.1, d3-scale@^3.2.1: d3-time "1" d3-time-format "2" +d3-selection@^1.3.0, d3-selection@^1.4.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.1.tgz#98eedbbe085fbda5bafa2f9e3f3a2f4d7d622a98" + integrity sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA== + d3-shape@^1.0.6, d3-shape@^1.2.0, d3-shape@^1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -6839,6 +6844,14 @@ d3-timer@1, d3-timer@^1.0.10: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== +d3-tip@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/d3-tip/-/d3-tip-0.9.1.tgz#84e6d331c4e6650d80c5228a07e41820609ab64b" + integrity sha512-EVBfG9d+HnjIoyVXfhpytWxlF59JaobwizqMX9EBXtsFmJytjwHeYiUs74ldHQjE7S9vzfKTx2LCtvUrIbuFYg== + dependencies: + d3-collection "^1.0.4" + d3-selection "^1.3.0" + d3-voronoi@^1.1.2: version "1.1.4" resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297"