├── .gitignore ├── .vscode └── settings.json ├── README-cn.md ├── README.md ├── package-lock.json ├── package.json ├── public ├── app-icon.png ├── beijing.json ├── changsha.json ├── favicon.ico ├── guangzhou.json ├── hongkong.json ├── index.html ├── manifest.json ├── robots.txt ├── shanghai.json ├── shenzhen.json └── tianjing.json ├── src ├── Common │ ├── AutoGrowthInput.scss │ ├── AutoGrowthInput.tsx │ ├── api.ts │ ├── color.ts │ ├── const.ts │ ├── teyvat.ts │ └── util.ts ├── Data │ ├── Shape.ts │ └── UserData.ts ├── DataStructure │ ├── Bend.ts │ ├── ConnectType.ts │ ├── Direction.ts │ ├── Display.ts │ ├── Line.ts │ ├── LineRecord.ts │ ├── Mode.ts │ ├── Point.ts │ ├── Rail.ts │ ├── RailPair.ts │ ├── Station.ts │ ├── Straight.ts │ ├── Track.ts │ └── Vector.ts ├── Entrance │ ├── App.scss │ ├── App.test.tsx │ ├── App.tsx │ ├── reportWebVitals.js │ └── setupTests.js ├── Grid │ └── Scale.ts ├── Line │ ├── Handle.ts │ └── LinePoints.ts ├── Render │ ├── Card │ │ ├── Cards.scss │ │ ├── Cards.tsx │ │ ├── LineCard.scss │ │ ├── LineCard.tsx │ │ ├── StationCard.scss │ │ └── StationCard.tsx │ ├── Component │ │ └── LineRender.tsx │ ├── Delete │ │ ├── DeleteConfirmation.scss │ │ └── DeleteConfirmation.tsx │ ├── ErrorFallback │ │ ├── ErrorFallback.scss │ │ └── ErrorFallback.tsx │ ├── Header │ │ ├── Component │ │ │ ├── OpacityControl.scss │ │ │ ├── OpacityControl.tsx │ │ │ ├── ShapeSelector.scss │ │ │ └── ShapeSelector.tsx │ │ ├── Menu.scss │ │ └── Menu.tsx │ ├── Layer │ │ ├── DevelopLayer.scss │ │ ├── DevelopLayer.tsx │ │ ├── RenderLayer.scss │ │ ├── RenderLayer.tsx │ │ ├── ScaleLayer.scss │ │ └── ScaleLayer.tsx │ └── Recovery │ │ ├── Recovery.scss │ │ └── Recovery.tsx ├── Resource │ ├── Icon │ │ ├── airwave.tsx │ │ ├── arrow.tsx │ │ ├── auto.tsx │ │ ├── bolt.tsx │ │ ├── clock.arrow.circlepath.svg │ │ ├── edit.tsx │ │ ├── expand.tsx │ │ ├── export.svg │ │ ├── export.tsx │ │ ├── finished.tsx │ │ ├── globe.svg │ │ ├── goto.tsx │ │ ├── infinity.tsx │ │ ├── no.svg │ │ ├── ok.svg │ │ ├── pageview.tsx │ │ ├── plus.tsx │ │ ├── share.svg │ │ ├── share2.svg │ │ ├── shrink.tsx │ │ └── touch.tsx │ └── Shape │ │ └── shape.tsx ├── Style │ └── Cursor.ts ├── WelcomeTour │ ├── Driver.ts │ ├── HightLights.tsx │ ├── Steps │ │ ├── common-operation.ts │ │ ├── export.ts │ │ ├── line-card.ts │ │ ├── quick-edit.ts │ │ ├── skip.ts │ │ ├── station-card.ts │ │ └── tag-setting.ts │ ├── WelcomeTour.scss │ └── WelcomeTour.tsx ├── i18n │ ├── config.ts │ └── locales │ │ ├── en.json │ │ └── zh.json ├── index.js └── types │ └── svg.d.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "i18n-ally.localesPaths": ["src/i18n/locales"], 3 | "i18n-ally.extract.autoDetect": true, 4 | "i18n-ally.keystyle": "nested", 5 | "i18n-ally.sourceLanguage": "zh", 6 | "i18n-ally.displayLanguage": "zh", 7 | "i18n-ally.extract.ignored": [ 8 | "“\n ", 9 | "”\n ", 10 | "”\n ", 11 | "\n 重做\n ", 12 | ".title .click-panel", 13 | ".title .click-panel" 14 | ], 15 | "i18n-ally.extract.ignoredByFiles": { 16 | "src/WelcomeTour/WelcomeTour.tsx": [ 17 | ".welcome-tour .body" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /README-cn.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Mini Metro Web 5 | 迷你地铁地图构建工具: `创建迷你地铁风格的地铁线路图`。 6 | 7 | 支持**无限**站点与线路,支持**多次穿过**同一站点,设置**背景图**,设置**支线**,支持**导出图片**。 8 | 9 | [https://mini-metro-web.gitlab.io/](https://mini-metro-web.gitlab.io/) 10 | 11 | [English Version](https://github.com/RyanEdo/mini-metro-web/blob/master/README.md) 12 | 13 | 14 | ## 更新日志 15 | #### 1.2.1 `修复添加站点时,连续双击站点导致的站点重复添加报错` 16 | #### 1.2.0 `支持英文` 17 | #### 1.1.1 `增加更多站点形状,新增站点时支持修改默认形状` 18 | #### 1.1.0 `支持修改背景颜色与背景图` 19 | #### 1.0.2 `加入刷新后快速恢复通知` 20 | #### 1.0.1 `加入报错指引` 21 | 22 | 23 | ## 基本用法 24 | 25 | ### 菜单 26 | 点击左上角标题进入菜单,点击任意空白处退出菜单 27 | 28 | ### 创建站点 29 | 1. 菜单 => 站点 => 添加站点 30 | 2. 点击空白处添加 31 | 32 | ### 创建线路 33 | 1. 点击任意站点 34 | 2. 选择 操作 => 以此为起点新建线路 35 | 3. 按照提示依次点击站点添加到线路 36 | 37 | ### 删除站点/线路 38 | 1. 点击站点/线路 39 | 2. 选择 操作 => 删除 40 | 41 | ### 设置背景图 42 | 1. 点击线路 => 设定背景 43 | 2. 选择 `纯白` / `浅黄` / `取色器` 设定背景颜色 44 | 3. 选择 `导入背景图` 导入图片 45 | 4. 导入图片后,进入修改图片页面,可以拖动修改图片位置,也可以修改图片透明度 46 | 47 | ### 导出图片/文件 48 | 菜单 => 作为图片/文件导出 49 | 50 | ## 进阶用法 51 | 请参考应用内教程或[视频教程](https://space.bilibili.com/8217854) 52 | 53 | ## 构建 54 | 55 | 和大部分react项目一样,先运行`npm i`,然后: 56 | 57 | ### `npm start` 58 | 59 | 本地运行 60 | 打开 [http://localhost:3000](http://localhost:3000) 浏览器中查看. 61 | 62 | ### `npm run build` 63 | 64 | 打包成静态文件 65 | 66 | ### 注意 67 | ##### 建议安装 `i18n-ally`[VS Code 插件地址](https://marketplace.visualstudio.com/items?itemName=Lokalise.i18n-ally) 。项目有部分字符串由该插件生成,安装后能自动在字符串位置显示原文。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Mini Metro Web 5 | Mini Metro Map Building Tool: `Create mini metro-style subway maps`. 6 | 7 | Supports **unlimited** stations and lines, supports **multiple crossings** of the same station, set **background images**, set **sub lines**, and supports **exporting images**. 8 | 9 | https://mini-metro-web.gitlab.io/ 10 | 11 | [中文文档](https://github.com/RyanEdo/mini-metro-web/blob/master/README-cn.md) 12 | 13 | ## Changelog 14 | #### 1.2.1 `fix adding duplicate stations when double clicking at adding station mode` 15 | #### 1.2.0 `Support for English` 16 | #### 1.1.1 `Added more station shapes, support for modifying default shapes when adding new stations` 17 | #### 1.1.0 `Support for changing background color and background image` 18 | #### 1.0.2 `Added quick recovery notification after refresh` 19 | #### 1.0.1 `Added error guidance` 20 | 21 | ## Basic Usage 22 | 23 | ### Menu 24 | Click the title in the top left corner to enter the menu, click any blank area to exit the menu. 25 | 26 | ### Create Station 27 | 1. Menu => Station => Add Station 28 | 2. Click on a blank area to add 29 | 30 | ### Create Line 31 | 1. Click any station 32 | 2. Select Action => Create new line from this point 33 | 3. Follow the prompts to click stations in sequence to add them to the line 34 | 35 | ### Delete Station/Line 36 | 1. Click the station/line 37 | 2. Select Action => Delete 38 | 39 | ### Set Background Image 40 | 1. Click the line => Set background 41 | 2. Choose `Pure White` / `Light Yellow` / `Color Picker` to set the background color 42 | 3. Choose `Import Background Image` to import an image 43 | 4. After importing the image, enter the image editing page, where you can drag to adjust the image position and modify the image transparency 44 | 45 | ### Export Image/File 46 | Menu => Export as image/file 47 | 48 | ## Advanced Usage 49 | Please refer to the in-app tutorial or video tutorial 50 | 51 | ## Build 52 | 53 | Like most React projects, first run `npm i`, then: 54 | 55 | ### `npm start` 56 | 57 | Run locally 58 | Open http://localhost:3000 to view in the browser. 59 | 60 | ### `npm run build` 61 | 62 | Build into static files 63 | 64 | ### Note 65 | ##### It is recommended to install the `i18n-ally` VS Code plugin. Some strings in the project are generated by this plugin, and after installation, the original text will be automatically displayed at the string location. 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-metro-web", 3 | "version": "1.2.1", 4 | "private": true, 5 | "dependencies": { 6 | "@svgr/webpack": "^8.1.0", 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/downloadjs": "^1.4.6", 11 | "classnames": "^2.5.1", 12 | "downloadjs": "^1.4.7", 13 | "driver.js": "^1.3.1", 14 | "html-to-image": "^1.11.11", 15 | "i18next": "^23.16.3", 16 | "i18next-browser-languagedetector": "^8.0.0", 17 | "moment": "^2.30.1", 18 | "qrcode": "^1.5.3", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-error-boundary": "^4.0.13", 22 | "react-i18next": "^15.1.0", 23 | "react-scripts": "5.0.1", 24 | "sass": "^1.62.0", 25 | "ua-parser-js": "^1.0.37", 26 | "web-vitals": "^2.1.4" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject" 33 | }, 34 | "eslintConfig": { 35 | "extends": [ 36 | "react-app", 37 | "react-app/jest" 38 | ] 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "@types/qrcode": "^1.5.5", 54 | "@types/ua-parser-js": "^0.7.39" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanEdo/mini-metro-web/fdbd501c7a9f7a5ef6f1822986b450f3565e5137/public/app-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RyanEdo/mini-metro-web/fdbd501c7a9f7a5ef6f1822986b450f3565e5137/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 19 | 20 | 29 | Mini Metro Web 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Mini Metro Web", 3 | "name": "Mini Metro Web", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "app-icon.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "app-icon.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#ffffff", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/Common/AutoGrowthInput.scss: -------------------------------------------------------------------------------- 1 | .auto-growth-container { 2 | position: relative; 3 | width: fit-content; 4 | .auto-growth-span { 5 | // font-size: 36px; 6 | border: none; 7 | // font-weight: 500; 8 | // width: 100px; 9 | // display: inline-block; 10 | // margin-top: 14px; 11 | opacity: 0; 12 | background-color: transparent; 13 | white-space: nowrap; 14 | } 15 | .auto-growth-input { 16 | // font-size: 36px; 17 | white-space: nowrap; 18 | 19 | border: none; 20 | // font-weight: 500; 21 | width: calc(100% + 18px); 22 | // margin-top: 14px; 23 | position: absolute; 24 | left: 0; 25 | padding: 0; 26 | background-color: transparent; 27 | &:disabled{ 28 | appearance: none; 29 | color: inherit; 30 | opacity: inherit; 31 | cursor: default; 32 | border: none; 33 | &:focus,&:focus-visible{ 34 | outline: none; 35 | } 36 | } 37 | } 38 | .click-panel{ 39 | position: absolute; 40 | left: 0; 41 | width: calc(100% + 18px); 42 | height: 100%; 43 | top: 0; 44 | // cursor: pointer; 45 | } 46 | 47 | &.disabled{ 48 | .auto-growth-span{ 49 | opacity: 1; 50 | } 51 | .auto-growth-input{ 52 | // opacity: 0; 53 | display: none; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Common/AutoGrowthInput.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { 3 | CSSProperties, 4 | Dispatch, 5 | LegacyRef, 6 | RefAttributes, 7 | RefObject, 8 | SetStateAction, 9 | StyleHTMLAttributes, 10 | forwardRef, 11 | useEffect, 12 | useState, 13 | } from "react"; 14 | import "./AutoGrowthInput.scss"; 15 | type InputProps = { 16 | value?: number | string; 17 | onInput?: (x: any) => void; 18 | className?: string; 19 | type?: string; 20 | disabled?: boolean; 21 | style?: CSSProperties; 22 | onClick?: (x: any) => void; 23 | } 24 | export const AutoGrowthInput = forwardRef(function ({ 25 | value, 26 | onInput, 27 | className = "", 28 | type = "", 29 | disabled = false, 30 | style = {}, 31 | onClick, 32 | }, ref) { 33 | return ( 34 |
42 | 43 | {value} 44 | 45 | { 56 | if (document.activeElement === e.currentTarget) e.stopPropagation(); 57 | }} 58 | onBlur={(e)=>{ 59 | if(type === "number") 60 | setTimeout(()=>{ 61 | //@ts-ignore 62 | e.target.value = value 63 | }) 64 | }} 65 | > 66 | {disabled?
:<>} 67 |
68 | ); 69 | }) 70 | -------------------------------------------------------------------------------- /src/Common/api.ts: -------------------------------------------------------------------------------- 1 | export const getExistMap = (id:string) => { 2 | const url = `/${id}.json`; 3 | return fetch(url).then(res=>res.text()); 4 | } -------------------------------------------------------------------------------- /src/Common/color.ts: -------------------------------------------------------------------------------- 1 | import { arrayToMap } from "./util"; 2 | 3 | export const colorSH = [ 4 | { 5 | "line": "1号线", 6 | "color": "#EA0B2A", 7 | "color_name": "正红色", 8 | "rgb": [234, 11, 42] 9 | }, 10 | { 11 | "line": "2号线", 12 | "color": "#94D40B", 13 | "color_name": "绿色", 14 | "rgb": [148, 212, 11] 15 | }, 16 | { 17 | "line": "3号线", 18 | "color": "#F8D000", 19 | "color_name": "黄色", 20 | "rgb": [248, 208, 0] 21 | }, 22 | { 23 | "line": "4号线", 24 | "color": "#60269E", 25 | "color_name": "紫色", 26 | "rgb": [96, 38, 158] 27 | }, 28 | { 29 | "line": "5号线", 30 | "color": "#934C9A", 31 | "color_name": "紫红色", 32 | "rgb": [147, 76, 154] 33 | }, 34 | { 35 | "line": "6号线", 36 | "color": "#D80169", 37 | "color_name": "品红色", 38 | "rgb": [216, 1, 105] 39 | }, 40 | { 41 | "line": "7号线", 42 | "color": "#FE6B01", 43 | "color_name": "橙色", 44 | "rgb": [254, 107, 1] 45 | }, 46 | { 47 | "line": "8号线", 48 | "color": "#00A0E8", 49 | "color_name": "蓝色", 50 | "rgb": [0, 160, 232] 51 | }, 52 | { 53 | "line": "9号线", 54 | "color": "#6FC5E8", 55 | "color_name": "淡蓝色", 56 | "rgb": [111, 197, 232] 57 | }, 58 | { 59 | "line": "10号线", 60 | "color": "#C3A5E1", 61 | "color_name": "淡紫色", 62 | "rgb": [195, 165, 225] 63 | }, 64 | { 65 | "line": "11号线", 66 | "color": "#792330", 67 | "color_name": "棕色", 68 | "rgb": [121, 35, 48] 69 | }, 70 | { 71 | "line": "12号线", 72 | "color": "#007A61", 73 | "color_name": "深绿色", 74 | "rgb": [0, 122, 97] 75 | }, 76 | { 77 | "line": "13号线", 78 | "color": "#F095CE", 79 | "color_name": "粉色", 80 | "rgb": [240, 149, 206] 81 | }, 82 | { 83 | "line": "14号线", 84 | "color": "#827805", 85 | "color_name": "橄榄绿色", 86 | "rgb": [130, 120, 5] 87 | }, 88 | { 89 | "line": "15号线", 90 | "color": "#BDA686", 91 | "color_name": "香槟金色", 92 | "rgb": [189, 166, 134] 93 | }, 94 | { 95 | "line": "16号线", 96 | "color": "#2AD2C5", 97 | "color_name": "水绿色", 98 | "rgb": [42, 210, 197] 99 | } 100 | ]; 101 | 102 | export const colorSHMap = arrayToMap("color",colorSH); -------------------------------------------------------------------------------- /src/Common/const.ts: -------------------------------------------------------------------------------- 1 | const gauge = 10; 2 | const line_radius = 10; 3 | const handleLength = 45; 4 | const handleWidth = 15; 5 | export { gauge, line_radius, handleLength, handleWidth }; 6 | -------------------------------------------------------------------------------- /src/Data/Shape.ts: -------------------------------------------------------------------------------- 1 | export const Shape = { 2 | 'cicle': '圆形', // typo 3 | 'square': '正方形', 4 | 'triangle': '三角形', 5 | 'start': '五角星', // typo 6 | 'pentagon': '五边形', 7 | 'hexagon': '六边形', 8 | 'cross': '十字形', 9 | 'rhombus': '菱形', 10 | 'diamond': '钻石', 11 | 'leaf': '叶子', 12 | 'ginkgo': '银杏' 13 | }; 14 | -------------------------------------------------------------------------------- /src/DataStructure/Bend.ts: -------------------------------------------------------------------------------- 1 | import { Rail } from "./Rail"; 2 | import { Track } from "./Track"; 3 | 4 | export class Bend { 5 | // filter empty indexes 6 | static round1(track: Track) { 7 | const emptyRails = track.getEmptyRails().map((rail) => rail.index); 8 | if (emptyRails.length) return emptyRails; 9 | return [0, 1, 2]; 10 | } 11 | // filter opposite rails 12 | static round2(track: Track, round1Indexes: number[], rail?: Rail) { 13 | // rail not exist or in/out rail direction not just opposite to the candidate rail direction 14 | if (!rail || !rail.track.direction.oppositeTo(track.direction)) 15 | return round1Indexes; 16 | return round1Indexes.filter( 17 | (railIndex) => rail.oppositeIndex() === railIndex 18 | ); 19 | } 20 | // filter center rail 21 | static round3(round2Indexes: number[]) { 22 | if (round2Indexes.find((railIndex) => railIndex === 1)) return 1; 23 | } 24 | 25 | // filter most close rail 26 | static round4(track: Track, rail?: Rail) { 27 | if (!rail) return 0; 28 | return rail.track.direction.rotationTo(track.direction) > 0 ? 0 : 2; 29 | } 30 | 31 | static getBestRailIndex(track: Track, rail?: Rail) { 32 | const round1Indexes = this.round1(track); 33 | if (round1Indexes.length === 1) return round1Indexes[0]; 34 | const round2Indexes = this.round2(track, round1Indexes, rail); 35 | if (round2Indexes.length === 1) return round2Indexes[0]; 36 | const round3Res = this.round3(round2Indexes); 37 | if (round3Res === 1) return 1; 38 | const round4Res = this.round4(track, rail); 39 | return round4Res; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/DataStructure/ConnectType.ts: -------------------------------------------------------------------------------- 1 | export enum ConnectType { 2 | straight, 3 | straightFirst, 4 | bendFirst 5 | } -------------------------------------------------------------------------------- /src/DataStructure/Direction.ts: -------------------------------------------------------------------------------- 1 | //clockwise 2 | 3 | export enum Direct { 4 | up, 5 | upRight, 6 | right, 7 | rightDown, 8 | down, 9 | downLeft, 10 | left, 11 | leftUp, 12 | // Quadrant I bisected by a diagonal line 13 | // upRightA means the upper half 14 | // upRightB means the lower half 15 | // all the directions is marked in clockwise 16 | upRightA, 17 | upRightB, 18 | rightDownA, 19 | rightDownB, 20 | downLeftA, 21 | downLeftB, 22 | leftUpA, 23 | leftUpB, 24 | coincide, 25 | } 26 | 27 | export class Direction { 28 | direct: Direct; 29 | standard: boolean; 30 | lean: boolean; 31 | constructor(direct: Direct) { 32 | this.direct = direct; 33 | this.standard = direct < 8; 34 | this.lean = direct % 2 === 1; 35 | } 36 | 37 | delta(direct: Direct){ 38 | const diff = Math.abs(direct - this.direct); 39 | return Math.min(diff, 8-diff); 40 | } 41 | 42 | opposite() { 43 | if (this.direct < 8) return new Direction((this.direct + 4) % 8); 44 | if (this.direct >= 8 && this.direct < 12) 45 | return new Direction(this.direct + 4); 46 | if (this.direct >= 12 && this.direct < 16) 47 | return new Direction(this.direct - 4); 48 | return new Direction(this.direct); 49 | } 50 | 51 | oppositeTo(direction: Direction | undefined) { 52 | if (direction) return direction.opposite().direct === this.direct; 53 | throw new Error("no direction!"); 54 | } 55 | 56 | sameTo(direction: Direction) { 57 | return this.direct === direction.direct; 58 | } 59 | 60 | // how many rotation should this direction do to coincide to given direction 61 | // 1 2 3 means clockwise rotate 1 2 3 times 62 | // -1 -2 -3 means counterclockwise 63 | // 0 means no need rotate 64 | // 4 means opposite direction 65 | rotationTo(direction: Direction) { 66 | if (this.oppositeTo(direction)) return 4; 67 | const side = direction.direct - this.direct; 68 | if (side < -4) return side + 8; 69 | if (side > 4) return side - 8; 70 | return side; 71 | } 72 | getBendSteps(bendFirst: boolean) { 73 | if (this.direct < 8 || this.direct > 15) { 74 | throw new Error("this is not bend direction"); 75 | } 76 | const firstStep = new Direction(this.direct - 7 - (this.direct % 2)); 77 | const secondStep = new Direction((this.direct - 8 + (this.direct % 2)) % 8); 78 | return bendFirst ? [firstStep, secondStep] : [secondStep, firstStep]; 79 | } 80 | } 81 | 82 | export const DirectionVictor = [ 83 | [0, 1], 84 | [1, 1], 85 | [1, 0], 86 | [1, -1], 87 | [0, -1], 88 | [-1, -1], 89 | [-1, 0], 90 | [-1, 1], 91 | ]; 92 | 93 | export const DirectionVictorReverseY = DirectionVictor.map(([x,y])=>[x,-y]); 94 | -------------------------------------------------------------------------------- /src/DataStructure/Display.ts: -------------------------------------------------------------------------------- 1 | import { Station } from "./Station"; 2 | 3 | export class DisplayStation { 4 | stationName: string; 5 | bendFirst: boolean; 6 | constructor(stationName: string, bendFirst: boolean){ 7 | this.stationName = stationName; 8 | this.bendFirst = bendFirst; 9 | } 10 | } -------------------------------------------------------------------------------- /src/DataStructure/Line.ts: -------------------------------------------------------------------------------- 1 | import { LineProps } from "../Data/UserData"; 2 | import { Bend } from "./Bend"; 3 | import { ConnectType } from "./ConnectType"; 4 | import { LineRecord } from "./LineRecord"; 5 | import { Rail } from "./Rail"; 6 | import { RailPair } from "./RailPair"; 7 | import { Station } from "./Station"; 8 | import { Straight } from "./Straight"; 9 | import { Vector } from "./Vector"; 10 | export class Line { 11 | empty: boolean; 12 | departureRecord: LineRecord | undefined; 13 | _dev_tag: string | undefined; 14 | displayLine?: LineProps; 15 | constructor() { 16 | this.empty = false; 17 | } 18 | 19 | getTerminalRecord(){ 20 | let p = this.departureRecord; 21 | while(p?.nextLineRecord){ 22 | p = p.nextLineRecord; 23 | if(p === this.departureRecord || !p.nextLineRecord) return p; 24 | } 25 | return p; 26 | } 27 | 28 | linkAll(stations: Station[]){ 29 | stations.reduce((pre,cur)=>{ 30 | this.link(pre,cur); 31 | return cur; 32 | }); 33 | } 34 | 35 | 36 | // connect B station and C station 37 | link(B: Station, C: Station, bendFirst: boolean = true) { 38 | const railPair = this.applyBestRailPair(B,C,bendFirst); 39 | railPair.setLine(this); 40 | let bLineRecord = B.getJoint(this); 41 | if (!bLineRecord) { 42 | //if record not exist, add one 43 | bLineRecord = new LineRecord(B, this); 44 | // register cLineRecord in C station 45 | B.addLineRecord(bLineRecord); 46 | this.departureRecord = bLineRecord; 47 | } 48 | let cLineRecord// = C.getJoint(this); 49 | if (!cLineRecord) { 50 | cLineRecord = new LineRecord(C, this); 51 | // register cLineRecord in C station 52 | C.addLineRecord(cLineRecord); 53 | } 54 | // establish doubly linked list 55 | bLineRecord?.establishConnectionTo(cLineRecord); 56 | // update rail and connect type for B and C 57 | LineRecord.updateLineRecords(bLineRecord, cLineRecord, railPair); 58 | } 59 | 60 | applyBestRailPair(B: Station, C: Station, bendFirst: boolean) { 61 | const direction = new Vector(B.position, C.position); 62 | if (direction.standard) { 63 | const bOutIndex = Straight.getBestRailIndex(B, C, this); 64 | const cInIndex = Rail.oppositeIndex(bOutIndex); 65 | // if(B.displayStation?.stationName==='风起地站'&&C.displayStation?.stationName==='达达乌帕谷') debugger 66 | const bTrack = B.getTrack(direction); 67 | const cTrack = C.getTrack(direction.opposite()); 68 | const bRail = bTrack.getAvailableRail(bOutIndex); 69 | const cRail = cTrack.getAvailableRail(cInIndex); 70 | return new RailPair(bRail, cRail); 71 | } else { 72 | const [bOutDirection, cInDirectionOpposite] = 73 | direction.getBendSteps(bendFirst); 74 | const cInDirection = cInDirectionOpposite.opposite(); 75 | const bTrack = B.getTrack(bOutDirection); 76 | const cTrack = C.getTrack(cInDirection); 77 | const bLastRail = B.getJoint(this)?.lastRail; 78 | const cNextRail = C.getJoint(this)?.nextRail; 79 | const bOutIndex = Bend.getBestRailIndex(bTrack, bLastRail); 80 | const cInIndex = Bend.getBestRailIndex(cTrack, cNextRail); 81 | const bRail = bTrack.getAvailableRail(bOutIndex); 82 | const cRail = cTrack.getAvailableRail(cInIndex); 83 | return new RailPair(bRail, cRail); 84 | } 85 | } 86 | } 87 | 88 | export class EmptyLine extends Line { 89 | constructor() { 90 | super(); 91 | this.empty = true; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/DataStructure/LineRecord.ts: -------------------------------------------------------------------------------- 1 | import { ConnectType } from "./ConnectType"; 2 | import { Line } from "./Line"; 3 | import { Rail } from "./Rail"; 4 | import { RailPair } from "./RailPair"; 5 | import { Station } from "./Station"; 6 | 7 | export class LineRecord { 8 | station: Station; 9 | line: Line | undefined; 10 | lastLineRecord: LineRecord | undefined; 11 | nextLineRecord: LineRecord | undefined; 12 | lastRail: Rail | undefined; 13 | nextRail: Rail | undefined; 14 | 15 | constructor(station: Station, line?: Line) { 16 | this.station = station; 17 | if (line) { 18 | this.line = line; 19 | } 20 | } 21 | 22 | getInDirection(){ 23 | return this.lastRail?.track.direction; 24 | } 25 | 26 | getOutDirection(){ 27 | return this.nextRail?.track.direction; 28 | } 29 | establishConnectionTo(BRecord: LineRecord) { 30 | LineRecord.establishConnection(this, BRecord); 31 | } 32 | 33 | static establishConnection(ARecord: LineRecord, BRecord: LineRecord) { 34 | ARecord.nextLineRecord = BRecord; 35 | BRecord.lastLineRecord = ARecord; 36 | } 37 | 38 | // update the rail and connect type information for connectting 2 linerecords 39 | static updateLineRecords( 40 | ARecord: LineRecord, 41 | BRecord: LineRecord, 42 | railPair: RailPair, 43 | ) { 44 | const { departureRail, arrivalRail } = railPair; 45 | ARecord.nextRail = departureRail; 46 | BRecord.lastRail = arrivalRail; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/DataStructure/Mode.ts: -------------------------------------------------------------------------------- 1 | export enum Mode { 2 | normal, 3 | moving, 4 | touchMoving, 5 | touchScaling 6 | } 7 | 8 | 9 | export enum FunctionMode{ 10 | normal, 11 | addingStation, 12 | dragingStation, 13 | lineEditing, 14 | backgroundEditing, 15 | customBackground, 16 | editingCustomBackgroundPosition, 17 | selectingStation, 18 | choosingExistMap, 19 | } -------------------------------------------------------------------------------- /src/DataStructure/Point.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, Touch } from "react"; 2 | import { Straight } from "./Straight"; 3 | 4 | export class Point { 5 | x: number; 6 | y: number; 7 | yReversed: boolean; 8 | q_start: boolean; 9 | q: boolean; 10 | q_end: boolean; 11 | constructor(x: number = 0, y: number = 0, yReversed: boolean = false) { 12 | this.x = x; 13 | this.y = y; 14 | this.yReversed = yReversed; 15 | this.q_start = false; 16 | this.q = false; 17 | this.q_end = false; 18 | } 19 | 20 | round(){ 21 | return new Point(Math.round(this.x), Math.round(this.y)) 22 | } 23 | 24 | offset(A: Point) { 25 | return new Point(A.x + this.x, A.y + this.y); 26 | } 27 | 28 | displacementTo(A: Point) { 29 | return new Point(this.x - A.x, this.y - A.y); 30 | } 31 | 32 | distanceTo(A: Point) { 33 | return Math.sqrt(Math.pow(this.x - A.x, 2) + Math.pow(this.y - A.y, 2)); 34 | } 35 | 36 | sameTo(A: Point) { 37 | return A.x === this.x && A.y === this.y && A.yReversed === this.yReversed; 38 | } 39 | 40 | reverseY() { 41 | return new Point(this.x, -this.y, !this.yReversed); 42 | } 43 | 44 | static getPointFromTouch(A: Touch) { 45 | return new Point(A.clientX, A.clientY); 46 | } 47 | 48 | static getPointFromMouse(A: MouseEvent) { 49 | return new Point(A.clientX, A.clientY); 50 | } 51 | 52 | static getMidPoint(A: Point, B: Point) { 53 | return new Point((A.x + B.x) / 2, (A.y + B.y) / 2); 54 | } 55 | 56 | static getDisplacement(A: Point, B: Point) { 57 | return new Point(A.x - B.x, A.y - B.y); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/DataStructure/Rail.ts: -------------------------------------------------------------------------------- 1 | import { EmptyLine, Line } from "./Line"; 2 | import { RailPair } from "./RailPair"; 3 | import { Track } from "./Track"; 4 | 5 | export class Rail { 6 | track: Track; 7 | index: number; 8 | line: Line; 9 | extra: boolean; 10 | constructor(track: Track, index: number) { 11 | this.track = track; 12 | this.index = index; 13 | this.line = new EmptyLine(); 14 | this.extra = false; 15 | } 16 | 17 | oppositeIndex(){ 18 | return 2 - this.index; 19 | } 20 | 21 | setLine(line: Line){ 22 | this.line = line; 23 | } 24 | 25 | static getStraightConnectRailPair(aEmptyRails: Rail[], bEmptyRails: Rail[]) { 26 | if (aEmptyRails.length === 0 || bEmptyRails.length === 0) return; 27 | const railPairs: RailPair[] = []; 28 | aEmptyRails.forEach((aRail) => { 29 | bEmptyRails.forEach((bRail) => { 30 | //found just opposite rail 31 | if (aRail.index + bRail.index === 2) { 32 | railPairs.push(new RailPair(aRail,bRail)); 33 | } 34 | }); 35 | }); 36 | return railPairs; 37 | } 38 | 39 | static getBestRail(rails: Rail[]){ 40 | return rails.find(rail=>rail.index === 1) || rails[0]; 41 | } 42 | 43 | static getRailByIndex(rails: Rail[], index: number){ 44 | return rails.find(rail=>rail.index === index); 45 | } 46 | 47 | static oppositeIndex(index: number){ 48 | return 2 - index; 49 | } 50 | 51 | } 52 | 53 | export class ExtraRail extends Rail{ 54 | constructor(track: Track, index: number){ 55 | super(track, index); 56 | this.extra = true; 57 | } 58 | } 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/DataStructure/RailPair.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from "./Direction"; 2 | import { Line } from "./Line"; 3 | import { Rail } from "./Rail"; 4 | import { Station } from "./Station"; 5 | 6 | export class RailPair { 7 | departureRail: Rail; 8 | arrivalRail: Rail; 9 | center: boolean; 10 | constructor(departureRail: Rail, arrivalRail: Rail) { 11 | this.departureRail = departureRail; 12 | this.arrivalRail = arrivalRail; 13 | this.center = departureRail.index === 1; 14 | } 15 | 16 | reverse(){ 17 | const temp = this.departureRail; 18 | this.departureRail = this.arrivalRail; 19 | this.arrivalRail = temp; 20 | } 21 | 22 | setLine(line: Line){ 23 | this.departureRail.setLine(line); 24 | this.arrivalRail.setLine(line); 25 | } 26 | } -------------------------------------------------------------------------------- /src/DataStructure/Station.ts: -------------------------------------------------------------------------------- 1 | import { StationProps } from "../Data/UserData"; 2 | import { Direct, Direction } from "./Direction"; 3 | import { Line } from "./Line"; 4 | import { LineRecord } from "./LineRecord"; 5 | import { Point } from "./Point"; 6 | import { Rail } from "./Rail"; 7 | import { Track } from "./Track"; 8 | 9 | export class Station { 10 | position: Point; 11 | tracks: Track[]; 12 | lineRecords: Map; 13 | _dev_tag: string | undefined; 14 | handlers: (Line | undefined | null)[]; 15 | displayStation?: StationProps; 16 | constructor(position: Point) { 17 | this.position = position; 18 | this.tracks = new Array(8) 19 | .fill(true) 20 | .map((x, direct: Direct) => new Track(this, new Direction(direct))); 21 | this.lineRecords = new Map(); 22 | this.handlers = new Array(8); 23 | } 24 | 25 | lineCount(){ 26 | const set = new Set(); 27 | this.lineRecords.forEach(line=>{ 28 | set.add(line); 29 | }) 30 | return set.size; 31 | } 32 | isEmpty(direct: Direct){ 33 | return !this.handlers[direct] && this.tracks[direct].isEmpty(); 34 | } 35 | 36 | getBestDirectionForName() { 37 | let space = 0, 38 | endIndex = 0, 39 | maxSpace = 0, 40 | firstSpace; 41 | for (let i = 0; i < 8; i++) { 42 | const empty = !this.handlers[i] && this.tracks[i].isEmpty(); 43 | if (empty) { 44 | space++; 45 | if (space > maxSpace) { 46 | maxSpace = space; 47 | endIndex = i; 48 | } 49 | } else { 50 | if (firstSpace === undefined) firstSpace = space; 51 | space = 0; 52 | } 53 | } 54 | // all empty 55 | if (space === 8 || maxSpace===0) { 56 | return Direct.right; 57 | } 58 | if(maxSpace <= 2) { 59 | if(this.isEmpty(2)) return 2; 60 | if(this.isEmpty(6)) return 6; 61 | if(this.isEmpty(1)) return 1; 62 | if(this.isEmpty(3)) return 3; 63 | if(this.isEmpty(5)) return 5; 64 | if(this.isEmpty(7)) return 7; 65 | } 66 | if(firstSpace === undefined) firstSpace =0; 67 | if (space + firstSpace >= maxSpace) { 68 | // max space containing 0 69 | if (firstSpace >= space) { 70 | return Math.floor((firstSpace - space) / 2); 71 | } else if (space > firstSpace) { 72 | return 7 - Math.floor((space - firstSpace) / 2); 73 | } 74 | } 75 | const best = endIndex - Math.floor(maxSpace / 2) 76 | // max space not containing 0 77 | if(maxSpace%2===0){ 78 | if(best%2===0){ 79 | return best+1; 80 | } 81 | } 82 | return best; 83 | 84 | } 85 | 86 | 87 | getBestDirectionForName2(){ 88 | } 89 | 90 | 91 | addLineRecord(lineRecord: LineRecord) { 92 | const { line, station } = lineRecord; 93 | if (!line) { 94 | throw new Error("line is undefined while add line record to statation"); 95 | } 96 | if (station !== this) { 97 | throw new Error( 98 | "you are adding a line record which not belongs to this station" 99 | ); 100 | } 101 | // this lineRecords is the array saving linerecords 102 | const lineRecordsArr = this.lineRecords.get(line) || []; 103 | lineRecordsArr.push(lineRecord); 104 | // this.lineRecords is the map Line=>Line 105 | // containing all the information of the lines go through this station 106 | this.lineRecords.set(line, lineRecordsArr); 107 | } 108 | 109 | getTrack(direction: Direction) { 110 | return this.tracks[direction.direct]; 111 | } 112 | 113 | getRail(direction: Direction, index: number) { 114 | return this.getTrack(direction).getRail(index); 115 | } 116 | 117 | // getBestRail(direction: Direction){ 118 | // return this.getTrack(direction).getBestRail(); 119 | // } 120 | 121 | // find the start or end of the Line 122 | // if no records find, this station must be the departure station 123 | // if both start and end exist, this station is the loop line joint 124 | getJoint(line: Line) { 125 | const terminal = this.lineRecords 126 | .get(line) 127 | ?.find((lineRecord) => !lineRecord.lastLineRecord); 128 | const departure = this.lineRecords 129 | .get(line) 130 | ?.find((lineRecord) => !lineRecord.nextLineRecord); 131 | return departure || terminal; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/DataStructure/Straight.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from "./Direction"; 2 | import { Line } from "./Line"; 3 | import { Point } from "./Point"; 4 | import { Rail } from "./Rail"; 5 | import { Station } from "./Station"; 6 | import { Track } from "./Track"; 7 | import { Vector } from "./Vector"; 8 | 9 | export class Straight { 10 | // round 1 : filter empty rails 11 | static round1(B: Station, C: Station) { 12 | const direction = new Vector(B.position, C.position); 13 | const bTrack = B.getTrack(direction); 14 | const cTrack = C.getTrack(direction.opposite()); 15 | const initScores = [0, 1, 2]; 16 | let max = 0; 17 | const round1Indexes = initScores 18 | .map((index) => { 19 | let score = 0; 20 | // if rail in bTrack is empty, this rail add one score 21 | if (bTrack.rails[index].line.empty) { 22 | score++; 23 | } 24 | // if rail in cTrack is empty, this rail add one score 25 | if (cTrack.rails[Rail.oppositeIndex(index)].line.empty) { 26 | score++; 27 | } 28 | // record the highest score 29 | if (score > max) { 30 | max = score; 31 | } 32 | return { index, score }; 33 | }) 34 | .filter(({ score }) => { 35 | // find the highest score indexes 36 | return score === max; 37 | }); 38 | return round1Indexes; 39 | } 40 | 41 | // round 2 : filter straight pass rails 42 | static round2( 43 | B: Station, 44 | C: Station, 45 | round1Indexes: RoundResult[], 46 | line: Line 47 | ) { 48 | const direction = new Vector(B.position, C.position); 49 | const bLineRecord = B.getJoint(line); 50 | const cLineRecord = C.getJoint(line); 51 | let max = 0; 52 | const round2Indexes = round1Indexes 53 | .map(({ index }) => { 54 | let score = 0; 55 | if ( 56 | bLineRecord?.lastRail?.track.direction.oppositeTo(direction) && 57 | bLineRecord?.lastRail?.oppositeIndex() === index 58 | ) { 59 | score++; 60 | } 61 | if ( 62 | cLineRecord?.nextRail?.track.direction.sameTo(direction) && 63 | cLineRecord?.nextRail?.index === index 64 | ) { 65 | score++; 66 | } 67 | if (score > max) { 68 | max = score; 69 | } 70 | return { index, score }; 71 | }) 72 | .filter(({ score }) => { 73 | // find the highest score indexes 74 | return score === max; 75 | }); 76 | return round2Indexes; 77 | } 78 | 79 | // round 3 : filter center rails 80 | static round3(B: Station, C: Station, round2Indexes: RoundResult[]) { 81 | const round3Res = round2Indexes.find(({ index }) => index === 1); 82 | return round3Res ? 1 : 0; 83 | } 84 | 85 | // round 4 : filter most close to the last or next rail 86 | static round4(B: Station, C: Station, line: Line) { 87 | const direction = new Vector(B.position, C.position); 88 | const bLineRecord = B.getJoint(line); 89 | const cLineRecord = C.getJoint(line); 90 | let zeroRailScore = 0; 91 | let secondRailScore = 0; 92 | if (bLineRecord?.lastRail) 93 | if (bLineRecord.lastRail.track.direction.rotationTo(direction) > 0) 94 | zeroRailScore++; 95 | else secondRailScore++; 96 | 97 | if (cLineRecord?.nextRail) 98 | if (cLineRecord.nextRail.track.direction.rotationTo(direction) > 0) 99 | zeroRailScore++; 100 | else secondRailScore++; 101 | 102 | return zeroRailScore > secondRailScore ? 0 : 2; 103 | } 104 | 105 | static getBestRailIndex(B: Station, C: Station, line: Line) { 106 | if (B._dev_tag === "B" && line._dev_tag === "line8") { 107 | // debugger; 108 | } 109 | // round 1: 110 | const round1Indexes = Straight.round1(B, C); 111 | if (round1Indexes.length === 1) { 112 | return round1Indexes[0].index; 113 | } 114 | // round 2: 115 | const round2Indexes = Straight.round2(B, C, round1Indexes, line); 116 | if (round2Indexes.length === 1) { 117 | return round2Indexes[0].index; 118 | } 119 | // round 3: 120 | const round3Index = Straight.round3(B, C, round2Indexes); 121 | if (round3Index === 1) { 122 | return round3Index; 123 | } 124 | // round 4: 125 | const round4Index = Straight.round4(B, C, line); 126 | return round4Index; 127 | } 128 | } 129 | 130 | class RoundResult { 131 | score!: number; 132 | index!: number; 133 | } 134 | -------------------------------------------------------------------------------- /src/DataStructure/Track.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from "./Direction"; 2 | import { ExtraRail, Rail } from "./Rail"; 3 | import { Station } from "./Station"; 4 | 5 | export class Track { 6 | station: Station; 7 | direction: Direction; 8 | rails: Rail[]; 9 | // extra rail won't occupy the space of rail 10 | // extraRails save extraRail by index, every index has an array 11 | extraRails: ExtraRail[][]; 12 | constructor(station: Station, direction: Direction) { 13 | this.station = station; 14 | this.direction = direction; 15 | this.rails = new Array(3) 16 | .fill(true) 17 | .map((x, index) => new Rail(this, index)); 18 | this.extraRails = new Array(3).fill(true).map((x, index) => new Array()); 19 | } 20 | 21 | isEmpty(){ 22 | return !this.rails.some(rail=>!rail.line.empty) 23 | } 24 | 25 | getEmptyRails() { 26 | return this.rails.filter((rail) => rail.line.empty); 27 | } 28 | 29 | getRail(index: number) { 30 | return this.rails[index]; 31 | } 32 | 33 | applyExtraRail(index: number) { 34 | const extraRail = new ExtraRail(this, index); 35 | this.extraRails[index].push(extraRail); 36 | return extraRail; 37 | } 38 | 39 | getAvailableRail(index: number) { 40 | const rail = this.getRail(index); 41 | return rail.line.empty ? rail : this.applyExtraRail(index); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/DataStructure/Vector.ts: -------------------------------------------------------------------------------- 1 | import { Direct, Direction, DirectionVictorReverseY } from "./Direction"; 2 | import { Point } from "./Point"; 3 | 4 | export class Vector extends Direction { 5 | start: Point; // startPoint 6 | end: Point; // endPoint 7 | constructor(start: Point, end: Point) { 8 | super(Vector.getDirection(start, end)); 9 | this.start = start; 10 | this.end = end; 11 | } 12 | 13 | verticalProlong(length: number) { 14 | const A = this.start, 15 | B = this.end; 16 | if (A.x === B.x) 17 | return [new Point(B.x + length, B.y), new Point(B.x - length, B.y)]; 18 | const diffSign = (A.xB.x&&A.y>B.y); 19 | const tan = (B.y - A.y) / (B.x - A.x); 20 | const alpha = Math.abs(Math.atan(tan)); 21 | const sin = Math.sin(alpha); 22 | const cos = Math.cos(alpha); 23 | 24 | if(diffSign) 25 | return [ 26 | new Point(B.x - sin * length, B.y + cos * length), 27 | new Point(B.x + sin * length, B.y - cos * length), 28 | ]; 29 | return [ 30 | new Point(B.x - sin * length, B.y - cos * length), 31 | new Point(B.x + sin * length, B.y + cos * length), 32 | ]; 33 | 34 | } 35 | 36 | prolong(length: number) { 37 | const A = this.start, 38 | B = this.end; 39 | const kX = A.x < B.x ? 1 : -1; 40 | const kY = A.y < B.y ? 1 : -1; 41 | if (A.x === B.x) return new Point(A.x, B.y + kY * length); 42 | const tan = (B.y - A.y) / (B.x - A.x); 43 | const alpha = Math.abs(Math.atan(tan)); 44 | const sin = Math.sin(alpha); 45 | const cos = Math.cos(alpha); 46 | return new Point(B.x + kX * cos * length, B.y + kY * sin * length); 47 | } 48 | 49 | normalize(k: number = 1) { 50 | const deltaX = this.end.x - this.start.x; 51 | const deltaY = this.end.y - this.start.y; 52 | const module = Math.sqrt( 53 | Math.pow(deltaX, 2) + Math.pow(deltaY, 2) 54 | ); 55 | return new Vector( 56 | new Point(0, 0), 57 | new Point((k * deltaX) / module, (k * deltaY) / module) 58 | ); 59 | } 60 | 61 | passesThroughPoint(A: Point) { 62 | return ( 63 | (A.x - this.start.x) * (A.y - this.end.y) === 64 | (A.y - this.start.y) * (A.x - this.end.x) 65 | ); 66 | } 67 | 68 | round() { 69 | return new Vector(this.start.round(), this.end.round()); 70 | } 71 | 72 | passesThroughPointRound(point: Point) { 73 | const A = point.round(); 74 | const vector = this.round(); 75 | return this.passesThroughPoint.bind(vector)(A); 76 | } 77 | 78 | getCrossPointTo(b: Vector) { 79 | const a = this; 80 | const A = a.start, 81 | B = a.end, 82 | C = b.start, 83 | D = b.end; 84 | 85 | const m = (A.x - B.x) / (A.y - B.y); 86 | const n = (C.x - D.x) / (C.y - D.y); 87 | // if(!Number.isFinite(m)) return new Point(A.x, (A.x - C.x) / n + C.y); 88 | // if(!Number.isFinite(n)) return new Point(C.x, (C.x - A.x) / m + A.y); 89 | if (!Number.isFinite(m)) return new Point(n * (A.y - C.y) + C.x, A.y); 90 | if (!Number.isFinite(n)) return new Point(m * (C.y - A.y) + A.x, C.y); 91 | const y = (C.x - A.x + m * A.y - n * C.y) / (m - n); 92 | const x = n * (y - C.y) + C.x; 93 | return new Point(x, y); 94 | } 95 | 96 | static getVectorByPointAndDirection(A: Point, direction: Direction) { 97 | const [x, y] = DirectionVictorReverseY[direction.direct]; 98 | return new Vector(A, new Point(A.x + x, A.y + y)); 99 | } 100 | 101 | static getDirection(start: Point, end: Point) { 102 | const A = start.reverseY(); 103 | const B = end.reverseY(); 104 | if (A.x === B.x && A.y === B.y) return Direct.coincide; 105 | if (A.x === B.x) return B.y > A.y ? Direct.up : Direct.down; 106 | if (A.y === B.y) return B.x > A.x ? Direct.right : Direct.left; 107 | const deltaX = B.x - A.x; 108 | const deltaY = B.y - A.y; 109 | if (deltaX > 0 && deltaY > 0) 110 | if (deltaX === deltaY) return Direct.upRight; 111 | else if (deltaX < deltaY) return Direct.upRightA; 112 | else return Direct.upRightB; 113 | if (deltaX > 0 && deltaY < 0) 114 | if (deltaX === -deltaY) return Direct.rightDown; 115 | else if (deltaX > -deltaY) return Direct.rightDownA; 116 | else return Direct.rightDownB; 117 | if (deltaX < 0 && deltaY < 0) 118 | if (-deltaX === -deltaY) return Direct.downLeft; 119 | else if (-deltaX < -deltaY) return Direct.downLeftA; 120 | else return Direct.downLeftB; 121 | if (deltaX < 0 && deltaY > 0) 122 | if (-deltaX === deltaY) return Direct.leftUp; 123 | else if (-deltaX > deltaY) return Direct.leftUpA; 124 | else return Direct.leftUpB; 125 | debugger 126 | throw Error("error happend when getting direction"); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Entrance/App.scss: -------------------------------------------------------------------------------- 1 | 2 | body{ 3 | margin: 0; 4 | touch-action: none; 5 | overscroll-behavior: none; 6 | } 7 | html{ 8 | touch-action: none; 9 | overscroll-behavior: none; 10 | } 11 | 12 | .App{ 13 | height: 100vh; 14 | width: 100vw; 15 | overflow: hidden; 16 | user-select: none; 17 | } 18 | 19 | *{ 20 | font-family: 'PingFang SC'; 21 | -webkit-tap-highlight-color: transparent; 22 | } -------------------------------------------------------------------------------- /src/Entrance/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | import React from 'react'; 4 | import { Direct, Direction } from '../DataStructure/Direction'; 5 | import { Track } from '../DataStructure/Track'; 6 | import { Station } from '../DataStructure/Station'; 7 | import { Point } from '../DataStructure/Point'; 8 | import { EmptyLine, Line } from '../DataStructure/Line'; 9 | import { Vector } from '../DataStructure/Vector'; 10 | 11 | test('renders learn react link', () => { 12 | render(); 13 | const linkElement = screen; 14 | const direction = new Direction(Direct.up); 15 | const station = new Station(new Point(1,2)); 16 | const track = new Track(station,new Direction(Direct.up)); 17 | const A = new Point(1,2); 18 | const B = new Point(3,4); 19 | const vector = new Vector(A,B); 20 | console.log(station.getTrack(vector).direction.direct) 21 | console.log(direction) 22 | 23 | expect(linkElement).toBeDefined(); 24 | }); 25 | 26 | 27 | test('vector cross pointer',()=>{ 28 | const a = new Vector(new Point(0,0), new Point(1,1)); 29 | const b = new Vector(new Point(0,5), new Point(5,0)); 30 | const abCrossPoint = a.getCrossPointTo(b); 31 | console.log(abCrossPoint); 32 | expect(abCrossPoint.x).toEqual(2.5); 33 | expect(abCrossPoint.y).toEqual(2.5); 34 | 35 | }) -------------------------------------------------------------------------------- /src/Entrance/App.tsx: -------------------------------------------------------------------------------- 1 | import '../i18n/config'; 2 | import { ErrorBoundary } from "react-error-boundary"; 3 | import { 4 | browserInfo, 5 | mapToArr, 6 | mediateMap, 7 | readFileFromIndexedDB, 8 | setLocalStorage, 9 | } from "../Common/util"; 10 | import { 11 | CardShowing, 12 | ChangeSteps, 13 | InsertInfo, 14 | LineChanges, 15 | LineProps, 16 | RecordType, 17 | StationProps, 18 | UserDataType, 19 | initData, 20 | setDataFromJson, 21 | } from "../Data/UserData"; 22 | import { FunctionMode, Mode } from "../DataStructure/Mode"; 23 | import { Cards } from "../Render/Card/Cards"; 24 | import { 25 | DeleteConfirmation, 26 | showConfirmationInterface, 27 | } from "../Render/Delete/DeleteConfirmation"; 28 | import { Menu } from "../Render/Header/Menu"; 29 | import ScaleLayer from "../Render/Layer/ScaleLayer"; 30 | import { WelcomeTour } from "../WelcomeTour/WelcomeTour"; 31 | import "./App.scss"; 32 | import "driver.js/dist/driver.css"; 33 | import React, { useEffect, useRef, useState } from "react"; 34 | import { ErrorFallback } from "../Render/ErrorFallback/ErrorFallback"; 35 | import { Recovery } from "../Render/Recovery/Recovery"; 36 | function App() { 37 | const [editingMode, setEditingMode] = useState(Mode.normal); 38 | const [functionMode, setFunctionMode] = useState(FunctionMode.normal); 39 | const [record, setRecord] = useState([]); 40 | const [currentRecordIndex, setCurrentRecordIndex] = useState(-1); 41 | const [insertInfo, setInsertInfo] = useState(); 42 | const [data, setDataOriginal] = useState(initData); 43 | const [showName, setShowName] = useState(true); 44 | const [autoHiddenName, setAutoHiddenName] = useState(true); 45 | const [drawing, setDrawing] = useState(false); 46 | const [translateX, setTranslateX] = useState(0); 47 | const [translateY, setTranslateY] = useState(0); 48 | const [page, setPage] = useState("title"); 49 | const [scale, setScale] = useState(1); 50 | const ref = useRef(); 51 | const menuRef = useRef(); 52 | const [saved, setSaved] = useState(true); 53 | const [defaultShape, setDefaultShape] = useState('cicle'); 54 | const [showTour, setShowTour] = useState(() => { 55 | return ( 56 | window.innerWidth >= 710 && !localStorage.getItem("skip-tour-viewed") 57 | ); 58 | }); 59 | const [recoveredFromError, setRecoveredFromError] = useState(false); 60 | const [showConfirmation, setShowConfirmation] = 61 | useState(); 62 | // keep latest data if crash happend 63 | const setData = (data: React.SetStateAction) => { 64 | if (typeof data === "function") { 65 | setDataOriginal((state) => { 66 | const newState = data(state); 67 | setLocalStorage(newState, () => setSaved(false)); 68 | return newState; 69 | }); 70 | } else { 71 | setLocalStorage(data, () => setSaved(false)); 72 | setDataOriginal(data); 73 | } 74 | }; 75 | useEffect(() => { 76 | setShowConfirmation(() => ref.current?.showConfirmation); 77 | }, [ref.current?.showConfirmation]); 78 | const [cardShowing, setCardShowing] = useState(new CardShowing()); 79 | const transfromTools = { 80 | scale, 81 | setScale, 82 | translateX, 83 | translateY, 84 | setTranslateX, 85 | setTranslateY, 86 | }; 87 | const {backgroundColor} = data; 88 | useEffect(() => { 89 | document.getElementById('theme-color')!.setAttribute('content', backgroundColor?backgroundColor:"#ffffff"); 90 | }, [backgroundColor]) 91 | return ( 92 | { 95 | const last = localStorage.getItem("last"); 96 | if (last) { 97 | const data = setDataFromJson(setData, last); 98 | readFileFromIndexedDB("image") 99 | .then((file) => { 100 | setData((data) => ({ 101 | ...data, 102 | // backgroundColor: "image", 103 | backgroundImage: file as File, 104 | })); 105 | }) 106 | .catch((e) => { 107 | console.error(e); 108 | }); 109 | mediateMap(data, transfromTools); 110 | } 111 | setRecoveredFromError(true); 112 | }} 113 | > 114 |
115 | 151 | 152 | 183 | 195 | 196 | 203 |
204 |
205 | ); 206 | } 207 | 208 | export default App; 209 | -------------------------------------------------------------------------------- /src/Entrance/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/Entrance/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/Line/Handle.ts: -------------------------------------------------------------------------------- 1 | import { Station } from "./../DataStructure/Station"; 2 | import { 3 | Direct, 4 | Direction, 5 | DirectionVictorReverseY, 6 | } from "../DataStructure/Direction"; 7 | import { Line } from "../DataStructure/Line"; 8 | import { LineRecord } from "../DataStructure/LineRecord"; 9 | import { Point } from "../DataStructure/Point"; 10 | import { Vector } from "../DataStructure/Vector"; 11 | import { handleLength, handleWidth } from "../Common/const"; 12 | 13 | const getLPLPoints = (allKeyPoints: Point[]) => { 14 | const LQLPoints = allKeyPoints.slice(); 15 | // LQLPoints.pop(); 16 | // LQLPoints.shift(); 17 | return LQLPoints; 18 | }; 19 | 20 | const checkIfStraightTrackHasHanderOrLine = ( 21 | direction: Direction, 22 | station: Station 23 | ) => { 24 | const { direct } = direction.opposite(); 25 | return station.handlers[direct] || !station.tracks[direct].isEmpty(); 26 | }; 27 | const checkifHandeCanGoStraight = ( 28 | outDirection: Direction, 29 | station: Station 30 | ) => { 31 | const ifStraightTrackHasHander = !checkIfStraightTrackHasHanderOrLine( 32 | outDirection, 33 | station 34 | ); 35 | return ifStraightTrackHasHander; 36 | }; 37 | const getDepartureGoStraightHandeCommand = (A: Point, B: Point) => { 38 | const BA = new Vector(B, A); 39 | const C = BA.prolong(handleLength); 40 | const BC = new Vector(B, C); 41 | const [D, E] = BC.verticalProlong(handleWidth); 42 | return `M ${D.x} ${D.y} L ${E.x} ${E.y} M ${C.x} ${C.y}`; 43 | }; 44 | const addHandleForStation = (station: Station, line: Line, direct: Direct) => { 45 | station.handlers[direct] = line; 46 | }; 47 | const getBestDirectionForHandle = (station: Station, direction: Direction) => { 48 | let min = Infinity, 49 | bestChoice = 0; 50 | for (let i = 0; i < station.handlers.length; i++) { 51 | const notEmpty = station.handlers[i] || !station.tracks[i].isEmpty(); 52 | if (!notEmpty) { 53 | const delta = direction.opposite().delta(i); 54 | if (delta < min) { 55 | bestChoice = i; 56 | min = delta; 57 | } 58 | } 59 | } 60 | return bestChoice; 61 | }; 62 | const getDepartureBestChoiceHandeCommand = ( 63 | A: Point, 64 | B: Point, 65 | pathStartPoint: Point 66 | ) => { 67 | const F = pathStartPoint; 68 | const AB = new Vector(A, B); 69 | const C = AB.prolong(handleLength - 1); 70 | const BC = new Vector(B, C); 71 | const [D, E] = BC.verticalProlong(handleWidth); 72 | return `M ${D.x} ${D.y} L ${E.x} ${E.y} M ${C.x} ${C.y} L ${A.x} ${A.y} L ${F.x} ${F.y}`; 73 | }; 74 | const getBestChoiceHandleCommand = ( 75 | station: Station, 76 | direction: Direction, 77 | pathStartPoint: Point, 78 | line: Line 79 | ) => { 80 | const handleDirect = getBestDirectionForHandle(station, direction); 81 | if (handleDirect === undefined) { 82 | return ` M ${pathStartPoint.x} ${pathStartPoint.y}`; 83 | } else { 84 | const A = station.position; 85 | const [x, y] = DirectionVictorReverseY[handleDirect]; 86 | const B = new Point(A.x + x, A.y + y); 87 | addHandleForStation(station, line!, handleDirect); 88 | return getDepartureBestChoiceHandeCommand(A, B, pathStartPoint); 89 | } 90 | }; 91 | const getStartHandleCommand = ( 92 | A: Point, 93 | B: Point, 94 | departureRecord: LineRecord 95 | ) => { 96 | let command = ""; 97 | const outDirection = departureRecord?.getOutDirection(); 98 | const { station, line } = departureRecord; 99 | const ifHandeCanGoStraight = checkifHandeCanGoStraight( 100 | outDirection!, 101 | station 102 | ); 103 | if (ifHandeCanGoStraight) { 104 | command = getDepartureGoStraightHandeCommand(A, B); 105 | addHandleForStation(station, line!, outDirection!.opposite().direct); 106 | } else { 107 | command = getBestChoiceHandleCommand(station, outDirection!, A, line!); 108 | } 109 | return command; 110 | }; 111 | 112 | const getTerminalGoStraightHandeCommand = (C: Point, D: Point) => { 113 | const CD = new Vector(C, D); 114 | const E = CD.prolong(handleLength); 115 | const CE = new Vector(C, E); 116 | const [F, G] = CE.verticalProlong(handleWidth); 117 | return ` L ${E.x} ${E.y} M ${F.x} ${F.y} L ${G.x} ${G.y}`; 118 | }; 119 | 120 | const getTerminalBestChoiceHandeCommand = ( 121 | A: Point, 122 | B: Point, 123 | pathEndPoint: Point 124 | ) => { 125 | const F = pathEndPoint; 126 | const AB = new Vector(A, B); 127 | const C = AB.prolong(handleLength - 1); 128 | const BC = new Vector(B, C); 129 | const [D, E] = BC.verticalProlong(handleWidth); 130 | return `L ${F.x} ${F.y} L ${A.x} ${A.y} L ${C.x} ${C.y} M ${D.x} ${D.y} L ${E.x} ${E.y} `; 131 | }; 132 | 133 | const getBestChoiceTerminalHandleCommand = ( 134 | station: Station, 135 | direction: Direction, 136 | pathStartPoint: Point, 137 | line: Line 138 | ) => { 139 | const handleDirect = getBestDirectionForHandle(station, direction); 140 | if (handleDirect === undefined) { 141 | return ` L ${pathStartPoint.x} ${pathStartPoint.y}`; 142 | } else { 143 | const A = station.position; 144 | const [x, y] = DirectionVictorReverseY[handleDirect]; 145 | const B = new Point(A.x + x, A.y + y); 146 | addHandleForStation(station, line!, handleDirect); 147 | return getTerminalBestChoiceHandeCommand(A, B, pathStartPoint); 148 | } 149 | }; 150 | 151 | const getEndHandleCommand = ( 152 | C: Point, 153 | D: Point, 154 | terminalRecord: LineRecord 155 | ) => { 156 | let command = ""; 157 | const inDirection = terminalRecord?.getInDirection(); 158 | const { station, line } = terminalRecord; 159 | const ifHandeCanGoStraight = checkifHandeCanGoStraight(inDirection!, station); 160 | if (ifHandeCanGoStraight) { 161 | command = getTerminalGoStraightHandeCommand(C, D); 162 | addHandleForStation(station, line!, inDirection!.opposite().direct); 163 | } else { 164 | command = getBestChoiceTerminalHandleCommand( 165 | station, 166 | inDirection!, 167 | D, 168 | line! 169 | ); 170 | } 171 | return command; 172 | }; 173 | const getHandleCommand = (line: Line, allKeyPoints: Point[]) => { 174 | const [A, B] = allKeyPoints; 175 | const C = allKeyPoints[allKeyPoints.length - 2], 176 | D = allKeyPoints[allKeyPoints.length - 1]; 177 | const { departureRecord, displayLine } = line; 178 | const { subLine } = displayLine!; 179 | const terminalRecord = line.getTerminalRecord(); 180 | let startHandleCommand = getStartHandleCommand(A, B, departureRecord!); 181 | let endHandleCommand = ` L ${A.x} ${A.y}`; // loop line 182 | if (!(departureRecord?.station === terminalRecord?.station)) { 183 | endHandleCommand = getEndHandleCommand(C, D, terminalRecord!); 184 | } 185 | const LQLPoints = getLPLPoints(allKeyPoints); 186 | // subline no need handler in joint 187 | if ( 188 | subLine && 189 | departureRecord?.station?.lineCount && 190 | departureRecord?.station?.lineCount() >= 2 191 | ) { 192 | startHandleCommand = ` M ${A.x} ${A.y} `; 193 | } 194 | if ( 195 | subLine && 196 | terminalRecord?.station?.lineCount && 197 | terminalRecord?.station?.lineCount() >= 2 198 | ) { 199 | endHandleCommand = ""; 200 | } 201 | return { startHandleCommand, LQLPoints, endHandleCommand }; 202 | }; 203 | const clearHandleFromRecord = (lineRecord: LineRecord | undefined) => { 204 | if (lineRecord) { 205 | const { station, line } = lineRecord; 206 | station.handlers.forEach((handle, index) => { 207 | if (handle === line) { 208 | station.handlers[index] = null; 209 | } 210 | }); 211 | } else { 212 | console.error("no linerecord to clear handle"); 213 | } 214 | }; 215 | const clearHandle = (line: Line) => { 216 | const { departureRecord } = line; 217 | const terminalRecord = line.getTerminalRecord(); 218 | clearHandleFromRecord(departureRecord); 219 | clearHandleFromRecord(terminalRecord); 220 | }; 221 | 222 | export { getHandleCommand, clearHandle }; 223 | -------------------------------------------------------------------------------- /src/Line/LinePoints.ts: -------------------------------------------------------------------------------- 1 | import { gauge, line_radius } from "../Common/const"; 2 | import { Direction } from "../DataStructure/Direction"; 3 | import { Line } from "../DataStructure/Line"; 4 | import { LineRecord } from "../DataStructure/LineRecord"; 5 | import { Point } from "../DataStructure/Point"; 6 | import { Rail } from "../DataStructure/Rail"; 7 | import { Vector } from "../DataStructure/Vector"; 8 | 9 | const getTurningPoint = ( 10 | A: Point, 11 | B: Point, 12 | aDirection: Direction, 13 | bDirection: Direction 14 | ) => { 15 | const aVector = Vector.getVectorByPointAndDirection(A, aDirection); 16 | const bVector = Vector.getVectorByPointAndDirection(B, bDirection); 17 | const crossPoint = aVector.getCrossPointTo(bVector); 18 | return crossPoint; 19 | }; 20 | const getOffsetPointFromDirectionAndRail = ( 21 | point: Point, 22 | direction: Direction, 23 | rail: Rail 24 | ) => { 25 | // if(rail.track.station.displayStation!.stationName==='风起地站' && rail.line.displayLine?.lineName==='2号线') 26 | // debugger 27 | const s = Math.SQRT1_2; 28 | 29 | // lean 30 | const directionOffset = [ 31 | [[-1,0],[0,0],[1,0]],// 0 up 32 | [[-s,-s],[0,0],[s,s]],// 1 upRight 33 | [[0,-1],[0,0],[0,1]],// 2 right 34 | [[s,-s],[0,0],[-s,s]],// 3 rightDown 35 | [[1,0],[0,0],[-1,0]],// 4 down 36 | [[s,s],[0,0],[-s,-s]],// 5 downLeft 37 | [[0,1],[0,0],[0,-1]],// 6 left 38 | [[-s,s],[0,0],[s,-s]],// 7 leftUp 39 | ]; 40 | 41 | // const directionOffset = [ 42 | // [0, -1], 43 | // [SQRT1_2, -SQRT1_2], 44 | // [1, 0], 45 | // [SQRT1_2, SQRT1_2], 46 | // [0, 1], 47 | // [-SQRT1_2, SQRT1_2], 48 | // [-1, 0], 49 | // [-SQRT1_2, -SQRT1_2], 50 | // ]; 51 | // const offsetIndex = (direction.direct + rail.index - 1 + 8) % 8; 52 | const offset = directionOffset[direction.direct]; 53 | const [offsetX, offsetY] = offset[rail.index].map((x) => x * gauge); 54 | return new Point(offsetX + point.x, offsetY + point.y); 55 | }; 56 | const getStartOffsetPointOfStation = (lineRecord: LineRecord) => { 57 | const { station, nextRail } = lineRecord; 58 | return getOffsetPointFromDirectionAndRail( 59 | station.position, 60 | nextRail!.track.direction, 61 | nextRail! 62 | ); 63 | }; 64 | const getEndOffsetPointOfStation = (lineRecord: LineRecord) => { 65 | const { station, lastRail } = lineRecord; 66 | return getOffsetPointFromDirectionAndRail( 67 | station.position, 68 | lastRail!.track.direction, 69 | lastRail! 70 | ); 71 | }; 72 | const getInOffsetPointOfStation = (lineRecord: LineRecord) => { 73 | const { station, lastRail } = lineRecord; 74 | return getOffsetPointFromDirectionAndRail( 75 | station.position, 76 | lastRail!.track.direction, 77 | lastRail! 78 | ); 79 | }; 80 | const getOutOffsetPointOfStation = (lineRecord: LineRecord) => { 81 | const { station, nextRail } = lineRecord; 82 | return getOffsetPointFromDirectionAndRail( 83 | station.position, 84 | nextRail!.track.direction, 85 | nextRail! 86 | ); 87 | }; 88 | const getPointsBetweenStations = (lineRecord: LineRecord) => { 89 | const { nextLineRecord } = lineRecord; 90 | if (nextLineRecord) { 91 | const AOffsetPoint = getStartOffsetPointOfStation(lineRecord); 92 | const BOffsetPoint = getEndOffsetPointOfStation(nextLineRecord); 93 | if ( 94 | lineRecord.getOutDirection()!.oppositeTo(nextLineRecord.getInDirection()) 95 | ) { 96 | //direct to next station 97 | return [AOffsetPoint, BOffsetPoint]; 98 | } else { 99 | //has turning 100 | const turningPoint = getTurningPoint( 101 | AOffsetPoint, 102 | BOffsetPoint, 103 | lineRecord.getOutDirection()!, 104 | nextLineRecord.getInDirection()! 105 | ); 106 | return [AOffsetPoint, turningPoint, BOffsetPoint]; 107 | } 108 | } else throw new Error("No NextLineRecord!"); 109 | return []; 110 | }; 111 | const getPointsInStation = (lineRecord: LineRecord) => { 112 | const { nextLineRecord, lastLineRecord } = lineRecord; 113 | if (nextLineRecord && lastLineRecord) { 114 | const AOffsetPoint = getInOffsetPointOfStation(lineRecord); 115 | const BOffsetPoint = getOutOffsetPointOfStation(lineRecord); 116 | const inDirection = lineRecord.getInDirection(); 117 | const outDirection = lineRecord.getOutDirection(); 118 | // same point in and out 119 | if ( 120 | AOffsetPoint.sameTo(BOffsetPoint) || 121 | inDirection?.oppositeTo(outDirection) 122 | ) 123 | return []; 124 | else { 125 | // same in same out, need two points 126 | if (inDirection?.sameTo(outDirection!)) { 127 | return []; // no need points cause last and next will add end and start points 128 | // return [AOffsetPoint, BOffsetPoint]; 129 | } 130 | const crossPointInStation = getTurningPoint( 131 | AOffsetPoint, 132 | BOffsetPoint, 133 | inDirection!, 134 | outDirection! 135 | ); 136 | // if(crossPointInStation.x === Infinity) debugger 137 | // not same point, but cross in inPoint or outPoint 138 | if ( 139 | crossPointInStation.sameTo(AOffsetPoint) || 140 | crossPointInStation.sameTo(BOffsetPoint) 141 | ) 142 | return []; 143 | return [crossPointInStation]; 144 | } 145 | } else return []; 146 | }; 147 | const isPointInStationInNextLine = ([A]: Point[], [B, C]: Point[]) => { 148 | if (A) { 149 | const BC = new Vector(B, C); 150 | return BC.passesThroughPoint(A); 151 | } 152 | return false; 153 | }; 154 | // keypoints record all points to draw line 155 | const getAllKeyPoints = (line: Line) => { 156 | let lineRecord = line.departureRecord; 157 | let keyPoints: Point[] = []; 158 | if (!lineRecord) { 159 | console.error("No DepartureStation!"); 160 | return []; 161 | } 162 | while (lineRecord.nextLineRecord) { 163 | const pointsInStation = getPointsInStation(lineRecord); 164 | // if(pointsInStation[0]&&(pointsInStation[0].x === 500 && pointsInStation[0].y === 600)) debugger; 165 | keyPoints = keyPoints.concat(pointsInStation); 166 | const pointsBetweenStations = getPointsBetweenStations(lineRecord); 167 | 168 | // cross point in next line, delete next line start point 169 | if (isPointInStationInNextLine(pointsInStation, pointsBetweenStations)) 170 | pointsBetweenStations.shift(); 171 | // next line start point is the same with this line's end, 172 | // delete next line start point 173 | if ( 174 | keyPoints.length && 175 | pointsBetweenStations[0].sameTo(keyPoints[keyPoints.length - 1]) 176 | ) 177 | pointsBetweenStations.shift(); 178 | keyPoints = keyPoints.concat(pointsBetweenStations); 179 | lineRecord = lineRecord.nextLineRecord; 180 | if (lineRecord === line.departureRecord) break; 181 | } 182 | // to-do why duplicate 183 | return keyPoints.filter((p,index)=>index>0?!(p.x===keyPoints[index-1].x&&p.y===keyPoints[index-1].y):true); 184 | }; 185 | 186 | const deleteDuplicatedPoints = (keyPoints: Point[]) => { 187 | if (keyPoints.length <= 2) return keyPoints; // no need delete duplicate point 188 | const start = keyPoints[0], 189 | end = keyPoints[keyPoints.length - 1]; 190 | const QKeyPoints: Point[] = [start]; 191 | for (let i = 1; i < keyPoints.length - 1; i++) { 192 | const A = keyPoints[i - 1]; 193 | const B = keyPoints[i]; 194 | const C = keyPoints[i + 1]; 195 | const AC = new Vector(A, C); 196 | const AB = new Vector(A, B); 197 | const BC = new Vector(B, C); 198 | if (!AC.passesThroughPointRound(B) //|| AB.direct !== BC.direct 199 | ) 200 | QKeyPoints.push(B); 201 | } 202 | QKeyPoints.push(end); 203 | return QKeyPoints; 204 | }; 205 | 206 | const addLPointsAroundQPoints = (qPoints: Point[]) => { 207 | if (qPoints.length <= 2) return qPoints; // no need add L points 208 | const start = qPoints[0], 209 | end = qPoints[qPoints.length - 1]; 210 | const LQLKeyPoints: Point[] = [start]; 211 | for (let i = 1; i < qPoints.length - 1; i++) { 212 | const A = qPoints[i - 1]; 213 | const B = qPoints[i]; 214 | const C = qPoints[i + 1]; 215 | const BA = new Vector(B, A); 216 | const BC = new Vector(B, C); 217 | const BAOffsetPoint = BA.normalize(line_radius).end.offset(B); 218 | const BCOffsetPoint = BC.normalize(line_radius).end.offset(B); 219 | BAOffsetPoint.q_start = true; 220 | B.q = true; 221 | BCOffsetPoint.q_end = true; 222 | LQLKeyPoints.push(BAOffsetPoint); 223 | LQLKeyPoints.push(B); 224 | LQLKeyPoints.push(BCOffsetPoint); 225 | } 226 | LQLKeyPoints.push(end); 227 | return LQLKeyPoints; 228 | }; 229 | const getRoundedPoints = (keyPoints: Point[]) => { 230 | const QKeyPoints = deleteDuplicatedPoints(keyPoints); 231 | const LQLPoints = addLPointsAroundQPoints(QKeyPoints); 232 | return LQLPoints; 233 | }; 234 | const generateLineCommand = (LQLPoints: Point[]) => { 235 | if (LQLPoints.length <= 1) { 236 | console.error( 237 | "no enough point to draw line. now we have " + LQLPoints.length 238 | ); 239 | return ""; 240 | } 241 | const start = LQLPoints[0], 242 | end = LQLPoints[LQLPoints.length - 1]; 243 | const MCommand = ``; 244 | const EndCommand = ` L ${end.x} ${end.y} `; 245 | let path = ""; 246 | for (let i = 1; i < LQLPoints.length - 1; i++) { 247 | const P = LQLPoints[i]; 248 | switch (true) { 249 | case P.q_start: 250 | path += ` L ${P.x} ${P.y} `; 251 | break; 252 | case P.q: 253 | path += ` Q ${P.x} ${P.y} `; 254 | break; 255 | case P.q_end: 256 | path += ` , ${P.x} ${P.y} `; 257 | break; 258 | default: 259 | throw new Error("no Point flag for command!"); 260 | } 261 | } 262 | return MCommand + path + EndCommand; 263 | }; 264 | 265 | export { getAllKeyPoints, getRoundedPoints, generateLineCommand }; 266 | -------------------------------------------------------------------------------- /src/Render/Card/Cards.scss: -------------------------------------------------------------------------------- 1 | .cards { 2 | position: fixed; 3 | bottom: 0; 4 | // left: -65px; 5 | // zoom: 0.85 6 | width: fit-content; 7 | max-width: 100vw; 8 | height: 500px; 9 | overflow-x: scroll; 10 | overflow-y: visible; 11 | white-space: nowrap; 12 | padding-top: 200px; 13 | // padding-right: 100px; 14 | box-sizing: border-box; 15 | pointer-events: none; 16 | &::-webkit-scrollbar { 17 | display: none; 18 | // width: 0.01px; 19 | } 20 | .card-container { 21 | display: inline-block; 22 | pointer-events: auto; 23 | 24 | & > div { 25 | margin-left: 50px; 26 | position: relative; 27 | box-shadow: 0 4px 159px 7px rgba(0, 0, 0, 0.25); 28 | // filter: drop-shadow(0 0 2px #999); 29 | } 30 | &:last-child { 31 | margin-right: 50px; 32 | } 33 | } 34 | 35 | // @media (max-width: 555px) { 36 | // height: 285px; 37 | // &>div{ 38 | // margin-left: 50px; 39 | // // box-shadow: none; 40 | // // border: 1px solid rgba(0, 0, 0, 0.266); 41 | 42 | // &:last-child{ 43 | // margin-right: 50px; 44 | // } 45 | // zoom: 0.7; 46 | // } 47 | // } 48 | } 49 | -------------------------------------------------------------------------------- /src/Render/Card/Cards.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | CSSProperties, 3 | Dispatch, 4 | RefObject, 5 | SetStateAction, 6 | useEffect, 7 | useLayoutEffect, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | import { Line } from "../../DataStructure/Line"; 12 | import { Station } from "../../DataStructure/Station"; 13 | import { DisplayStation } from "../../DataStructure/Display"; 14 | import { LineCard } from "./LineCard"; 15 | import "./Cards.scss"; 16 | import { StationCard } from "./StationCard"; 17 | import { CardShowing, InsertInfo, UserDataType } from "../../Data/UserData"; 18 | import { browserInfo, mapToArr, onWheelX, onWheelY } from "../../Common/util"; 19 | import { showConfirmationInterface } from "../Delete/DeleteConfirmation"; 20 | import { FunctionMode } from "../../DataStructure/Mode"; 21 | 22 | export function Cards({ 23 | data, 24 | setData, 25 | showConfirmation, 26 | menuRef, 27 | functionMode, 28 | setFunctionMode, 29 | insertInfo, 30 | setInsertInfo, 31 | cardShowing, 32 | setCardShowing, 33 | }: { 34 | data: UserDataType; 35 | setData: Dispatch>; 36 | showConfirmation?: showConfirmationInterface; 37 | menuRef: RefObject; 38 | functionMode: FunctionMode; 39 | setFunctionMode: React.Dispatch>; 40 | insertInfo?: InsertInfo; 41 | setInsertInfo: React.Dispatch>; 42 | cardShowing: CardShowing; 43 | setCardShowing: Dispatch>; 44 | }) { 45 | const { lines, stations } = data; 46 | const { engine } = browserInfo; 47 | const [pointerEvents, setPointerEvents] = useState<"auto" | "none">("none"); 48 | const { lineIds, stationIds, stationFirst } = cardShowing; 49 | const linesComp = lineIds?.map((lineId) => { 50 | const line = lines.get(lineId); 51 | if (line) 52 | return ( 53 |
54 | 68 |
69 | ); 70 | }); 71 | const stationComp = stationIds?.map((stationId) => { 72 | const station = stations.get(stationId); 73 | if (station) 74 | return ( 75 |
76 | 90 |
91 | ); 92 | }); 93 | const [style, setStyle] = useState(); 94 | const handleStyle = () => { 95 | const style: CSSProperties = 96 | engine.name === "WebKit" 97 | ? { pointerEvents: "auto", height: 370, paddingTop: 70 } 98 | : {}; 99 | if ( 100 | ((stationIds?.length || 0) + (lineIds?.length || 0)) * 555 < 101 | window.innerWidth 102 | ) { 103 | style.paddingRight = 100; 104 | style.pointerEvents = "none"; 105 | } 106 | setStyle(style); 107 | }; 108 | 109 | useEffect(() => { 110 | handleStyle(); 111 | window.addEventListener("resize", handleStyle); 112 | return () => window.removeEventListener("resize", handleStyle); 113 | }, [cardShowing]); 114 | return ( 115 |
{ 121 | const { target, currentTarget } = e; 122 | if (target === currentTarget) setPointerEvents("none"); 123 | else setPointerEvents("auto"); 124 | }} 125 | > 126 | {stationFirst ? stationComp : linesComp} 127 | {stationFirst ? linesComp : stationComp} 128 |
129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/Render/Component/LineRender.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | CSSProperties, 3 | Dispatch, 4 | SetStateAction, 5 | memo, 6 | useEffect, 7 | useState, 8 | } from "react"; 9 | import { Line } from "../../DataStructure/Line"; 10 | import { Point } from "../../DataStructure/Point"; 11 | 12 | import { 13 | generateLineCommand, 14 | getAllKeyPoints, 15 | getRoundedPoints, 16 | } from "../../Line/LinePoints"; 17 | import { clearHandle, getHandleCommand } from "../../Line/Handle"; 18 | import { gauge } from "../../Common/const"; 19 | import { 20 | CardShowing, 21 | DrawProps, 22 | DrawerSize, 23 | UserDataType, 24 | } from "../../Data/UserData"; 25 | 26 | function LineRender({ 27 | line, 28 | cardShowing, 29 | setCardShowing, 30 | command, 31 | data, 32 | setData, 33 | drawing, 34 | drawerX, 35 | drawerY, 36 | }: { 37 | line: Line; 38 | cardShowing: CardShowing; 39 | setCardShowing: Dispatch>; 40 | command: string; 41 | data: UserDataType; 42 | setData: Dispatch>; 43 | } & DrawProps & 44 | DrawerSize) { 45 | const { stations } = data; 46 | const { displayLine, departureRecord } = line; 47 | const { color, lineId, subLine, lineName } = displayLine!; 48 | const { lineIds, stationIds } = cardShowing; 49 | const showing = lineIds?.length || stationIds?.length; 50 | const emphasis = 51 | lineIds?.includes(lineId) || 52 | (stationIds && 53 | stationIds.length === 1 && 54 | stationIds[0] && 55 | stations.get(stationIds[0]) && 56 | stations.get(stationIds[0])?.lineIds?.includes(lineId)); 57 | 58 | const [moved, setMoved] = useState(false); 59 | const onClick = () => { 60 | if (!moved) { 61 | setCardShowing({ lineIds: [lineId] }); 62 | } 63 | }; 64 | return ( 65 | <> 66 |
67 | 72 | {/* */} 73 | setMoved(false)} 85 | onTouchStart={() => setMoved(false)} 86 | onTouchMove={() => (!moved) && setMoved(true)} 87 | onMouseMove={() => (!moved) && setMoved(true)} 88 | onMouseUp={onClick} 89 | onTouchEnd={onClick} 90 | /> 91 | 92 | {/* */} 93 | 94 |
95 | {/* {renderPoints(allKeyPoints)} */} 96 | 97 | ); 98 | } 99 | 100 | export default memo(LineRender); 101 | -------------------------------------------------------------------------------- /src/Render/Delete/DeleteConfirmation.scss: -------------------------------------------------------------------------------- 1 | .delete-confirmation-container { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | z-index: -1000; 6 | height: 100vh; 7 | width: 100vw; 8 | background-color: rgba(255, 255, 255, 0.2); 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | opacity: 0; 13 | transition: 14 | 0.3s ease-in-out opacity, 15 | 0.3s ease-in-out z-index; 16 | &.before-animated, 17 | &.before-disappear { 18 | backdrop-filter: blur(1px); 19 | } 20 | &.animated { 21 | opacity: 1; 22 | backdrop-filter: blur(10px); 23 | z-index: 1000; 24 | } 25 | .delete-confirmation { 26 | text-align: center; 27 | 28 | .title { 29 | font-weight: 500; 30 | font-size: 36px; 31 | padding-left: 13px; 32 | } 33 | .sub-title { 34 | font-weight: 500; 35 | font-size: 18px; 36 | } 37 | .preview { 38 | margin-top: 74.2px; 39 | display: inline-block; 40 | // border-top: 1px solid black; 41 | width: fit-content; 42 | // padding: 0 40px; 43 | text-align: center; 44 | position: relative; 45 | // display: flex; 46 | // align-items: center; 47 | // justify-content: center; 48 | // &>div{ 49 | // transform: translateY(-50%); 50 | // } 51 | .preview-content { 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | .icon { 56 | display: inline-block; 57 | margin-left: 40px; 58 | 59 | .line { 60 | // width: 35.5px; 61 | margin-right: 35.5px; 62 | 63 | .sign-input { 64 | // width: 35.5px; 65 | * { 66 | min-width: 35.5px; 67 | height: 35.5px; 68 | border: none; 69 | border-radius: 8.87px; 70 | background-color: #ea0b2a; 71 | font-size: 23.25px; 72 | color: white; 73 | text-align: center; 74 | } 75 | } 76 | } 77 | .station { 78 | display: flex; 79 | justify-content: center; 80 | align-items: center; 81 | margin-right: 25.8px; 82 | svg { 83 | &.square { 84 | zoom: 0.9; 85 | } 86 | &.triangle { 87 | zoom: 1.05; 88 | } 89 | &.start { 90 | zoom: 1.15; 91 | } 92 | &.hexagon { 93 | zoom: 0.9; 94 | } 95 | &.pentagon { 96 | zoom: 1.1; 97 | } 98 | &.diamond { 99 | zoom: 1.15; 100 | } 101 | &.leaf { 102 | zoom: 0.9; 103 | } 104 | &.ginkgo { 105 | zoom: 0.8; 106 | } 107 | } 108 | } 109 | } 110 | .text { 111 | margin-right: 40px; 112 | display: inline-block; 113 | font-weight: 500; 114 | font-size: 18px; 115 | 116 | line-height: 35.5px; 117 | // vertical-align: middle; 118 | } 119 | } 120 | 121 | .delete-line { 122 | border-top: 1px solid #ea0b2a; 123 | position: absolute; 124 | top: 50%; 125 | width: 100%; 126 | transition: 0.3s ease-in-out; 127 | // animation-delay: 5s; 128 | transition-delay: 0.3s; 129 | } 130 | } 131 | 132 | .delete { 133 | font-weight: 500; 134 | font-size: 18px; 135 | margin-top: 104.8px; 136 | cursor: pointer; 137 | color: #ea0b2a; 138 | } 139 | .back { 140 | margin-top: 16.12px; 141 | font-weight: 500; 142 | font-size: 18px; 143 | cursor: pointer; 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/Render/Delete/DeleteConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Dispatch, 3 | SetStateAction, 4 | forwardRef, 5 | useEffect, 6 | useImperativeHandle, 7 | useState, 8 | } from "react"; 9 | import { LineProps, StationProps } from "../../Data/UserData"; 10 | import "./DeleteConfirmation.scss"; 11 | import { AutoGrowthInput } from "../../Common/AutoGrowthInput"; 12 | import shapes from "../../Resource/Shape/shape"; 13 | import classNames from "classnames"; 14 | import { useTranslation } from "react-i18next"; 15 | import i18n from "../../i18n/config"; 16 | export interface showConfirmationInterface { 17 | ( 18 | { 19 | line, 20 | station, 21 | stationIndex, 22 | }: { line?: LineProps; station?: StationProps; stationIndex?: number }, 23 | callback?: any 24 | ): void; 25 | } 26 | enum ShowMode { 27 | none, 28 | beforeAnimate, 29 | animated, 30 | beforeDisappear, 31 | } 32 | export const DeleteConfirmation = forwardRef(function ( 33 | {}: any, 34 | ref: React.Ref | undefined 35 | ) { 36 | const [show, setShow] = useState(ShowMode.none); 37 | // const [title, setTitle] = useState(); 38 | // const [subTitle, setSubTitle] = useState(); 39 | const [line, setLine] = useState(); 40 | const [station, setStation] = useState(); 41 | const [stationIndex, setStationIndex] = useState(); 42 | const [callback, setCallback] = useState(() => () => {}); 43 | const {t} = useTranslation(); 44 | //@ts-ignore 45 | window.setShow = setShow 46 | const showWithAnimate = () => { 47 | setShow(ShowMode.beforeAnimate); 48 | setTimeout(() => setShow(ShowMode.animated)); 49 | }; 50 | const disappearWithAnimate = () => { 51 | setShow(ShowMode.beforeDisappear); 52 | setTimeout(() => setShow(ShowMode.none),300); 53 | }; 54 | const showConfirmation: showConfirmationInterface = ( 55 | { line, station, stationIndex }, 56 | callback 57 | ) => { 58 | showWithAnimate(); 59 | setLine(line); 60 | setStation(station); 61 | setCallback(() => callback); 62 | setStationIndex(stationIndex); 63 | }; 64 | useImperativeHandle( 65 | ref, 66 | () => { 67 | return { 68 | showConfirmation, 69 | }; 70 | }, 71 | [] 72 | ); 73 | const remove = line && station; 74 | const deleteText = remove ? t('delete.remove') : t('delete.delete'); 75 | const title = t('que-shi-yao-deletetext-ma', {deleteText}); 76 | const { stationName, shape } = station || {}; 77 | const { lineName, sign, color } = line || {}; 78 | const index = stationIndex ? stationIndex + 1 : 1; 79 | const getOrdinalSuffix = (number: number)=> { 80 | const suffixes = ["th", "st", "nd", "rd"]; 81 | const v = number % 100; 82 | return number + (suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]); 83 | } 84 | const subTitle = t('delete.subtile', {lineName, index:i18n.language === 'en-US'? getOrdinalSuffix(index):index}); 85 | 86 | return ( 87 |
97 |
98 |
{title}
99 | {remove ?
{subTitle}
: <>} 100 |
101 |
102 |
103 | {line && !station ? ( 104 |
105 | 111 |
112 | ) : ( 113 |
114 | { 115 | //@ts-ignore 116 | shapes[shape] 117 | } 118 |
119 | )} 120 |
121 |
{stationName || lineName}
122 |
123 |
127 |
128 |
{ 131 | disappearWithAnimate(); 132 | if (typeof callback === "function") callback(); 133 | }} 134 | > 135 | {deleteText} 136 |
137 |
138 | {t('delete.cancel')} 139 |
140 |
141 |
142 | ); 143 | }); 144 | -------------------------------------------------------------------------------- /src/Render/ErrorFallback/ErrorFallback.scss: -------------------------------------------------------------------------------- 1 | .error-layer { 2 | text-align: center; 3 | .title { 4 | margin-top: 20vh; 5 | font-size: 36px; 6 | font-weight: 1000; 7 | } 8 | .sub-title { 9 | margin-top: 0.5vh; 10 | font-size: 18px; 11 | font-weight: 500; 12 | } 13 | .error-btn { 14 | span{ 15 | margin-left: 55px; 16 | text-align: left; 17 | } 18 | user-select: none; 19 | font-size: 18px; 20 | font-weight: 500; 21 | cursor: pointer; 22 | --gap: min(20px, 2vw); 23 | position: relative; 24 | svg{ 25 | position: absolute; 26 | left: calc(var(--gap) + 20px); 27 | } 28 | &.recover-from-cache { 29 | margin-top: 12vh; 30 | background-color: rgb(234, 11, 42, 0.09); 31 | color: #ea0b2a; 32 | svg { 33 | width: 20px; 34 | 35 | } 36 | } 37 | &.export-from-cache { 38 | margin-top: 0.5vh; 39 | background-color: #D9D9D9; 40 | svg { 41 | width: 16px; 42 | padding: 0 2px; 43 | 44 | } 45 | } 46 | padding: 20px 30px; 47 | width: 315px; 48 | margin: auto; 49 | border-radius: 10px; 50 | display: flex; 51 | justify-content: left; 52 | align-items: center; 53 | 54 | } 55 | .export-error{ 56 | position: absolute; 57 | bottom: 10vh; 58 | margin: auto; 59 | left: 0; 60 | right: 0; 61 | font-size: 18px; 62 | font-weight: 500; 63 | .export-error-file{ 64 | color: #ea0b2a; 65 | cursor: pointer; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Render/ErrorFallback/ErrorFallback.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./ErrorFallback.scss"; 3 | import { ReactComponent as RecoverIcon } from "../../Resource/Icon/clock.arrow.circlepath.svg"; 4 | import { ReactComponent as ExportIcon } from "../../Resource/Icon/export.svg"; 5 | import { exportFile, exportJson } from "../../Common/util"; 6 | import moment from "moment"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | export const ErrorFallback = ({ 10 | error, 11 | resetErrorBoundary, 12 | }: { 13 | error: Error; 14 | resetErrorBoundary: () => void; 15 | }) => { 16 | const exportFile = (key: string) => { 17 | const current = localStorage.getItem(key); 18 | exportJson( 19 | current!, 20 | `recovery-${key}-${moment().format("YYYY-MM-DD_HH-mm-ss")}.json` 21 | ); 22 | }; 23 | const { t } = useTranslation(); 24 | return ( 25 |
26 |
{t('error.metError')}
27 |
{t('error.dontWorry')}
28 |
29 | 30 | {t('error.recoverFromCache')} 31 |
32 |
exportFile("last")} 35 | > 36 | 37 | {t('error.exportNoErrorFile')} 38 |
39 |
40 |
41 | {t('error.orYouCould')} 42 | exportFile("current")} 45 | > 46 | {t('error.exportError')} 47 | 48 |
49 |
{t('error.sendAuthor')}
50 |
51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/Render/Header/Component/OpacityControl.scss: -------------------------------------------------------------------------------- 1 | .tool { 2 | position: relative; 3 | display: inline-block; 4 | cursor: pointer; 5 | perspective: 1000px; 6 | perspective-origin: 0 50%; 7 | 8 | &:has(.slider-container) { 9 | z-index: 600; 10 | } 11 | 12 | .slider-container { 13 | transition: 14 | transform 0s, 15 | transform 0.3s ease-in-out, 16 | all 0.3s ease-in-out; 17 | &.show { 18 | opacity: 1; 19 | pointer-events: unset; 20 | top: 100%; 21 | transform: scale(calc(1 + var(--value) * 0.075)) rotate3d(0, 1, 0, 0deg); 22 | } 23 | opacity: 0; 24 | pointer-events: none; 25 | position: absolute; 26 | top: 50%; 27 | left: 0; 28 | margin-top: 8px; 29 | background: rgba(255, 255, 255, 0.136); 30 | box-shadow: 0 0 100px rgba(#000000, 0.15); 31 | // border: #ea0b2a 1px solid; 32 | z-index: 600; 33 | width: 400%; 34 | max-width: 100vw; 35 | overflow: hidden; 36 | height: 40px; 37 | border-radius: 5px; 38 | backdrop-filter: blur(20px); 39 | transform: scale(calc(1 + var(--value) * 0.075)) rotate3d(0, 1, 0, var(--deg)); 40 | transform-origin: 0 0; 41 | .opacity-text { 42 | position: absolute; 43 | top: 0; 44 | bottom: 0; 45 | margin: auto; 46 | pointer-events: none; 47 | display: flex; 48 | justify-content: center; 49 | align-items: center; 50 | margin-left: 20px; 51 | font-size: 14px; 52 | opacity: var(--value); 53 | span { 54 | margin-left: 2px; 55 | font-size: 10px; 56 | font-weight: 600; 57 | vertical-align: baseline; 58 | margin-top: 2.5px; 59 | } 60 | } 61 | .slider { 62 | appearance: none; 63 | width: 100%; 64 | height: 100%; 65 | appearance: none; 66 | margin: 0; 67 | background-color: transparent; 68 | background-image: repeating-linear-gradient( 69 | to right, 70 | transparent, 71 | transparent calc(18% - 1px), 72 | #05051a1c 18% 73 | ); 74 | &::-webkit-slider-thumb { 75 | box-shadow: -20rem 0 0 20rem rgba(#ea0b2a, 0.2); 76 | cursor: col-resize; 77 | background-color: rgba(#ea0b2a, 0.2); 78 | } 79 | 80 | @supports not (-webkit-touch-callout: none) { 81 | &::-webkit-slider-thumb { 82 | -webkit-appearance: none; 83 | appearance: none; 84 | width: 0; 85 | } 86 | } 87 | @supports (-webkit-min-device-pixel-ratio: 0) { 88 | &::-webkit-slider-thumb { 89 | -webkit-appearance: auto; 90 | appearance: auto; 91 | width: auto; 92 | } 93 | } 94 | 95 | 96 | &::-webkit-slider-runnable-track { 97 | // pointer-events: none; 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Render/Header/Component/OpacityControl.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | CSSProperties, 6 | useLayoutEffect, 7 | } from "react"; 8 | import "./OpacityControl.scss"; 9 | import classNames from "classnames"; 10 | import { browserInfo } from "../../../Common/util"; 11 | import { useTranslation } from "react-i18next"; 12 | interface OpacityControlProps { 13 | opacity: number; 14 | setOpacity: (value: number) => void; 15 | } 16 | 17 | 18 | const OpacityControl: React.FC = ({ 19 | opacity, 20 | setOpacity, 21 | }) => { 22 | const [showSlider, setShowSlider] = useState(false); 23 | const sliderRef = useRef(null); 24 | const inputRef = useRef(null); 25 | const toolRef = useRef(null); 26 | const { t } = useTranslation(); 27 | const handleClick = () => { 28 | setShowSlider(!showSlider); 29 | }; 30 | 31 | const handleSliderChange = (event: React.ChangeEvent) => { 32 | setOpacity(Number(event.target.value)); 33 | }; 34 | 35 | useEffect(() => { 36 | if (showSlider && sliderRef.current && toolRef.current) { 37 | const toolRect = 38 | toolRef.current.getBoundingClientRect(); 39 | const viewportWidth = window.innerWidth; 40 | 41 | if (toolRect.left + 230 > viewportWidth) { 42 | sliderRef.current.style.left = "unset"; 43 | sliderRef.current.style.right = "0"; 44 | (sliderRef.current.style as any)['--deg'] = "-25deg"; 45 | sliderRef.current.style.transformOrigin = "100% 50%"; 46 | toolRef.current.style.perspectiveOrigin = "100% 50%"; 47 | } else { 48 | sliderRef.current.style.left = "0"; 49 | sliderRef.current.style.transformOrigin = "0 50%"; 50 | toolRef.current.style.perspectiveOrigin = "0 50%"; 51 | (sliderRef.current.style as any)['--deg'] = "25deg"; 52 | } 53 | } 54 | }, [showSlider]); 55 | useLayoutEffect(() => { 56 | if (sliderRef.current) { 57 | sliderRef.current.style.transition = "0.3s ease-in-out"; 58 | } 59 | }, [showSlider]); 60 | useEffect(() => { 61 | const handleClickCapture = (event: TouchEvent | MouseEvent) => { 62 | if (event.target !== inputRef.current && event.target !== toolRef.current) { 63 | console.log("focusout"); 64 | setShowSlider(false); 65 | } 66 | }; 67 | const resize = () => setShowSlider(false); 68 | document.addEventListener("touchstart", handleClickCapture, true); 69 | document.addEventListener("click", handleClickCapture, true); 70 | document.addEventListener("resize",resize); 71 | return () => { 72 | document.removeEventListener("touchstart", handleClickCapture, true); 73 | document.addEventListener("click", handleClickCapture, true); 74 | document.removeEventListener("resize",resize); 75 | }; 76 | }, []); 77 | const { engine } = browserInfo; 78 | const safari = engine.name === "WebKit"; 79 | return ( 80 |
81 | {t('opacity')} 82 | { 83 |
e.stopPropagation()} 87 | style={{ "--value": opacity } as CSSProperties} 88 | onTransitionEnd={() => { 89 | if (showSlider && sliderRef.current) { 90 | sliderRef.current.style.transition = "none"; 91 | } 92 | }} 93 | > 94 | 98 | {Math.floor(opacity * 100)} 99 | % 100 | 101 | e.stopPropagation()} 111 | style={{ "--value": opacity , "--safari": safari} as CSSProperties} 112 | onBlur={() => setShowSlider(false)} 113 | /> 114 |
115 | } 116 |
117 | ); 118 | }; 119 | 120 | export default OpacityControl; 121 | -------------------------------------------------------------------------------- /src/Render/Header/Component/ShapeSelector.scss: -------------------------------------------------------------------------------- 1 | .tool { 2 | position: relative; 3 | display: inline-block; 4 | cursor: pointer; 5 | perspective: 1000px; 6 | perspective-origin: 0 50%; 7 | 8 | &:has(.shape-selector-container) { 9 | z-index: 600; 10 | } 11 | 12 | .shape-selector-container { 13 | .color-detail { 14 | .color-detail-choosing { 15 | box-sizing: border-box; 16 | padding: 10px 10px; 17 | width: 222.75px; 18 | display: grid; 19 | border: white 1px solid; 20 | grid-template-columns: repeat(4, 25%); 21 | grid-template-rows: repeat(3, 33.33%); 22 | // grid-row-gap: 9px; 23 | // grid-column-gap: 6px; 24 | .shape-container { 25 | height: 46.13px; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | border-left: 1px black dotted; 30 | border-bottom: 1px black solid; 31 | transform: translate(-1px, 1px); 32 | cursor: pointer; 33 | 34 | &.left { 35 | border-left: none; 36 | } 37 | &.bottom { 38 | border-bottom: none; 39 | } 40 | .shape-preview { 41 | width: 19.5px; 42 | height: 19.5px; 43 | // border-radius: 50%; 44 | // border: 2px solid; 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | &.shape-selected { 49 | svg { 50 | * { 51 | stroke: #ea0b2a; 52 | } 53 | } 54 | } 55 | &.square { 56 | zoom: 0.9; 57 | } 58 | &.triangle { 59 | zoom: 1.05; 60 | } 61 | &.start { 62 | zoom: 1.15; 63 | } 64 | &.hexagon { 65 | zoom: 0.9; 66 | } 67 | &.pentagon { 68 | zoom: 1.1; 69 | } 70 | &.diamond{ 71 | zoom: 1.15; 72 | } 73 | &.leaf{ 74 | zoom: 0.9; 75 | } 76 | } 77 | } 78 | } 79 | } 80 | transition: 81 | transform 0s, 82 | transform 0.3s ease-in-out, 83 | all 0.3s ease-in-out; 84 | &.show { 85 | opacity: 1; 86 | pointer-events: unset; 87 | top: 100%; 88 | transform: scale(calc(1 + var(--value) * 0.075)) rotate3d(0, 1, 0, 0deg); 89 | } 90 | opacity: 0; 91 | pointer-events: none; 92 | position: absolute; 93 | top: 50%; 94 | left: 0; 95 | margin-top: 8px; 96 | background: rgba(255, 255, 255, 0.136); 97 | box-shadow: 0 0 100px rgba(#000000, 0.15); 98 | // border: #ea0b2a 1px solid; 99 | z-index: 600; 100 | width: 222.75px; 101 | max-width: 100vw; 102 | overflow: hidden; 103 | height: 162px; 104 | border-radius: 5px; 105 | backdrop-filter: blur(20px); 106 | transform: scale(calc(1 + var(--value) * 0.075)) rotate3d(0, 1, 0, var(--deg)); 107 | transform-origin: 0 0; 108 | .opacity-text { 109 | position: absolute; 110 | top: 0; 111 | bottom: 0; 112 | margin: auto; 113 | pointer-events: none; 114 | display: flex; 115 | justify-content: center; 116 | align-items: center; 117 | margin-left: 20px; 118 | font-size: 14px; 119 | opacity: var(--value); 120 | span { 121 | margin-left: 2px; 122 | font-size: 10px; 123 | font-weight: 600; 124 | vertical-align: baseline; 125 | margin-top: 2.5px; 126 | } 127 | } 128 | .slider { 129 | appearance: none; 130 | width: 100%; 131 | height: 100%; 132 | appearance: none; 133 | margin: 0; 134 | background-color: transparent; 135 | background-image: repeating-linear-gradient( 136 | to right, 137 | transparent, 138 | transparent calc(18% - 1px), 139 | #05051a1c 18% 140 | ); 141 | &::-webkit-slider-thumb { 142 | box-shadow: -20rem 0 0 20rem rgba(#ea0b2a, 0.2); 143 | cursor: col-resize; 144 | background-color: rgba(#ea0b2a, 0.2); 145 | } 146 | 147 | @supports not (-webkit-touch-callout: none) { 148 | &::-webkit-slider-thumb { 149 | -webkit-appearance: none; 150 | appearance: none; 151 | width: 0; 152 | } 153 | } 154 | @supports (-webkit-min-device-pixel-ratio: 0) { 155 | &::-webkit-slider-thumb { 156 | -webkit-appearance: auto; 157 | appearance: auto; 158 | width: auto; 159 | } 160 | } 161 | 162 | 163 | &::-webkit-slider-runnable-track { 164 | // pointer-events: none; 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Render/Header/Component/ShapeSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | CSSProperties, 6 | useLayoutEffect, 7 | SetStateAction, 8 | Dispatch, 9 | } from "react"; 10 | import "./ShapeSelector.scss"; 11 | import classNames from "classnames"; 12 | import { browserInfo } from "../../../Common/util"; 13 | import shapes from "../../../Resource/Shape/shape"; 14 | import { Shape } from "../../../Data/Shape"; 15 | import { useTranslation } from "react-i18next"; 16 | interface OpacityControlProps { 17 | defaultShape: string; 18 | setDefaultShape: Dispatch>; 19 | } 20 | 21 | const ShapeSelector: React.FC = ({ 22 | defaultShape, 23 | setDefaultShape, 24 | }) => { 25 | const [showSlider, setShowSlider] = useState(false); 26 | const sliderRef = useRef(null); 27 | const toolRef = useRef(null); 28 | const { t } = useTranslation(); 29 | const handleClick = () => { 30 | setShowSlider(!showSlider); 31 | }; 32 | 33 | useEffect(() => { 34 | if (showSlider && sliderRef.current && toolRef.current) { 35 | const toolRect = toolRef.current.getBoundingClientRect(); 36 | const viewportWidth = window.innerWidth; 37 | 38 | if (toolRect.left + 230 > viewportWidth) { 39 | sliderRef.current.style.left = "unset"; 40 | sliderRef.current.style.right = "0"; 41 | (sliderRef.current.style as any)["--deg"] = "-25deg"; 42 | sliderRef.current.style.transformOrigin = "100% 50%"; 43 | toolRef.current.style.perspectiveOrigin = "100% 50%"; 44 | } else { 45 | sliderRef.current.style.left = "0"; 46 | sliderRef.current.style.transformOrigin = "0 50%"; 47 | toolRef.current.style.perspectiveOrigin = "0 50%"; 48 | (sliderRef.current.style as any)["--deg"] = "25deg"; 49 | } 50 | } 51 | }, [showSlider]); 52 | useLayoutEffect(() => { 53 | if (sliderRef.current) { 54 | sliderRef.current.style.transition = "0.3s ease-in-out"; 55 | } 56 | }, [showSlider]); 57 | useEffect(() => { 58 | const handleClickCapture = (event: TouchEvent | MouseEvent) => { 59 | if (!toolRef.current?.contains(event.target as Node)) { 60 | console.log("focusout"); 61 | setShowSlider(false); 62 | } 63 | }; 64 | const resize = () => setShowSlider(false); 65 | document.addEventListener("touchstart", handleClickCapture, true); 66 | document.addEventListener("click", handleClickCapture, true); 67 | document.addEventListener("resize", resize); 68 | return () => { 69 | document.removeEventListener("touchstart", handleClickCapture, true); 70 | document.addEventListener("click", handleClickCapture, true); 71 | document.removeEventListener("resize", resize); 72 | }; 73 | }, []); 74 | const { engine } = browserInfo; 75 | const safari = engine.name === "WebKit"; 76 | const column = 4; 77 | const row = 3; 78 | const grid = new Array(column * row).fill(0); 79 | Object.keys(shapes).forEach((shape, index) => (grid[index] = shape)); 80 | return ( 81 |
82 | { 83 | t(`shape.${defaultShape}`) 84 | } 85 | { 86 |
e.stopPropagation()} 90 | style={{} as CSSProperties} 91 | onTransitionEnd={() => { 92 | if (showSlider && sliderRef.current) { 93 | sliderRef.current.style.transition = "none"; 94 | } 95 | }} 96 | > 97 |
98 |
99 | {grid.map((shape, index) => { 100 | const left = index % column === 0; 101 | const bottom = Math.floor(index / column) === row - 1; 102 | // console.log({ shape }); 103 | return ( 104 |
{ 111 | if (shape) setDefaultShape(shape); 112 | }} 113 | > 114 |
121 | { 122 | //@ts-ignore 123 | shapes[shape] 124 | } 125 |
126 |
127 | ); 128 | })} 129 |
130 |
131 |
132 | } 133 |
134 | ); 135 | }; 136 | 137 | export default ShapeSelector; 138 | -------------------------------------------------------------------------------- /src/Render/Header/Menu.scss: -------------------------------------------------------------------------------- 1 | .menu { 2 | &.page-menu { 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | height: 100vh; 7 | width: 100vw; 8 | z-index: 500; 9 | background-color: rgba(255, 255, 255, 0.2); 10 | backdrop-filter: blur(10px); 11 | .title { 12 | .tools { 13 | margin-left: -50px; 14 | .tool { 15 | margin-left: -5px; 16 | opacity: 0; 17 | } 18 | } 19 | } 20 | .dots { 21 | width: calc(100vw - 51.6px - 51.6px); 22 | transition: 23 | 0.3s ease-in-out opacity, 24 | 0.3s ease-in-out width; 25 | opacity: 1; 26 | } 27 | .menus { 28 | transition: 0.3s ease-in-out opacity; 29 | opacity: 1; 30 | .columns { 31 | .column { 32 | @media (max-width: 710px) { 33 | margin-top: 28.5px; 34 | opacity: 1; 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | &.page-title { 42 | // position: fixed; 43 | // top: 0; 44 | // left: 0; 45 | // height: 100vh; 46 | // width: 100vw; 47 | .title { 48 | .click-panel { 49 | cursor: pointer; 50 | } 51 | .tools { 52 | margin-left: -50px; 53 | .tool { 54 | margin-left: -5px; 55 | opacity: 0; 56 | } 57 | } 58 | } 59 | user-select: none; 60 | z-index: -500; 61 | .dots { 62 | margin-top: 100.66px; 63 | position: fixed; 64 | opacity: 1; 65 | width: 0; 66 | transition: 67 | 0.3s ease-in-out opacity, 68 | 0.3s ease-in-out width; 69 | } 70 | 71 | .menus { 72 | margin-top: 101.66px; 73 | position: fixed; 74 | opacity: 0; 75 | transition: 0.3s ease-in-out opacity; 76 | 77 | .columns { 78 | justify-content: space-between; 79 | transition: 0.3s ease-in-out width; 80 | width: calc(70vw - 51.6px - 51.6px); 81 | } 82 | } 83 | } 84 | 85 | &.page-tools { 86 | .title { 87 | } 88 | .dots { 89 | margin-top: 100.66px; 90 | position: fixed; 91 | opacity: 1; 92 | width: 0; 93 | transition: 94 | 0.3s ease-in-out opacity, 95 | 0.3s ease-in-out width; 96 | } 97 | .menus { 98 | margin-top: 101.66px; 99 | position: fixed; 100 | opacity: 0; 101 | transition: 0.3s ease-in-out opacity; 102 | 103 | .columns { 104 | justify-content: space-between; 105 | transition: 0.3s ease-in-out width; 106 | width: calc(70vw - 51.6px - 51.6px); 107 | } 108 | } 109 | } 110 | .title { 111 | position: fixed; 112 | margin: 35.5px 0 0 51.6px; 113 | z-index: 500; 114 | .auto-growth-container { 115 | display: inline-block; 116 | * { 117 | font-size: 36px; 118 | font-weight: 1000; 119 | } 120 | input { 121 | // background-color: rgba(255, 255, 255, 0.901); 122 | // backdrop-filter: blur(10px); 123 | // padding-left: 18px; 124 | // margin-left: -18px; 125 | } 126 | } 127 | .tools { 128 | @media (max-width: 710px) { 129 | // display: block !important; 130 | margin-left: -21.77px; 131 | .tool { 132 | margin-top: 5px; 133 | 134 | &:first-child { 135 | // display: none; 136 | } 137 | } 138 | } 139 | display: inline-block; 140 | transition: 0.3s ease-in-out; 141 | .tool { 142 | transition: 0.3s ease-in-out; 143 | opacity: 1; 144 | display: inline-block; 145 | font-size: 18px; 146 | font-weight: 500; 147 | color: #ea0b2a; 148 | &.tool-title{ 149 | color: #00A0E8; 150 | cursor:auto; 151 | } 152 | margin-left: 21.77px; 153 | cursor: pointer; 154 | &.disabled { 155 | color: #ea0b2a55; 156 | cursor:auto; 157 | } 158 | svg{ 159 | vertical-align: text-bottom; 160 | line-height: 26px; 161 | height: 26px; 162 | [fill="black"]{ 163 | fill: rgba(234, 11, 42, 0.3333333333) !important; 164 | fill-opacity: 1 !important; 165 | } 166 | } 167 | 168 | 169 | } 170 | } 171 | } 172 | 173 | .dots { 174 | // margin-top: 20.16px; 175 | border-bottom: dotted black 1px; 176 | margin: 100.66px 51.6px 0 51.6px; 177 | } 178 | .menus { 179 | height: calc(100vh - 101px); 180 | overflow-y: overlay; 181 | overflow-x: hidden; 182 | &::-webkit-scrollbar { 183 | display: none; 184 | } 185 | .columns { 186 | margin: 0 51.6px 0 51.6px; 187 | display: flex; 188 | @media (max-width: 710px) { 189 | display: grid; 190 | } 191 | justify-content: space-between; 192 | transition: 0.3s ease-in-out width; 193 | width: calc(100vw - 51.6px - 51.6px); 194 | .column { 195 | margin-top: 25.8px; 196 | display: inline-block; 197 | vertical-align: top; 198 | width: 20vw; 199 | transition: 0.3s ease-in-out; 200 | @media (max-width: 710px) { 201 | width: auto; 202 | margin-top: -15px; 203 | opacity: 0; 204 | &:last-child{ 205 | padding-bottom: 150px; 206 | } 207 | } 208 | .column-title { 209 | font-size: 36px; 210 | font-weight: 300; 211 | } 212 | .column-items { 213 | // margin-top: 17px; 214 | 215 | .column-item { 216 | width: fit-content; 217 | margin-top: 17px; 218 | font-size: 18px; 219 | font-weight: 500; 220 | color: #ea0b2a; 221 | cursor: pointer; 222 | &.sub-menu{ 223 | margin-top: 10px; 224 | margin-left: 10px; 225 | .sub-item{ 226 | margin-right: 10px; 227 | } 228 | } 229 | &.small{ 230 | font-size: small; 231 | } 232 | &.author{ 233 | margin-top: 4px; 234 | &:hover{ 235 | // text-decoration: underline; 236 | } 237 | } 238 | &.friend{ 239 | position: relative; 240 | svg{ 241 | margin-left: 5px; 242 | position: absolute; 243 | margin-top: 3px; 244 | width: 17px; 245 | // height: 25px; 246 | // vertical-align: bottom; 247 | } 248 | } 249 | } 250 | } 251 | } 252 | } 253 | .notice{ 254 | position: fixed; 255 | left: 0; 256 | bottom: 0; 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/Render/Layer/DevelopLayer.scss: -------------------------------------------------------------------------------- 1 | .DevelopLayer { 2 | .grid { 3 | display: grid; 4 | grid-template-columns: repeat(100, 100px); 5 | grid-template-rows: repeat(50, 100px); 6 | .grid-item { 7 | border: 1px solid black; 8 | height: 100px; 9 | width: 100px; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Render/Layer/DevelopLayer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./DevelopLayer.scss"; 3 | import { Point } from "../../DataStructure/Point"; 4 | import { Station } from "../../DataStructure/Station"; 5 | import { Line } from "../../DataStructure/Line"; 6 | import LineRender from "../Component/LineRender"; 7 | import { CardShowing } from "../../Data/UserData"; 8 | 9 | function DevelopLayer() { 10 | // test line and station 11 | 12 | const pointA = new Point(200, 200); 13 | const pointB = new Point(300, 300); 14 | const pointC = new Point(600, 300); 15 | const pointD = new Point(800, 300); 16 | const pointE = new Point(200, 400); 17 | const pointF = new Point(800, 200); 18 | const pointG = new Point(500, 500); 19 | 20 | const A = new Station(pointA); 21 | const B = new Station(pointB); 22 | B._dev_tag = "B"; 23 | const C = new Station(pointC); 24 | const D = new Station(pointD); 25 | const E = new Station(pointE); 26 | const F = new Station(pointF); 27 | const G = new Station(pointG); 28 | 29 | const line1 = new Line(); 30 | const line3 = new Line(); 31 | const line8 = new Line(); 32 | const line9 = new Line(); 33 | 34 | line1.linkAll([A, B, C, D]); 35 | // line3.linkAll([E, B, C, D, F, E]); 36 | line8._dev_tag='line8'; 37 | line8.linkAll([E, B, C, D]); 38 | line9.linkAll([A, E, G,D,F]); 39 | 40 | // console.log(line1, line3, line8); 41 | console.log(A, B, C, D, E, F); 42 | 43 | 44 | const allStationsList = [A, B, C, D, E, F, G]; 45 | const allLinesList = [line1, line3, line8, line9]; 46 | 47 | const renderStations = (allStationsList: Station[]) => { 48 | return ( 49 |
50 | {allStationsList.map((station, index) => ( 51 |
58 | {String.fromCharCode("A".charCodeAt(0) + index)} 59 |
60 | ))} 61 |
62 | ); 63 | }; 64 | 65 | // const renderLines = (allLinesList: Line[], cardShowing: CardShowing, setCardShowing: Dispatch>) => { 66 | // return ( 67 | //
68 | // {allLinesList.map((line) => { 69 | // return ; 71 | // })} 72 | //
73 | // ); 74 | // }; 75 | 76 | 77 | return ( 78 |
79 | {/*
80 | {new Array(100 * 50).fill(1).map((x, index) => ( 81 |
{index}
82 | ))} 83 |
*/} 84 | {renderStations(allStationsList)} 85 | {/* {renderLines(allLinesList)} */} 86 | 87 |
88 | ); 89 | } 90 | 91 | export default DevelopLayer; 92 | -------------------------------------------------------------------------------- /src/Render/Layer/RenderLayer.scss: -------------------------------------------------------------------------------- 1 | .station-render { 2 | cursor: pointer; 3 | .station-shape { 4 | height: 30px; 5 | width: 30px; 6 | // transform: translate(-50%,-50%); 7 | display: inline-flex; 8 | // align-content: center; 9 | justify-content: center; 10 | align-items: center; 11 | svg { 12 | fill: white; 13 | // &.square { 14 | // zoom: 0.9; 15 | // } 16 | // &.triangle { 17 | // zoom: 1.05; 18 | // } 19 | // &.start { 20 | // zoom: 1.15; 21 | // } 22 | // &.hexagon { 23 | // zoom: 0.9; 24 | // } 25 | // &.pentagon { 26 | // zoom: 1.1; 27 | // } 28 | 29 | &.shadow{ 30 | position: absolute; 31 | transform: scale(1.5); 32 | transform-origin: center; 33 | *{ 34 | fill: inherit; 35 | stroke: inherit; 36 | } 37 | } 38 | } 39 | } 40 | .station-name { 41 | display: inline-block; 42 | font-size: 18px; 43 | font-weight: 500; 44 | // font-size: 30px; 45 | // font-weight: 1000; 46 | vertical-align: top; 47 | // margin-left: 5px; 48 | white-space: nowrap; 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Render/Layer/ScaleLayer.scss: -------------------------------------------------------------------------------- 1 | .ScaleLayer{ 2 | width: 100vw; 3 | height: 100vh; 4 | overflow: hidden; 5 | .layer-for-welcome-tour{ 6 | position: fixed; 7 | z-index: -500; 8 | width: 70vw; 9 | height: 70vh; 10 | left: 15vw; 11 | top: 15vw; 12 | } 13 | .transform-layer{ 14 | transform-origin: left top; 15 | // never use will-change here, it makes child elements blurry 16 | // will-change: transform; 17 | } 18 | 19 | .background-layer{ 20 | position: absolute; 21 | pointer-events: none; 22 | transform-origin: left top; 23 | appearance: none; 24 | border: none; 25 | box-shadow: none; 26 | border-image-width: 0; 27 | 28 | &:not([src]) { 29 | opacity: 0 !important; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/Render/Recovery/Recovery.scss: -------------------------------------------------------------------------------- 1 | .App{ 2 | perspective: 1000px; 3 | perspective-origin: 100vw 105px; 4 | z-index: 10000; 5 | } 6 | .recovery-notification-container { 7 | 8 | position: fixed; 9 | right: 0; 10 | top: 0; 11 | transform-origin: right center; 12 | transition: ease-in-out 0.5s; 13 | 14 | &.show { 15 | transform: rotate3d(0, 1, 0, 0deg); 16 | opacity: 1; 17 | } 18 | &:not(.show) { 19 | transform: rotate3d(0, 1, 0, -90deg); 20 | opacity: 0; 21 | } 22 | .recovery-notification { 23 | @media screen and (max-width: 520px) { 24 | display: none; 25 | } 26 | position: fixed; 27 | top: 30px; 28 | right: 30px; 29 | width: 363px; 30 | height: 75px; 31 | display: flex; 32 | // justify-content: space-between; 33 | align-items: center; 34 | background-color: rgb(255, 255, 255, 0.72); 35 | box-shadow: 0 0 80px 0 rgba(0, 0, 0, 0.25); 36 | backdrop-filter: blur(15px); 37 | border-radius: 15px; 38 | transition: ease-in-out 0.3s; 39 | 40 | // zoom: 1.2; 41 | &:hover { 42 | background-color: rgba(255, 255, 255, 0.001); 43 | } 44 | .icon { 45 | margin: 0 20px; 46 | } 47 | 48 | .icon, 49 | .ok, 50 | .no { 51 | width: 36px; 52 | height: 36px; 53 | 54 | border-radius: 50%; 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | } 59 | 60 | .ok, 61 | .no { 62 | cursor: pointer; 63 | position: absolute; 64 | } 65 | 66 | .icon, 67 | .ok { 68 | background-color: rgb(235, 10, 40, 0.1); 69 | } 70 | 71 | .no { 72 | background-color: #d9d9d9; 73 | right: 20px; 74 | } 75 | 76 | .icon { 77 | svg { 78 | width: 20px; 79 | height: 20px; 80 | } 81 | } 82 | 83 | .ok { 84 | right: 60px; 85 | transition: ease-in-out 0.3s; 86 | &:hover { 87 | background-color: rgb(235, 10, 40, 0.2); 88 | } 89 | svg { 90 | width: 16px; 91 | } 92 | } 93 | 94 | .no { 95 | transition: ease-in-out 0.3s; 96 | &:hover { 97 | background-color: #c9c9c9; 98 | } 99 | svg { 100 | width: 12px; 101 | } 102 | } 103 | 104 | .text { 105 | .title { 106 | font-size: 16px; 107 | } 108 | .sub-title { 109 | font-size: 12px; 110 | } 111 | font-weight: 500; 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Render/Recovery/Recovery.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Dispatch, 3 | SetStateAction, 4 | useEffect, 5 | useRef, 6 | useState, 7 | } from "react"; 8 | import { ReactComponent as RecoverIcon } from "../../Resource/Icon/clock.arrow.circlepath.svg"; 9 | import { ReactComponent as OkIcon } from "../../Resource/Icon/ok.svg"; 10 | import { ReactComponent as NoIcon } from "../../Resource/Icon/no.svg"; 11 | 12 | import "./Recovery.scss"; 13 | import { setDataFromJson, UserDataType } from "../../Data/UserData"; 14 | import { mediateMap, readFileFromIndexedDB } from "../../Common/util"; 15 | import classNames from "classnames"; 16 | import { useTranslation } from "react-i18next"; 17 | export function Recovery({ 18 | data, 19 | setData, 20 | recoveredFromError, 21 | setRecoveredFromError, 22 | transfromTools, 23 | }: { 24 | data: UserDataType; 25 | setData: Dispatch>; 26 | recoveredFromError: boolean; 27 | setRecoveredFromError: Dispatch>; 28 | transfromTools: { 29 | scale: number; 30 | setScale: React.Dispatch>; 31 | translateX: number; 32 | translateY: number; 33 | setTranslateX: React.Dispatch>; 34 | setTranslateY: React.Dispatch>; 35 | }; 36 | }) { 37 | const {t} = useTranslation(); 38 | const notificationRef = useRef(null); 39 | const [showNotification, setShowNotification] = useState(false); 40 | useEffect(() => { 41 | const current = localStorage.getItem("current"); 42 | const show = current && !recoveredFromError; 43 | setRecoveredFromError(false); 44 | setShowNotification(!!show); 45 | const handleClickCapture = (event: TouchEvent | MouseEvent) => { 46 | if ( 47 | notificationRef.current && 48 | !notificationRef.current.contains(event.target as Node) 49 | ) { 50 | console.log("recovery focusout"); 51 | setShowNotification(false); 52 | } 53 | }; 54 | document.addEventListener("touchstart", handleClickCapture, true); 55 | document.addEventListener("click", handleClickCapture, true); 56 | return () => { 57 | document.removeEventListener("touchstart", handleClickCapture, true); 58 | document.addEventListener("click", handleClickCapture, true); 59 | }; 60 | }, []); 61 | return ( 62 |
69 |
75 |
76 | 77 |
78 |
79 |
{t('recover.text')}
80 |
{t('recover.subTitle')}
81 |
82 |
{ 85 | const current = localStorage.getItem("current"); 86 | if (current) { 87 | const data = setDataFromJson(setData, current); 88 | readFileFromIndexedDB("image") 89 | .then((file) => { 90 | setData((data) => ({ 91 | ...data, 92 | // backgroundColor: "image", 93 | backgroundImage: file as File, 94 | })); 95 | }) 96 | .catch((e) => { 97 | console.error(e); 98 | }); 99 | mediateMap(data, transfromTools); 100 | } 101 | setShowNotification(false); 102 | }} 103 | > 104 | 105 |
106 |
{ 109 | setShowNotification(false); 110 | }} 111 | > 112 | 113 |
114 |
115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /src/Resource/Icon/airwave.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const AirWaveIcon =({className}:{className?: string})=> ( 4 | 5 | 6 | 7 | ); 8 | 9 | export default AirWaveIcon; -------------------------------------------------------------------------------- /src/Resource/Icon/arrow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const arrowIcon = ({ className }: { className?: string }) => ( 4 | 11 | 12 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | export default arrowIcon; 31 | -------------------------------------------------------------------------------- /src/Resource/Icon/auto.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const AutoIcon = ({ className }: { className?: string }) => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | 12 | export default AutoIcon; 13 | -------------------------------------------------------------------------------- /src/Resource/Icon/bolt.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const BoltIcon = ({ className }: { className?: string }) => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | 12 | export default BoltIcon; 13 | 14 | -------------------------------------------------------------------------------- /src/Resource/Icon/clock.arrow.circlepath.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Resource/Icon/edit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const edit =({className}:{className: string})=> ( 4 | 12 | 17 | 18 | ); 19 | 20 | export default edit; -------------------------------------------------------------------------------- /src/Resource/Icon/expand.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const expand =({className}:{className?: string})=> ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | export default expand; -------------------------------------------------------------------------------- /src/Resource/Icon/export.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/Resource/Icon/export.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ExportIcon = ({ className }: { className?: string }) => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | 12 | export default ExportIcon; 13 | 14 | -------------------------------------------------------------------------------- /src/Resource/Icon/finished.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const FinishedIcon =({className}:{className?: string})=> ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default FinishedIcon; -------------------------------------------------------------------------------- /src/Resource/Icon/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Resource/Icon/goto.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const GoToIcon = ({ className }: { className?: string }) => ( 4 | 13 | 22 | 30 | 31 | 32 | 36 | 37 | 38 | ); 39 | 40 | export default GoToIcon; 41 | -------------------------------------------------------------------------------- /src/Resource/Icon/infinity.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Infinity = ({ className }: { className?: string }) => ( 4 | 12 | 21 | 22 | 23 | 24 | 28 | 29 | 30 | ); 31 | 32 | export default Infinity; 33 | -------------------------------------------------------------------------------- /src/Resource/Icon/no.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/Resource/Icon/ok.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/Resource/Icon/pageview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PageViewIcon =({className}:{className?: string})=> ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | ); 12 | 13 | export default PageViewIcon; -------------------------------------------------------------------------------- /src/Resource/Icon/plus.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const plusIcon = ({ className }: { className?: string }) => ( 4 | 11 | 12 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | export default plusIcon; 31 | -------------------------------------------------------------------------------- /src/Resource/Icon/share.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Resource/Icon/share2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Resource/Icon/shrink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const shrink =({className}:{className: string})=> ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | export default shrink; -------------------------------------------------------------------------------- /src/Resource/Icon/touch.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const TouchIcon = ({ className }: { className?: string }) => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | ); 11 | 12 | export default TouchIcon; 13 | -------------------------------------------------------------------------------- /src/Style/Cursor.ts: -------------------------------------------------------------------------------- 1 | import { Mode } from "../DataStructure/Mode"; 2 | 3 | export const getCursor = (mode: Mode)=>{ 4 | let cursor; 5 | //chrome has bug here, the cursor won't change sometimes while DOM already updated. 6 | switch(mode){ 7 | case Mode.moving:{ 8 | cursor = 'grabbing'; 9 | break; 10 | } 11 | default:{ 12 | cursor = 'default'; 13 | break; 14 | } 15 | } 16 | return cursor; 17 | } -------------------------------------------------------------------------------- /src/WelcomeTour/Driver.ts: -------------------------------------------------------------------------------- 1 | import { driver as Driver, Config } from "driver.js"; 2 | import {t} from 'i18next'; 3 | export const showTour = async (id: string, callback: Function) => { 4 | const driver = Driver(); 5 | const { getSteps } = await import(/* webpackMode: "eager" */ `./Steps/${id}`); 6 | const eventListeners: EventListenerOrEventListenerObject[] = []; 7 | const touch = window.ontouchend === null; 8 | const eventName =// touch ? "touchend" : 9 | "click"; 10 | const config: Config = { 11 | prevBtnText: t('shang-yi-bu'), 12 | doneBtnText: t('wan-cheng'), 13 | nextBtnText: t('xia-yi-bu'), 14 | progressText: "{{current}} / {{total}}", 15 | showProgress: true, 16 | allowClose: false, 17 | showButtons: ["close"], 18 | // onPrevClick:(ele)=>{ 19 | // if(ele){ 20 | // const element = ele as HTMLElement; 21 | // eventListeners.forEach(listener=>{ 22 | // element.removeEventListener('click',listener); 23 | // }) 24 | // driver.movePrevious(); 25 | // } 26 | // }, 27 | onHighlighted: (ele, step, opt) => { 28 | const steps = opt.config.steps!; 29 | const last = step === steps[steps.length - 1]; 30 | const next = steps[opt.state.activeIndex! + 1]; 31 | 32 | if (ele) { 33 | const element = ele as HTMLElement; 34 | const moveToNext = () => { 35 | const gotoNext = () => { 36 | // element.removeEventListener(eventName, moveToNext); 37 | element.removeEventListener("click", moveToNext); 38 | element.removeEventListener("touchend", moveToNext); 39 | if(driver.getActiveStep()===step) 40 | driver.moveNext(); 41 | }; 42 | setTimeout(() => { 43 | if (!next || document.querySelector(next.element as string)) { 44 | gotoNext(); 45 | }else{ 46 | setTimeout(()=>{ 47 | if (!next || document.querySelector(next.element as string)) 48 | gotoNext() 49 | },300) 50 | } 51 | }, 100); 52 | }; 53 | eventListeners.push(moveToNext); 54 | element.addEventListener("click", moveToNext); 55 | element.addEventListener("touchend", moveToNext); 56 | } 57 | }, 58 | onNextClick: (ele) => { 59 | if (ele) { 60 | const element = ele as HTMLElement; 61 | element.dispatchEvent( 62 | new Event("click", { bubbles: true, cancelable: true }) 63 | ); 64 | } 65 | }, 66 | steps: getSteps(driver), 67 | onDestroyed: (ele,step) => { 68 | if (ele) { 69 | const element = ele as HTMLElement; 70 | if(step.element!==".tour-btn") 71 | element.dispatchEvent( 72 | new Event("click", { bubbles: true, cancelable: true }) 73 | ); 74 | } 75 | callback(); 76 | }, 77 | }; 78 | driver.setConfig(config); 79 | 80 | driver.drive(); 81 | }; 82 | -------------------------------------------------------------------------------- /src/WelcomeTour/HightLights.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Infinity from "../Resource/Icon/infinity"; 3 | import AirWaveIcon from "../Resource/Icon/airwave"; 4 | import PageViewIcon from "../Resource/Icon/pageview"; 5 | import BoltIcon from "../Resource/Icon/bolt"; 6 | import TouchIcon from "../Resource/Icon/touch"; 7 | import AutoIcon from "../Resource/Icon/auto"; 8 | import ExportIcon from "../Resource/Icon/export"; 9 | import {t} from 'i18next'; 10 | export const hightLights = [ 11 | { 12 | id:'common-operation', 13 | icon: , 14 | title: t('wu-xian-zi-chan'), 15 | subTitle: t('wu-xian-da-hua-bu'), 16 | introText: [ 17 | [false, t('zhi-chi'), t('wu-xian-tiao-xian-lu')], 18 | [false, t('zhi-chi'), t('wu-xian-ge-zhan-dian')], 19 | ], 20 | more: t('le-jie-ji-ben-cao-zuo') 21 | }, 22 | { 23 | id:'line-card', 24 | icon: , 25 | title: t('ling-huo-zou-xian'), 26 | subTitle: t('yi-tiao-xian-lu-neng-duo-ci-chuan-guo-tong-yi-zhan'), 27 | introText: [ 28 | [false, t('zhi-chi'), t('q-zi-zou-xian-yu-zou-xian')], 29 | [false, t('zhi-chi-she-zhi'), t('zhi-xian')], 30 | ], 31 | more: t('le-jie-xian-lu-she-zhi') 32 | }, 33 | { 34 | id:'station-card', 35 | icon: , 36 | title: t('fang-bian-cha-kan'), 37 | subTitle: t('ka-pian-hua-zhan-shi-zhan-dian-yu-xian-lu'), 38 | introText: [ 39 | [false, t('zhan-dian-xian-lu-hu-xiang'), t('guan-lian')], 40 | [true, t('gao-liang'), t("zhan-shi-xuan-zhong-zhan-dian-yu-xian-lu"),] 41 | ], 42 | more: t('le-jie-zhan-dian-ka-pian') 43 | }, 44 | { 45 | id:'quick-edit', 46 | icon: , 47 | title: t('gao-xiao-bian-ji'), 48 | subTitle: t('lian-xu-chuang-jian-zhan-dian-mo-shi'), 49 | introText: [ 50 | [true, t('lian-xu-tian-jia-zhan-dian'), t('mo-shi')], 51 | [false, t('zhi-chi'), t('che-hui'), t('yu'), t('zhong-zuo')], 52 | ], 53 | more: t('le-jie-kuai-su-chuang-jian') 54 | }, 55 | { 56 | id:'mobile', 57 | icon: , 58 | title: t('yi-dong-duan-zhi-chi'), 59 | subTitle: t('wan-zheng-de-chu-kong-shi-jian-zhi-chi'), 60 | introText: [ 61 | [true, t('dan-zhi'), t('tuo-dong'), t('shuang-zhi'), t('suo-fang-di-tu')], 62 | [false, t('shi-bie-dong-zuo-yi-tu'), t('jian-shao-wu-cao-zuo')], 63 | ], 64 | more: t('zai-ping-ban-shang-ti-yan') 65 | }, 66 | { 67 | id:'tag-setting', 68 | icon: , 69 | title: t('zi-dong-bi-rang'), 70 | subTitle: t('zi-dong-tian-jia-pian-yi-zhi'), 71 | introText: [ 72 | [false, t('gong-xian-xian-lu'), t('bu-hui-zhong-die')], 73 | [false, t('zhan-dian-ming-cheng-zi-dong-xuan-ze-bai-fang-wei-zhi')], 74 | ], 75 | more: t('le-jie-biao-qian-she-zhi') 76 | }, 77 | { 78 | id:'export', 79 | icon: , 80 | title: t('dao-ru-dao-chu'), 81 | subTitle: t('dao-chu-gao-fen-bian-shuai-tu-pian'), 82 | introText: [ 83 | [false, t('dao-chu'), t('pngsvg-tu-pian')], 84 | [false, t('zhi-chi-cong-mo-ban-chuang-jian')], 85 | ], 86 | more: t('le-jie-dao-ru-dao-chu') 87 | }, 88 | ]; 89 | -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/common-operation.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | import {t} from 'i18next'; 3 | 4 | export const getSteps = (driver: Driver): DriveStep[] => [ 5 | { 6 | element: ".ScaleLayer", 7 | onHighlighted:()=>{}, 8 | popover: { 9 | title: t('tour.tryScale'), 10 | description: window.ontouchend === null? t('tour.touchScale'): t('tour.mouseScale'), 11 | showButtons:['next'], 12 | onNextClick:driver.moveNext 13 | }, 14 | }, 15 | { 16 | element: ".title .click-panel", 17 | popover: { 18 | title: t('tour.clickTile'), 19 | description: t('tour.clickOpenMenu'), 20 | }, 21 | }, 22 | { 23 | element: ".title", 24 | popover: { 25 | title: t('zai-ci-dian-ji-biao-ti'), 26 | description: t('ke-yi-xiu-gai-biao-ti'), 27 | }, 28 | }, 29 | { 30 | element: ".menu", 31 | popover: { 32 | title: t('dian-ji-ren-yi-kong-bai-qu-yu-tui-chu-cai-dan'), 33 | description: t('tui-chu-cai-dan'), 34 | }, 35 | }, 36 | { 37 | element: ".station-descend-31", 38 | popover: { 39 | title: t('dian-ji-zhan-dian'), 40 | description: t('yi-da-kai-zhan-dian-ka-pian'), 41 | }, 42 | }, 43 | { 44 | element: ".station-card", 45 | popover: { 46 | title: t('zhan-dian-ka-pian'), 47 | description: t('ke-yi-zai-zhe-li-bian-ji-zhan-dian-de-suo-you-she-zhi'), 48 | showButtons:["next"], 49 | // onNextClick:driver.moveNext 50 | }, 51 | }, 52 | { 53 | element: ".ScaleLayer", 54 | popover: { 55 | title: t('chang-shi-dian-ji-xian-lu'), 56 | description: t('tu-zhong-de-cai-se-xian-tiao-ji-wei-xian-lu-ru-guo-wu-fa-xuan-zhong-ke-yi-chang-shi-fang-da-di-tu-zai-dian-xuan'), 57 | }, 58 | }, 59 | { 60 | element: ".line-card", 61 | popover: { 62 | title: t('xian-lu-ka-pian'), 63 | description: t('ke-yi-zai-zhe-li-bian-ji-xian-lu-de-suo-you-she-zhi'), 64 | showButtons:["next"], 65 | // onNextClick:driver.moveNext 66 | }, 67 | }, 68 | ]; 69 | -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/export.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | import { browserInfo } from "../../Common/util"; 3 | import {t} from 'i18next'; 4 | export const getSteps = (driver: Driver): DriveStep[] => { 5 | const { engine } = browserInfo; 6 | const webkit = engine.name === "WebKit"; 7 | return[ 8 | { 9 | element: ".title .click-panel", 10 | popover: { 11 | title: t('dian-ji-biao-ti'), 12 | description: t('dian-ji-biao-ti-da-kai-cai-dan'), 13 | }, 14 | }, 15 | { 16 | element: ".existed-map-btn", 17 | popover: { 18 | title: t('cong-mo-ban-xin-jian'), 19 | description: t('zai-yi-you-de-di-tu-shang-xiu-gai'), 20 | }, 21 | }, 22 | { 23 | element: ".tools", 24 | onHighlighted:()=>{}, 25 | popover: { 26 | title: t('xuan-ze-yi-ge-ni-xi-huan-de-cheng-shi'), 27 | description: t('ran-hou-dian-ji-xia-yi-bu'), 28 | showButtons:["next"], 29 | onNextClick:()=>{ 30 | driver.moveNext(); 31 | } 32 | }, 33 | }, 34 | { 35 | element: ".confirm-add-from-existed-map-btn", 36 | popover: { 37 | title: t('yi-ci-wei-mo-ban-xin-jian'), 38 | description: t('que-ren-xin-jian-qian-qing-que-bao-yi-jing-bao-cun-le-dang-qian-de-di-tu'), 39 | }, 40 | }, 41 | { 42 | element: ".title .click-panel", 43 | popover: { 44 | title: t('da-kai-cai-dan'), 45 | description: t('xuan-ze-zuo-wei-wen-jian-dao-chu'), 46 | }, 47 | }, 48 | { 49 | element: ".export-as-file-btn", 50 | popover: { 51 | title: t('zuo-wei-wen-jian-dao-chu'), 52 | description: t('xuan-ze-zuo-wei-wen-jian-dao-chu-0'), 53 | }, 54 | }, 55 | { 56 | element: ".import-file-btn", 57 | onHighlighted:()=>{}, 58 | popover: { 59 | title: t('dao-ru-gang-cai-dao-chu-wen-jian'), 60 | description: t('xia-ci-dian-ji-dao-ru-wen-jian-ke-yi-ji-xu-bian-ji'), 61 | showButtons:["next"], 62 | onNextClick:driver.moveNext 63 | }, 64 | }, 65 | { 66 | element: `.export-as${webkit?'-svg':''}-image-btn`, 67 | popover: { 68 | title: t('dao-chu-tu-pian'), 69 | description: t('dian-ji-dao-chu-tu-pian-dao-chu-ke-neng-hui-dao-zhi-ka-dun-ji-miao-shu-yu-zheng-chang-xian-xiang'), 70 | }, 71 | }, 72 | { 73 | element: `.recover-btn`, 74 | popover: { 75 | title: t('cong-huan-cun-zhong-hui-fu-shu-ju'), 76 | description: t('ru-guo-mei-bao-cun-de-shi-hou-bu-xiao-xin-shua-xin-ke-yi-dian-ji-zhe-li-hui-fu-shu-ju'), 77 | }, 78 | }, 79 | { 80 | element: `.export-recover-btn`, 81 | popover: { 82 | title: t('dao-chu-hui-fu-shu-ju'), 83 | description: t('ru-guo-mou-yi-bu-cao-zuo-hou-dao-zhi-bao-cuo-huo-beng-kui-dian-ji-zhe-li-dao-chu-zui-hou-yi-ci-zheng-que-de-shu-ju-ru-guo-yu-dao-zhe-zhong-qing-kuang-qing-fan-kui-gei-zuo-zhe'), 84 | }, 85 | }, 86 | ]; 87 | } -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/line-card.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | import {t} from 'i18next'; 3 | export const getSteps = (driver: Driver): DriveStep[] => [ 4 | { 5 | element: ".ScaleLayer", 6 | popover: { 7 | title: t('dian-ji-ren-yi-xian-lu'), 8 | description: t('tu-zhong-cai-se-qu-xian-biao-shi-di-tie-xian-lu'), 9 | }, 10 | }, 11 | { 12 | element: ".line-card", 13 | popover: { 14 | title: t('xian-lu-ka-pian-0'), 15 | description: t('ke-yi-zai-zhe-li-bian-ji-xian-lu-de-suo-you-she-zhi-0'), 16 | showButtons:["next"], 17 | // onNextClick:driver.moveNext 18 | }, 19 | }, 20 | { 21 | element: ".bend-first", 22 | popover: { 23 | title: t('dian-ji-ci-chu-kong-zhi-xian-lu-zai-liang-zhan-zhi-jian-de-zou-xiang'), 24 | description: t('zhan-dian-qu-jian-lian-xian-zhi-you-liang-zhong-fang-shi-xie-xian-yu-zhi-xian'), 25 | }, 26 | }, 27 | { 28 | element: ".expand", 29 | popover: { title: t('fang-da-an-niu'), description: t('ke-yi-tuo-kuan-xian-lu-ka-pian') }, 30 | }, 31 | { 32 | element: ".shrink", 33 | popover: { title: t('suo-xiao-an-niu'), description: t('zai-suo-xiao-zhuang-tai-ke-yi-dian-ji-geng-duo-she-zhi') }, 34 | }, 35 | { 36 | element: ".edit", 37 | popover: { title: t('bian-ji-an-niu'), description: t('jin-ru-geng-duo-she-zhi-mian-ban') }, 38 | }, 39 | { 40 | element: ".name-detail", 41 | popover: { title: t('ci-chu-xiu-gai-xian-lu-ming-cheng-yu-biao-shi'), description: t('pai-xu-ke-yi-kong-zhi-xian-lu-jian-de-zhe-dang-guan-xi') ,showButtons:["next"],onNextClick:driver.moveNext}, 42 | }, 43 | { 44 | element: ".edit-tool.color", 45 | popover: { title: t('dian-ji-ci-chu-xiu-gai-xian-lu-biao-shi-se'), description: t('zuo-xia-jiao-de-yan-se-xuan-ze-qi-ke-yi-qu-se') ,side:"right"}, 46 | }, 47 | { 48 | element: ".edit-tool.operation", 49 | popover: { title: t('dian-ji-ci-chu-shan-chu-xian-lu-huo-zhe-she-zhi-zhi-xian'), description: t('she-zhi-zhi-xian-hou-xian-lu-hui-yi-xu-xian-xian-shi-qie-lian-jie-chu-bu-zai-xian-shi-ba-shou'),side:"right" }, 50 | }, 51 | { 52 | element: ".done", 53 | popover: { title: t('dian-ji-ci-chu-wan-cheng-she-zhi'), description: t('hui-dao-zhan-dian-ye') }, 54 | }, 55 | { 56 | element: ".stations-count", 57 | popover: { title: t('xian-shi-tu-jing-zhan-dian-ka-pian'), description: t('dian-ji-ci-chu-suo-yi-tu-jing-de-zhan-dian-ka-pian') }, 58 | }, 59 | ]; 60 | -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/quick-edit.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | import {t} from 'i18next'; 3 | export const getSteps = (driver: Driver): DriveStep[] => [ 4 | { 5 | element: ".title .click-panel", 6 | popover: { 7 | title: t('da-kai-cai-dan-0'), 8 | description: t('dian-ji-biao-ti-da-kai-cai-dan-0'), 9 | }, 10 | }, 11 | { 12 | element: "#menu-add-station", 13 | popover: { 14 | title: t('dian-ji-tian-jia-zhan-dian'), 15 | description: t('jin-ru-tian-jia-zhan-dian-mo-shi'), 16 | }, 17 | }, 18 | { 19 | element: ".ScaleLayer", 20 | onHighlighted: () => {}, 21 | popover: { 22 | title: t('dian-ji-ren-yi-kong-bai-chu-tian-jia-zhan-dian'), 23 | description: t('tian-jia-hao-zhi-hou-dian-ji-xia-yi-bu-jian-yi-nin-zai-ping-mu-zuo-shang-jiao-de-kong-bai-qu-yu-tian-jia-san-ge-zhan-dian'), 24 | showButtons: ["next"], 25 | onNextClick: () => { 26 | driver.moveNext(); 27 | }, 28 | }, 29 | }, 30 | { 31 | element: "#add-station-finish-btn", 32 | popover: { title: t('dian-ji-wan-cheng'), description: t('tui-chu-bian-ji-mo-shi') }, 33 | }, 34 | { 35 | element: ".station-descend-1", 36 | popover: { 37 | title: t('dian-ji-gang-cai-chuang-jian-de-zhan-dian'), 38 | description: t('da-kai-zhan-dian-xin-xi-ka-pian'), 39 | }, 40 | }, 41 | { 42 | element: ".station-card-operation", 43 | popover: { title: t('dian-ji-cao-zuo-xuan-xiang-ka'), description: t('wo-men-lai-tian-jia-xian-lu') }, 44 | }, 45 | { 46 | element: ".add-new-line-btn", 47 | popover: { 48 | title: t('dian-ji-yi-ci-wei-qi-dian-xin-jian-xian-lu'), 49 | description: t('jin-ru-tian-jia-xian-lu-mo-shi'), 50 | }, 51 | }, 52 | { 53 | element: ".station-descend-2", 54 | popover: { 55 | title: t('dian-ji-zhan-dian-0'), 56 | description: t('lian-jie-zhan-dian'), 57 | }, 58 | }, 59 | { 60 | element: ".station-descend-3", 61 | popover: { 62 | title: t('dian-ji-zhan-dian'), 63 | description: t('lian-jie-zhan-dian'), 64 | }, 65 | }, 66 | { 67 | element: "#add-line-finish-btn", 68 | popover: { title: t('dian-ji-wan-cheng'), description: t('xian-lu-jiu-chuang-jian-hao-le') }, 69 | }, 70 | ]; 71 | -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/skip.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | 3 | export const getSteps = (driver: Driver): DriveStep[] => [ 4 | { 5 | element: ".title .click-panel", 6 | // onHighlighted:()=>{}, 7 | popover: { 8 | title: "下次可以点这里再次打开教程", 9 | description: "点击标题打开菜单", 10 | showButtons:["next"], 11 | // onNextClick:driver.moveNext 12 | }, 13 | }, 14 | { 15 | element: ".tour-btn", 16 | onHighlighted:()=>{}, 17 | popover: { 18 | title: "使用教程", 19 | description: "点这里再次打开", 20 | showButtons:["next"], 21 | onNextClick: driver.moveNext, 22 | }, 23 | }, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/station-card.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | import {t} from 'i18next'; 3 | export const getSteps = (driver: Driver): DriveStep[] => [ 4 | { 5 | element: ".station-descend-31", 6 | popover: { 7 | title: t('dian-ji-zhan-dian'), 8 | description: t('yi-da-kai-zhan-dian-ka-pian-0'), 9 | }, 10 | }, 11 | { 12 | element: ".station-card", 13 | popover: { 14 | title: t('zhan-dian-ka-pian'), 15 | description: t('ke-yi-zai-zhe-li-bian-ji-zhan-dian-de-suo-you-she-zhi'), 16 | showButtons:["next"], 17 | // onNextClick:driver.moveNext 18 | }, 19 | }, 20 | { 21 | element: ".name-detail", 22 | popover: { 23 | title: t('bian-ji-zhan-dian-wei-zhi'), 24 | description: t('dian-ji-zuo-biao-zhi-hou-shi-yong-shu-biao-gun-lun-ke-yi-jing-que-tiao-jie'), 25 | showButtons:["next"], 26 | // onNextClick:driver.moveNext 27 | }, 28 | }, 29 | { 30 | element: ".edit-tool.color", 31 | popover: { title: t('dian-ji-ci-chu-xiu-gai-zhan-dian-xing-zhuang'), description: t('xiu-gai-xing-zhuang') ,side:"right"}, 32 | }, 33 | { 34 | element: ".edit-tool.operation", 35 | popover: { title: t('dian-ji-ci-chu-shan-chu-zhan-dian-huo-xin-jian-xian-lu'), description: t('shan-chu-huo-xin-jian'),side:"right" }, 36 | }, 37 | { 38 | element: ".edit-tool.operation.tag", 39 | popover: { title: t('dian-ji-ci-chu-she-zhi-zhan-dian-ming-cheng-wei-zhi'), description: t('zhan-dian-ming-wei-zhi') }, 40 | }, 41 | { 42 | element: ".tag-detail", 43 | popover: { title: t('dian-ji-hui-se-de-fang-kuai-xuan-ze-fang-wei'), description: t('dian-ji-zhong-jian-de-wen-zi-hui-fu-zi-dong-wei-zhi'),showButtons:['next'],onNextClick:driver.moveNext }, 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /src/WelcomeTour/Steps/tag-setting.ts: -------------------------------------------------------------------------------- 1 | import { DriveStep, Driver } from "driver.js"; 2 | import {t} from 'i18next'; 3 | export const getSteps = (driver: Driver): DriveStep[] => [ 4 | { 5 | element: ".title .click-panel", 6 | popover: { 7 | title: t('dian-ji-biao-ti'), 8 | description: t('dian-ji-biao-ti-da-kai-cai-dan-1'), 9 | }, 10 | }, 11 | { 12 | element: ".auto-hidden-btn", 13 | popover: { 14 | title: t('guan-bi-zi-dong-yin-cang'), 15 | description: t('zhan-dian-ming-hui-zai-di-tu-suo-fang-dao-xiao-chi-du-shi-zi-dong-yin-cang-guan-bi-zi-dong-yin-cang-yi-que-bao-ke-yi-yi-zhi-xian-shi-zhan-dian-ming-cheng'), 16 | }, 17 | }, 18 | { 19 | element: ".menu", 20 | popover: { 21 | title: t('dian-ji-ren-yi-kong-bai-qu-yu-tui-chu-cai-dan-0'), 22 | description: t('tui-chu-cai-dan-0'), 23 | }, 24 | }, 25 | { 26 | element: ".station-descend-31", 27 | popover: { 28 | title: t('dian-ji-zhan-dian'), 29 | description: t('yi-da-kai-zhan-dian-ka-pian-1'), 30 | }, 31 | }, 32 | { 33 | element: ".edit-tools", 34 | popover: { title: t('zai-zhe-li-xiang-xia-gun-dong-yi-xia'), description: t('zai-dian-ji-biao-qian-an-niu') }, 35 | }, 36 | { 37 | element: ".tag-detail", 38 | popover: { title: t('chang-shi-gai-bian-zhan-dian-ming-dao-you-xia-jiao'), description: t('dian-ji-you-xia-jiao-de-hui-se-fang-kuai') }, 39 | }, 40 | { 41 | element: ".station-name-descend-31", 42 | popover: { title: t('ke-yi-kan-dao-zhan-dian-ming-yi-jing-wei-yu-zhan-dian-you-xia-jiao-le'), description: t('ke-yi-yong-zhe-zhong-fang-fa-xiu-gai-zhan-dian-ming-wei-zhi'), showButtons:["next"],onNextClick:driver.moveNext }, 43 | }, 44 | { 45 | element: ".tag-item.center", 46 | popover: { title: t('dian-ji-zhong-jian-de-wen-zi-hui-fu-dao-zi-dong-xuan-ze-wei-zhi'), description: t('hui-fu-dao-zi-dong-xuan-ze-wei-zhi') }, 47 | }, 48 | ]; 49 | -------------------------------------------------------------------------------- /src/WelcomeTour/WelcomeTour.scss: -------------------------------------------------------------------------------- 1 | .welcome-tour-container { 2 | position: fixed; 3 | height: 100vh; 4 | width: 100vw; 5 | left: 0; 6 | top: 0; 7 | background-color: rgba(0, 0, 0, 0.4); 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | z-index: 2000; 12 | font-weight: 500; 13 | font-size: 18px; 14 | .welcome-tour { 15 | margin-top: -10vh; 16 | // width: 800px; 17 | // min-width: 45vw; 18 | width: 80vw; 19 | max-width: 800px; 20 | height: 475px; 21 | background-color: #454545; 22 | border-radius: 12px; 23 | .header { 24 | margin: 33.3px 33.3px 20px 33.3px; 25 | white-space: nowrap; 26 | position: relative; 27 | .icon { 28 | img { 29 | width: 40px; 30 | height: 40px; 31 | border-radius: 5px; 32 | } 33 | } 34 | .title { 35 | display: inline-block; 36 | margin-left: 13px; 37 | vertical-align: top; 38 | .sub-title { 39 | font-size: 12px; 40 | color: white; 41 | width: fit-content; 42 | margin-top: -2px; 43 | } 44 | .main-title { 45 | color: white; 46 | font-weight: 800; 47 | width: fit-content; 48 | } 49 | } 50 | .control { 51 | display: inline-flex; 52 | vertical-align: top; 53 | font-size: 12px; 54 | position: absolute; 55 | right: 0; 56 | height: 40px; 57 | justify-content: center; 58 | align-items: center; 59 | 60 | .skip-tour { 61 | color: rgba(255, 255, 255, 0.6); 62 | margin: 0 20px; 63 | cursor: pointer; 64 | // text-transform: capitalize; 65 | } 66 | .start-tour { 67 | text-transform: capitalize; 68 | cursor: pointer; 69 | color: white; 70 | padding: 7.33px 20px; 71 | background-color: #2196f3; 72 | border-radius: 35px; 73 | } 74 | } 75 | } 76 | .divider { 77 | border-bottom: rgba(255, 255, 255, 0.27) solid 1px; 78 | } 79 | .body { 80 | white-space: nowrap; 81 | overflow-x: scroll; 82 | position: relative; 83 | &::-webkit-scrollbar { 84 | display: none; 85 | } 86 | .intro { 87 | display: inline-block; 88 | color: white; 89 | width: 285px; 90 | margin-left: 38px; 91 | margin-top: 36.67px; 92 | vertical-align: top; 93 | &:last-child{ 94 | margin-right: 38px; 95 | } 96 | .hight-light { 97 | display: inline-flex; 98 | justify-content: center; 99 | align-items: center; 100 | background-color: #727272; 101 | padding: 4px 15px; 102 | border-radius: 35px; 103 | .icon { 104 | height: 20px; 105 | width: 20px; 106 | svg { 107 | height: 20px; 108 | width: 20px; 109 | } 110 | .air{ 111 | width: 16px; 112 | margin-left: 2px; 113 | } 114 | } 115 | .title { 116 | margin-left: 5px; 117 | text-transform: capitalize; 118 | } 119 | } 120 | .detail-card { 121 | height: 240px; 122 | background-color: #727272; 123 | border-radius: 14px; 124 | margin-top: 17.3px; 125 | display: flow-root; 126 | position: relative; 127 | .qrcode-container{ 128 | position: absolute; 129 | border-radius: 14px; 130 | margin: auto; 131 | left: 0; 132 | right: 0; 133 | top:0; 134 | bottom: 0; 135 | width: 100%; 136 | height: 100%; 137 | background-color: #0000002e; 138 | backdrop-filter: blur(10px); 139 | cursor: pointer; 140 | opacity: 0; 141 | pointer-events: none; 142 | transition: 0.3s ease-in-out; 143 | &.show{ 144 | opacity: 1; 145 | pointer-events: auto; 146 | } 147 | canvas{ 148 | position: absolute; 149 | border-radius: 14px; 150 | margin: auto; 151 | left: 0; 152 | right: 0; 153 | top:0; 154 | bottom: 0; 155 | } 156 | } 157 | 158 | .title { 159 | margin-top: 20.67px; 160 | margin-left: 24px; 161 | width: 265px; 162 | white-space: normal; 163 | text-transform: capitalize; 164 | } 165 | .left-down { 166 | position: absolute; 167 | left: 24px; 168 | bottom: 20.67px; 169 | .intro-text { 170 | .intro-text-line { 171 | &:not(.line-card-text){ 172 | text-transform: capitalize; 173 | } 174 | :not(.emphasis) { 175 | color: rgba(255, 255, 255, 0.6); 176 | } 177 | } 178 | } 179 | .more { 180 | margin-top: 26px; 181 | cursor: pointer; 182 | &:hover{ 183 | // text-decoration: underline; 184 | opacity: 0.9; 185 | } 186 | .more-text { 187 | text-transform: capitalize; 188 | &.finished{ 189 | opacity: 0.6; 190 | } 191 | } 192 | .more-icon { 193 | height: 20px; 194 | width: 28px; 195 | vertical-align: top; 196 | display: inline-block; 197 | svg { 198 | height: 28px; 199 | width: 28px; 200 | &.finished{ 201 | width: 21px; 202 | // margin-top: -1px; 203 | margin-left: 2px; 204 | opacity: 0.6; 205 | } 206 | } 207 | } 208 | } 209 | } 210 | } 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/WelcomeTour/WelcomeTour.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | LegacyRef, 3 | MutableRefObject, 4 | RefObject, 5 | useEffect, 6 | useRef, 7 | useState, 8 | useTransition, 9 | } from "react"; 10 | import "./WelcomeTour.scss"; 11 | import Infinity from "../Resource/Icon/infinity"; 12 | import GoToIcon from "../Resource/Icon/goto"; 13 | import { hightLights } from "./HightLights"; 14 | import classNames from "classnames"; 15 | import { onWheelX } from "../Common/util"; 16 | import { showTour } from "./Driver"; 17 | import { ShowTourProps, UserDataType } from "../Data/UserData"; 18 | import FinishedIcon from "../Resource/Icon/finished"; 19 | import QRCode from "qrcode"; 20 | import { useTranslation } from "react-i18next"; 21 | export function WelcomeTour({ 22 | showTour: show, 23 | setShowTour: setShow, 24 | }: ShowTourProps) { 25 | const [qrCode, setQRCode] = useState(false); 26 | const {t} = useTranslation(); 27 | const [visitedSteps, setVisitedSteps] = useState(() => { 28 | const visitedStepsJson = localStorage.getItem("visited-steps"); 29 | return visitedStepsJson ? JSON.parse(visitedStepsJson) : []; 30 | }); 31 | const setVisited = (step: string) => { 32 | const steps = visitedSteps.concat([step]); 33 | localStorage.setItem("visited-steps", JSON.stringify(steps)); 34 | setVisitedSteps(steps); 35 | }; 36 | const reset = () => { 37 | localStorage.setItem("visited-steps", JSON.stringify([])); 38 | setVisitedSteps([]); 39 | localStorage.setItem("skip-tour-viewed", "Y"); 40 | }; 41 | const next = hightLights.find( 42 | (highlight) => !visitedSteps.includes(highlight.id) 43 | ); 44 | const canvasRef = useRef(null); 45 | const showQRCode = () => { 46 | setQRCode(true); 47 | setVisited("mobile"); 48 | setTimeout(() => { 49 | QRCode.toCanvas(canvasRef.current, window.location.href); 50 | }); 51 | }; 52 | const scrollToNext = ()=>{ 53 | setTimeout(() => { 54 | const contianer = document.querySelector(".welcome-tour .body"); 55 | if(next && contianer){ 56 | const nextIntro = document.querySelector(`.intro-${next.id}`) 57 | if(nextIntro){ 58 | const {offsetLeft} = nextIntro as HTMLDivElement; 59 | contianer.scrollTo(offsetLeft-38,0); 60 | } 61 | } 62 | },100); 63 | } 64 | useEffect(() => { 65 | scrollToNext(); 66 | }, []); 67 | return ( 68 |
72 |
73 |
74 | 75 | 76 | 77 | 78 |
{t('welcome.welcome')}
79 |
{t('welecome.minimetroweb')}
80 |
81 |
82 | {next ? ( 83 | <> 84 | { 87 | setShow(false); 88 | // if (!localStorage.getItem("skip-tour-viewed")) 89 | // showTour("skip", () => { 90 | localStorage.setItem("skip-tour-viewed", "Y"); 91 | // }); 92 | }} 93 | > 94 | {t('welcome.skip')} 95 | 96 | { 99 | if (next.id === "mobile") { 100 | showQRCode(); 101 | } else { 102 | setShow(false); 103 | showTour(next.id, () => { 104 | setShow(true); 105 | setVisited(next.id); 106 | scrollToNext(); 107 | }); 108 | } 109 | }} 110 | > 111 | {visitedSteps.length ? t('welcome.goOn') : ""} 112 | {next.more} 113 | 114 | 115 | ) : ( 116 | <> 117 | 118 | {t('welcome.restart')} 119 | 120 | { 123 | localStorage.setItem("skip-tour-viewed", "Y"); 124 | setShow(false); 125 | }} 126 | > 127 | {t('welcome.done')} 128 | 129 | 130 | )} 131 |
132 |
133 |
134 |
135 | {hightLights.map((hightLight) => { 136 | const { id, icon, title, subTitle, introText, more } = hightLight; 137 | const finished = visitedSteps.includes(id); 138 | return ( 139 |
140 |
141 | {icon} 142 | {title} 143 |
144 |
145 |
{subTitle}
146 |
147 |
148 | {introText.map((line) => { 149 | const emphasisStart = line[0]; 150 | const lineText = line.slice(1); 151 | return ( 152 |
153 | {lineText.map((text, index) => { 154 | const emphasis = 155 | (Number(emphasisStart) + index) % 2; 156 | return ( 157 | 158 | {text} 159 | 160 | ); 161 | })} 162 |
163 | ); 164 | })} 165 |
166 |
{ 169 | if (id === "mobile") { 170 | showQRCode(); 171 | } else { 172 | showTour(id, () => { 173 | setVisited(id); 174 | setShow(true); 175 | scrollToNext(); 176 | }); 177 | setShow(false); 178 | } 179 | }} 180 | > 181 | 184 | {more} 185 | 186 | 187 | {finished ? ( 188 | 189 | ) : ( 190 | 191 | )} 192 | 193 |
194 |
195 | {id === "mobile" ? ( 196 |
setQRCode(false)} 202 | > 203 | 204 |
205 | ) : ( 206 | <> 207 | )} 208 |
209 |
210 | ); 211 | })} 212 |
213 |
214 |
215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /src/i18n/config.ts: -------------------------------------------------------------------------------- 1 | 2 | import i18n from 'i18next'; 3 | import { initReactI18next } from 'react-i18next'; 4 | import translation_en from './locales/en.json'; 5 | import translation_zh from './locales/zh.json'; 6 | import LanguageDetector from 'i18next-browser-languagedetector'; 7 | const resources = { 8 | en: { translation: translation_en }, 9 | zh: { translation: translation_zh }, 10 | }; 11 | 12 | i18n 13 | .use(LanguageDetector) 14 | .use(initReactI18next) 15 | .init({ 16 | resources, 17 | fallbackLng: 'en', 18 | detection: { 19 | order: ['queryString', 'cookie', 'localStorage', 'navigator', 'htmlTag', 'path', 'subdomain'], 20 | caches: ['localStorage', 'cookie'] 21 | }, 22 | interpolation: { escapeValue: false }, 23 | }); 24 | 25 | export default i18n; 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | 4 | import App from './Entrance/App'; 5 | import reportWebVitals from './Entrance/reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 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(); -------------------------------------------------------------------------------- /src/types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | export const ReactComponent: React.FC>; 4 | const src: string; 5 | export default src; 6 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 5 | 6 | /* Basic Options */ 7 | // "incremental": true, /* Enable incremental compilation */ 8 | "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 9 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 10 | // "lib": [], /* Specify library files to be included in the compilation. */ 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 16 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | // "outDir": "./", /* Redirect output structure to the directory. */ 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true, /* Enable all strict type-checking options. */ 30 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | 44 | /* Module Resolution Options */ 45 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 49 | // "typeRoots": [], /* List of folders to include type definitions from. */ 50 | // "types": [], /* Type declaration files to be included in compilation. */ 51 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 52 | "resolveJsonModule": true, 53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 56 | 57 | /* Source Map Options */ 58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 62 | 63 | /* Experimental Options */ 64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 66 | 67 | /* Advanced Options */ 68 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 70 | } 71 | } 72 | --------------------------------------------------------------------------------