diff --git a/src/components/BarChart.js b/src/components/BarChart.js new file mode 100644 index 0000000..ce5cd34 --- /dev/null +++ b/src/components/BarChart.js @@ -0,0 +1,474 @@ +import React from 'react'; +import { Vega } from 'react-vega'; + +const bar_color = '#00c398'; // ccv green +const bar_hover_color = '#ffc72c'; // ccv yellow + +function generateBarPlot({ data = {}, xLabel = '', yLabel = '' }) { + // Validate input JSON structure + if (!Array.isArray(data) || data.some((item) => !('label' in item && 'count' in item))) { + throw new Error("Input must be an array of objects with 'label' and 'count' properties"); + } + + // Transform the input JSON to match Vega's expected data format + const dataMap = data.map((item) => ({ + bin: item.label, // Use 'label' for the x-axis + count: item.count, // Use 'count' for the y-axis + })); + + // Define the Vega specification + const spec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + width: 800, + height: 400, + padding: 5, + data: [ + { + name: 'table', + values: dataMap, + }, + ], + + signals: [ + { + name: 'tooltip', + value: {}, + on: [ + { events: 'rect:pointerover', update: 'datum' }, + { events: 'rect:pointerout', update: '{}' }, + ], + }, + ], + + scales: [ + { + name: 'xscale', + type: 'band', + domain: { data: 'table', field: 'bin' }, + range: 'width', + padding: 0.1, + }, + { + name: 'yscale', + type: 'linear', + domain: { data: 'table', field: 'count' }, + range: 'height', + nice: true, + }, + ], + + axes: [ + { + orient: 'bottom', + scale: 'xscale', + title: xLabel, + labelAngle: -50, + labelAlign: 'right', + labelBaseline: 'top', + }, + { orient: 'left', scale: 'yscale', title: yLabel }, + ], + + marks: [ + { + type: 'rect', + from: { data: 'table' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin' }, + width: { scale: 'xscale', band: 1 }, + y: { scale: 'yscale', field: 'count' }, + y2: { scale: 'yscale', value: 0 }, + }, + update: { + fill: { value: bar_color }, + }, + hover: { + fill: { value: bar_hover_color }, + }, + }, + }, + { + type: 'text', + encode: { + enter: { + align: { value: 'center' }, + baseline: { value: 'bottom' }, + fill: { value: '#333' }, + fontSize: { value: 15 }, + }, + update: { + x: { scale: 'xscale', signal: `tooltip.bin`, band: 0.5 }, + y: { scale: 'yscale', signal: 'tooltip.count', offset: -2 }, + text: { signal: 'tooltip.count' }, + fillOpacity: [{ test: 'datum === tooltip', value: 0 }, { value: 1 }], + }, + }, + }, + ], + + config: { + title: { + fontSize: 24, + }, + axis: { + labelFontSize: 16, + titleFontSize: 20, + }, + }, + }; + + return spec; +} + +function generateCumuSumPlot({ data = {}, xLabel = '', yLabel = '' }) { + // Validate input JSON structure + if (!Array.isArray(data) || data.some((item) => !('label' in item && 'count' in item))) { + throw new Error("Input must be an array of objects with 'label' and 'count' properties"); + } + + // Transform the input JSON to match Vega's expected data format + const dataMap = data.map((item) => ({ + bin: item.label, // Use 'label' for the x-axis + count: item.count, // Use 'count' for the y-axis + })); + + // Define the Vega specification + const spec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + width: 800, + height: 400, + padding: 5, + data: [ + { + name: 'table', + values: dataMap, + transform: [ + { + type: 'window', + ops: ['sum'], + fields: ['count'], + as: ['cumulativeSum'], + }, + ], + }, + ], + signals: [ + { + name: 'hoveredPoint', + value: null, + on: [ + { events: 'symbol:mouseover', update: 'datum' }, + { events: 'symbol:mouseout', update: 'null' }, + ], + }, + ], + + scales: [ + { + name: 'xscale', + type: 'band', + domain: { data: 'table', field: 'bin' }, + range: 'width', + padding: 0.1, + }, + { + name: 'yscale', + type: 'linear', + domain: { data: 'table', field: 'cumulativeSum' }, + range: 'height', + nice: true, + }, + ], + + axes: [ + { + orient: 'bottom', + scale: 'xscale', + title: xLabel, + labelAngle: -50, + labelAlign: 'right', + labelBaseline: 'top', + }, + { orient: 'left', scale: 'yscale', title: yLabel }, + ], + + marks: [ + { + type: 'line', + from: { data: 'table' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin', band: 0.5 }, + y: { scale: 'yscale', field: 'cumulativeSum' }, + stroke: { value: 'steelblue' }, + strokeWidth: { value: 2 }, + }, + }, + }, + { + type: 'symbol', + from: { data: 'table' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin', band: 0.5 }, + y: { scale: 'yscale', field: 'cumulativeSum' }, + size: { value: 60 }, + fill: { value: 'steelblue' }, + }, + update: { + fill: { value: 'steelblue' }, + size: { value: 60 }, + }, + hover: { + fill: { value: bar_hover_color }, + size: { value: 100 }, + }, + }, + }, + { + type: 'rule', + encode: { + update: { + x: { value: 0 }, + x2: { + signal: + "hoveredPoint ? scale('xscale', hoveredPoint.bin) + (bandwidth('xscale') / 2) : 0", + }, + y: { signal: "hoveredPoint ? scale('yscale', hoveredPoint.cumulativeSum) : 0" }, + stroke: { value: 'dimgray' }, + strokeDash: { value: [4, 4] }, + strokeWidth: { value: 1.2 }, + opacity: { signal: 'hoveredPoint ? 1 : 0' }, + }, + }, + }, + ], + + config: { + title: { + fontSize: 24, + }, + axis: { + labelFontSize: 16, + titleFontSize: 20, + }, + }, + }; + + return spec; +} + +const generateBarPlotWithCumuSum = (dataJson, xLabel) => { + // Validate input JSON structure + if (!Array.isArray(dataJson) || dataJson.some((item) => !('label' in item && 'count' in item))) { + throw new Error("Input must be an array of objects with 'label' and 'count' properties"); + } + + // Transform the input JSON to match Vega's expected data format + const data = dataJson.map((item) => ({ + bin: item.label, // Use 'label' for the x-axis + count: item.count, // Use 'count' for the y-axis + })); + + // Define the Vega specification + const spec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + width: 800, + height: 400, + padding: 5, + data: [ + { + name: 'table', + values: data, + transform: [ + { + type: 'window', + ops: ['sum'], + fields: ['count'], + as: ['cumulativeSum'], + }, + ], + }, + ], + + signals: [ + { + name: 'tooltip', + value: {}, + on: [ + { events: 'rect:pointerover', update: 'datum' }, + { events: 'rect:pointerout', update: '{}' }, + ], + }, + { + name: 'hoveredPoint', + value: null, + on: [ + { events: 'symbol:mouseover', update: 'datum' }, + { events: 'symbol:mouseout', update: 'null' }, + ], + }, + ], + + scales: [ + { + name: 'xscale', + type: 'band', + domain: { data: 'table', field: 'bin' }, + range: 'width', + padding: 0.1, + }, + { + name: 'yscale', + type: 'linear', + domain: { data: 'table', field: 'count' }, + range: 'height', + nice: true, + }, + { + name: 'y2scale', + type: 'linear', + domain: { data: 'table', field: 'cumulativeSum' }, + range: 'height', + nice: true, + }, + ], + + axes: [ + { orient: 'bottom', scale: 'xscale', title: xLabel }, + { orient: 'left', scale: 'yscale', title: 'Publications' }, + { orient: 'right', scale: 'y2scale', title: 'Cumulative', grid: false }, + ], + + marks: [ + { + type: 'rect', + from: { data: 'table' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin' }, + width: { scale: 'xscale', band: 1 }, + y: { scale: 'yscale', field: 'count' }, + y2: { scale: 'yscale', value: 0 }, + }, + update: { + fill: { value: bar_color }, + }, + hover: { + fill: { value: bar_hover_color }, + }, + }, + }, + { + type: 'line', + from: { data: 'table' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin', band: 0.5 }, + y: { scale: 'y2scale', field: 'cumulativeSum' }, + stroke: { value: 'steelblue' }, + strokeWidth: { value: 2 }, + }, + }, + }, + { + type: 'symbol', + from: { data: 'table' }, + encode: { + enter: { + x: { scale: 'xscale', field: 'bin', band: 0.5 }, + y: { scale: 'y2scale', field: 'cumulativeSum' }, + size: { value: 50 }, + fill: { value: 'steelblue' }, + }, + }, + }, + { + type: 'text', + encode: { + enter: { + align: { value: 'center' }, + baseline: { value: 'bottom' }, + fill: { value: '#333' }, + fontSize: { value: 15 }, + }, + update: { + x: { scale: 'xscale', signal: 'tooltip.bin', band: 0.5 }, + y: { scale: 'yscale', signal: 'tooltip.count', offset: -2 }, + text: { signal: 'tooltip.count' }, + fillOpacity: [{ test: 'datum === tooltip', value: 0 }, { value: 1 }], + }, + }, + }, + { + type: 'rule', + encode: { + update: { + x: { scale: 'xscale', signal: 'tooltip.bin', band: 0.5 }, + x2: { signal: 'width' }, + y: { signal: "tooltip ? scale('y2scale', tooltip.cumulativeSum) : 0" }, + stroke: { value: 'dimgray' }, + strokeDash: { value: [4, 4] }, + strokeWidth: { value: 1.2 }, + opacity: { signal: 'tooltip && tooltip.bin ? 1 : 0' }, + }, + }, + }, + ], + + config: { + title: { + fontSize: 24, + }, + axis: { + labelFontSize: 16, + titleFontSize: 20, + }, + }, + }; + + return spec; +}; + +const inputJson = [ + { label: '2008', count: 1 }, + { label: '2009', count: 2 }, + { label: '2010', count: 6 }, + { label: '2011', count: 3 }, + { label: '2012', count: 8 }, + { label: '2013', count: 18 }, + { label: '2014', count: 35 }, + { label: '2015', count: 41 }, + { label: '2016', count: 35 }, + { label: '2017', count: 60 }, + { label: '2018', count: 70 }, + { label: '2019', count: 60 }, + { label: '2020', count: 70 }, + { label: '2021', count: 58 }, + { label: '2022', count: 62 }, + { label: '2023', count: 80 }, + { label: '2024', count: 5 }, +]; + +export function CountsByYearPlot({ type }) { + let vegaSpec = {}; + const xLabel = 'Year'; + if (type === 'bar') { + vegaSpec = generateBarPlot({ + data: inputJson, + xLabel: xLabel, + yLabel: 'Publications', + }); + } else if (type === 'cumu-line') { + vegaSpec = generateCumuSumPlot({ + data: inputJson, + xLabel: xLabel, + yLabel: 'Cumulative Publications', + }); + } else if (type === 'bar-cumu-line') { + vegaSpec = generateBarPlotWithCumuSum(inputJson, xLabel); + } + + return <Vega spec={vegaSpec} />; +} diff --git a/src/components/ContentPage.jsx b/src/components/ContentPage.jsx index 9300b0e..7450e75 100755 --- a/src/components/ContentPage.jsx +++ b/src/components/ContentPage.jsx @@ -5,6 +5,7 @@ import { useSelector } from 'react-redux'; import { selectUser } from '../store/slice/appState'; import { PublicationsTable } from './PublicationsTable.tsx'; import { AddPublicationModal } from './AddPublicationModal.tsx'; +import { CountsByYearPlot } from './BarChart.js'; export function ContentPage() { const user = useSelector(selectUser); @@ -29,6 +30,9 @@ export function ContentPage() { {/*<div className="viz d-flex justify-content-center pt-5">*/} {/* <WordCloud />*/} {/*<YearChart />*/} + <CountsByYearPlot type="bar" /> + <CountsByYearPlot type="cumu-line" /> + <CountsByYearPlot type="bar-cumu-line" /> {/*</div>*/} </div> );