├── .eslintignore ├── src ├── vite-env.d.ts ├── app │ ├── routes │ │ ├── routes.ts │ │ ├── RequireApiClient │ │ │ └── index.tsx │ │ └── AppRoutes │ │ │ └── index.tsx │ ├── themes │ │ ├── AppThemeProvider │ │ │ └── index.tsx │ │ ├── GlobalStyle │ │ │ └── index.tsx │ │ └── theme.ts │ ├── App │ │ └── index.tsx │ ├── pages │ │ ├── ApiKeyInputPage │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ │ └── PrefecturePopulationPage │ │ │ ├── usePrefecturePopulations.ts │ │ │ ├── LoadingPrefecturesPanel │ │ │ └── index.tsx │ │ │ ├── usePrefectureSelections.ts │ │ │ ├── PrefectureSelector │ │ │ └── index.tsx │ │ │ ├── PopulationGraph │ │ │ ├── index.tsx │ │ │ └── useHighCharts.ts │ │ │ ├── index.stories.tsx │ │ │ └── index.tsx │ └── layouts │ │ ├── UninitializedPageLayout │ │ └── index.tsx │ │ └── PageLayout │ │ ├── ErrorFallback │ │ └── index.tsx │ │ └── index.tsx ├── libs │ ├── Headline │ │ ├── index.tsx │ │ └── index.stories.tsx │ ├── TextField │ │ ├── index.stories.tsx │ │ └── index.tsx │ ├── TopAppBar │ │ ├── index.stories.tsx │ │ └── index.tsx │ └── Button │ │ ├── index.stories.tsx │ │ └── index.tsx ├── mocks │ ├── browser.ts │ ├── handlers.ts │ └── resolvers │ │ ├── pop30.json │ │ ├── pop24.json │ │ ├── pop25.json │ │ ├── pop26.json │ │ ├── pop27.json │ │ ├── pop28.json │ │ ├── pop29.json │ │ ├── mockPrefectures.ts │ │ └── mockPopulations.ts ├── api │ ├── usePrefecturesQuery.ts │ ├── resas │ │ ├── useResasClient.ts │ │ ├── useResasApiKey.ts │ │ ├── ResasApiKeyProvider.tsx │ │ └── ResasClient.ts │ ├── ApiClientBoundary.tsx │ ├── usePopulationsQueries.ts │ ├── useApiClientInitializer.ts │ └── ApiClientProvider.tsx ├── types.ts ├── main.tsx └── emotion.d.ts ├── .storybook ├── preview-head.html ├── main.js └── preview.jsx ├── .husky └── pre-commit ├── .prettierrc ├── tsconfig.node.json ├── README.md ├── vite.config.ts ├── .gitignore ├── index.html ├── docs ├── index.html ├── favicon.svg └── mockServiceWorker.js ├── tsconfig.json ├── .circleci └── config.yml ├── public ├── favicon.svg └── mockServiceWorker.js ├── .eslintrc.js ├── package.json └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | docs 2 | .eslintrc.js 3 | vite.config.ts 4 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /src/app/routes/routes.ts: -------------------------------------------------------------------------------- 1 | export const route = { 2 | mainPage: '/', 3 | apiKeyInputPage: '/apikey', 4 | }; 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /src/libs/Headline/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | 4 | export const Headline = styled.h2( 5 | ({ theme }) => css` 6 | ${theme.fonts.headlineM} 7 | `, 8 | ); 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yumemi-frontend 2 | 3 | フロントエンドの勉強にちょうど良さそうなのでやってみる 4 | 5 | - [フロントエンドコーディング試験](https://notion.yumemi.co.jp/0e9ef27b55704d7882aab55cc86c999d) 6 | - [ワイヤーフレーム](https://yumemi.notion.site/7646721865fa47e7b2c9b2a52c8c40ac) 7 | -------------------------------------------------------------------------------- /src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw'; 2 | 3 | import { handlers } from './handlers'; 4 | 5 | const worker = setupWorker(...handlers); 6 | 7 | export async function startMockWorker() { 8 | await worker.start({ 9 | onUnhandledRequest: 'bypass', 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/api/usePrefecturesQuery.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { useResasClient } from 'src/api/resas/useResasClient'; 3 | 4 | export const usePrefecturesQuery = () => { 5 | const resasClient = useResasClient(); 6 | return useQuery('prefectures', () => resasClient.fetchPrefectures()); 7 | }; 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import tsconfigPaths from 'vite-tsconfig-paths'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: '/yumemi-frontend/', 8 | plugins: [react(), tsconfigPaths()], 9 | }); 10 | -------------------------------------------------------------------------------- /src/libs/Headline/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; 2 | import { Headline } from 'src/libs/Headline/index'; 3 | 4 | export default { 5 | component: Headline, 6 | } as ComponentMeta; 7 | 8 | export const Default: ComponentStoryObj = { 9 | args: { 10 | children: '総人口推移グラフ', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Prefecture = { 2 | prefCode: number; 3 | prefName: string; 4 | }; 5 | 6 | export type PrefectureSelection = Prefecture & { 7 | selected: boolean; 8 | }; 9 | 10 | export type PopulationPerYear = { 11 | year: number; 12 | value: number; 13 | }; 14 | 15 | export type PrefecturePopulation = Prefecture & { 16 | populations: PopulationPerYear[]; 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Storybook 27 | storybook-static/ 28 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 都道府県別総人口推移グラフ 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/libs/TextField/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; 2 | import { TextField } from 'src/libs/TextField/index'; 3 | 4 | export default { 5 | component: TextField, 6 | } as ComponentMeta; 7 | 8 | export const Default: ComponentStoryObj = { 9 | args: { 10 | type: 'password', 11 | placeholder: 'RESAS-APIキー', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { mockPopulations } from 'src/mocks/resolvers/mockPopulations'; 3 | import { mockPrefectures } from 'src/mocks/resolvers/mockPrefectures'; 4 | 5 | export const handlers = [ 6 | rest.get('https://opendata.resas-portal.go.jp/api/v1/prefectures', mockPrefectures), 7 | rest.get('https://opendata.resas-portal.go.jp/api/v1/population/composition/perYear', mockPopulations), 8 | ]; 9 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { App } from 'src/app/App'; 4 | 5 | if (import.meta.env.DEV && !import.meta.env.VITE_WITHOUT_MSW) { 6 | const { startMockWorker } = await import('./mocks/browser'); 7 | await startMockWorker(); 8 | } 9 | 10 | ReactDOM.createRoot(document.getElementById('root')!).render( 11 | 12 | 13 | , 14 | ); 15 | -------------------------------------------------------------------------------- /src/api/resas/useResasClient.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { ResasClient } from 'src/api/resas/ResasClient'; 3 | import { useResasApiKey } from 'src/api/resas/useResasApiKey'; 4 | 5 | export const useResasClient = () => { 6 | const [resasApiKey] = useResasApiKey(); 7 | if (!resasApiKey) { 8 | throw new Error('No ResasApiKey set, useApiClientInitializer to set one'); 9 | } 10 | return useMemo(() => new ResasClient(resasApiKey), [resasApiKey]); 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/themes/AppThemeProvider/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from '@emotion/react'; 2 | import { ReactNode } from 'react'; 3 | import { GlobalStyle } from 'src/app/themes/GlobalStyle'; 4 | import { theme } from 'src/app/themes/theme'; 5 | 6 | type Props = { 7 | children: ReactNode; 8 | }; 9 | 10 | export const AppThemeProvider = ({ children }: Props) => ( 11 | 12 | 13 | {children} 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 都道府県別総人口推移グラフ 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/libs/TopAppBar/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; 2 | import { TopAppBar } from 'src/libs/TopAppBar/index'; 3 | 4 | export default { 5 | component: TopAppBar, 6 | } as ComponentMeta; 7 | 8 | export const TitleOnly: ComponentStoryObj = { 9 | args: { 10 | title: '都道府県別総人口推移グラフ', 11 | onBack: undefined, 12 | }, 13 | }; 14 | 15 | export const WithBackAction: ComponentStoryObj = { 16 | args: { 17 | title: '都道府県別総人口推移グラフ', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/api/resas/useResasApiKey.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useContext } from 'react'; 2 | import { ResasApiKeyContext, SetResasApiKeyContext } from 'src/api/resas/ResasApiKeyProvider'; 3 | 4 | export const useResasApiKey = (): [string | undefined, Dispatch>] => { 5 | const setResasApiKey = useContext(SetResasApiKeyContext); 6 | if (!setResasApiKey) { 7 | throw new Error('The ResasApiKeyProvider is missing.'); 8 | } 9 | const resasApiKey = useContext(ResasApiKeyContext); 10 | return [resasApiKey, setResasApiKey]; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { MemoryRouter } from 'react-router-dom'; 2 | import { ApiClientProvider } from 'src/api/ApiClientProvider'; 3 | import { AppRoutes } from 'src/app/routes/AppRoutes'; 4 | import { AppThemeProvider } from 'src/app/themes/AppThemeProvider'; 5 | 6 | export const App = () => ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/app/routes/RequireApiClient/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement } from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | import { useApiClientInitializer } from 'src/api/useApiClientInitializer'; 4 | import { route } from 'src/app/routes/routes'; 5 | 6 | type Props = { 7 | children: ReactElement; 8 | }; 9 | 10 | export const RequireApiClient: FC = ({ children }) => { 11 | const { isInitialized } = useApiClientInitializer(); 12 | if (!isInitialized) { 13 | return ; 14 | } 15 | return children; 16 | }; 17 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 5 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions'], 6 | framework: '@storybook/react', 7 | core: { 8 | builder: '@storybook/builder-vite', 9 | }, 10 | features: { 11 | storyStoreV7: true, 12 | }, 13 | viteFinal: async (config) => { 14 | config.resolve.alias = { 15 | ...(config.resolve.alias || {}), 16 | src: path.resolve(__dirname, '../src'), 17 | }; 18 | return config; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/api/ApiClientBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ErrorBoundary, ErrorBoundaryPropsWithRender } from 'react-error-boundary'; 3 | import { useQueryErrorResetBoundary } from 'react-query'; 4 | 5 | type Props = { 6 | children: ReactNode; 7 | renderErrorFallback: ErrorBoundaryPropsWithRender['fallbackRender']; 8 | }; 9 | 10 | export const ApiClientBoundary = ({ children, renderErrorFallback }: Props) => { 11 | const { reset } = useQueryErrorResetBoundary(); 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /.storybook/preview.jsx: -------------------------------------------------------------------------------- 1 | import { AppThemeProvider } from 'src/app/themes/AppThemeProvider'; 2 | import { startMockWorker } from 'src/mocks/browser'; 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | startMockWorker(); 6 | 7 | export const parameters = { 8 | actions: { argTypesRegex: '^on[A-Z].*' }, 9 | controls: { 10 | matchers: { 11 | color: /(background|color)$/i, 12 | date: /Date$/, 13 | }, 14 | }, 15 | }; 16 | 17 | export const decorators = [ 18 | (Story) => ( 19 | 20 | 21 | 22 | 23 | 24 | ), 25 | ]; 26 | -------------------------------------------------------------------------------- /src/api/usePopulationsQueries.ts: -------------------------------------------------------------------------------- 1 | import { useQueries } from 'react-query'; 2 | import { useResasClient } from 'src/api/resas/useResasClient'; 3 | import { PopulationPerYear, Prefecture, PrefecturePopulation } from 'src/types'; 4 | 5 | export const usePopulationsQueries = (prefectures: Prefecture[]) => { 6 | const resasClient = useResasClient(); 7 | return useQueries( 8 | prefectures.map((it) => ({ 9 | queryKey: ['population', it.prefCode], 10 | queryFn: () => resasClient.fetchPopulations(it.prefCode), 11 | select: (populations: PopulationPerYear[]): PrefecturePopulation => ({ 12 | ...it, 13 | populations, 14 | }), 15 | })), 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "." 19 | }, 20 | "include": ["src", "vite.config.ts"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /src/app/routes/AppRoutes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Route, Routes } from 'react-router-dom'; 2 | import { ApiKeyInputPage } from 'src/app/pages/ApiKeyInputPage'; 3 | import { PrefecturePopulationPage } from 'src/app/pages/PrefecturePopulationPage'; 4 | import { RequireApiClient } from 'src/app/routes/RequireApiClient'; 5 | import { route } from 'src/app/routes/routes'; 6 | 7 | export const AppRoutes = () => ( 8 | 9 | 13 | 14 | 15 | } 16 | /> 17 | } /> 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/libs/TextField/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | 4 | export const TextField = styled.input( 5 | ({ theme }) => css` 6 | background-color: transparent; 7 | border-radius: 4px; 8 | border: none; 9 | outline: solid 1px ${theme.colors.outline}; 10 | padding: 14px 16px; 11 | 12 | ${theme.fonts.bodyL} 13 | 14 | height: 56px; 15 | width: 100%; 16 | 17 | color: ${theme.colors.onSurface}; 18 | caret-color: ${theme.colors.primary}; 19 | 20 | ::placeholder { 21 | color: ${theme.colors.onSurfaceVariant}; 22 | } 23 | 24 | :focus { 25 | outline: solid 2px ${theme.colors.primary}; 26 | } 27 | `, 28 | ); 29 | -------------------------------------------------------------------------------- /src/app/pages/ApiKeyInputPage/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; 2 | import { ApiClientProvider } from 'src/api/ApiClientProvider'; 3 | import { UninitializedPageLayout } from 'src/app/layouts/UninitializedPageLayout'; 4 | import { Presentation } from 'src/app/pages/ApiKeyInputPage/index'; 5 | 6 | export default { 7 | component: Presentation, 8 | } as ComponentMeta; 9 | 10 | export const Default: ComponentStoryObj = { 11 | decorators: [ 12 | (Story) => ( 13 | 14 | 15 | 16 | 17 | 18 | ), 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /src/app/layouts/UninitializedPageLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { ReactNode } from 'react'; 3 | import { TopAppBar } from 'src/libs/TopAppBar'; 4 | 5 | const Wrapper = styled.div` 6 | div:first-of-type { 7 | margin-bottom: 24px; 8 | } 9 | `; 10 | 11 | type PresentationProps = { 12 | children: ReactNode; 13 | }; 14 | 15 | export const Presentation = ({ children }: PresentationProps) => ( 16 | 17 |
18 | 19 |
20 |
{children}
21 |
22 | ); 23 | 24 | type Props = { 25 | children: ReactNode; 26 | }; 27 | 28 | export const UninitializedPageLayout = ({ children }: Props) => {children}; 29 | -------------------------------------------------------------------------------- /src/libs/Button/index.stories.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentMeta, ComponentStoryObj } from '@storybook/react'; 2 | import { MdArrowForward, MdReplay } from 'react-icons/all'; 3 | import { Button } from 'src/libs/Button/index'; 4 | 5 | export default { 6 | component: Button, 7 | } as ComponentMeta; 8 | 9 | export const LabelOnly: ComponentStoryObj = { 10 | args: { 11 | label: 'Button', 12 | }, 13 | }; 14 | 15 | export const WithStartIcon: ComponentStoryObj = { 16 | args: { 17 | label: 'リトライ', 18 | startIcon: , 19 | }, 20 | }; 21 | 22 | export const WithEndIcon: ComponentStoryObj = { 23 | args: { 24 | label: '利用開始', 25 | endIcon: , 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/app/pages/PrefecturePopulationPage/usePrefecturePopulations.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { usePopulationsQueries } from 'src/api/usePopulationsQueries'; 3 | import { PrefectureSelection } from 'src/types'; 4 | 5 | export const usePrefecturePopulations = (prefectureSelections: PrefectureSelection[]) => { 6 | const queryResults = usePopulationsQueries(prefectureSelections.filter((it) => it.selected)); 7 | const isLoading = queryResults.some((result) => result.isLoading); 8 | const prefecturePopulations = useMemo(() => queryResults.flatMap((result) => result.data || []), [queryResults]); 9 | return useMemo( 10 | () => ({ 11 | isLoading, 12 | prefecturePopulations, 13 | }), 14 | [isLoading, prefecturePopulations], 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop30.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 1002191 }, 3 | { "year": 1965, "value": 1026975 }, 4 | { "year": 1970, "value": 1042736 }, 5 | { "year": 1975, "value": 1072118 }, 6 | { "year": 1980, "value": 1087012 }, 7 | { "year": 1985, "value": 1087206 }, 8 | { "year": 1990, "value": 1074325 }, 9 | { "year": 1995, "value": 1080435 }, 10 | { "year": 2000, "value": 1069912 }, 11 | { "year": 2005, "value": 1035969 }, 12 | { "year": 2010, "value": 1002198 }, 13 | { "year": 2015, "value": 963579 }, 14 | { "year": 2020, "value": 921152 }, 15 | { "year": 2025, "value": 875553 }, 16 | { "year": 2030, "value": 829087 }, 17 | { "year": 2035, "value": 781816 }, 18 | { "year": 2040, "value": 734325 }, 19 | { "year": 2045, "value": 688031 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop24.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 1485054 }, 3 | { "year": 1965, "value": 1514467 }, 4 | { "year": 1970, "value": 1543083 }, 5 | { "year": 1975, "value": 1626002 }, 6 | { "year": 1980, "value": 1686936 }, 7 | { "year": 1985, "value": 1747311 }, 8 | { "year": 1990, "value": 1792514 }, 9 | { "year": 1995, "value": 1841358 }, 10 | { "year": 2000, "value": 1857339 }, 11 | { "year": 2005, "value": 1866963 }, 12 | { "year": 2010, "value": 1854724 }, 13 | { "year": 2015, "value": 1815865 }, 14 | { "year": 2020, "value": 1768098 }, 15 | { "year": 2025, "value": 1709820 }, 16 | { "year": 2030, "value": 1645050 }, 17 | { "year": 2035, "value": 1575867 }, 18 | { "year": 2040, "value": 1503635 }, 19 | { "year": 2045, "value": 1430804 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop25.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 842695 }, 3 | { "year": 1965, "value": 853385 }, 4 | { "year": 1970, "value": 889768 }, 5 | { "year": 1975, "value": 985621 }, 6 | { "year": 1980, "value": 1079898 }, 7 | { "year": 1985, "value": 1155844 }, 8 | { "year": 1990, "value": 1222411 }, 9 | { "year": 1995, "value": 1287005 }, 10 | { "year": 2000, "value": 1342832 }, 11 | { "year": 2005, "value": 1380361 }, 12 | { "year": 2010, "value": 1410777 }, 13 | { "year": 2015, "value": 1412916 }, 14 | { "year": 2020, "value": 1409153 }, 15 | { "year": 2025, "value": 1394593 }, 16 | { "year": 2030, "value": 1371841 }, 17 | { "year": 2035, "value": 1341440 }, 18 | { "year": 2040, "value": 1304201 }, 19 | { "year": 2045, "value": 1262924 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop26.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 1993403 }, 3 | { "year": 1965, "value": 2102808 }, 4 | { "year": 1970, "value": 2250087 }, 5 | { "year": 1975, "value": 2424856 }, 6 | { "year": 1980, "value": 2527330 }, 7 | { "year": 1985, "value": 2586574 }, 8 | { "year": 1990, "value": 2602460 }, 9 | { "year": 1995, "value": 2629592 }, 10 | { "year": 2000, "value": 2644391 }, 11 | { "year": 2005, "value": 2647660 }, 12 | { "year": 2010, "value": 2636092 }, 13 | { "year": 2015, "value": 2610353 }, 14 | { "year": 2020, "value": 2573772 }, 15 | { "year": 2025, "value": 2509875 }, 16 | { "year": 2030, "value": 2430849 }, 17 | { "year": 2035, "value": 2338843 }, 18 | { "year": 2040, "value": 2238226 }, 19 | { "year": 2045, "value": 2136807 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop27.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 5504746 }, 3 | { "year": 1965, "value": 6657189 }, 4 | { "year": 1970, "value": 7620480 }, 5 | { "year": 1975, "value": 8278925 }, 6 | { "year": 1980, "value": 8473446 }, 7 | { "year": 1985, "value": 8668095 }, 8 | { "year": 1990, "value": 8734516 }, 9 | { "year": 1995, "value": 8797268 }, 10 | { "year": 2000, "value": 8805081 }, 11 | { "year": 2005, "value": 8817166 }, 12 | { "year": 2010, "value": 8865245 }, 13 | { "year": 2015, "value": 8839469 }, 14 | { "year": 2020, "value": 8732289 }, 15 | { "year": 2025, "value": 8526202 }, 16 | { "year": 2030, "value": 8262029 }, 17 | { "year": 2035, "value": 7962983 }, 18 | { "year": 2040, "value": 7649229 }, 19 | { "year": 2045, "value": 7335352 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop28.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 3906487 }, 3 | { "year": 1965, "value": 4309944 }, 4 | { "year": 1970, "value": 4667928 }, 5 | { "year": 1975, "value": 4992140 }, 6 | { "year": 1980, "value": 5144892 }, 7 | { "year": 1985, "value": 5278050 }, 8 | { "year": 1990, "value": 5405040 }, 9 | { "year": 1995, "value": 5401877 }, 10 | { "year": 2000, "value": 5550574 }, 11 | { "year": 2005, "value": 5590601 }, 12 | { "year": 2010, "value": 5588133 }, 13 | { "year": 2015, "value": 5534800 }, 14 | { "year": 2020, "value": 5443224 }, 15 | { "year": 2025, "value": 5306083 }, 16 | { "year": 2030, "value": 5139095 }, 17 | { "year": 2035, "value": 4948778 }, 18 | { "year": 2040, "value": 4742647 }, 19 | { "year": 2045, "value": 4532499 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/pop29.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "year": 1960, "value": 781058 }, 3 | { "year": 1965, "value": 825965 }, 4 | { "year": 1970, "value": 930160 }, 5 | { "year": 1975, "value": 1077491 }, 6 | { "year": 1980, "value": 1209365 }, 7 | { "year": 1985, "value": 1304866 }, 8 | { "year": 1990, "value": 1375481 }, 9 | { "year": 1995, "value": 1430862 }, 10 | { "year": 2000, "value": 1442795 }, 11 | { "year": 2005, "value": 1421310 }, 12 | { "year": 2010, "value": 1400728 }, 13 | { "year": 2015, "value": 1364316 }, 14 | { "year": 2020, "value": 1320075 }, 15 | { "year": 2025, "value": 1264574 }, 16 | { "year": 2030, "value": 1202479 }, 17 | { "year": 2035, "value": 1135578 }, 18 | { "year": 2040, "value": 1066267 }, 19 | { "year": 2045, "value": 998076 } 20 | ] 21 | -------------------------------------------------------------------------------- /src/mocks/resolvers/mockPrefectures.ts: -------------------------------------------------------------------------------- 1 | import { ResponseResolver, MockedRequest, restContext } from 'msw'; 2 | 3 | export const mockPrefectures: ResponseResolver = (req, res, ctx) => { 4 | if (req.headers.get('x-api-key') !== 'dev') { 5 | return res(ctx.status(200), ctx.json({ statusCode: '403', message: 'Forbidden.', description: '' })); 6 | } 7 | return res( 8 | ctx.status(200), 9 | ctx.json({ 10 | message: null, 11 | result: [ 12 | { prefCode: 24, prefName: '三重県' }, 13 | { prefCode: 25, prefName: '滋賀県' }, 14 | { prefCode: 26, prefName: '京都府' }, 15 | { prefCode: 27, prefName: '大阪府' }, 16 | { prefCode: 28, prefName: '兵庫県' }, 17 | { prefCode: 29, prefName: '奈良県' }, 18 | { prefCode: 30, prefName: '和歌山県' }, 19 | ], 20 | }), 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/pages/PrefecturePopulationPage/LoadingPrefecturesPanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { css, useTheme } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { BarLoader } from 'react-spinners'; 4 | 5 | const LoaderWrapper = styled.div` 6 | position: absolute; 7 | left: 0; 8 | top: 0; 9 | width: 100vw; 10 | height: 100vh; 11 | 12 | display: grid; 13 | justify-items: center; 14 | align-content: center; 15 | grid-row-gap: 15px; 16 | `; 17 | 18 | const LoadingLabel = styled.div( 19 | ({ theme }) => css` 20 | color: ${theme.colors.onSurfaceVariant}; 21 | `, 22 | ); 23 | 24 | export const LoadingPrefecturesPanel = () => { 25 | const theme = useTheme(); 26 | return ( 27 | 28 | 29 | Loading... 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/api/useApiClientInitializer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { useQueryClient } from 'react-query'; 3 | import { useResasApiKey } from 'src/api/resas/useResasApiKey'; 4 | 5 | export const useApiClientInitializer = () => { 6 | const [resasApiKey, setResasApiKey] = useResasApiKey(); 7 | const queryClient = useQueryClient(); 8 | 9 | const isInitialized = !!resasApiKey; 10 | 11 | const initialize = useCallback( 12 | (newResasApiKey: string) => { 13 | queryClient.clear(); 14 | setResasApiKey(newResasApiKey); 15 | }, 16 | [queryClient, setResasApiKey], 17 | ); 18 | 19 | const reset = useCallback(() => { 20 | queryClient.clear(); 21 | setResasApiKey(undefined); 22 | }, [queryClient, setResasApiKey]); 23 | 24 | return useMemo(() => ({ isInitialized, initialize, reset }), [isInitialized, initialize, reset]); 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/resas/ResasApiKeyProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, Dispatch, ReactNode, SetStateAction, useState } from 'react'; 2 | 3 | export const ResasApiKeyContext = createContext(undefined); 4 | 5 | export const SetResasApiKeyContext = createContext>>(() => {}); 6 | 7 | type Props = { 8 | children: ReactNode; 9 | initialResasApiKey?: string; 10 | }; 11 | 12 | export const ResasApiKeyProvider = ({ children, initialResasApiKey }: Props) => { 13 | const [apiKey, setApiKey] = useState(initialResasApiKey); 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | ResasApiKeyProvider.defaultProps = { 22 | initialResasApiKey: undefined, 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/themes/GlobalStyle/index.tsx: -------------------------------------------------------------------------------- 1 | import { css, Global, Theme, useTheme } from '@emotion/react'; 2 | import emotionReset from 'emotion-reset'; 3 | 4 | const globalStyle = (theme: Theme) => css` 5 | ${emotionReset} 6 | 7 | *, 8 | *::after, 9 | *::before { 10 | box-sizing: border-box; 11 | -moz-osx-font-smoothing: grayscale; 12 | -webkit-font-smoothing: antialiased; 13 | } 14 | 15 | body { 16 | font-family: ${theme.fonts.fontFamily}; 17 | color: ${theme.colors.onBackground}; 18 | } 19 | 20 | button, 21 | input, 22 | select, 23 | textarea { 24 | font-family: inherit; 25 | font-size: 100%; 26 | } 27 | 28 | button { 29 | background-color: transparent; 30 | border: none; 31 | cursor: pointer; 32 | outline: none; 33 | padding: 0; 34 | appearance: none; 35 | } 36 | `; 37 | 38 | export const GlobalStyle = () => { 39 | const theme = useTheme(); 40 | return ; 41 | }; 42 | -------------------------------------------------------------------------------- /src/api/ApiClientProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { QueryClient, QueryClientProvider } from 'react-query'; 3 | import { ReactQueryDevtools } from 'react-query/devtools'; 4 | import { ResasApiKeyProvider } from 'src/api/resas/ResasApiKeyProvider'; 5 | 6 | const queryClient = new QueryClient({ 7 | defaultOptions: { 8 | queries: { 9 | retry: false, 10 | refetchOnWindowFocus: false, 11 | cacheTime: Infinity, 12 | staleTime: Infinity, 13 | useErrorBoundary: true, 14 | }, 15 | }, 16 | }); 17 | 18 | type Props = { 19 | children: ReactNode; 20 | initialResasApiKey?: string; 21 | }; 22 | 23 | export const ApiClientProvider = ({ children, initialResasApiKey }: Props) => ( 24 | 25 | {children} 26 | 27 | 28 | ); 29 | 30 | ApiClientProvider.defaultProps = { 31 | initialResasApiKey: undefined, 32 | }; 33 | -------------------------------------------------------------------------------- /src/emotion.d.ts: -------------------------------------------------------------------------------- 1 | import '@emotion/react'; 2 | 3 | declare module '@emotion/react' { 4 | export interface Theme { 5 | colors: Colors; 6 | fonts: Fonts; 7 | } 8 | } 9 | 10 | interface Colors { 11 | onBackground: string; 12 | 13 | surface0: string; 14 | surface1: string; 15 | surface2: string; 16 | onSurface: string; 17 | 18 | surfaceVariant: string; 19 | onSurfaceVariant: string; 20 | 21 | primary: string; 22 | primary700: string; 23 | onPrimary: string; 24 | 25 | primaryContainer: string; 26 | onPrimaryContainer: string; 27 | 28 | outline: string; 29 | neutral100: string; 30 | neutral200: string; 31 | } 32 | 33 | interface Fonts { 34 | fontFamily: string; 35 | titleM: { 36 | fontSize: string; 37 | lineHeight: string; 38 | fontWeight: string; 39 | }; 40 | headlineM: { 41 | fontSize: string; 42 | lineHeight: string; 43 | fontWeight: string; 44 | }; 45 | bodyL: { 46 | fontSize: string; 47 | lineHeight: string; 48 | fontWeight: string; 49 | }; 50 | bodyS: { 51 | fontSize: string; 52 | lineHeight: string; 53 | fontWeight: string; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/app/pages/PrefecturePopulationPage/usePrefectureSelections.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { usePrefecturesQuery } from 'src/api/usePrefecturesQuery'; 3 | import { PrefectureSelection } from 'src/types'; 4 | 5 | export const usePrefectureSelections = () => { 6 | const { isLoading, data: prefectures } = usePrefecturesQuery(); 7 | const [prefectureSelections, setPrefectureSelections] = useState([]); 8 | 9 | useEffect(() => { 10 | if (prefectures) { 11 | const selections = prefectures.map((prefecture) => ({ ...prefecture, selected: false })); 12 | setPrefectureSelections(selections); 13 | } else { 14 | setPrefectureSelections([]); 15 | } 16 | }, [prefectures]); 17 | 18 | const togglePrefectureSelection = useCallback( 19 | (prefCode: number) => { 20 | setPrefectureSelections((prevState) => 21 | prevState.map((p) => { 22 | if (p.prefCode !== prefCode) { 23 | return p; 24 | } 25 | return { ...p, selected: !p.selected }; 26 | }), 27 | ); 28 | }, 29 | [setPrefectureSelections], 30 | ); 31 | 32 | return { isLoading, prefectureSelections, togglePrefectureSelection }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/app/themes/theme.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@emotion/react'; 2 | 3 | const colors = { 4 | onBackground: '#1f1f1f', 5 | 6 | surface0: '#ffffff', 7 | surface1: '#f6f8fc', 8 | surface2: '#f2f6fc', 9 | onSurface: '#1f1f1f', 10 | 11 | surfaceVariant: '#e1e3e1', 12 | onSurfaceVariant: '#444746', 13 | 14 | primary: '#0b57d0', 15 | primary700: '#0842a0', 16 | onPrimary: '#ffffff', 17 | 18 | primaryContainer: '#d3e3fd', 19 | onPrimaryContainer: '#041e49', 20 | 21 | outline: '#747775', 22 | neutral100: '#e3e3e3', 23 | neutral200: '#c7c7c7', 24 | }; 25 | 26 | const fonts = { 27 | fontFamily: '"Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif', 28 | titleM: { 29 | fontSize: `${16 / 16}rem`, 30 | lineHeight: `${24 / 16}rem`, 31 | fontWeight: '600', 32 | }, 33 | headlineM: { 34 | fontSize: `${28 / 16}rem`, 35 | lineHeight: `${36 / 16}rem`, 36 | fontWeight: '500', 37 | }, 38 | bodyL: { 39 | fontSize: `${16 / 16}rem`, 40 | lineHeight: `${24 / 16}rem`, 41 | fontWeight: '400', 42 | }, 43 | bodyS: { 44 | fontSize: `${12 / 16}rem`, 45 | lineHeight: `${16 / 16}rem`, 46 | fontWeight: '400', 47 | }, 48 | }; 49 | 50 | export const theme: Theme = { 51 | colors, 52 | fonts, 53 | }; 54 | -------------------------------------------------------------------------------- /src/app/layouts/PageLayout/ErrorFallback/index.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { MdReplay } from 'react-icons/all'; 4 | import { Button } from 'src/libs/Button'; 5 | import { Headline } from 'src/libs/Headline'; 6 | 7 | const ButtonContainer = styled.a` 8 | justify-self: center; 9 | `; 10 | 11 | const TextWrapper = styled.p` 12 | display: grid; 13 | grid-row-gap: 12px; 14 | `; 15 | 16 | const ErrorMessage = styled.div( 17 | ({ theme }) => css` 18 | ${theme.fonts.bodyL} 19 | `, 20 | ); 21 | 22 | const ErrorFallbackRoot = styled.div` 23 | display: grid; 24 | grid-row-gap: 24px; 25 | 26 | margin: 0 auto 0 auto; 27 | padding: 0 24px; 28 | max-width: 500px; 29 | 30 | @media (max-width: 40em) { 31 | width: 100%; 32 | min-width: 350px; 33 | } 34 | `; 35 | 36 | type Props = { 37 | onReset: () => void; 38 | }; 39 | 40 | export const ErrorFallback = ({ onReset }: Props) => ( 41 | 42 | 43 | エラーが発生しました 44 | しばらく待ってリトライするか、前の画面に戻ってAPIキーを入力しなおしてください。 45 | 46 | 47 |