├── src ├── styles │ ├── InfoWindow.css │ ├── ReportModal.css │ ├── App.css │ ├── index.css │ └── InfoHeader.css ├── setupTests.js ├── App.test.js ├── common │ └── constant.js ├── components │ ├── index.js │ ├── InfoMarker.js │ ├── InfoWindow.js │ ├── InfoItem.js │ ├── LocationControl.js │ ├── BaiduMap.js │ ├── ReportModal.js │ └── InfoHeader.js ├── reportWebVitals.js ├── icon │ └── index.js ├── index.js ├── logo.svg └── App.js ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── screenshot.png ├── .idea ├── .gitignore ├── vcs.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml └── rescue-viz-website.iml ├── README.md ├── package.json ├── LICENSE ├── .github └── workflows │ └── main.yml └── .gitignore /src/styles/InfoWindow.css: -------------------------------------------------------------------------------- 1 | .windowCloseBtn { 2 | float: right; 3 | padding: 1px; 4 | } -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GiveHenanAHand/henan-rescue-viz-website/HEAD/screenshot.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GiveHenanAHand/henan-rescue-viz-website/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GiveHenanAHand/henan-rescue-viz-website/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GiveHenanAHand/henan-rescue-viz-website/HEAD/public/logo512.png -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /src/common/constant.js: -------------------------------------------------------------------------------- 1 | const COLOR_MAP = { 2 | '求救': '#EC2215', 3 | '帮助': '#47AE53', 4 | '其他': '#3371BE', 5 | } 6 | 7 | const CATEGORY_MAP = { 8 | '求救': ['招物', '招人', '招地', '寻人'], 9 | '帮助': ['人力', '物资', '住所'], 10 | '其他': [], 11 | '未分类': [] 12 | } 13 | 14 | export { COLOR_MAP, CATEGORY_MAP } -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | 2 | export { default as InfoHeader } from "./InfoHeader"; 3 | export { default as InfoMarker } from "./InfoMarker"; 4 | export { default as InfoWindow } from "./InfoWindow"; 5 | export { default as LocationControl } from "./LocationControl" 6 | export { default as BaiduMap } from "./BaiduMap"; 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/styles/ReportModal.css: -------------------------------------------------------------------------------- 1 | .report-modal { 2 | 3 | } 4 | 5 | .report-modal .ant-divider { 6 | margin: 5px 0 15px; 7 | } 8 | 9 | .report-modal .info-list-category { 10 | min-width: 80px; 11 | } 12 | 13 | .report-modal .info-list-types { 14 | min-width: 120px; 15 | } 16 | 17 | .report-modal .info-item-tag-list { 18 | margin: 10px; 19 | } 20 | 21 | .report-modal .new-tag { 22 | margin-right: 5px; 23 | } -------------------------------------------------------------------------------- /src/icon/index.js: -------------------------------------------------------------------------------- 1 | export const LEFT_FOLD = ; 2 | -------------------------------------------------------------------------------- /.idea/rescue-viz-website.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './styles/index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/InfoMarker.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Marker } from "react-bmapgl"; 3 | 4 | function InfoMarker(props) { 5 | let basePosition = { 6 | lng: props.item.location.lng, 7 | lat: props.item.location.lat 8 | } 9 | 10 | function onClick(){ 11 | // console.log(props.item.id) 12 | props.onClickMarker(props.item.id) 13 | } 14 | 15 | // set top if is highlighted 16 | return ( 17 | 21 | ) 22 | } 23 | 24 | export default InfoMarker; -------------------------------------------------------------------------------- /src/styles/App.css: -------------------------------------------------------------------------------- 1 | @import '~antd/dist/antd.css'; 2 | 3 | .App { 4 | text-align: center; 5 | } 6 | 7 | .App-logo { 8 | height: 40vmin; 9 | pointer-events: none; 10 | } 11 | 12 | @media (prefers-reduced-motion: no-preference) { 13 | .App-logo { 14 | animation: App-logo-spin infinite 20s linear; 15 | } 16 | } 17 | 18 | .App-header { 19 | background-color: #282c34; 20 | min-height: 100vh; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | font-size: calc(10px + 2vmin); 26 | color: white; 27 | } 28 | 29 | .App-link { 30 | color: #61dafb; 31 | } 32 | 33 | @keyframes App-logo-spin { 34 | from { 35 | transform: rotate(0deg); 36 | } 37 | to { 38 | transform: rotate(360deg); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/InfoWindow.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, Button } from "antd"; 3 | import {CloseCircleOutlined} from '@ant-design/icons'; 4 | import {CustomOverlay} from 'react-bmapgl'; 5 | import InfoItem from "./InfoItem"; 6 | import '../styles/InfoWindow.css' 7 | 8 | function InfoWindow(props) { 9 | 10 | if (typeof (props.item) === 'undefined') return null 11 | 12 | return 15 | 16 | 19 | 20 | 21 | 22 | } 23 | 24 | export default InfoWindow 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![项目截图](https://user-images.githubusercontent.com/8768646/127113652-59e5ccd3-c877-4740-a310-dd9efc204ba2.png) 2 | 3 | 4 | 河南暴雨求助微博的地图可视化项目:https://henan.tianshili.me/ 5 | 6 | ## 关于本项目 7 | 我们通过爬虫把带有#河南暴雨求助#等相关微博获取后,使用本项目进行可视化,希望可以通过地图上的分布帮助到更多的人了解灾情的进展。 8 | 9 | ❗️❗️❗️ **由于微博信息中含有大量的隐私信息,本项目仅适合在重大突发事件下,作为传播微博上发布的紧急信息的辅助渠道。** 10 | 11 | [团队介绍与更新日志](https://u9u37118bj.feishu.cn/docs/doccn3QzzbeQLPQwNSb4Hcl2X1g#) 12 | 13 | ## 相关项目 14 | - [后台与数据处理](https://github.com/GiveHenanAHand/henan-rescue-viz-data) 15 | - [信息自动分类](https://github.com/RLSNLP/henan-rescue) 16 | 17 | 18 | ## 项目数据已同步到飞书文档 19 | 现在需要志愿者协同我们一起对信息标注(筛选有用信息,标记信息类别),如果感兴趣的同学可以向我们发邮件(z1m6r3@gmail.com),申请编辑权限。 20 | [文档地址](https://u9u37118bj.feishu.cn/sheets/shtcnh4177SPTo2N8NglZHCirDe) 21 | 22 | ## 在本地运行 23 | `yarn install && yarn start` 24 | 25 | ## 参与贡献 26 | - 请求新的feature可以加上todo标识提交issues 27 | - 对于存在的问题和bug加上bug标识直接提交issues 28 | - 想要贡献自己代码的同学可以直接clone提交pull request 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rescue-viz-website", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^4.6.2", 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "antd": "^4.16.8", 11 | "react": "^17.0.2", 12 | "react-bmapgl": "^0.1.23", 13 | "react-dom": "^17.0.2", 14 | "react-scripts": "4.0.3", 15 | "shallowequal": "^1.1.0", 16 | "web-vitals": "^1.0.1" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 河南暴雨互助组 (GiveHenanAHand) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/InfoItem.js: -------------------------------------------------------------------------------- 1 | import { Tag, Button } from "antd" 2 | 3 | function InfoItem(props) { 4 | const handleCorrection = () => { 5 | if (props.handleCorrection) { 6 | props.handleCorrection(props.info) 7 | } 8 | } 9 | 10 | let link_section = null 11 | const showCorrection = typeof (props.hideCorrection) === 'undefined' || props.hideCorrection === false 12 | if (props.info.isWeibo) { 13 | link_section = <> 14 | 原微博 15 | {showCorrection ? : null} 16 | 17 | } 18 | 19 | return
20 |
{props.info.post}
21 |
22 | 23 | {link_section} 24 |
25 | {props.info.category} 26 | { props.info.types.length > 0 ? props.info.types.map(type => {type}) : null } 27 |
28 |
29 |
30 | } 31 | 32 | export default InfoItem 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build & deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | pull_request: 9 | branches: 10 | - master 11 | - dev 12 | 13 | jobs: 14 | build: 15 | name: Build 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v2 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: 13.x 26 | 27 | - name: Install NPM packages 28 | run: npm ci 29 | 30 | - name: Build project 31 | run: CI=false npm run build 32 | 33 | - name: Upload production-ready build files 34 | uses: actions/upload-artifact@v2 35 | with: 36 | name: production-files 37 | path: ./build 38 | 39 | deploy: 40 | name: Deploy 41 | needs: build 42 | runs-on: ubuntu-latest 43 | if: github.ref == 'refs/heads/master' 44 | 45 | steps: 46 | - name: Download artifact 47 | uses: actions/download-artifact@v2 48 | with: 49 | name: production-files 50 | path: ./build 51 | 52 | - uses: AEnterprise/rsync-deploy@v1.0 53 | env: 54 | DEPLOY_KEY: ${{ secrets.PRIVATE_KEY }} 55 | ARGS: "-c -r --delete" 56 | SERVER_PORT: 22 57 | FOLDER: ./build/ 58 | SERVER_IP: ${{ secrets.HOST }} 59 | USERNAME: ${{ secrets.USERNAME }} 60 | SERVER_DESTINATION: /var/www/henan.tianshili.me/ 61 | -------------------------------------------------------------------------------- /src/components/LocationControl.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'shallowequal'; 2 | import { PureComponent } from 'react' 3 | 4 | export default class LocationControl extends PureComponent { 5 | 6 | componentDidUpdate(prevProps) { 7 | if (!this.map) { 8 | this.initialize(); 9 | return; 10 | } 11 | let {anchor, offset} = this.props; 12 | let {anchor: preAnchor, offset: preOffset} = prevProps; 13 | 14 | let isAnchorChanged = !shallowEqual(anchor, preAnchor); 15 | let isOffsetChanged = !shallowEqual(offset, preOffset); 16 | if (anchor && isAnchorChanged) { 17 | this.control.setAnchor(anchor); 18 | } 19 | if (offset && isOffsetChanged) { 20 | this.control.setOffset(offset); 21 | } 22 | } 23 | 24 | componentDidMount() { 25 | this.initialize(); 26 | } 27 | 28 | componentWillUnmount() { 29 | this.destroy(); 30 | } 31 | 32 | destroy() { 33 | if (this.control && this.map) { 34 | this.map.removeControl(this.control); 35 | // @ts-ignore 36 | this.control = null; 37 | } 38 | } 39 | 40 | initialize() { 41 | let map = this.props.map 42 | if (!map) { 43 | return; 44 | } 45 | 46 | this.destroy(); 47 | 48 | this.control = this.getControl(); 49 | map.addControl(this.control); 50 | } 51 | 52 | getControl() { 53 | return new window.BMapGL.LocationControl(); 54 | } 55 | 56 | render() { 57 | return null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 18 | 19 | 28 | 河南暴雨微博求助信息 29 | 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | html, 16 | body { 17 | height: 100%; 18 | } 19 | 20 | #root { 21 | height: 100%; 22 | } 23 | 24 | .rootDiv { 25 | background-color: #444; 26 | height: 100%; 27 | } 28 | 29 | .mapDiv { 30 | height: 100%; 31 | } 32 | 33 | .info-container { 34 | z-index: 999; 35 | /*width: auto;*/ 36 | max-width: 35%; 37 | max-height: 80%; 38 | display: flex; 39 | flex-direction: column; 40 | padding: 0.75rem 1.35rem; 41 | margin-left: 1.25rem; 42 | margin-right: 1.25rem; 43 | position: fixed; 44 | top: 1rem; 45 | background-color: #fff; 46 | border-radius: 0.25rem; 47 | font-size: 14px; 48 | color: #666; 49 | box-shadow: 0 2px 6px 0 rgba(27, 142, 236, 0.5); 50 | transition: transform 0.3s; 51 | } 52 | 53 | @media screen and (max-width: 1000px) { 54 | .info-container { 55 | max-width: 50%; 56 | } 57 | } 58 | 59 | @media screen and (max-width: 700px) { 60 | .info-container { 61 | max-width: unset; 62 | width: auto; 63 | left: 0; 64 | right: 0; 65 | } 66 | } 67 | 68 | .info-container[data-fold='true'] { 69 | transform: translateX(-95%); 70 | } 71 | 72 | .info[data-fold='true'] { 73 | visibility: hidden; 74 | } 75 | 76 | .left-fold { 77 | position: absolute; 78 | top: 50%; 79 | transform: translateY(-50%); 80 | right: 0px; 81 | } 82 | 83 | .left-fold[data-fold='true'] > svg { 84 | transform: rotate(180deg); 85 | transform-origin: center; 86 | } 87 | 88 | a.aboutButton { 89 | display:inline-block; 90 | padding:0.7em 1.4em; 91 | margin:0 0.3em 0.3em 1.5em; 92 | border-radius:0.15em; 93 | box-sizing: border-box; 94 | text-decoration:none; 95 | font-family:'Roboto',sans-serif; 96 | text-transform:uppercase; 97 | font-weight:400; 98 | color:#FFFFFF; 99 | background-color:#3369ff; 100 | box-shadow:inset 0 -0.6em 0 -0.35em rgba(0,0,0,0.17); 101 | text-align:center; 102 | position:relative; 103 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 26 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 27 | 28 | # Editor directories and files 29 | .idea 30 | .vscode 31 | 32 | # User-specific stuff 33 | .idea/**/workspace.xml 34 | .idea/**/tasks.xml 35 | .idea/**/usage.statistics.xml 36 | .idea/**/dictionaries 37 | .idea/**/shelf 38 | 39 | # AWS User-specific 40 | .idea/**/aws.xml 41 | 42 | # Generated files 43 | .idea/**/contentModel.xml 44 | 45 | # Sensitive or high-churn files 46 | .idea/**/dataSources/ 47 | .idea/**/dataSources.ids 48 | .idea/**/dataSources.local.xml 49 | .idea/**/sqlDataSources.xml 50 | .idea/**/dynamic.xml 51 | .idea/**/uiDesigner.xml 52 | .idea/**/dbnavigator.xml 53 | 54 | # Gradle 55 | .idea/**/gradle.xml 56 | .idea/**/libraries 57 | 58 | # Gradle and Maven with auto-import 59 | # When using Gradle or Maven with auto-import, you should exclude module files, 60 | # since they will be recreated, and may cause churn. Uncomment if using 61 | # auto-import. 62 | # .idea/artifacts 63 | # .idea/compiler.xml 64 | # .idea/jarRepositories.xml 65 | # .idea/modules.xml 66 | # .idea/*.iml 67 | # .idea/modules 68 | # *.iml 69 | # *.ipr 70 | 71 | # CMake 72 | cmake-build-*/ 73 | 74 | # Mongo Explorer plugin 75 | .idea/**/mongoSettings.xml 76 | 77 | # File-based project format 78 | *.iws 79 | 80 | # IntelliJ 81 | out/ 82 | 83 | # mpeltonen/sbt-idea plugin 84 | .idea_modules/ 85 | 86 | # JIRA plugin 87 | atlassian-ide-plugin.xml 88 | 89 | # Cursive Clojure plugin 90 | .idea/replstate.xml 91 | 92 | # Crashlytics plugin (for Android Studio and IntelliJ) 93 | com_crashlytics_export_strings.xml 94 | crashlytics.properties 95 | crashlytics-build.properties 96 | fabric.properties 97 | 98 | # Editor-based Rest Client 99 | .idea/httpRequests 100 | 101 | # Android studio 3.1+ serialized cache file 102 | .idea/caches/build_file_checksums.ser 103 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/styles/InfoHeader.css: -------------------------------------------------------------------------------- 1 | .source-switch { 2 | margin-bottom: 5px; 3 | } 4 | 5 | .label-col { 6 | margin-left: 10px; 7 | display: flex !important; 8 | align-items: center; 9 | } 10 | 11 | .info-list { 12 | margin-top: 10px; 13 | margin-right: 25px !important; 14 | background-color: white; 15 | overflow-x: hidden; 16 | overflow-y: auto; 17 | } 18 | 19 | .info-list .ant-list-item:hover { 20 | background: #fafafa; 21 | } 22 | 23 | .info-list .ant-list-item.selected-item { 24 | background: #e6f7ff; 25 | border-color: #00000008; 26 | } 27 | 28 | .info-list .info-item { 29 | display: block; 30 | text-decoration: none; 31 | cursor: pointer; 32 | } 33 | 34 | .info-item-content { 35 | white-space: pre-wrap; /* CSS3 */ 36 | white-space: -moz-pre-wrap; /* Firefox */ 37 | white-space: -o-pre-wrap; /* Opera 7 */ 38 | word-wrap: break-word; /* IE */ 39 | } 40 | 41 | .info-item:first-child { 42 | border-top: none; 43 | } 44 | 45 | .info-item-footer { 46 | display: flex; 47 | flex-wrap: wrap; 48 | align-items: center; 49 | margin-top: 10px; 50 | font-size: 14px; 51 | } 52 | 53 | .info-item-link { 54 | margin-left: 10px; 55 | /*margin-right: 5px;*/ 56 | font-size: 13px; 57 | } 58 | 59 | .ant-btn-link.info-item-link { 60 | padding: 0 !important; 61 | } 62 | 63 | .info-item-date { 64 | display: inline; 65 | float: right; 66 | font-size: 13px; 67 | color: #666; 68 | } 69 | 70 | .info-button-list { 71 | display: flex; 72 | justify-content: space-evenly; 73 | margin-top: 10px; 74 | margin-bottom: 10px; 75 | } 76 | 77 | @media screen and (max-width: 400px) { 78 | .info-button-list a { 79 | padding-left: 10px; 80 | padding-right: 10px; 81 | } 82 | } 83 | 84 | .info-item-tag-list { 85 | margin-left: 10px; 86 | } 87 | 88 | .info-sheet-item .info-item-tag-list { 89 | margin-left: 0; 90 | } 91 | 92 | .info-list-header { 93 | display: flex; 94 | justify-content: center; 95 | flex-wrap: wrap; 96 | margin-bottom: 10px; 97 | } 98 | 99 | .info-list-header .info-list-search { 100 | flex-basis: 100%; 101 | margin: 0 25px; 102 | } 103 | 104 | .info-list-header .info-list-category { 105 | margin-top: 10px; 106 | margin-right: 5px; 107 | } 108 | 109 | .info-list-header .info-list-types { 110 | margin-top: 10px; 111 | min-width: 80px; 112 | } 113 | 114 | .info-list-header .info-list-types .ant-select-selection-search { 115 | } 116 | 117 | .info-list-search-icon { 118 | /*color: red;*/ 119 | } -------------------------------------------------------------------------------- /src/components/BaiduMap.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useCallback, useMemo} from "react"; 2 | import {Map, ScaleControl, ZoomControl, MapTypeControl} from 'react-bmapgl'; 3 | import { InfoMarker,InfoWindow,LocationControl } from "."; 4 | 5 | 6 | function BaiduMap(props) { 7 | const [focus, setFocus] = useState("") 8 | const [bounds, setBounds] = useState(null) 9 | const [shouldAutoFocus, setShouldAutoFocus] = useState(true) 10 | 11 | function onClickMarker(id){ 12 | setShouldAutoFocus(true) 13 | if (focus === id) { 14 | setFocus("") 15 | } else { 16 | setFocus(id) 17 | } 18 | } 19 | 20 | const mapRef = useCallback(node => { 21 | if (node !== null && bounds == null) { 22 | const map = node.map 23 | // updateBounds('init', map) 24 | props.mapInited() 25 | map.addEventListener('moveend', () => { 26 | updateBounds('moveend', map) 27 | }) 28 | map.addEventListener('zoomend', () => { 29 | updateBounds('zoomend', map) 30 | }) 31 | } 32 | }, []); 33 | 34 | let lastUpdateTime = Date.now() 35 | const updateBounds = (type, map) => { 36 | const offset = Date.now() - lastUpdateTime; 37 | // infowindow/autoviewport triggers move/zoom event 38 | // which leads infinite loop 39 | // prevent frequent refreshing 40 | if (offset < 500 && bounds !== null) return 41 | if (map == null) return 42 | 43 | lastUpdateTime = Date.now() 44 | const visibleBounds = map.getBounds() 45 | setShouldAutoFocus(false) 46 | setBounds(visibleBounds) 47 | props.handleBoundChanged(visibleBounds) 48 | } 49 | 50 | function onWindowCloseClick() { 51 | onClickMarker(focus) 52 | } 53 | 54 | const infoMarkers = useMemo(() => { 55 | return props.data.map( item => { 56 | // color in props.changeList has a higher priority 57 | return 58 | }) 59 | }, [props.changeList, props.data]) 60 | 61 | return 69 | 70 | 71 | 72 | 73 | { infoMarkers } 74 | e.id === focus)} 76 | shouldAutoCenter={shouldAutoFocus} 77 | handleCorrection={props.handleCorrection} 78 | onCloseClick={onWindowCloseClick}/> 79 | 80 | } 81 | 82 | export default BaiduMap -------------------------------------------------------------------------------- /src/components/ReportModal.js: -------------------------------------------------------------------------------- 1 | import {useState} from "react" 2 | import { Modal, Select, Divider, message } from "antd" 3 | import InfoItem from "./InfoItem"; 4 | import {CATEGORY_MAP} from "../common/constant"; 5 | import "../styles/ReportModal.css" 6 | const {Option} = Select 7 | 8 | const ReportModal = (props) => { 9 | const [confirmLoading, setConfirmLoading] = useState(false) 10 | const [selectedTypes, setSelectedTypes] = useState([]) 11 | const [category, setCategory] = useState('') 12 | const [types, setTypes] = useState([]) 13 | 14 | // callback argument: (result: string) 15 | const uploadData = (id, category, callback) => { 16 | fetch('https://w6nyjxy4l9.execute-api.ap-east-1.amazonaws.com/api_deploy/item', { 17 | method: 'POST', 18 | headers: { 19 | 'Accept': 'application/json', 20 | 'Content-Type': 'application/json' 21 | }, 22 | body: JSON.stringify({ 23 | itemID: id, 24 | category: category 25 | }) 26 | }).then(e => callback('ok')) 27 | .catch(err => callback(err.toString())) 28 | } 29 | 30 | const handleOk = () => { 31 | setConfirmLoading(true) 32 | 33 | const newCategory = [category, ...selectedTypes].join('_') 34 | uploadData(props.item.link, newCategory, msg => { 35 | console.log(msg) 36 | 37 | if (msg === 'ok') { 38 | message.success('提交成功,请等待信息刷新') 39 | props.setVisible(false, props.item, newCategory) 40 | } else { 41 | message.error('上传出错,请稍后再试'); 42 | } 43 | 44 | setConfirmLoading(false) 45 | }) 46 | } 47 | 48 | const handleCancel = () => { 49 | console.log('Clicked cancel button') 50 | props.setVisible(false) 51 | } 52 | 53 | const handleCategoryChange = (v) => { 54 | setTypes(CATEGORY_MAP[v] || []) 55 | setSelectedTypes([]) 56 | setCategory(v) 57 | } 58 | 59 | const handleTypesChange = (v) => { 60 | setSelectedTypes(v) 61 | } 62 | 63 | const categories = Object.keys(CATEGORY_MAP) 64 | 65 | return ( 66 | 76 | 77 | 78 | 79 | 80 | 81 | 85 | 98 | 99 | ) 100 | } 101 | 102 | export default ReportModal -------------------------------------------------------------------------------- /src/components/InfoHeader.js: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useState} from "react"; 2 | import { LEFT_FOLD } from '../icon'; 3 | import {Button, Slider, Row, Col, Input, Select, List, Radio} from 'antd'; 4 | import {SearchOutlined} from "@ant-design/icons"; 5 | import InfoItem from "./InfoItem"; 6 | import {CATEGORY_MAP} from "../common/constant"; 7 | import '../styles/InfoHeader.css' 8 | const { Option } = Select 9 | 10 | function InfoHeader(props) { 11 | const [isFold, setIsFold] = useState(false) 12 | const [timeRange, setTimeRange] = useState(8) 13 | const [displayList, setDisplayList] = useState([]) 14 | const [selectedId, setSelectedId] = useState('') 15 | const [dataSource, setDataSource] = useState('weibo') 16 | const [types, setTypes] = useState([]) 17 | 18 | useEffect(() => { 19 | if (props.bounds == null) return 20 | 21 | const list = props.list.filter(e => props.bounds.containsPoint(e.location)) 22 | setDisplayList(list) 23 | }, [props.list, props.bounds]) 24 | 25 | const onLeftFold = useCallback((e) => { 26 | e.stopPropagation(); 27 | setIsFold(!isFold); 28 | }, [isFold]) 29 | 30 | 31 | const handleSliderChange = (value) => { 32 | setTimeRange(value) 33 | props.notifySliderChange(value) 34 | } 35 | 36 | const handleItemClicked = (item) => { 37 | setSelectedId(item.id) 38 | props.handleItemClick(item) 39 | } 40 | 41 | let handleSouceSwitched = (event) => { 42 | setDataSource(event.target.value) 43 | props.notifyDataSourceSwitch(event.target.value) 44 | } 45 | 46 | let souceSwitch = () => { 47 | return 48 | 信息来源: 49 | 50 | 51 | 微博 52 | 在线表格 53 | 54 | 55 | } 56 | 57 | let headerText = () => { 58 | if (dataSource === 'weibo') { 59 | return 62 | } else { 63 | return 66 | } 67 | } 68 | 69 | const handleCategoryChange = (value) => { 70 | setTypes(CATEGORY_MAP[value] || []) 71 | props.notifyCategoryChange(value) 72 | } 73 | 74 | const categories = Object.keys(CATEGORY_MAP) 75 | 76 | let slider = () => { 77 | if (dataSource === 'sheet') { 78 | return null 79 | } 80 | 81 | let labelText = "最近" + timeRange + "小时"; 82 | if (timeRange === 12) { 83 | labelText = "全部记录" 84 | } 85 | return
86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 |
95 | 96 | 97 | 98 |
99 |
100 | } 101 | 102 | return ( 103 |
104 |
105 | {souceSwitch()} 106 |
{headerText()}
107 |
108 | {slider()} 109 |
110 |
111 | props.notifyKeywordChange(e.target.value) } 115 | allowClear 116 | prefix={} 117 | style={{ }} 118 | /> 119 | 123 | 136 |
137 | ( 144 | { handleItemClicked(item) } }> 145 | 146 | 147 | )} 148 | /> 149 |
{LEFT_FOLD}
150 |
151 | ) 152 | } 153 | 154 | export default InfoHeader; -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState, useMemo} from "react"; 2 | import { BaiduMap, InfoHeader } from "./components"; 3 | import { COLOR_MAP } from './common/constant' 4 | import './styles/App.css'; 5 | import ReportModal from "./components/ReportModal"; 6 | 7 | function App() { 8 | const [timeRange, setTimeRange] = useState(6) 9 | const [data, setData] = useState({}) 10 | const [dataSource, setDataSource] = useState('weibo') 11 | const [bounds, setBounds] = useState(null) 12 | const [listDefaultText, setListDefaultText] = useState("") 13 | // map center 14 | const [center, setCenter] = useState({ lng: 113.802193, lat: 34.820333 }) 15 | 16 | // filter relevant states 17 | const [ keyword, setKeyword ] = useState('') 18 | const [ selectedCategory, setSelectedCategory ] = useState('') 19 | const [ selectedTypes, setSelectedTypes ] = useState([]) 20 | 21 | // highlight relevant states 22 | // changeList: (id -> icon) dict 23 | const [ changeList, setChangeList ] = useState({}) 24 | 25 | // modal relevant states 26 | const [ modalState, setModalState ] = useState({ modalVisible: false, item: null }) 27 | 28 | function createDataItem(item) { 29 | // including different ways to create the latLong and time fields to make it compatible 30 | // across different versions of json format; only for the transition phase 31 | item.isWeibo = !item.link.startsWith('no_link') 32 | // generate random to prevent overlap 33 | let random1 = Math.random()-0.5 34 | let random2 = Math.random()-0.5 35 | if (!item.isWeibo) { 36 | random1 = random1 / 200 37 | random2 = random2 / 200 38 | } else { 39 | random1 = random1 / 1000 40 | random2 = random2 / 1000 41 | } 42 | item.location = { 43 | lng: ((item.location && item.location.lng) || item.lng) + random1, 44 | lat: ((item.location && item.location.lat) || item.lat) + random2 45 | } 46 | 47 | item.time = item.Time || item.time 48 | if (item.isWeibo) { 49 | // format time 50 | item.timestamp = Date.parse(item.time) 51 | const date = new Date(item.timestamp) 52 | item.formatTime = `${date.getMonth() + 1}月${date.getDate()}日 ${item.time.substring(11, 20)}` 53 | } else { 54 | item.formatTime = '' 55 | } 56 | 57 | if (!item.isWeibo) { 58 | let text = '地址: ' + item.address + '\n' 59 | if (item.post) text += '内容: ' + item.post + '\n' 60 | if (item.contact_person) text += '联系人: ' + item.contact_person + '\n' 61 | if (item.contact_info) text += '联系方式: ' + item.contact_info 62 | 63 | item.post = text 64 | } 65 | 66 | // use last part of link as id 67 | let arr = item.link.split('/') 68 | item.id = arr[arr.length - 1] 69 | 70 | // fill null category 71 | // revised_category has a higher priority 72 | item.category = item.revised_category || item.category || '未分类' 73 | 74 | // item category and types 75 | const category = item.category 76 | arr = category.split('_').map(e => e.trim()) 77 | // the first is category 78 | item.category = arr.shift() 79 | item.types = arr 80 | item.color = COLOR_MAP[item.category] 81 | 82 | // default icon 83 | item.icon = 'loc_red' 84 | 85 | // fill null address 86 | item.address = item.address || '' 87 | 88 | return item 89 | } 90 | 91 | // Fetch data on init 92 | useEffect(() => { 93 | let xhr_weibo = new XMLHttpRequest(); 94 | xhr_weibo.onload = function () { 95 | if ('weibo' in data) return 96 | const serverData = JSON.parse(xhr_weibo.responseText) 97 | const items = serverData.map(createDataItem) 98 | setData(previousData => ({...previousData, weibo: items})) 99 | }; 100 | xhr_weibo.open("GET", "https://api-henan.tianshili.me/parse_json.json"); 101 | xhr_weibo.send() 102 | 103 | let xhr_sheet = new XMLHttpRequest(); 104 | xhr_sheet.onload = function () { 105 | if ('sheet' in data) return 106 | const serverData = JSON.parse(xhr_sheet.responseText) 107 | const items = serverData.map(createDataItem) 108 | setData(previousData => ({...previousData, sheet: items})) 109 | }; 110 | xhr_sheet.open("GET", "https://api-henan.tianshili.me/manual.json"); 111 | xhr_sheet.send() 112 | }) 113 | 114 | // [SECTION] Data generation 115 | let filterData = useMemo(() => { 116 | if (!(dataSource in data)) return [] 117 | let currentFilteredData 118 | // convert selectedTypes into map, with (item -> true) 119 | const selectedTypesMap = selectedTypes.reduce((result, item) => { 120 | result[item] = true 121 | return result 122 | }, {}) 123 | 124 | if (dataSource === 'weibo') { 125 | const beginTime = Date.now() - timeRange * 60 * 60 * 1000 126 | currentFilteredData = data[dataSource].filter(item => { 127 | const result = (item.timestamp > beginTime) && 128 | item.post.indexOf(keyword) > -1 && 129 | item.category.indexOf(selectedCategory) > -1 130 | // if already false 131 | if (result === false) { return false } 132 | // default select all 133 | if (selectedTypes.length === 0) return true 134 | // if previous condition is true, check selected types 135 | for (const type of item.types) { 136 | if (selectedTypesMap[type]) { return true } 137 | } 138 | return false 139 | }) 140 | } else { 141 | // filter for manual sheet source 142 | currentFilteredData = data[dataSource].filter(item => { 143 | let contains_keyword = ((item.address.indexOf(keyword) > -1) || 144 | (item.post && item.post.indexOf(keyword) > -1)) 145 | const result = contains_keyword && item.category.indexOf(selectedCategory) > -1 146 | if (!result) { return false } 147 | if (selectedTypes.length === 0) return true 148 | for (const type of item.types) { 149 | if (selectedTypesMap[type]) { return true } 150 | } 151 | return false 152 | }) 153 | } 154 | return currentFilteredData 155 | }, [data, dataSource, timeRange, keyword, selectedCategory, selectedTypes]) 156 | 157 | // [SECTION] component call backs 158 | function handleDataSourceSwitch(value) { 159 | setDataSource(value) 160 | } 161 | 162 | function handleSliderChange(e) { 163 | setTimeRange(e) 164 | } 165 | 166 | function updateBounds(newBounds) { 167 | setBounds(newBounds) 168 | setListDefaultText("无数据") 169 | } 170 | 171 | function handleMapInited() { 172 | setListDefaultText("移动地图显示列表") 173 | } 174 | 175 | function handleInfoSelected(item) { 176 | let list = Object.assign({}, changeList) 177 | 178 | // revert last change 179 | for (const id in list) { 180 | if (id === item.id) continue 181 | 182 | const prevItem = data[dataSource].find(e => e.id === id) 183 | // if prevItem exists and it's highlighted, un-highlight it 184 | if (prevItem && list[id] !== prevItem.icon) { 185 | list[id] = prevItem.icon 186 | } else { 187 | // no previous item exists or it's already not highlighted, remove from list 188 | delete list[id] 189 | } 190 | } 191 | 192 | // highlight item 193 | list[item.id] = 'loc_blue' 194 | setChangeList(list) 195 | setCenter(item.location) 196 | } 197 | 198 | function handleCorrection(item) { 199 | setModalState({ visible: true, item: item }) 200 | } 201 | 202 | function handleModalVisible(isVisible, item, newCategory) { 203 | // if successfully updated 204 | if (isVisible === false && item) { 205 | let items = [...data.weibo] 206 | let index = items.findIndex(e => e.id === item.id) 207 | if (index > -1) { 208 | let newItem = {...items[index]} 209 | newItem.category = newCategory 210 | items[index] = createDataItem(newItem) 211 | setData(previousData => ({...previousData, weibo: items})) 212 | } 213 | } 214 | 215 | setModalState({ visible: isVisible, item: item }) 216 | } 217 | 218 | return ( 219 |
220 | setKeyword(e) } 229 | notifyCategoryChange={ e => { setSelectedCategory(e); setSelectedTypes([]) } } 230 | notifyTypesChange={ e => setSelectedTypes(e) } 231 | handleItemClick={ e => handleInfoSelected(e) } 232 | handleCorrection={ e => handleCorrection(e) } 233 | /> 234 | 241 | { modalState.visible && modalState.item ? 242 | :null } 245 |
246 | ) 247 | } 248 | 249 | export default App; 250 | --------------------------------------------------------------------------------