Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ We use the raw data and try not to process the data to much. Just give some filt
## Demo

<div>
Desktop:
<img src="https://github.com/Hatzen/SODevSalaryGuide/blob/develop/docs/imgs/SOSalary-screenshot1.png?raw=true" width="1000">
Mobile:
<img src="https://github.com/Hatzen/SODevSalaryGuide/blob/develop/docs/imgs/SOSalary-screenshot2.png?raw=true" width="1000">
<img src="https://github.com/Hatzen/SODevSalaryGuide/blob/develop/docs/imgs/SOSalary-screenshot3.png?raw=true" width="1000">
</div>


Expand Down
Binary file modified docs/imgs/SOSalary-screenshot1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/imgs/SOSalary-screenshot2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/imgs/SOSalary-screenshot3.png
Binary file not shown.
66 changes: 54 additions & 12 deletions src/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react'
import React, { Profiler } from 'react'
import entryStore from '../stores/entryStore'
import BoxPlot from './boxplot'
import BarPlot from './barplot'
Expand All @@ -14,9 +14,12 @@ import SurveyEntry from '../model/surveyEntry'
import ConsideredDataTable from './consideredDataTable'
import CurrencyConversionTable from './currencyConversionTable'
import SalaryEstimator from './salaryEstimator'
import HistogramTab from './histogramTab'
import translationStore from '../stores/translationStore'
import controlStore from '../stores/controlStore'
import { uiStore } from '../stores/uiStore'
import { mark, measure } from '../utils/perfLogger'
import { trackRender, startFPSMonitor, logAllRenderStats } from '../utils/reactPerfMonitor'

interface AppState {
tabIndex: number
Expand Down Expand Up @@ -45,6 +48,12 @@ class App extends React.Component<Record<string, unknown>, AppState> {

componentDidMount(): void {
uiStore.initMobileDetection()
startFPSMonitor(1000)
console.log('[ReactPerf] FPS monitor started. Stats will be logged after 60s or on manual trigger.')
}

componentWillUnmount(): void {
logAllRenderStats()
}

render(): JSX.Element {
Expand Down Expand Up @@ -107,18 +116,32 @@ class App extends React.Component<Record<string, unknown>, AppState> {
}}>
<Tab label={t.salaryTab} />
<Tab label={t.participationTab} />
<Tab label={t.histogramTab} />
<Tab label={t.consideredDataTab} />
<Tab label={t.currencyRatesTab} />
<Tab label={t.estimatorTab} />
</Tabs>
</div>
<div style={{position: 'relative', top: 0, left: 0, right: 0, height: 'calc(100% - 48px)', width: '100%'}}>
<div style={{width: '100%', height: '100%', overflow: 'auto'}}>
{this.state.tabIndex === 0 ? <BoxPlot /> :
this.state.tabIndex === 1 ? <BarPlot /> :
this.state.tabIndex === 2 ? <ConsideredDataTable /> :
this.state.tabIndex === 3 ? <CurrencyConversionTable /> :
<SalaryEstimator />}
<Profiler id="BoxPlot" onRender={trackRender('BoxPlot')}>
{this.state.tabIndex === 0 ? <BoxPlot /> : null}
</Profiler>
<Profiler id="BarPlot" onRender={trackRender('BarPlot')}>
{this.state.tabIndex === 1 ? <BarPlot /> : null}
</Profiler>
<Profiler id="HistogramTab" onRender={trackRender('HistogramTab')}>
{this.state.tabIndex === 2 ? <HistogramTab /> : null}
</Profiler>
<Profiler id="ConsideredDataTable" onRender={trackRender('ConsideredDataTable')}>
{this.state.tabIndex === 3 ? <ConsideredDataTable /> : null}
</Profiler>
<Profiler id="CurrencyConversionTable" onRender={trackRender('CurrencyConversionTable')}>
{this.state.tabIndex === 4 ? <CurrencyConversionTable /> : null}
</Profiler>
<Profiler id="SalaryEstimator" onRender={trackRender('SalaryEstimator')}>
{this.state.tabIndex === 5 ? <SalaryEstimator /> : null}
</Profiler>
</div>
</div>
</Allotment.Pane>
Expand Down Expand Up @@ -153,18 +176,32 @@ class App extends React.Component<Record<string, unknown>, AppState> {
}}>
<Tab label={t.salaryTab} />
<Tab label={t.participationTab} />
<Tab label={t.histogramTab} />
<Tab label={t.consideredDataTab} />
<Tab label={t.currencyRatesTab} />
<Tab label={t.estimatorTab} />
</Tabs>
</div>
<div style={{position: 'relative', top: 0, left: 0, right: 0, height: 'calc(100% - 48px)', width: '100%'}}>
<div style={{width: '100%', height: '100%'}}>
{this.state.tabIndex === 0 ? <BoxPlot /> :
this.state.tabIndex === 1 ? <BarPlot /> :
this.state.tabIndex === 2 ? <ConsideredDataTable /> :
this.state.tabIndex === 3 ? <CurrencyConversionTable /> :
<SalaryEstimator />}
<Profiler id="BoxPlot" onRender={trackRender('BoxPlot')}>
{this.state.tabIndex === 0 ? <BoxPlot /> : null}
</Profiler>
<Profiler id="BarPlot" onRender={trackRender('BarPlot')}>
{this.state.tabIndex === 1 ? <BarPlot /> : null}
</Profiler>
<Profiler id="HistogramTab" onRender={trackRender('HistogramTab')}>
{this.state.tabIndex === 2 ? <HistogramTab /> : null}
</Profiler>
<Profiler id="ConsideredDataTable" onRender={trackRender('ConsideredDataTable')}>
{this.state.tabIndex === 3 ? <ConsideredDataTable /> : null}
</Profiler>
<Profiler id="CurrencyConversionTable" onRender={trackRender('CurrencyConversionTable')}>
{this.state.tabIndex === 4 ? <CurrencyConversionTable /> : null}
</Profiler>
<Profiler id="SalaryEstimator" onRender={trackRender('SalaryEstimator')}>
{this.state.tabIndex === 5 ? <SalaryEstimator /> : null}
</Profiler>
</div>
</div>
</div>
Expand All @@ -190,6 +227,7 @@ class App extends React.Component<Record<string, unknown>, AppState> {
<Autocomplete
options={['en', 'de']}
value={controlStore.language ?? 'en'}
disableClearable
onChange={(_event, value) => {
if (value) {
controlStore.setLanguage(value as 'en' | 'de')
Expand All @@ -210,7 +248,11 @@ class App extends React.Component<Record<string, unknown>, AppState> {
}

private changeTab = (event: React.ChangeEvent<unknown>, newValue: number | string): void => {
this.setState({tabIndex: Number(newValue)})
mark('app-change-tab-start')
this.setState({tabIndex: Number(newValue)}, () => {
const duration = measure('app-change-tab', 'app-change-tab-start')
console.log(`[PERF] Tab change: ${duration.toFixed(0)}ms, tab=${Number(newValue)}`)
})
}

private toggleControls(): void {
Expand Down
3 changes: 2 additions & 1 deletion src/components/appBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const LanguageSelector: React.FC = () => {
<Autocomplete
options={['en', 'de']}
value={controlStore.language ?? 'en'}
disableClearable
onChange={(_event, value) => {
if (value) {
controlStore.setLanguage(value as 'en' | 'de')
Expand Down Expand Up @@ -66,7 +67,7 @@ class MenuAppBar extends React.Component<MenuAppBarProps> {
<AppBar position='static'>
<Toolbar variant={isMobile ? 'dense' : 'regular'}>
<IconButton onClick={this.props.menuClicked} color='inherit' aria-label='Menu'>
<MenuIcon />
<MenuIcon visibility={isMobile ? 'visible' : 'hidden'}/>
</IconButton>
<Typography variant={isMobile ? 'subtitle1' : 'h5'} noWrap sx={{ flexGrow: 1 }}>
{t.title}
Expand Down
2 changes: 1 addition & 1 deletion src/components/barplot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class BarPlot extends React.Component<StoreProps> {
const invalidNumbers = [yearEntrySet.invalidEntryCount]
const matchingFilterNumbers = [filteredYearList.length]

return [
return [
{
y: matchingFilterNumbers,
name: 'matching filter',
Expand Down
26 changes: 13 additions & 13 deletions src/components/consideredDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,19 +127,19 @@ const ConsideredDataTable = observer(() => {
}
]

const renderMappedTable = (): JSX.Element => {
const rowsWithId = mappedData.map((entry, index) => {
const rawSalary = entry._salary
const entryCurrencyRatio = entryStore.currencyValues?.getRatioByCode(entry.currency) ?? 1
const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / entryCurrencyRatio
const targetCurrencyRatio = entryStore.currencyValues?.getRatioByCode(selectedCurrency) ?? 1
return {
...entry,
id: `mapped-${index}`,
convertedSalary: usdSalary * targetCurrencyRatio,
salary: rawSalary
}
})
const renderMappedTable = (): JSX.Element => {
const rowsWithId = mappedData.map((entry, index) => {
const rawSalary = entry._salary
const entryCurrencyRatio = entryStore.currencyValues?.getRatioByCode(entry.currency) ?? 1
const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / entryCurrencyRatio
const targetCurrencyRatio = entryStore.currencyValues?.getRatioByCode(selectedCurrency) ?? 1
return {
...entry,
id: `mapped-${index}`,
convertedSalary: usdSalary * targetCurrencyRatio,
salary: rawSalary
}
})

return (
<div style={{flex: 1, minHeight: 0}}>
Expand Down
39 changes: 38 additions & 1 deletion src/components/controlPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import { Checkbox, FormGroup, FormControl, Slider, Box, TextField, Typography, IconButton } from '@mui/material'
import MoreVertIcon from '@mui/icons-material/MoreVert'
import Menu from '@mui/material/Menu'
import MenuIcon from '@mui/icons-material/Menu'
import MenuItem from '@mui/material/MenuItem'
import { inject, observer } from 'mobx-react'
import { injectClause, StoreProps } from '../stores/storeHelper'
Expand Down Expand Up @@ -91,11 +92,15 @@ class ControlPane extends React.Component<StoreProps, ControlPaneState> {

get headerWithMenu(): JSX.Element {
const t = translationStore.t
const isMobile = uiStore.isMobileView
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<Typography variant="h6" style={{ fontFamily: 'Roboto, Helvetica, Arial, sans-serif' }}>
{t.filters}
</Typography>
<IconButton onClick={this.closeMenu.bind(this)} size="small">
<MenuIcon visibility={isMobile ? 'visible' : 'hidden'} />
</IconButton>
<IconButton onClick={this.handleMenuClick.bind(this)} size="small">
<MoreVertIcon />
</IconButton>
Expand All @@ -104,6 +109,10 @@ class ControlPane extends React.Component<StoreProps, ControlPaneState> {
)
}

closeMenu = (event: React.MouseEvent<HTMLElement>): void => {
uiStore.setControlPaneOpen(false)
}

handleMenuClick = (event: React.MouseEvent<HTMLElement>): void => {
this.setState({ anchorEl: event.currentTarget })
}
Expand Down Expand Up @@ -419,13 +428,33 @@ class ControlPane extends React.Component<StoreProps, ControlPaneState> {

get salaryFilter(): JSX.Element {
const t = translationStore.t
const cs = this.props.controlStore!
const salaryMin = cs.salaryThresholdMin
const salaryMax = cs.salaryThresholdMax

const slider = (
<Slider
style={{ width: '90%', minWidth: '200px' }}
value={[salaryMin, salaryMax]}
min={0}
step={10000}
max={500000}
onChange={this.handleSalaryThresholdChange.bind(this)}
valueLabelDisplay="auto"
valueLabelFormat={(value: number) => `${(value / 1000).toLocaleString()}k`}
disableSwap
color="secondary"
/>
)

return (<div style={{marginBottom: '16px'}}>
<ControlComponentWrapper
title={t.salaryFilterLabel}
controlComponent={<Typography variant="body2" style={{ color: '#666', fontSize: '0.85em' }}>{t.salaryFilterHint}</Typography>}
controlComponent={slider}
isEnabled={this.props.controlStore!.enableSalaryFilter}
enable={(event, value) => { this.props.controlStore!.setEnableSalaryFilter(value)}}>
</ControlComponentWrapper>
<Typography variant="body2" style={{ color: '#666', fontSize: '0.85em', marginTop: '10px'}}>{t.salaryFilterHint}</Typography>
</div>)
}

Expand All @@ -439,6 +468,14 @@ class ControlPane extends React.Component<StoreProps, ControlPaneState> {
this.props.controlStore!.setCompanySizeFromMax(value)
}

handleSalaryThresholdChange = (_event: Event | React.SyntheticEvent, value: number | number[]): void => {
const cs = this.props.controlStore!
if (Array.isArray(value) && value.length === 2) {
cs.setSalaryThresholdMin(value[0])
cs.setSalaryThresholdMax(value[1])
}
}

handleChangesForCountries(event: React.ChangeEvent<unknown>, value: { key: string, label: string }[]): void {
this.props.controlStore!.setCountries(value.map(v => v.key))
}
Expand Down
101 changes: 101 additions & 0 deletions src/components/histogramTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react'
import { observer } from 'mobx-react'
import Plot from 'react-plotly.js'
import { Data, Layout } from 'plotly.js'
import { uiStore } from '../stores/uiStore'
import controlStore from '../stores/controlStore'
import entryStore from '../stores/entryStore'
import translationStore from '../stores/translationStore'

const HistogramTab = observer(() => {
const t = translationStore.t
const selectedYearNum = parseInt(controlStore.selectedYear, 10)
const selectedYearData = entryStore.parsedDataByYear[selectedYearNum]
const selectedCurrency = controlStore.selectedCurrency
const filteredData = uiStore.filteredData[selectedYearNum] ?? []
const mappedData = selectedYearData?.resultSet ?? []

const isLoading = mappedData.length === 0

if (isLoading) {
return (
<div style={{textAlign: 'center', padding: '40px'}}>
<p>{t.noDataAvailable || t.salaryTab}... <br/>Loading large dataset, please wait.</p>
</div>
)
}

if (filteredData.length === 0) {
return (
<div style={{flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<p style={{ color: '#666' }}>No data available for histogram</p>
</div>
)
}

const currencyValues = entryStore.currencyValues
const salaries = filteredData.map(entry => {
const rawSalary = entry._salary
const entryCurrencyRatio = currencyValues?.getRatioByCode(entry.currency) ?? 1
const usdSalary = entry.salaryIsUsd ? rawSalary : rawSalary / entryCurrencyRatio
const targetCurrencyRatio = currencyValues?.getRatioByCode(selectedCurrency) ?? 1
return usdSalary * targetCurrencyRatio
})

const binSize = 10000
const maxSalary = Math.max(...salaries)
const maxBin = Math.max(Math.ceil(maxSalary / binSize) * binSize, binSize * 2)
const numBins = maxBin / binSize

const bins: number[] = new Array(numBins).fill(0)
salaries.forEach(salary => {
const binIndex = Math.min(Math.floor(salary / binSize), bins.length - 1)
if (binIndex >= 0 && binIndex < bins.length) {
bins[binIndex]++
}
})

const xLabels = bins.map((_, i) => {
const from = i * binSize
const to = (i + 1) * binSize
return `${(from / 1000)}k - ${(to / 1000)}k`
})

const histogramData: Data[] = [{
x: xLabels,
y: bins,
type: 'bar',
marker: { color: '#F48024' }
}]

const histogramLayout: Partial<Layout> = {
title: { text: 'Salary Distribution' },
xaxis: {
title: { text: `Salary (${selectedCurrency})` },
tickangle: -45
},
yaxis: {
title: { text: 'Number of Salaries' }
},
paper_bgcolor: '#FF000000',
plot_bgcolor: '#FF000000',
margin: {
l: 60,
r: 30,
t: 60,
b: 120
}
}

return (
<div style={{flex: 1, minHeight: 0, overflow: 'auto'}}>
<Plot
data={histogramData}
layout={histogramLayout}
style={{width: '100%', height: '100%'}}
/>
</div>
)
})

export default HistogramTab
Loading
Loading