Compare commits

...

59 Commits

Author SHA1 Message Date
Andrew Lalis 1cf5ed50fc Upgraded build system, and introduced start of map selection modes. 2022-06-04 16:47:49 +02:00
Andrew Lalis 9b6eb7b667 Cleaned up map rendering code into modules. 2022-06-04 12:28:01 +02:00
Andrew Lalis 9c0d588543 Added inline component adding form. 2022-06-03 19:07:12 +02:00
Andrew Lalis ad18c1b3d4 Set to always allow any origin to open app websocket. 2022-06-03 14:56:23 +02:00
Andrew Lalis ac7d040b5e Added custom description. 2022-06-03 13:12:07 +02:00
Andrew Lalis e90c267ec2 Finalized build script for creating releases. 2022-06-03 13:08:13 +02:00
Andrew Lalis feaff75121 version update for testing. 2022-06-03 12:00:23 +02:00
Andrew Lalis ebad42cf99 Improved build script. 2022-06-03 10:46:26 +02:00
Andrew Lalis 99f438161f Version bump, and fixed some small issues with rendering and creating switches. 2022-06-02 10:07:59 +02:00
Andrew Lalis cb8eed835a Added improvements to advanced rail system components. 2022-06-01 20:31:50 +02:00
Andrew Lalis 3c9554ebba Version bump. 2022-06-01 15:57:30 +02:00
Andrew Lalis 72b4d0dda9 Improved driver docs. 2022-06-01 15:57:06 +02:00
Andrew Lalis bd6c87149a Fixed switch UI updating, websocket bugs, and context switching. 2022-06-01 15:54:29 +02:00
Andrew Lalis c16627e7d3 Fixed infinite redirect bug. 2022-05-31 15:24:48 +02:00
Andrew Lalis 71ca7b4467 Added about page and better index page. 2022-05-31 15:18:40 +02:00
Andrew Lalis 2561c8f3d4 Version bump, and cleaned up some frontend. 2022-05-31 13:23:24 +02:00
Andrew Lalis 9cb074b2ec Fixed rs deletion. 2022-05-31 09:25:53 +02:00
Andrew Lalis f67be6e3ee Added better RS add and remove functionality. 2022-05-31 09:24:57 +02:00
Andrew Lalis f090e105dd Improved build script to allow for defining a custom domain for the webapp to use. 2022-05-31 00:47:11 +02:00
Andrew Lalis 2f7e3ec20c Updated driver to show error screen when not connected. 2022-05-30 09:21:12 +02:00
Andrew Lalis 3c51204261 Version bump. 2022-05-30 09:09:08 +02:00
Andrew Lalis b84f271a40 Improved installer, and added more info to readme. 2022-05-30 09:08:25 +02:00
Andrew Lalis 637dee747d Added working installer workflow 2022-05-29 22:12:24 +02:00
Andrew Lalis d9a4ec31c4 Updated switch service and driver.lua 2022-05-29 17:02:33 +02:00
Andrew Lalis 3b2797f259 Added first implementation of CC:Tweaked driver and improved UI. 2022-05-29 17:02:05 +02:00
Andrew Lalis 2771e934c9 Added WIP driver for computer-craft flavors of devices. 2022-05-26 09:31:37 +02:00
Andrew Lalis dc9ca7c2af Removed old vue project, improved live connection infrastructure. 2022-05-26 09:16:02 +02:00
Andrew Lalis 2a05e26d6d Added settings and improved websocket management. 2022-05-25 13:15:21 +02:00
Andrew Lalis e1a756ffc9 Added config for TcpSocketServer port. 2022-05-25 11:03:55 +02:00
Andrew Lalis f1bc3c7b0a Added settings, updated build system for quasar. 2022-05-24 10:38:42 +02:00
Andrew Lalis c0c036d223 Added Switch dialog, and link token creation. 2022-05-24 00:32:40 +02:00
Andrew Lalis 2cc3c2259a Updated component creation and update workflow. 2022-05-23 12:45:28 +02:00
Andrew Lalis 6cfc630310 Added more component dialogs and component links to settings page. 2022-05-23 09:36:49 +02:00
Andrew Lalis b5bcdc12e1 Added better rail systems list. 2022-05-20 23:30:57 +02:00
Andrew Lalis ee165f6d8b Added initial quazar implementation. 2022-05-20 18:40:44 +02:00
Andrew Lalis e45b942f34 Added UI for creating link tokens, and improved component search api. 2022-05-20 01:12:26 +02:00
Andrew Lalis ed3f6bd6b9 Cleaned up JS and improved link token stuff. 2022-05-18 12:19:45 +02:00
Andrew Lalis 4abd39cbe1 Added component search endpoint. 2022-05-17 22:31:53 +02:00
Andrew Lalis 08fb892cc5 Added websocket connectivity and improved some things. 2022-05-13 13:24:13 +02:00
Andrew Lalis 35c13d83bd Added stuff. 2022-05-12 22:10:42 +02:00
Andrew Lalis ecd9549e77 Added more component stuff in preparation for integrations. 2022-05-09 08:34:57 +02:00
Andrew Lalis 1906111ab8
Merge pull request #2 from andrewlalis/model-refactor
Model refactor
2022-05-08 20:05:09 +02:00
Andrew Lalis e5165330d9 Added build_system.d and changed to --mode=development. 2022-05-08 20:04:21 +02:00
Andrew Lalis 74cf5736f0 Added deploy script. 2022-05-08 18:42:12 +02:00
Andrew Lalis ba409985e5 Added modals, icon, and better formatting. 2022-05-08 18:17:51 +02:00
Andrew Lalis a02758ecd4 Added proper connection management. 2022-05-08 01:37:25 +02:00
Andrew Lalis 3ac886feeb added stuff 2022-05-07 20:31:15 +02:00
Andrew Lalis e608e2ba8c added stuff 2022-05-06 22:43:02 +02:00
Andrew Lalis 2fbc22af0d Added new model. 2022-05-05 22:21:43 +02:00
Andrew Lalis e8cb22276a Added more to the readme. 2021-11-26 22:35:17 +01:00
Andrew Lalis d623812959 Updated web interface and readme. 2021-11-26 19:09:57 +01:00
Andrew Lalis 91988c17b0 Removed redundant messages. 2021-11-26 18:52:26 +01:00
Andrew Lalis fe92f2903c Added switch and some more endpoints to support installation script. 2021-11-26 16:54:32 +01:00
Andrew Lalis e7274cb6d0 Improved UI a bit. 2021-11-25 11:58:09 +01:00
Andrew Lalis fd2cf357dd Added functionality to add and remove connections to other signals. 2021-11-25 11:23:23 +01:00
Andrew Lalis 25e59cd92c Added delete endpoint for rail systems. 2021-11-24 12:33:58 +01:00
Andrew Lalis 4546993f0f Added improved rendering and signal detail panel. 2021-11-24 12:01:36 +01:00
Andrew Lalis 6edb2e4912 Added single page for signal system. 2021-11-23 20:53:44 +01:00
Andrew Lalis cbbf74ee4a Added signal online indicator. 2021-11-23 10:49:07 +01:00
190 changed files with 17896 additions and 622 deletions

5
.gitignore vendored
View File

@ -34,3 +34,8 @@ build/
*.mv.db
*.trace.db
src/main/resources/app
/build_system
/log
/github_token.properties

View File

@ -1,2 +1,24 @@
# RailSignalAPI
A simple API for tracking rail traffic in signalled blocks.
# Rail Signal
A comprehensive solution to tracking and managing your rail system, in real time.
## Setup
To set up your own Rail Signal, system, you will need to follow the following steps:
1. Download and run the Rail Signal API and web app. Go to the [releases](https://github.com/andrewlalis/RailSignalAPI/releases) page to download the latest release. Then, place the JAR file in your desired location, and run it with `java -jar rail-signal.jar`.
2. Open the web app (http://localhost:8080 by default) and create a new rail system, and add components and segments to build your network. More information about this will be given later.
3. Add components to your actual rail system, and install a driver script onto your device. For Minecraft rail systems using Immersive railroading and some computer mod, you can run `pastebin run jKyAiE8k` to run an installation script. *Please make an issue if you have a system for which there is not yet any available driver.*
## Development
To work on and develop Rail Signal, you will need to run both the Java/Spring-Boot backend API, and the Vue/Quasar frontend app.
To start up the API, the project directory in IntelliJ (or the IDE of your choice), and run the `RailSignalApiApplication` main method.
To start up the app, open a terminal in the `quasar-app` directory, and run `quasar dev`.
### Building
To build a complete API/app distributable JAR file, simply run the following:
```
./build_system.d
```
> Note: The build script requires the D language toolchain to be installed on your system. Also, you can compile `build_system.d` to a native executable to run the build script more efficiently.
This will produce a `rail-signal-api-XXX.jar` file in the `target` directory, which contains both the API, and the frontend app, packaged together so that the entire JAR can simply be run via `java -jar`.

166
banner.svg Normal file
View File

@ -0,0 +1,166 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1280"
height="640"
viewBox="0 0 338.66666 169.33334"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="banner.svg"
inkscape:export-filename="/home/andrew/Pictures/railsignalbanner.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="-19.654761 : -18.142858 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="338.66666 : 84.66667 : 1"
inkscape:persp3d-origin="169.33333 : 56.444447 : 1"
id="perspective815" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="845.17179"
inkscape:cy="205.3419"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-127.66666)">
<rect
style="fill:#659a54;fill-opacity:1;stroke:none;stroke-width:12.70000076;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect870"
width="338.66666"
height="169.33333"
x="0"
y="127.66666" />
<rect
style="fill:#454e44;fill-opacity:1;stroke:none;stroke-width:12.70000076;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect911"
width="338.66666"
height="111.125"
x="-1.110223e-16"
y="185.875" />
<g
id="g852"
transform="rotate(90,151.19047,228.96429)">
<path
inkscape:connector-curvature="0"
id="path835"
d="m 122.84226,146.37648 h 92.98215"
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 122.84226,212.9003 h 92.98215"
id="path837"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path839"
d="m 122.84226,246.1622 h 92.98215"
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 122.84226,179.63839 h 92.98215"
id="path841"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path843"
d="m 122.84226,279.42411 h 92.98215"
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path831"
d="M 148.35565,291.70833 V 129.93452"
style="fill:none;stroke:#989898;stroke-width:8.46666718;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#989898;stroke-width:8.46666718;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 190.31102,291.70833 V 129.93452"
id="path833"
inkscape:connector-curvature="0" />
</g>
<flowRoot
xml:space="preserve"
id="flowRoot913"
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0.57599614,0,0,0.57599614,-10.203222,101.60397)"><flowRegion
id="flowRegion915"
style="fill:#ffffff;fill-opacity:1;stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"><rect
id="rect917"
width="1102.8572"
height="162.85715"
x="51.42857"
y="34.285713"
style="fill:#ffffff;fill-opacity:1;stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /></flowRegion><flowPara
id="flowPara919"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:96px;font-family:'Ubuntu Mono';-inkscape-font-specification:'Ubuntu Mono Bold';stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">Rail Signal</flowPara></flowRoot> <g
id="g933"
transform="translate(123.59822)">
<path
inkscape:connector-curvature="0"
id="path921"
d="m 169.33333,245.97321 v 46.49107"
style="fill:none;stroke:#000000;stroke-width:16.93333435;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
ry="2.6458333"
rx="2.6458333"
y="196.08035"
x="152.13544"
height="51.026794"
width="34.395802"
id="rect923"
style="fill:#000000;fill-opacity:1;stroke:#bababa;stroke-width:4.23333359;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="6.0476193"
cy="212.33333"
cx="169.33333"
id="path925"
style="fill:#1cd500;fill-opacity:1;stroke:none;stroke-width:8.46666718;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#d50000;fill-opacity:1;stroke:none;stroke-width:8.46666718;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle927"
cx="169.33333"
cy="228.5863"
r="6.0476193" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

173
build_system.d Executable file
View File

@ -0,0 +1,173 @@
#!/usr/bin/env dub
/+ dub.sdl:
dependency "dsh" version="~>1.6.1"
dependency "dxml" version="~>0.4.3"
dependency "requests" version="~>2.0.8"
dependency "d-properties" version="~>1.0.4"
+/
/**
* This script will build the Rail Signal Vue app, then bundle it into this
* Spring project's files under src/main/resources/app/, and will then build
* this project into a jar file.
*/
module build_system;
import dsh;
import dxml.dom;
import dxml.util;
import std.stdio;
import std.string;
import std.algorithm;
import std.uni;
const DIST = "./src/main/resources/app";
const DIST_ORIGIN = "./quasar-app/dist/spa";
const APP_DIR = "./quasar-app";
const APP_BUILD = "quasar build -m spa";
const API_BUILD = "mvn clean package spring-boot:repackage -DskipTests=true";
const LOG_DIR = "./log";
const API_LOG = LOG_DIR ~ "/api_build.txt";
const APP_LOG = LOG_DIR ~ "/app_build.txt";
const GITHUB_PROPS_FILE = "github_token.properties";
int main(string[] args) {
string ver = getVersion();
if (ver is null) {
error("Could not determine version.");
return 1;
}
removeIfExists(LOG_DIR);
mkdir(LOG_DIR);
print("Building Rail Signal v%s", ver);
if (args.length >= 2) {
string command = args[1].strip.toLower;
if (command == "app") {
buildApp();
} else if (command == "api") {
buildApi(ver);
} else if (command == "all") {
buildApp();
buildApi(ver);
if (args.length >= 3 && args[2].strip.toLower == "release") {
print("Are you sure you want to create a GitHub release for version %s?", ver);
string response = readln().strip.toLower;
if (response == "yes" || response == "y") {
print("Please enter a short description for this release.");
string description = readln().strip;
createRelease(ver, description);
}
}
}
} else {
buildApp();
buildApi(ver);
}
return 0;
}
/**
* Builds the production version of the frontend app and injects it into the
* API's resources to serve statically.
*/
void buildApp() {
chdir(APP_DIR);
print("Building app...");
runOrQuit(APP_BUILD, "." ~ APP_LOG); // Use an extra dot because we moved into app dir.
print("Copying dist from %s to %s", DIST_ORIGIN, DIST);
chdir("..");
removeIfExists(DIST);
mkdir(DIST);
copyDir(DIST_ORIGIN, DIST);
}
/**
* Builds the production version of the backend API.
*/
void buildApi(string ver) {
print("Building API...");
runOrQuit(API_BUILD, API_LOG);
string[] jars = findFilesByExtension("target", ".jar", false);
string jarFile = jars[0];
string finalJarFile = "./target/rail-signal-" ~ ver ~ ".jar";
// Clean up the jar file name.
copy(jarFile, finalJarFile);
print("Build complete. Created %s", finalJarFile);
}
/**
* Parses the version of the system from the pom file.
* Returns: The version string, or null if it couldn't be found.
*/
string getVersion() {
auto data = parseDOM!simpleXML(readText("pom.xml"));
auto root = data.children[0];
foreach (child; root.children) {
if (child.name == "version") {
return child.children[0].text;
}
}
return null;
}
/**
* Creates a new GitHub release using the specified version, and uploads the
* JAR file to the release.
* Params:
* ver = The version.
*/
void createRelease(string ver, string description) {
import d_properties;
import requests;
import std.json;
print("Creating release...");
JSONValue data = [
"tag_name": "v" ~ ver,
"name": "Rail Signal v" ~ ver,
"body": description
];
data.object["prerelease"] = JSONValue(false);
data.object["generate_release_notes"] = JSONValue(false);
auto rq = Request();
auto props = Properties(GITHUB_PROPS_FILE);
string username = props["username"];
string token = props["token"];
rq.authenticator = new BasicAuthentication(username, token);
auto response = rq.post(
"https://api.github.com/repos/andrewlalis/RailSignalAPI/releases",
data.toString,
"application/json"
);
if (response.code == 201) {
string responseBody = cast(string) response.responseBody;
JSONValue responseData = parseJSON(responseBody);
print("Created release %s", responseData["url"].str);
long releaseId = responseData["id"].integer;
string uploadUrl = format!"https://uploads.github.com/repos/andrewlalis/RailSignalAPI/releases/%d/assets?name=%s"(
releaseId,
"rail-signal-" ~ ver ~ ".jar"
);
print("Uploading JAR file to %s", uploadUrl);
auto f = File("./target/rail-signal-" ~ ver ~ ".jar", "rb");
ulong assetSize = f.size();
rq.addHeaders(["Content-Length": format!"%d"(assetSize)]);
auto assetResponse = rq.post(uploadUrl, f.byChunk(4096));
if (assetResponse.code == 201) {
print("JAR file uploaded successfully.");
} else {
error("An error occurred while uploading the JAR file.");
}
} else {
error("An error occurred while creating the release.");
writeln(response.responseBody);
}
}

111
component-drivers.md Normal file
View File

@ -0,0 +1,111 @@
# Component Drivers
This document describes the general methodology for writing component drivers that operate within your rail system to connect it to the online system's up- and down-link services.
The following types of components are supported by Rail Signal:
- `SIGNAL`
- `SEGMENT_BOUNDARY`
- `SWITCH`
The following information is generally required for any driver to be able to connect to the system and operate nominally:
- A valid link token
- The base URL of the system's API. Usually `http://localhost:8080` or whatever you've configured it to be.
- Live connection information. This differs depending on what type of communication your device supports.
- For devices with **websocket** support, you will need the base URL of the websocket. Usually `ws://localhost:8080` or whatever you've configured your server to use.
- For devices with **TCP socket** support, you will need the hostname and port of the system's server socket. By default, this is `localhost:8081`.
A device is not limited to a single component, but will act as a relay for all components linked to the device's link token. Generally, there is no limit to the number of components that a single device can manage, but more components will lead to more load on the device.
## Live Communication
The main purpose of component drivers is to relay real-time messages between actual component devices, and their representations in the online rail system. While multiple types of communication are available, in general, all messages are sent as JSON UTF-8 encoded strings. Devices must present their link token when they initiate the connection.
If the link token is valid, the connection will be initiated, and the device will immediately receive a `COMPONENT_DATA` message for each component that the token is linked to.
### Websocket
Websocket connections should be made to `{BASE_WS_URL}/api/ws/component?token={token}`, where `{BASE_WS_URL}` is the base websocket URL, such as `ws://localhost:8080`, and `{token}` is the device's link token.
- If the link token is missing or improperly formatted, a 400 Bad Request response is given.
- If the link token is not correct or not active or otherwise set to reject connections, a 401 Unauthorized response is given.
### TCP Socket
TCP socket connections should be made to the server's TCP socket address, which by default is `localhost:8081`. The device should immediately send 2 bytes indicating the length of its token string, followed by the token string's bytes. The client should expect to receive in response a *connect message*:
```json
{
"valid": true,
"message": "Connection established."
}
```
- If the token is invalid, a `"valid": false` is returned and the server closes the socket.
Note: All messages sent via TCP are sent as JSON messages with a 2-byte length header.
## Components
Each device should be designed to handle multiple independent components concurrently. The device may receive messages at any time pertaining to any of the components, and the device may send messages at any time, pertaining to any of the components. Messages are only sent regarding a single component.
Every component message should contain at least the following two properties:
```json
{
"cId": 123,
"type": "COMPONENT_DATA",
...
}
```
`cId` is the id of the component that this message is about. `type` is the type of message. This defines what additional structure to expect.
All components may receive `COMPONENT_DATA` messages. For example, the following could be a message regarding a signal:
```json
{
"cId": 123,
"type": "COMPONENT_DATA",
"data": {
"id": 123,
"position": {"x": 0, "y": 0, "z": 0},
"name": "my-component",
"type": "SIGNAL",
"online": true,
"segment": {
"id": 4,
"name": "my-segment",
"occupied": false
}
}
}
```
The following sections will provide more detail about the other types of messages that can be sent and received by the different components.
### Signal
Signals display the status of a connected segment, and as such can only receive data. They will receive `SEGMENT_STATUS` messages:
```json
{
"cId": 123,
"type": "SEGMENT_STATUS",
"segmentId": 4,
"occupied": true
}
```
`sId` is the id of the segment that was updated. `occupied` contains the current status of the segment.
### Segment Boundary
Segment boundaries send updates as trains pass them, in order to provide information to the system about the state of connected segments.
```json
{
"cId": 123,
"type": "SEGMENT_BOUNDARY_UPDATE",
"toSegmentId": 3,
"eventType": "ENTERING"
}
```
`toSegmentId` is the id of the segment a train is moving towards. `eventType` is the type of boundary event. This can either be `ENTERING` if a train has just begun entering the segment, or `ENTERED` if a train has just left the boundary and completely entered the segment.
### Switch
Switches can send information about their status, if it's been updated, and they can also receive messages that direct them to change their status.
```json
{
"cId": 123,
"type": "SWITCH_UPDATE",
"activeConfigId": 497238
}
```
`activeConfigId` is the id of the switch configuration that's active. This message can be sent by either the system or the switch.

21
pom.xml
View File

@ -5,12 +5,12 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.0</version>
<version>2.6.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>nl.andrewl</groupId>
<artifactId>rail-signal-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<version>2.3.0</version>
<name>rail-signal-api</name>
<description>A simple API for tracking rail traffic in signalled blocks.</description>
<properties>
@ -25,6 +25,22 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
@ -32,6 +48,7 @@
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.1.212</version>
</dependency>
<dependency>

9
quasar-app/.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

6
quasar-app/.eslintignore Normal file
View File

@ -0,0 +1,6 @@
/dist
/src-capacitor
/src-cordova
/.quasar
/node_modules
.eslintrc.js

66
quasar-app/.eslintrc.js Normal file
View File

@ -0,0 +1,66 @@
module.exports = {
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
// This option interrupts the configuration hierarchy at this file
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
root: true,
parserOptions: {
ecmaVersion: '2021', // Allows for the parsing of modern ECMAScript features
},
env: {
node: true,
browser: true,
'vue/setup-compiler-macros': true
},
// Rules order is important, please avoid shuffling them
extends: [
// Base ESLint recommended rules
// 'eslint:recommended',
// Uncomment any of the lines below to choose desired strictness,
// but leave only one uncommented!
// See https://eslint.vuejs.org/rules/#available-rules
'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
// 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'prettier'
],
plugins: [
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
// required to lint *.vue files
'vue',
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE
],
globals: {
ga: 'readonly', // Google Analytics
cordova: 'readonly',
__statics: 'readonly',
__QUASAR_SSR__: 'readonly',
__QUASAR_SSR_SERVER__: 'readonly',
__QUASAR_SSR_CLIENT__: 'readonly',
__QUASAR_SSR_PWA__: 'readonly',
process: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly'
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}
}

29
quasar-app/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln

41
quasar-app/README.md Normal file
View File

@ -0,0 +1,41 @@
# Rail Signal App (rail-signal)
App for the Rail Signal system.
## Install the dependencies
```bash
yarn
# or
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
```
### Lint the files
```bash
yarn lint
# or
npm run lint
```
### Format the files
```bash
yarn format
# or
npm run format
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

View File

@ -0,0 +1,280 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="200"
viewBox="0 0 135.46666 52.916668"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="layout.svg"
inkscape:export-filename="/home/andrew/Programming/github-andrewlalis/RailSignalAPI/quasar-app/src/assets/img/guide/layout.png"
inkscape:export-xdpi="192"
inkscape:export-ydpi="192">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="261.33576"
inkscape:cy="131.69301"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="false"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-244.08333)">
<path
style="fill:#fb6700;fill-opacity:0.23137255;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 43.67314,257.80597 55.767023,12.57864 0.06207,18.71578 H 30.710567 v -18.33185 z"
id="path1012"
inkscape:connector-curvature="0" />
<rect
y="151.32266"
x="173.9552"
height="18.148809"
width="39.182587"
id="rect1006"
style="fill:#00fcb4;fill-opacity:0.23157893;stroke:none;stroke-width:1.05833328;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:2.11666664, 1.05833332;stroke-dashoffset:0;stroke-opacity:1"
transform="rotate(45)" />
<rect
style="fill:#0afc00;fill-opacity:0.23157893;stroke:none;stroke-width:1.05833328;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:2.11666664, 1.05833332;stroke-dashoffset:0;stroke-opacity:1"
id="rect1004"
width="31.245104"
height="18.715776"
x="-0.53453904"
y="270.38474" />
<rect
y="270.38474"
x="99.440163"
height="18.715775"
width="36.914753"
id="rect1008"
style="fill:#2200fc;fill-opacity:0.23157893;stroke:none;stroke-width:1.05833328;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:2.11666664, 1.05833332;stroke-dashoffset:0;stroke-opacity:1" />
<path
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M -2.2717908,280.13995 H 137.51017"
id="path817"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 75.50364,280.14805 c -34.076865,0 -57.596582,-37.15857 -57.596582,-37.15857"
id="path819"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<rect
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1"
id="rect945"
width="4.5178719"
height="4.5178719"
x="266.22791"
y="125.43176"
transform="rotate(45)" />
<path
style="fill:none;stroke:#ff0000;stroke-width:1.05833328;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.11666664, 1.05833332;stroke-dashoffset:0;stroke-opacity:1"
d="m 99.502231,270.76854 v 18.33185"
id="path821"
inkscape:connector-curvature="0" />
<rect
transform="rotate(45)"
y="174.00168"
x="217.65794"
height="4.5178719"
width="4.5178719"
id="rect943"
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path823"
d="m 30.710565,270.76854 v 18.33185"
style="fill:none;stroke:#ff0000;stroke-width:1.05833328;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.11666664, 1.05833332;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1"
id="rect941"
width="4.5178719"
height="4.5178719"
x="210.94887"
y="158.59915"
transform="rotate(45)" />
<path
style="fill:none;stroke:#ff0000;stroke-width:1.05833328;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:2.11666664, 1.05833332;stroke-dashoffset:0;stroke-opacity:1"
d="M 43.673141,257.80597 30.710565,270.76854"
id="path825"
inkscape:connector-curvature="0" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332"
x="63.122021"
y="251.64285"
id="text829"><tspan
sodipodi:role="line"
id="tspan827"
x="63.122021"
y="261.00662"
style="stroke-width:0.26458332"></tspan></text>
<g
transform="matrix(0.08953353,0,0,0.08953353,102.00472,251.52712)"
id="layer1-3"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
id="path815"
d="M 33.866667,296.90549 V 276.77826"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
rx="6.6145835"
y="233.04643"
x="16.933334"
height="43.845234"
width="33.866665"
id="rect817"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="5.8586307"
cy="245.50073"
cx="33.866665"
id="path819-5"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle821"
cx="33.866665"
cy="262.60416"
r="5.8586307" />
</g>
<g
inkscape:label="Layer 1"
id="g929"
transform="matrix(0.08953353,0,0,0.08953353,23.962019,262.35154)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,296.90549 V 276.77826"
id="path921"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect923"
width="33.866665"
height="43.845234"
x="16.933334"
y="233.04643"
rx="6.6145835" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle925"
cx="33.866665"
cy="245.50073"
r="5.8586307" />
<circle
r="5.8586307"
cy="262.60416"
cx="33.866665"
id="circle927"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
transform="matrix(0.08953353,0,0,0.08953353,25.832906,239.90089)"
id="g939"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
id="path931"
d="M 33.866667,296.90549 V 276.77826"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
rx="6.6145835"
y="233.04643"
x="16.933334"
height="43.845234"
width="33.866665"
id="rect933"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="5.8586307"
cy="245.50073"
cx="33.866665"
id="circle935"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle937"
cx="33.866665"
cy="262.60416"
r="5.8586307" />
</g>
<g
transform="matrix(0.10431371,0,0,0.10431371,60.72992,252.67196)"
id="layer1-2"
inkscape:label="Layer 1"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1">
<path
inkscape:connector-curvature="0"
id="path838"
d="M 33.866667,291.12006 V 259.31498"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 56.356255,236.8254"
id="path840"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path842"
d="M 33.866667,259.31498 11.377087,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="7.7508163"
cy="238.06705"
cx="13.554182"
id="path844"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle846"
cx="54.713692"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="287.79529"
cx="33.866665"
id="circle848"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,632 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="256"
viewBox="0 0 135.46666 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="layout2.svg"
inkscape:export-filename="/home/andrew/Programming/github-andrewlalis/RailSignalAPI/quasar-app/src/assets/img/guide/layout2.png"
inkscape:export-xdpi="192"
inkscape:export-ydpi="192">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="191.26796"
inkscape:cy="128.73452"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="false"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26666)">
<rect
style="fill:#fc8700;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect1179"
width="58.515881"
height="29.386139"
x="35.644123"
y="253.14487" />
<rect
transform="rotate(90)"
y="-136.71936"
x="264.76611"
height="42.559361"
width="8.8824463"
id="rect1173"
style="fill:#fcdf00;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332"
x="63.122021"
y="251.64285"
id="text829"><tspan
sodipodi:role="line"
id="tspan827"
x="63.122021"
y="261.00662"
style="stroke-width:0.26458332"></tspan></text>
<g
transform="matrix(0.08953353,0,0,0.08953353,50.295819,223.70883)"
id="g939"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
id="path931"
d="M 33.866667,296.90549 V 276.77826"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
rx="6.6145835"
y="233.04643"
x="16.933334"
height="43.845234"
width="33.866665"
id="rect933"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="5.8586307"
cy="245.50073"
cx="33.866665"
id="circle935"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle937"
cx="33.866665"
cy="262.60416"
r="5.8586307" />
</g>
<rect
transform="rotate(90)"
y="-35.825115"
x="273.64856"
height="42.559361"
width="8.8824463"
id="rect1177"
style="fill:#00fcac;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="fill:#7afc00;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect1175"
width="8.8824463"
height="42.559361"
x="264.76611"
y="-35.825115"
transform="rotate(90)" />
<path
style="fill:none;stroke:#000000;stroke-width:2.11666664;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M -2.0045214,269.47123 H 137.37654"
id="path1071"
inkscape:connector-curvature="0" />
<rect
style="fill:#fc0058;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect1171"
width="8.8824463"
height="42.559361"
x="273.64856"
y="-136.71936"
transform="rotate(90)" />
<path
inkscape:connector-curvature="0"
id="path1073"
d="M -2.0045214,278.29112 H 137.37654"
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
style="fill:#2200fc;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect1167"
width="8.8824406"
height="27.59226"
x="56.412945"
y="225.94048" />
<path
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 61.071086,227.50992 v 21.11429"
id="path1075"
inkscape:connector-curvature="0" />
<rect
y="225.94048"
x="65.295387"
height="27.59226"
width="8.8824406"
id="rect1169"
style="fill:#b200fc;fill-opacity:0.23157893;stroke:none;stroke-width:2.11666656;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path1077"
d="m 69.490076,227.50992 v 21.11429"
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 69.490076,248.62421 c 0,29.66692 29.600098,29.66692 29.600098,29.66692"
id="path1079"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path1081"
d="m 61.071086,248.62421 c 0,29.66692 29.600098,29.66692 29.600098,29.66692"
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path1083"
d="m 69.490076,248.62421 c 0,29.66692 -29.600098,29.66692 -29.600098,29.66692"
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1"
id="rect945"
width="4.5178719"
height="4.5178719"
x="224.94077"
y="126.66696"
transform="rotate(45)" />
<path
style="fill:none;stroke:#000000;stroke-width:2.11666656;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 61.071086,248.62421 c 0,29.66692 -29.600098,29.66692 -29.600098,29.66692"
id="path1085"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<rect
transform="rotate(45)"
y="132.63582"
x="218.97191"
height="4.5178719"
width="4.5178719"
id="rect1087"
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1" />
<rect
transform="rotate(45)"
y="126.71421"
x="262.33102"
height="4.5178719"
width="4.5178719"
id="rect1089"
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1" />
<g
transform="matrix(0.10431371,0,0,0.10431371,65.957315,227.96333)"
id="layer1-2"
inkscape:label="Layer 1"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1">
<path
inkscape:connector-curvature="0"
id="path838"
d="M 33.866667,291.12006 V 259.31498"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 56.356255,236.8254"
id="path840"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path842"
d="M 33.866667,259.31498 11.377087,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="7.7508163"
cy="238.06705"
cx="13.554182"
id="path844"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle846"
cx="54.713692"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="287.79529"
cx="33.866665"
id="circle848"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
<rect
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1"
id="rect1091"
width="4.5178719"
height="4.5178719"
x="256.04718"
y="120.43036"
transform="rotate(45)" />
<rect
transform="rotate(45)"
y="170.01033"
x="219.0349"
height="4.5178719"
width="4.5178719"
id="rect1093"
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="fill:#963ae0;fill-opacity:1;stroke:none;stroke-width:0.8320865;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.6641731, 0.83208655;stroke-dashoffset:0;stroke-opacity:1"
id="rect1095"
width="4.5178719"
height="4.5178719"
x="212.75104"
y="163.72647"
transform="rotate(45)" />
<g
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1"
inkscape:label="Layer 1"
id="g1109"
transform="matrix(0.10431371,0,0,0.10431371,57.516077,227.96333)">
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,291.12006 V 259.31498"
id="path1097"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path1099"
d="M 33.866667,259.31498 56.356255,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 11.377087,236.8254"
id="path1101"
inkscape:connector-curvature="0" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1103"
cx="13.554182"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="238.06705"
cx="54.713692"
id="circle1105"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1107"
cx="33.866665"
cy="287.79529"
r="7.7508163" />
</g>
<g
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1"
inkscape:label="Layer 1"
id="g1123"
transform="matrix(0,0.10431371,-0.10431371,0,119.75106,274.75839)">
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,291.12006 V 259.31498"
id="path1111"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path1113"
d="M 33.866667,259.31498 56.356255,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 11.377087,236.8254"
id="path1115"
inkscape:connector-curvature="0" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1117"
cx="13.554182"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="238.06705"
cx="54.713692"
id="circle1119"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1121"
cx="33.866665"
cy="287.79529"
r="7.7508163" />
</g>
<g
transform="matrix(0,0.10431371,-0.10431371,0,112.85299,274.75839)"
id="g1137"
inkscape:label="Layer 1"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1">
<path
inkscape:connector-curvature="0"
id="path1125"
d="M 33.866667,291.12006 V 259.31498"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 56.356255,236.8254"
id="path1127"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path1129"
d="M 33.866667,259.31498 11.377087,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="7.7508163"
cy="238.06705"
cx="13.554182"
id="circle1131"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1133"
cx="54.713692"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="287.79529"
cx="33.866665"
id="circle1135"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1"
inkscape:label="Layer 1"
id="g1151"
transform="matrix(0,-0.10431371,0.10431371,0,10.810111,281.82389)">
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,291.12006 V 259.31498"
id="path1139"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path1141"
d="M 33.866667,259.31498 56.356255,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 11.377087,236.8254"
id="path1143"
inkscape:connector-curvature="0" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1145"
cx="13.554182"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="238.06705"
cx="54.713692"
id="circle1147"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1149"
cx="33.866665"
cy="287.79529"
r="7.7508163" />
</g>
<g
transform="matrix(0,-0.10431371,0.10431371,0,18.180647,281.82389)"
id="g1165"
inkscape:label="Layer 1"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-opacity:1">
<path
inkscape:connector-curvature="0"
id="path1153"
d="M 33.866667,291.12006 V 259.31498"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 56.356255,236.8254"
id="path1155"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path1157"
d="M 33.866667,259.31498 11.377087,236.8254"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="7.7508163"
cy="238.06705"
cx="13.554182"
id="circle1159"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1161"
cx="54.713692"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="287.79529"
cx="33.866665"
id="circle1163"
style="fill:#f5bc42;fill-opacity:1;stroke:#f5bc42;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
inkscape:label="Layer 1"
id="g1189"
transform="matrix(0.08953353,0,0,0.08953353,75.151885,223.70883)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,296.90549 V 276.77826"
id="path1181"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1183"
width="33.866665"
height="43.845234"
x="16.933334"
y="233.04643"
rx="6.6145835" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1185"
cx="33.866665"
cy="245.50073"
r="5.8586307" />
<circle
r="5.8586307"
cy="262.60416"
cx="33.866665"
id="circle1187"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
transform="matrix(0.08953353,0,0,0.08953353,95.063465,236.80504)"
id="g1199"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
id="path1191"
d="M 33.866667,296.90549 V 276.77826"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
rx="6.6145835"
y="233.04643"
x="16.933334"
height="43.845234"
width="33.866665"
id="rect1193"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="5.8586307"
cy="245.50073"
cx="33.866665"
id="circle1195"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1197"
cx="33.866665"
cy="262.60416"
r="5.8586307" />
</g>
<g
inkscape:label="Layer 1"
id="g1209"
transform="matrix(0.08953353,0,0,0.08953353,95.063465,263.39836)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,296.90549 V 276.77826"
id="path1201"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1203"
width="33.866665"
height="43.845234"
x="16.933334"
y="233.04643"
rx="6.6145835" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1205"
cx="33.866665"
cy="245.50073"
r="5.8586307" />
<circle
r="5.8586307"
cy="262.60416"
cx="33.866665"
id="circle1207"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
transform="matrix(0.08953353,0,0,0.08953353,27.978814,263.39836)"
id="g1219"
inkscape:label="Layer 1">
<path
inkscape:connector-curvature="0"
id="path1211"
d="M 33.866667,296.90549 V 276.77826"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<rect
rx="6.6145835"
y="233.04643"
x="16.933334"
height="43.845234"
width="33.866665"
id="rect1213"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
r="5.8586307"
cy="245.50073"
cx="33.866665"
id="circle1215"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1217"
cx="33.866665"
cy="262.60416"
r="5.8586307" />
</g>
<g
inkscape:label="Layer 1"
id="g1229"
transform="matrix(0.08953353,0,0,0.08953353,27.978814,236.93868)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,296.90549 V 276.77826"
id="path1221"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1223"
width="33.866665"
height="43.845234"
x="16.933334"
y="233.04643"
rx="6.6145835" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle1225"
cx="33.866665"
cy="245.50073"
r="5.8586307" />
<circle
r="5.8586307"
cy="262.60416"
cx="33.866665"
id="circle1227"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="label_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="-7.53551"
inkscape:cy="167.89676"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 23.812499,294.90224 V 240.28468"
id="path841"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect843"
width="38.364582"
height="24.095985"
x="23.812498"
y="233.40552"
rx="6.614583" />
<path
style="fill:none;stroke:#000000;stroke-width:4.23333334;stroke-linecap:round;stroke-linejoin:bevel;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 31.372024,241.57922 H 54.475819"
id="path845"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path847"
d="M 31.479768,247.91032 H 54.583563"
style="fill:none;stroke:#000000;stroke-width:4.23333311;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="segment-boundary_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="56.595604"
inkscape:cy="134.48081"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 0.70544035,242.15266 H 28.234201 v 41.96132 H 1.1063446"
id="path869"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path871"
d="M 67.027893,284.11398 H 39.499132 v -41.96132 h 27.127856"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 6.9392617,256.58521 H 60.794072"
id="path873"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path875"
d="M 6.9392617,269.68142 H 60.794072"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="signal_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="97.668938"
inkscape:cy="136.97831"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.35000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,296.90549 V 276.77826"
id="path815"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect817"
width="33.866665"
height="43.845234"
x="16.933334"
y="233.04643"
rx="6.6145835" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path819"
cx="33.866665"
cy="245.50073"
r="5.8586307" />
<circle
r="5.8586307"
cy="262.60416"
cx="33.866665"
id="circle821"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="switch_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="34.945838"
inkscape:cy="171.1685"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,291.12006 V 259.31498"
id="path838"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path840"
d="M 33.866667,259.31498 56.356255,236.8254"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 11.377087,236.8254"
id="path842"
inkscape:connector-curvature="0" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path844"
cx="13.554182"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="238.06705"
cx="54.713692"
id="circle846"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle848"
cx="33.866665"
cy="287.79529"
r="7.7508163" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

16
quasar-app/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8">
<meta name="description" content="<%= productDescription %>">
<meta name="format-detection" content="telephone=no">
<meta name="msapplication-tap-highlight" content="no">
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

39
quasar-app/jsconfig.json Normal file
View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": [
"src/*"
],
"app/*": [
"*"
],
"components/*": [
"src/components/*"
],
"layouts/*": [
"src/layouts/*"
],
"pages/*": [
"src/pages/*"
],
"assets/*": [
"src/assets/*"
],
"boot/*": [
"src/boot/*"
],
"stores/*": [
"src/stores/*"
],
"vue$": [
"node_modules/vue/dist/vue.runtime.esm-bundler.js"
]
}
},
"exclude": [
"dist",
".quasar",
"node_modules"
]
}

8030
quasar-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
quasar-app/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "rail-signal",
"version": "0.0.1",
"description": "App for the Rail Signal system.",
"productName": "Rail Signal App",
"author": "Andrew Lalis <andrewlalisofficial@gmail.com>",
"private": true,
"scripts": {
"lint": "eslint --ext .js,.vue ./",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0"
},
"dependencies": {
"@quasar/extras": "^1.0.0",
"axios": "^0.21.1",
"pinia": "^2.0.11",
"quasar": "^2.6.0",
"randomcolor": "^0.6.2",
"vue": "^3.0.0",
"vue-router": "^4.0.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.0.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^8.5.0",
"prettier": "^2.5.1"
},
"engines": {
"node": "^18 || ^16 || ^14.19",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}

View File

@ -0,0 +1,27 @@
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions'
]
})
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

230
quasar-app/quasar.config.js Normal file
View File

@ -0,0 +1,230 @@
/* eslint-env node */
/*
* This file runs in a Node context (it's NOT transpiled by Babel), so use only
* the ES6 features that are supported by your Node version. https://node.green/
*/
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { configure } = require('quasar/wrappers');
const { Notify } = require("quasar");
module.exports = configure(function (ctx) {
return {
eslint: {
// fix: true,
// include = [],
// exclude = [],
// rawOptions = {},
warnings: true,
errors: true
},
// https://v2.quasar.dev/quasar-cli/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files
boot: [
'axios'
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: [
'app.scss'
],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v5',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
target: {
browser: [ 'es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1' ],
node: 'node16'
},
vueRouterMode: 'history', // available values: 'hash', 'history'
// vueRouterBase: '/rail-systems',
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
publicPath: "/app/",
// analyze: true,
// env: {
// API_URL: ctx.dev ? "http://localhost:8080/api" : process.env.RAIL_SIGNAL_API_URL,
// WS_URL: ctx.dev ? "ws://localhost:8080/api/ws/app" : process.env.RAIL_SIGNAL_WS_URL
// },
// rawDefine: {}
// ignorePublicFolder: true,
// minify: "hidden",
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
// vitePlugins: [
// [ 'package-name', { ..options.. } ]
// ]
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: {
// https: true
open: true // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {
brand: {
primary: '#29733c',
secondary: '#7b9651',
accent: '#a38234',
dark: '#072e14',
positive: '#22ba64',
negative: '#a64a1c',
info: '#43a180',
warning: '#a88c40'
}
},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: [
"Notify",
"Dialog"
]
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#property-sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// registerServiceWorker: 'src-pwa/register-service-worker',
// serviceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// },
// https://v2.quasar.dev/quasar-cli/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
pwa: false,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render' // keep this as last one
]
},
// https://v2.quasar.dev/quasar-cli/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'generateSW', // or 'injectManifest'
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialsForManifestTag: false,
// extendGenerateSWOptions (cfg) {}
// extendInjectManifestOptions (cfg) {},
// extendManifestJson (json) {}
// extendPWACustomSWConf (esbuildConf) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true
},
// Full list of options: https://v2.quasar.dev/quasar-cli/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf)
// extendElectronPreloadConf (esbuildConf)
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration/configuration
appId: 'rail-signal'
}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
contentScripts: [
'my-content-script'
],
// extendBexScriptsConf (esbuildConf) {}
// extendBexManifestJson (json) {}
}
}
});

11
quasar-app/src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<router-view />
</template>
<script>
import { defineComponent } from "vue";
export default defineComponent({
name: 'App'
})
</script>

View File

@ -0,0 +1,124 @@
import axios from "axios";
import {API_URL} from "./constants";
export function refreshComponents(rs) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/c`)
.then(response => {
const previousSelectedComponentIds = rs.selectedComponents.map(c => c.id);
rs.selectedComponents.length = 0;
rs.components = response.data;
for (let i = 0; i < previousSelectedComponentIds.length; i++) {
const component = rs.components.find(c => c.id === previousSelectedComponentIds[i]);
if (component) {
rs.selectedComponents.push(component);
}
}
resolve();
})
.catch(reject);
});
}
export function refreshSomeComponents(rs, components) {
const promises = [];
for (let i = 0; i < components.length; i++) {
const promise = new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/c/${components[i].id}`)
.then(resp => {
const idx = rs.components.findIndex(c => c.id === resp.data.id);
if (idx > -1) rs.components[idx] = resp.data;
resolve();
})
.catch(reject);
});
promises.push(promise);
}
return Promise.all(promises);
}
export function getComponent(rs, id) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/c/${id}`)
.then(response => resolve(response.data))
.catch(reject);
});
}
/**
* Searches through the rail system's components.
* @param {RailSystem} rs
* @param {string|null} searchQuery
* @return {Promise<Object>}
*/
export function searchComponents(rs, searchQuery) {
return new Promise((resolve, reject) => {
const params = {
page: 0,
size: 25
};
if (searchQuery) params.q = searchQuery;
axios.get(`${API_URL}/rs/${rs.id}/c/search`, {params: params})
.then(response => {
resolve(response.data);
})
.catch(reject);
});
}
export function createComponent(rs, data) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs/${rs.id}/c`, data)
.then(response => {
const newComponentId = response.data.id;
refreshComponents(rs)
.then(() => {
const newComponent = rs.components.find(c => c.id === newComponentId);
if (newComponent) {
rs.selectedComponents.length = 0;
rs.selectedComponents.push(newComponent);
}
resolve();
})
.catch(reject);
})
.catch(reject);
});
}
export function removeComponent(rs, id) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${rs.id}/c/${id}`)
.then(() => {
refreshComponents(rs)
.then(resolve)
.catch(reject);
})
.catch(reject);
});
}
export function updateComponent(rs, component) {
return new Promise((resolve, reject) => {
axios.patch(`${API_URL}/rs/${rs.id}/c/${component.id}`, component)
.then(() => {
refreshComponents(rs)
.then(resolve)
.catch(reject);
})
.catch(reject);
});
}
export function updateSwitchConfiguration(rs, sw, configId) {
return new Promise((resolve, reject) => {
axios.post(
`${API_URL}/rs/${rs.id}/c/${sw.id}/activeConfiguration`,
{
activeConfigurationId: configId
}
)
.then(resolve)
.catch(reject);
});
}

View File

@ -0,0 +1,12 @@
/*
In development mode, we should use localhost:8080 as that's where the API is set
to run. In production mode, this app is deployed under the same host as the API,
so we can simply use `location.origin` and `location.host` to define our API and
WS urls.
*/
export const API_URL = process.env.DEV
? "http://localhost:8080/api"
: location.origin + "/api";
export const WS_URL = process.env.DEV
? "ws://localhost:8080/api/ws/app"
: (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/ws/app";

View File

@ -0,0 +1,62 @@
import {API_URL} from "./constants";
import axios from "axios";
/**
* A token that's used by components to provide real-time up and down links.
*/
export class LinkToken {
constructor(data) {
this.id = data.id;
this.label = data.label;
this.components = data.components;
}
}
/**
* Refreshes the list of link tokens in a rail system.
* @param {RailSystem} rs
* @return {Promise}
*/
export function refreshLinkTokens(rs) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/lt`)
.then(response => {
rs.linkTokens = response.data;
resolve();
})
.catch(reject);
});
}
/**
* Creates a new link token.
* @param {RailSystem} rs
* @param {{label: String, componentIds: Number[]}} data
* @return {Promise<string>} A promise that resolves to the token that was created.
*/
export function createLinkToken(rs, data) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs/${rs.id}/lt`, data)
.then(response => {
const token = response.data.token;
refreshLinkTokens(rs).then(() => resolve(token)).catch(reject);
})
.catch(reject);
});
}
/**
* Deletes a link token.
* @param {RailSystem} rs
* @param {Number} tokenId
*/
export function deleteLinkToken(rs, tokenId) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${rs.id}/lt/${tokenId}`)
.then(() => {
refreshLinkTokens(rs).then(resolve).catch(reject);
})
.catch(reject);
});
}

View File

@ -0,0 +1,64 @@
import axios from "axios";
import {API_URL} from "./constants";
import {refreshSomeComponents} from "./components";
/**
* Updates the connections to a path node.
* @param {RailSystem} rs The rail system to which the node belongs.
* @param {Object} node The node to update.
* @returns {Promise}
*/
export function updateConnections(rs, node) {
return new Promise((resolve, reject) => {
axios.patch(
`${API_URL}/rs/${rs.id}/c/${node.id}/connectedNodes`,
node
)
.then(response => {
node.connectedNodes = response.data.connectedNodes;
resolve();
})
.catch(reject);
});
}
/**
* Adds a connection to a path node.
* @param {RailSystem} rs
* @param {Object} node
* @param {Object} other
* @returns {Promise}
*/
export function addConnection(rs, node, other) {
node.connectedNodes.push(other);
return updateConnections(rs, node);
}
/**
* Removes a connection from a path node.
* @param {RailSystem} rs
* @param {Object} node
* @param {Object} other
* @returns {Promise}
*/
export function removeConnection(rs, node, other) {
const idx = node.connectedNodes.findIndex(n => n.id === other.id);
return new Promise((resolve, reject) => {
if (idx > -1) {
node.connectedNodes.splice(idx, 1);
updateConnections(rs, node)
.then(() => {
const nodes = [];
nodes.push(...node.connectedNodes);
nodes.push(other);
refreshSomeComponents(rs, nodes)
.then(resolve)
.catch(reject);
})
.catch(reject);
} else {
resolve();
}
});
}

View File

@ -0,0 +1,111 @@
import axios from "axios";
import {API_URL} from "./constants";
import { refreshSegments } from "src/api/segments";
import { refreshComponents } from "src/api/components";
import { closeWebsocketConnection, establishWebsocketConnection } from "src/api/websocket";
import { refreshLinkTokens } from "src/api/linkTokens";
import { refreshSettings } from "src/api/settings";
export class RailSystem {
constructor(data) {
this.id = data.id;
this.name = data.name;
this.settings = null;
this.segments = [];
this.components = [];
this.linkTokens = [];
this.websocket = null;
this.selectedComponents = [];
this.loaded = false;
}
}
export function refreshRailSystems(rsStore) {
return new Promise(resolve => {
axios.get(`${API_URL}/rs`)
.then(response => {
const rsItems = response.data;
rsStore.railSystems.length = 0;
for (let i = 0; i < rsItems.length; i++) {
rsStore.railSystems.push(new RailSystem(rsItems[i]));
}
resolve();
})
.catch(error => console.error(error));
})
}
export function refreshRailSystem(rs) {
const promises = [];
promises.push(refreshSegments(rs));
promises.push(refreshComponents(rs));
return Promise.all(promises);
}
export function createRailSystem(rsStore, name) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs`, {name: name})
.then(response => {
const newId = response.data.id;
refreshRailSystems(rsStore)
.then(() => resolve(rsStore.railSystems.find(rs => rs.id === newId)))
.catch(error => reject(error));
})
.catch(error => reject(error));
});
}
export function removeRailSystem(rsStore, id) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${id}`)
.then(() => {
if (rsStore.selectedRailSystem !== null && rsStore.selectedRailSystem.id === id) {
rsStore.selectedRailSystem = null;
}
refreshRailSystems(rsStore)
.then(resolve)
.catch(reject);
})
.catch(reject);
});
}
/**
* Loads all data for a rail system. This is generally done when a rail system
* is selected.
* @param {RailSystem} rs
*/
export async function loadData(rs) {
console.log("Loading rail system " + rs.id);
await closeWebsocketConnection(rs);
console.log("Closed websocket connection to " + rs.id);
const updatePromises = [];
updatePromises.push(refreshSegments(rs));
updatePromises.push(refreshComponents(rs));
updatePromises.push(refreshLinkTokens(rs));
updatePromises.push(refreshSettings(rs));
await Promise.all(updatePromises);
await establishWebsocketConnection(rs);
console.log("Finished loading rail system " + rs.id);
rs.loaded = true;
}
/**
* Unloads all data for a rail system. This is generally done when the user
* navigates away from a rail system's page.
* @param {RailSystem} rs
* @returns {Promise}
*/
export async function unloadData(rs) {
console.log("Unloading data for rail system " + rs.id);
await closeWebsocketConnection(rs);
rs.segments = [];
rs.components = [];
rs.linkTokens = [];
rs.selectedComponents = [];
rs.settings = null;
rs.loaded = false;
console.log("Finished unloading data for rail system " + rs.id);
}

View File

@ -0,0 +1,63 @@
import axios from "axios";
import {API_URL} from "./constants";
/**
* Fetches the set of segments for a rail system.
* @param {Number} rsId
* @returns {Promise<[Object]>}
*/
export function getSegments(rsId) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rsId}/s`)
.then(response => resolve(response.data))
.catch(error => reject(error));
});
}
export function refreshSegments(rs) {
return new Promise(resolve => {
getSegments(rs.id)
.then(segments => {
rs.segments = segments;
resolve();
})
.catch(error => console.error(error));
});
}
export function createSegment(rs, name) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs/${rs.id}/s`, {name: name})
.then(() => {
refreshSegments(rs)
.then(() => resolve())
.catch(error => reject(error));
})
.catch(error => reject(error));
});
}
export function removeSegment(rs, segmentId) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${rs.id}/s/${segmentId}`)
.then(() => {
refreshSegments(rs)
.then(() => resolve())
.catch(error => reject(error));
})
.catch(error => reject(error));
});
}
export function toggleOccupied(rs, segmentId) {
return new Promise((resolve, reject) => {
axios.patch(`${API_URL}/rs/${rs.id}/s/${segmentId}/occupied`)
.then(response => {
const updatedSegment = response.data;
const segment = rs.segments.find(s => s.id === updatedSegment.id);
segment.occupied = updatedSegment.occupied;
resolve();
})
.catch(reject);
})
}

View File

@ -0,0 +1,23 @@
import {API_URL} from "src/api/constants";
import axios from "axios";
export function refreshSettings(rs) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/settings`)
.then(response => {
rs.settings = response.data;
resolve();
})
.catch(reject);
});
}
export function updateSettings(rs, newSettings) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs/${rs.id}/settings`, newSettings)
.then(() => {
refreshSettings(rs).then(resolve).catch(reject);
})
.catch(reject);
});
}

View File

@ -0,0 +1,68 @@
import { WS_URL } from "./constants";
/**
* The time to wait before attempting to reconnect if a websocket connection is
* abruptly closed.
* @type {number}
*/
const WS_RECONNECT_TIMEOUT = 3000;
/**
* Establishes a websocket connection to the given rail system.
* @param {RailSystem} rs
* @return {Promise} A promise that resolves when a connection is established.
*/
export function establishWebsocketConnection(rs) {
if (rs.websocket) {
console.log('rail system ' + rs.id + ' already has websocket')
}
return new Promise(resolve => {
rs.websocket = new WebSocket(`${WS_URL}/${rs.id}`);
rs.websocket.onopen = resolve;
rs.websocket.onclose = event => {
if (event.code === 1000) {
console.log(`Closed websocket connection to rail system "${rs.name}" (${rs.id})`);
} else {
console.warn(`Unexpectedly lost websocket connection to rail system "${rs.name}" (${rs.id}). Attempting to reestablish in ${WS_RECONNECT_TIMEOUT} ms.`);
setTimeout(() => {
establishWebsocketConnection(rs)
.then(() => console.log("Successfully reestablished connection."));
}, WS_RECONNECT_TIMEOUT);
}
};
rs.websocket.onmessage = msg => {
const data = JSON.parse(msg.data);
console.log(data);
if (data.type === "COMPONENT_DATA") {
const id = data.cId;
const idx = rs.components.findIndex(c => c.id === id);
if (idx > -1) {
Object.assign(rs.components[idx], data.data);
}
}
};
rs.websocket.onerror = error => {
console.log(error);
};
});
}
/**
* Closes the websocket connection to a rail system, if possible.
* @param {RailSystem} rs
* @return {Promise} A promise that resolves when the connection is closed.
*/
export function closeWebsocketConnection(rs) {
return new Promise(resolve => {
if (rs.websocket && rs.websocket.readyState !== WebSocket.CLOSED) {
rs.websocket.onclose = () => {
rs.websocket = null;
resolve();
};
rs.websocket.close();
} else {
rs.websocket = null;
resolve();
}
});
}

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="icon.svg"
inkscape:export-filename="/home/andrew/Programming/github-andrewlalis/RailSignalAPI/railsignal-app/src/assets/icon.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="164.88542"
inkscape:cy="100.52253"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<rect
id="rect3713"
width="52.916668"
height="67.73333"
x="7.4083328"
y="229.26665"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.26458332"
rx="13.229167" />
<rect
rx="12.30785"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.24762905"
y="231.24725"
x="9.250967"
height="63.772137"
width="49.2314"
id="rect4520" />
<circle
style="fill:#00d900;fill-opacity:1;stroke:none;stroke-width:0.65214598"
id="path4522"
cx="33.866665"
cy="248.70078"
r="10.364463" />
<circle
r="10.364463"
cy="277.56586"
cx="33.866665"
id="circle4524"
style="fill:#e71e00;fill-opacity:1;stroke:none;stroke-width:0.65214598" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="label_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="-7.53551"
inkscape:cy="167.89676"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 23.812499,294.90224 V 240.28468"
id="path841"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect843"
width="38.364582"
height="24.095985"
x="23.812498"
y="233.40552"
rx="6.614583" />
<path
style="fill:none;stroke:#000000;stroke-width:4.23333334;stroke-linecap:round;stroke-linejoin:bevel;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 31.372024,241.57922 H 54.475819"
id="path845"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path847"
d="M 31.479768,247.91032 H 54.583563"
style="fill:none;stroke:#000000;stroke-width:4.23333311;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="segment-boundary_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="56.595604"
inkscape:cy="134.48081"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 0.70544035,242.15266 H 28.234201 v 41.96132 H 1.1063446"
id="path869"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path871"
d="M 67.027893,284.11398 H 39.499132 v -41.96132 h 27.127856"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 6.9392617,256.58521 H 60.794072"
id="path873"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path875"
d="M 6.9392617,269.68142 H 60.794072"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="signal_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="97.668938"
inkscape:cy="136.97831"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.35000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,296.90549 V 276.77826"
id="path815"
inkscape:connector-curvature="0" />
<rect
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect817"
width="33.866665"
height="43.845234"
x="16.933334"
y="233.04643"
rx="6.6145835" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path819"
cx="33.866665"
cy="245.50073"
r="5.8586307" />
<circle
r="5.8586307"
cy="262.60416"
cx="33.866665"
id="circle821"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:4.23333311;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="256"
height="256"
viewBox="0 0 67.733332 67.733335"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="switch_icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.979899"
inkscape:cx="34.945838"
inkscape:cy="171.1685"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:pagecheckerboard="true"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-229.26665)">
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,291.12006 V 259.31498"
id="path838"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path840"
d="M 33.866667,259.31498 56.356255,236.8254"
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
style="fill:none;stroke:#000000;stroke-width:6.3499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 33.866667,259.31498 11.377087,236.8254"
id="path842"
inkscape:connector-curvature="0" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path844"
cx="13.554182"
cy="238.06705"
r="7.7508163" />
<circle
r="7.7508163"
cy="238.06705"
cx="54.713692"
id="circle846"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:6.3499999;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="circle848"
cx="33.866665"
cy="287.79529"
r="7.7508163" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

View File

@ -0,0 +1,24 @@
import { boot } from 'quasar/wrappers'
import axios from 'axios'
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
const api = axios.create({ baseURL: 'https://api.example.com' })
export default boot(({ app }) => {
// for use inside Vue files (Options API) through this.$axios and this.$api
app.config.globalProperties.$axios = axios
// ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
// so you won't necessarily have to import axios in each vue file
app.config.globalProperties.$api = api
// ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
// so you can easily perform requests against your app's API
})
export { api }

View File

@ -0,0 +1,24 @@
<template>
<div class="row">
<div class="col-md-6 q-pa-md">
<div class="text-h4">{{title}}</div>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: "IndexPageSection",
props: {
title: {
type: String,
required: true
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,62 @@
<template>
<q-item
clickable
v-ripple
:to="'/rail-systems/' + railSystem.id"
>
<q-item-section>
<q-item-label>{{railSystem.name}}</q-item-label>
</q-item-section>
<q-item-section top side>
<q-btn size="12px" flat dense round icon="delete" @click.prevent="deleteRs"/>
</q-item-section>
</q-item>
</template>
<script>
import { RailSystem, removeRailSystem } from "src/api/railSystems";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { useQuasar } from "quasar";
export default {
name: "RailSystemLink",
props: {
railSystem: {
type: RailSystem,
required: true
}
},
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
methods: {
deleteRs() {
this.quasar.dialog({
title: "Confirm Removal",
message: "Are you sure you want to remove this rail system? All associated data will be deleted, permanently.",
cancel: true
}).onOk(() => {
removeRailSystem(this.rsStore, this.railSystem.id)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Rail system removed."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
});
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,71 @@
<template>
<q-list>
<q-item-label header>Rail Systems</q-item-label>
<rail-system-link
v-for="rs in railSystems"
:key="rs.id"
:rail-system="rs"
/>
<q-item>
<q-input
dense
type="text"
label="Name"
v-model="addRailSystemName"
/>
<q-btn label="Add" color="primary" @click="create"></q-btn>
</q-item>
</q-list>
</template>
<script>
import RailSystemLink from "components/RailSystemLink.vue";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { createRailSystem } from "src/api/railSystems";
import { useQuasar } from "quasar";
export default {
name: "RailSystemsList",
components: { RailSystemLink },
props: {
railSystems: {
type: Array,
required: true
}
},
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
data() {
return {
addRailSystemName: ""
}
},
methods: {
create() {
createRailSystem(this.rsStore, this.addRailSystemName)
.then(() => {
this.addRailSystemName = "";
this.quasar.notify({
color: "positive",
message: "Rail system created."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,48 @@
<template>
<q-card>
<q-form>
<q-input
label="Name"
type="text"
v-model="component.name"
/>
<q-input
label="X"
type="number"
v-model="component.position.x"
/>
<q-input
label="Y"
type="number"
v-model="component.position.y"
/>
<q-input
label="Z"
type="number"
v-model="component.position.z"
/>
</q-form>
</q-card>
</template>
<script>
export default {
name: "AddComponentDialog",
data() {
return {
component: {
name: "",
position: {
x: 0,
y: 0,
z: 0
}
}
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,78 @@
<template>
<div class="row">
<div class="col-md-8" id="railSystemMapCanvasContainer">
<canvas id="railSystemMapCanvas" height="700">
Your browser doesn't support canvas.
</canvas>
</div>
<q-scroll-area class="col-md-4">
<div class="row" v-for="component in railSystem.selectedComponents" :key="component.id">
<div class="col full-width">
<selected-component-view :component="component"/>
</div>
</div>
<add-component-form v-if="addComponent.visible" :rail-system="railSystem" @created="addComponent.visible = false"/>
</q-scroll-area>
</div>
<q-page-sticky position="bottom-right" :offset="[25, 25]">
<q-fab icon="add" color="accent" v-model="addComponent.visible"/>
</q-page-sticky>
</template>
<script>
import {RailSystem} from "src/api/railSystems";
import {draw, initMap} from "src/map/mapRenderer";
import SelectedComponentView from "components/rs/SelectedComponentView.vue";
import {useQuasar} from "quasar";
import AddComponentForm from "components/rs/add_component/AddComponentForm.vue";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { registerComponentSelectionListener } from "src/map/mapEventListener";
export default {
name: "MapView",
components: { AddComponentForm, SelectedComponentView },
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {quasar, rsStore};
},
props: {
railSystem: {
type: RailSystem,
required: true
}
},
data() {
return {
addComponent: {
visible: false
}
}
},
mounted() {
initMap(this.railSystem);
registerComponentSelectionListener("addComponentFormHide", () => this.addComponent.visible = false);
},
updated() {
initMap(this.railSystem);
registerComponentSelectionListener("addComponentFormHide", () => this.addComponent.visible = false);
},
watch: {
railSystem: {
handler() {
draw();
},
deep: true
},
'addComponent.visible'(newValue) { // Deselect all components when the user opens the "Add Component" form.
if (newValue === true) {
this.rsStore.selectedRailSystem.selectedComponents.length = 0;
}
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,27 @@
<template>
<q-item clickable v-ripple>
<q-item-section>
<q-item-label>{{segment.name}}</q-item-label>
<q-item-label caption>Id: {{segment.id}}</q-item-label>
</q-item-section>
<q-item-section side v-if="segment.occupied">
<q-chip>Occupied</q-chip>
</q-item-section>
</q-item>
</template>
<script>
export default {
name: "SegmentListItem",
props: {
segment: {
type: Object,
required: true
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,155 @@
<template>
<div class="flex">
<div class="row full-width">
<div class="col-md-6">
<q-list>
<q-item
v-for="segment in railSystem.segments"
:key="segment.id"
clickable
v-ripple
>
<q-item-section>
<q-item-label>{{segment.name}}</q-item-label>
<q-item-label caption>Id: {{segment.id}}</q-item-label>
</q-item-section>
<q-item-section v-if="segment.occupied" side>
<q-chip label="Occupied"/>
</q-item-section>
<q-menu touch-position context-menu>
<q-list dense style="min-width: 100px">
<q-item clickable v-close-popup @click="toggleOccupiedInline(segment)">
<q-item-section>Toggle Occupied</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="remove(segment)">
<q-item-section>Delete</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-item>
</q-list>
</div>
<div class="col-md-6">
</div>
</div>
</div>
<q-page-sticky position="bottom-right" :offset="[18, 18]">
<q-btn fab icon="add" color="accent" @click="openAddSegmentDialog"></q-btn>
</q-page-sticky>
<q-dialog v-model="showAddSegmentDialog" persistent>
<q-card style="min-width: 400px">
<q-form @submit="onSubmit" @reset="onReset">
<q-card-section>
<div class="text-h6">Add Segment</div>
<p>
Add a new segment to the rail system. A segment can be thought of as
the basic building block of any rail system, and segments define a
section of rails that trains can travel in and out of, usually one at
a time.
</p>
</q-card-section>
<q-card-section>
<q-input
v-model="segmentName"
autofocus
label="Segment Name"
:rules="[value => value && value.length > 0]"
lazy-rules
type="text"
/>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" type="reset" @click="showAddSegmentDialog = false"/>
<q-btn flat label="Add Segment" type="submit"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</template>
<script>
import { useRailSystemsStore } from "stores/railSystemsStore";
import { RailSystem } from "src/api/railSystems";
import { useQuasar } from "quasar";
import { createSegment, removeSegment, toggleOccupied } from "src/api/segments";
import { ref } from "vue";
export default {
name: "SegmentsView",
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
props: {
railSystem: {
type: RailSystem,
required: true
}
},
data() {
return {
showAddSegmentDialog: false,
segmentName: ""
}
},
methods: {
openAddSegmentDialog() {
this.showAddSegmentDialog = true;
},
onSubmit() {
createSegment(this.railSystem, this.segmentName)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Segment created."
});
this.onReset();
this.showAddSegmentDialog = false;
})
.catch(error => {
console.log(error);
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
onReset() {
this.segmentName = "";
},
toggleOccupiedInline(segment) {
toggleOccupied(this.rsStore.selectedRailSystem, segment.id)
},
remove(segment) {
this.quasar.dialog({
title: "Confirm",
message: "Are you sure you want to remove this segment? This will remove any connected components, and it cannot be undone.",
cancel: true,
persistent: true
}).onOk(() => {
removeSegment(this.railSystem, segment.id)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Segment " + segment.name + " has been removed."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
});
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,42 @@
<template>
<label-component-view
:label="component"
v-if="component.type === 'LABEL'"
/>
<signal-component-view
:signal="component"
v-if="component.type === 'SIGNAL'"
/>
<segment-boundary-component-view
:segment-boundary="component"
v-if="component.type === 'SEGMENT_BOUNDARY'"
/>
<switch-component-view
:sw="component"
v-if="component.type === 'SWITCH'"
/>
</template>
<script>
import SignalComponentView from "components/rs/component_views/SignalComponentView.vue";
import SegmentBoundaryComponentView from "components/rs/component_views/SegmentBoundaryComponentView.vue";
import LabelComponentView from "components/rs/component_views/LabelComponentView.vue";
import SwitchComponentView from "components/rs/component_views/SwitchComponentView.vue";
export default {
name: "SelectedComponentView",
components: {
SwitchComponentView,
LabelComponentView, SegmentBoundaryComponentView, SignalComponentView },
props: {
component: {
type: Object,
required: true
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,92 @@
<template>
<div class="row">
<div class="col-sm-4">
<q-list>
<q-item-label header>General Settings</q-item-label>
<q-item>
<q-item-section>
<q-item-label>Read Only</q-item-label>
<q-item-label caption>Freeze this rail system and prevent all updates.</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="settings.readOnly"/>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>Authentication Required</q-item-label>
<q-item-label caption>Require users to login to view and manage the system.</q-item-label>
</q-item-section>
<q-item-section side>
<q-toggle v-model="settings.authenticationRequired"/>
</q-item-section>
</q-item>
<q-separator/>
<link-tokens-view :rail-system="railSystem"/>
</q-list>
</div>
</div>
</template>
<script>
import { RailSystem } from "src/api/railSystems";
import LinkTokensView from "components/rs/settings/LinkTokensView.vue";
import { useQuasar } from "quasar";
import { updateSettings } from "src/api/settings";
export default {
name: "SettingsView",
components: { LinkTokensView },
props: {
railSystem: {
type: RailSystem,
required: true
}
},
setup() {
const quasar = useQuasar();
return {quasar};
},
data() {
return {
settings: {
readOnly: this.railSystem.settings.readOnly,
authenticationRequired: this.railSystem.settings.authenticationRequired
}
}
},
watch: {
settings: {
handler(newValue, oldValue) {
this.update();
},
deep: true
}
},
methods: {
update() {
updateSettings(this.railSystem, this.settings)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Settings have been updated.",
closeBtn: true
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "Settings could not be updated: " + error.response.data.message
});
});
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,120 @@
<template>
<q-dialog v-model="componentData.toggle" style="min-width: 400px" @hide="onReset">
<q-card>
<q-form @submit="onSubmit" @reset="onReset" @change="onChange">
<q-card-section>
<div class="text-h6">{{title}}</div>
<slot name="subtitle"></slot>
</q-card-section>
<q-card-section>
<q-input
label="Name"
type="text"
v-model="componentData.name"
autofocus
/>
<div class="row">
<q-input
label="X"
type="number"
class="col-sm-4"
v-model="componentData.position.x"
/>
<q-input
label="Y"
type="number"
class="col-sm-4"
v-model="componentData.position.y"
/>
<q-input
label="Z"
type="number"
class="col-sm-4"
v-model="componentData.position.z"
/>
</div>
</q-card-section>
<slot :component-data="componentData"></slot>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" type="reset" @click="componentData.toggle = false"/>
<q-btn flat label="Add" type="submit"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</template>
<script>
import { createComponent } from "src/api/components";
import { useQuasar } from "quasar";
import { RailSystem } from "src/api/railSystems";
export default {
name: "AddComponentDialog",
props: {
modelValue: {
type: Object,
required: true
},
type: {
type: String,
required: true
},
railSystem: {
type: RailSystem,
required: true
},
title: {
type: String,
required: true
},
successMessage: {
type: String,
required: true
}
},
setup() {
const quasar = useQuasar();
return {quasar};
},
data() {
return {
componentData: this.modelValue
}
},
methods: {
onSubmit() {
const postData = {type: this.type, ...this.componentData};
createComponent(this.railSystem, postData)
.then(() => {
this.quasar.notify({
color: "positive",
message: this.successMessage
});
this.componentData.toggle = false;
})
.catch(error => {
console.log(error);
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
onReset() {
this.componentData.name = "";
this.componentData.position.x = 0;
this.componentData.position.y = 0;
this.componentData.position.z = 0;
this.$emit("reset");
},
onChange() {
this.$emit("update:modelValue", this.componentData);
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,293 @@
<template>
<div class="row q-pa-md">
<div class="col full-width">
<q-form>
<div class="text-h4">Add Component</div>
<p>
Add a new component to the rail system.
</p>
<!-- Basic Attributes -->
<div class="row">
<div class="col full-width">
<q-input label="Name" type="text" v-model="component.name" autofocus/>
</div>
</div>
<div class="row">
<div class="col">
<q-input
label="X"
type="number"
class="col-sm-4"
v-model="component.position.x"
/>
</div>
<div class="col">
<q-input
label="Y"
type="number"
class="col-sm-4"
v-model="component.position.y"
/>
</div>
<div class="col">
<q-input
label="Z"
type="number"
class="col-sm-4"
v-model="component.position.z"
/>
</div>
</div>
<div class="row">
<div class="col">
<q-select
v-model="component.type"
:options="typeOptions"
option-value="value"
option-label="label"
emit-value
map-options
label="Type"
/>
</div>
</div>
<!-- Signal Attributes -->
<div v-if="component.type === 'SIGNAL'" class="q-mt-md">
<div class="row">
<div class="col">
<q-select
v-model="signal.segment"
:options="railSystem.segments"
option-value="id"
option-label="name"
label="Segment"
/>
</div>
</div>
</div>
<!-- Label Attributes -->
<div v-if="component.type === 'LABEL'" class="q-mt-md">
<div class="row">
<div class="col">
<q-input
v-model="label.text"
type="text"
label="Text"
/>
</div>
</div>
</div>
<!-- Segment Boundary Attributes -->
<div v-if="component.type === 'SEGMENT_BOUNDARY'" class="q-mt-md">
<div class="row">
<div class="col">
<q-select
v-model="segmentBoundary.segments"
:options="railSystem.segments"
:option-value="segment => segment"
:option-label="segment => segment.name"
use-chips
stack-label
label="Segments"
multiple
:max-values="2"
/>
</div>
</div>
</div>
<!-- Switch Attributes -->
<div v-if="component.type === 'SWITCH'" class="q-mt-md">
<div class="row">
<div class="col">
<q-list>
<q-item
v-for="config in switchData.possibleConfigurations"
:key="config.key"
>
<q-item-section>
<q-item-label class="q-gutter-sm">
<q-chip
v-for="node in config.nodes"
:key="node.id"
:label="node.name"
dense
size="sm"
/>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn size="12px" flat dense round icon="delete" @click="removeSwitchConfig(config)"/>
</q-item-section>
</q-item>
</q-list>
</div>
</div>
<div class="row">
<div class="col-sm-6">
<q-select
v-model="switchData.configNode1"
:options="getEligibleSwitchConfigNodes(switchData.configNode2)"
:option-value="node => node"
:option-label="node => node.name"
label="First Node"
/>
</div>
<div class="col-sm-6">
<q-select
v-model="switchData.configNode2"
:options="getEligibleSwitchConfigNodes(switchData.configNode1)"
:option-value="node => node"
:option-label="node => node.name"
label="Second Node"
/>
</div>
</div>
<div class="row">
<q-btn label="Add Configuration" @click="addSwitchConfig" v-if="canAddSwitchConfig"/>
</div>
</div>
<div class="row q-mt-md">
<div class="col">
<q-btn color="primary" label="Add" @click="submit" :disable="!canAdd()"/>
</div>
</div>
</q-form>
</div>
</div>
</template>
<script>
import { RailSystem } from "src/api/railSystems";
import { useQuasar } from "quasar";
import { createComponent } from "src/api/components";
export default {
name: "AddComponentForm",
props: {
railSystem: {
type: RailSystem,
required: true
}
},
setup() {
const typeOptions = [
{label: "Signal", value: "SIGNAL"},
{label: "Segment Boundary", value: "SEGMENT_BOUNDARY"},
{label: "Switch", value: "SWITCH"},
{label: "Label", value: "LABEL"}
];
const quasar = useQuasar();
return {
typeOptions,
quasar
};
},
data() {
return {
component: {
name: "",
position: {x: 0, y: 0, z: 0},
type: null
},
signal: {
segment: null
},
label: {
text: ""
},
segmentBoundary: {
segments: []
},
switchData: {
possibleConfigurations: [],
configNode1: null,
configNode2: null
}
}
},
methods: {
submit() {
const data = this.component;
if (this.component.type === 'SIGNAL') {
Object.assign(data, this.signal);
}
if (this.component.type === 'LABEL') {
Object.assign(data, this.label);
}
if (this.component.type === 'SEGMENT_BOUNDARY') {
Object.assign(data, this.segmentBoundary);
}
if (this.component.type === 'SWITCH') {
Object.assign(data, this.switchData);
}
createComponent(this.railSystem, data)
.then(() => {
this.$emit('created');
this.quasar.notify({
color: "positive",
message: "Added component: " + data.name
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
canAdd() {
if (this.component.type === null || this.component.name.length < 1) return false;
if (this.component.type === 'SIGNAL') {
return this.signal.segment !== null;
}
if (this.component.type === 'LABEL') {
return this.label.text.length > 0;
}
if (this.component.type === 'SEGMENT_BOUNDARY') {
return this.segmentBoundary.segments.length > 0;
}
return true;
},
getEligibleSwitchConfigNodes(excludedNode) {
return this.railSystem.components.filter(c => {
return (c.connectedNodes !== undefined && c.connectedNodes !== null) &&
(excludedNode === null || c.id !== excludedNode.id);
})
},
removeSwitchConfig(config) {
const idx = this.switchData.possibleConfigurations.findIndex(cfg => cfg.key === config.key);
if (idx > -1) {
this.switchData.possibleConfigurations.splice(idx, 1);
}
},
canAddSwitchConfig() {
const n1 = this.switchData.configNode1;
const n2 = this.switchData.configNode2;
return n1 !== null && n2 !== null && n1.id !== n2.id &&
!this.switchData.possibleConfigurations.some(config => {
return config.nodes.every(node => {
return node.id === n1.id || node.id === n2.id;
});
});
},
addSwitchConfig() {
this.switchData.possibleConfigurations.push({
nodes: [this.switchData.configNode1, this.switchData.configNode2],
key: this.switchData.configNode1.id + '_' + this.switchData.configNode2.id
});
this.switchData.configNode1 = null;
this.switchData.configNode2 = null;
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,68 @@
<template>
<add-component-dialog
v-model="addSignalData"
type="SIGNAL"
:rail-system="railSystem"
title="Add Signal"
success-message="Signal added."
@reset="onReset"
>
<template #subtitle>
<p>
Add a signal to the rail system.
</p>
</template>
<template #default>
<q-card-section>
<q-select
v-model="addSignalData.segment"
:options="railSystem.segments"
option-value="id"
option-label="name"
label="Segment"
/>
</q-card-section>
</template>
</add-component-dialog>
</template>
<script>
import { RailSystem } from "src/api/railSystems";
import AddComponentDialog from "components/rs/add_component/AddComponentDialog.vue";
export default {
name: "AddSignalDialog",
components: {AddComponentDialog},
props: {
railSystem: {
type: RailSystem,
required: true
},
modelValue: {
type: Boolean,
required: true
}
},
data() {
return {
addSignalData: {
name: "",
position: {
x: 0, y: 0, z: 0
},
segment: null,
toggle: this.modelValue
}
}
},
methods: {
onReset() {
this.addSignalData.segment = null;
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,94 @@
<template>
<div class="q-pa-md">
<div class="text-h6">{{component.name}}</div>
<q-list bordered>
<q-item clickable>
<q-item-section>
<q-item-label>{{component.type}}</q-item-label>
<q-item-label caption>Id: {{component.id}}</q-item-label>
</q-item-section>
<q-item-section v-if="component.online === true" top side>
<q-chip color="positive" text-color="white">Online</q-chip>
</q-item-section>
<q-item-section v-if="component.online === false" top side>
<q-chip color="negative" text-color="white">Offline</q-chip>
</q-item-section>
</q-item>
<q-item clickable>
<q-item-section>
<q-item-label>Position</q-item-label>
<q-item-label caption>
X: {{component.position.x}},
Y: {{component.position.y}},
Z: {{component.position.z}}
</q-item-label>
</q-item-section>
</q-item>
<q-separator/>
<slot></slot>
<q-item>
<q-item-section side>
<q-btn color="negative" label="Remove" size="sm" @click="remove(component)"/>
</q-item-section>
</q-item>
</q-list>
</div>
</template>
<script>
import {removeComponent} from "src/api/components";
import {useQuasar} from "quasar";
import {useRailSystemsStore} from "stores/railSystemsStore";
export default {
name: "BaseComponentView",
props: {
component: {
type: Object,
required: true
}
},
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
methods: {
select(component) {
const c = this.rsStore.selectedRailSystem.components.find(cp => cp.id === component.id);
if (c) {
this.rsStore.selectedRailSystem.selectedComponents.length = 0;
this.rsStore.selectedRailSystem.selectedComponents.push(c);
}
},
remove(component) {
this.quasar.dialog({
title: "Confirm Removal",
message: "Are you sure you want to remove this component? This cannot be undone.",
cancel: true
}).onOk(() => {
removeComponent(this.rsStore.selectedRailSystem, component.id)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Component has been removed."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,28 @@
<template>
<base-component-view :component="label">
<q-item-label header>Label</q-item-label>
<q-item>
<q-item-section>
<q-item-label>{{label.text}}</q-item-label>
</q-item-section>
</q-item>
</base-component-view>
</template>
<script>
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
export default {
name: "LabelComponentView",
components: {BaseComponentView},
props: {
label: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,125 @@
<template>
<q-item-label header>Connected Path Nodes</q-item-label>
<q-item>
<q-item-section>
<q-item-label class="q-gutter-sm">
<q-chip
v-for="node in pathNode.connectedNodes"
:key="node.id"
dense
size="sm"
:label="node.name"
/>
</q-item-label>
</q-item-section>
</q-item>
<q-item dense v-if="editable">
<q-btn size="sm" color="accent" label="Edit Connected Nodes" @click="showDialog"/>
<q-dialog v-model="dialog.visible" style="min-width: 400px" @hide="reset">
<q-card>
<q-form @submit="onSubmit" @reset="reset">
<q-card-section>
<div class="text-h6">Edit Connected Nodes</div>
<p>
Update the nodes that this path node is connected to. These
connections define how the system understands your rail network,
and are used for routing and analytics.
</p>
<p v-if="pathNode.type ==='SEGMENT_BOUNDARY'">
You're editing a <strong>segment boundary</strong> node. This
means that you can only have at most <strong>2</strong> nodes
connected to it.
</p>
</q-card-section>
<q-card-section>
<q-select
v-model="dialog.selectedNodes"
:options="getEligibleNodes()"
multiple
:option-value="node => node"
:option-label="node => node.name"
use-chips
stack-label
label="Nodes"
/>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" type="reset" @click="dialog.visible = false"/>
<q-btn flat label="Edit" type="submit"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</q-item>
</template>
<script>
import {useRailSystemsStore} from "stores/railSystemsStore";
import {useQuasar} from "quasar";
import {updateComponent} from "src/api/components";
export default {
name: "PathNodeItem",
props: {
pathNode: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false
}
},
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
data() {
return {
dialog: {
visible: false,
selectedNodes: []
}
}
},
methods: {
showDialog() {
this.dialog.selectedNodes = this.pathNode.connectedNodes.slice();
this.dialog.visible = true;
},
reset() {
this.dialog.selectedNodes.length = 0;
},
onSubmit() {
const data = {...this.pathNode};
data.connectedNodes = this.dialog.selectedNodes;
updateComponent(this.rsStore.selectedRailSystem, data)
.then(() => {
this.dialog.visible = false;
this.quasar.notify({
color: "positive",
message: "Path node updated."
});
})
.catch(error => {
console.log(error);
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
getEligibleNodes() {
return this.rsStore.selectedRailSystem.components.filter(c => {
return (c.connectedNodes !== null && c.connectedNodes !== undefined) &&
this.dialog.selectedNodes.findIndex(node => node.id === c.id) === -1;
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,118 @@
<template>
<base-component-view :component="segmentBoundary">
<q-item-label header>Connected Segments</q-item-label>
<q-item>
<q-item-section
v-for="segment in segmentBoundary.segments"
:key="segment.id"
>
<q-item-label>{{segment.name}}</q-item-label>
<q-item-label caption>ID: {{segment.id}}</q-item-label>
</q-item-section>
</q-item>
<q-item dense>
<q-btn size="sm" color="accent" label="Edit Segments" @click="showDialog"/>
<q-dialog v-model="dialog.visible" style="max-width: 400px" @hide="reset">
<q-card>
<q-form @submit="onSubmit" @reset="reset">
<q-card-section>
<div class="text-h6">Edit Segments</div>
<p>
Update the segments that this boundary joins.
</p>
</q-card-section>
<q-card-section>
<q-select
v-model="dialog.segments"
:options="rsStore.selectedRailSystem.segments"
multiple
:option-value="s => s"
:option-label="s => s.name"
use-chips
stack-label
label="Segments"
max-values="2"
/>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" type="reset" @click="dialog.visible = false"/>
<q-btn flat label="Edit" type="submit"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</q-item>
<q-separator/>
<path-node-item :path-node="segmentBoundary" :editable="true"/>
</base-component-view>
</template>
<script>
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
import PathNodeItem from "components/rs/component_views/PathNodeItem.vue";
import { useQuasar } from "quasar";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { updateComponent } from "src/api/components";
export default {
name: "SegmentBoundaryComponentView",
components: {PathNodeItem, BaseComponentView},
props: {
segmentBoundary: {
type: Object,
required: true
}
},
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
data() {
return {
dialog: {
visible: false,
segments: []
}
}
},
methods: {
showDialog() {
this.dialog.segments = this.segmentBoundary.segments.slice();
this.dialog.visible = true;
},
reset() {
this.dialog.segments.length = 0;
},
onSubmit() {
if (this.dialog.segments.length > 2) {
this.quasar.notify({
color: "warning",
message: "Segment boundaries can only join 2 adjacent segments."
});
return;
}
const data = {...this.segmentBoundary};
data.segments = this.dialog.segments;
updateComponent(this.rsStore.selectedRailSystem, data)
.then(() => {
this.dialog.visible = false;
this.quasar.notify({
color: "positive",
message: "Segments updated."
});
})
.catch(error => {
console.log(error);
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,25 @@
<template>
<base-component-view :component="signal">
<q-item-label header>Connected to Segment</q-item-label>
<segment-list-item :segment="signal.segment"/>
</base-component-view>
</template>
<script>
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
import SegmentListItem from "components/rs/SegmentListItem.vue";
export default {
name: "SignalComponentView",
components: {SegmentListItem, BaseComponentView},
props: {
signal: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,210 @@
<template>
<base-component-view :component="sw">
<q-item-label header>Switch Configurations</q-item-label>
<q-list>
<q-item
v-for="config in sw.possibleConfigurations"
:key="config.id"
dense
>
<q-item-section>
<q-item-label class="q-gutter-sm">
<q-chip
v-for="node in config.nodes"
:key="node.id"
dense
size="sm"
:label="node.name"
/>
</q-item-label>
</q-item-section>
<q-item-section v-if="!isConfigActive(config)" side top>
<q-btn dense size="sm" color="positive" @click="setActiveSwitchConfig(config.id)">Set Active</q-btn>
</q-item-section>
</q-item>
<q-item>
<q-btn size="sm" color="accent" label="Edit Configurations" @click="showDialog"/>
<q-dialog v-model="dialog.visible" style="min-width: 400px" @hide="reset">
<q-card>
<q-form @submit="onSubmit" @reset="reset">
<q-card-section>
<div class="text-h6">Edit Switch Configurations</div>
<p>
Edit the possible configurations for this switch.
</p>
</q-card-section>
<q-card-section>
<div class="row">
<q-list>
<q-item
v-for="config in dialog.configurations"
:key="config.key"
>
<q-item-section>
<q-item-label>
<q-chip
v-for="node in config.nodes"
:key="node.id"
:label="node.name"
/>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn size="12px" flat dense round icon="delete" @click="removeConfig(config)"/>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="row">
<div class="col-sm-6">
<q-select
v-model="dialog.pathNode1"
:options="getEligibleSwitchNodes(dialog.pathNode2)"
:option-value="node => node"
:option-label="node => node.name"
label="First Path Node"
/>
</div>
<div class="col-sm-6">
<q-select
v-model="dialog.pathNode2"
:options="getEligibleSwitchNodes(dialog.pathNode1)"
:option-value="node => node"
:option-label="node => node.name"
label="Second Path Node"
/>
</div>
</div>
<div class="row">
<q-btn label="Add Configuration" @click="addConfiguration" v-if="canAddConfig(dialog.pathNode1, dialog.pathNode2)"/>
</div>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Cancel" type="reset" @click="dialog.visible = false"/>
<q-btn flat label="Edit" type="submit"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</q-item>
</q-list>
<q-separator/>
<path-node-item :path-node="sw"/>
</base-component-view>
</template>
<script>
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
import {updateComponent, updateSwitchConfiguration} from "src/api/components";
import PathNodeItem from "components/rs/component_views/PathNodeItem.vue";
import {useRailSystemsStore} from "stores/railSystemsStore";
import {useQuasar} from "quasar";
export default {
name: "SwitchComponentView",
components: {PathNodeItem, BaseComponentView},
props: {
sw: {
type: Object,
required: true
}
},
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {rsStore, quasar};
},
data() {
return {
dialog: {
visible: false,
configurations: [],
pathNode1: null,
pathNode2: null
}
}
},
methods: {
setActiveSwitchConfig(configId) {
updateSwitchConfiguration(this.rsStore.selectedRailSystem, this.sw, configId)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Sent switch configuration update request."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
isConfigActive(config) {
return this.sw.activeConfiguration !== null && this.sw.activeConfiguration.id === config.id;
},
showDialog() {
this.dialog.configurations = this.sw.possibleConfigurations.slice();
this.dialog.configurations.forEach(cfg => {
cfg.key = cfg.nodes[0].id + '_' + cfg.nodes[1].id
});
this.dialog.visible = true;
},
reset() {
this.dialog.pathNode1 = null;
this.dialog.pathNode2 = null;
this.dialog.configurations.length = 0;
},
onSubmit() {
const data = {...this.sw};
data.possibleConfigurations = this.dialog.configurations;
data.activeConfiguration = null;
updateComponent(this.rsStore.selectedRailSystem, data)
.then(() => {
this.dialog.visible = false;
this.quasar.notify({
color: "positive",
message: "Switch configurations updated."
});
})
.catch(error => {
console.log(error);
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
getEligibleSwitchNodes(exclude) {
return this.rsStore.selectedRailSystem.components.filter(c => {
return (c.connectedNodes !== undefined && c.connectedNodes !== null) &&
(exclude === null || c.id !== exclude.id);
});
},
canAddConfig(n1, n2) {
return this.dialog.configurations.length < 2 &&
n1 !== null && n2 !== null &&
n1.id !== n2.id &&
!this.dialog.configurations.some(config => {
return config.nodes.every(node => {
return node.id === n1.id || node.id === n2.id;
});
});
},
addConfiguration() {
this.dialog.configurations.push({
nodes: [this.dialog.pathNode1, this.dialog.pathNode2],
key: this.dialog.pathNode1.id + '_' + this.dialog.pathNode2.id
});
},
removeConfig(config) {
const idx = this.dialog.configurations.findIndex(cfg => cfg.key === config.key);
if (idx === -1) return;
this.dialog.configurations.splice(idx, 1);
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,186 @@
<template>
<q-expansion-item
expand-separator
label="Component Links"
caption="Link components to your system."
switch-toggle-side
:content-inset-level="0.5"
>
<q-list>
<q-item
v-for="token in railSystem.linkTokens"
:key="token.id"
>
<q-item-section top>
<q-item-label>{{token.label}}</q-item-label>
<q-item-label caption>{{token.id}}</q-item-label>
</q-item-section>
<q-item-section top>
<q-item-label caption>Components</q-item-label>
<q-item-label>
<q-chip
v-for="component in token.components"
:key="component.id"
:label="component.name"
dense
size="sm"
/>
</q-item-label>
</q-item-section>
<q-item-section top side>
<q-btn size="12px" flat dense round icon="delete" @click="deleteToken(token)"/>
</q-item-section>
</q-item>
<q-item v-if="railSystem.linkTokens.length === 0">
<q-item-section>
<q-item-label caption>There are no link tokens. Add one via the <em>Add Link</em> button below.</q-item-label>
</q-item-section>
</q-item>
<q-separator/>
<q-item>
<q-item-section side>
<q-btn label="Add Link" color="primary" @click="addTokenDialog = true" />
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<q-dialog v-model="addTokenDialog" style="min-width: 400px;" @hide="onReset">
<q-card>
<q-form @submit="onSubmit" @reset="onReset">
<q-card-section>
<div class="text-h6">Add Link Token</div>
<p>
Create a new link token that gives external components access to
this rail system's live data streams. Use a link token to let signals
in your system get real-time updates, and to let segment boundaries
send real-time status updates as trains move.
</p>
<p>
You will be shown the link token <strong>only once</strong> when the
token is first created. If you lose it, you will need to delete the
token and create a new one.
</p>
</q-card-section>
<q-card-section>
<q-input
label="Label"
type="text"
v-model="addTokenData.label"
autofocus
/>
<q-select
label="Components"
multiple
v-model="addTokenData.selectedComponents"
:options="getEligibleComponents()"
:option-value="component => component.id"
:option-label="component => component.name"
use-chips
stack-label
/>
</q-card-section>
<q-card-section v-if="token">
<q-banner class="bg-primary text-white">
<p>
Link token created: <code class="text-bold">{{token}}</code>
</p>
<p>
Copy this token now; it won't be shown again.
</p>
</q-banner>
</q-card-section>
<q-card-actions align="right" class="text-primary">
<q-btn flat label="Close" type="reset" @click="addTokenDialog = false"/>
<q-btn flat label="Add" type="submit" v-if="!token"/>
</q-card-actions>
</q-form>
</q-card>
</q-dialog>
</template>
<script>
import { RailSystem } from "src/api/railSystems";
import { createLinkToken, deleteLinkToken } from "src/api/linkTokens";
import { useQuasar } from "quasar";
export default {
name: "LinkTokensView",
props: {
railSystem: {
type: RailSystem,
required: true
}
},
setup() {
const quasar = useQuasar();
return {quasar};
},
data() {
return {
addTokenDialog: false,
addTokenData: {
label: "",
selectedComponents: [],
componentIds: []
},
token: null
};
},
methods: {
getEligibleComponents() {
return this.railSystem.components.filter(component => {
return component.type === "SIGNAL" || component.type === "SEGMENT_BOUNDARY" || component.type === "SWITCH";
});
},
deleteToken(token) {
this.quasar.dialog({
title: "Confirm Removal",
message: "Are you sure you want to remove this token? All devices using it will need to be given a new token.",
cancel: true
}).onOk(() => {
deleteLinkToken(this.railSystem, token.id)
.then(() => {
this.quasar.notify({
color: "positive",
message: "Token removed."
});
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
});
},
onSubmit() {
this.addTokenData.componentIds = this.addTokenData.selectedComponents.map(c => c.id);
createLinkToken(this.railSystem, this.addTokenData)
.then(token => {
this.token = token;
})
.catch(error => {
this.quasar.notify({
color: "negative",
message: "An error occurred: " + error.response.data.message
});
});
},
onReset() {
this.addTokenData.label = "";
this.addTokenData.selectedComponents.length = 0;
this.addTokenData.componentIds.length = 0;
this.token = null;
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1 @@
// app global css in SCSS form

View File

@ -0,0 +1,3 @@
/*# sourceMappingURL=quasar.variables.css.map */

View File

@ -0,0 +1,24 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #29733c;
$secondary : #7b9651;
$accent : #a38234;
$dark : #072e14;
$positive : #22ba64;
$negative : #a64a1c;
$info : #43a180;
$warning : #a88c40;

View File

@ -0,0 +1,73 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-btn
flat
dense
round
icon="menu"
aria-label="Menu"
@click="toggleLeftDrawer"
/>
<q-toolbar-title>
Rail Signal
<span v-if="rsStore.selectedRailSystem">
- {{rsStore.selectedRailSystem.name}}
</span>
</q-toolbar-title>
</q-toolbar>
</q-header>
<q-drawer
v-model="leftDrawerOpen"
show-if-above
bordered
>
<rail-systems-list :rail-systems="rsStore.railSystems" />
<q-item clickable v-ripple :to="'/'">
<q-item-section>
<q-item-label>Home</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple :to="'/about'">
<q-item-section>
<q-item-label>About</q-item-label>
</q-item-section>
</q-item>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>
<script>
import { defineComponent, ref } from "vue";
import RailSystemsList from "components/RailSystemsList.vue";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { refreshRailSystems } from "src/api/railSystems";
export default defineComponent({
name: 'MainLayout',
components: {
RailSystemsList
},
setup () {
const rsStore = useRailSystemsStore()
const leftDrawerOpen = ref(false)
return {
rsStore,
leftDrawerOpen,
toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value
}
}
},
created() {
refreshRailSystems(this.rsStore);
}
})
</script>

View File

@ -0,0 +1,64 @@
import { MAP_CANVAS } from "src/map/mapRenderer";
export function roundedRect(ctx, x, y, w, h, r) {
if (w < 2 * r) r = w / 2;
if (h < 2 * r) r = h / 2;
ctx.beginPath();
ctx.moveTo(x+r, y);
ctx.arcTo(x+w, y, x+w, y+h, r);
ctx.arcTo(x+w, y+h, x, y+h, r);
ctx.arcTo(x, y+h, x, y, r);
ctx.arcTo(x, y, x+w, y, r);
ctx.closePath();
}
export function circle(ctx, x, y, r) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
}
export function mulberry32(a) {
return function() {
let t = a += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
export function sortPoints(points) {
points = points.splice(0);
const p0 = {};
p0.y = Math.min.apply(null, points.map(p=>p.y));
p0.x = Math.max.apply(null, points.filter(p=>p.y === p0.y).map(p=>p.x));
points.sort((a,b)=>angleCompare(p0, a, b));
return points;
}
function angleCompare(p0, a, b) {
const left = isLeft(p0, a, b);
if (left === 0) return distCompare(p0, a, b);
return left;
}
function isLeft(p0, a, b) {
return (a.x-p0.x)*(b.y-p0.y) - (b.x-p0.x)*(a.y-p0.y);
}
function distCompare(p0, a, b) {
const distA = (p0.x-a.x)*(p0.x-a.x) + (p0.y-a.y)*(p0.y-a.y);
const distB = (p0.x-b.x)*(p0.x-b.x) + (p0.y-b.y)*(p0.y-b.y);
return distA - distB;
}
/**
* Gets the point at which the user clicked on the map.
* @param {MouseEvent} event
* @returns {DOMPoint}
*/
export function getMousePoint(event) {
const rect = MAP_CANVAS.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return new DOMPoint(x, y, 0, 1);
}

View File

@ -0,0 +1,253 @@
/*
Helper functions to actually perform rendering of different components.
*/
import { isComponentHovered, isComponentSelected, MAP_CTX, MAP_RAIL_SYSTEM } from "./mapRenderer";
import { circle, roundedRect, sortPoints } from "./canvasUtils";
import randomColor from "randomcolor";
import { camGetTransform, camScale } from "src/map/mapCamera";
export function drawMap() {
const worldTx = camGetTransform();
MAP_CTX.setTransform(worldTx);
drawSegments();
drawNodeConnections(worldTx);
drawComponents(worldTx);
}
function drawSegments() {
const segmentPoints = new Map();
// Gather for each segment a set of points representing its bounds.
MAP_RAIL_SYSTEM.segments.forEach(segment => segmentPoints.set(segment.id, []));
for (let i = 0; i < MAP_RAIL_SYSTEM.components.length; i++) {
const c = MAP_RAIL_SYSTEM.components[i];
if (c.type === "SEGMENT_BOUNDARY") {
for (let j = 0; j < c.segments.length; j++) {
segmentPoints.get(c.segments[j].id).push({ x: c.position.x, y: c.position.z });
}
}
}
// Sort the points to make regular convex polygons.
for (let i = 0; i < MAP_RAIL_SYSTEM.segments.length; i++) {
const unsortedPoints = segmentPoints.get(MAP_RAIL_SYSTEM.segments[i].id);
segmentPoints.set(MAP_RAIL_SYSTEM.segments[i].id, sortPoints(unsortedPoints));
}
for (let i = 0; i < MAP_RAIL_SYSTEM.segments.length; i++) {
const segment = MAP_RAIL_SYSTEM.segments[i];
const color = randomColor({ luminosity: "light", format: "rgb", seed: segment.id });
MAP_CTX.fillStyle = color;
MAP_CTX.strokeStyle = color;
MAP_CTX.lineWidth = 5;
MAP_CTX.lineCap = "round";
MAP_CTX.lineJoin = "round";
MAP_CTX.font = "3px Sans-Serif";
const points = segmentPoints.get(segment.id);
if (points.length === 0) continue;
const avgPoint = { x: points[0].x, y: points[0].y };
if (points.length === 1) {
circle(MAP_CTX, points[0].x, points[0].y, 5);
MAP_CTX.fill();
} else {
MAP_CTX.beginPath();
MAP_CTX.moveTo(points[0].x, points[0].y);
for (let j = 1; j < points.length; j++) {
MAP_CTX.lineTo(points[j].x, points[j].y);
avgPoint.x += points[j].x;
avgPoint.y += points[j].y;
}
avgPoint.x /= points.length;
avgPoint.y /= points.length;
MAP_CTX.lineTo(points[0].x, points[0].y);
MAP_CTX.fill();
MAP_CTX.stroke();
}
// Draw the segment name.
MAP_CTX.fillStyle = randomColor({ luminosity: "dark", format: "rgb", seed: segment.id });
MAP_CTX.fillText(segment.name, avgPoint.x, avgPoint.y);
}
}
function drawNodeConnections(worldTx) {
for (let i = 0; i < MAP_RAIL_SYSTEM.components.length; i++) {
const c = MAP_RAIL_SYSTEM.components[i];
if (c.connectedNodes !== undefined && c.connectedNodes !== null) {
drawConnectedNodes(worldTx, c);
}
}
}
function drawComponents(worldTx) {
// Draw switch configurations first
for (let i = 0; i < MAP_RAIL_SYSTEM.components.length; i++) {
const c = MAP_RAIL_SYSTEM.components[i];
if (c.type === "SWITCH") {
drawSwitchConfigurations(worldTx, c);
}
}
for (let i = 0; i < MAP_RAIL_SYSTEM.components.length; i++) {
drawComponent(worldTx, MAP_RAIL_SYSTEM.components[i]);
}
}
function drawComponent(worldTx, component) {
const componentTransform = DOMMatrix.fromMatrix(worldTx);
componentTransform.translateSelf(component.position.x, component.position.z, 0);
const s = camScale();
componentTransform.scaleSelf(1 / s, 1 / s, 1 / s);
componentTransform.scaleSelf(20, 20, 20);
MAP_CTX.setTransform(componentTransform);
if (component.type === "SIGNAL") {
drawSignal(component);
} else if (component.type === "SEGMENT_BOUNDARY") {
drawSegmentBoundary();
} else if (component.type === "SWITCH") {
drawSwitch();
} else if (component.type === "LABEL") {
drawLabel(component);
}
MAP_CTX.setTransform(componentTransform.translate(0.75, -0.75));
if (component.online !== undefined && component.online !== null) {
drawOnlineIndicator(component);
}
MAP_CTX.setTransform(componentTransform);
// Draw hovered status.
if (isComponentHovered(component) || isComponentSelected(component)) {
MAP_CTX.fillStyle = `rgba(255, 255, 0, 0.5)`;
circle(MAP_CTX, 0, 0, 0.75);
MAP_CTX.fill();
}
}
function drawSignal(signal) {
roundedRect(MAP_CTX, -0.3, -0.5, 0.6, 1, 0.25);
MAP_CTX.fillStyle = "black";
MAP_CTX.fill();
if (signal.segment) {
if (signal.segment.occupied === true) {
MAP_CTX.fillStyle = `rgb(255, 0, 0)`;
} else if (signal.segment.occupied === false) {
MAP_CTX.fillStyle = `rgb(0, 255, 0)`;
} else {
MAP_CTX.fillStyle = `rgb(255, 255, 0)`;
}
} else {
MAP_CTX.fillStyle = `rgb(0, 0, 255)`;
}
circle(MAP_CTX, 0, -0.2, 0.15);
MAP_CTX.fill();
}
function drawSegmentBoundary() {
MAP_CTX.fillStyle = `rgb(150, 58, 224)`;
MAP_CTX.beginPath();
MAP_CTX.moveTo(0, -0.5);
MAP_CTX.lineTo(-0.5, 0);
MAP_CTX.lineTo(0, 0.5);
MAP_CTX.lineTo(0.5, 0);
MAP_CTX.lineTo(0, -0.5);
MAP_CTX.fill();
}
function drawSwitchConfigurations(worldTx, sw) {
const tx = DOMMatrix.fromMatrix(worldTx);
tx.translateSelf(sw.position.x, sw.position.z, 0);
const s = camScale();
tx.scaleSelf(1 / s, 1 / s, 1 / s);
tx.scaleSelf(20, 20, 20);
MAP_CTX.setTransform(tx);
for (let i = 0; i < sw.possibleConfigurations.length; i++) {
const config = sw.possibleConfigurations[i];
MAP_CTX.strokeStyle = randomColor({
seed: config.id,
format: "rgb",
luminosity: "bright"
});
if (sw.activeConfiguration !== null && sw.activeConfiguration.id === config.id) {
MAP_CTX.lineWidth = 0.6;
} else {
MAP_CTX.lineWidth = 0.3;
}
for (let j = 0; j < config.nodes.length; j++) {
const node = config.nodes[j];
const diff = {
x: sw.position.x - node.position.x,
y: sw.position.z - node.position.z
};
const mag = Math.sqrt(Math.pow(diff.x, 2) + Math.pow(diff.y, 2));
diff.x = 3 * -diff.x / mag;
diff.y = 3 * -diff.y / mag;
MAP_CTX.beginPath();
MAP_CTX.moveTo(0, 0);
MAP_CTX.lineTo(diff.x, diff.y);
MAP_CTX.stroke();
}
}
}
function drawSwitch() {
MAP_CTX.fillStyle = `rgb(245, 188, 66)`;
MAP_CTX.strokeStyle = `rgb(245, 188, 66)`;
MAP_CTX.lineWidth = 0.2;
circle(MAP_CTX, 0, 0.3, 0.2);
MAP_CTX.fill();
circle(MAP_CTX, -0.3, -0.3, 0.2);
MAP_CTX.fill();
circle(MAP_CTX, 0.3, -0.3, 0.2);
MAP_CTX.fill();
MAP_CTX.beginPath();
MAP_CTX.moveTo(0, 0.3);
MAP_CTX.lineTo(0, 0);
MAP_CTX.lineTo(0.3, -0.3);
MAP_CTX.moveTo(0, 0);
MAP_CTX.lineTo(-0.3, -0.3);
MAP_CTX.stroke();
}
function drawLabel(lbl) {
MAP_CTX.fillStyle = "black";
circle(MAP_CTX, 0, 0, 0.1);
MAP_CTX.fill();
MAP_CTX.strokeStyle = "black";
MAP_CTX.font = "0.5px Sans-Serif";
MAP_CTX.fillText(lbl.text, 0.1, -0.2);
}
function drawOnlineIndicator(component) {
MAP_CTX.lineWidth = 0.1;
if (component.online) {
MAP_CTX.fillStyle = `rgba(52, 174, 235, 128)`;
MAP_CTX.strokeStyle = `rgba(52, 174, 235, 128)`;
} else {
MAP_CTX.fillStyle = `rgba(153, 153, 153, 128)`;
MAP_CTX.strokeStyle = `rgba(153, 153, 153, 128)`;
}
MAP_CTX.beginPath();
MAP_CTX.arc(0, 0.2, 0.125, 0, Math.PI * 2);
MAP_CTX.fill();
for (let r = 0; r < 3; r++) {
MAP_CTX.beginPath();
MAP_CTX.arc(0, 0, 0.1 + 0.2 * r, 7 * Math.PI / 6, 11 * Math.PI / 6);
MAP_CTX.stroke();
}
}
function drawConnectedNodes(worldTx, component) {
const s = camScale();
MAP_CTX.lineWidth = 5 / s;
MAP_CTX.strokeStyle = "black";
for (let i = 0; i < component.connectedNodes.length; i++) {
const node = component.connectedNodes[i];
MAP_CTX.beginPath();
MAP_CTX.moveTo(component.position.x, component.position.z);
MAP_CTX.lineTo(node.position.x, node.position.z);
MAP_CTX.stroke();
}
}

View File

@ -0,0 +1,115 @@
import { MAP_CANVAS } from "src/map/mapRenderer";
const SCALE_VALUES = [0.01, 0.1, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 30.0, 45.0, 60.0, 80.0, 100.0];
const SCALE_INDEX_NORMAL = 7;
let scaleIndex = SCALE_INDEX_NORMAL;
let translation = {x: 0, y: 0};
let panOrigin = null;
let panTranslation = null;
let lastPanPoint = null;
/**
* Resets the camera view to the default values.
*/
export function camResetView() {
scaleIndex = SCALE_INDEX_NORMAL;
translation.x = 0;
translation.y = 0;
panOrigin = null;
panTranslation = null;
lastPanPoint = null;
}
/**
* Zooms in the camera, if possible.
*/
export function camZoomIn() {
if (scaleIndex < SCALE_VALUES.length - 1) {
scaleIndex++;
}
}
/**
* Zooms out the camera, if possible.
*/
export function camZoomOut() {
if (scaleIndex > 0) {
scaleIndex--;
}
}
/**
* Gets the current zoom scale of the camera.
* @returns {number}
*/
export function camScale() {
return SCALE_VALUES[scaleIndex];
}
/**
* Gets the camera transform that's used to map world coordinates to the canvas.
* @return {DOMMatrix}
*/
export function camGetTransform() {
const tx = new DOMMatrix();
const canvasRect = MAP_CANVAS.getBoundingClientRect();
tx.translateSelf(canvasRect.width / 2, canvasRect.height / 2, 0);
const scale = SCALE_VALUES[scaleIndex];
tx.scaleSelf(scale, scale, scale);
tx.translateSelf(translation.x, translation.y, 0);
if (panOrigin && panTranslation) {
tx.translateSelf(panTranslation.x, panTranslation.y, 0);
}
return tx;
}
export function camPanStart(point) {
panOrigin = point;
panTranslation = {x: 0, y: 0};
}
export function camPanActive() {
return panOrigin !== null;
}
export function camPanNonzero() {
return panTranslation !== null && (panTranslation.x !== 0 || panTranslation.y !== 0);
}
export function camPanMove(point) {
if (panOrigin) {
lastPanPoint = point;
const scale = SCALE_VALUES[scaleIndex];
const dx = point.x - panOrigin.x;
const dy = point.y - panOrigin.y;
panTranslation = {x: dx / scale, y: dy / scale};
}
}
export function camPanFinish() {
if (panTranslation) {
translation.x += panTranslation.x;
translation.y += panTranslation.y;
}
panOrigin = null;
panTranslation = null;
}
/**
* Maps a point on the map coordinates to world coordinates.
* @param {DOMPoint} point
* @returns {DOMPoint}
*/
export function camTransformMapToWorld(point) {
return camGetTransform().invertSelf().transformPoint(point);
}
/**
* Maps a point in the world to map coordinates.
* @param {DOMPoint} point
* @returns {DOMPoint}
*/
export function camTransformWorldToMap(point) {
return camGetTransform().transformPoint(point);
}

View File

@ -0,0 +1,146 @@
import { draw, MAP_COMPONENTS_HOVERED, MAP_CANVAS, MAP_RAIL_SYSTEM } from "src/map/mapRenderer";
import {
camPanActive,
camPanFinish,
camPanMove,
camPanNonzero,
camPanStart, camResetView, camTransformWorldToMap,
camZoomIn,
camZoomOut
} from "src/map/mapCamera";
import { getMousePoint } from "src/map/canvasUtils";
const HOVER_RADIUS = 10;
export let LAST_MOUSE_POINT = null;
const SELECTION_MODE_NORMAL = 1;
const SELECTION_MODE_CHOOSE = 2;
let selectionMode = SELECTION_MODE_NORMAL;
const componentSelectionListeners = new Map();
/**
* Initializes all event listeners for the map controls.
*/
export function initListeners() {
registerListener(MAP_CANVAS, "wheel", onMouseWheel);
registerListener(MAP_CANVAS, "mousedown", onMouseDown);
registerListener(MAP_CANVAS, "mouseup", onMouseUp);
registerListener(MAP_CANVAS, "mousemove", onMouseMove);
registerListener(window, "keydown", onKeyDown);
}
function registerListener(element, type, callback) {
element.removeEventListener(type, callback);
element.addEventListener(type, callback);
}
export function registerComponentSelectionListener(name, callback) {
componentSelectionListeners.set(name, callback);
}
/**
* Handles mouse scroll wheel. This is used to control camera zoom.
* @param {WheelEvent} event
*/
function onMouseWheel(event) {
if (!event.shiftKey) return;
const s = event.deltaY;
if (s < 0) {
camZoomIn();
} else if (s > 0) {
camZoomOut();
}
draw();
event.stopPropagation();
return false;
}
/**
* Handles mouse down clicks. We only use this to start tracking panning.
* @param {MouseEvent} event
*/
function onMouseDown(event) {
const p = getMousePoint(event);
if (event.shiftKey) {
camPanStart({ x: p.x, y: p.y });
}
}
/**
* Handles mouse up events. This means we do the following:
* - Finish any camera panning, if it was active.
* - Select any components that the mouse is close enough to.
* - If no components were selected, clear the list of selected components.
* @param {MouseEvent} event
*/
function onMouseUp(event) {
if (selectionMode === SELECTION_MODE_NORMAL) {
handleNormalSelectionMouseUp(event);
}
camPanFinish();
}
/**
* Handles the mouse up event in normal selection mode. This means changing the
* set of selected components.
* @param {MouseEvent} event
*/
function handleNormalSelectionMouseUp(event) {
if (MAP_COMPONENTS_HOVERED.length > 0) {
if (!event.shiftKey) {// If the user isn't holding SHIFT, clear the set of selected components first.
MAP_RAIL_SYSTEM.selectedComponents.length = 0;
}
// If the user is clicking on a component that's already selected, deselect it.
for (let i = 0; i < MAP_COMPONENTS_HOVERED.length; i++) {
const hoveredComponent = MAP_COMPONENTS_HOVERED[i];
const idx = MAP_RAIL_SYSTEM.selectedComponents.findIndex(c => c.id === hoveredComponent.id);
if (idx > -1) {
MAP_RAIL_SYSTEM.selectedComponents.splice(idx, 1);
} else {
MAP_RAIL_SYSTEM.selectedComponents.push(hoveredComponent);
}
}
componentSelectionListeners.forEach(callback => callback(MAP_RAIL_SYSTEM.selectedComponents));
} else if (!camPanNonzero()) {
MAP_RAIL_SYSTEM.selectedComponents.length = 0;
}
}
/**
* Handle when the mouse moves.
* @param {MouseEvent} event
*/
function onMouseMove(event) {
const p = getMousePoint(event);
LAST_MOUSE_POINT = p;
if (camPanActive()) {
if (event.shiftKey) {
camPanMove({ x: p.x, y: p.y });
} else {
camPanFinish();
}
} else {
MAP_COMPONENTS_HOVERED.length = 0;
// Populate with list of hovered elements.
for (let i = 0; i < MAP_RAIL_SYSTEM.components.length; i++) {
const c = MAP_RAIL_SYSTEM.components[i];
const componentPoint = new DOMPoint(c.position.x, c.position.z, 0, 1);
const mapComponentPoint = camTransformWorldToMap(componentPoint);
const dist2 = (p.x - mapComponentPoint.x) * (p.x - mapComponentPoint.x) + (p.y - mapComponentPoint.y) * (p.y - mapComponentPoint.y);
if (dist2 < HOVER_RADIUS * HOVER_RADIUS) {
MAP_COMPONENTS_HOVERED.push(c);
}
}
}
draw();
}
/**
* @param {KeyboardEvent} event
*/
function onKeyDown(event) {
if (event.ctrlKey && event.code === "Space") {
camResetView();
}
}

View File

@ -0,0 +1,100 @@
/*
This is the main script which organizes the drawing of the rail system map.
*/
import { drawMap } from "./drawing";
import { camResetView, camScale, camTransformMapToWorld } from "./mapCamera";
import { initListeners, LAST_MOUSE_POINT } from "src/map/mapEventListener";
/**
* The div containing the canvas element.
* @type {Element | null}
*/
let mapContainerDiv = null;
/**
* The canvas element.
* @type {HTMLCanvasElement | null}
*/
export let MAP_CANVAS = null;
/**
* The map's 2D rendering context.
* @type {CanvasRenderingContext2D | null}
*/
export let MAP_CTX = null;
/**
* The rail system that we're currently rendering.
* @type {RailSystem | null}
*/
export let MAP_RAIL_SYSTEM = null;
/**
* The set of components that are currently hovered over.
* @type {Object[]}
*/
export const MAP_COMPONENTS_HOVERED = [];
export function initMap(rs) {
MAP_RAIL_SYSTEM = rs;
console.log("Initializing map for rail system: " + rs.name);
camResetView();
MAP_CANVAS = document.getElementById("railSystemMapCanvas");
mapContainerDiv = document.getElementById("railSystemMapCanvasContainer");
MAP_CTX = MAP_CANVAS?.getContext("2d");
initListeners();
// Do an initial draw.
draw();
}
export function draw() {
if (!(MAP_CANVAS && MAP_RAIL_SYSTEM && MAP_RAIL_SYSTEM.components)) {
console.warn("Attempted to draw map without canvas or railSystem.");
return;
}
if (MAP_CANVAS.width !== mapContainerDiv.clientWidth) {
MAP_CANVAS.width = mapContainerDiv.clientWidth;
}
if (MAP_CANVAS.height !== mapContainerDiv.clientHeight) {
MAP_CANVAS.height = mapContainerDiv.clientHeight - 6;
}
const width = MAP_CANVAS.width;
const height = MAP_CANVAS.height;
MAP_CTX.resetTransform();
MAP_CTX.fillStyle = `rgb(240, 240, 240)`;
MAP_CTX.fillRect(0, 0, width, height);
drawMap();
drawDebugInfo();
}
function drawDebugInfo() {
MAP_CTX.resetTransform();
MAP_CTX.fillStyle = "black";
MAP_CTX.strokeStyle = "black";
MAP_CTX.font = "10px Sans-Serif";
const lastWorldPoint = camTransformMapToWorld(LAST_MOUSE_POINT);
const lines = [
"Scale factor: " + camScale(),
`(x = ${lastWorldPoint.x.toFixed(2)}, y = ${lastWorldPoint.y.toFixed(2)}, z = ${lastWorldPoint.z.toFixed(2)})`,
`Components: ${MAP_RAIL_SYSTEM.components.length}`,
`Hovered components: ${MAP_COMPONENTS_HOVERED.length}`
];
for (let i = 0; i < MAP_COMPONENTS_HOVERED.length; i++) {
lines.push(" " + MAP_COMPONENTS_HOVERED[i].name);
}
for (let i = 0; i < lines.length; i++) {
MAP_CTX.fillText(lines[i], 10, 20 + (i * 15));
}
}
export function isComponentHovered(component) {
return MAP_COMPONENTS_HOVERED.some(c => c.id === component.id);
}
export function isComponentSelected(component) {
return MAP_RAIL_SYSTEM.selectedComponents.some(c => c.id === component.id);
}

View File

@ -0,0 +1,54 @@
<template>
<q-page class="q-pa-md">
<div class="row">
<div class="col-md-6">
<div class="text-h2">About Rail Signal</div>
<hr>
<p>
This application was developed by <a href="https://andrewlalis.github.io">Andrew Lalis</a>
after several years of tinkering with various ways to automate rail
networks in Minecraft. However, Rail Signal was designed from the
ground up to be free from the constraints of any particular system.
<em>Theoretically</em>, you could use Rail Signal to manage a real-world
rail network, although I wouldn't recommend it, due to the complex and
secure nature of real-world networks.
</p>
<p>
Of course, Rail Signal was originally designed for Minecraft, so we've
started by adding Rail Signal compatible drivers for Computercraft
and Immersive Railroading by default. If you have a different system
and would like to integrate it with Rail Signal, please <a href="https://github.com/andrewlalis/RailSignalAPI/issues" target="_blank">create a new issue</a>
on the GitHub repository.
</p>
<div class="text-h4"><q-icon :name="fasMicrochip"/> Technologies</div>
<p>
This web app was built using <a href="https://quasar.dev">Quasar</a>
and <a href="https://vuejs.org/">VueJS</a>. The API that powers this
application was built using <a href="https://spring.io/">Spring</a>
and Java 17. For more technical information, please visit Rail Signal's
<a href="https://github.com/andrewlalis/RailSignalAPI">GitHub repository</a>.
</p>
<div class="text-h4"><q-icon :name="fasHeart"/> Support</div>
<p>
If you're enjoying this app, please consider making a <a href="https://paypal.me/andrewlalis" target="_blank">donation to
Andrew's work on paypal</a>.
</p>
</div>
</div>
</q-page>
</template>
<script>
import {fasHeart, fasMicrochip} from "@quasar/extras/fontawesome-v6";
export default {
name: "AboutPage",
setup() {
return {fasHeart, fasMicrochip};
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,31 @@
<template>
<div class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center">
<div>
<div style="font-size: 30vh">
404
</div>
<div class="text-h2" style="opacity:.4">
Oops. Nothing here...
</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ErrorNotFound'
})
</script>

View File

@ -0,0 +1,137 @@
<template>
<q-page class="q-pa-md">
<div class="row">
<div class="col-md-6">
<div class="text-h2">Welcome to Rail Signal</div>
<hr>
<p>
To begin, expand the menu on the left, and select a rail system, or
create a new one. The remainder of this page will serve as a quick
guide on how to use Rail Signal to manage your rail system more
effectively.
</p>
</div>
</div>
<index-page-section title="Introduction to Rail Networks">
<q-img src="~assets/img/guide/layout.png"/>
<p>
The above diagram illustrates all of the basic concepts you need to
know in order to build and manage your rail networks.
</p>
<p>
Each rail system can be conceptually split up into lots of small
<strong>segments</strong>, each of which represents a single part of
the network that a single train should go through at once. For
example, in our diagram, each shaded area is a segment. We only want
one train to go through the junction at once, or there might be a
crash!
</p>
<p>
At the places where segments meet, we see a <strong style="color: #963ae0">segment boundary</strong>
which is also denoted with a red dotted line for convenience. This is
a physical point on a track where trains travel from one segment to
another. What's special about segment boundaries is that they're where
we can used devices to track trains moving in and out of segments. To
put it simply, imagine there's a little computer next to each segment
boundary point that sends a message saying, "Hey! A train just passed!"
every time that it detects a train going over it.
</p>
<p>
Now that we've covered segments and segment boundaries, we can now
display a segment's status using a <strong>signal</strong>. A signal
is a device that is linked to a segment, and whenever the segment's
status updates (<em>when a train enters or leaves it</em>), the signal
will be updated as well. Usually, signals are placed near the segment
boundary, so that approaching trains know whether they're safe to
continue, but with Rail Signal, you can place a signal anywhere, and
connect it to any segment.
</p>
<p>
Finally, unless you're just making a boring single-line loop, you'll
most likely have some <strong style="color: #f5bc42">switches</strong>
in your network. Switches are just sections of rail that allow trains
to choose between two different paths to take. Rail Signal gives you
the ability to manage these automatically, so you can use this web
interface to configure switches instead of doing it manually.
</p>
</index-page-section>
<index-page-section title="Paths and Path Nodes">
<p>
We mentioned segment boundaries and switches earlier, as simple
components that you can add to your network in order to link it to the
internet. There's more to it than that, however.
</p>
<p>
Behind the scenes, your Rail Signal models your network as a set of
<strong>path nodes</strong>, where each node can be connected to any
other number of nodes. A train travels through your network by moving
from node to node, until it reaches its desired destination. Both the
segment boundary and switch are types of path nodes.
</p>
<ul>
<li>
Segment boundaries may only be connected to at most two nodes. This
is because a segment boundary is fundamentally just a point on a
single rail line.
</li>
<li>
Switches are connected to nodes based on their set of defined
configurations. In the example diagram, our switch allows two
possible configurations:
<ul>
<li>Between the <strong style="color: #3cadab">blue</strong> and <strong style="color: #7169b4">purple</strong> segments.</li>
<li>Between the <strong style="color: #81d07b">green</strong> and <strong style="color: #7169b4">purple</strong> segments.</li>
</ul>
This implies that our switch node is connected to three other nodes:
each of the segment boundaries that it allows traffic between.
</li>
</ul>
</index-page-section>
<index-page-section title="Drivers">
<p>
While you can play around in this web app as long as you'd like, the
main point is to connect to an external rail system. That's done through
a <strong>driver</strong>, which is a dedicated piece of code that sends
and receives messages from the Rail Signal server. Usually, driver
software will be installed into physical components in your system, like
signals and trackside detectors, and switch levers. It's the responsibility
of driver software to tell Rail Signal when a train crosses a segment
boundary, or when a switch updates, or anything else it should know
about.
</p>
<p>
For Minecraft-related systems which use Lua computers, you can generally
run <code>pastebin run jKyAiE8k</code> to run an installer script that
automatically downloads the compatible drivers and walks you through an
installation process.
</p>
</index-page-section>
<index-page-section title="Advanced Usage">
<q-img src="~assets/img/guide/layout2.png"/>
<p>
The above diagram shows a more typical network arrangement for a large
scale, two-way mainline. Here, we see that each side of the main line
has its own segment, so that trains can travel past each other without
issue. We make the entire junction a single segment, so that only one
train can pass through at a time. More advanced setups might have
separate segments for bypass lines to avoid traffic jams. Beside each
entrance and exit to the junction, we've placed a signal. On the
outbound segments, the signal will report the status of the outbound
segment, while on the inbound segments, the signal will show the
status of the junction segment.
</p>
</index-page-section>
</q-page>
</template>
<script>
import IndexPageSection from "components/IndexPageSection.vue";
export default {
name: "IndexPage",
components: { IndexPageSection }
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,94 @@
<template>
<q-page>
<div v-if="railSystem && railSystem.loaded">
<q-tabs
v-model="panel"
align="left"
active-bg-color="positive"
class="bg-secondary"
>
<q-tab name="map" label="Map"/>
<q-tab name="segments" label="Segments"/>
<q-tab name="settings" label="Settings"/>
</q-tabs>
<q-tab-panels v-model="panel">
<q-tab-panel name="map">
<map-view :rail-system="railSystem"/>
</q-tab-panel>
<q-tab-panel name="segments">
<segments-view :rail-system="railSystem"/>
</q-tab-panel>
<q-tab-panel name="settings">
<settings-view :rail-system="railSystem"/>
</q-tab-panel>
</q-tab-panels>
<router-view />
</div>
<q-inner-loading
:showing="!railSystem || !railSystem.loaded"
label="Loading rail system..."
/>
</q-page>
</template>
<script>
import { useRailSystemsStore } from "stores/railSystemsStore";
import MapView from "components/rs/MapView.vue";
import SegmentsView from "components/rs/SegmentsView.vue";
import SettingsView from "components/rs/SettingsView.vue";
import { loadData, unloadData } from "src/api/railSystems";
export default {
name: "RailSystemPage",
components: { SettingsView, SegmentsView, MapView },
data() {
return {
panel: "map",
railSystem: null,
loading: false
}
},
setup() {
const rsStore = useRailSystemsStore();
return {rsStore};
},
mounted() {
this.updateRailSystem();
},
created() {
this.$watch(
() => this.$route.params,
this.updateRailSystem,
{
immediate: true
}
)
},
methods: {
async updateRailSystem() {
if (this.loading) return;
this.loading = true;
console.log(">>>> updating rail system.")
if (this.railSystem) {
this.rsStore.selectedRailSystem = null;
await unloadData(this.railSystem);
}
if (this.$route.params.id) {
const newRsId = parseInt(this.$route.params.id);
const rs = this.rsStore.railSystems.find(r => r.id === newRsId);
if (rs) {
this.railSystem = rs;
this.rsStore.selectedRailSystem = rs;
await loadData(rs);
}
}
this.loading = false;
}
}
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,30 @@
import { route } from 'quasar/wrappers'
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
import routes from './routes'
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default route(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE)
})
return Router
})

View File

@ -0,0 +1,32 @@
import { useRailSystemsStore } from "stores/railSystemsStore";
const routes = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [
{
path: '',
component: () => import('pages/IndexPage.vue')
},
{
path: 'about',
component: () => import('pages/AboutPage.vue')
},
{// Rail Systems page
path: 'rail-systems/:id',
component: () => import('pages/RailSystem.vue'),
props: true
}
]
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue')
}
]
export default routes

View File

@ -0,0 +1,20 @@
import { store } from 'quasar/wrappers'
import { createPinia } from 'pinia'
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default store((/* { ssrContext } */) => {
const pinia = createPinia()
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia
})

View File

@ -0,0 +1,14 @@
import {defineStore} from "pinia";
export const useRailSystemsStore = defineStore('RailSystemsStore', {
state: () => ({
/**
* @type {RailSystem[]}
*/
railSystems: [],
/**
* @type {RailSystem | null}
*/
selectedRailSystem: null
})
});

10
quasar-app/src/stores/store-flag.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
store: true;
}
}

View File

@ -2,8 +2,11 @@ package nl.andrewl.railsignalapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
@SpringBootApplication
@SpringBootApplication(exclude = {
UserDetailsServiceAutoConfiguration.class
})
public class RailSignalApiApplication {
public static void main(String[] args) {

View File

@ -1,18 +0,0 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.Branch;
import nl.andrewl.railsignalapi.model.RailSystem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface BranchRepository extends JpaRepository<Branch, Long> {
Optional<Branch> findByIdAndRailSystem(long id, RailSystem railSystem);
Optional<Branch> findByIdAndRailSystemId(long id, long railSystemId);
Optional<Branch> findByNameAndRailSystem(String name, RailSystem railSystem);
List<Branch> findAllByRailSystemOrderByName(RailSystem railSystem);
List<Branch> findAllByNameAndRailSystem(String name, RailSystem railSystem);
}

View File

@ -0,0 +1,31 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.model.component.Position;
import org.springframework.data.domain.Page;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ComponentRepository<T extends Component> extends JpaRepository<T, Long>, JpaSpecificationExecutor<T> {
Optional<T> findByIdAndRailSystemId(long id, long rsId);
boolean existsByNameAndRailSystem(String name, RailSystem rs);
boolean existsByNameAndRailSystemId(String name, long rsId);
@Query("SELECT c FROM Component c " +
"WHERE c.railSystem = :rs AND " +
"c.position.x >= :#{#lower.x} AND c.position.y >= :#{#lower.y} AND c.position.z >= :#{#lower.z} AND " +
"c.position.x <= :#{#upper.x} AND c.position.y <= :#{#upper.y} AND c.position.z <= :#{#upper.z}")
List<T> findAllInBounds(RailSystem rs, Position lower, Position upper);
List<T> findAllByRailSystem(RailSystem rs);
void deleteAllByRailSystem(RailSystem rs);
}

View File

@ -0,0 +1,13 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.component.Label;
import nl.andrewl.railsignalapi.model.RailSystem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface LabelRepository extends JpaRepository<Label, Long> {
List<Label> findAllByRailSystem(RailSystem rs);
}

View File

@ -0,0 +1,17 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.LinkToken;
import nl.andrewl.railsignalapi.model.RailSystem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface LinkTokenRepository extends JpaRepository<LinkToken, Long> {
Iterable<LinkToken> findAllByTokenPrefix(String prefix);
boolean existsByLabel(String label);
List<LinkToken> findAllByRailSystem(RailSystem rs);
Optional<LinkToken> findByIdAndRailSystemId(long ltId, long rsId);
}

View File

@ -0,0 +1,20 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Segment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface SegmentRepository extends JpaRepository<Segment, Long> {
boolean existsByNameAndRailSystem(String name, RailSystem rs);
List<Segment> findAllByRailSystemIdOrderByName(long rsId);
Optional<Segment> findByIdAndRailSystemId(long id, long rsId);
void deleteAllByRailSystem(RailSystem rs);
}

View File

@ -1,25 +1,8 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.Branch;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Signal;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import nl.andrewl.railsignalapi.model.component.Signal;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface SignalRepository extends JpaRepository<Signal, Long> {
Optional<Signal> findByIdAndRailSystem(long id, RailSystem railSystem);
Optional<Signal> findByIdAndRailSystemId(long id, long railSystemId);
boolean existsByNameAndRailSystem(String name, RailSystem railSystem);
@Query("SELECT DISTINCT s FROM Signal s " +
"LEFT JOIN s.branchConnections bc " +
"WHERE bc.branch = :branch")
List<Signal> findAllConnectedToBranch(Branch branch);
List<Signal> findAllByRailSystemOrderByName(RailSystem railSystem);
public interface SignalRepository extends ComponentRepository<Signal> {
}

View File

@ -0,0 +1,11 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.component.PathNode;
import nl.andrewl.railsignalapi.model.component.SwitchConfiguration;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SwitchConfigurationRepository extends JpaRepository<SwitchConfiguration, Long> {
void deleteAllByNodesContaining(PathNode p);
}

View File

@ -0,0 +1,8 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.component.Switch;
import org.springframework.stereotype.Repository;
@Repository
public interface SwitchRepository extends ComponentRepository<Switch> {
}

View File

@ -1,9 +0,0 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.Train;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TrainRepository extends JpaRepository<Train, Long> {
}

View File

@ -0,0 +1,29 @@
package nl.andrewl.railsignalapi.live;
import lombok.Getter;
/**
* A downlink connection to one or more components (linked by a {@link nl.andrewl.railsignalapi.model.LinkToken})
* which we can send messages to.
*/
public abstract class ComponentDownlink {
@Getter
private final long tokenId;
public ComponentDownlink(long tokenId) {
this.tokenId = tokenId;
}
public abstract void send(Object msg) throws Exception;
public abstract void shutdown() throws Exception;
@Override
public boolean equals(Object o) {
return o instanceof ComponentDownlink cd && cd.tokenId == this.tokenId;
}
@Override
public int hashCode() {
return Long.hashCode(tokenId);
}
}

View File

@ -0,0 +1,135 @@
package nl.andrewl.railsignalapi.live;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.LinkTokenRepository;
import nl.andrewl.railsignalapi.live.dto.ComponentDataMessage;
import nl.andrewl.railsignalapi.live.dto.ComponentMessage;
import nl.andrewl.railsignalapi.live.dto.SegmentStatusMessage;
import nl.andrewl.railsignalapi.live.dto.SwitchUpdateMessage;
import nl.andrewl.railsignalapi.live.websocket.AppUpdateService;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.model.component.Signal;
import nl.andrewl.railsignalapi.model.component.Switch;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/**
* A service that manages all the active component downlink connections.
*
* We keep track of active component downlinks by storing a mapping which maps
* each downlink to the set of components it is responsible for, and another
* mapping that maps each online component to the set of downlinks that are
* responsible for it.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ComponentDownlinkService {
private final Map<ComponentDownlink, Set<Long>> componentDownlinks = new HashMap<>();
private final Map<Long, Set<ComponentDownlink>> downlinksByCId = new HashMap<>();
private final LinkTokenRepository tokenRepository;
private final ComponentRepository<Component> componentRepository;
private final AppUpdateService appUpdateService;
/**
* Registers a new active downlink to one or more components. Sets all
* linked components as online, and sends messages to any connected apps
* to notify them of the update components.
* @param downlink The downlink to register.
*/
@Transactional
public synchronized void registerDownlink(ComponentDownlink downlink) throws Exception {
Set<Component> components = tokenRepository.findById(downlink.getTokenId()).orElseThrow().getComponents();
componentDownlinks.put(downlink, components.stream().map(Component::getId).collect(Collectors.toSet()));
for (var c : components) {
c.setOnline(true);
componentRepository.saveAndFlush(c); // Make sure to flush, so that online status is immediately visible everywhere.
// Immediately send a data message to the downlink and app for each component that comes online.
var msg = new ComponentDataMessage(c);
downlink.send(msg);
appUpdateService.sendUpdate(c.getRailSystem().getId(), msg);
// Send initial data updates to make sure that devices' state is up to date immediately.
if (c instanceof Signal sig) {
downlink.send(new SegmentStatusMessage(c.getId(), sig.getSegment().getId(), sig.getSegment().isOccupied()));
} else if (c instanceof Switch sw) {
long activeConfigId;
if (sw.getActiveConfiguration() != null) {
activeConfigId = sw.getActiveConfiguration().getId();
} else {
activeConfigId = sw.getPossibleConfigurations().stream().findAny().orElseThrow().getId();
}
downlink.send(new SwitchUpdateMessage(c.getId(), activeConfigId));
}
Set<ComponentDownlink> downlinks = downlinksByCId.computeIfAbsent(c.getId(), aLong -> new HashSet<>());
downlinks.add(downlink);
}
log.info("Registered downlink with token id {}.", downlink.getTokenId());
}
/**
* De-registers a downlink to components. This should be called when this
* downlink is closed.
* @param downlink The downlink to de-register.
*/
@Transactional
public synchronized void deregisterDownlink(ComponentDownlink downlink) {
Set<Long> componentIds = componentDownlinks.remove(downlink);
if (componentIds != null) {
for (var cId : componentIds) {
componentRepository.findById(cId).ifPresent(component -> {
component.setOnline(false);
componentRepository.save(component);
appUpdateService.sendComponentUpdate(component.getRailSystem().getId(), component.getId());
});
Set<ComponentDownlink> downlinks = downlinksByCId.get(cId);
if (downlinks != null) {
downlinks.remove(downlink);
if (downlinks.isEmpty()) {
downlinksByCId.remove(cId);
}
}
}
}
try {
downlink.shutdown();
} catch (Exception e) {
log.warn("An error occurred while shutting down a component downlink.", e);
}
log.info("De-registered downlink with token id {}.", downlink.getTokenId());
}
@Transactional
public synchronized void deregisterDownlink(long tokenId) {
List<ComponentDownlink> removeSet = componentDownlinks.keySet().stream()
.filter(downlink -> downlink.getTokenId() == tokenId).toList();
for (var downlink : removeSet) {
deregisterDownlink(downlink);
}
}
public void sendMessage(long componentId, Object msg) {
var downlinks = downlinksByCId.get(componentId);
if (downlinks != null) {
for (var downlink : downlinks) {
try {
downlink.send(msg);
} catch (Exception e) {
log.warn("An error occurred while sending a message to downlink with token id " + downlink.getTokenId(), e);
}
}
}
}
public void sendMessage(ComponentMessage msg) {
sendMessage(msg.cId, msg);
}
}

View File

@ -0,0 +1,32 @@
package nl.andrewl.railsignalapi.live;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.live.dto.ComponentMessage;
import nl.andrewl.railsignalapi.live.dto.SegmentBoundaryUpdateMessage;
import nl.andrewl.railsignalapi.live.dto.SwitchUpdateMessage;
import nl.andrewl.railsignalapi.service.SegmentService;
import nl.andrewl.railsignalapi.service.SwitchService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* A central service that manages all incoming component messages from any
* connected component links.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ComponentUplinkMessageHandler {
private final SwitchService switchService;
private final SegmentService segmentService;
@Transactional
public void messageReceived(ComponentMessage msg) {
if (msg instanceof SegmentBoundaryUpdateMessage sb) {
segmentService.onBoundaryUpdate(sb);
} else if (msg instanceof SwitchUpdateMessage sw) {
switchService.onSwitchUpdate(sw);
}
}
}

View File

@ -0,0 +1,19 @@
package nl.andrewl.railsignalapi.live.dto;
import lombok.Getter;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.rest.dto.component.out.ComponentResponse;
/**
* A message that's sent to devices which contains the full component response
* for a specific component.
*/
@Getter
public class ComponentDataMessage extends ComponentMessage {
private final ComponentResponse data;
public ComponentDataMessage(Component c) {
super(c.getId(), "COMPONENT_DATA");
this.data = ComponentResponse.of(c);
}
}

View File

@ -0,0 +1,34 @@
package nl.andrewl.railsignalapi.live.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import lombok.NoArgsConstructor;
/**
* Base class for all messages that will be sent to components.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, visible = true)
@JsonSubTypes({
@JsonSubTypes.Type(value = SegmentBoundaryUpdateMessage.class, name = "SEGMENT_BOUNDARY_UPDATE"),
@JsonSubTypes.Type(value = SwitchUpdateMessage.class, name = "SWITCH_UPDATE"),
@JsonSubTypes.Type(value = ErrorMessage.class, name = "ERROR")
})
@NoArgsConstructor
public abstract class ComponentMessage {
/**
* The id of the component that this message is for.
*/
public long cId;
/**
* The type of message.
*/
public String type;
public ComponentMessage(long cId, String type) {
this.cId = cId;
this.type = type;
}
}

View File

@ -0,0 +1,13 @@
package nl.andrewl.railsignalapi.live.dto;
/**
* A message that's sent regarding an error that occurred.
*/
public class ErrorMessage extends ComponentMessage {
public String message;
public ErrorMessage(long cId, String message) {
super(cId, "ERROR");
this.message = message;
}
}

Some files were not shown because too many files have changed in this diff Show More