Removed old vue project, improved live connection infrastructure.

This commit is contained in:
Andrew Lalis 2022-05-26 09:16:02 +02:00
parent 2a05e26d6d
commit dc9ca7c2af
70 changed files with 315 additions and 5746 deletions

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",
"sId": 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.

View File

@ -1,2 +0,0 @@
VITE_API_URL=http://localhost:8080/api
VITE_WS_URL=ws://localhost:8080/api/ws/app

View File

@ -1,2 +0,0 @@
VITE_API_URL=http://localhost:8080/api
VITE_WS_URL=ws://localhost:8080/api/ws/app

View File

@ -1,11 +0,0 @@
/* eslint-env node */
module.exports = {
"root": true,
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"env": {
"vue/setup-compiler-macros": true
}
}

View File

@ -1,28 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,35 +0,0 @@
# railsignal-app
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-typescript-vue-plugin).
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

View File

@ -1,15 +0,0 @@
#!/usr/bin/env dub
/+ dub.sdl:
dependency "dsh" version="~>1.6.1"
+/
import dsh;
const DEST = "../src/main/resources/static";
void main() {
print("Deploying Vue app to Spring's /static directory.");
runOrQuit("vite build --base=/app/");
rmdirRecurse(DEST);
copyDir("./dist", DEST);
}

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rail Signal</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +0,0 @@
{
"name": "railsignal-app",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build --base=/app/ --mode=development",
"preview": "vite preview --port 5050",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"@popperjs/core": "^2.11.5",
"axios": "^0.27.2",
"bootstrap": "^5.1.3",
"pinia": "^2.0.14",
"three": "^0.140.0",
"vue": "^3.2.33",
"vue-router": "^4.0.14"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.3.2",
"eslint": "^8.5.0",
"eslint-plugin-vue": "^8.2.0",
"vite": "^2.9.8"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -1,30 +0,0 @@
<template>
<AppNavbar />
<RailSystem v-if="rsStore.selectedRailSystem !== null" :railSystem="rsStore.selectedRailSystem"/>
</template>
<script>
import {useRailSystemsStore} from "./stores/railSystemsStore";
import AppNavbar from "./components/AppNavbar.vue";
import RailSystem from "./components/RailSystem.vue";
export default {
components: {
AppNavbar,
RailSystem
},
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
}
}
</script>
<style>
body {
padding: 0;
margin: 0;
}
</style>

View File

@ -1,101 +0,0 @@
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 previousSelectedComponentId = rs.selectedComponent ? rs.selectedComponent.id : null;
rs.components = response.data;
if (previousSelectedComponentId !== null) {
const previousComponent = rs.components.find(c => c.id === previousSelectedComponentId);
if (previousComponent) {
rs.selectedComponent = previousComponent;
} else {
rs.selectedComponent = null;
}
} else {
rs.selectedComponent = null;
}
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(`${this.apiUrl}/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.selectedComponent = 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);
});
}

View File

@ -1,2 +0,0 @@
export const API_URL = import.meta.env.VITE_API_URL;
export const WS_URL = import.meta.env.VITE_WS_URL;

View File

@ -1,57 +0,0 @@
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;
}
}
/**
* Gets the list of link tokens in a rail system.
* @param {RailSystem} rs
* @return {Promise<LinkToken[]>}
*/
export function getTokens(rs) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/lt`)
.then(response => {
resolve(response.data.map(obj => new LinkToken(obj)));
})
.catch(reject);
});
}
/**
* Creates a new link token.
* @param {RailSystem} rs
* @param {LinkToken} 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 => {
resolve(response.data.token);
})
.catch(reject);
});
}
/**
* Deletes a link token.
* @param {RailSystem} rs
* @param {Number} tokenId
*/
export function deleteToken(rs, tokenId) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${rs.id}/lt/${tokenId}`)
.then(resolve)
.catch(reject);
});
}

View File

@ -1,64 +0,0 @@
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

@ -1,53 +0,0 @@
import axios from "axios";
import {API_URL} from "./constants";
export class RailSystem {
constructor(data) {
this.id = data.id;
this.name = data.name;
this.segments = [];
this.components = [];
this.websocket = null;
this.selectedComponent = null;
}
}
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 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(() => {
rsStore.selectedRailSystem = null;
refreshRailSystems(rsStore)
.then(() => resolve)
.catch(error => reject(error));
});
});
}

View File

@ -1,50 +0,0 @@
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}/${segmentId}`)
.then(() => {
refreshSegments(rs)
.then(() => resolve())
.catch(error => reject(error));
})
.catch(error => reject(error));
});
}

View File

@ -1,39 +0,0 @@
import {WS_URL} from "./constants";
/**
* Establishes a websocket connection to the given rail system.
* @param {RailSystem} rs
*/
export function establishWebsocketConnection(rs) {
closeWebsocketConnection(rs);
rs.websocket = new WebSocket(`${WS_URL}/${rs.id}`);
rs.websocket.onopen = () => {
console.log("Opened websocket connection to rail system " + rs.id);
};
rs.websocket.onclose = event => {
if (event.code !== 1000) {
console.warn("Lost websocket connection. Attempting to reestablish.");
setTimeout(() => {
establishWebsocketConnection(rs);
}, 3000);
}
console.log("Closed websocket connection to rail system " + rs.id);
};
rs.websocket.onmessage = msg => {
console.log(msg);
};
rs.websocket.onerror = error => {
console.log(error);
};
}
/**
* Closes the websocket connection to a rail system, if possible.
* @param {RailSystem} rs
*/
export function closeWebsocketConnection(rs) {
if (rs.websocket) {
rs.websocket.close();
rs.websocket = null;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -1,90 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,87 +0,0 @@
<template>
<nav class="navbar navbar-expand-md navbar-light bg-light">
<div class="container-fluid">
<span class="navbar-brand h1 mb-0">
<img src="@/assets/icon.svg" height="24"/>
Rail Signal
</span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2, mb-md-0">
<li class="nav-item me-2 mb-2 mb-md-0">
<select
id="railSystemSelect"
v-model="rsStore.selectedRailSystem"
class="form-select form-select-sm"
@change="rsStore.onSelectedRailSystemChanged()"
>
<option v-for="rs in rsStore.railSystems" :key="rs.id" :value="rs">
{{rs.name}}
</option>
</select>
</li>
<li class="nav-item me-2" v-if="rsStore.selectedRailSystem !== null">
<button
@click="remove()"
class="btn btn-danger btn-sm"
type="button"
>
Remove this Rail System
</button>
</li>
<li class="nav-item">
<button
type="button"
class="btn btn-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#addRailSystemModal"
>
Add Rail System
</button>
<AddRailSystem />
</li>
</ul>
</div>
</div>
</nav>
<ConfirmModal
ref="confirmModal"
:id="'removeRailSystemModal'"
:title="'Confirm Rail System Removal'"
:message="'Are you sure you want to remove this rail system? This CANNOT be undone. All data will be permanently lost.'"
/>
</template>
<script>
import {useRailSystemsStore} from "../stores/railSystemsStore";
import AddRailSystemModal from "./railsystem/AddRailSystemModal.vue";
import ConfirmModal from "./ConfirmModal.vue";
import {refreshRailSystems, removeRailSystem} from "../api/railSystems";
export default {
name: "AppNavbar",
components: {AddRailSystem: AddRailSystemModal, ConfirmModal},
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
mounted() {
refreshRailSystems(this.rsStore);
},
methods: {
remove() {
this.$refs.confirmModal.showConfirm()
.then(() => removeRailSystem(this.rsStore, this.rsStore.selectedRailSystem.id));
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,77 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" :id="id">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{title}}</h5>
</div>
<div class="modal-body">
<p>{{message}}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" :id="id + '_cancel'">Cancel</button>
<button type="button" class="btn btn-primary" :id="id + '_yes'">Yes</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {Modal} from "bootstrap";
export default {
name: "ConfirmModal",
props: {
id: {
type: String,
required: true
},
message: {
type: String,
required: false,
default: "Are you sure you want to continue?"
},
title: {
type: String,
required: false,
default: "Confirm"
}
},
expose: ['showConfirm'],
methods: {
showConfirm() {
return new Promise(resolve => {
console.log(this.id);
console.log(this.title);
const modalElement = document.getElementById(this.id);
const modal = new Modal(modalElement);
console.log(modal);
function onDismiss() {
modal.hide();
}
function onYes() {
modalElement.addEventListener("hidden.bs.modal", function onSuccess() {
modalElement.removeEventListener("hidden.bs.modal", onSuccess);
resolve();
});
modal.hide();
}
const cancelButton = document.getElementById(this.id + "_cancel");
cancelButton.removeEventListener("click", onDismiss);
cancelButton.addEventListener("click", onDismiss)
const yesButton = document.getElementById(this.id + "_yes");
yesButton.removeEventListener("click", onYes);
yesButton.addEventListener("click", onYes);
modal.show(modalElement);
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,36 +0,0 @@
<template>
<div class="container-fluid">
<div class="row">
<div class="col-md-8 p-0">
<MapView :railSystem="railSystem" v-if="railSystem.segments && railSystem.components" />
</div>
<div class="col-md-4">
<ComponentView v-if="railSystem.selectedComponent" :component="railSystem.selectedComponent" :railSystem="railSystem"/>
<RailSystemPropertiesView v-if="!railSystem.selectedComponent" :railSystem="railSystem"/>
</div>
</div>
</div>
</template>
<script>
import MapView from './railsystem/MapView.vue'
import ComponentView from './railsystem/component/ComponentView.vue'
import RailSystemPropertiesView from "./railsystem/RailSystemPropertiesView.vue";
export default {
components: {
RailSystemPropertiesView,
MapView,
ComponentView
},
props: {
railSystem: {
type: Object,
required: true
}
}
}
</script>
<style>
</style>

View File

@ -1,78 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" id="addRailSystemModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Rail System</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<label for="addRailSystemNameInput" class="form-label">Name</label>
<input
id="addRailSystemNameInput"
class="form-control"
type="text"
v-model="formData.rsName"
required
/>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="formSubmitted()">Add</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {useRailSystemsStore} from "../../stores/railSystemsStore";
import {Modal} from "bootstrap";
import {createRailSystem} from "../../api/railSystems";
export default {
name: "AddRailSystem",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: ""
},
warnings: []
};
},
methods: {
formSubmitted() {
createRailSystem(this.rsStore, this.formData.rsName)
.then(rs => {
this.formData.rsName = "";
const modal = Modal.getInstance(document.getElementById("addRailSystemModal"));
modal.hide();
this.rsStore.selectedRailSystem = rs;
})
.catch(error => {
console.log(error);
this.warnings.length = 0;
this.warnings.push("Couldn't add the rail system: " + error.response.data.message);
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,87 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" id="addSegmentModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Segment</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Add a new segment to this rail system. A <em>segment</em> is the
basic organizational unit of any rail system. It is a section of
the network that signals can monitor, and <em>segment boundary nodes</em>
define the extent of the segment, and monitor trains entering and
leaving the segment.
</p>
<p>
You can think of a segment as a single, secure block of of the rail
network that only one train may pass through at once. For example,
a junction or station siding.
</p>
<form>
<label for="addSegmentName" class="form-label">Name</label>
<input
id="addSegmentName"
class="form-control"
type="text"
v-model="formData.name"
required
/>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="formSubmitted()">Add</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {useRailSystemsStore} from "../../stores/railSystemsStore";
import {Modal} from "bootstrap";
import {createSegment} from "../../api/segments";
export default {
name: "AddSegmentModal",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: ""
},
warnings: []
};
},
methods: {
formSubmitted() {
const modal = Modal.getInstance(document.getElementById("addSegmentModal"));
createSegment(this.rsStore.selectedRailSystem, this.formData.name)
.then(() => {
this.formData.name = "";
modal.hide();
})
.catch(error => {
this.warnings.length = 0;
this.warnings.push("Couldn't add the segment: " + error.response.data.message)
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,115 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" id="createLinkTokenModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Link Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Create a <em>link token</em> to link components in this rail system
to actual devices in your system, so your world can talk to this
system. Each link token should have a unique label that can be used
to identify it, and a list of components that it's linked to.
</p>
<p>
Note that for security purposes, the raw token that's generated is
only shown once, and is never available again. If you lose the
token, you must create a new one instead and delete the old one.
</p>
<form>
<div class="mb-3">
<label for="createLinkTokenLabel" class="form-label">Label</label>
<input
id="createLinkTokenLabel"
class="form-control"
type="text"
v-model="formData.label"
required
/>
</div>
<div class="mb-3">
<label class="form-label" for="createLinkTokenComponentSelect">Select Components to Link</label>
<ComponentSelector id="createLinkTokenComponentSelect" :railSystem="railSystem" v-model="components"/>
</div>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
<div v-if="token !== null" class="alert alert-success mt-2">
Created token: {{token}}
<br>
<small>Copy this token now; it will not be shown again.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="reset()">Close</button>
<button
type="button"
class="btn btn-primary"
@click="formSubmitted()"
v-if="token == null"
>
Add
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {createLinkToken} from "../../api/linkTokens";
import {RailSystem} from "../../api/railSystems";
import ComponentSelector from "./util/ComponentSelector.vue";
export default {
name: "CreateLinkTokenModal",
components: {ComponentSelector},
props: {
railSystem: {
type: RailSystem,
required: true
}
},
data() {
return {
formData: {
label: "",
componentIds: []
},
components: [],
warnings: [],
token: null
};
},
methods: {
formSubmitted() {
this.formData.componentIds = this.components.map(c => c.id);
createLinkToken(this.railSystem, this.formData)
.then(token => {
this.token = token;
})
.catch(error => {
this.warnings.length = 0;
this.warnings.push("Couldn't create token: " + error.response.data.message);
});
},
reset() {
// TODO: Fix this!! Reset doesn't work.
this.formData.label = "";
this.formData.componentIds = [];
this.components = [];
this.warnings = [];
this.token = null;
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,47 +0,0 @@
<script>
import {initMap, draw} from "./mapRenderer.js";
export default {
props: {
railSystem: {
type: Object,
required: true
}
},
mounted() {
// The first time this map is mounted, initialize the map.
initMap(this.railSystem);
},
updated() {
// Also, re-initialize any time this view is updated.
initMap(this.railSystem);
},
watch: {
railSystem: {
handler() {
draw();
},
deep: true
}
}
}
</script>
<template>
<div class="canvas-container" id="railSystemMapCanvasContainer">
<canvas id="railSystemMapCanvas">
Your browser doesn't support canvas!
</canvas>
</div>
</template>
<style>
.canvas-container {
width: 100%;
height: 800px;
}
canvas {
width: 100%;
height: 100%;
}
</style>

View File

@ -1,112 +0,0 @@
<template>
<h3>Rail System: <em>{{railSystem.name}}</em></h3>
<SegmentsView :segments="railSystem.segments" v-if="railSystem.segments"/>
<div class="dropdown d-inline-block me-2">
<button class="btn btn-success btn-sm dropdown-toggle" type="button" id="railSystemAddComponentsToggle" data-bs-toggle="dropdown" aria-expanded="false">
Add Component
</button>
<ul class="dropdown-menu" aria-labelledby="railSystemAddComponentsToggle">
<li>
<button
type="button"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#addSegmentModal"
>
Add Segment
</button>
</li>
<li v-if="addSignalAllowed()">
<button
type="button"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#addSignalModal"
>
Add Signal
</button>
</li>
<li v-if="addSegmentBoundaryAllowed()">
<button
type="button"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#addSegmentBoundaryModal"
>
Add Segment Boundary
</button>
</li>
<li v-if="addSwitchAllowed()">
<button
type="button"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#addSwitchModal"
>
Add Switch
</button>
</li>
</ul>
</div>
<div v-if="createLinkTokenAllowed()" class="d-inline-block">
<button
class="btn btn-success btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#createLinkTokenModal"
>
Create Link Token
</button>
<CreateLinkTokenModal :railSystem="railSystem" />
</div>
<AddSegmentModal />
<AddSignalModal v-if="addSignalAllowed()" />
<AddSegmentBoundaryModal v-if="addSegmentBoundaryAllowed()" />
<AddSwitchModal v-if="addSwitchAllowed()" />
</template>
<script>
import SegmentsView from "./SegmentsView.vue";
import AddSegmentModal from "./AddSegmentModal.vue";
import AddSignalModal from "./component/AddSignalModal.vue";
import AddSegmentBoundaryModal from "./component/AddSegmentBoundaryModal.vue";
import AddSwitchModal from "./component/AddSwitchModal.vue";
import CreateLinkTokenModal from "./CreateLinkTokenModal.vue";
import {RailSystem} from "../../api/railSystems";
export default {
name: "RailSystemPropertiesView",
components: {
CreateLinkTokenModal,
AddSwitchModal,
AddSegmentBoundaryModal,
AddSignalModal,
AddSegmentModal,
SegmentsView
},
props: {
railSystem: {
type: RailSystem,
required: true
}
},
methods: {
addSignalAllowed() {
return this.railSystem.segments && this.railSystem.segments.length > 0;
},
addSegmentBoundaryAllowed() {
return this.railSystem.segments && this.railSystem.segments.length > 1;
},
addSwitchAllowed() {
return this.railSystem.components && this.railSystem.components.length > 1;
},
createLinkTokenAllowed() {
return this.railSystem.components && this.railSystem.components.length > 0;
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,52 +0,0 @@
<template>
<h5>Segments</h5>
<input type="search" class="form-control-sm w-100 mb-1" placeholder="Filter by name" v-model="segmentNameFilter" />
<ul class="list-group overflow-auto mb-2" style="max-height: 200px;">
<li
v-for="segment in filteredSegments()"
:key="segment.id"
class="list-group-item"
>
{{segment.name}}
<button @click.prevent="removeSegment(rsStore.selectedRailSystem, segment.id)" class="btn btn-sm btn-danger float-end">Remove</button>
</li>
</ul>
</template>
<script>
import {useRailSystemsStore} from "../../stores/railSystemsStore";
import {removeSegment} from "../../api/segments";
export default {
name: "SegmentsView.vue",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore,
removeSegment
}
},
props: {
segments: {
type: Array,
required: true
}
},
data() {
return {
segmentNameFilter: null
}
},
methods: {
filteredSegments() {
if (this.segmentNameFilter === null || this.segmentNameFilter.trim().length === 0) return this.segments;
const filterString = this.segmentNameFilter.trim().toLowerCase();
return this.segments.filter(segment => segment.name.toLowerCase().includes(filterString));
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,16 +0,0 @@
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);
}

View File

@ -1,113 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" id="addSegmentBoundaryModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Segment Boundary</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
A <em>segment boundary</em> is a component that defines a link
between one segment and another. This component can be used to
monitor trains entering and exiting the connected segments. Usually
used in conjunction with signals for classic railway signalling
systems.
</p>
<form>
<div class="mb-3">
<label for="addSignalName" class="form-label">Name</label>
<input class="form-control" type="text" id="addSignalName" v-model="formData.name" required/>
</div>
<div class="input-group mb-3">
<label for="addSignalX" class="input-group-text">X</label>
<input class="form-control" type="number" id="addSignalX" v-model="formData.position.x" required/>
<label for="addSignalY" class="input-group-text">Y</label>
<input class="form-control" type="number" id="addSignalY" v-model="formData.position.y" required/>
<label for="addSignalZ" class="input-group-text">Z</label>
<input class="form-control" type="number" id="addSignalZ" v-model="formData.position.z" required/>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="addSegmentBoundarySegmentA" class="form-label">Segment A</label>
<select id="addSegmentBoundarySegmentA" class="form-select" v-model="formData.segmentA">
<option v-for="segment in rsStore.selectedRailSystem.segments" :key="segment.id" :value="segment">
{{segment.name}}
</option>
</select>
</div>
<div class="col-md-6">
<label for="addSegmentBoundarySegmentA" class="form-label">Segment B</label>
<select id="addSegmentBoundarySegmentA" class="form-select" v-model="formData.segmentB">
<option v-for="segment in rsStore.selectedRailSystem.segments" :key="segment.id" :value="segment">
{{segment.name}}
</option>
</select>
</div>
</div>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="formSubmitted()">Add</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {useRailSystemsStore} from "../../../stores/railSystemsStore";
import {Modal} from "bootstrap";
import {createComponent} from "../../../api/components";
export default {
name: "AddSegmentBoundaryModal",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: "",
position: {
x: 0,
y: 0,
z: 0
},
segmentA: null,
segmentB: null,
segments: [],
type: "SEGMENT_BOUNDARY"
},
warnings: []
};
},
methods: {
formSubmitted() {
const modal = Modal.getInstance(document.getElementById("addSegmentBoundaryModal"));
this.formData.segments = [this.formData.segmentA, this.formData.segmentB];
createComponent(this.rsStore.selectedRailSystem, this.formData)
.then(() => {
modal.hide();
})
.catch(error => {
this.warnings.length = 0;
this.warnings.push("Couldn't add the segment boundary: " + error.response.data.message)
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,99 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" id="addSignalModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Signal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
A <em>signal</em> is a component that relays information about your
rail system to in-world devices. Classically, rail signals show a
lamp indicator to tell information about the segment of the network
they're attached to.
</p>
<form>
<div class="mb-3">
<label for="addSignalName" class="form-label">Name</label>
<input class="form-control" type="text" id="addSignalName" v-model="formData.name" required/>
</div>
<div class="input-group mb-3">
<label for="addSignalX" class="input-group-text">X</label>
<input class="form-control" type="number" id="addSignalX" v-model="formData.position.x" required/>
<label for="addSignalY" class="input-group-text">Y</label>
<input class="form-control" type="number" id="addSignalY" v-model="formData.position.y" required/>
<label for="addSignalZ" class="input-group-text">Z</label>
<input class="form-control" type="number" id="addSignalZ" v-model="formData.position.z" required/>
</div>
<div class="mb-3">
<label for="addSignalSegment" class="form-label">Segment</label>
<select id="addSignalSegment" class="form-select" v-model="formData.segment">
<option v-for="segment in rsStore.selectedRailSystem.segments" :key="segment.id" :value="segment">
{{segment.name}}
</option>
</select>
</div>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="formSubmitted()">Add</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {useRailSystemsStore} from "../../../stores/railSystemsStore";
import {Modal} from "bootstrap";
import {createComponent} from "../../../api/components";
export default {
name: "AddSignalModal",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: "",
position: {
x: 0,
y: 0,
z: 0
},
segment: null,
type: "SIGNAL"
},
warnings: []
};
},
methods: {
formSubmitted() {
const modal = Modal.getInstance(document.getElementById("addSignalModal"));
createComponent(this.rsStore.selectedRailSystem, this.formData)
.then(() => {
modal.hide();
})
.catch(error => {
this.warnings.length = 0;
this.warnings.push("Couldn't add the signal: " + error.response.data.message)
});
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,123 +0,0 @@
<template>
<div class="modal fade" tabindex="-1" id="addSwitchModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Switch</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form>
<div class="mb-3">
<label for="addSwitchName" class="form-label">Name</label>
<input class="form-control" type="text" id="addSwitchName" v-model="formData.name" required/>
</div>
<div class="input-group mb-3">
<label for="addSwitchX" class="input-group-text">X</label>
<input class="form-control" type="number" id="addSwitchX" v-model="formData.position.x" required/>
<label for="addSwitchY" class="input-group-text">Y</label>
<input class="form-control" type="number" id="addSwitchY" v-model="formData.position.y" required/>
<label for="addSwitchZ" class="input-group-text">Z</label>
<input class="form-control" type="number" id="addSwitchZ" v-model="formData.position.z" required/>
</div>
<div class="mb-3">
<ul class="list-group overflow-auto" style="height: 200px;">
<li class="list-group-item" v-for="(config, idx) in formData.possibleConfigurations" :key="idx">
{{getConfigString(config)}}
<button class="btn btn-sm btn-secondary" @click="formData.possibleConfigurations.splice(idx, 1)">Remove</button>
</li>
</ul>
</div>
<div class="mb-3">
<label for="addSwitchConfigs" class="form-label">Select two nodes this switch can connect.</label>
<select id="addSwitchConfigs" class="form-select" multiple v-model="formData.possibleConfigQueue">
<option v-for="node in getEligibleNodes()" :key="node.id" :value="node">
{{node.name}}
</option>
</select>
<button type="button" class="btn btn-sm btn-success" @click="addPossibleConfig()">Add</button>
</div>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="formSubmitted()">Add</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {useRailSystemsStore} from "../../../stores/railSystemsStore";
import {Modal} from "bootstrap";
import {createComponent} from "../../../api/components";
export default {
name: "AddSwitchModal",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: "",
position: {
x: 0,
y: 0,
z: 0
},
possibleConfigurations: [],
possibleConfigQueue: [],
type: "SWITCH"
},
warnings: []
};
},
methods: {
formSubmitted() {
const modal = Modal.getInstance(document.getElementById("addSwitchModal"));
createComponent(this.rsStore.selectedRailSystem, this.formData)
.then(() => {
modal.hide();
})
.catch(error => {
this.warnings.length = 0;
this.warnings.push("Couldn't add the signal: " + error.response.data.message)
});
},
getConfigString(config) {
return config.nodes.map(n => n.name).join(", ");
},
getEligibleNodes() {
return this.rsStore.selectedRailSystem.components.filter(c => {
if (c.connectedNodes === undefined || c.connectedNodes === null) return false;
for (let i = 0; i < this.formData.possibleConfigurations.length; i++) {
const config = this.formData.possibleConfigurations[i];
for (const node in config.nodes) {
if (node.id === c.id) return false;
}
}
return true;
});
},
addPossibleConfig() {
if (this.formData.possibleConfigQueue.length < 2) return;
this.formData.possibleConfigurations.push({nodes: this.formData.possibleConfigQueue});
this.formData.possibleConfigQueue = [];
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,88 +0,0 @@
<template>
<div>
<h3>{{component.name}}</h3>
<small class="text-muted">
{{component.type}}
</small>
<table class="table">
<tbody>
<tr>
<th>Id</th><td>{{component.id}}</td>
</tr>
<tr>
<th>Position</th>
<td>
<table class="table table-borderless m-0 p-0">
<tbody>
<tr>
<td class="p-0">X = {{component.position.x}}</td>
<td class="p-0">Y = {{component.position.y}}</td>
<td class="p-0">Z = {{component.position.z}}</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<SignalComponentView v-if="component.type === 'SIGNAL'" :signal="component" />
<SegmentBoundaryNodeComponentView v-if="component.type === 'SEGMENT_BOUNDARY'" :node="component" />
<SwitchComponentView v-if="component.type === 'SWITCH'" :sw="component"/>
<PathNodeComponentView v-if="component.connectedNodes" :pathNode="component" :railSystem="railSystem" />
<button @click="remove()" class="btn btn-sm btn-danger">Remove</button>
</div>
<ConfirmModal
ref="removeConfirm"
:id="'removeComponentModal'"
:title="'Remove Component'"
:message="'Are you sure you want to remove this component? It, and all associated data, will be permanently deleted.'"
/>
</template>
<script>
import SignalComponentView from "./SignalComponentView.vue";
import PathNodeComponentView from "./PathNodeComponentView.vue";
import SegmentBoundaryNodeComponentView from "./SegmentBoundaryNodeComponentView.vue";
import {useRailSystemsStore} from "../../../stores/railSystemsStore";
import ConfirmModal from "../../ConfirmModal.vue";
import SwitchComponentView from "./SwitchComponentView.vue";
import {removeComponent} from "../../../api/components";
export default {
components: {
SwitchComponentView,
ConfirmModal,
SegmentBoundaryNodeComponentView,
SignalComponentView,
PathNodeComponentView
},
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
props: {
component: {
type: Object,
required: true
},
railSystem: {
type: Object,
required: true
}
},
methods: {
remove() {
this.$refs.removeConfirm.showConfirm()
.then(() => {
removeComponent(this.rsStore.selectedRailSystem, this.component.id)
.catch(console.error);
});
}
}
}
</script>
<style>
</style>

View File

@ -1,84 +0,0 @@
<template>
<h5>Connected Nodes</h5>
<ul class="list-group list-group-flush mb-2 border" v-if="pathNode.connectedNodes.length > 0" style="overflow: auto; max-height: 150px;">
<li
v-for="node in pathNode.connectedNodes"
:key="node.id"
class="list-group-item"
>
{{node.name}}
<button @click="remove(node)" class="btn btn-sm btn-danger float-end">
Remove
</button>
</li>
</ul>
<p v-if="pathNode.connectedNodes.length === 0">
There are no connected nodes.
</p>
<form
@submit.prevent="add(formData.nodeToAdd)"
v-if="getEligibleConnections().length > 0"
class="input-group mb-3"
>
<select v-model="formData.nodeToAdd" class="form-select form-select-sm">
<option v-for="node in this.getEligibleConnections()" :key="node.id" :value="node">
{{node.name}}
</option>
</select>
<button type="submit" class="btn btn-sm btn-success">Add Connection</button>
</form>
</template>
<script>
import {addConnection, removeConnection} from "../../../api/paths";
export default {
name: "PathNodeComponentView",
props: {
pathNode: {
type: Object,
required: true
},
railSystem: {
type: Object,
required: true
}
},
data() {
return {
formData: {
nodeToAdd: null
}
}
},
methods: {
getEligibleConnections() {
const nodes = [];
for (let i = 0; i < this.railSystem.components.length; i++) {
const c = this.railSystem.components[i];
if (c.id !== this.pathNode.id && c.connectedNodes !== undefined && c.connectedNodes !== null) {
let exists = false;
for (let j = 0; j < this.pathNode.connectedNodes.length; j++) {
if (this.pathNode.connectedNodes[j].id === c.id) {
exists = true;
break;
}
}
if (!exists) nodes.push(c);
}
}
return nodes;
},
remove(node) {
removeConnection(this.railSystem, this.pathNode, node);
},
add(node) {
addConnection(this.railSystem, this.pathNode, node);
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,28 +0,0 @@
<template>
<h5>Segments Connected</h5>
<div class="mb-2">
<span
v-for="segment in node.segments"
:key="segment.id"
class="badge bg-secondary me-1"
>
{{segment.name}}
</span>
</div>
</template>
<script>
export default {
name: "SegmentBoundaryNodeComponentView",
props: {
node: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,27 +0,0 @@
<template>
<h5>Signal Properties</h5>
<table class="table">
<tbody>
<tr>
<th>Connected to</th>
<td>{{signal.segment.name}}, Occupied: {{signal.segment.occupied}}</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
name: "SignalComponentView",
props: {
signal: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,44 +0,0 @@
<template>
<h5>Switch Configurations</h5>
<ul
class="list-group list-group-flush border mb-2"
v-if="sw.possibleConfigurations.length > 0"
style="overflow: auto; max-height: 150px;"
>
<li
v-for="config in sw.possibleConfigurations"
:key="config.id"
class="list-group-item"
>
<span
v-for="node in config.nodes"
:key="node.id"
class="badge bg-secondary me-1"
>
{{node.name}}
</span>
<span
v-if="sw.activeConfiguration !== null && sw.activeConfiguration.id === config.id"
class="badge bg-success"
>
Active
</span>
</li>
</ul>
</template>
<script>
export default {
name: "SwitchComponentView",
props: {
sw: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,132 +0,0 @@
/*
Helper functions to actually perform rendering of different components.
*/
import {getScaleFactor, isComponentHovered} from "./mapRenderer";
import {roundedRect, circle} from "./canvasUtils";
export function drawComponent(ctx, worldTx, component) {
const tx = DOMMatrix.fromMatrix(worldTx);
tx.translateSelf(component.position.x, component.position.z, 0);
const s = getScaleFactor();
tx.scaleSelf(1/s, 1/s, 1/s);
tx.scaleSelf(20, 20, 20);
ctx.setTransform(tx);
if (component.type === "SIGNAL") {
drawSignal(ctx, component);
} else if (component.type === "SEGMENT_BOUNDARY") {
drawSegmentBoundary(ctx, component);
} else if (component.type === "SWITCH") {
drawSwitch(ctx, component);
}
ctx.setTransform(tx.translate(0.75, -0.75));
if (component.online !== undefined && component.online !== null) {
drawOnlineIndicator(ctx, component);
}
ctx.setTransform(tx);
// Draw hovered status.
if (isComponentHovered(component)) {
ctx.fillStyle = `rgba(255, 255, 0, 0.5)`;
circle(ctx, 0, 0, 0.75);
ctx.fill();
}
}
function drawSignal(ctx) {
roundedRect(ctx, -0.3, -0.5, 0.6, 1, 0.25);
ctx.fillStyle = "black";
ctx.fill();
ctx.fillStyle = "rgb(0, 255, 0)";
circle(ctx, 0, -0.2, 0.15);
ctx.fill();
}
function drawSegmentBoundary(ctx) {
ctx.fillStyle = `rgb(150, 58, 224)`;
ctx.beginPath();
ctx.moveTo(0, -0.5);
ctx.lineTo(-0.5, 0);
ctx.lineTo(0, 0.5);
ctx.lineTo(0.5, 0);
ctx.lineTo(0, -0.5);
ctx.fill();
}
function drawSwitch(ctx, sw) {
const colors = [
`rgba(61, 148, 66, 0.25)`,
`rgba(59, 22, 135, 0.25)`,
`rgba(145, 17, 90, 0.25)`,
`rgba(191, 49, 10, 0.25)`
];
ctx.lineWidth = 1;
for (let i = 0; i < sw.possibleConfigurations.length; i++) {
const config = sw.possibleConfigurations[i];
ctx.strokeStyle = colors[i];
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 = 2 * -diff.x / mag;
diff.y = 2 * -diff.y / mag;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(diff.x, diff.y);
ctx.stroke();
}
}
ctx.fillStyle = `rgb(245, 188, 66)`;
ctx.strokeStyle = `rgb(245, 188, 66)`;
ctx.lineWidth = 0.2;
circle(ctx, 0, 0.3, 0.2);
ctx.fill();
circle(ctx, -0.3, -0.3, 0.2);
ctx.fill();
circle(ctx, 0.3, -0.3, 0.2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0.3);
ctx.lineTo(0, 0);
ctx.lineTo(0.3, -0.3);
ctx.moveTo(0, 0);
ctx.lineTo(-0.3, -0.3);
ctx.stroke();
}
function drawOnlineIndicator(ctx, component) {
ctx.lineWidth = 0.1;
if (component.online) {
ctx.fillStyle = `rgba(52, 174, 235, 128)`;
ctx.strokeStyle = `rgba(52, 174, 235, 128)`;
} else {
ctx.fillStyle = `rgba(153, 153, 153, 128)`;
ctx.strokeStyle = `rgba(153, 153, 153, 128)`;
}
ctx.beginPath();
ctx.arc(0, 0.2, 0.125, 0, Math.PI * 2);
ctx.fill();
for (let r = 0; r < 3; r++) {
ctx.beginPath();
ctx.arc(0, 0, 0.1 + 0.2 * r, 7 * Math.PI / 6, 11 * Math.PI / 6);
ctx.stroke();
}
}
export function drawConnectedNodes(ctx, worldTx, component) {
const s = getScaleFactor();
ctx.lineWidth = 5 / s;
ctx.strokeStyle = "black";
for (let i = 0; i < component.connectedNodes.length; i++) {
const node = component.connectedNodes[i];
ctx.beginPath();
ctx.moveTo(component.position.x, component.position.z);
ctx.lineTo(node.position.x, node.position.z);
ctx.stroke();
}
}

View File

@ -1,248 +0,0 @@
/*
This component is responsible for the rendering of a RailSystem in a 2d map
view.
*/
import {drawComponent, drawConnectedNodes} from "./drawing";
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;
const HOVER_RADIUS = 10;
let mapContainerDiv = null;
let mapCanvas = null;
let railSystem = null;
let mapScaleIndex = SCALE_INDEX_NORMAL;
let mapTranslation = {x: 0, y: 0};
let mapDragOrigin = null;
let mapDragTranslation = null;
let lastMousePoint = new DOMPoint(0, 0, 0, 0);
const hoveredElements = [];
export function initMap(rs) {
railSystem = rs;
console.log("Initializing map for rail system: " + rs.name);
hoveredElements.length = 0;
mapCanvas = document.getElementById("railSystemMapCanvas");
mapContainerDiv = document.getElementById("railSystemMapCanvasContainer");
mapCanvas.removeEventListener("wheel", onMouseWheel);
mapCanvas.addEventListener("wheel", onMouseWheel);
mapCanvas.removeEventListener("mousedown", onMouseDown);
mapCanvas.addEventListener("mousedown", onMouseDown);
mapCanvas.removeEventListener("mouseup", onMouseUp);
mapCanvas.addEventListener("mouseup", onMouseUp);
mapCanvas.removeEventListener("mousemove", onMouseMove);
mapCanvas.addEventListener("mousemove", onMouseMove);
// Do an initial draw.
draw();
}
export function draw() {
if (!(mapCanvas && railSystem && railSystem.components)) {
console.warn("Attempted to draw map without canvas or railSystem.");
return;
}
const ctx = mapCanvas.getContext("2d");
if (mapCanvas.width !== mapContainerDiv.clientWidth) {
mapCanvas.width = mapContainerDiv.clientWidth;
}
if (mapCanvas.height !== mapContainerDiv.clientHeight) {
mapCanvas.height = mapContainerDiv.clientHeight;
}
const width = mapCanvas.width;
const height = mapCanvas.height;
ctx.resetTransform();
ctx.fillStyle = `rgb(240, 240, 240)`;
ctx.fillRect(0, 0, width, height);
const worldTx = getWorldTransform();
ctx.setTransform(worldTx);
// Draw segments!
const segmentPoints = new Map();
railSystem.segments.forEach(segment => segmentPoints.set(segment.id, []));
for (let i = 0; i < railSystem.components.length; i++) {
const c = railSystem.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});
}
}
}
railSystem.segments.forEach(segment => {
const points = segmentPoints.get(segment.id);
const avgPoint = {x: 0, y: 0};
points.forEach(point => {
avgPoint.x += point.x;
avgPoint.y += point.y;
});
avgPoint.x /= points.length;
avgPoint.y /= points.length;
let r = 5;
points.forEach(point => {
const dist2 = Math.pow(avgPoint.x - point.x, 2) + Math.pow(avgPoint.y - point.y, 2);
if (dist2 > r * r) {
r = Math.sqrt(dist2);
}
});
ctx.fillStyle = `rgba(200, 200, 200, 0.25)`;
const p = worldPointToMap(new DOMPoint(avgPoint.x, avgPoint.y, 0, 0));
const s = getScaleFactor();
ctx.beginPath();
ctx.arc(p.x / s, p.y / s, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.font = "3px Sans-Serif";
ctx.fillText(`${segment.name}`, p.x / s, p.y / s);
});
for (let i = 0; i < railSystem.components.length; i++) {
const c = railSystem.components[i];
if (c.connectedNodes !== undefined && c.connectedNodes !== null) {
drawConnectedNodes(ctx, worldTx, c);
}
}
for (let i = 0; i < railSystem.components.length; i++) {
drawComponent(ctx, worldTx, railSystem.components[i]);
}
// Draw debug info.
ctx.resetTransform();
ctx.fillStyle = "black";
ctx.strokeStyle = "black";
ctx.font = "10px Sans-Serif";
const lastWorldPoint = mapPointToWorld(lastMousePoint);
const lines = [
"Scale factor: " + getScaleFactor(),
`(x = ${lastWorldPoint.x.toFixed(2)}, y = ${lastWorldPoint.y.toFixed(2)}, z = ${lastWorldPoint.z.toFixed(2)})`,
`Components: ${railSystem.components.length}`,
`Hovered elements: ${hoveredElements.length}`
]
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], 10, 20 + (i * 15));
}
}
export function getScaleFactor() {
return SCALE_VALUES[mapScaleIndex];
}
function getWorldTransform() {
const canvasRect = mapCanvas.getBoundingClientRect();
const scale = getScaleFactor();
const tx = new DOMMatrix();
tx.translateSelf(canvasRect.width / 2, canvasRect.height / 2, 0);
tx.scaleSelf(scale, scale, scale);
tx.translateSelf(mapTranslation.x, mapTranslation.y, 0);
if (mapDragOrigin !== null && mapDragTranslation !== null) {
tx.translateSelf(mapDragTranslation.x, mapDragTranslation.y, 0);
}
return tx;
}
export function isComponentHovered(component) {
for (let i = 0; i < hoveredElements.length; i++) {
if (hoveredElements[i].id === component.id) return true;
}
return false;
}
/**
* Maps a point on the map coordinates to world coordinates.
* @param {DOMPoint} p
* @returns {DOMPoint}
*/
function mapPointToWorld(p) {
return getWorldTransform().invertSelf().transformPoint(p);
}
/**
* Maps a point in the world to map coordinates.
* @param {DOMPoint} p
* @returns {DOMPoint}
*/
function worldPointToMap(p) {
return getWorldTransform().transformPoint(p);
}
/*
EVENT HANDLING
*/
/**
* @param {WheelEvent} event
*/
function onMouseWheel(event) {
const s = event.deltaY;
if (s > 0) {
mapScaleIndex = Math.max(0, mapScaleIndex - 1);
} else if (s < 0) {
mapScaleIndex = Math.min(SCALE_VALUES.length - 1, mapScaleIndex + 1);
}
draw();
event.stopPropagation();
return false;
}
/**
* @param {MouseEvent} event
*/
function onMouseDown(event) {
const p = getMousePoint(event);
mapDragOrigin = {x: p.x, y: p.y};
}
function onMouseUp() {
if (mapDragTranslation !== null) {
mapTranslation.x += mapDragTranslation.x;
mapTranslation.y += mapDragTranslation.y;
}
if (hoveredElements.length === 1) {
railSystem.selectedComponent = hoveredElements[0];
} else {
railSystem.selectedComponent = null;
}
mapDragOrigin = null;
mapDragTranslation = null;
}
/**
* @param {MouseEvent} event
*/
function onMouseMove(event) {
const p = getMousePoint(event);
lastMousePoint = p;
if (mapDragOrigin !== null) {
const scale = getScaleFactor();
const dx = p.x - mapDragOrigin.x;
const dy = p.y - mapDragOrigin.y;
mapDragTranslation = {x: dx / scale, y: dy / scale};
} else {
hoveredElements.length = 0;
// Populate with list of hovered elements.
for (let i = 0; i < railSystem.components.length; i++) {
const c = railSystem.components[i];
const componentPoint = new DOMPoint(c.position.x, c.position.z, 0, 1);
const mapComponentPoint = worldPointToMap(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) {
hoveredElements.push(c);
}
}
}
draw();
}
/**
* Gets the point at which the user clicked on the map.
* @param {MouseEvent} event
* @returns {DOMPoint}
*/
function getMousePoint(event) {
const rect = mapCanvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return new DOMPoint(x, y, 0, 1);
}

View File

@ -1,106 +0,0 @@
<template>
<div class="border p-2">
<input type="text" class="form-control mb-2" placeholder="Search for components" v-model="searchQuery" @keyup="refreshComponents()" />
<ul class="list-group list-group-flush" style="overflow: auto; max-height: 200px">
<li
class="list-group-item"
v-for="component in possibleComponents"
:key="component.id"
>
{{component.name}}
<button
type="button"
class="badge btn btn-sm btn-success float-end"
@click="selectComponent(component)"
v-if="!isComponentSelected(component)"
>
Select
</button>
<span v-if="isComponentSelected(component)" class="badge bg-secondary float-end">Selected</span>
</li>
</ul>
<div v-if="selectedComponents.length > 0" class="mt-2">
<span
class="badge bg-secondary me-1 mb-1"
v-for="component in selectedComponents"
:key="component.id"
>
{{component.name}}
<button class="badge rounded-pill bg-danger" @click="deselectComponent(component)">
X
</button>
</span>
</div>
<button
class="btn btn-sm btn-secondary"
type="button"
v-if="selectedComponents.length > 1"
@click="selectedComponents.length = 0"
>
Clear All
</button>
</div>
</template>
<script>
import {RailSystem} from "../../../api/railSystems";
import {searchComponents} from "../../../api/components";
export default {
name: "ComponentSelector",
props: {
railSystem: {
type: RailSystem,
required: true
},
modelValue: {
type: Array,
required: true
}
},
emits: ["update:modelValue"],
data() {
return {
searchQuery: null,
selectedComponents: [],
possibleComponents: []
};
},
mounted() {
this.refreshComponents();
},
methods: {
refreshComponents() {
searchComponents(this.railSystem, this.searchQuery)
.then(page => this.possibleComponents = page.content);
},
isComponentSelected(component) {
return this.selectedComponents.some(c => c.id === component.id);
},
selectComponent(component) {
if (this.isComponentSelected(component)) return;
this.selectedComponents.push(component);
this.selectedComponents.sort((a, b) => {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
});
this.$emit("update:modelValue", this.selectedComponents);
},
deselectComponent(component) {
const idx = this.selectedComponents.findIndex(c => c.id === component.id);
if (idx > -1) {
this.selectedComponents.splice(idx, 1);
this.$emit("update:modelValue", this.selectedComponents);
}
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,11 +0,0 @@
import {createApp} from 'vue';
import {createPinia} from 'pinia';
import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap";
const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.mount('#app')

View File

@ -1,32 +0,0 @@
import {defineStore} from "pinia";
import {refreshSegments} from "../api/segments"
import {refreshComponents} from "../api/components";
import {closeWebsocketConnection, establishWebsocketConnection} from "../api/websocket";
export const useRailSystemsStore = defineStore('RailSystemsStore', {
state: () => ({
/**
* @type {RailSystem[]}
*/
railSystems: [],
/**
* @type {RailSystem | null}
*/
selectedRailSystem: null
}),
actions: {
onSelectedRailSystemChanged() {
this.railSystems.forEach(rs => closeWebsocketConnection(rs));
if (!this.selectedRailSystem) return;
refreshSegments(this.selectedRailSystem);
refreshComponents(this.selectedRailSystem);
establishWebsocketConnection(this.selectedRailSystem);
}
},
getters: {
rsId() {
if (this.selectedRailSystem === null) return null;
return this.selectedRailSystem.id;
}
}
});

View File

@ -1,17 +0,0 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig(({command, mode}) => {
return {
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
base: mode === "production" ? "/app/" : undefined
}
})

View File

@ -3,7 +3,7 @@ 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}
* 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 {

View File

@ -4,6 +4,8 @@ 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.websocket.AppUpdateService;
import nl.andrewl.railsignalapi.model.component.Component;
import org.springframework.stereotype.Service;
@ -31,13 +33,18 @@ public class ComponentDownlinkService {
* @param downlink The downlink to register.
*/
@Transactional
public synchronized void registerDownlink(ComponentDownlink downlink) {
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.save(c);
appUpdateService.sendComponentUpdate(c.getRailSystem().getId(), c.getId());
// 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);
Set<ComponentDownlink> downlinks = downlinksByCId.computeIfAbsent(c.getId(), aLong -> new HashSet<>());
downlinks.add(downlink);
}
@ -97,4 +104,8 @@ public class ComponentDownlinkService {
}
}
}
public void sendMessage(ComponentMessage msg) {
sendMessage(msg.cId, msg);
}
}

View File

@ -2,7 +2,7 @@ package nl.andrewl.railsignalapi.live;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.live.dto.ComponentUplinkMessage;
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;
@ -22,7 +22,7 @@ public class ComponentUplinkMessageHandler {
private final SegmentService segmentService;
@Transactional
public void messageReceived(ComponentUplinkMessage msg) {
public void messageReceived(ComponentMessage msg) {
if (msg instanceof SegmentBoundaryUpdateMessage sb) {
segmentService.onBoundaryUpdate(sb);
} else if (msg instanceof SwitchUpdateMessage 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

@ -3,18 +3,32 @@ 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;
/**
* The parent class for all uplink messages that can be sent by connected
* components.
* 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 = "sb"),
@JsonSubTypes.Type(value = SwitchUpdateMessage.class, name = "sw")
@JsonSubTypes.Type(value = SegmentBoundaryUpdateMessage.class, name = "SEGMENT_BOUNDARY_UPDATE"),
@JsonSubTypes.Type(value = SwitchUpdateMessage.class, name = "SWITCH_UPDATE"),
@JsonSubTypes.Type(value = ErrorMessage.class, name = "ERROR")
})
public abstract class ComponentUplinkMessage {
@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;
}
}

View File

@ -1,9 +1,12 @@
package nl.andrewl.railsignalapi.live.dto;
import lombok.NoArgsConstructor;
/**
* Message that's sent by segment boundaries when a train crosses it.
*/
public class SegmentBoundaryUpdateMessage extends ComponentUplinkMessage {
@NoArgsConstructor
public class SegmentBoundaryUpdateMessage extends ComponentMessage {
/**
* The id of the segment that a train detected by the segment boundary is
* moving towards.

View File

@ -1,6 +1,26 @@
package nl.andrewl.railsignalapi.live.dto;
public record SegmentStatusMessage (
long cId,
boolean occupied
) {}
import lombok.Getter;
/**
* A message that's sent to signal components regarding a change in a segment's
* status.
*/
@Getter
public class SegmentStatusMessage extends ComponentMessage {
/**
* The id of the segment that updated.
*/
private final long sId;
/**
* Whether the segment is occupied.
*/
private final boolean occupied;
public SegmentStatusMessage(long cId, long sId, boolean occupied) {
super(cId, "SEGMENT_STATUS");
this.sId = sId;
this.occupied = occupied;
}
}

View File

@ -1,13 +1,19 @@
package nl.andrewl.railsignalapi.live.dto;
import java.util.Set;
import lombok.NoArgsConstructor;
/**
* Message that's sent by a switch when its active configuration is updated.
*/
public class SwitchUpdateMessage extends ComponentUplinkMessage {
@NoArgsConstructor
public class SwitchUpdateMessage extends ComponentMessage {
/**
* A set of path node ids that represents the active configuration.
* The id of the configuration that's active.
*/
public Set<Long> configuration;
public long activeConfigId;
public SwitchUpdateMessage(long cId, long activeConfigId) {
super(cId, "SWITCH_UPDATE");
this.activeConfigId = activeConfigId;
}
}

View File

@ -1,5 +1,10 @@
package nl.andrewl.railsignalapi.live.tcp_socket;
/**
* A message that's sent in response to a client's attempt to connect.
* @param valid Whether the connection is valid.
* @param message A message.
*/
public record ConnectMessage(
boolean valid,
String message

View File

@ -4,7 +4,7 @@ import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.live.ComponentDownlink;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.ComponentUplinkMessageHandler;
import nl.andrewl.railsignalapi.live.dto.ComponentUplinkMessage;
import nl.andrewl.railsignalapi.live.dto.ComponentMessage;
import nl.andrewl.railsignalapi.util.JsonUtils;
import java.io.DataInputStream;
@ -43,10 +43,20 @@ public class TcpLinkManager extends ComponentDownlink implements Runnable {
@Override
public void run() {
try {
downlinkService.registerDownlink(this);
} catch (Exception e) {
log.error("An error occurred while registering a downlink.");
try {
shutdown();
} catch (IOException ex) {
log.error("An error occurred while shutting down a downlink.", ex);
}
return;
}
while (!socket.isClosed()) {
try {
var msg = JsonUtils.readMessage(in, ComponentUplinkMessage.class);
var msg = JsonUtils.readMessage(in, ComponentMessage.class);
uplinkMessageHandler.messageReceived(msg);
} catch (IOException e) {
log.warn("An error occurred while receiving an uplink message.", e);

View File

@ -53,6 +53,11 @@ public class AppUpdateService {
log.info("De-registered an app websocket session.");
}
/**
* Sends an update to any connected apps.
* @param rsId The id of the rail system that the update pertains to.
* @param msg The message to send.
*/
public synchronized void sendUpdate(long rsId, Object msg) {
Set<WebSocketSession> sessionsForRs = sessions.get(rsId);
if (sessionsForRs != null) {

View File

@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.ComponentUplinkMessageHandler;
import nl.andrewl.railsignalapi.live.dto.ComponentUplinkMessage;
import nl.andrewl.railsignalapi.live.dto.ComponentMessage;
import nl.andrewl.railsignalapi.util.JsonUtils;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@ -13,6 +13,8 @@ import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
/**
* Handler for websocket connections that components open to send and receive
* real-time updates from the server.
@ -28,12 +30,21 @@ public class ComponentWebsocketHandler extends TextWebSocketHandler {
@Transactional(readOnly = true)
public void afterConnectionEstablished(WebSocketSession session) {
long tokenId = (long) session.getAttributes().get("tokenId");
try {
componentDownlinkService.registerDownlink(new WebsocketDownlink(tokenId, session));
} catch (Exception e) {
log.error("An error occurred while registering a new websocket downlink.", e);
try {
session.close();
} catch (IOException ex) {
log.error("An error occurred while closing a websocket downlink.", e);
}
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
var msg = JsonUtils.readMessage(message.getPayload(), ComponentUplinkMessage.class);
var msg = JsonUtils.readMessage(message.getPayload(), ComponentMessage.class);
uplinkMessageHandler.messageReceived(msg);
}

View File

@ -7,9 +7,7 @@ import lombok.Setter;
import nl.andrewl.railsignalapi.model.RailSystem;
import javax.persistence.*;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* A switch is a component that directs traffic between several connected
@ -38,12 +36,4 @@ public class Switch extends PathNode {
this.possibleConfigurations = possibleConfigurations;
this.activeConfiguration = activeConfiguration;
}
public Optional<SwitchConfiguration> findConfiguration(Set<Long> pathNodeIds) {
for (var config : possibleConfigurations) {
Set<Long> configNodeIds = config.getNodes().stream().map(Component::getId).collect(Collectors.toSet());
if (pathNodeIds.equals(configNodeIds)) return Optional.of(config);
}
return Optional.empty();
}
}

View File

@ -14,6 +14,10 @@ public class WebConfig implements WebMvcConfigurer {
// Configure resource handlers to use the /app directory for all vue frontend stuff.
registry.addResourceHandler("/app/**")
.addResourceLocations("classpath:/app/");
// Configure resource handlers for driver files.
registry.addResourceHandler("/driver/**")
.addResourceLocations("classpath:/driver/");
}
@Override

View File

@ -68,11 +68,15 @@ public class SegmentService {
private void sendSegmentOccupiedStatus(Segment segment) {
for (var signal : segment.getSignals()) {
downlinkService.sendMessage(signal.getId(), new SegmentStatusMessage(signal.getId(), segment.isOccupied()));
downlinkService.sendMessage(signal.getId(), new SegmentStatusMessage(signal.getId(), segment.getId(), segment.isOccupied()));
appUpdateService.sendComponentUpdate(segment.getRailSystem().getId(), signal.getId());
}
}
/**
* Handles updates from segment boundary components.
* @param msg The update message.
*/
@Transactional
public void onBoundaryUpdate(SegmentBoundaryUpdateMessage msg) {
var segmentBoundary = segmentBoundaryRepository.findById(msg.cId)

View File

@ -3,9 +3,11 @@ package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
import nl.andrewl.railsignalapi.live.dto.ErrorMessage;
import nl.andrewl.railsignalapi.live.dto.SwitchUpdateMessage;
import nl.andrewl.railsignalapi.live.websocket.AppUpdateService;
import nl.andrewl.railsignalapi.model.component.Switch;
import nl.andrewl.railsignalapi.model.component.SwitchConfiguration;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -23,10 +25,15 @@ public class SwitchService {
@Transactional
public void onSwitchUpdate(SwitchUpdateMessage msg) {
switchRepository.findById(msg.cId).ifPresent(sw -> {
sw.findConfiguration(msg.configuration).ifPresent(config -> {
sw.getPossibleConfigurations().stream()
.filter(c -> c.getId().equals(msg.activeConfigId))
.findFirst()
.ifPresentOrElse(config -> {
sw.setActiveConfiguration(config);
switchRepository.save(sw);
appUpdateService.sendComponentUpdate(sw.getRailSystem().getId(), sw.getId());
}, () -> {
downlinkService.sendMessage(new ErrorMessage(sw.getId(), "Invalid active config id."));
});
});
}

View File

@ -5,8 +5,6 @@ spring.jpa.open-in-view=false
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=1
spring.thymeleaf.enabled=false
server.port=8080
server.tomcat.threads.max=10
server.tomcat.threads.min-spare=2

View File

@ -0,0 +1,41 @@
function tableLength(t)
local c = 0
for _ in pairs(t) do
c = c + 1
end
return c
end
function startsWith(str, s)
return str:find(s, 1, true) == 1
end
function readNum(validationFunction)
local func = validationFunction or function (n)
return n ~= nil
end
local num = nil
while true do
local s = io.read()
if s ~= nil then num = tonumber(s) end
if not func(num) then
print("The number you entered is not valid.")
else
return num
end
end
end
function readStr(validationFunction)
local func = validationFunction or function (s)
return s ~= nil and string.len(s) > 0
end
while true do
local str = io.read()
if func(str) then
return str
else
print("The string you entered is not valid.")
end
end
end