Skip to content
Open
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
26 changes: 26 additions & 0 deletions Homework2/jiazuo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# More about the Framework


This is a template in React and TypeScript. React has a steeper learning curve than Vue.js because it requires understanding JSX syntax and concepts like hooks and component lifecycle.

If you want to use React but not with TypeScript, just remove any type specifications from the `Example.tsx`, `Notes.tsx`, and `NotesWithReducer.tsx`. You can always refer to `VanillaJS-Template/example.js` for this migration.


## Files You Have to Care about

`package.json` is where we manage the libraries we installed. Besides this, most of the files you can ignore, but **the files under `./src/` are your concern**.

* `./src/main.tsx` is the root script file for React that instatinates our single page application.
* `./src/App.tsx` is the root file for all **development** needs and is also where we manage the layout and load in components.
* `./src/types.ts` is usually where we declare our customized types if you're planning to use it.
* `./src/stores/` is where we manage the stores if you're planning to use it. The store is responsible for global state management.
* `./src/components/` is where we create the components. You may have multiple components depends on your design.
* `Example.tsx` shows how to read `.csv` and `.json`, how component size is being watched, how a bar chart is created, and how the component updates if there are any changes.
* `Notes.tsx` shows the difference of **state** and **prop**, how to use MUI, and how a local state updates based on interaction.
* `NotesWithReducer.tsx` is equivalent to `Notes.tsx`, excepts it uses store called reducer.

## Libraries Installed in this Framework
* D3.js v7 for visualization
* [axios](https://axios-http.com/docs/intro) for API.
* [Material UI](https://mui.com/material-ui/getting-started/) for UI components.
* [lodash](https://lodash.com/) for utility functions in JavaScript.
16 changes: 16 additions & 0 deletions Homework2/jiazuo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Book Popularity Dashboard - ECS272 HW2</title>

<!-- TopoJSON library for geographic map -->
<script src="https://cdn.jsdelivr.net/npm/topojson@3"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
36 changes: 36 additions & 0 deletions Homework2/jiazuo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "react-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/material": "^7.3.6",
"@types/d3": "^7.4.3",
"@types/d3-sankey": "^0.12.4",
"@types/lodash": "^4.17.21",
"axios": "^1.13.2",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"lodash": "^4.17.21",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@types/material-ui": "^0.21.18",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"globals": "^17.0.0",
"typescript": "^5.9.3",
"vite": "^7.3.0"
}
}
991 changes: 991 additions & 0 deletions Homework2/jiazuo/public/data/top_1000_most_swapped_books.csv

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions Homework2/jiazuo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import GeographicMap from './components/GeographicMap';
import ScatterPlot from './components/ScatterPlot';
import SankeyDiagram from './components/SankeyDiagram';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { grey, blue } from '@mui/material/colors';

const theme = createTheme({
palette: {
primary: {
main: blue[700],
},
secondary: {
main: grey[700],
},
},
});

function Layout() {
return (
<Box
sx={{
width: '98vw',
height: '95vh',
display: 'flex',
padding: 2,
gap: 2,
backgroundColor: grey[50],
overflow: 'hidden'
}}
>
{}
<Box sx={{
flex: '0 0 60%',
display: 'flex',
flexDirection: 'column',
gap: 3,
minWidth: 0
}}>
{}
<Box sx={{ flex: '0 0 45%', minHeight: 0 }}>
<Paper elevation={3} sx={{ height: '100%', padding: 0.5 }}>
<GeographicMap />
</Paper>
</Box>

{}
<Box sx={{ flex: '1 1 55%', minHeight: 0 }}>
<Paper elevation={3} sx={{ height: '100%', padding: 0.5 }}>
<ScatterPlot />
</Paper>
</Box>
</Box>

{}
<Box sx={{ flex: '1 1 40%', minWidth: 0 }}>
<Paper elevation={3} sx={{ height: '100%', padding: 0.5 }}>
<SankeyDiagram />
</Paper>
</Box>
</Box>
);
}

function App() {
return (
<ThemeProvider theme={theme}>
<Layout />
</ThemeProvider>
);
}

export default App;
216 changes: 216 additions & 0 deletions Homework2/jiazuo/src/components/GeographicMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import React, { useEffect, useState, useRef } from 'react';
import * as d3 from 'd3';
import { useResizeObserver, useDebounceCallback } from 'usehooks-ts';
import { ComponentSize, Margin, CountryData } from '../types';
import { loadBookData, aggregateByCountry } from '../utils/dataLoader';

const COUNTRY_NAME_MAP: { [key: string]: string } = {
'USA': 'United States of America',
'UK': 'United Kingdom',
'Brazil': 'Brazil',
'India': 'India',
'Germany': 'Germany',
'France': 'France',
'Japan': 'Japan',
'China': 'China',
'Russia': 'Russia',
'Canada': 'Canada',
'Australia': 'Australia',
'Italy': 'Italy',
'Spain': 'Spain',
'Mexico': 'Mexico',
'South Korea': 'South Korea',
'Netherlands': 'Netherlands',
'Sweden': 'Sweden',
'Poland': 'Poland',
'Turkey': 'Turkey',
'Argentina': 'Argentina',
};

export default function GeographicMap() {
const [countryData, setCountryData] = useState<CountryData[]>([]);
const [geoData, setGeoData] = useState<any>(null);
const chartRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState<ComponentSize>({ width: 0, height: 0 });
const margin: Margin = { top: 45, right: 15, bottom: 50, left: 15 };

const onResize = useDebounceCallback((size: ComponentSize) => setSize(size), 200);
useResizeObserver({ ref: chartRef, onResize });

useEffect(() => {
const loadData = async () => {
const books = await loadBookData();
const aggregated = aggregateByCountry(books);
setCountryData(aggregated);

try {
const worldData = await d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json');
setGeoData(worldData);
} catch (error) {
console.error('Error loading world map:', error);
}
};
loadData();
}, []);

useEffect(() => {
if (!geoData || countryData.length === 0 || size.width === 0 || size.height === 0) return;
drawMap();
}, [geoData, countryData, size]);

function drawMap() {
d3.select('#map-svg').selectAll('*').remove();

const svg = d3.select('#map-svg');
const width = size.width - margin.left - margin.right;
const height = size.height - margin.top - margin.bottom;

const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);


const projection = d3.geoNaturalEarth1()
.scale(width / 5.5)
.translate([width / 2, height / 2])
.center([0, 15]);

const path = d3.geoPath().projection(projection);

const countryMap = new Map<string, CountryData>();
countryData.forEach(d => {
const mappedName = COUNTRY_NAME_MAP[d.country] || d.country;
countryMap.set(mappedName, d);
});

const maxCount = d3.max(countryData, d => d.count) || 1;
const colorScale = d3.scaleSequential()
.domain([0, maxCount])
.interpolator(d3.interpolateBlues);

const countries = (window as any).topojson.feature(geoData, geoData.objects.countries);

const filteredCountries = countries.features.filter((d: any) =>
d.properties.name !== 'Antarctica'
);

const tooltip = d3.select('body')
.append('div')
.attr('class', 'map-tooltip')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background-color', 'white')
.style('border', '1px solid #ddd')
.style('border-radius', '4px')
.style('padding', '8px')
.style('font-size', '11px')
.style('pointer-events', 'none')
.style('z-index', '1000')
.style('box-shadow', '0 2px 4px rgba(0,0,0,0.1)');

g.selectAll('path')
.data(filteredCountries)
.join('path')
.attr('d', path as any)
.attr('fill', (d: any) => {
const countryName = d.properties.name;
const data = countryMap.get(countryName);
return data ? colorScale(data.count) : '#f5f5f5';
})
.attr('stroke', '#999')
.attr('stroke-width', 0.5)
.on('mouseover', function(event, d: any) {
const countryName = d.properties.name;
const data = countryMap.get(countryName);

if (data) {
d3.select(this)
.attr('stroke-width', 1.5)
.attr('stroke', '#333');

tooltip
.style('visibility', 'visible')
.html(`
<strong>${countryName}</strong><br/>
Books: ${data.count}<br/>
Avg Rating: ${data.avgRating.toFixed(2)}
`);
}
})
.on('mousemove', function(event) {
tooltip
.style('top', (event.pageY - 10) + 'px')
.style('left', (event.pageX + 10) + 'px');
})
.on('mouseout', function() {
d3.select(this)
.attr('stroke-width', 0.5)
.attr('stroke', '#999');

tooltip.style('visibility', 'hidden');
});

svg.append('text')
.attr('x', size.width / 2)
.attr('y', 20)
.style('text-anchor', 'middle')
.style('font-size', '15px')
.style('font-weight', 'bold')
.text('Global Distribution of Popular Books');


const legendWidth = 280;
const legendHeight = 12;
const legendX = (size.width - legendWidth) / 2;
const legendY = size.height - 35;

const legendScale = d3.scaleLinear()
.domain([0, maxCount])
.range([0, legendWidth]);

const legendAxis = d3.axisBottom(legendScale)
.ticks(5)
.tickFormat(d3.format('d'));

const defs = svg.append('defs');
const linearGradient = defs.append('linearGradient')
.attr('id', 'legend-gradient');

const stops = d3.range(0, 1.01, 0.1);
stops.forEach(t => {
linearGradient.append('stop')
.attr('offset', `${t * 100}%`)
.attr('stop-color', colorScale(t * maxCount));
});

const legend = svg.append('g')
.attr('transform', `translate(${legendX}, ${legendY})`);

legend.append('rect')
.attr('width', legendWidth)
.attr('height', legendHeight)
.style('fill', 'url(#legend-gradient)')
.style('stroke', '#999')
.style('stroke-width', 0.5);

legend.append('g')
.attr('transform', `translate(0, ${legendHeight})`)
.call(legendAxis)
.selectAll('text')
.style('font-size', '10px');

legend.append('text')
.attr('x', legendWidth / 2)
.attr('y', -4)
.style('text-anchor', 'middle')
.style('font-size', '11px')
.style('font-weight', 'bold')
.text('Number of Books');
}

return (
<div ref={chartRef} className='chart-container' style={{ width: '100%', height: '100%' }}>
<svg id='map-svg' width='100%' height='100%'></svg>
<script src="https://cdn.jsdelivr.net/npm/topojson@3"></script>
</div>
);
}
Loading