├── .circleci
└── config.yml
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .husky
└── pre-commit
├── .prettierrc
├── .storybook
├── main.js
├── preview-head.html
└── preview.jsx
├── LICENSE
├── README.md
├── docs
├── assets
│ └── index.1272fa6a.js
├── favicon.svg
├── index.html
└── mockServiceWorker.js
├── index.html
├── package-lock.json
├── package.json
├── public
├── favicon.svg
└── mockServiceWorker.js
├── src
├── api
│ ├── ApiClientBoundary.tsx
│ ├── ApiClientProvider.tsx
│ ├── resas
│ │ ├── ResasApiKeyProvider.tsx
│ │ ├── ResasClient.ts
│ │ ├── useResasApiKey.ts
│ │ └── useResasClient.ts
│ ├── useApiClientInitializer.ts
│ ├── usePopulationsQueries.ts
│ └── usePrefecturesQuery.ts
├── app
│ ├── App
│ │ └── index.tsx
│ ├── layouts
│ │ ├── PageLayout
│ │ │ ├── ErrorFallback
│ │ │ │ └── index.tsx
│ │ │ └── index.tsx
│ │ └── UninitializedPageLayout
│ │ │ └── index.tsx
│ ├── pages
│ │ ├── ApiKeyInputPage
│ │ │ ├── index.stories.tsx
│ │ │ └── index.tsx
│ │ └── PrefecturePopulationPage
│ │ │ ├── LoadingPrefecturesPanel
│ │ │ └── index.tsx
│ │ │ ├── PopulationGraph
│ │ │ ├── index.tsx
│ │ │ └── useHighCharts.ts
│ │ │ ├── PrefectureSelector
│ │ │ └── index.tsx
│ │ │ ├── index.stories.tsx
│ │ │ ├── index.tsx
│ │ │ ├── usePrefecturePopulations.ts
│ │ │ └── usePrefectureSelections.ts
│ ├── routes
│ │ ├── AppRoutes
│ │ │ └── index.tsx
│ │ ├── RequireApiClient
│ │ │ └── index.tsx
│ │ └── routes.ts
│ └── themes
│ │ ├── AppThemeProvider
│ │ └── index.tsx
│ │ ├── GlobalStyle
│ │ └── index.tsx
│ │ └── theme.ts
├── emotion.d.ts
├── libs
│ ├── Button
│ │ ├── index.stories.tsx
│ │ └── index.tsx
│ ├── Headline
│ │ ├── index.stories.tsx
│ │ └── index.tsx
│ ├── TextField
│ │ ├── index.stories.tsx
│ │ └── index.tsx
│ └── TopAppBar
│ │ ├── index.stories.tsx
│ │ └── index.tsx
├── main.tsx
├── mocks
│ ├── browser.ts
│ ├── handlers.ts
│ └── resolvers
│ │ ├── mockPopulations.ts
│ │ ├── mockPrefectures.ts
│ │ ├── pop24.json
│ │ ├── pop25.json
│ │ ├── pop26.json
│ │ ├── pop27.json
│ │ ├── pop28.json
│ │ ├── pop29.json
│ │ └── pop30.json
├── types.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | node: circleci/node@5.0.2
5 |
6 | workflows:
7 | deploy-chromatic:
8 | jobs:
9 | - deploy-ghpages:
10 | filters:
11 | branches:
12 | only:
13 | - main
14 | - node/run:
15 | version: '18.2.0'
16 | override-ci-command: npm ci --legacy-peer-deps
17 | npm-run: chromatic
18 |
19 |
20 | jobs:
21 | deploy-ghpages:
22 | executor:
23 | name: node/default
24 | tag: '18.2.0'
25 | steps:
26 | - checkout
27 | - node/install-packages:
28 | override-ci-command: npm ci --legacy-peer-deps
29 | - run:
30 | command: |
31 | npx tsc
32 | npx vite build --outDir docs
33 | - run:
34 | command: |
35 | git config user.email "bufferings+github@gmail.com"
36 | git config user.name "CircleCI (Mitsuyuki Shiiba)"
37 | - add_ssh_keys:
38 | fingerprints:
39 | - "91:ac:ed:60:a2:be:47:cb:27:75:bc:ff:13:b0:6d:4c"
40 | - run:
41 | command: |
42 | git add docs
43 | if ! git diff --staged --quiet --; then
44 | git commit -m "[skip ci] Update GH Pages"
45 | git push origin main
46 | fi
47 |
48 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | docs
2 | .eslintrc.js
3 | vite.config.ts
4 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: [
7 | 'airbnb',
8 | 'airbnb-typescript',
9 | 'airbnb/hooks',
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:@typescript-eslint/recommended-requiring-type-checking',
12 | 'prettier',
13 | ],
14 | overrides: [
15 | {
16 | files: '*.stories.@(ts|tsx|js)',
17 | extends: 'plugin:storybook/recommended',
18 | rules: {
19 | 'import/no-default-export': 'off',
20 | 'import/no-extraneous-dependencies': 'off',
21 | 'react/jsx-props-no-spreading': 'off',
22 | },
23 | },
24 | ],
25 | ignorePatterns: ['dist', 'mockServiceWorker.js'],
26 | parser: '@typescript-eslint/parser',
27 | parserOptions: {
28 | ecmaFeatures: {
29 | jsx: true,
30 | },
31 | ecmaVersion: 'latest',
32 | sourceType: 'module',
33 | project: './tsconfig.json',
34 | },
35 | plugins: ['react', '@typescript-eslint', 'unused-imports', 'import'],
36 | rules: {
37 | 'react/react-in-jsx-scope': 'off',
38 | 'no-unused-vars': 'off',
39 | 'unused-imports/no-unused-imports': 'error',
40 | 'unused-imports/no-unused-vars': [
41 | 'warn',
42 | { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
43 | ],
44 | 'import/order': [
45 | 'error',
46 | {
47 | groups: ['builtin', 'external', 'internal', ['parent', 'sibling'], 'object', 'type', 'index'],
48 | 'newlines-between': 'always',
49 | pathGroupsExcludedImportTypes: ['builtin'],
50 | alphabetize: { order: 'asc', caseInsensitive: true },
51 | },
52 | ],
53 | 'react/function-component-definition': [
54 | 'error',
55 | {
56 | namedComponents: 'arrow-function',
57 | },
58 | ],
59 | '@typescript-eslint/no-non-null-assertion': 'off',
60 | 'import/prefer-default-export': 'off',
61 | 'import/no-default-export': 'error',
62 | },
63 | };
64 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # yumemi-frontend
2 |
3 | フロントエンドの勉強にちょうど良さそうなのでやってみる
4 |
5 | - [フロントエンドコーディング試験](https://notion.yumemi.co.jp/0e9ef27b55704d7882aab55cc86c999d)
6 | - [ワイヤーフレーム](https://yumemi.notion.site/7646721865fa47e7b2c9b2a52c8c40ac)
7 |
--------------------------------------------------------------------------------
/docs/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 都道府県別総人口推移グラフ
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/docs/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* tslint:disable */
3 |
4 | /**
5 | * Mock Service Worker (0.42.0).
6 | * @see https://github.com/mswjs/msw
7 | * - Please do NOT modify this file.
8 | * - Please do NOT serve this file on production.
9 | */
10 |
11 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'
12 | const bypassHeaderName = 'x-msw-bypass'
13 | const activeClientIds = new Set()
14 |
15 | self.addEventListener('install', function () {
16 | return self.skipWaiting()
17 | })
18 |
19 | self.addEventListener('activate', async function (event) {
20 | return self.clients.claim()
21 | })
22 |
23 | self.addEventListener('message', async function (event) {
24 | const clientId = event.source.id
25 |
26 | if (!clientId || !self.clients) {
27 | return
28 | }
29 |
30 | const client = await self.clients.get(clientId)
31 |
32 | if (!client) {
33 | return
34 | }
35 |
36 | const allClients = await self.clients.matchAll()
37 |
38 | switch (event.data) {
39 | case 'KEEPALIVE_REQUEST': {
40 | sendToClient(client, {
41 | type: 'KEEPALIVE_RESPONSE',
42 | })
43 | break
44 | }
45 |
46 | case 'INTEGRITY_CHECK_REQUEST': {
47 | sendToClient(client, {
48 | type: 'INTEGRITY_CHECK_RESPONSE',
49 | payload: INTEGRITY_CHECKSUM,
50 | })
51 | break
52 | }
53 |
54 | case 'MOCK_ACTIVATE': {
55 | activeClientIds.add(clientId)
56 |
57 | sendToClient(client, {
58 | type: 'MOCKING_ENABLED',
59 | payload: true,
60 | })
61 | break
62 | }
63 |
64 | case 'MOCK_DEACTIVATE': {
65 | activeClientIds.delete(clientId)
66 | break
67 | }
68 |
69 | case 'CLIENT_CLOSED': {
70 | activeClientIds.delete(clientId)
71 |
72 | const remainingClients = allClients.filter((client) => {
73 | return client.id !== clientId
74 | })
75 |
76 | // Unregister itself when there are no more clients
77 | if (remainingClients.length === 0) {
78 | self.registration.unregister()
79 | }
80 |
81 | break
82 | }
83 | }
84 | })
85 |
86 | // Resolve the "main" client for the given event.
87 | // Client that issues a request doesn't necessarily equal the client
88 | // that registered the worker. It's with the latter the worker should
89 | // communicate with during the response resolving phase.
90 | async function resolveMainClient(event) {
91 | const client = await self.clients.get(event.clientId)
92 |
93 | if (client.frameType === 'top-level') {
94 | return client
95 | }
96 |
97 | const allClients = await self.clients.matchAll()
98 |
99 | return allClients
100 | .filter((client) => {
101 | // Get only those clients that are currently visible.
102 | return client.visibilityState === 'visible'
103 | })
104 | .find((client) => {
105 | // Find the client ID that's recorded in the
106 | // set of clients that have registered the worker.
107 | return activeClientIds.has(client.id)
108 | })
109 | }
110 |
111 | async function handleRequest(event, requestId) {
112 | const client = await resolveMainClient(event)
113 | const response = await getResponse(event, client, requestId)
114 |
115 | // Send back the response clone for the "response:*" life-cycle events.
116 | // Ensure MSW is active and ready to handle the message, otherwise
117 | // this message will pend indefinitely.
118 | if (client && activeClientIds.has(client.id)) {
119 | ;(async function () {
120 | const clonedResponse = response.clone()
121 | sendToClient(client, {
122 | type: 'RESPONSE',
123 | payload: {
124 | requestId,
125 | type: clonedResponse.type,
126 | ok: clonedResponse.ok,
127 | status: clonedResponse.status,
128 | statusText: clonedResponse.statusText,
129 | body:
130 | clonedResponse.body === null ? null : await clonedResponse.text(),
131 | headers: serializeHeaders(clonedResponse.headers),
132 | redirected: clonedResponse.redirected,
133 | },
134 | })
135 | })()
136 | }
137 |
138 | return response
139 | }
140 |
141 | async function getResponse(event, client, requestId) {
142 | const { request } = event
143 | const requestClone = request.clone()
144 | const getOriginalResponse = () => fetch(requestClone)
145 |
146 | // Bypass mocking when the request client is not active.
147 | if (!client) {
148 | return getOriginalResponse()
149 | }
150 |
151 | // Bypass initial page load requests (i.e. static assets).
152 | // The absence of the immediate/parent client in the map of the active clients
153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
154 | // and is not ready to handle requests.
155 | if (!activeClientIds.has(client.id)) {
156 | return await getOriginalResponse()
157 | }
158 |
159 | // Bypass requests with the explicit bypass header
160 | if (requestClone.headers.get(bypassHeaderName) === 'true') {
161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers)
162 |
163 | // Remove the bypass header to comply with the CORS preflight check.
164 | delete cleanRequestHeaders[bypassHeaderName]
165 |
166 | const originalRequest = new Request(requestClone, {
167 | headers: new Headers(cleanRequestHeaders),
168 | })
169 |
170 | return fetch(originalRequest)
171 | }
172 |
173 | // Send the request to the client-side MSW.
174 | const reqHeaders = serializeHeaders(request.headers)
175 | const body = await request.text()
176 |
177 | const clientMessage = await sendToClient(client, {
178 | type: 'REQUEST',
179 | payload: {
180 | id: requestId,
181 | url: request.url,
182 | method: request.method,
183 | headers: reqHeaders,
184 | cache: request.cache,
185 | mode: request.mode,
186 | credentials: request.credentials,
187 | destination: request.destination,
188 | integrity: request.integrity,
189 | redirect: request.redirect,
190 | referrer: request.referrer,
191 | referrerPolicy: request.referrerPolicy,
192 | body,
193 | bodyUsed: request.bodyUsed,
194 | keepalive: request.keepalive,
195 | },
196 | })
197 |
198 | switch (clientMessage.type) {
199 | case 'MOCK_SUCCESS': {
200 | return delayPromise(
201 | () => respondWithMock(clientMessage),
202 | clientMessage.payload.delay,
203 | )
204 | }
205 |
206 | case 'MOCK_NOT_FOUND': {
207 | return getOriginalResponse()
208 | }
209 |
210 | case 'NETWORK_ERROR': {
211 | const { name, message } = clientMessage.payload
212 | const networkError = new Error(message)
213 | networkError.name = name
214 |
215 | // Rejecting a request Promise emulates a network error.
216 | throw networkError
217 | }
218 |
219 | case 'INTERNAL_ERROR': {
220 | const parsedBody = JSON.parse(clientMessage.payload.body)
221 |
222 | console.error(
223 | `\
224 | [MSW] Uncaught exception in the request handler for "%s %s":
225 |
226 | ${parsedBody.location}
227 |
228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
229 | `,
230 | request.method,
231 | request.url,
232 | )
233 |
234 | return respondWithMock(clientMessage)
235 | }
236 | }
237 |
238 | return getOriginalResponse()
239 | }
240 |
241 | self.addEventListener('fetch', function (event) {
242 | const { request } = event
243 | const accept = request.headers.get('accept') || ''
244 |
245 | // Bypass server-sent events.
246 | if (accept.includes('text/event-stream')) {
247 | return
248 | }
249 |
250 | // Bypass navigation requests.
251 | if (request.mode === 'navigate') {
252 | return
253 | }
254 |
255 | // Opening the DevTools triggers the "only-if-cached" request
256 | // that cannot be handled by the worker. Bypass such requests.
257 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
258 | return
259 | }
260 |
261 | // Bypass all requests when there are no active clients.
262 | // Prevents the self-unregistered worked from handling requests
263 | // after it's been deleted (still remains active until the next reload).
264 | if (activeClientIds.size === 0) {
265 | return
266 | }
267 |
268 | const requestId = uuidv4()
269 |
270 | return event.respondWith(
271 | handleRequest(event, requestId).catch((error) => {
272 | if (error.name === 'NetworkError') {
273 | console.warn(
274 | '[MSW] Successfully emulated a network error for the "%s %s" request.',
275 | request.method,
276 | request.url,
277 | )
278 | return
279 | }
280 |
281 | // At this point, any exception indicates an issue with the original request/response.
282 | console.error(
283 | `\
284 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
285 | request.method,
286 | request.url,
287 | `${error.name}: ${error.message}`,
288 | )
289 | }),
290 | )
291 | })
292 |
293 | function serializeHeaders(headers) {
294 | const reqHeaders = {}
295 | headers.forEach((value, name) => {
296 | reqHeaders[name] = reqHeaders[name]
297 | ? [].concat(reqHeaders[name]).concat(value)
298 | : value
299 | })
300 | return reqHeaders
301 | }
302 |
303 | function sendToClient(client, message) {
304 | return new Promise((resolve, reject) => {
305 | const channel = new MessageChannel()
306 |
307 | channel.port1.onmessage = (event) => {
308 | if (event.data && event.data.error) {
309 | return reject(event.data.error)
310 | }
311 |
312 | resolve(event.data)
313 | }
314 |
315 | client.postMessage(JSON.stringify(message), [channel.port2])
316 | })
317 | }
318 |
319 | function delayPromise(cb, duration) {
320 | return new Promise((resolve) => {
321 | setTimeout(() => resolve(cb()), duration)
322 | })
323 | }
324 |
325 | function respondWithMock(clientMessage) {
326 | return new Response(clientMessage.payload.body, {
327 | ...clientMessage.payload,
328 | headers: clientMessage.payload.headers,
329 | })
330 | }
331 |
332 | function uuidv4() {
333 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
334 | const r = (Math.random() * 16) | 0
335 | const v = c == 'x' ? r : (r & 0x3) | 0x8
336 | return v.toString(16)
337 | })
338 | }
339 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 都道府県別総人口推移グラフ
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "yumemi-frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "dev": "vite",
7 | "dev:without-msw": "VITE_WITHOUT_MSW=true vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview",
10 | "lint": "eslint . --ext .js,.tsx,.tsx",
11 | "lint:fix": "npm run lint -- --fix",
12 | "format": "prettier --write **/*.{js,ts,tsx,css}",
13 | "prepare": "husky install",
14 | "lint-staged": "lint-staged",
15 | "storybook": "NODE_OPTIONS=--openssl-legacy-provider start-storybook -p 6006",
16 | "build-storybook": "NODE_OPTIONS=--openssl-legacy-provider build-storybook",
17 | "chromatic": "npx chromatic --exit-zero-on-changes"
18 | },
19 | "lint-staged": {
20 | "!(docs)/**/*.{js,ts,tsx}": [
21 | "eslint --fix",
22 | "prettier --write"
23 | ],
24 | "!(docs)/**/*.css": [
25 | "prettier --write"
26 | ]
27 | },
28 | "dependencies": {
29 | "@emotion/react": "^11.9.0",
30 | "@emotion/styled": "^11.8.1",
31 | "emotion-reset": "^3.0.1",
32 | "highcharts": "^10.0.0",
33 | "highcharts-react-official": "^3.1.0",
34 | "msw": "^0.42.0",
35 | "react": "^18.0.0",
36 | "react-dom": "^18.0.0",
37 | "react-error-boundary": "^3.1.4",
38 | "react-icons": "^4.3.1",
39 | "react-loading-overlay-ts": "^2.0.0",
40 | "react-query": "^3.35.0",
41 | "react-router-dom": "^6.3.0",
42 | "react-spinners": "^0.12.0",
43 | "spin-delay": "^1.1.0"
44 | },
45 | "devDependencies": {
46 | "@babel/core": "^7.18.2",
47 | "@storybook/addon-actions": "^6.5.5",
48 | "@storybook/addon-essentials": "^6.5.5",
49 | "@storybook/addon-interactions": "^6.5.5",
50 | "@storybook/addon-links": "^6.5.5",
51 | "@storybook/builder-vite": "^0.1.35",
52 | "@storybook/react": "^6.5.5",
53 | "@storybook/testing-library": "^0.0.11",
54 | "@types/react": "^18.0.0",
55 | "@types/react-dom": "^18.0.0",
56 | "@typescript-eslint/eslint-plugin": "^5.20.0",
57 | "@typescript-eslint/parser": "^5.20.0",
58 | "@vitejs/plugin-react": "^1.3.0",
59 | "babel-loader": "^8.2.5",
60 | "chromatic": "^6.5.4",
61 | "eslint": "^8.13.0",
62 | "eslint-config-airbnb": "^19.0.4",
63 | "eslint-config-airbnb-typescript": "^17.0.0",
64 | "eslint-config-prettier": "^8.5.0",
65 | "eslint-plugin-import": "^2.26.0",
66 | "eslint-plugin-jsx-a11y": "^6.5.1",
67 | "eslint-plugin-react": "^7.29.4",
68 | "eslint-plugin-react-hooks": "^4.4.0",
69 | "eslint-plugin-storybook": "^0.5.12",
70 | "eslint-plugin-unused-imports": "^2.0.0",
71 | "husky": "^7.0.0",
72 | "lint-staged": "^12.3.8",
73 | "prettier": "^2.6.2",
74 | "typescript": "^4.6.3",
75 | "vite": "^2.9.5",
76 | "vite-tsconfig-paths": "^3.4.1"
77 | },
78 | "msw": {
79 | "workerDirectory": "public"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* tslint:disable */
3 | // noinspection JSUnusedLocalSymbols,EqualityComparisonWithCoercionJS
4 |
5 | /**
6 | * Mock Service Worker (0.42.0).
7 | * @see https://github.com/mswjs/msw
8 | * - Please do NOT modify this file.
9 | * - Please do NOT serve this file on production.
10 | */
11 |
12 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929';
13 | const bypassHeaderName = 'x-msw-bypass';
14 | const activeClientIds = new Set();
15 |
16 | self.addEventListener('install', function () {
17 | return self.skipWaiting();
18 | });
19 |
20 | self.addEventListener('activate', async function (event) {
21 | return self.clients.claim();
22 | });
23 |
24 | self.addEventListener('message', async function (event) {
25 | const clientId = event.source.id;
26 |
27 | if (!clientId || !self.clients) {
28 | return;
29 | }
30 |
31 | const client = await self.clients.get(clientId);
32 |
33 | if (!client) {
34 | return;
35 | }
36 |
37 | const allClients = await self.clients.matchAll();
38 |
39 | switch (event.data) {
40 | case 'KEEPALIVE_REQUEST': {
41 | sendToClient(client, {
42 | type: 'KEEPALIVE_RESPONSE',
43 | });
44 | break;
45 | }
46 |
47 | case 'INTEGRITY_CHECK_REQUEST': {
48 | sendToClient(client, {
49 | type: 'INTEGRITY_CHECK_RESPONSE',
50 | payload: INTEGRITY_CHECKSUM,
51 | });
52 | break;
53 | }
54 |
55 | case 'MOCK_ACTIVATE': {
56 | activeClientIds.add(clientId);
57 |
58 | sendToClient(client, {
59 | type: 'MOCKING_ENABLED',
60 | payload: true,
61 | });
62 | break;
63 | }
64 |
65 | case 'MOCK_DEACTIVATE': {
66 | activeClientIds.delete(clientId);
67 | break;
68 | }
69 |
70 | case 'CLIENT_CLOSED': {
71 | activeClientIds.delete(clientId);
72 |
73 | const remainingClients = allClients.filter((client) => {
74 | return client.id !== clientId;
75 | });
76 |
77 | // Unregister itself when there are no more clients
78 | if (remainingClients.length === 0) {
79 | self.registration.unregister();
80 | }
81 |
82 | break;
83 | }
84 | }
85 | });
86 |
87 | // Resolve the "main" client for the given event.
88 | // Client that issues a request doesn't necessarily equal the client
89 | // that registered the worker. It's with the latter the worker should
90 | // communicate with during the response resolving phase.
91 | async function resolveMainClient(event) {
92 | const client = await self.clients.get(event.clientId);
93 |
94 | if (client.frameType === 'top-level') {
95 | return client;
96 | }
97 |
98 | const allClients = await self.clients.matchAll();
99 |
100 | return allClients
101 | .filter((client) => {
102 | // Get only those clients that are currently visible.
103 | return client.visibilityState === 'visible';
104 | })
105 | .find((client) => {
106 | // Find the client ID that's recorded in the
107 | // set of clients that have registered the worker.
108 | return activeClientIds.has(client.id);
109 | });
110 | }
111 |
112 | async function handleRequest(event, requestId) {
113 | const client = await resolveMainClient(event);
114 | const response = await getResponse(event, client, requestId);
115 |
116 | // Send back the response clone for the "response:*" life-cycle events.
117 | // Ensure MSW is active and ready to handle the message, otherwise
118 | // this message will pend indefinitely.
119 | if (client && activeClientIds.has(client.id)) {
120 | (async function () {
121 | const clonedResponse = response.clone();
122 | sendToClient(client, {
123 | type: 'RESPONSE',
124 | payload: {
125 | requestId,
126 | type: clonedResponse.type,
127 | ok: clonedResponse.ok,
128 | status: clonedResponse.status,
129 | statusText: clonedResponse.statusText,
130 | body: clonedResponse.body === null ? null : await clonedResponse.text(),
131 | headers: serializeHeaders(clonedResponse.headers),
132 | redirected: clonedResponse.redirected,
133 | },
134 | });
135 | })();
136 | }
137 |
138 | return response;
139 | }
140 |
141 | async function getResponse(event, client, requestId) {
142 | const { request } = event;
143 | const requestClone = request.clone();
144 | const getOriginalResponse = () => fetch(requestClone);
145 |
146 | // Bypass mocking when the request client is not active.
147 | if (!client) {
148 | return getOriginalResponse();
149 | }
150 |
151 | // Bypass initial page load requests (i.e. static assets).
152 | // The absence of the immediate/parent client in the map of the active clients
153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
154 | // and is not ready to handle requests.
155 | if (!activeClientIds.has(client.id)) {
156 | return await getOriginalResponse();
157 | }
158 |
159 | // Bypass requests with the explicit bypass header
160 | if (requestClone.headers.get(bypassHeaderName) === 'true') {
161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers);
162 |
163 | // Remove the bypass header to comply with the CORS preflight check.
164 | delete cleanRequestHeaders[bypassHeaderName];
165 |
166 | const originalRequest = new Request(requestClone, {
167 | headers: new Headers(cleanRequestHeaders),
168 | });
169 |
170 | return fetch(originalRequest);
171 | }
172 |
173 | // Send the request to the client-side MSW.
174 | const reqHeaders = serializeHeaders(request.headers);
175 | const body = await request.text();
176 |
177 | const clientMessage = await sendToClient(client, {
178 | type: 'REQUEST',
179 | payload: {
180 | id: requestId,
181 | url: request.url,
182 | method: request.method,
183 | headers: reqHeaders,
184 | cache: request.cache,
185 | mode: request.mode,
186 | credentials: request.credentials,
187 | destination: request.destination,
188 | integrity: request.integrity,
189 | redirect: request.redirect,
190 | referrer: request.referrer,
191 | referrerPolicy: request.referrerPolicy,
192 | body,
193 | bodyUsed: request.bodyUsed,
194 | keepalive: request.keepalive,
195 | },
196 | });
197 |
198 | switch (clientMessage.type) {
199 | case 'MOCK_SUCCESS': {
200 | return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay);
201 | }
202 |
203 | case 'MOCK_NOT_FOUND': {
204 | return getOriginalResponse();
205 | }
206 |
207 | case 'NETWORK_ERROR': {
208 | const { name, message } = clientMessage.payload;
209 | const networkError = new Error(message);
210 | networkError.name = name;
211 |
212 | // Rejecting a request Promise emulates a network error.
213 | throw networkError;
214 | }
215 |
216 | case 'INTERNAL_ERROR': {
217 | const parsedBody = JSON.parse(clientMessage.payload.body);
218 |
219 | console.error(
220 | `\
221 | [MSW] Uncaught exception in the request handler for "%s %s":
222 |
223 | ${parsedBody.location}
224 |
225 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
226 | `,
227 | request.method,
228 | request.url,
229 | );
230 |
231 | return respondWithMock(clientMessage);
232 | }
233 | }
234 |
235 | return getOriginalResponse();
236 | }
237 |
238 | self.addEventListener('fetch', function (event) {
239 | const { request } = event;
240 | const accept = request.headers.get('accept') || '';
241 |
242 | // Bypass server-sent events.
243 | if (accept.includes('text/event-stream')) {
244 | return;
245 | }
246 |
247 | // Bypass navigation requests.
248 | if (request.mode === 'navigate') {
249 | return;
250 | }
251 |
252 | // Opening the DevTools triggers the "only-if-cached" request
253 | // that cannot be handled by the worker. Bypass such requests.
254 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
255 | return;
256 | }
257 |
258 | // Bypass all requests when there are no active clients.
259 | // Prevents the self-unregistered worked from handling requests
260 | // after it's been deleted (still remains active until the next reload).
261 | if (activeClientIds.size === 0) {
262 | return;
263 | }
264 |
265 | const requestId = uuidv4();
266 |
267 | return event.respondWith(
268 | handleRequest(event, requestId).catch((error) => {
269 | if (error.name === 'NetworkError') {
270 | console.warn(
271 | '[MSW] Successfully emulated a network error for the "%s %s" request.',
272 | request.method,
273 | request.url,
274 | );
275 | return;
276 | }
277 |
278 | // At this point, any exception indicates an issue with the original request/response.
279 | console.error(
280 | `\
281 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
282 | request.method,
283 | request.url,
284 | `${error.name}: ${error.message}`,
285 | );
286 | }),
287 | );
288 | });
289 |
290 | function serializeHeaders(headers) {
291 | const reqHeaders = {};
292 | headers.forEach((value, name) => {
293 | reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value;
294 | });
295 | return reqHeaders;
296 | }
297 |
298 | function sendToClient(client, message) {
299 | return new Promise((resolve, reject) => {
300 | const channel = new MessageChannel();
301 |
302 | channel.port1.onmessage = (event) => {
303 | if (event.data && event.data.error) {
304 | return reject(event.data.error);
305 | }
306 |
307 | resolve(event.data);
308 | };
309 |
310 | client.postMessage(JSON.stringify(message), [channel.port2]);
311 | });
312 | }
313 |
314 | function delayPromise(cb, duration) {
315 | return new Promise((resolve) => {
316 | setTimeout(() => resolve(cb()), duration);
317 | });
318 | }
319 |
320 | function respondWithMock(clientMessage) {
321 | return new Response(clientMessage.payload.body, {
322 | ...clientMessage.payload,
323 | headers: clientMessage.payload.headers,
324 | });
325 | }
326 |
327 | function uuidv4() {
328 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
329 | const r = (Math.random() * 16) | 0;
330 | const v = c == 'x' ? r : (r & 0x3) | 0x8;
331 | return v.toString(16);
332 | });
333 | }
334 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/api/resas/ResasClient.ts:
--------------------------------------------------------------------------------
1 | import { PopulationPerYear, Prefecture } from 'src/types';
2 |
3 | const RESAS_ENDPOINT = 'https://opendata.resas-portal.go.jp';
4 |
5 | type PrefecturesApiResponse = {
6 | result: Prefecture[];
7 | };
8 |
9 | type PopulationApiResponse = {
10 | result: {
11 | data: [{ data: PopulationPerYear[] }];
12 | };
13 | };
14 |
15 | export class ResasClient {
16 | private readonly apiKey: string;
17 |
18 | constructor(apiKey: string) {
19 | this.apiKey = apiKey;
20 | }
21 |
22 | public async fetchPrefectures() {
23 | const response = await fetch(`${RESAS_ENDPOINT}/api/v1/prefectures`, this.option());
24 | if (!response.ok) {
25 | throw new Error(`RESAS API response was not ok. status=${response.status}`);
26 | }
27 | const json = (await response.json()) as PrefecturesApiResponse;
28 | if (!json.result) {
29 | throw new Error(`RESAS API result was not ok. json=${JSON.stringify(json)}`);
30 | }
31 | return json.result;
32 | }
33 |
34 | public async fetchPopulations(prefCode: number) {
35 | const q = new URLSearchParams({ prefCode: String(prefCode), cityCode: '-' }).toString();
36 | const response = await fetch(`${RESAS_ENDPOINT}/api/v1/population/composition/perYear?${q}`, this.option());
37 | if (!response.ok) {
38 | throw new Error(`RESAS API response was not ok. status=${response.status}`);
39 | }
40 | const json = (await response.json()) as PopulationApiResponse;
41 | if (!json.result) {
42 | throw new Error(`RESAS API result was not ok. json=${JSON.stringify(json)}`);
43 | }
44 | return json.result.data[0].data;
45 | }
46 |
47 | private option() {
48 | return {
49 | headers: {
50 | 'x-api-key': this.apiKey,
51 | },
52 | };
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | } onClick={onReset} />
48 |
49 |
50 | );
51 |
--------------------------------------------------------------------------------
/src/app/layouts/PageLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { ReactNode, useCallback } from 'react';
3 | import { FallbackProps } from 'react-error-boundary';
4 | import { useNavigate } from 'react-router-dom';
5 | import { ApiClientBoundary } from 'src/api/ApiClientBoundary';
6 | import { useApiClientInitializer } from 'src/api/useApiClientInitializer';
7 | import { ErrorFallback } from 'src/app/layouts/PageLayout/ErrorFallback';
8 | import { route } from 'src/app/routes/routes';
9 | import { TopAppBar } from 'src/libs/TopAppBar';
10 |
11 | const renderErrorFallback = ({ resetErrorBoundary }: FallbackProps) => ;
12 |
13 | const Wrapper = styled.div`
14 | header:first-of-type {
15 | margin-bottom: 24px;
16 | }
17 | `;
18 |
19 | type PresentationProps = {
20 | children: ReactNode;
21 | onClickBackButton: () => void;
22 | };
23 |
24 | export const Presentation = ({ children, onClickBackButton }: PresentationProps) => (
25 |
26 |
27 | {children}
28 |
29 | );
30 |
31 | type Props = {
32 | children: ReactNode;
33 | };
34 |
35 | export const InitializedPageLayout = ({ children }: Props) => {
36 | const { reset } = useApiClientInitializer();
37 | const navigate = useNavigate();
38 |
39 | const handleResetApiKey = useCallback(() => {
40 | reset();
41 | navigate(route.apiKeyInputPage);
42 | }, [reset, navigate]);
43 |
44 | return {children} ;
45 | };
46 |
--------------------------------------------------------------------------------
/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/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/pages/ApiKeyInputPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 | import { FormEventHandler, useCallback, useState } from 'react';
4 | import { MdArrowForward } from 'react-icons/all';
5 | import { useNavigate } from 'react-router-dom';
6 | import { useApiClientInitializer } from 'src/api/useApiClientInitializer';
7 | import { UninitializedPageLayout } from 'src/app/layouts/UninitializedPageLayout';
8 | import { route } from 'src/app/routes/routes';
9 | import { Button } from 'src/libs/Button';
10 | import { Headline } from 'src/libs/Headline';
11 | import { TextField } from 'src/libs/TextField';
12 |
13 | const Wrapper = styled.div`
14 | form {
15 | margin: 0 auto 0 auto;
16 | padding: 0 24px;
17 | max-width: 500px;
18 |
19 | display: flex;
20 | flex-direction: column;
21 |
22 | div:first-of-type {
23 | margin-bottom: 12px;
24 | }
25 |
26 | div:nth-of-type(2) {
27 | margin-bottom: 24px;
28 | }
29 |
30 | div:nth-of-type(3) {
31 | margin-bottom: 24px;
32 | }
33 |
34 | div:nth-of-type(4) {
35 | display: flex;
36 | justify-content: end;
37 | }
38 |
39 | @media (max-width: 40em) {
40 | width: 100%;
41 | min-width: 350px;
42 | }
43 | }
44 | `;
45 |
46 | const Description = styled.p(
47 | ({ theme }) => css`
48 | ${theme.fonts.bodyL}
49 | `,
50 | );
51 |
52 | type PresentationProps = {
53 | onApiKeySubmit: (newResasApiKey: string) => void;
54 | };
55 |
56 | export const Presentation = ({ onApiKeySubmit }: PresentationProps) => {
57 | const [resasApiKeyInput, setResasApiKeyInput] = useState('');
58 |
59 | const handleSubmit: FormEventHandler = useCallback(
60 | (event) => {
61 | event.preventDefault();
62 | onApiKeySubmit(resasApiKeyInput);
63 | },
64 | [resasApiKeyInput, onApiKeySubmit],
65 | );
66 |
67 | return (
68 |
69 |
92 |
93 | );
94 | };
95 |
96 | const Container = () => {
97 | const { initialize } = useApiClientInitializer();
98 | const navigate = useNavigate();
99 |
100 | const handleApiKeySubmit = useCallback(
101 | (newResasApiKey: string) => {
102 | initialize(newResasApiKey);
103 | navigate(route.mainPage);
104 | },
105 | [initialize, navigate],
106 | );
107 |
108 | return ;
109 | };
110 |
111 | export const ApiKeyInputPage = () => (
112 |
113 |
114 |
115 | );
116 |
--------------------------------------------------------------------------------
/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/app/pages/PrefecturePopulationPage/PopulationGraph/index.tsx:
--------------------------------------------------------------------------------
1 | import { css, useTheme } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 | import Highcharts from 'highcharts';
4 | import HighchartsReact from 'highcharts-react-official';
5 | import { useMemo } from 'react';
6 | import LoadingOverlay from 'react-loading-overlay-ts';
7 | import { BarLoader } from 'react-spinners';
8 | import { useSpinDelay } from 'spin-delay';
9 | import { useHighCharts } from 'src/app/pages/PrefecturePopulationPage/PopulationGraph/useHighCharts';
10 | import { PrefecturePopulation } from 'src/types';
11 |
12 | const LoaderWrapper = styled.div`
13 | display: grid;
14 | justify-items: center;
15 | align-content: center;
16 | grid-row-gap: 15px;
17 | `;
18 |
19 | const LoadingLabel = styled.div(
20 | ({ theme }) => css`
21 | color: ${theme.colors.onSurfaceVariant};
22 | `,
23 | );
24 |
25 | const MyLoading = () => {
26 | const theme = useTheme();
27 | return (
28 |
29 |
30 | Loading...
31 |
32 | );
33 | };
34 |
35 | const MyLoadingOverlay = styled(LoadingOverlay)(
36 | ({ theme }) => css`
37 | .MyLoader_overlay {
38 | background: rgba(200, 200, 200, 0.5);
39 | }
40 | .MyLoader_content {
41 | color: ${theme.colors.neutral100};
42 | }
43 | `,
44 | );
45 |
46 | type Props = {
47 | isLoading: boolean;
48 | prefecturePopulations: PrefecturePopulation[];
49 | };
50 |
51 | export const PopulationGraph = ({ isLoading, prefecturePopulations }: Props) => {
52 | const showSpinner = useSpinDelay(isLoading);
53 | const theme = useTheme();
54 | const { options, colors, markerSymbols, minYear, maxYear } = useHighCharts(theme);
55 |
56 | const series = useMemo(() => {
57 | if (prefecturePopulations.length === 0) {
58 | return [{ data: [], showInLegend: false }];
59 | }
60 |
61 | return prefecturePopulations.map((it) => ({
62 | id: it.prefCode,
63 | index: it.prefCode,
64 | name: it.prefName,
65 | color: colors[it.prefCode % colors.length],
66 | data: it.populations.filter((p) => p.year >= minYear && p.year <= maxYear).map((p) => [p.year, p.value]),
67 | showInLegend: true,
68 | marker: { symbol: markerSymbols[it.prefCode % markerSymbols.length] },
69 | }));
70 | }, [prefecturePopulations, colors, markerSymbols, minYear, maxYear]);
71 |
72 | return (
73 | } classNamePrefix="MyLoader_">
74 |
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/src/app/pages/PrefecturePopulationPage/PopulationGraph/useHighCharts.ts:
--------------------------------------------------------------------------------
1 | import { Theme } from '@emotion/react';
2 | import Highcharts from 'highcharts';
3 | import { useMemo } from 'react';
4 |
5 | const minYear = 1980;
6 |
7 | const currentYear = new Date().getFullYear();
8 |
9 | const maxYear = currentYear - (currentYear % 5);
10 |
11 | const markerSymbols = ['circle', 'square', 'diamond', 'triangle', 'triangle-down'];
12 |
13 | const colors = ['#ff4b00', '#03af7a', '#005aff', '#4dc4ff', '#f6aa00', '#804000'];
14 |
15 | const createOptions = (theme: Theme): Highcharts.Options => ({
16 | chart: {
17 | backgroundColor: theme.colors.surface0,
18 | style: {
19 | fontFamily: theme.fonts.fontFamily,
20 | fontSize: '12px',
21 | },
22 | },
23 | title: {
24 | text: '',
25 | },
26 | xAxis: {
27 | title: {
28 | text: '年度',
29 | },
30 | gridLineWidth: 1,
31 | tickInterval: 5,
32 | min: minYear,
33 | max: maxYear,
34 | crosshair: true,
35 | },
36 | yAxis: {
37 | title: {
38 | text: '総人口(万人)',
39 | },
40 | minTickInterval: 50000,
41 | gridLineWidth: 1,
42 | labels: {
43 | formatter() {
44 | return `${(this.value as number) / 10000}`;
45 | },
46 | },
47 | },
48 | tooltip: {
49 | headerFormat: '',
50 | useHTML: true,
51 | formatter() {
52 | const value = (Math.round((this.y as number) / 1000) / 10).toFixed(1);
53 | return `
54 | ${this.x as number}年
55 | ${this.series.name}
56 |
57 | ${value}
58 | 万人
59 |
`;
60 | },
61 | shape: 'square',
62 | borderWidth: 0,
63 | borderRadius: 4,
64 | backgroundColor: '#fff',
65 | },
66 | legend: {
67 | layout: 'vertical',
68 | align: 'right',
69 | verticalAlign: 'middle',
70 | itemStyle: {
71 | cursor: 'default',
72 | fontWeight: 'normal',
73 | },
74 | itemHoverStyle: {
75 | fontWeight: 'bold',
76 | },
77 | itemMarginBottom: 4,
78 | },
79 | plotOptions: {
80 | series: {
81 | marker: {
82 | fillColor: '#FFFFFF',
83 | lineWidth: 2,
84 | lineColor: undefined, // inherit from series
85 | radius: 5,
86 | },
87 | events: {
88 | legendItemClick(e) {
89 | e.preventDefault();
90 | },
91 | },
92 | },
93 | },
94 | responsive: {
95 | rules: [
96 | {
97 | condition: {
98 | maxWidth: 500,
99 | },
100 | chartOptions: {
101 | legend: {
102 | layout: 'horizontal',
103 | align: 'center',
104 | verticalAlign: 'bottom',
105 | },
106 | },
107 | },
108 | ],
109 | },
110 | });
111 |
112 | export const useHighCharts = (theme: Theme) => {
113 | const options = useMemo(() => createOptions(theme), [theme]);
114 | return { options, colors, markerSymbols, minYear, maxYear };
115 | };
116 |
--------------------------------------------------------------------------------
/src/app/pages/PrefecturePopulationPage/PrefectureSelector/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 | import { PrefectureSelection } from 'src/types';
4 |
5 | type Props = {
6 | prefectureSelections: PrefectureSelection[];
7 | onToggleSelection: (prefCode: number) => void;
8 | };
9 |
10 | type PrefectureLabelProps = {
11 | selected: boolean;
12 | };
13 |
14 | const PrefectureLabel = styled.label(
15 | ({ selected, theme }) => css`
16 | grid-template-columns: 24px auto;
17 | display: grid;
18 | align-items: center;
19 | padding-left: 4px;
20 |
21 | border-radius: 4px;
22 | height: 28px;
23 | vertical-align: center;
24 |
25 | background-color: ${selected ? theme.colors.surface2 : 'inherit'};
26 |
27 | :hover {
28 | background-color: ${theme.colors.surface1};
29 | }
30 | `,
31 | );
32 |
33 | const PrefectureList = styled.div`
34 | display: grid;
35 | grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
36 | grid-gap: 1px;
37 | `;
38 |
39 | const PrefectureName = styled.span(
40 | ({ theme }) => css`
41 | ${theme.fonts.bodyL};
42 | `,
43 | );
44 |
45 | export const PrefectureSelector = ({ prefectureSelections, onToggleSelection }: Props) => (
46 |
47 | {prefectureSelections.map((it) => (
48 |
49 | onToggleSelection(it.prefCode)}
54 | />
55 | {it.prefName}
56 |
57 | ))}
58 |
59 | );
60 |
--------------------------------------------------------------------------------
/src/app/pages/PrefecturePopulationPage/index.stories.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
2 | import { ApiClientProvider } from 'src/api/ApiClientProvider';
3 | import { Presentation as PageLayout } from 'src/app/layouts/PageLayout';
4 | import { Presentation } from 'src/app/pages/PrefecturePopulationPage/index';
5 | import { usePrefecturePopulations } from 'src/app/pages/PrefecturePopulationPage/usePrefecturePopulations';
6 | import { usePrefectureSelections } from 'src/app/pages/PrefecturePopulationPage/usePrefectureSelections';
7 |
8 | type Props = {
9 | isLoadingPrefecturesParam: boolean | undefined;
10 | isLoadingPopulationsParam: boolean | undefined;
11 | };
12 |
13 | // Need to separate target component to prevent the following error:
14 | // "Error: Rendered more hooks than during the previous render."
15 | // Apparently, it's because Suspense option of useQuery is enabled.
16 | const Target = ({ isLoadingPrefecturesParam, isLoadingPopulationsParam }: Props) => {
17 | const {
18 | isLoading: isLoadingPrefectures,
19 | prefectureSelections,
20 | togglePrefectureSelection,
21 | } = usePrefectureSelections();
22 | const { isLoading: isLoadingPopulations, prefecturePopulations } = usePrefecturePopulations(prefectureSelections);
23 |
24 | return (
25 |
32 | );
33 | };
34 |
35 | export default {
36 | component: Target,
37 | } as ComponentMeta;
38 |
39 | export const Default: ComponentStoryObj = {
40 | decorators: [
41 | (Story) => (
42 |
43 | {}}>
44 |
45 |
46 |
47 | ),
48 | ],
49 | };
50 |
51 | export const OnLoadingPrefectures: ComponentStoryObj = {
52 | ...Default,
53 | args: {
54 | isLoadingPrefecturesParam: true,
55 | },
56 | };
57 |
58 | export const OnLoadingPopulations: ComponentStoryObj = {
59 | ...Default,
60 | args: {
61 | isLoadingPopulationsParam: true,
62 | },
63 | };
64 |
65 | const ThrowErrorComponent = () => {
66 | // eslint-disable-next-line @typescript-eslint/no-throw-literal
67 | throw new Error();
68 | };
69 |
70 | export const OnError: ComponentStoryObj = {
71 | ...Default,
72 | render: () => ,
73 | };
74 |
--------------------------------------------------------------------------------
/src/app/pages/PrefecturePopulationPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 | import { InitializedPageLayout } from 'src/app/layouts/PageLayout';
4 | import { LoadingPrefecturesPanel } from 'src/app/pages/PrefecturePopulationPage/LoadingPrefecturesPanel';
5 | import { PopulationGraph } from 'src/app/pages/PrefecturePopulationPage/PopulationGraph';
6 | import { PrefectureSelector } from 'src/app/pages/PrefecturePopulationPage/PrefectureSelector';
7 | import { usePrefecturePopulations } from 'src/app/pages/PrefecturePopulationPage/usePrefecturePopulations';
8 | import { usePrefectureSelections } from 'src/app/pages/PrefecturePopulationPage/usePrefectureSelections';
9 | import { Headline } from 'src/libs/Headline';
10 | import { PrefecturePopulation, PrefectureSelection } from 'src/types';
11 |
12 | const Wrapper = styled.div`
13 | padding: 0 24px;
14 | display: grid;
15 | grid-row-gap: 24px;
16 |
17 | // to enable the graph to shrink
18 | // https://github.com/highcharts/highcharts/issues/9491#issuecomment-1047591279
19 | grid-template-columns: minmax(0, 1fr);
20 |
21 | p {
22 | text-align: right;
23 | }
24 | `;
25 |
26 | const DataSource = styled.p(
27 | ({ theme }) => css`
28 | ${theme.fonts.bodyS}
29 | `,
30 | );
31 |
32 | type PresentationProps = {
33 | isLoadingPrefectures: boolean;
34 | prefectureSelections: PrefectureSelection[];
35 | onTogglePrefectureSelection: (prefCode: number) => void;
36 | isLoadingPopulations: boolean;
37 | prefecturePopulations: PrefecturePopulation[];
38 | };
39 |
40 | export const Presentation = ({
41 | isLoadingPrefectures,
42 | prefectureSelections,
43 | onTogglePrefectureSelection,
44 | isLoadingPopulations,
45 | prefecturePopulations,
46 | }: PresentationProps) => {
47 | if (isLoadingPrefectures) {
48 | return ;
49 | }
50 | return (
51 |
52 | 都道府県
53 |
54 | 総人口推移グラフ
55 |
56 | 出典:RESAS(地域経済分析システム)
57 |
58 | );
59 | };
60 |
61 | const Container = () => {
62 | const {
63 | isLoading: isLoadingPrefectures,
64 | prefectureSelections,
65 | togglePrefectureSelection,
66 | } = usePrefectureSelections();
67 | const { isLoading: isLoadingPopulations, prefecturePopulations } = usePrefecturePopulations(prefectureSelections);
68 |
69 | return (
70 |
77 | );
78 | };
79 |
80 | export const PrefecturePopulationPage = () => (
81 |
82 |
83 |
84 | );
85 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/src/app/routes/routes.ts:
--------------------------------------------------------------------------------
1 | export const route = {
2 | mainPage: '/',
3 | apiKeyInputPage: '/apikey',
4 | };
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/libs/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 | import { MouseEventHandler, ReactNode } from 'react';
4 |
5 | const StyledButton = styled.button(
6 | ({ theme }) => css`
7 | cursor: pointer;
8 | outline: none;
9 | padding: 0;
10 | appearance: none;
11 |
12 | border: none;
13 | border-radius: 32px;
14 |
15 | display: grid;
16 | grid-template-columns: 16px auto 16px;
17 | justify-content: center;
18 | align-items: center;
19 | grid-column-gap: 8px;
20 | text-align: center;
21 |
22 | height: 40px;
23 | width: 200px;
24 |
25 | background-color: ${theme.colors.primary};
26 | color: ${theme.colors.onPrimary};
27 |
28 | ${theme.fonts.titleM}
29 |
30 | :hover {
31 | background-color: ${theme.colors.primary700};
32 | }
33 |
34 | :focus {
35 | outline: solid 2px ${theme.colors.onSurface};
36 | box-shadow: inset 0 0 0 2px ${theme.colors.surface0};
37 | }
38 | `,
39 | );
40 |
41 | const Label = styled.span`
42 | grid-column-start: 2;
43 | justify-content: right;
44 | `;
45 |
46 | const IconContainer = styled.span`
47 | display: grid;
48 | `;
49 |
50 | type Props = {
51 | label: string;
52 | startIcon?: ReactNode;
53 | endIcon?: ReactNode;
54 | onClick?: MouseEventHandler;
55 | };
56 |
57 | export const Button = ({ label, startIcon, endIcon, onClick = () => {} }: Props) => (
58 |
59 | {startIcon}
60 | {label}
61 | {endIcon}
62 |
63 | );
64 |
65 | Button.defaultProps = {
66 | startIcon: null,
67 | endIcon: null,
68 | onClick: undefined,
69 | };
70 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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/libs/TopAppBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { css, useTheme } from '@emotion/react';
2 | import styled from '@emotion/styled';
3 | import { MdArrowBack } from 'react-icons/all';
4 |
5 | const TopAppBarContainer = styled.header`
6 | display: grid;
7 | grid-template-columns: 64px auto 64px;
8 | height: 64px;
9 | align-items: center;
10 | `;
11 |
12 | const TopAppBarIconContainer = styled.div`
13 | padding: 8px;
14 | `;
15 |
16 | const TopAppBarIconButton = styled.button(
17 | ({ theme }) => css`
18 | display: block;
19 |
20 | width: 48px;
21 | height: 48px;
22 |
23 | border-radius: 26px;
24 |
25 | color: ${theme.colors.onPrimary};
26 | cursor: pointer;
27 |
28 | padding: 16px;
29 |
30 | :hover {
31 | background-color: ${theme.colors.neutral100};
32 | }
33 |
34 | :active {
35 | background-color: ${theme.colors.neutral200};
36 | }
37 |
38 | :focus {
39 | width: 52px;
40 | height: 52px;
41 | margin: -2px;
42 | border: solid 2px ${theme.colors.onSurface};
43 | box-shadow: inset 0 0 0 2px ${theme.colors.surface0};
44 | }
45 | `,
46 | );
47 |
48 | const TopAppBarTitle = styled.h1(
49 | ({ theme }) => css`
50 | grid-column-start: 2;
51 | text-align: center;
52 |
53 | ${theme.fonts.titleM}
54 |
55 | color: ${theme.colors.onSurface};
56 |
57 | white-space: nowrap;
58 | overflow: hidden;
59 | text-overflow: ellipsis;
60 | `,
61 | );
62 |
63 | type Props = {
64 | title: string;
65 | onBack?: () => void;
66 | };
67 |
68 | export const TopAppBar = ({ title, onBack }: Props) => {
69 | const theme = useTheme();
70 | return (
71 |
72 | {onBack && (
73 |
74 |
75 |
76 |
77 |
78 | )}
79 | {title}
80 |
81 | );
82 | };
83 |
84 | TopAppBar.defaultProps = {
85 | onBack: undefined,
86 | };
87 |
--------------------------------------------------------------------------------
/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/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/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/mocks/resolvers/mockPopulations.ts:
--------------------------------------------------------------------------------
1 | import { ResponseResolver, MockedRequest, restContext } from 'msw';
2 |
3 | import pop24 from './pop24.json';
4 | import pop25 from './pop25.json';
5 | import pop26 from './pop26.json';
6 | import pop27 from './pop27.json';
7 | import pop28 from './pop28.json';
8 | import pop29 from './pop29.json';
9 | import pop30 from './pop30.json';
10 |
11 | const populations = new Map();
12 | populations.set('24', pop24);
13 | populations.set('25', pop25);
14 | populations.set('26', pop26);
15 | populations.set('27', pop27);
16 | populations.set('28', pop28);
17 | populations.set('29', pop29);
18 | populations.set('30', pop30);
19 |
20 | export const mockPopulations: ResponseResolver = (req, res, ctx) => {
21 | if (req.headers.get('x-api-key') !== 'dev') {
22 | return res(ctx.status(200), ctx.json({ statusCode: '403', message: 'Forbidden.', description: '' }));
23 | }
24 |
25 | const prefCode = req.url.searchParams.get('prefCode');
26 |
27 | if (prefCode === null) {
28 | return res(ctx.status(200), ctx.text('"400"'));
29 | }
30 |
31 | if (!populations.has(prefCode)) {
32 | return res(ctx.status(200), ctx.json({ message: null, result: null }));
33 | }
34 |
35 | return res(
36 | ctx.status(200),
37 | ctx.json({
38 | message: null,
39 | result: {
40 | boundaryYear: 2015,
41 | data: [{ label: '総人口', data: populations.get(prefCode) }],
42 | },
43 | }),
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "esnext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------