├── .dockerignore ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── docs ├── basic-overcast.png ├── basic.png ├── blueprint.png ├── dockerbuild.png ├── dockerrun.png ├── frank.png ├── mapbox-dark.png ├── mapbox-japan.png ├── mapbox-light.png ├── mapbox-navigation-day.png ├── mapbox-navigation-night.png ├── mapbox-outdoors.png ├── mapbox-satellite-streets.jpg ├── mapbox-satellite.jpg ├── mapbox-streets.png ├── marvel-americachaves.png ├── marvel-blackpanther.png ├── marvel-blackwidow.png ├── marvel-captainamerica.png ├── marvel-drstrange.png ├── marvel-hulk.png ├── marvel-ironman.png ├── marvel-thanos.png ├── marvel-wanda.png ├── minimo.png ├── obsbrowser.png ├── obsdone.png ├── standard-oil.png ├── styled.png └── unicorn.png ├── index.html ├── package.json ├── public ├── assets │ ├── 01d.svg │ ├── 01n.svg │ ├── 02d.svg │ ├── 02n.svg │ ├── 03d.svg │ ├── 03n.svg │ ├── 04d.svg │ ├── 04n.svg │ ├── 09d.svg │ ├── 09n.svg │ ├── 10d.svg │ ├── 10n.svg │ ├── 11d.svg │ ├── 11n.svg │ ├── 13d.svg │ ├── 13n.svg │ ├── 50d.svg │ ├── 50n.svg │ ├── cloud-arrow-down.svg │ ├── cloud-arrow-up.svg │ └── marker.png ├── favicon.ico ├── index.html ├── robots.txt └── vite.svg ├── src ├── App.scss ├── App.tsx ├── components │ ├── DateTime │ │ ├── DateTime.scss │ │ ├── DateTime.tsx │ │ └── index.tsx │ ├── Map │ │ ├── Map.scss │ │ ├── Map.tsx │ │ └── index.tsx │ ├── Metrics │ │ ├── Altitude │ │ │ ├── Altitude.tsx │ │ │ └── index.tsx │ │ ├── Distance │ │ │ ├── Distance.tsx │ │ │ └── index.tsx │ │ ├── Heading │ │ │ ├── Heading.tsx │ │ │ └── index.tsx │ │ ├── Heartrate │ │ │ ├── Heartrate.tsx │ │ │ └── index.tsx │ │ ├── Metrics.scss │ │ ├── Metrics.tsx │ │ ├── Speed │ │ │ ├── Speed.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── Neighbourhood │ │ ├── Neighbourhood.scss │ │ ├── Neighbourhood.tsx │ │ └── index.tsx │ ├── RotatingElements │ │ ├── RotatingElements.tsx │ │ └── index.tsx │ ├── Rotator │ │ ├── Rotator.scss │ │ ├── Rotator.tsx │ │ └── index.tsx │ ├── StreamElements │ │ ├── LatestCheer.tsx │ │ ├── LatestFollow.tsx │ │ ├── LatestSub.tsx │ │ ├── LatestTip.tsx │ │ ├── RecentCheer.tsx │ │ ├── RecentFollow.tsx │ │ ├── RecentSub.tsx │ │ ├── RecentTip.tsx │ │ ├── StreamElements.scss │ │ ├── StreamElements.tsx │ │ └── index.tsx │ └── Weather │ │ ├── Weather.scss │ │ ├── Weather.tsx │ │ ├── defaultWeatherValues.ts │ │ └── index.tsx ├── functions │ ├── isEmpty.ts │ ├── isJapanese.ts │ └── valueFormatter.ts ├── handlers │ ├── handleDateTime.ts │ ├── handleDistance.ts │ ├── handleMapZoomInterval.ts │ ├── handleNeighbourhood.ts │ ├── handleSpringProps.ts │ ├── handleStreamElements.ts │ ├── handleTheme.ts │ └── handleWeather.ts ├── hooks │ └── useListener.tsx ├── index.tsx ├── store │ ├── flagStore.ts │ ├── globalStore.ts │ ├── keyStore.ts │ └── triggerStore.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore all directories 2 | */ 3 | 4 | # But include files at the root 5 | !*/ 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_TIMEZONE_KEY = REPLACEMEWITHYOURKEYHERE 2 | VITE_LEAFLET_PROVIDER_KEY = REPLACEMEWITHYOURKEYHERE 3 | VITE_MAPBOX_KEY = REPLACEMEWITHYOURKEYHERE 4 | VITE_OPENWEATHER_KEY = REPLACEMEWITHYOURKEYHERE 5 | VITE_PULL_KEY = REPLACEMEWITHYOURKEYHERE 6 | VITE_STREAMELEMENTS_KEY = REPLACEMEWITHYOURKEYHERE -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env.local -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.18.2 as build-deps 2 | RUN git clone https://github.com/scallensc/react-realtimeirl 3 | WORKDIR "/react-realtimeirl" 4 | COPY . . 5 | RUN yarn && yarn build 6 | 7 | FROM nginx:1.23.1-alpine 8 | COPY --from=build-deps /react-realtimeirl/dist /usr/share/nginx/html 9 | EXPOSE 80 10 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RealtimeIRL React-based overlay 2 | This overlay is designed to be used in [**OBS**](https://obsproject.com/) in conjunction with the [**RealtimeIRL**](https://rtirl.com/) app. 3 | 4 | # Getting started 5 | 6 | > [!CAUTION] 7 | > Existing users: 8 | > This app has been completely rewritten. Please read the documentation thoroughly. 9 | > 10 | > Backup your `.env.local` file, delete your old copy of the app, then clone the repository again. 11 | > 12 | > Please take note of the new prefixes required in the `.env.local` file as described below, as well as the new URL parameters and ports, and update your browser source in OBS accordingly. 13 | 14 | ## Requirements 15 | - `Mapbox` account and API key, which you can create here: https://www.mapbox.com/ 16 | - `OpenWeatherMap` account and API key, which you can create here: https://openweathermap.org/ 17 | - `RealtimeIRL` account and pull key, which you can create here: https://rtirl.com/ 18 | - `TimezoneDB` account and API key, which you can create here: https://timezonedb.com/ 19 | 20 | ### Optional: 21 | - `StreamElements` account and API key, which you can create here: https://streamelements.com/ 22 | 23 | # Installation: 24 | [**Git**](https://git-scm.com/downloads) must be installed to clone the repository 25 | 26 | From a terminal, run the following commands: 27 | ```bash 28 | git clone https://github.com/scallensc/react-realtimeirl.git 29 | cd react-realtimeirl 30 | ``` 31 | 32 | A `.env.local` file is **required** in the root of the project folder. 33 | You can copy the `.env.example` file to `.env.local` and edit the values as required. 34 | 35 | Required entries: 36 | ``` 37 | VITE_MAPBOX_KEY = INSERTMAPBOXAPIKEYHERE 38 | VITE_OPENWEATHER_KEY = INSERTOPENWEATHERMAPAPIKEYHERE 39 | VITE_PULL_KEY = INSERTREALTIMEIRLPULLKEYHERE 40 | VITE_TIMEZONE_KEY = INSERTTIMEZONEDBAPIKEYHERE 41 | ``` 42 | Optional entries: 43 | ``` 44 | VITE_STREAMELEMENTS_KEY = INSERTSTREAMELEMENTSAPIKEYHERE 45 | ``` 46 | 47 | ## Easiest option - Docker: 48 | You will need to install and configure [**Docker**](https://docs.docker.com/get-docker/). 49 | 50 | > [!WARNING] 51 | > Ensure you have created the `.env.local` file as described above, before proceeding. 52 | > You can copy the `.env.example` file to `.env.local` and edit the values as required. 53 | 54 | Once **Docker Desktop** has been installed and the engine is running, from a terminal in the project root folder, you can build the image with the following command: 55 | ```bash 56 | docker build -t react-realtimeirl . 57 | ``` 58 | You should see the following output: 59 | ![](docs/dockerbuild.png) 60 | 61 | Now, you can run the image with the following command: 62 | ```bash 63 | docker run --name react-realtimeirl -p 80:80 -d react-realtimeirl 64 | ``` 65 | You should see the following output: (the container ID will be different for you) 66 | ![](docs/dockerrun.png) 67 | 68 | Please see the [docker documentation](https://docs.docker.com/) for more information on how to use docker, starting/stopping, how to prune containers, etc. 69 | 70 | ### The overlay will now be running at http://localhost 71 | 72 | # OBS 73 | Add a `browser source` 74 | Set the `URL` http://localhost 75 | Set the `Width` to `1920` 76 | Set the `Height` to `1080` 77 | 78 | All other settings should be left at default, but please ensure that both these values are ***unchecked***: 79 | `☐ Shutdown source when not visible` 80 | `☐ Refresh browser when scene becomes active` 81 | 82 | ![](docs/obsbrowser.png) 83 | 84 | If you followed all of the instructions correctly up to this point, you should now see the overlay in OBS, start pushing data to the `RealtimeIRL` app, and you should see the overlay updating in real time. 85 | 86 | ![](docs/obsdone.png) 87 | Done! 88 | 89 | ## Self hosting option - Node: 90 | You will need to install [**NodeJS (v18)**](https://nodejs.org/en/download), as well as [**Yarn**](https://classic.yarnpkg.com/en/docs/install). 91 | 92 | After cloning the repository, you will need to install the dependencies, from a terminal in the project root folder, run the following command: 93 | ```bash 94 | yarn 95 | ``` 96 | > [!NOTE] 97 | > Ensure you have created the `.env.local` file as described above, before proceeding. 98 | > You can copy the `.env.example` file to `.env.local` and edit the values as required. 99 | 100 | To start the app, run the following command: 101 | ```bash 102 | yarn start 103 | ``` 104 | 105 | ### The overlay will now be running at http://localhost:5173 106 | Follow the above instructions to add the overlay to OBS, ensuring that `:5173` is added to the end of the URL in the browser source. 107 | 108 | > [!WARNING] 109 | > Existing users: 110 | > Bundler changed from CRA to Vite, browser source URL changes from http://localhost:3000 to http://localhost:5173 111 | 112 | # Customisation 113 | URL parameters can be used for basic customisation of the output of the overlay. For more advanced customisation, you will need to edit the source code. 114 | Each component has a `.scss` file in the `src/components` folder, which you can edit to change the appearance of each part of the overlay. 115 | 116 | ### URL parameters: 117 | To enable or disable display of various elements of the overlay, you can use URL parameters, these are described below. Add these to the end of the URL in your browser source in OBS. 118 | 119 | **Docker** e.g: 120 | `http://localhost/?theme=mapbox-japan&showMetrics=1&showHeartrate=1&showHeading=1&showAltitude=1&showDistance=1&showSpeed=1` 121 | 122 | **Node** e.g: 123 | `http://localhost:5173/?theme=mapbox-japan&showMetrics=1&showHeartrate=1&showHeading=1&showAltitude=1&showDistance=1&showSpeed=1` 124 | 125 | > [!NOTE] 126 | > The first parameter must be preceded by `?`, and all subsequent parameters must be preceded by `&` 127 | > 128 | > These parameters are only required if you wish to customise the default output. 129 | 130 | > [!WARNING] 131 | > Existing users: 132 | > All flags have been changed and no longer need values unless specified below. 133 | > e.g.: 134 | > `disableAnimation=1` becomes `disableAnimation` 135 | > `mapZoom` still requires a value: `mapZoom=15` 136 | 137 | #### Animation: 138 | - disableAnimation - disable the animation in/out of the location/weather <> streamelements data container 139 | `disableAnimation` 140 | 141 | #### Date/Time: 142 | - hideTime - hide the time 143 | `hideTime` 144 | 145 | - splitDateTime - split the date and time into separate lines 146 | `splitDateTime` 147 | 148 | - timeAtBottom - move the date/time display below the map (looks bad with metrics enabled, might be good without) 149 | `timeAtBottom` 150 | 151 | #### Map: 152 | - hideMap - hide the map 153 | `hideMap` 154 | 155 | - mapFollowsHeading - map display will rotate according to heading, instead of being fixed north 156 | `mapFollowsHeading` 157 | 158 | - mapHasBorder - add a border around the map 159 | `mapHasBorder` 160 | 161 | - mapIs3d - change to 3D map display 162 | `mapIs3d` 163 | 164 | - mapIsCircular - make the map circular instead of square 165 | `mapIsCircular` 166 | 167 | - pulseMarker - enable the pulse animation on the marker 168 | `pulseMarker` 169 | 170 | - theme - choose from available map themes (default is `mapbox-streets` - see bottom of this page for more info) 171 | `theme=mapbox-streets` 172 | 173 | - zoom - set the zoom level of the map (default is 14) 174 | `zoom=14` 175 | 176 | - zoomLevels - specify zoom level/interval pairs to dynamically adjust map zoom 177 | `zoomLevels=5-5,10-1,15-1` 178 | - This example sets zoom level 5 for 5 minutes, then zoom level 10 for 1 minute, then zoom level 15 for 1 minute, then repeats 179 | 180 | #### Metrics: 181 | 182 | - showMetrics - enable metrics container (see below for options) 183 | `showMetrics` 184 | 185 | - useImperial - use imperial units instead of metric 186 | `useImperial` 187 | 188 | #### Metrics container options: 189 | - showAltitude - enable altitude 190 | `showAltitude` 191 | 192 | - showDistance - enable total distance 193 | `showDistance` 194 | 195 | - showHeading - enable heading 196 | `showHeading` 197 | 198 | - showHeartrate - enable heartrate 199 | `showHeartrate` 200 | 201 | - showSpeed - enable speed 202 | `showSpeed` 203 | 204 | #### Neighbourhood: 205 | 206 | - shortLocation - use short location name instead of full info with POI, etc. 207 | `shortLocation` 208 | 209 | # Map themes: 210 | Leaflet based map has been removed and replaced with Mapbox, which has a number of themes available. 211 | 212 | All default styles from https://docs.mapbox.com/api/maps/styles/ are available: 213 | 214 | `theme=mapbox-dark` 215 | 216 | ![](docs/mapbox-dark.png) 217 | 218 | `theme=mapbox-japan` 219 | 220 | ![](docs/mapbox-japan.png) 221 | 222 | `theme=mapbox-light` 223 | 224 | ![](docs/mapbox-light.png) 225 | 226 | `theme=mapbox-navigation-day` 227 | 228 | ![](docs/mapbox-navigation-day.png) 229 | 230 | `theme=mapbox-navigation-night` 231 | 232 | ![](docs/mapbox-navigation-night.png) 233 | 234 | `theme=mapbox-outdoors` 235 | 236 | ![](docs/mapbox-outdoors.png) 237 | 238 | `theme=mapbox-satellite` 239 | 240 | ![](docs/mapbox-satellite.jpg) 241 | 242 | `theme=mapbox-satellite-streets` 243 | 244 | ![](docs/mapbox-satellite-streets.jpg) 245 | 246 | `theme=mapbox-streets` 247 | 248 | ![](docs/mapbox-streets.png) 249 | 250 | Additionally, the following themes from https://www.mapbox.com/gallery are available: 251 | 252 | `theme=basic` 253 | 254 | ![](docs/basic.png) 255 | 256 | `theme=basic-overcast` 257 | 258 | ![](docs/basic-overcast.png) 259 | 260 | `theme=blueprint` 261 | 262 | ![](docs/blueprint.png) 263 | 264 | `theme=frank` 265 | 266 | ![](docs/frank.png) 267 | 268 | `theme=minimo` 269 | 270 | ![](docs/minimo.png) 271 | 272 | `theme=standard-oil` 273 | 274 | ![](docs/standard-oil.png) 275 | 276 | `theme=unicorn` 277 | 278 | ![](docs/unicorn.png) 279 | 280 | Marvel themes from https://www.mapbox.jp/gallery are also available: 281 | 282 | `theme=marvel-americachaves` 283 | 284 | ![](docs/marvel-americachaves.png) 285 | 286 | `theme=marvel-blackwidow` 287 | 288 | ![](docs/marvel-blackwidow.png) 289 | 290 | `theme=marvel-blackpanther` 291 | 292 | ![](docs/marvel-blackpanther.png) 293 | 294 | `theme=marvel-captainamerica` 295 | 296 | ![](docs/marvel-captainamerica.png) 297 | 298 | `theme=marvel-drstrange` 299 | 300 | ![](docs/marvel-drstrange.png) 301 | 302 | `theme=marvel-hulk` 303 | 304 | ![](docs/marvel-hulk.png) 305 | 306 | `theme=marvel-ironman` 307 | 308 | ![](docs/marvel-ironman.png) 309 | 310 | `theme=marvel-thanos` 311 | 312 | ![](docs/marvel-thanos.png) 313 | 314 | `theme=marvel-wanda` 315 | 316 | ![](docs/marvel-wanda.png) 317 | 318 | 319 | 320 | Custom themes can be created at https://studio.mapbox.com/ 321 | 322 | You can then either add an entry to the `themes` object in `src/handlers/handleTheme.ts`, and pass the name of this entry as a URL parameter, or you 323 | can hardcode the value in `src/components/Map.tsx` in the `style` property here: 324 | ```javascript 325 | const map = new mapboxgl.Map({ 326 | style: 'insert mapbox style url here, e.g. mapbox://styles/mapbox/dark-v11', 327 | center: [location.longitude, location.latitude], 328 | zoom: parseInt(mapZoom), 329 | pitch: mapIs3d ? 45 : 0, 330 | bearing: heading, 331 | container: mapContainer.current, 332 | antialias: true 333 | }); 334 | ``` 335 | 336 | ***Example output with `theme=mapbox-dark`, `metrics` options and `mapIs3d` enabled:*** 337 | 338 | ![](docs/styled.png) -------------------------------------------------------------------------------- /docs/basic-overcast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/basic-overcast.png -------------------------------------------------------------------------------- /docs/basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/basic.png -------------------------------------------------------------------------------- /docs/blueprint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/blueprint.png -------------------------------------------------------------------------------- /docs/dockerbuild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/dockerbuild.png -------------------------------------------------------------------------------- /docs/dockerrun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/dockerrun.png -------------------------------------------------------------------------------- /docs/frank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/frank.png -------------------------------------------------------------------------------- /docs/mapbox-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-dark.png -------------------------------------------------------------------------------- /docs/mapbox-japan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-japan.png -------------------------------------------------------------------------------- /docs/mapbox-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-light.png -------------------------------------------------------------------------------- /docs/mapbox-navigation-day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-navigation-day.png -------------------------------------------------------------------------------- /docs/mapbox-navigation-night.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-navigation-night.png -------------------------------------------------------------------------------- /docs/mapbox-outdoors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-outdoors.png -------------------------------------------------------------------------------- /docs/mapbox-satellite-streets.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-satellite-streets.jpg -------------------------------------------------------------------------------- /docs/mapbox-satellite.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-satellite.jpg -------------------------------------------------------------------------------- /docs/mapbox-streets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/mapbox-streets.png -------------------------------------------------------------------------------- /docs/marvel-americachaves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-americachaves.png -------------------------------------------------------------------------------- /docs/marvel-blackpanther.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-blackpanther.png -------------------------------------------------------------------------------- /docs/marvel-blackwidow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-blackwidow.png -------------------------------------------------------------------------------- /docs/marvel-captainamerica.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-captainamerica.png -------------------------------------------------------------------------------- /docs/marvel-drstrange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-drstrange.png -------------------------------------------------------------------------------- /docs/marvel-hulk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-hulk.png -------------------------------------------------------------------------------- /docs/marvel-ironman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-ironman.png -------------------------------------------------------------------------------- /docs/marvel-thanos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-thanos.png -------------------------------------------------------------------------------- /docs/marvel-wanda.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/marvel-wanda.png -------------------------------------------------------------------------------- /docs/minimo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/minimo.png -------------------------------------------------------------------------------- /docs/obsbrowser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/obsbrowser.png -------------------------------------------------------------------------------- /docs/obsdone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/obsdone.png -------------------------------------------------------------------------------- /docs/standard-oil.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/standard-oil.png -------------------------------------------------------------------------------- /docs/styled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/styled.png -------------------------------------------------------------------------------- /docs/unicorn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/docs/unicorn.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | rtIRL 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-realtimeirl", 3 | "private": true, 4 | "version": "0.9.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@legendapp/state": "^2.1.3", 14 | "@mapbox/mapbox-sdk": "^0.15.3", 15 | "@rtirl/api": "^1.1.5", 16 | "@types/mapbox-gl": "^2.7.19", 17 | "@types/react-leaflet": "^3.0.0", 18 | "axios": "^1.6.2", 19 | "leaflet": "^1.9.4", 20 | "luxon": "^3.4.4", 21 | "mapbox-gl": "^2.15.0", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-fitty": "^1.0.1", 25 | "react-leaflet": "^4.2.1", 26 | "react-map-gl": "^7.1.6", 27 | "react-spring": "8.0.27", 28 | "react-svgmt": "^2.0.2", 29 | "react-timer-hook": "^3.0.7", 30 | "react-transition-group": "^4.4.5", 31 | "sass": "^1.69.5", 32 | "socket.io-client": "^4.7.2" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^20.9.4", 36 | "@types/react": "^18.2.37", 37 | "@types/react-dom": "^18.2.15", 38 | "@typescript-eslint/eslint-plugin": "^6.10.0", 39 | "@typescript-eslint/parser": "^6.10.0", 40 | "@vitejs/plugin-react-swc": "^3.5.0", 41 | "eslint": "^8.53.0", 42 | "eslint-plugin-react-hooks": "^4.6.0", 43 | "eslint-plugin-react-refresh": "^0.4.4", 44 | "typescript": "^5.2.2", 45 | "vite": "^5.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/assets/01d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/01n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/02d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/02n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/03d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/03n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/04d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/04n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/09d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/09n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/10d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/10n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/11d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/11n.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/13d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/13n.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/50d.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/50n.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/cloud-arrow-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/cloud-arrow-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/public/assets/marker.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scallensc/react-realtimeirl/b57fb06efa9da3cf0110a493c20e8e365af22539/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | rtIRL 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Anek+Latin&family=Blinker&family=Questrial&display=swap'); 2 | 3 | body { 4 | overflow: hidden; 5 | // background-color: black; 6 | } 7 | 8 | .App { 9 | overflow: hidden; 10 | position: absolute; 11 | width: 100vw; 12 | height: 100vh; 13 | } 14 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import Map from '@components/Map'; 4 | import Metrics from '@components/Metrics'; 5 | import Neighbourhood from '@components/Neighbourhood'; 6 | import Rotator from '@components/Rotator'; 7 | 8 | import useListener from '@hooks/useListener'; 9 | 10 | import './App.scss'; 11 | 12 | function App() { 13 | // Propagate events to global state 14 | useListener(); 15 | 16 | // Run handlers 17 | useEffect(() => { 18 | import('@handlers/handleDateTime') 19 | import('@handlers/handleDistance') 20 | import('@handlers/handleMapZoomInterval') 21 | import('@handlers/handleStreamElements') 22 | import('@handlers/handleTheme') 23 | import('@handlers/handleWeather') 24 | import('@handlers/handleNeighbourhood') 25 | }, []) 26 | return ( 27 |
28 | 29 | 30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /src/components/DateTime/DateTime.scss: -------------------------------------------------------------------------------- 1 | .time-container { 2 | display: inline-flex; 3 | justify-content: center; 4 | text-align: center; 5 | width: 100%; 6 | font-family: 'Anek Latin', sans-serif; 7 | font-size: 20px; 8 | color: #ffffff; 9 | text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; 10 | padding-block: 2px; 11 | 12 | .time { 13 | font-feature-settings: 'tnum'; 14 | } 15 | } -------------------------------------------------------------------------------- /src/components/DateTime/DateTime.tsx: -------------------------------------------------------------------------------- 1 | import { ReactFitty } from 'react-fitty'; 2 | 3 | import './DateTime.scss'; 4 | import globalStore from '@store/globalStore'; 5 | 6 | const DateTime = () => { 7 | const dateTime = globalStore.get().dateTime; 8 | return ( 9 |
10 | {dateTime} 11 |
12 | ); 13 | }; 14 | 15 | export default DateTime; 16 | -------------------------------------------------------------------------------- /src/components/DateTime/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './DateTime'; 2 | -------------------------------------------------------------------------------- /src/components/Map/Map.scss: -------------------------------------------------------------------------------- 1 | .map-container { 2 | position: absolute; 3 | width: 256px; 4 | height: fit-content; 5 | bottom: 5px; 6 | right: 5px; 7 | } 8 | 9 | .map3d-container { 10 | width: 256px; 11 | height: 256px; 12 | box-sizing: border-box; 13 | } 14 | 15 | .map3d-marker { 16 | width: 15px; 17 | height: 15px; 18 | background-color: cyan; 19 | border-radius: 50%; 20 | } 21 | 22 | // .mapboxgl-control-container { 23 | // display: none; 24 | // } -------------------------------------------------------------------------------- /src/components/Map/Map.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import mapboxgl from 'mapbox-gl'; 3 | 4 | import DateTime from '@components/DateTime'; 5 | 6 | import flagStore from '@store/flagStore'; 7 | import globalStore from '@store/globalStore'; 8 | import keyStore from '@store/keyStore'; 9 | 10 | import './Map.scss'; 11 | 12 | interface IPulsingDot { 13 | width: number; 14 | height: number; 15 | data: Uint8ClampedArray; 16 | context?: CanvasRenderingContext2D | null; 17 | onAdd: () => void; 18 | render: () => boolean; 19 | } 20 | 21 | const Map = () => { 22 | const mapContainer = useRef(null); 23 | const [map3D, setMap3D] = useState(null); 24 | 25 | const { hideMap, mapFollowsHeading, mapHasBorder, mapIs3d, mapIsCircular, mapZoom, pulseMarker, timeAtBottom } = flagStore.get(); 26 | const { heading, location, theme } = globalStore.get(); 27 | const { mapboxKey } = keyStore.get(); 28 | 29 | mapboxgl.accessToken = mapboxKey; 30 | 31 | useEffect(() => { 32 | if (mapContainer.current) { 33 | mapContainer.current.innerHTML = ''; 34 | const map = new mapboxgl.Map({ 35 | style: theme, 36 | center: [location.longitude, location.latitude], 37 | zoom: parseInt(mapZoom), 38 | pitch: mapIs3d ? 45 : 0, 39 | bearing: mapFollowsHeading ? heading : 0, 40 | container: mapContainer.current, 41 | antialias: true, 42 | attributionControl: false, 43 | }); 44 | const size = 100; 45 | const pulsingDot: IPulsingDot = { 46 | width: size, 47 | height: size, 48 | data: new Uint8ClampedArray(size * size * 4), 49 | 50 | onAdd: function () { 51 | const canvas = document.createElement('canvas'); 52 | canvas.width = this.width; 53 | canvas.height = this.height; 54 | this.context = canvas.getContext('2d', { willReadFrequently: true }); 55 | }, 56 | 57 | render: function () { 58 | const duration = 1500; 59 | const t = (performance.now() % duration) / duration; 60 | 61 | const radius = (size / 2) * 0.3; 62 | const outerRadius = (size / 2) * 0.3 * t + radius; 63 | 64 | if (this.context) { 65 | const context = this.context; 66 | const colours = { 67 | mainFill: 'rgba(0, 255, 255, 1)', 68 | outerFill: 'rgba(96, 96, 96, 1)', 69 | pulse: { red: 96, green: 96, blue: 96, alpha: 1 } 70 | } 71 | if (pulseMarker) { 72 | // Draw the outer circle. 73 | context.clearRect(0, 0, this.width, this.height); 74 | context.beginPath(); 75 | context.arc(this.width / 2, this.height / 2, outerRadius, 0, Math.PI * 2); 76 | context.fillStyle = `rgba(${colours.pulse.red}, ${colours.pulse.green}, ${colours.pulse.blue}, ${colours.pulse.alpha - t})`; 77 | context.fill(); 78 | } 79 | // Draw the inner circle. 80 | context.beginPath(); 81 | context.arc(this.width / 2, this.height / 2, radius, 0, Math.PI * 2); 82 | context.fillStyle = colours.mainFill; 83 | context.strokeStyle = colours.outerFill; 84 | context.lineWidth = 1 + 4 * (1 - t); 85 | context.fill(); 86 | pulseMarker && context.stroke(); 87 | 88 | this.data = context.getImageData(0, 0, this.width, this.height).data; 89 | map.triggerRepaint(); 90 | 91 | return true; 92 | } 93 | return false; 94 | } 95 | }; 96 | 97 | map.on('load', () => { 98 | map.addImage('pulsing-dot', pulsingDot, { pixelRatio: 2 }); 99 | 100 | map.addSource('dot-point', { 101 | type: 'geojson', 102 | data: { 103 | type: 'FeatureCollection', 104 | features: [{ 105 | type: 'Feature', 106 | geometry: { 107 | type: 'Point', 108 | coordinates: [location.longitude, location.latitude] 109 | }, 110 | properties: {} 111 | }] 112 | } 113 | }); 114 | 115 | map.addLayer({ 116 | id: 'layer-with-pulsing-dot', 117 | type: 'symbol', 118 | source: 'dot-point', 119 | layout: { 120 | 'icon-image': 'pulsing-dot' 121 | } 122 | }); 123 | 124 | map.on('move', () => { 125 | const newCenter = map.getCenter(); 126 | const source = map.getSource('dot-point') as mapboxgl.GeoJSONSource; 127 | if (source) { 128 | source.setData({ 129 | type: 'FeatureCollection', 130 | features: [{ 131 | type: 'Feature', 132 | geometry: { 133 | type: 'Point', 134 | coordinates: [newCenter.lng, newCenter.lat] 135 | }, 136 | properties: {} 137 | }] 138 | }); 139 | } 140 | }); 141 | 142 | mapIs3d && map.addLayer( 143 | { 144 | 'id': 'add-3d-buildings', 145 | 'source': 'composite', 146 | 'source-layer': 'building', 147 | 'filter': ['==', 'extrude', 'true'], 148 | 'type': 'fill-extrusion', 149 | 'minzoom': 0, 150 | 'paint': { 151 | 'fill-extrusion-color': '#aaa', 152 | 'fill-extrusion-height': ['get', 'height'], 153 | 'fill-extrusion-base': 0, 154 | 'fill-extrusion-opacity': 0.7 155 | } 156 | }, 157 | ); 158 | }); 159 | setMap3D(map); 160 | } 161 | 162 | return () => { 163 | map3D?.remove(); 164 | setMap3D(null) 165 | }; 166 | }, [theme]); // eslint-disable-line react-hooks/exhaustive-deps 167 | 168 | useEffect(() => { 169 | if (map3D) { 170 | map3D.easeTo({ 171 | center: [location.longitude, location.latitude], 172 | zoom: parseInt(mapZoom), 173 | bearing: mapFollowsHeading ? heading : 0, 174 | }); 175 | } 176 | }, [location, heading, mapZoom]); // eslint-disable-line react-hooks/exhaustive-deps 177 | 178 | return ( 179 |
180 | {timeAtBottom ?
: } 181 |
189 |
190 | {timeAtBottom ? :
} 191 |
192 | ); 193 | }; 194 | 195 | export default Map; -------------------------------------------------------------------------------- /src/components/Map/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Map'; -------------------------------------------------------------------------------- /src/components/Metrics/Altitude/Altitude.tsx: -------------------------------------------------------------------------------- 1 | import valueFormatter from "@functions/valueFormatter"; 2 | 3 | import flagStore from "@store/flagStore" 4 | import globalStore from "@store/globalStore" 5 | 6 | const Altitude = () => { 7 | const { showAltitude, useImperial } = flagStore.get(); 8 | const { altitude } = globalStore.get(); 9 | 10 | const { metric, imperial } = valueFormatter('altitude', altitude['EGM96']) 11 | 12 | return ( 13 |
14 | Altitude: {useImperial ? imperial : metric} 15 |
16 | ) 17 | } 18 | 19 | export default Altitude -------------------------------------------------------------------------------- /src/components/Metrics/Altitude/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Altitude'; -------------------------------------------------------------------------------- /src/components/Metrics/Distance/Distance.tsx: -------------------------------------------------------------------------------- 1 | import valueFormatter from "@functions/valueFormatter"; 2 | 3 | import flagStore from "@store/flagStore" 4 | import globalStore from "@store/globalStore" 5 | 6 | const Distance = () => { 7 | const { showDistance, useImperial } = flagStore.get(); 8 | const { totalDistance } = globalStore.get(); 9 | 10 | const { metric, imperial } = valueFormatter('distance', totalDistance) 11 | 12 | return ( 13 |
14 | Distance: {useImperial ? imperial : metric} 15 |
16 | ) 17 | } 18 | 19 | export default Distance -------------------------------------------------------------------------------- /src/components/Metrics/Distance/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Distance'; -------------------------------------------------------------------------------- /src/components/Metrics/Heading/Heading.tsx: -------------------------------------------------------------------------------- 1 | import flagStore from "@store/flagStore"; 2 | import globalStore from "@store/globalStore"; 3 | 4 | const Heading = () => { 5 | const { heading } = globalStore.get(); 6 | const { showHeading } = flagStore.get(); 7 | 8 | const compass = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; 9 | const cardinal = compass[(((heading + 22.5) % 360) / 45) | 0]; 10 | 11 | return ( 12 |
Heading:  13 | {cardinal}  14 | {heading}° 15 |
16 | ) 17 | } 18 | 19 | export default Heading -------------------------------------------------------------------------------- /src/components/Metrics/Heading/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Heading'; -------------------------------------------------------------------------------- /src/components/Metrics/Heartrate/Heartrate.tsx: -------------------------------------------------------------------------------- 1 | import flagStore from "@store/flagStore" 2 | import globalStore from "@store/globalStore" 3 | 4 | const Heartrate = () => { 5 | const { showHeartrate } = flagStore.get(); 6 | const { heartrate } = globalStore.get(); 7 | return ( 8 |
{heartrate === 0 ? `Heartrate: ${heartrate} bpm` : ''}
9 | ) 10 | } 11 | 12 | export default Heartrate 13 | -------------------------------------------------------------------------------- /src/components/Metrics/Heartrate/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Heartrate'; -------------------------------------------------------------------------------- /src/components/Metrics/Metrics.scss: -------------------------------------------------------------------------------- 1 | .metrics-container { 2 | bottom: 8px; 3 | color: #ffffff; 4 | font-family: 'Anek Latin', sans-serif; 5 | font-size: 18px; 6 | height: fit-content; 7 | margin-bottom: -5px; 8 | position: absolute; 9 | right: 266px; 10 | text-align: right; 11 | text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; 12 | width: 500px; 13 | } -------------------------------------------------------------------------------- /src/components/Metrics/Metrics.tsx: -------------------------------------------------------------------------------- 1 | import Altitude from '@components/Metrics/Altitude'; 2 | import Distance from '@components/Metrics/Distance'; 3 | import Heading from '@components/Metrics/Heading' 4 | import Heartrate from '@components/Metrics/Heartrate'; 5 | import Speed from '@components/Metrics/Speed'; 6 | 7 | import flagStore from '@store/flagStore'; 8 | 9 | import './Metrics.scss'; 10 | 11 | const OtherMetrics = () => { 12 | const { showMetrics } = flagStore.get(); 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 21 |
22 | ); 23 | }; 24 | 25 | export default OtherMetrics; 26 | -------------------------------------------------------------------------------- /src/components/Metrics/Speed/Speed.tsx: -------------------------------------------------------------------------------- 1 | import flagStore from "@store/flagStore" 2 | import globalStore from "@store/globalStore" 3 | 4 | import valueFormatter from "@functions/valueFormatter"; 5 | 6 | const Speed = () => { 7 | const { showSpeed, useImperial } = flagStore.get(); 8 | const { speed } = globalStore.get(); 9 | const { metric, imperial } = valueFormatter('speed', speed) 10 | return ( 11 |
12 | Speed: {useImperial ? imperial : metric} 13 |
14 | ) 15 | } 16 | 17 | export default Speed -------------------------------------------------------------------------------- /src/components/Metrics/Speed/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Speed'; -------------------------------------------------------------------------------- /src/components/Metrics/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Metrics'; -------------------------------------------------------------------------------- /src/components/Neighbourhood/Neighbourhood.scss: -------------------------------------------------------------------------------- 1 | .neighbourhood-container { 2 | position: absolute; 3 | width: 100vw; 4 | height: 100vh; 5 | right: 10px; 6 | top: -4px; 7 | text-align: right; 8 | 9 | font-family: 'Hiragino Kaku Gothic Pro', 'Blinker', sans-serif; 10 | 11 | color: #fff; 12 | text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 13 | 1px 1px 0 #000; 14 | will-change: transform; 15 | transition: transform 0.1s linear; 16 | } 17 | 18 | .neighbourhood { 19 | display: inline-block; 20 | text-align: center; 21 | position: inline-flex; 22 | width: fit-content; 23 | font-size: 24px; 24 | } -------------------------------------------------------------------------------- /src/components/Neighbourhood/Neighbourhood.tsx: -------------------------------------------------------------------------------- 1 | import { useSpring, animated } from 'react-spring'; 2 | 3 | import isJapanese from '@functions/isJapanese'; 4 | 5 | import handleSpringProps from '@handlers/handleSpringProps'; 6 | import flagStore from '@store/flagStore'; 7 | import globalStore from '@store/globalStore'; 8 | import triggerStore from '@store/triggerStore'; 9 | 10 | import './Neighbourhood.scss'; 11 | 12 | const Neighbourhood = () => { 13 | const { disableAnimation } = flagStore.get(); 14 | const { neighbourhood } = globalStore.get(); 15 | const { show } = triggerStore.neighbourhood.get(); 16 | 17 | const neighbourhoodProps = useSpring(handleSpringProps('neighbourhood', show)); 18 | 19 | const renderWithMixedFonts = (text) => { 20 | return text.split(/(\s+)/).map((word, index) => { 21 | const style = isJapanese(word) ? { fontFamily: "'Hiragino Kaku Gothic Pro', sans-serif" } : { fontFamily: "'Blinker', sans-serif" }; 22 | return {word}; 23 | }); 24 | }; 25 | 26 | const content = neighbourhood || 'Locating...'; 27 | const styledText = renderWithMixedFonts(content); 28 | 29 | if (disableAnimation) { 30 | return ( 31 |
32 |

{styledText}
33 |
34 | ); 35 | } else { 36 | return ( 37 | 38 |

{styledText}
39 |
40 | ); 41 | } 42 | }; 43 | 44 | export default Neighbourhood; 45 | -------------------------------------------------------------------------------- /src/components/Neighbourhood/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Neighbourhood'; -------------------------------------------------------------------------------- /src/components/RotatingElements/RotatingElements.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { useSpring, animated } from 'react-spring'; 3 | 4 | const RotatingElements = ({ elements, intervalTime = 10000 }) => { 5 | const [index, setIndex] = useState(0); 6 | const [visible, setVisible] = useState(true); 7 | 8 | useEffect(() => { 9 | const interval = setInterval(() => { 10 | setVisible(false); 11 | setTimeout(() => { 12 | setIndex((prevIndex) => (prevIndex + 1) % elements.length); 13 | setVisible(true); 14 | }, 500); // Change element after fade out 15 | }, intervalTime); 16 | return () => clearInterval(interval); 17 | }, [elements.length, intervalTime]); 18 | 19 | const fade = useSpring({ 20 | opacity: visible ? 1 : 0, 21 | config: { duration: 500 }, 22 | }); 23 | 24 | return ( 25 |
26 | 27 | {elements[index]} 28 | 29 |
30 | ); 31 | }; 32 | 33 | export default RotatingElements; 34 | -------------------------------------------------------------------------------- /src/components/RotatingElements/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './RotatingElements'; -------------------------------------------------------------------------------- /src/components/Rotator/Rotator.scss: -------------------------------------------------------------------------------- 1 | .rotator-container { 2 | overflow: visible; 3 | position: absolute; 4 | width: 100vw; 5 | height: 100vh; 6 | right: 10px; 7 | top: 80px; 8 | text-align: right; 9 | 10 | font-family: 'Anek Latin', sans-serif; 11 | 12 | color: #fff; 13 | text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 14 | 1px 1px 0 #000; 15 | will-change: transform; 16 | transition: transform 0.1s linear; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Rotator/Rotator.tsx: -------------------------------------------------------------------------------- 1 | import { useSpring, animated } from 'react-spring'; 2 | 3 | import RotatingElements from '@components/RotatingElements'; 4 | import StreamElements from '@components/StreamElements'; 5 | import Weather from '@components/Weather'; 6 | 7 | import flagStore from '@store/flagStore'; 8 | import triggerStore from '@store/triggerStore'; 9 | 10 | import './Rotator.scss'; 11 | import handleSpringProps from '@handlers/handleSpringProps'; 12 | 13 | const Rotator = () => { 14 | const { disableAnimation, streamElementsSubscribed } = flagStore.get() 15 | const { show } = triggerStore.rotator.get() 16 | 17 | const rotatorProps = useSpring(handleSpringProps('rotator', show)) 18 | 19 | const components = [, ] 20 | 21 | if (disableAnimation) { 22 | return ( 23 |
24 | {streamElementsSubscribed 25 | ? 26 | : 27 | } 28 |
29 | ) 30 | } else return ( 31 | 32 | {streamElementsSubscribed 33 | ? 34 | : 35 | } 36 | 37 | ); 38 | }; 39 | 40 | export default Rotator; 41 | -------------------------------------------------------------------------------- /src/components/Rotator/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Rotator'; -------------------------------------------------------------------------------- /src/components/StreamElements/LatestCheer.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | const LatestCheer = () => { 4 | const value = globalStore.streamElements['cheer-latest'].get(); 5 | 6 | return ( 7 |
8 | {value && ( 9 | <> 10 |
Latest Cheer:
11 |
12 | {value.name && `${value.name} - ${value.amount} bits`} 13 |
14 | 15 | )} 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default LatestCheer; 22 | -------------------------------------------------------------------------------- /src/components/StreamElements/LatestFollow.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | const LatestFollow = () => { 4 | const value = globalStore.streamElements['follower-latest'].get(); 5 | return ( 6 |
7 |
Latest Follow:
8 | {value && ( 9 |
10 | {value.name && `${value.name}`} 11 |
12 | )} 13 |
14 |
15 | ); 16 | }; 17 | 18 | export default LatestFollow; 19 | -------------------------------------------------------------------------------- /src/components/StreamElements/LatestSub.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | const tier: { [key: string]: string } = { 4 | '1000': 'Tier 1', 5 | '2000': 'Tier 2', 6 | '3000': 'Tier 3', 7 | prime: 'Prime', 8 | '': 'Tier 0' 9 | }; 10 | 11 | const LatestSub = () => { 12 | const value = globalStore.streamElements['subscriber-latest'].get(); 13 | return ( 14 |
15 |
Latest Sub:
16 | {value && ( 17 |
18 | {value.name && `${value.name} - ${value.amount} ${value.amount > 1 ? 'months' : 'month'} - (${tier[value['tier']]})`} 19 |
20 | )} 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default LatestSub; 27 | -------------------------------------------------------------------------------- /src/components/StreamElements/LatestTip.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | const LatestTip = () => { 4 | const value = globalStore.streamElements['tip-latest'].get(); 5 | 6 | return ( 7 |
8 | {value && ( 9 | <> 10 |
Latest Tip:
11 |
12 | {value.name && `${value.name} - $${value.amount.toFixed(2)} `} 13 |
14 | 15 | )} 16 |
17 |
18 | ); 19 | }; 20 | 21 | export default LatestTip; 22 | -------------------------------------------------------------------------------- /src/components/StreamElements/RecentCheer.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | interface ICheerProps { 4 | amount: number 5 | name: string; 6 | } 7 | 8 | const RecentCheer = () => { 9 | const value = globalStore.streamElements['cheer-recent'].get(); 10 | 11 | return ( 12 |
13 | {value && ( 14 | <> 15 |
Recent Cheers:
16 |
17 | {value.map( 18 | (cheer: ICheerProps, index: number) => { 19 | if (index > 0 && index <= 4) { 20 | return ( 21 |
22 | {cheer.name && `${cheer.name} - ${cheer.amount} bits `} 23 |
24 | ); 25 | } else return null; 26 | } 27 | )} 28 |
29 | 30 | )} 31 |
32 |
33 | ); 34 | }; 35 | 36 | export default RecentCheer; -------------------------------------------------------------------------------- /src/components/StreamElements/RecentFollow.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | interface IFollowProps { 4 | name: string; 5 | } 6 | 7 | const RecentFollow = () => { 8 | const value = globalStore.streamElements['follower-recent'].get(); 9 | 10 | return ( 11 |
12 |
Recent Followers:
13 |
14 | {value.map( 15 | (follow: IFollowProps, index: number) => { 16 | if (index > 0 && index <= 4) { 17 | return ( 18 |
19 | {follow['name'] && follow['name']} 20 |
21 | ); 22 | } else return null; 23 | } 24 | )} 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default RecentFollow; 31 | -------------------------------------------------------------------------------- /src/components/StreamElements/RecentSub.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | interface ISubProps { 4 | name: string; 5 | amount: number; 6 | tier: string; 7 | } 8 | 9 | const tier: { [key: string]: string } = { 10 | '1000': 'Tier 1', 11 | '2000': 'Tier 2', 12 | '3000': 'Tier 3', 13 | prime: 'Prime', 14 | '': 'Tier 0' 15 | }; 16 | 17 | const RecentSub = () => { 18 | const value = globalStore.streamElements['subscriber-recent'].get() as ISubProps[]; 19 | 20 | return ( 21 |
22 |
Recent Subs:
23 |
24 | {value.map((sub, index) => { 25 | if (index < 5) { 26 | return ( 27 |
28 | {sub.name && `${sub.name} - ${sub.amount} ${sub.amount > 1 ? 'months' : 'month'} - (${tier[sub.tier]})`} 29 |
30 | ); 31 | } else return null; 32 | })} 33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export default RecentSub; 40 | -------------------------------------------------------------------------------- /src/components/StreamElements/RecentTip.tsx: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | interface ITipProps { 4 | amount: number; 5 | name: string; 6 | } 7 | 8 | const RecentTip = () => { 9 | const value: ITipProps[] = globalStore.streamElements['tip-recent'].get(); 10 | return ( 11 |
12 | {value && ( 13 | <> 14 |
Recent Tips:
15 |
16 | {value.map( 17 | (tip: ITipProps, index: number) => { 18 | if (index > 0 && index <= 4) { 19 | return ( 20 |
21 | {tip.name && `${tip.name} - $${tip.amount.toFixed(2)} `} 22 |
23 | ); 24 | } else return null; 25 | } 26 | )} 27 |
28 | 29 | )} 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default RecentTip; 36 | -------------------------------------------------------------------------------- /src/components/StreamElements/StreamElements.scss: -------------------------------------------------------------------------------- 1 | .streamelements-container { 2 | overflow: visible; 3 | position: absolute; 4 | width: 100vw; 5 | height: 100vh; 6 | right: 0px; 7 | top: 0px; 8 | text-align: right; 9 | 10 | font-family: 'Anek Latin', sans-serif; 11 | 12 | color: #fff; 13 | text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; 14 | will-change: transform; 15 | transition: transform 0.1s linear; 16 | font-size: 22px; 17 | } 18 | 19 | .se-heading { 20 | font-size: 25px; 21 | } -------------------------------------------------------------------------------- /src/components/StreamElements/StreamElements.tsx: -------------------------------------------------------------------------------- 1 | import LatestCheer from './LatestCheer'; 2 | import LatestFollow from './LatestFollow'; 3 | import LatestSub from './LatestSub'; 4 | import LatestTip from './LatestTip'; 5 | 6 | import RecentCheer from './RecentCheer'; 7 | import RecentFollow from './RecentFollow'; 8 | import RecentSub from './RecentSub'; 9 | import RecentTip from './RecentTip'; 10 | 11 | import RotatingElements from '@components/RotatingElements'; 12 | 13 | import './StreamElements.scss'; 14 | 15 | const StreamElements = () => { 16 | const latestSubTip = [, ]; 17 | const latestFollowCheer = [, ]; 18 | const recentSubTip = [, ]; 19 | const recentFollowCheer = [, ]; 20 | 21 | return ( 22 |
23 | 24 | 25 | 26 | 27 |
28 | ); 29 | }; 30 | 31 | export default StreamElements; 32 | -------------------------------------------------------------------------------- /src/components/StreamElements/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './StreamElements'; -------------------------------------------------------------------------------- /src/components/Weather/Weather.scss: -------------------------------------------------------------------------------- 1 | .weather-container { 2 | position: absolute; 3 | width: 100vw; 4 | height: 100vh; 5 | right: 0; 6 | top: 0; 7 | text-align: right; 8 | font-family: 'Anek Latin', sans-serif; 9 | color: #fff; 10 | text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; 11 | font-size: 20px; 12 | } 13 | 14 | .icon-container { 15 | display: inline-flex; 16 | align-items: center; // Aligns the SVG vertically in the center 17 | } 18 | 19 | .icon-main { 20 | padding-top: 10px; 21 | 22 | svg { 23 | height: 2em; 24 | width: 2em; 25 | } 26 | } 27 | 28 | .icon { 29 | svg { 30 | height: 1.5em; 31 | width: 1.5em; 32 | } 33 | } 34 | 35 | .text { 36 | display: inline-flex; 37 | align-items: center; // Aligns the text vertically in the center 38 | padding-bottom: 0.25em; 39 | } -------------------------------------------------------------------------------- /src/components/Weather/Weather.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { SvgLoader } from 'react-svgmt'; 3 | 4 | import valueFormatter from '@functions/valueFormatter'; 5 | 6 | import flagStore from '@store/flagStore'; 7 | import globalStore from '@store/globalStore'; 8 | 9 | import './Weather.scss'; 10 | 11 | const Weather = () => { 12 | const { useImperial } = flagStore.get(); 13 | const { locationData } = globalStore.get(); 14 | 15 | const icon = `assets/${locationData?.weather?.[0]?.icon}.svg`; 16 | const max = `assets/cloud-arrow-up.svg` 17 | const min = `assets/cloud-arrow-down.svg` 18 | 19 | const feels_like = valueFormatter('temperature', locationData.main.feels_like) 20 | const temp = valueFormatter('temperature', locationData.main.temp) 21 | const temp_max = valueFormatter('temperature', locationData.main.temp_max) 22 | const temp_min = valueFormatter('temperature', locationData.main.temp_min) 23 | 24 | return ( 25 | }> 26 |
27 |
28 | {locationData.weather[0].main} /{' '} 29 | {locationData.weather[0].description} 30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 |   44 | {useImperial ? temp_min.imperial : temp_min.metric} 45 |  -  46 |
47 |
48 |
49 |
50 | 51 |
52 |
53 |   54 | {useImperial ? temp_max.imperial : temp_max.metric} 55 |
56 |
57 |
58 |
59 | Current: {useImperial ? temp.imperial : temp.metric} 60 |
61 | Feels like: {useImperial ? feels_like.imperial : feels_like.metric} 62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | export default Weather; 69 | -------------------------------------------------------------------------------- /src/components/Weather/defaultWeatherValues.ts: -------------------------------------------------------------------------------- 1 | export const defaultWeatherValues = { 2 | "coord": { 3 | "lon": 0, 4 | "lat": 0 5 | }, 6 | "weather": [ 7 | { 8 | "id": 0, 9 | "main": "Fine", 10 | "description": "Fine", 11 | "icon": "01d" 12 | } 13 | ], 14 | "base": "stations", 15 | "main": { 16 | "temp": 0, 17 | "feels_like": 0, 18 | "temp_min": 0, 19 | "temp_max": 0, 20 | "pressure": 0, 21 | "humidity": 0, 22 | "sea_level": 0, 23 | "grnd_level": 0 24 | }, 25 | "visibility": 0, 26 | "wind": { 27 | "speed": 0, 28 | "deg": 0, 29 | "gust": 0 30 | }, 31 | "rain": { 32 | "1h": 0 33 | }, 34 | "clouds": { 35 | "all": 0 36 | }, 37 | "dt": 0, 38 | "sys": { 39 | "type": 0, 40 | "id": 0, 41 | "country": "AU", 42 | "sunrise": 0, 43 | "sunset": 0 44 | }, 45 | "timezone": 0, 46 | "id": 0, 47 | "name": "Perth", 48 | "cod": 0 49 | } -------------------------------------------------------------------------------- /src/components/Weather/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Weather'; -------------------------------------------------------------------------------- /src/functions/isEmpty.ts: -------------------------------------------------------------------------------- 1 | // this function determines if an array or object is empty 2 | const isEmpty = (obj: any) => { 3 | return ( 4 | [Object, Array].includes((obj || {}).constructor) && 5 | !Object.entries(obj || {}).length 6 | ); 7 | }; 8 | 9 | export default isEmpty; 10 | -------------------------------------------------------------------------------- /src/functions/isJapanese.ts: -------------------------------------------------------------------------------- 1 | const isJapanese = (text) => { return /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]/.test(text) } 2 | 3 | export default isJapanese 4 | -------------------------------------------------------------------------------- /src/functions/valueFormatter.ts: -------------------------------------------------------------------------------- 1 | const valueFormatter = (type: string, inputValue: number) => { 2 | // altitude from meters to feet 3 | if (type === 'altitude') { 4 | return { 5 | metric: `${(inputValue).toFixed(0)} m`, 6 | imperial: `${(inputValue * 3.28084).toFixed(0)} ft` 7 | } 8 | } 9 | // distance from km to miles 10 | if (type === 'distance') { 11 | return { 12 | metric: `${(inputValue).toFixed(2)} km`, 13 | imperial: `${(inputValue * 0.621371).toFixed(2)} mi` 14 | } 15 | } 16 | // speed from kph to mph 17 | if (type === 'speed') { 18 | return { 19 | metric: `${(inputValue).toFixed()} km/h`, 20 | imperial: `${(inputValue * 0.621371).toFixed()} mph` 21 | } 22 | } 23 | // temperature from celsius to fahrenheit 24 | if (type === 'temperature') { 25 | return { 26 | metric: `${(inputValue).toFixed(2)} °C`, 27 | imperial: `${(inputValue * 9 / 5 + 32).toFixed(2)} °F` 28 | } 29 | } 30 | // default return 31 | return { metric: '0', imperial: '0' } 32 | } 33 | 34 | export default valueFormatter -------------------------------------------------------------------------------- /src/handlers/handleDateTime.ts: -------------------------------------------------------------------------------- 1 | import * as luxon from 'luxon'; 2 | import { when } from '@legendapp/state'; 3 | 4 | import globalStore from '@store/globalStore'; 5 | import keyStore from '@store/keyStore'; 6 | 7 | const { timezoneKey } = keyStore.get(); 8 | 9 | /*eslint no-constant-condition: ["error", { "checkLoops": false }]*/ 10 | const handleDateTime = async () => { 11 | 12 | while (true) { 13 | try { 14 | const { location } = globalStore.get() 15 | const response = await fetch( 16 | `https://api.timezonedb.com/v2.1/get-time-zone?key=${timezoneKey}&format=json&by=position&lat=${location.latitude}&lng=${location.longitude}` 17 | ); 18 | if (!response.ok) { 19 | throw new Error(`HTTP error! status: ${response.status}`); 20 | } 21 | const data = await response.json() 22 | globalStore.set((prevData => ({ ...prevData, zoneId: data.zoneName }))) 23 | await new Promise(resolve => setTimeout(resolve, 10000)); // Refresh interval 24 | } catch (error) { 25 | console.error('Error fetching time zone, retrying:', error); 26 | await new Promise(resolve => setTimeout(resolve, 10000)); // Retry on error interval 27 | } 28 | } 29 | }; 30 | 31 | const lang = 'en'; 32 | const date = 'ccc, MMM dd, yyyy'; 33 | const time = 'HH:mm:ss'; 34 | const datetime = 'ccc, MMM dd, yyyy | HH:mm:ss'; 35 | 36 | setInterval(() => { 37 | globalStore.time.set(luxon.DateTime.now().setZone(globalStore.zoneId.get()).setLocale(lang).toFormat(time)); 38 | globalStore.date.set(luxon.DateTime.now().setZone(globalStore.zoneId.get()).setLocale(lang).toFormat(date)); 39 | globalStore.dateTime.set(luxon.DateTime.now().setZone(globalStore.zoneId.get()).setLocale(lang).toFormat(datetime)); 40 | }, 1000); 41 | 42 | when( 43 | () => globalStore.location.latitude.get() && globalStore.location.longitude.get(), 44 | () => handleDateTime() 45 | ); -------------------------------------------------------------------------------- /src/handlers/handleDistance.ts: -------------------------------------------------------------------------------- 1 | import globalStore from "@store/globalStore"; 2 | 3 | const handleDistance = () => { 4 | // Get distance between pairs of lat/lon 5 | const getDistanceFromLatLonInKm = ( 6 | lat1: number, 7 | lon1: number, 8 | lat2: number, 9 | lon2: number 10 | ) => { 11 | const R = 6371; // Radius of the earth in km 12 | const dLat = deg2rad(lat2 - lat1); // deg2rad below 13 | const dLon = deg2rad(lon2 - lon1); 14 | const a = 15 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + 16 | Math.cos(deg2rad(lat1)) * 17 | Math.cos(deg2rad(lat2)) * 18 | Math.sin(dLon / 2) * 19 | Math.sin(dLon / 2); 20 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 21 | const d = R * c; // Distance in km 22 | return d; 23 | }; 24 | 25 | // Return radians from degrees 26 | const deg2rad = (deg: number) => { 27 | return deg * (Math.PI / 180); 28 | }; 29 | 30 | // Check for a sessionId, reset totalDistance if it changes as this indicates stream start. 31 | globalStore.sessionId.onChange((sessionId) => { 32 | if (sessionId.value && sessionId.value !== sessionId.getPrevious()) { 33 | globalStore.totalDistance.set(0) 34 | } 35 | }) 36 | 37 | // Observe location changes and calculate distance 38 | globalStore.location.onChange((location) => { 39 | // Ignore location and return if the previous lat/lon was 0 - this indicates cold start - GPS often jumps from 0 to new location. 40 | if (!location.getPrevious().latitude && !location.getPrevious().longitude) return 41 | // Otherwise, calculate distance between previous and current location and add to total distance 42 | globalStore.totalDistance.set(globalStore.totalDistance.get() + getDistanceFromLatLonInKm( 43 | location.getPrevious().latitude, 44 | location.getPrevious().longitude, 45 | location.value.latitude, 46 | location.value.longitude 47 | )) 48 | }) 49 | }; 50 | 51 | handleDistance(); 52 | -------------------------------------------------------------------------------- /src/handlers/handleMapZoomInterval.ts: -------------------------------------------------------------------------------- 1 | import flagStore from "@store/flagStore"; 2 | 3 | const { zoomLevelPairs } = flagStore.get(); 4 | 5 | const intervals = zoomLevelPairs.map(pair => { 6 | const [mapZoom, intervalTime] = pair.split('-').map(Number); 7 | return { mapZoom, intervalTime }; 8 | }); 9 | 10 | /*eslint no-constant-condition: ["error", { "checkLoops": false }]*/ 11 | const handleMapZoomInterval = async () => { 12 | // Abort if no intervals are specified 13 | if (!Array.isArray(intervals) || intervals.length === 0) { 14 | return; 15 | } 16 | 17 | // Abort if any interval pairs are invalid 18 | if (intervals.some(interval => typeof interval.mapZoom !== 'number' || typeof interval.intervalTime !== 'number')) { 19 | return; 20 | } 21 | 22 | let index = 0; 23 | while (true) { 24 | try { 25 | flagStore.mapZoom.set(intervals[index].mapZoom.toString()); 26 | await new Promise(resolve => setTimeout(resolve, (intervals[index].intervalTime * 60000))); 27 | index = (index + 1) % intervals.length; 28 | } catch (error) { 29 | console.error('Error setting map zoom:', error); 30 | } 31 | } 32 | }; 33 | 34 | handleMapZoomInterval(); -------------------------------------------------------------------------------- /src/handlers/handleNeighbourhood.ts: -------------------------------------------------------------------------------- 1 | import createServiceFactory from '@mapbox/mapbox-sdk/services/geocoding'; 2 | 3 | import flagStore from '@store/flagStore'; 4 | import globalStore from '@store/globalStore'; 5 | import keyStore from '@store/keyStore'; 6 | 7 | const { shortLocation } = flagStore.get() 8 | const { mapboxKey } = keyStore.get() 9 | 10 | interface MapboxFeature { 11 | place_name: string; 12 | place_type: string[]; 13 | properties: Record; //eslint-disable-line 14 | text: string; 15 | } 16 | 17 | interface MapboxResponse { 18 | body: { 19 | features: MapboxFeature[]; 20 | }; 21 | } 22 | 23 | const handleNeighbourhood = () => { 24 | const mbxGeocode = createServiceFactory({ accessToken: mapboxKey }); 25 | 26 | const getNeighbourhood = () => { 27 | const { location } = globalStore.get() 28 | mbxGeocode 29 | .reverseGeocode({ 30 | query: [location.longitude, location.latitude], 31 | }) 32 | .send() 33 | .then((response: MapboxResponse) => { 34 | const context: { [key: string]: MapboxFeature } = {}; 35 | for (const param of [ 36 | 'country', 37 | 'region', 38 | 'postcode', 39 | 'district', 40 | 'place', 41 | 'locality', 42 | 'neighborhood', 43 | 'address', 44 | 'poi', 45 | ]) { 46 | context[param] = response.body.features.find( 47 | (feature) => feature.place_type.includes(param) 48 | )!; 49 | } 50 | context['japan'] = response.body.features.find( 51 | (feature) => feature.place_name.includes('Japan') 52 | )!; 53 | 54 | const { country, region, place, locality, neighborhood, poi, japan } = context; 55 | 56 | if (!shortLocation && japan && region && place && locality) { 57 | globalStore.neighbourhood.set( 58 | poi ? `${poi.text}, ${locality.text}, ${place.text} - ${region.text}, ${country.properties.short_code.toUpperCase()}` : `${locality.text}, ${place.text} - ${region.text}, ${country.properties.short_code.toUpperCase()}` 59 | ) 60 | } 61 | else if (!shortLocation && locality && country && !neighborhood) { 62 | globalStore.neighbourhood.set( 63 | poi ? `${poi.text}, ${locality.text}, ${country.properties.short_code.toUpperCase()}` : `${locality.text} - ${country.properties.short_code.toUpperCase()}`, 64 | ) 65 | } 66 | else if (!shortLocation && neighborhood && locality && place) { 67 | globalStore.neighbourhood.set( 68 | poi ? `${poi.text}, ${neighborhood.text}, ${locality.text} - ${place.text}, ${country.properties.short_code.toUpperCase()}` : `${neighborhood.text}, ${locality.text} - ${place.text}, ${country.properties.short_code.toUpperCase()}` 69 | ) 70 | } 71 | else if (!shortLocation && place) { 72 | globalStore.neighbourhood.set( 73 | poi ? `${poi.text}, ${place.text}, ${country.properties.short_code.toUpperCase()}` : `${place.text}, ${country.properties.short_code.toUpperCase()}` 74 | ) 75 | } 76 | else if (!shortLocation && country && !place) { 77 | globalStore.neighbourhood.set( 78 | poi ? `${poi.text}, ${country.place_name}` : `${country.place_name}` 79 | ) 80 | } 81 | else if (shortLocation) { 82 | globalStore.neighbourhood.set( 83 | `${region!.place_name}` 84 | ) 85 | } 86 | globalStore.geocode.set( 87 | { ...response.body } 88 | ) 89 | }) 90 | .catch((error: Error) => { 91 | console.error('Error fetching neighborhood data:', error); 92 | }); 93 | }; 94 | // Get neighbourhood data from mapbox every 10 seconds 95 | setInterval(() => { 96 | getNeighbourhood(); 97 | }, 10000); 98 | }; 99 | 100 | handleNeighbourhood(); -------------------------------------------------------------------------------- /src/handlers/handleSpringProps.ts: -------------------------------------------------------------------------------- 1 | const defaultSpringConfig = { mass: 4, tension: 400, friction: 80, velocity: 10 }; 2 | 3 | const componentConfigs = { 4 | neighbourhood: { 5 | springConfig: defaultSpringConfig, 6 | transform: { 7 | from: { in: 'translate(-100px, 0px)', out: 'translate(0px, 0px)' }, 8 | to: { in: `translate(0px, 0px)`, out: `translate(0px, -100px)` } 9 | }, 10 | }, 11 | rotator: { 12 | springConfig: defaultSpringConfig, 13 | transform: { 14 | from: { in: `translate(400px, 0px)`, out: `translate(0px, 0px)` }, 15 | to: { in: `translate(0px, 0px)`, out: `translate(400px, 0px)` }, 16 | } 17 | } 18 | }; 19 | 20 | const handleSpringProps = (componentName, show) => { 21 | const component = componentConfigs[componentName]; 22 | return { 23 | config: component.springConfig, 24 | from: { transform: show ? component.transform.from.in : component.transform.from.out }, 25 | transform: show ? component.transform.to.in : component.transform.to.out, 26 | }; 27 | } 28 | 29 | export default handleSpringProps; 30 | -------------------------------------------------------------------------------- /src/handlers/handleStreamElements.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { io } from 'socket.io-client'; 3 | 4 | import flagStore from '@store/flagStore'; 5 | import globalStore from '@store/globalStore'; 6 | import keyStore from '@store/keyStore'; 7 | 8 | const baseUrl = 'https://api.streamelements.com/kappa/v2/sessions'; 9 | 10 | const handleStreamElements = () => { 11 | const { streamElementsSubscribed } = flagStore.get(); 12 | const { streamElementsKey } = keyStore.get(); 13 | 14 | //* Initial fetch for streamelements data 15 | const fetchData = async () => { 16 | const { streamElementsChannel } = keyStore.get() 17 | const data = await axios.get(`${baseUrl}/${streamElementsChannel}`, { headers: { Authorization: `Bearer ${streamElementsKey}` } }); 18 | globalStore.streamElements.set(data.data.data); 19 | }; 20 | 21 | //* Subscribe to streamelements websocket using socket.io 22 | const subStreamElements = async () => { 23 | const JWT = streamElementsKey; 24 | const socket = io('https://realtime.streamelements.com', { 25 | transports: ['websocket'], 26 | autoConnect: true, 27 | }); 28 | // Socket connected 29 | socket.on('connect', () => { 30 | socket.emit('authenticate', { 31 | method: 'jwt', 32 | token: JWT, 33 | }); 34 | }); 35 | socket.on('authenticated', (data) => { 36 | const { channelId } = data; 37 | keyStore.streamElementsChannel.set(channelId); 38 | flagStore.streamElementsSubscribed.set(true); 39 | fetchData(); 40 | }); 41 | socket.on('connect_error', (err: Error) => { 42 | console.log('connection error', err); 43 | }); 44 | socket.on('disconnect', (reason: string) => { 45 | console.log('disconnected', reason); 46 | }); 47 | socket.on('error', (err: Error) => { 48 | socket.close(); 49 | console.log('socket error', err); 50 | }); 51 | 52 | /** 53 | * StreamElements documentation sucks, since the last update to this overlay, 54 | * the socket API is returning data in a different shape and the changes to 55 | * this have not been documented correctly at all. 56 | * 57 | * As such, I'm just retriggering the initial fetch function to the HTTP based API, 58 | * which grabs the entire channel data and chucking it into the store again. 59 | */ 60 | socket.on('event', () => { fetchData() }) 61 | }; 62 | 63 | if (streamElementsKey && !streamElementsSubscribed) { 64 | subStreamElements(); 65 | } 66 | }; 67 | 68 | handleStreamElements(); -------------------------------------------------------------------------------- /src/handlers/handleTheme.ts: -------------------------------------------------------------------------------- 1 | import globalStore from '@store/globalStore'; 2 | import flagStore from '@store/flagStore'; 3 | 4 | const handleTheme = () => { 5 | const { theme } = flagStore.get(); 6 | const themes = { 7 | 'mapbox-dark': 'mapbox://styles/mapbox/dark-v11', 8 | 'mapbox-japan': 'mapbox://styles/mapbox-map-design/ckt20wgoy1awp17ms7pyygigf', 9 | 'mapbox-light': 'mapbox://styles/mapbox/light-v11', 10 | 'mapbox-navigation-day': 'mapbox://styles/mapbox/navigation-day-v1', 11 | 'mapbox-navigation-night': 'mapbox://styles/mapbox/navigation-night-v1', 12 | 'mapbox-outdoors': 'mapbox://styles/mapbox/outdoors-v12', 13 | 'mapbox-satellite': 'mapbox://styles/mapbox/satellite-v9', 14 | 'mapbox-satellite-streets': 'mapbox://styles/mapbox/satellite-streets-v12', 15 | 'mapbox-streets': 'mapbox://styles/mapbox/streets-v12', 16 | 'basic': 'mapbox://styles/mapbox-map-design/cl4whef7m000714pc44f3qaxs', 17 | 'basic-overcast': 'mapbox://styles/mapbox-map-design/cl4whev1w002w16s9mgoliotw', 18 | 'blueprint': 'mapbox://styles/mapbox-map-design/cks97e1e37nsd17nzg7p0308g', 19 | 'frank': 'mapbox://styles/mapbox-map-design/ckshxkppe0gge18nz20i0nrwq', 20 | 'minimo': 'mapbox://styles/mapbox-map-design/cksjc2nsq1bg117pnekb655h1', 21 | 'standard-oil': 'mapbox://styles/mapbox-map-design/ckr0svm3922ki18qntevm857n', 22 | 'unicorn': 'mapbox://styles/mapbox-map-design/cl4fotjdi000l15p8cqc6nuts', 23 | 'marvel-thanos': 'mapbox://styles/chichan5224/cliwp31hh00et01pw2tjkcy1v', 24 | 'marvel-hulk': 'mapbox://styles/chichan5224/cliwn3a8i00n901r1a4pvhjzq', 25 | 'marvel-blackwidow': 'mapbox://styles/chichan5224/cliwp11uq00ou01r8h6rn1d9k', 26 | 'marvel-ironman': 'mapbox://styles/chichan5224/cliwp57bs00nb01r12nny2y62', 27 | 'marvel-blackpanther': 'mapbox://styles/chichan5224/cliwp490b00eu01pw4zl12i4g', 28 | 'marvel-drstrange': 'mapbox://styles/chichan5224/cliwp5ege00f201pz5acwhz4e', 29 | 'marvel-americachaves': 'mapbox://styles/chichan5224/cliwp7bea00p501q12fl17tac', 30 | 'marvel-captainamerica': 'mapbox://styles/chichan5224/cliwp56zg00ot01od7fpvek1d', 31 | 'marvel-wanda': 'mapbox://styles/chichan5224/cliwp7dg700f301pzd3rzhfyk', 32 | } 33 | const defaultTheme = 'mapbox://styles/mapbox/streets-v12'; 34 | const themeUrl = themes[theme] || defaultTheme; 35 | return globalStore.set((prevState) => ({ 36 | ...prevState, 37 | theme: themeUrl 38 | })); 39 | }; 40 | 41 | handleTheme(); -------------------------------------------------------------------------------- /src/handlers/handleWeather.ts: -------------------------------------------------------------------------------- 1 | import { when } from "@legendapp/state"; 2 | 3 | import globalStore from "@store/globalStore"; 4 | import keyStore from "@store/keyStore"; 5 | 6 | const { weatherKey } = keyStore.get(); 7 | 8 | /*eslint no-constant-condition: ["error", { "checkLoops": false }]*/ 9 | const handleWeather = async () => { 10 | while (true) { 11 | try { 12 | const { location } = globalStore.get(); 13 | const response = await fetch( 14 | `https://api.openweathermap.org/data/2.5/weather?lat=${location.latitude}&lon=${location.longitude}&exclude=minutely,hourly,alerts&units=metric&appid=${weatherKey}` 15 | ) 16 | if (!response.ok) { 17 | throw new Error(`HTTP error! status: ${response.status}`); 18 | } 19 | const data = await response.json(); 20 | globalStore.locationData.set(data) 21 | await new Promise(resolve => setTimeout(resolve, 10000)); // Refresh interval 22 | } catch (error) { 23 | console.error('Error fetching weather, retrying:', error); 24 | await new Promise(resolve => setTimeout(resolve, 10000)); // Retry on error interval 25 | } 26 | } 27 | }; 28 | 29 | when( 30 | () => globalStore.location.latitude.get() && globalStore.location.longitude.get(), 31 | () => handleWeather() 32 | ); -------------------------------------------------------------------------------- /src/hooks/useListener.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { forPullKey } from '@rtirl/api'; 3 | 4 | import globalStore from '@store/globalStore'; 5 | import keyStore from '@store/keyStore'; 6 | 7 | interface IListenerProps { 8 | altitude: { EGM96: number, WGS84: number }; 9 | heading: number; 10 | location: { latitude: number, longitude: number }; 11 | reportedAt: number; 12 | speed: number; 13 | updatedAt: number; 14 | } 15 | 16 | interface ISessionListenerProps { 17 | sessionId: string; 18 | } 19 | 20 | const useListener = () => { 21 | const { pullKey } = keyStore.get(); 22 | useEffect(() => { 23 | const unsubscribeListener = forPullKey(pullKey).addListener((data: IListenerProps) => { 24 | globalStore.set((prevState) => ({ 25 | ...prevState, 26 | altitude: data.altitude, 27 | heading: data.heading, 28 | location: data.location, 29 | speed: (data.speed * 3.6), 30 | })); 31 | }); 32 | const unsubscribeSessionListener = forPullKey(pullKey).addListener((data: ISessionListenerProps) => { 33 | globalStore.set((prevState) => ({ 34 | ...prevState, 35 | sessionId: data.sessionId, 36 | })); 37 | }); 38 | return () => { 39 | unsubscribeListener() 40 | unsubscribeSessionListener() 41 | }; 42 | }); 43 | }; 44 | 45 | export default useListener; 46 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom/client'; 2 | import App from './App'; 3 | 4 | import { enableReactTracking } from "@legendapp/state/config/enableReactTracking"; 5 | 6 | enableReactTracking({ 7 | auto: true, 8 | }); 9 | 10 | const root = ReactDOM.createRoot( 11 | document.getElementById('root') as HTMLElement 12 | ); 13 | 14 | root.render(); 15 | -------------------------------------------------------------------------------- /src/store/flagStore.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "@legendapp/state"; 2 | 3 | const queryParams = new URLSearchParams(window.location.search); 4 | 5 | const getQueryParamFlag = (key) => { 6 | return queryParams.has(key.toLowerCase()) || queryParams.has(key.toUpperCase()) || queryParams.has(key); 7 | }; 8 | 9 | const parseZoomLevels = (zoomLevelsString) => { 10 | return zoomLevelsString.split(',').filter(pair => pair.includes('-')); 11 | }; 12 | 13 | const flagStore = observable({ 14 | disableAnimation: getQueryParamFlag('disableAnimation'), 15 | hideMap: getQueryParamFlag('hideMap'), 16 | hideTime: getQueryParamFlag('hideTime'), 17 | mapFollowsHeading: getQueryParamFlag('mapFollowsHeading'), 18 | mapHasBorder: getQueryParamFlag('mapHasBorder'), 19 | mapIs3d: getQueryParamFlag('mapIs3d'), 20 | mapIsCircular: getQueryParamFlag('mapIsCircular'), 21 | mapZoom: queryParams.get('mapZoom') || '15', 22 | pulseMarker: getQueryParamFlag('pulseMarker'), 23 | shortLocation: getQueryParamFlag('shortLocation'), 24 | showAltitude: getQueryParamFlag('showAltitude'), 25 | showDistance: getQueryParamFlag('showDistance'), 26 | showHeading: getQueryParamFlag('showHeading'), 27 | showHeartrate: getQueryParamFlag('showHeartrate'), 28 | showMetrics: getQueryParamFlag('showMetrics'), 29 | showSpeed: getQueryParamFlag('showSpeed'), 30 | splitDateTime: getQueryParamFlag('splitDateTime'), 31 | streamElementsSubscribed: false, 32 | theme: queryParams.get('theme') || 'mapbox://styles/mapbox/streets-v12', 33 | timeAtBottom: getQueryParamFlag('timeAtBottom'), 34 | useImperial: getQueryParamFlag('useImperial'), 35 | zoomLevels: queryParams.get('zoomLevels') || '', 36 | zoomLevelPairs: parseZoomLevels(queryParams.get('zoomLevels') || ''), 37 | }); 38 | 39 | export default flagStore; 40 | -------------------------------------------------------------------------------- /src/store/globalStore.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "@legendapp/state"; 2 | import { defaultWeatherValues } from "@components/Weather/defaultWeatherValues"; 3 | 4 | const globalStore = observable({ 5 | altitude: { 6 | EGM96: 0, 7 | WGS84: 0 8 | }, 9 | date: '', 10 | dateTime: '', 11 | distance: 0, 12 | geocode: {}, 13 | heading: 0, 14 | heartrate: 0, 15 | location: { latitude: 0, longitude: 0 }, 16 | locationData: { ...defaultWeatherValues }, 17 | neighbourhood: '', 18 | prevLocation: { 19 | latitude: 0, 20 | longitude: 0, 21 | }, 22 | sessionId: '', 23 | speed: 0, 24 | streamElements: { 25 | 'cheer-latest': { name: '', amount: 0 }, 26 | 'follower-latest': { name: '' }, 27 | 'subscriber-latest': { name: '', amount: 0, tier: '1000' }, 28 | 'tip-latest': { name: '', amount: 0 }, 29 | 'cheer-recent': [{ name: '', amount: 0 }], 30 | 'follower-recent': [{ name: '' }], 31 | 'subscriber-recent': [{ name: '', amount: 0, tier: '1000' }], 32 | 'tip-recent': [{ name: '', amount: 0 }], 33 | }, 34 | theme: 'mapbox://styles/mapbox/streets-v12', 35 | time: '', 36 | totalDistance: 0, 37 | zoneId: 'Europe/London', 38 | }); 39 | 40 | export default globalStore; 41 | -------------------------------------------------------------------------------- /src/store/keyStore.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "@legendapp/state"; 2 | 3 | const queryParams = new URLSearchParams(window.location.search); 4 | 5 | const mapboxKey = import.meta.env.VITE_MAPBOX_KEY || queryParams.get('mapboxKey') || ''; 6 | const pullKey = import.meta.env.VITE_PULL_KEY || queryParams.get('pullKey') || ''; 7 | const stadiaProviderKey = import.meta.env.VITE_STADIA_PROVIDER_KEY || queryParams.get('stadiaProviderKey') || ''; 8 | const streamElementsKey = import.meta.env.VITE_STREAMELEMENTS_KEY || queryParams.get('streamElementsKey') || ''; 9 | const timezoneKey = import.meta.env.VITE_TIMEZONE_KEY || queryParams.get('timezoneKey') || ''; 10 | const weatherKey = import.meta.env.VITE_OPENWEATHER_KEY || queryParams.get('weatherKey') || ''; 11 | 12 | const keyStore = observable({ 13 | mapboxKey: mapboxKey, 14 | pullKey: pullKey, 15 | stadiaProviderKey: stadiaProviderKey, 16 | streamElementsKey: streamElementsKey, 17 | streamElementsChannel: '', 18 | timezoneKey: timezoneKey, 19 | weatherKey: weatherKey, 20 | }); 21 | 22 | export default keyStore; -------------------------------------------------------------------------------- /src/store/triggerStore.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "@legendapp/state"; 2 | 3 | const triggerStore = observable({ 4 | rotator: { show: false }, 5 | neighbourhood: { show: false }, 6 | }); 7 | 8 | /*eslint no-constant-condition: ["error", { "checkLoops": false }]*/ 9 | (async () => { 10 | while (true) { 11 | await new Promise(resolve => setTimeout(resolve, 20000)); 12 | triggerStore.neighbourhood.show.set(true); 13 | await new Promise(resolve => setTimeout(resolve, 40000)); 14 | triggerStore.neighbourhood.show.set(false); 15 | } 16 | })(); 17 | 18 | (async () => { 19 | while (true) { 20 | await new Promise(resolve => setTimeout(resolve, 40000)); 21 | triggerStore.rotator.show.set(true); 22 | await new Promise(resolve => setTimeout(resolve, 20000)); 23 | triggerStore.rotator.show.set(false); 24 | } 25 | })(); 26 | 27 | export default triggerStore; 28 | 29 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "ES2020", 7 | "DOM", 8 | "DOM.Iterable" 9 | ], 10 | "module": "ESNext", 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "node", 14 | "allowImportingTsExtensions": true, 15 | "allowSyntheticDefaultImports": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | /* Linting */ 21 | "strict": true, 22 | "noImplicitAny": false, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noFallthroughCasesInSwitch": true, 26 | /* Base URL and paths for aliasing */ 27 | "baseUrl": ".", 28 | "paths": { 29 | "@components/*": [ 30 | "src/components/*" 31 | ], 32 | "@functions/*": [ 33 | "src/functions/*" 34 | ], 35 | "@handlers/*": [ 36 | "src/handlers/*" 37 | ], 38 | "@hooks/*": [ 39 | "src/hooks/*" 40 | ], 41 | "@store/*": [ 42 | "src/store/*" 43 | ] 44 | } 45 | }, 46 | "include": [ 47 | "src" 48 | ], 49 | "references": [ 50 | { 51 | "path": "./tsconfig.node.json" 52 | } 53 | ] 54 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | import path from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | '@components': path.resolve(__dirname, './src/components'), 11 | '@functions': path.resolve(__dirname, './src/functions'), 12 | '@handlers': path.resolve(__dirname, './src/handlers'), 13 | '@hooks': path.resolve(__dirname, './src/hooks'), 14 | '@store': path.resolve(__dirname, './src/store') 15 | }, 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------