diff --git a/components/LineChart.js b/components/LineChart.js
new file mode 100644
index 00000000..eca5ef89
--- /dev/null
+++ b/components/LineChart.js
@@ -0,0 +1,349 @@
+import { c, t, msgid } from 'ttag';
+import { max, curveMonotoneX, scaleTime, scaleLinear, line } from 'd3';
+import { startOfDay } from 'date-fns';
+import { makeStyles } from '@material-ui/core/styles';
+
+const useStyles = makeStyles(theme => ({
+ lineChartContainer: {
+ position: 'relative',
+ },
+ plot: {
+ fontSize: '10px',
+ color: theme.palette.secondary[300],
+ lineHeight: '10px',
+ '& .line': {
+ strokeWidth: '2px',
+ fill: 'none',
+ },
+ '& .gridline': {
+ strokeWidth: 1,
+ stroke: theme.palette.secondary[100],
+ },
+ '& .leftAxis': {
+ color: theme.palette.info.dark,
+ fontWeight: 500,
+ },
+ '& .webLine': {
+ stroke: theme.palette.info.main,
+ },
+ '& .rightAxis': {
+ color: theme.palette.primary[900],
+ fontWeight: 500,
+ },
+ '& .chatbotLine': {
+ stroke: theme.palette.primary.main,
+ },
+ },
+ wrapper: {
+ display: 'inline-block',
+ position: 'absolute',
+ cursor: 'crosshair',
+
+ '& .hitbox': {
+ position: 'absolute',
+ zIndex: 1000,
+
+ '&:hover .dot, &:hover .vLine, &:hover .tooltip': {
+ display: 'inline-block',
+ },
+ },
+
+ '& .vLine': {
+ width: '1px',
+ borderLeft: `1px solid ${theme.palette.error.main}`,
+ position: 'absolute',
+ display: 'none',
+ },
+ '& .dot': {
+ width: '10px',
+ height: '10px',
+ borderRadius: '5px',
+ position: 'absolute',
+ display: 'none',
+
+ '&.web': {
+ background: theme.palette.info.main,
+ },
+ '&.chatbot': {
+ background: theme.palette.primary.main,
+ },
+ },
+ '& .tooltip': {
+ background: theme.palette.secondary[500],
+ borderRadius: '2px',
+ display: 'none',
+ fontSize: '12px',
+ lineHeight: '20px',
+ padding: '5px 10px',
+ textAlign: 'center',
+ whiteSpace: 'nowrap',
+ position: 'absolute',
+
+ '& .tooltip-title': {
+ borderBottom: `1px solid ${theme.palette.secondary[400]}`,
+ color: 'white',
+ },
+ '& .tooltip-text': {
+ color: 'white',
+ margin: 0,
+ },
+ '& .tooltip-subtitle': {
+ color: theme.palette.secondary[300],
+ margin: 0,
+ },
+
+ '&.left': {
+ left: '40px',
+ },
+ '&.right': {
+ right: '40px',
+ },
+ },
+ },
+}));
+
+// round up to the nearest 10
+const getMax = (list, getter) =>
+ Math.max(Math.ceil(max(list, getter) / 10) * 10, 10);
+
+/**
+ * Given analytics stat, performs computations needed to plot a line chart.
+
+ * @param {array} dataset List of data of the form {date, webVisit, lineVisit}
+ * @param {number} width Width of plot area
+ * @param {number} height Height of plot area
+
+ * @return {
+ xScale: {d3.scaleTime}
+ yScaleWeb: {d3.scaleLinear}
+ yScaleLine: {d3.scaleLinear}
+ chatbotDots: {array} List of corresponding positions for each chatbot entry
+ chatbotLine: {d3.line}
+ webDots: {array} List of corresponding positions for each web entry
+ webLine: {d3.line}
+ }
+ */
+const computeChartData = (dataset, width, height) => {
+ const maxWebVisit = getMax(dataset, d => d.webVisit);
+ const maxLineVisit = getMax(dataset, d => d.lineVisit);
+
+ const xScale = scaleTime()
+ .domain([dataset[0].date, dataset[dataset.length - 1].date])
+ .range([0, width]);
+
+ const yScaleWeb = scaleLinear()
+ .domain([0, maxWebVisit])
+ .range([height, 0]);
+
+ const yScaleLine = scaleLinear()
+ .domain([0, maxLineVisit])
+ .range([height, 0]);
+
+ const webLine = line()
+ .x(d => xScale(d.date))
+ .y(d => yScaleWeb(d.webVisit))
+ .curve(curveMonotoneX);
+
+ const chatbotLine = line()
+ .x(d => xScale(d.date))
+ .y(d => yScaleLine(d.lineVisit))
+ .curve(curveMonotoneX);
+
+ const webDots = dataset.map(d => ({
+ value: d.webVisit,
+ date: d.date,
+ cx: xScale(d.date),
+ cy: yScaleWeb(d.webVisit),
+ }));
+ const chatbotDots = dataset.map(d => ({
+ value: d.lineVisit,
+ date: d.date,
+ cx: xScale(d.date),
+ cy: yScaleLine(d.lineVisit),
+ }));
+
+ return {
+ xScale,
+ yScaleWeb,
+ yScaleLine,
+ chatbotDots,
+ chatbotLine,
+ webDots,
+ webLine,
+ };
+};
+
+/* Renders gridline and axis tick label. */
+function TickGroup({ x, y, gridline, text }) {
+ return (
+
+
+
+ {text.text}
+
+
+ );
+}
+/* Populates a list of tick values given a scale and number of ticks wanted. */
+const getTicks = (scale, roundFn, tickNum) => {
+ const [max, min] = scale.domain();
+ const diff = (max - min) / (tickNum - 1);
+ let ticks = [];
+ for (let i = 0; i < tickNum; i++) {
+ ticks.push(roundFn(max - diff * i));
+ }
+ return ticks;
+};
+
+const formatDate = (date, withYear = false) => {
+ const d = new Date(date);
+ const dateString = `${d.getMonth() + 1}/${d.getDate()}`; // MM/DD
+ if (withYear) {
+ return `${d.getFullYear()}/${dateString}`;
+ }
+ return dateString;
+};
+
+function plotTicks({ scale, x, y, text, roundFn, tickNum, gridline }) {
+ const ticks = getTicks(scale, roundFn, tickNum);
+ return ticks.map((tick, i) => (
+
+ ));
+}
+
+const getVisitText = visitNum =>
+ c('LineChart').ngettext(
+ msgid`${visitNum} time`,
+ `${visitNum} times`,
+ visitNum
+ );
+
+export default function LineChart({ dataset, width, margin }) {
+ const classes = useStyles();
+ const height = Math.max(Math.ceil(width / 6), 75);
+
+ const innerWidth = width - margin.left - margin.right;
+ const innerHeight = height - margin.top - margin.bottom;
+
+ const boxWidth = innerWidth / (dataset.length - 1);
+
+ const chartData = computeChartData(dataset, innerWidth, innerHeight);
+ return (
+
+
+
+ {dataset.map((d, i) => (
+
+
+
+
+
+
{formatDate(d.date, true)}
+
+
{t`Web Visit`}
+
{getVisitText(d.webVisit)}
+
{t`Line Inquiry`}
+
{getVisitText(d.lineVisit)}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/LineChart.stories.js b/components/LineChart.stories.js
new file mode 100644
index 00000000..306e8c8f
--- /dev/null
+++ b/components/LineChart.stories.js
@@ -0,0 +1,48 @@
+import React from 'react';
+
+import LineChart from './LineChart';
+export default {
+ title: 'LineChart',
+ component: 'LineChart',
+};
+
+const data = [
+ { date: new Date('2020-07-07'), webVisit: 277, lineVisit: 5 },
+ { date: new Date('2020-07-08'), webVisit: 2969, lineVisit: 29 },
+ { date: new Date('2020-07-09'), webVisit: 14865, lineVisit: 84 },
+ { date: new Date('2020-07-10'), webVisit: 18923, lineVisit: 71 },
+ { date: new Date('2020-07-11'), webVisit: 8213, lineVisit: 44 },
+ { date: new Date('2020-07-12'), webVisit: 2981, lineVisit: 29 },
+ { date: new Date('2020-07-13'), webVisit: 927, lineVisit: 24 },
+ { date: new Date('2020-07-14'), webVisit: 366, lineVisit: 6 },
+ { date: new Date('2020-07-15'), webVisit: 360, lineVisit: 6 },
+ { date: new Date('2020-07-16'), webVisit: 434, lineVisit: 8 },
+ { date: new Date('2020-07-17'), webVisit: 227, lineVisit: 13 },
+ { date: new Date('2020-07-18'), webVisit: 78, lineVisit: 2 },
+ { date: new Date('2020-07-19'), webVisit: 92, lineVisit: 2 },
+ { date: new Date('2020-07-20'), webVisit: 50, lineVisit: 2 },
+ { date: new Date('2020-07-21'), webVisit: 50, lineVisit: 0 },
+ { date: new Date('2020-07-22'), webVisit: 42, lineVisit: 0 },
+ { date: new Date('2020-07-23'), webVisit: 55, lineVisit: 0 },
+ { date: new Date('2020-07-24'), webVisit: 45, lineVisit: 0 },
+ { date: new Date('2020-07-25'), webVisit: 36, lineVisit: 0 },
+ { date: new Date('2020-07-26'), webVisit: 36, lineVisit: 0 },
+ { date: new Date('2020-07-27'), webVisit: 18, lineVisit: 0 },
+ { date: new Date('2020-07-28'), webVisit: 18, lineVisit: 2 },
+ { date: new Date('2020-07-29'), webVisit: 19, lineVisit: 0 },
+ { date: new Date('2020-07-30'), webVisit: 19, lineVisit: 0 },
+ { date: new Date('2020-07-31'), webVisit: 1, lineVisit: 0 },
+ { date: new Date('2020-08-01'), webVisit: 6, lineVisit: 0 },
+ { date: new Date('2020-08-02'), webVisit: 3, lineVisit: 0 },
+ { date: new Date('2020-08-03'), webVisit: 6, lineVisit: 0 },
+ { date: new Date('2020-08-04'), webVisit: 4, lineVisit: 0 },
+ { date: new Date('2020-08-05'), webVisit: 6, lineVisit: 0 },
+ { date: new Date('2020-08-06'), webVisit: 0, lineVisit: 0 },
+];
+export const Normal = () => (
+
+);
diff --git a/components/TrendPlot.js b/components/TrendPlot.js
new file mode 100644
index 00000000..dfec5318
--- /dev/null
+++ b/components/TrendPlot.js
@@ -0,0 +1,164 @@
+import { t } from 'ttag';
+import { useState } from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { makeStyles } from '@material-ui/core/styles';
+import {
+ startOfDay,
+ eachDayOfInterval,
+ subDays,
+ addDays,
+ differenceInDays,
+} from 'date-fns';
+import LineChart from './LineChart';
+import KeyboardArrowUpIcon from '@material-ui/icons/KeyboardArrowUp';
+import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
+import Box from '@material-ui/core/Box';
+import Hidden from '@material-ui/core/Hidden';
+
+const CHART_DURATION = 31;
+
+const margin = { top: 10, left: 40, right: 20, bottom: 20 };
+
+const useStyles = makeStyles(theme => ({
+ label: {
+ fontSize: 14,
+ display: 'inline-block',
+ margin: '0 5px',
+
+ [theme.breakpoints.down('xs')]: {
+ margin: '0 2px',
+ },
+
+ '&.plotLabel': {
+ color: theme.palette.secondary[500],
+ },
+ '&.webLabel': {
+ color: theme.palette.info.main,
+ },
+ '&.lineLabel': {
+ color: theme.palette.primary.main,
+ },
+ '&.totalLabel': {
+ color: theme.palette.secondary[200],
+ textAlign: 'right',
+ marginTop: '-7px',
+
+ '& .MuiSvgIcon-root': {
+ transform: 'translate(0px, 6px)',
+ width: 14,
+ },
+ },
+ },
+ root: {
+ flex: 1,
+ height: '100%',
+ marginBottom: '20px',
+ },
+}));
+
+/**
+ * Push zero entries to dataset for dates between `start` and `end`.
+ */
+const fillEmptyDates = (start, end, dataset) => {
+ eachDayOfInterval({ start, end }).forEach(date =>
+ dataset.push({ date, webVisit: 0, lineVisit: 0 })
+ );
+};
+
+/**
+ * Given analytics stat, populate dataset for last 31 days.
+ */
+const populateChartData = data => {
+ let dataset = [];
+ const endDate = startOfDay(new Date());
+ const startDate = subDays(endDate, CHART_DURATION - 1);
+ const firstDateInData =
+ data && data.length > 0
+ ? startOfDay(new Date(data[0].date))
+ : addDays(endDate, 1);
+ let lastProcessedDate;
+ let totalWebVisits = 0;
+ let totalLineVisits = 0;
+
+ // fill in zeros if first date in data is less than 31 days ago.
+ if (firstDateInData > startDate) {
+ fillEmptyDates(startDate, subDays(firstDateInData, 1), dataset);
+ lastProcessedDate = subDays(firstDateInData, 1);
+ }
+
+ if (data) {
+ data.forEach(d => {
+ const date = startOfDay(new Date(d.date));
+ // if there's a gap between dates, fill with zeros.
+ if (differenceInDays(date, lastProcessedDate) > 1) {
+ fillEmptyDates(
+ addDays(lastProcessedDate, 1),
+ subDays(date, 1),
+ dataset
+ );
+ }
+ const webVisit = +d.webVisit || 0;
+ const lineVisit = +d.lineVisit || 0;
+ dataset.push({ date, webVisit, lineVisit });
+ totalWebVisits += webVisit;
+ totalLineVisits += lineVisit;
+ lastProcessedDate = date;
+ });
+ }
+
+ // if last processed date is before current date, fill the gap with zeros.
+ if (lastProcessedDate < endDate) {
+ fillEmptyDates(addDays(lastProcessedDate, 1), endDate, dataset);
+ }
+
+ return { dataset, totalLineVisits, totalWebVisits };
+};
+
+export default function TrendPlot({ data }) {
+ const classes = useStyles();
+ const [showPlot, setPlotShow] = useState(true);
+
+ const { dataset, totalLineVisits, totalWebVisits } = populateChartData(data);
+ const totalVisits = totalWebVisits + totalLineVisits;
+
+ return (
+
+
+ {t`past 31 days`}
+
+
+ {t`Web Visit: ${totalWebVisits}`}
+
+
+ {t`Line Inquery: ${totalLineVisits}`}
+
+
+
+
+ {t`Web: ${totalWebVisits}`}
+
+
+ {t`Line: ${totalLineVisits}`}
+
+
+
+ {t`Total Visit: ${totalVisits}`}
+ {showPlot ? (
+ setPlotShow(false)} />
+ ) : (
+ setPlotShow(true)} />
+ )}
+
+
+
+ {showPlot && (
+
+ {({ width }) => (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/components/TrendPlot.stories.js b/components/TrendPlot.stories.js
new file mode 100644
index 00000000..2289a4f8
--- /dev/null
+++ b/components/TrendPlot.stories.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import { startOfDay, eachDayOfInterval, subDays } from 'date-fns';
+import TrendPlot from './TrendPlot';
+export default {
+ title: 'TrendPlot',
+ component: 'TrendPlot',
+};
+const stats = [
+ null,
+ null,
+ { webVisit: '2969', lineVisit: '29' },
+ { webVisit: '14865', lineVisit: '84' },
+ { webVisit: '18923', lineVisit: '71' },
+ { webVisit: '8213', lineVisit: '44' },
+ { webVisit: '2981', lineVisit: '29' },
+ { webVisit: '927', lineVisit: '24' },
+ { webVisit: '366', lineVisit: '6' },
+ { webVisit: '360', lineVisit: '6' },
+ { webVisit: '434', lineVisit: '8' },
+ { webVisit: '227', lineVisit: '13' },
+ { webVisit: '78', lineVisit: '2' },
+ { webVisit: '92', lineVisit: '2' },
+ { webVisit: '50', lineVisit: '2' },
+ { webVisit: '50' },
+ { webVisit: '42' },
+ { webVisit: '55' },
+ { webVisit: '45' },
+ { webVisit: '36' },
+ { webVisit: '36' },
+ { webVisit: '18' },
+ { webVisit: '18', lineVisit: '2' },
+ { webVisit: '19' },
+ { webVisit: '19' },
+ null,
+ { webVisit: '2' },
+ { webVisit: '6' },
+ { webVisit: '3' },
+ { webVisit: '6' },
+];
+
+const populateData = () => {
+ let data = [];
+ const today = startOfDay(new Date());
+ eachDayOfInterval({
+ start: subDays(today, 29),
+ end: subDays(today, 1),
+ }).forEach((date, i) => {
+ if (stats[i])
+ data.push({ ...stats[i], date: date.toISOString().substr(0, 10) });
+ });
+ return data;
+};
+
+export const Normal = () => (
+
+
+
+);
diff --git a/components/__snapshots__/LineChart.stories.storyshot b/components/__snapshots__/LineChart.stories.storyshot
new file mode 100644
index 00000000..127129aa
--- /dev/null
+++ b/components/__snapshots__/LineChart.stories.storyshot
@@ -0,0 +1,3030 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Storyshots LineChart Normal 1`] = `
+
+
+
+
+
+
+
+
+
+
+ 2020/7/7
+
+
+
+ Web Visit
+
+
+ 277 times
+
+
+ Line Inquiry
+
+
+ 5 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/8
+
+
+
+ Web Visit
+
+
+ 2969 times
+
+
+ Line Inquiry
+
+
+ 29 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/9
+
+
+
+ Web Visit
+
+
+ 14865 times
+
+
+ Line Inquiry
+
+
+ 84 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/10
+
+
+
+ Web Visit
+
+
+ 18923 times
+
+
+ Line Inquiry
+
+
+ 71 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/11
+
+
+
+ Web Visit
+
+
+ 8213 times
+
+
+ Line Inquiry
+
+
+ 44 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/12
+
+
+
+ Web Visit
+
+
+ 2981 times
+
+
+ Line Inquiry
+
+
+ 29 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/13
+
+
+
+ Web Visit
+
+
+ 927 times
+
+
+ Line Inquiry
+
+
+ 24 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/14
+
+
+
+ Web Visit
+
+
+ 366 times
+
+
+ Line Inquiry
+
+
+ 6 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/15
+
+
+
+ Web Visit
+
+
+ 360 times
+
+
+ Line Inquiry
+
+
+ 6 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/16
+
+
+
+ Web Visit
+
+
+ 434 times
+
+
+ Line Inquiry
+
+
+ 8 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/17
+
+
+
+ Web Visit
+
+
+ 227 times
+
+
+ Line Inquiry
+
+
+ 13 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/18
+
+
+
+ Web Visit
+
+
+ 78 times
+
+
+ Line Inquiry
+
+
+ 2 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/19
+
+
+
+ Web Visit
+
+
+ 92 times
+
+
+ Line Inquiry
+
+
+ 2 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/20
+
+
+
+ Web Visit
+
+
+ 50 times
+
+
+ Line Inquiry
+
+
+ 2 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/21
+
+
+
+ Web Visit
+
+
+ 50 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/22
+
+
+
+ Web Visit
+
+
+ 42 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/23
+
+
+
+ Web Visit
+
+
+ 55 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/24
+
+
+
+ Web Visit
+
+
+ 45 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/25
+
+
+
+ Web Visit
+
+
+ 36 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/26
+
+
+
+ Web Visit
+
+
+ 36 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/27
+
+
+
+ Web Visit
+
+
+ 18 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/28
+
+
+
+ Web Visit
+
+
+ 18 times
+
+
+ Line Inquiry
+
+
+ 2 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/29
+
+
+
+ Web Visit
+
+
+ 19 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/30
+
+
+
+ Web Visit
+
+
+ 19 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/7/31
+
+
+
+ Web Visit
+
+
+ 1 time
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/8/1
+
+
+
+ Web Visit
+
+
+ 6 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/8/2
+
+
+
+ Web Visit
+
+
+ 3 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/8/3
+
+
+
+ Web Visit
+
+
+ 6 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/8/4
+
+
+
+ Web Visit
+
+
+ 4 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/8/5
+
+
+
+ Web Visit
+
+
+ 6 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+
+
+
+ 2020/8/6
+
+
+
+ Web Visit
+
+
+ 0 times
+
+
+ Line Inquiry
+
+
+ 0 times
+
+
+
+
+
+
+
+`;
diff --git a/components/__snapshots__/TrendPlot.stories.storyshot b/components/__snapshots__/TrendPlot.stories.storyshot
new file mode 100644
index 00000000..5988821a
--- /dev/null
+++ b/components/__snapshots__/TrendPlot.stories.storyshot
@@ -0,0 +1,217 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Storyshots TrendPlot Normal 1`] = `
+
+
+
+
+
+ past 31 days
+
+
+
+
+
+
+
+
+ Total Visit: 51156
+
+
+
+
+
+
+
+`;
diff --git a/i18n/zh_TW.po b/i18n/zh_TW.po
index cd3038d7..b604e880 100644
--- a/i18n/zh_TW.po
+++ b/i18n/zh_TW.po
@@ -1087,4 +1087,53 @@ msgstr "等你來答"
#: components/AppLayout/AppHeader.js:188
msgctxt "App header"
msgid "Forum"
-msgstr "討論區"
\ No newline at end of file
+msgstr "討論區"
+
+#: components/LineChart.js:221
+msgctxt "LineChart"
+msgid "${ visitNum } time"
+msgid_plural "${ visitNum } times"
+msgstr[0] "${ visitNum } 次"
+msgstr[1] "${ visitNum } 次"
+
+#: components/LineChart.js:329
+msgid "Web Visit"
+msgstr "網頁瀏覽"
+
+#: components/LineChart.js:333
+msgid "Line Inquiry"
+msgstr "Line 詢問"
+
+#: components/LineChart.js:343
+#: components/LineChart.js:345
+msgid "Line"
+msgstr "詢問"
+
+#: components/TrendPlot.js:127
+msgid "past 31 days"
+msgstr "近31日"
+
+#: components/TrendPlot.js:130
+#, javascript-format
+msgid "Web Visit: ${ totalWebVisits }"
+msgstr "網頁瀏覽 ${ totalWebVisits } 次"
+
+#: components/TrendPlot.js:138
+#, javascript-format
+msgid "Web: ${ totalWebVisits }"
+msgstr "瀏覽 ${ totalWebVisits } 次"
+
+#: components/TrendPlot.js:133
+#, javascript-format
+msgid "Line Inquery: ${ totalLineVisits }"
+msgstr "Line 詢問 ${ totalLineVisits } 次"
+
+#: components/TrendPlot.js:141
+#, javascript-format
+msgid "Line: ${ totalLineVisits }"
+msgstr "詢問 ${ totalLineVisits } 次"
+
+#: components/TrendPlot.js:145
+#, javascript-format
+msgid "Total Visit: ${ totalVisits }"
+msgstr "${ totalVisits } 次瀏覽"
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index d34360a3..0cb1d777 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11261,6 +11261,270 @@
"type": "^1.0.1"
}
},
+ "d3": {
+ "version": "5.16.0",
+ "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz",
+ "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==",
+ "requires": {
+ "d3-array": "1",
+ "d3-axis": "1",
+ "d3-brush": "1",
+ "d3-chord": "1",
+ "d3-collection": "1",
+ "d3-color": "1",
+ "d3-contour": "1",
+ "d3-dispatch": "1",
+ "d3-drag": "1",
+ "d3-dsv": "1",
+ "d3-ease": "1",
+ "d3-fetch": "1",
+ "d3-force": "1",
+ "d3-format": "1",
+ "d3-geo": "1",
+ "d3-hierarchy": "1",
+ "d3-interpolate": "1",
+ "d3-path": "1",
+ "d3-polygon": "1",
+ "d3-quadtree": "1",
+ "d3-random": "1",
+ "d3-scale": "2",
+ "d3-scale-chromatic": "1",
+ "d3-selection": "1",
+ "d3-shape": "1",
+ "d3-time": "1",
+ "d3-time-format": "2",
+ "d3-timer": "1",
+ "d3-transition": "1",
+ "d3-voronoi": "1",
+ "d3-zoom": "1"
+ }
+ },
+ "d3-array": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
+ "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="
+ },
+ "d3-axis": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz",
+ "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ=="
+ },
+ "d3-brush": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.5.tgz",
+ "integrity": "sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==",
+ "requires": {
+ "d3-dispatch": "1",
+ "d3-drag": "1",
+ "d3-interpolate": "1",
+ "d3-selection": "1",
+ "d3-transition": "1"
+ }
+ },
+ "d3-chord": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz",
+ "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==",
+ "requires": {
+ "d3-array": "1",
+ "d3-path": "1"
+ }
+ },
+ "d3-collection": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz",
+ "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A=="
+ },
+ "d3-color": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz",
+ "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q=="
+ },
+ "d3-contour": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz",
+ "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==",
+ "requires": {
+ "d3-array": "^1.1.1"
+ }
+ },
+ "d3-dispatch": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
+ "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="
+ },
+ "d3-drag": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz",
+ "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==",
+ "requires": {
+ "d3-dispatch": "1",
+ "d3-selection": "1"
+ }
+ },
+ "d3-dsv": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz",
+ "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==",
+ "requires": {
+ "commander": "2",
+ "iconv-lite": "0.4",
+ "rw": "1"
+ }
+ },
+ "d3-ease": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.6.tgz",
+ "integrity": "sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ=="
+ },
+ "d3-fetch": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz",
+ "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==",
+ "requires": {
+ "d3-dsv": "1"
+ }
+ },
+ "d3-force": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz",
+ "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==",
+ "requires": {
+ "d3-collection": "1",
+ "d3-dispatch": "1",
+ "d3-quadtree": "1",
+ "d3-timer": "1"
+ }
+ },
+ "d3-format": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.4.tgz",
+ "integrity": "sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw=="
+ },
+ "d3-geo": {
+ "version": "1.12.1",
+ "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz",
+ "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==",
+ "requires": {
+ "d3-array": "1"
+ }
+ },
+ "d3-hierarchy": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
+ "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ=="
+ },
+ "d3-interpolate": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
+ "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
+ "requires": {
+ "d3-color": "1"
+ }
+ },
+ "d3-path": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
+ "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
+ },
+ "d3-polygon": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz",
+ "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ=="
+ },
+ "d3-quadtree": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz",
+ "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA=="
+ },
+ "d3-random": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz",
+ "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ=="
+ },
+ "d3-scale": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz",
+ "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==",
+ "requires": {
+ "d3-array": "^1.2.0",
+ "d3-collection": "1",
+ "d3-format": "1",
+ "d3-interpolate": "1",
+ "d3-time": "1",
+ "d3-time-format": "2"
+ }
+ },
+ "d3-scale-chromatic": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz",
+ "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==",
+ "requires": {
+ "d3-color": "1",
+ "d3-interpolate": "1"
+ }
+ },
+ "d3-selection": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz",
+ "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg=="
+ },
+ "d3-shape": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
+ "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
+ "requires": {
+ "d3-path": "1"
+ }
+ },
+ "d3-time": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
+ "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="
+ },
+ "d3-time-format": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.3.tgz",
+ "integrity": "sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==",
+ "requires": {
+ "d3-time": "1"
+ }
+ },
+ "d3-timer": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz",
+ "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw=="
+ },
+ "d3-transition": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz",
+ "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==",
+ "requires": {
+ "d3-color": "1",
+ "d3-dispatch": "1",
+ "d3-ease": "1",
+ "d3-interpolate": "1",
+ "d3-selection": "^1.1.0",
+ "d3-timer": "1"
+ }
+ },
+ "d3-voronoi": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz",
+ "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg=="
+ },
+ "d3-zoom": {
+ "version": "1.8.3",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz",
+ "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==",
+ "requires": {
+ "d3-dispatch": "1",
+ "d3-drag": "1",
+ "d3-interpolate": "1",
+ "d3-selection": "1",
+ "d3-transition": "1"
+ }
+ },
"dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
@@ -23615,6 +23879,11 @@
}
}
},
+ "react-virtualized-auto-sizer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz",
+ "integrity": "sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg=="
+ },
"reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
@@ -24293,6 +24562,11 @@
"aproba": "^1.1.1"
}
},
+ "rw": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
+ "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q="
+ },
"rxjs": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.2.tgz",
diff --git a/package.json b/package.json
index 355162b1..56a88835 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"clsx": "^1.0.4",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.6.5",
+ "d3": "^5.16.0",
"date-fns": "^2.0.1",
"dotenv": "^8.0.0",
"emoji-mart": "^3.0.0",
@@ -44,6 +45,7 @@
"pm2": "^4.2.3",
"react": "^16.8.6",
"react-dom": "^16.8.6",
+ "react-virtualized-auto-sizer": "^1.0.2",
"rollbar": "^2.14.4",
"stackimpact": "^1.3.23",
"ttag": "^1.7.22"
diff --git a/pages/article/[id].js b/pages/article/[id].js
index a7923238..ac839ee2 100644
--- a/pages/article/[id].js
+++ b/pages/article/[id].js
@@ -26,7 +26,7 @@ import NewReplySection from 'components/NewReplySection';
import ArticleInfo from 'components/ArticleInfo';
import ArticleCategories from 'components/ArticleCategories';
import cx from 'clsx';
-import Trendline from 'components/Trendline';
+import TrendPlot from 'components/TrendPlot';
const useStyles = makeStyles(theme => ({
root: {
@@ -177,6 +177,11 @@ const LOAD_ARTICLE = gql`
...ArticleCategoryData
...AddCategoryDialogData
}
+ stats {
+ date
+ webVisit
+ lineVisit
+ }
}
}
${Hyperlinks.fragments.HyperlinkData}
@@ -342,7 +347,7 @@ function ArticlePage() {
({ status }) => status === 'NORMAL'
)}
/>
-
+