Merge pull request #2 from andrewlalis/model-refactor

Model refactor
This commit is contained in:
Andrew Lalis 2022-05-08 20:05:09 +02:00 committed by GitHub
commit 1906111ab8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
104 changed files with 6162 additions and 1490 deletions

3
.gitignore vendored
View File

@ -34,3 +34,6 @@ build/
*.mv.db
*.trace.db
src/main/resources/app
/build_system

41
build_system.d Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env dub
/+ dub.sdl:
dependency "dsh" version="~>1.6.1"
+/
/**
* 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;
const DIST = "./src/main/resources/app";
void main(string[] args) {
print("Building RailSignalAPI");
chdir("railsignal-app");
print("Building app...");
runOrQuit("npm run build");
print("Copying dist to %s", DIST);
chdir("..");
removeIfExists(DIST);
mkdir(DIST);
copyDir("railsignal-app/dist", DIST);
print("Building API...");
runOrQuit("mvn clean package spring-boot:repackage");
print("Build complete!");
if (args.length > 1 && args[1] == "run") {
string f = findFile("target", "^.+\\.jar$", false);
if (f == null) {
error("Could not find jar file!");
} else {
print("Running the program.");
run("java -jar " ~ f);
}
}
}

View File

@ -5,7 +5,7 @@
<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>
@ -40,6 +40,7 @@
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.1.212</version>
</dependency>
<dependency>

View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080/api

View File

@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080/api

View File

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

28
railsignal-app/.gitignore vendored Normal file
View File

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

35
railsignal-app/README.md Normal file
View File

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

@ -0,0 +1,15 @@
#!/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);
}

13
railsignal-app/index.html Normal file
View File

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

3226
railsignal-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"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.

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

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,91 @@
<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"
>
<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="removeRailSystem()"
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";
export default {
name: "AppNavbar",
components: {AddRailSystem: AddRailSystemModal, ConfirmModal},
setup() {
const rsStore = useRailSystemsStore();
rsStore.$subscribe(mutation => {
const evt = mutation.events;
if (evt.key === "selectedRailSystem" && evt.newValue !== null) {
rsStore.fetchSelectedRailSystemData();
}
});
return {
rsStore
};
},
mounted() {
this.rsStore.refreshRailSystems();
},
methods: {
removeRailSystem() {
this.$refs.confirmModal.showConfirm()
.then(() => this.rsStore.removeRailSystem(this.rsStore.selectedRailSystem));
}
}
}
</script>
<style scoped>
</style>

View File

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

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

@ -0,0 +1,77 @@
<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";
export default {
name: "AddRailSystem",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: ""
},
warnings: []
};
},
methods: {
formSubmitted() {
this.rsStore.createRailSystem(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

@ -0,0 +1,86 @@
<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";
export default {
name: "AddSegmentModal",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
name: ""
},
warnings: []
};
},
methods: {
formSubmitted() {
const modal = Modal.getInstance(document.getElementById("addSegmentModal"));
this.rsStore.addSegment(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

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

@ -0,0 +1,73 @@
<template>
<h3>Rail System: <em>{{railSystem.name}}</em></h3>
<SegmentsView />
<button
type="button"
class="btn btn-success btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#addSegmentModal"
>
Add Segment
</button>
<AddSegmentModal />
<span v-if="railSystem.segments && railSystem.segments.length > 0">
<button
type="button"
class="btn btn-success btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#addSignalModal"
>
Add Signal
</button>
<AddSignalModal />
<button
type="button"
class="btn btn-success btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#addSegmentBoundaryModal"
>
Add Segment Boundary
</button>
<AddSegmentBoundaryModal />
</span>
<span v-if="railSystem.components && railSystem.components.length > 1">
<button
type="button"
class="btn btn-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#addSwitchModal"
>
Add Switch
</button>
<AddSwitchModal />
</span>
</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";
export default {
name: "RailSystemPropertiesView",
components: {
AddSwitchModal,
AddSegmentBoundaryModal,
AddSignalModal,
AddSegmentModal,
SegmentsView
},
props: {
railSystem: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,31 @@
<template>
<h5>Segments</h5>
<ul class="list-group overflow-auto mb-2" style="max-height: 200px;">
<li
v-for="segment in rsStore.selectedRailSystem.segments"
:key="segment.id"
class="list-group-item"
>
{{segment.name}}
<button @click.prevent="rsStore.removeSegment(segment.id)" class="btn btn-sm btn-danger float-end">Remove</button>
</li>
</ul>
</template>
<script>
import {useRailSystemsStore} from "../../stores/railSystemsStore";
export default {
name: "SegmentsView.vue",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
}
}
}
</script>
<style scoped>
</style>

View File

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

@ -0,0 +1,112 @@
<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";
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];
this.rsStore.addComponent(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

@ -0,0 +1,98 @@
<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";
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"));
this.rsStore.addComponent(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

@ -0,0 +1,122 @@
<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">Segment</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";
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"));
this.rsStore.addComponent(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

@ -0,0 +1,82 @@
<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>
<tr>
<th>Online</th><td>{{component.online}}</td>
</tr>
</tbody>
</table>
<SignalComponentView v-if="component.type === 'SIGNAL'" :signal="component" />
<SegmentBoundaryNodeComponentView v-if="component.type === 'SEGMENT_BOUNDARY'" :node="component" />
<PathNodeComponentView v-if="component.connectedNodes" :pathNode="component" :railSystem="railSystem" />
<button @click="removeComponent()" 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";
export default {
components: {
ConfirmModal,
SegmentBoundaryNodeComponentView,
SignalComponentView,
PathNodeComponentView
},
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
props: {
component: {
type: Object,
required: true
},
railSystem: {
type: Object,
required: true
}
},
methods: {
removeComponent() {
this.$refs.removeConfirm.showConfirm()
.then(() => this.rsStore.removeComponent(this.component.id));
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,92 @@
<template>
<h5>Connected Nodes</h5>
<table class="table" v-if="pathNode.connectedNodes.length > 0">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr v-for="node in pathNode.connectedNodes" :key="node.id">
<td>{{node.name}}</td>
<td>
<button
@click="rsStore.removeConnection(pathNode, node)"
class="btn btn-sm btn-danger"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="pathNode.connectedNodes.length === 0">
There are no connected nodes.
</p>
<form
@submit.prevent="rsStore.addConnection(pathNode, 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 {useRailSystemsStore} from "../../../stores/railSystemsStore";
export default {
name: "PathNodeComponentView",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
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;
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,31 @@
<template>
<h5>Segments Connected</h5>
<table class="table">
<thead>
<tr>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr v-for="segment in node.segments" :key="segment.id">
<td>{{segment.name}}</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
name: "SegmentBoundaryNodeComponentView",
props: {
node: {
type: Object,
required: true
}
}
}
</script>
<style scoped>
</style>

View File

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

View File

@ -0,0 +1,132 @@
/*
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

@ -0,0 +1,248 @@
/*
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

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

@ -0,0 +1,163 @@
import { defineStore } from "pinia";
import axios from "axios";
export const useRailSystemsStore = defineStore('RailSystemsStore', {
state: () => ({
railSystems: [],
/**
* @type {{segments: [Object], components: [Object], selectedComponent: Object} | null}
*/
selectedRailSystem: null,
apiUrl: import.meta.env.VITE_API_URL
}),
actions: {
refreshRailSystems() {
return new Promise((resolve, reject) => {
axios.get(this.apiUrl + "/rs")
.then(response => {
this.railSystems = response.data;
resolve();
})
.catch(error => {
reject(error);
});
});
},
createRailSystem(name) {
return new Promise((resolve, reject) => {
axios.post(this.apiUrl + "/rs", {name: name})
.then(response => {
const newId = response.data.id;
this.refreshRailSystems()
.then(() => resolve(this.railSystems.find(rs => rs.id === newId)))
.catch(error => reject(error));
})
.catch(error => reject(error));
});
},
removeRailSystem(rs) {
return new Promise((resolve, reject) => {
axios.delete(this.apiUrl + "/rs/" + rs.id)
.then(() => {
this.selectedRailSystem = null;
this.refreshRailSystems()
.then(() => resolve)
.catch(error => reject(error));
})
})
},
refreshSegments(rs) {
return new Promise(resolve => {
axios.get(`${this.apiUrl}/rs/${rs.id}/s`)
.then(response => {
rs.segments = response.data;
resolve();
});
});
},
refreshAllComponents(rs) {
return new Promise(resolve => {
axios.get(`${this.apiUrl}/rs/${rs.id}/c`)
.then(response => {
rs.selectedComponent = null;
rs.components = response.data;
resolve();
});
});
},
fetchSelectedRailSystemData() {
if (!this.selectedRailSystem) return;
this.refreshSegments(this.selectedRailSystem);
this.refreshAllComponents(this.selectedRailSystem);
},
addSegment(name) {
const rs = this.selectedRailSystem;
return new Promise((resolve, reject) => {
axios.post(`${this.apiUrl}/rs/${rs.id}/s`, {name: name})
.then(() => {
this.refreshSegments(rs)
.then(() => resolve())
.catch(error => reject(error));
})
.catch(error => reject(error));
});
},
removeSegment(id) {
const rs = this.selectedRailSystem;
axios.delete(`${this.apiUrl}/rs/${rs.id}/s/${id}`)
.then(() => this.refreshSegments(rs))
.catch(error => console.log(error));
},
addComponent(data) {
const rs = this.selectedRailSystem;
return new Promise((resolve, reject) => {
axios.post(`${this.apiUrl}/rs/${rs.id}/c`, data)
.then(() => {
this.refreshAllComponents(rs)
.then(() => resolve())
.catch(error => reject(error));
})
.catch(error => reject(error));
});
},
removeComponent(id) {
const rs = this.selectedRailSystem;
axios.delete(`${this.apiUrl}/rs/${rs.id}/c/${id}`)
.then(() => this.refreshAllComponents(rs))
.catch(error => console.log(error));
},
fetchComponentData(component) {
return new Promise(resolve => {
const rs = this.selectedRailSystem;
axios.get(`${this.apiUrl}/rs/${rs.id}/c/${component.id}`)
.then(response => resolve(response.data))
.catch(error => console.log(error));
});
},
refreshComponents(components) {
const rs = this.selectedRailSystem;
for (let i = 0; i < components.length; i++) {
axios.get(`${this.apiUrl}/rs/${rs.id}/c/${components[i].id}`)
.then(resp => {
const idx = this.selectedRailSystem.components.findIndex(c => c.id === resp.data.id);
if (idx > -1) this.selectedRailSystem.components[idx] = resp.data;
})
.catch(error => console.log(error));
}
},
updateConnections(pathNode) {
const rs = this.selectedRailSystem;
return new Promise(resolve => {
axios.patch(
`${this.apiUrl}/rs/${rs.id}/c/${pathNode.id}/connectedNodes`,
pathNode
)
.then(response => {
pathNode.connectedNodes = response.data.connectedNodes;
resolve();
})
.catch(error => console.log(error));
});
},
addConnection(pathNode, other) {
pathNode.connectedNodes.push(other);
this.updateConnections(pathNode)
.then(() => {
this.refreshComponents(pathNode.connectedNodes);
});
},
removeConnection(pathNode, other) {
const idx = pathNode.connectedNodes.findIndex(n => n.id === other.id);
if (idx > -1) {
pathNode.connectedNodes.splice(idx, 1);
this.updateConnections(pathNode)
.then(() => {
const nodes = [];
nodes.push(pathNode.connectedNodes);
nodes.push(other);
this.refreshComponents(nodes);
})
}
}
}
});

View File

@ -0,0 +1,14 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

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,28 @@
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.jpa.repository.JpaRepository;
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> {
Optional<T> findByIdAndRailSystemId(long id, long rsId);
boolean existsByNameAndRailSystem(String name, RailSystem rs);
@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.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,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> findAllByRailSystemId(long rsId);
Optional<Segment> findByIdAndRailSystemId(long id, long rsId);
void deleteAllByRailSystem(RailSystem rs);
}

View File

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

View File

@ -1,26 +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 " +
"ORDER BY s.name")
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

@ -1,38 +0,0 @@
package nl.andrewl.railsignalapi.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@NoArgsConstructor
@Getter
public class Branch {
@Id
@GeneratedValue
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private RailSystem railSystem;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Setter
private BranchStatus status;
@OneToMany(mappedBy = "branch", orphanRemoval = true)
private Set<SignalBranchConnection> signalConnections;
public Branch(RailSystem railSystem, String name, BranchStatus status) {
this.railSystem = railSystem;
this.name = name;
this.status = status;
this.signalConnections = new HashSet<>();
}
}

View File

@ -1,6 +0,0 @@
package nl.andrewl.railsignalapi.model;
public enum BranchStatus {
FREE,
OCCUPIED
}

View File

@ -1,5 +1,8 @@
package nl.andrewl.railsignalapi.model;
/**
* A cardinal direction, useful for some components.
*/
public enum Direction {
NORTH,
SOUTH,

View File

@ -0,0 +1,31 @@
package nl.andrewl.railsignalapi.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
/**
* A simple label element that allows text to be placed in the rail system
* model.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Label {
@Id
@GeneratedValue
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private RailSystem railSystem;
@Column(nullable = false, length = 63)
private String text;
public Label(RailSystem rs, String text) {
this.railSystem = rs;
this.text = text;
}
}

View File

@ -8,6 +8,9 @@ import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
/**
* Represents a closed system that contains a collection of components.
*/
@Entity
@Getter
@NoArgsConstructor
@ -16,6 +19,9 @@ public class RailSystem {
@GeneratedValue
private Long id;
/**
* The name of this system.
*/
@Column(nullable = false, unique = true)
private String name;

View File

@ -0,0 +1,58 @@
package nl.andrewl.railsignalapi.model;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.railsignalapi.model.component.SegmentBoundaryNode;
import nl.andrewl.railsignalapi.model.component.Signal;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
/**
* Represents a traversable segment of a rail system that components can
* connect to.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Segment {
@Id
@GeneratedValue
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private RailSystem railSystem;
/**
* A unique name for this segment.
*/
@Column
private String name;
/**
* The signals that are connected to this branch.
*/
@OneToMany(mappedBy = "segment")
private Set<Signal> signals;
/**
* The set of nodes from which trains can enter and exit this segment.
*/
@ManyToMany(mappedBy = "segments")
private Set<SegmentBoundaryNode> boundaryNodes;
public Segment(RailSystem railSystem, String name) {
this.railSystem = railSystem;
this.name = name;
this.signals = new HashSet<>();
this.boundaryNodes = new HashSet<>();
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
return o instanceof Segment s && this.id != null && this.id.equals(s.id);
}
}

View File

@ -1,40 +0,0 @@
package nl.andrewl.railsignalapi.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.persistence.*;
import java.util.Set;
@Entity
@Getter
@NoArgsConstructor
public class Signal {
@Id
@GeneratedValue
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private RailSystem railSystem;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "signal", orphanRemoval = true, cascade = CascadeType.ALL)
private Set<SignalBranchConnection> branchConnections;
@Embedded
private Position position;
@Column(nullable = false)
@Setter
private boolean online = false;
public Signal(RailSystem railSystem, String name, Position position, Set<SignalBranchConnection> branchConnections) {
this.railSystem = railSystem;
this.name = name;
this.position = position;
this.branchConnections = branchConnections;
}
}

View File

@ -1,57 +0,0 @@
package nl.andrewl.railsignalapi.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@Entity
@NoArgsConstructor
@Getter
public class SignalBranchConnection implements Comparable<SignalBranchConnection> {
@Id
@GeneratedValue
private Long id;
@ManyToOne(optional = false)
private Signal signal;
@ManyToOne(optional = false, cascade = CascadeType.PERSIST)
private Branch branch;
@Enumerated(EnumType.STRING)
private Direction direction;
@ManyToMany
private Set<SignalBranchConnection> reachableSignalConnections;
public SignalBranchConnection(Signal signal, Branch branch, Direction direction) {
this.signal = signal;
this.branch = branch;
this.direction = direction;
reachableSignalConnections = new HashSet<>();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
return this.id != null &&
o instanceof SignalBranchConnection sbc && sbc.getId() != null &&
this.id.equals(sbc.getId());
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
@Override
public int compareTo(SignalBranchConnection o) {
int c = Long.compare(this.getSignal().getId(), o.getSignal().getId());
if (c != 0) return c;
return this.direction.compareTo(o.getDirection());
}
}

View File

@ -1,30 +0,0 @@
package nl.andrewl.railsignalapi.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Getter
@NoArgsConstructor
public class Switch {
@Id
@GeneratedValue
private Long id;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private RailSystem railSystem;
@Column(nullable = false)
private String name;
@Embedded
private Position position;
@Column(nullable = false)
private int state;
@Column(nullable = false)
private int maxStates;
}

View File

@ -1,20 +0,0 @@
package nl.andrewl.railsignalapi.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@NoArgsConstructor
@Getter
public class Train {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Branch currentBranch;
}

View File

@ -0,0 +1,79 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import nl.andrewl.railsignalapi.model.RailSystem;
import javax.persistence.*;
/**
* Represents a physical component of the rail system that the API can interact
* with, and send or receive data from. For example, a signal, switch, or
* detector.
*/
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public abstract class Component {
@Id
@GeneratedValue
private Long id;
/**
* The rail system that this component belongs to.
*/
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private RailSystem railSystem;
/**
* The position of this component in the system.
*/
@Embedded
private Position position;
/**
* A human-readable name for the component. This must be unique among all
* components in the rail system.
*/
@Column(nullable = false)
private String name;
/**
* The type of this component.
*/
@Enumerated(EnumType.ORDINAL)
private ComponentType type;
/**
* Whether this component is online, meaning that an in-world device is
* currently connected to relay information regarding this component.
*/
@Column(nullable = false)
@Setter
private boolean online = false;
public Component(RailSystem railSystem, Position position, String name, ComponentType type) {
this.railSystem = railSystem;
this.position = position;
this.name = name;
this.type = type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
return o instanceof Component c && this.id != null && this.id.equals(c.id);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(id);
if (name != null) sb.append('[').append(name).append(']');
sb.append(String.format("@[x=%.1f,y=%.1f,z=%.1f]", position.getX(), position.getY(), position.getZ()));
return sb.toString();
}
}

View File

@ -0,0 +1,7 @@
package nl.andrewl.railsignalapi.model.component;
public enum ComponentType {
SIGNAL,
SWITCH,
SEGMENT_BOUNDARY
}

View File

@ -0,0 +1,30 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.railsignalapi.model.RailSystem;
import javax.persistence.*;
import java.util.Set;
/**
* A node that, together with other nodes, forms a path that trains can follow
* to traverse through segments in the rail system.
*/
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public abstract class PathNode extends Component {
/**
* The set of nodes that this one is connected to.
*/
@ManyToMany(cascade = CascadeType.DETACH)
private Set<PathNode> connectedNodes;
public PathNode(RailSystem railSystem, Position position, String name, ComponentType type, Set<PathNode> connectedNodes) {
super(railSystem, position, name, type);
this.connectedNodes = connectedNodes;
}
}

View File

@ -1,4 +1,4 @@
package nl.andrewl.railsignalapi.model;
package nl.andrewl.railsignalapi.model.component;
import lombok.AllArgsConstructor;
import lombok.Data;
@ -6,6 +6,9 @@ import lombok.NoArgsConstructor;
import javax.persistence.Embeddable;
/**
* A three-dimensional position for a component within a system.
*/
@Embeddable
@Data
@AllArgsConstructor

View File

@ -0,0 +1,32 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Segment;
import javax.persistence.Entity;
import javax.persistence.ManyToMany;
import java.util.Set;
/**
* Component that relays information about trains traversing from one segment
* to another. It links exactly two segments together at a specific point.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class SegmentBoundaryNode extends PathNode {
/**
* The set of segments that this boundary node connects. This should
* generally always have exactly two segments.
*/
@ManyToMany
private Set<Segment> segments;
public SegmentBoundaryNode(RailSystem railSystem, Position position, String name, Set<PathNode> connectedNodes, Set<Segment> segments) {
super(railSystem, position, name, ComponentType.SEGMENT_BOUNDARY, connectedNodes);
this.segments = segments;
}
}

View File

@ -0,0 +1,32 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Segment;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.ManyToOne;
/**
* A signal is a component that relays the status of a connected segment to
* some sort of in-world representation, whether that be a certain light
* color, or electrical signal.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Signal extends Component {
/**
* The segment that this signal connects to.
*/
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private Segment segment;
public Signal(RailSystem railSystem, Position position, String name, Segment segment) {
super(railSystem, position, name, ComponentType.SIGNAL);
this.segment = segment;
}
}

View File

@ -0,0 +1,39 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import nl.andrewl.railsignalapi.model.RailSystem;
import javax.persistence.*;
import java.util.Set;
/**
* A switch is a component that directs traffic between several connected
* segments.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Switch extends PathNode {
/**
* The set of all possible configurations that this switch can be in.
*/
@OneToMany(mappedBy = "switchComponent", orphanRemoval = true, cascade = CascadeType.ALL)
private Set<SwitchConfiguration> possibleConfigurations;
/**
* The switch configuration that this switch is currently in. If null, then
* we don't know what configuration the switch is in.
*/
@OneToOne(fetch = FetchType.LAZY)
@Setter
private SwitchConfiguration activeConfiguration;
public Switch(RailSystem railSystem, Position position, String name, Set<PathNode> connectedNodes, Set<SwitchConfiguration> possibleConfigurations, SwitchConfiguration activeConfiguration) {
super(railSystem, position, name, ComponentType.SWITCH, connectedNodes);
this.possibleConfigurations = possibleConfigurations;
this.activeConfiguration = activeConfiguration;
}
}

View File

@ -0,0 +1,47 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.Set;
/**
* A possible connection that can be made between path nodes, if this is set
* as an active configuration in the linked switch component.
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class SwitchConfiguration {
@Id
@GeneratedValue
private Long id;
/**
* The switch component that this configuration belongs to.
*/
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private Switch switchComponent;
/**
* The set of nodes that this switch configuration connects. This should
* be almost always a set of two nodes.
*/
@ManyToMany(fetch = FetchType.EAGER)
private Set<PathNode> nodes;
public SwitchConfiguration(Switch switchComponent, Set<PathNode> nodes) {
this.switchComponent = switchComponent;
this.nodes = nodes;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
return o instanceof SwitchConfiguration sc &&
sc.switchComponent.equals(this.switchComponent) &&
sc.nodes.equals(this.nodes);
}
}

View File

@ -5,10 +5,10 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping(path = "/")
@RequestMapping(path = {"/", "/app", "/home", "/index.html", "/index"})
public class IndexPageController {
@GetMapping
public String getIndex() {
return "index";
return "forward:/app/index.html";
}
}

View File

@ -1,38 +0,0 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.BranchResponse;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import nl.andrewl.railsignalapi.service.BranchService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping(path = "/api/railSystems/{rsId}/branches")
@RequiredArgsConstructor
public class BranchesController {
private final BranchService branchService;
@GetMapping
public List<BranchResponse> getAllBranches(@PathVariable long rsId) {
return branchService.getAllBranches(rsId);
}
@GetMapping(path = "/{branchId}")
public BranchResponse getBranch(@PathVariable long rsId, @PathVariable long branchId) {
return branchService.getBranch(rsId, branchId);
}
@GetMapping(path = "/{branchId}/signals")
public List<SignalResponse> getBranchSignals(@PathVariable long rsId, @PathVariable long branchId) {
return branchService.getConnectedSignals(rsId, branchId);
}
@DeleteMapping(path = "/{branchId}")
public ResponseEntity<?> deleteBranch(@PathVariable long rsId, @PathVariable long branchId) {
branchService.deleteBranch(rsId, branchId);
return ResponseEntity.noContent().build();
}
}

View File

@ -0,0 +1,44 @@
package nl.andrewl.railsignalapi.rest;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.PathNodeUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.component.out.ComponentResponse;
import nl.andrewl.railsignalapi.service.ComponentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping(path = "/api/rs/{rsId}/c")
@RequiredArgsConstructor
public class ComponentsApiController {
private final ComponentService componentService;
@GetMapping
public List<ComponentResponse> getAllComponents(@PathVariable long rsId) {
return componentService.getComponents(rsId);
}
@GetMapping(path = "/{cId}")
public ComponentResponse getComponent(@PathVariable long rsId, @PathVariable long cId) {
return componentService.getComponent(rsId, cId);
}
@PostMapping
public ComponentResponse createComponent(@PathVariable long rsId, @RequestBody ObjectNode data) {
return componentService.create(rsId, data);
}
@DeleteMapping(path = "/{cId}")
public ResponseEntity<Void> removeComponent(@PathVariable long rsId, @PathVariable long cId) {
componentService.removeComponent(rsId, cId);
return ResponseEntity.noContent().build();
}
@PatchMapping(path = "/{cId}/connectedNodes")
public ComponentResponse updateConnectedNodes(@PathVariable long rsId, @PathVariable long cId, @RequestBody PathNodeUpdatePayload payload) {
return componentService.updatePath(rsId, cId, payload);
}
}

View File

@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping(path = "/api/railSystems")
@RequestMapping(path = "/api/rs")
@RequiredArgsConstructor
public class RailSystemsApiController {
private final RailSystemService railSystemService;

View File

@ -0,0 +1,39 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.FullSegmentResponse;
import nl.andrewl.railsignalapi.rest.dto.SegmentPayload;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
import nl.andrewl.railsignalapi.service.SegmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping(path = "/api/rs/{rsId}/s")
@RequiredArgsConstructor
public class SegmentsApiController {
private final SegmentService segmentService;
@GetMapping
public List<SegmentResponse> getSegments(@PathVariable long rsId) {
return segmentService.getSegments(rsId);
}
@GetMapping(path = "/{sId}")
public FullSegmentResponse getSegment(@PathVariable long rsId, @PathVariable long sId) {
return segmentService.getSegment(rsId, sId);
}
@PostMapping
public FullSegmentResponse createSegment(@PathVariable long rsId, @RequestBody SegmentPayload payload) {
return segmentService.create(rsId, payload);
}
@DeleteMapping(path = "/{sId}")
public ResponseEntity<Void> removeSegment(@PathVariable long rsId, @PathVariable long sId) {
segmentService.remove(rsId, sId);
return ResponseEntity.noContent().build();
}
}

View File

@ -1,44 +0,0 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.SignalConnectionsUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.SignalCreationPayload;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import nl.andrewl.railsignalapi.service.SignalService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping(path = "/api/railSystems/{rsId}/signals")
@RequiredArgsConstructor
public class SignalsApiController {
private final SignalService signalService;
@PostMapping
public SignalResponse createSignal(@PathVariable long rsId, @RequestBody SignalCreationPayload payload) {
return signalService.createSignal(rsId, payload);
}
@GetMapping
public List<SignalResponse> getSignals(@PathVariable long rsId) {
return signalService.getAllSignals(rsId);
}
@GetMapping(path = "/{sigId}")
public SignalResponse getSignal(@PathVariable long rsId, @PathVariable long sigId) {
return signalService.getSignal(rsId, sigId);
}
@PostMapping(path = "/{sigId}/signalConnections")
public SignalResponse updateSignalConnections(@PathVariable long rsId, @PathVariable long sigId, @RequestBody SignalConnectionsUpdatePayload payload) {
return signalService.updateSignalBranchConnections(rsId, sigId, payload);
}
@DeleteMapping(path = "/{sigId}")
public ResponseEntity<?> deleteSignal(@PathVariable long rsId, @PathVariable long sigId) {
signalService.deleteSignal(rsId, sigId);
return ResponseEntity.noContent().build();
}
}

View File

@ -1,6 +1,7 @@
package nl.andrewl.railsignalapi.rest;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -10,7 +11,17 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
// Configure resource handlers to use the /app directory for all vue frontend stuff.
registry.addResourceHandler("/app/**")
.addResourceLocations("classpath:/app/");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*");
}
}

View File

@ -1,13 +0,0 @@
package nl.andrewl.railsignalapi.rest.dto;
import nl.andrewl.railsignalapi.model.Branch;
public record BranchResponse(
long id,
String name,
String status
) {
public BranchResponse(Branch branch) {
this(branch.getId(), branch.getName(), branch.getStatus().name());
}
}

View File

@ -0,0 +1,18 @@
package nl.andrewl.railsignalapi.rest.dto;
import nl.andrewl.railsignalapi.model.Segment;
import nl.andrewl.railsignalapi.rest.dto.component.out.SegmentBoundaryNodeResponse;
import nl.andrewl.railsignalapi.rest.dto.component.out.SignalResponse;
import java.util.List;
public class FullSegmentResponse extends SegmentResponse {
public List<SignalResponse> signals;
public List<SegmentBoundaryNodeResponse> boundaryNodes;
public FullSegmentResponse(Segment s) {
super(s);
this.signals = s.getSignals().stream().map(SignalResponse::new).toList();
this.boundaryNodes = s.getBoundaryNodes().stream().map(SegmentBoundaryNodeResponse::new).toList();
}
}

View File

@ -0,0 +1,10 @@
package nl.andrewl.railsignalapi.rest.dto;
import java.util.List;
public class PathNodeUpdatePayload {
public List<NodeIdObj> connectedNodes;
public static class NodeIdObj {
public long id;
}
}

View File

@ -0,0 +1,4 @@
package nl.andrewl.railsignalapi.rest.dto;
public record SegmentPayload(String name) {
}

View File

@ -0,0 +1,17 @@
package nl.andrewl.railsignalapi.rest.dto;
import nl.andrewl.railsignalapi.model.Segment;
public class SegmentResponse {
public long id;
public String name;
public SegmentResponse(long id, String name) {
this.id = id;
this.name = name;
}
public SegmentResponse(Segment s) {
this(s.getId(), s.getName());
}
}

View File

@ -1,9 +0,0 @@
package nl.andrewl.railsignalapi.rest.dto;
import java.util.List;
public record SignalConnectionsUpdatePayload(
List<ConnectionData> connections
) {
public static record ConnectionData(long from, long to) {}
}

View File

@ -1,13 +0,0 @@
package nl.andrewl.railsignalapi.rest.dto;
import nl.andrewl.railsignalapi.model.Position;
import java.util.List;
public record SignalCreationPayload(
String name,
Position position,
List<BranchData> branchConnections
) {
public static record BranchData(String direction, String name, Long id) {}
}

View File

@ -1,61 +0,0 @@
package nl.andrewl.railsignalapi.rest.dto;
import nl.andrewl.railsignalapi.model.Position;
import nl.andrewl.railsignalapi.model.Signal;
import nl.andrewl.railsignalapi.model.SignalBranchConnection;
import java.util.Comparator;
import java.util.List;
public record SignalResponse(
long id,
String name,
Position position,
List<ConnectionData> branchConnections,
boolean online
) {
public SignalResponse(Signal signal) {
this(
signal.getId(),
signal.getName(),
signal.getPosition(),
signal.getBranchConnections().stream()
.sorted(Comparator.comparing(SignalBranchConnection::getDirection))
.map(ConnectionData::new)
.toList(),
signal.isOnline()
);
}
public static record ConnectionData(
long id,
String direction,
BranchResponse branch,
List<ReachableConnectionData> reachableSignalConnections
) {
public ConnectionData(SignalBranchConnection c) {
this(
c.getId(),
c.getDirection().name(),
new BranchResponse(c.getBranch()),
c.getReachableSignalConnections().stream()
.map(cc -> new ReachableConnectionData(
cc.getId(),
cc.getDirection().name(),
cc.getSignal().getId(),
cc.getSignal().getName(),
cc.getSignal().getPosition()
))
.toList()
);
}
public static record ReachableConnectionData(
long connectionId,
String direction,
long signalId,
String signalName,
Position signalPosition
) {}
}
}

View File

@ -0,0 +1,9 @@
package nl.andrewl.railsignalapi.rest.dto.component.in;
import nl.andrewl.railsignalapi.model.component.Position;
public abstract class ComponentPayload {
public String name;
public String type;
public Position position;
}

View File

@ -0,0 +1,5 @@
package nl.andrewl.railsignalapi.rest.dto.component.in;
public class SignalPayload extends ComponentPayload {
public long segmentId;
}

View File

@ -0,0 +1,27 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.*;
public abstract class ComponentResponse {
public long id;
public Position position;
public String name;
public String type;
public boolean online;
public ComponentResponse(Component c) {
this.id = c.getId();
this.position = c.getPosition();
this.name = c.getName();
this.type = c.getType().name();
this.online = c.isOnline();
}
public static ComponentResponse of(Component c) {
return switch (c.getType()) {
case SIGNAL -> new SignalResponse((Signal) c);
case SWITCH -> new SwitchResponse((Switch) c);
case SEGMENT_BOUNDARY -> new SegmentBoundaryNodeResponse((SegmentBoundaryNode) c);
};
}
}

View File

@ -0,0 +1,14 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.PathNode;
import java.util.List;
public abstract class PathNodeResponse extends ComponentResponse {
public List<SimpleComponentResponse> connectedNodes;
public PathNodeResponse(PathNode p) {
super(p);
this.connectedNodes = p.getConnectedNodes().stream().map(SimpleComponentResponse::new).toList();
}
}

View File

@ -0,0 +1,15 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.SegmentBoundaryNode;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
import java.util.List;
public class SegmentBoundaryNodeResponse extends PathNodeResponse {
public List<SegmentResponse> segments;
public SegmentBoundaryNodeResponse(SegmentBoundaryNode n) {
super(n);
this.segments = n.getSegments().stream().map(SegmentResponse::new).toList();
}
}

View File

@ -0,0 +1,12 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.Signal;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
public class SignalResponse extends ComponentResponse {
public SegmentResponse segment;
public SignalResponse(Signal s) {
super(s);
this.segment = new SegmentResponse(s.getSegment());
}
}

View File

@ -0,0 +1,22 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.model.component.Position;
public record SimpleComponentResponse (
long id,
Position position,
String name,
String type,
boolean online
) {
public SimpleComponentResponse(Component c) {
this(
c.getId(),
c.getPosition(),
c.getName(),
c.getType().name(),
c.isOnline()
);
}
}

View File

@ -0,0 +1,17 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.SwitchConfiguration;
import java.util.List;
public record SwitchConfigurationResponse (
long id,
List<SimpleComponentResponse> nodes
) {
public SwitchConfigurationResponse(SwitchConfiguration sc) {
this(
sc.getId(),
sc.getNodes().stream().map(SimpleComponentResponse::new).toList()
);
}
}

View File

@ -0,0 +1,16 @@
package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.Switch;
import java.util.List;
public class SwitchResponse extends PathNodeResponse {
public List<SwitchConfigurationResponse> possibleConfigurations;
public SwitchConfigurationResponse activeConfiguration;
public SwitchResponse(Switch s) {
super(s);
this.possibleConfigurations = s.getPossibleConfigurations().stream().map(SwitchConfigurationResponse::new).toList();
this.activeConfiguration = s.getActiveConfiguration() == null ? null : new SwitchConfigurationResponse(s.getActiveConfiguration());
}
}

View File

@ -1,57 +0,0 @@
package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.BranchRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SignalRepository;
import nl.andrewl.railsignalapi.rest.dto.BranchResponse;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Service
@RequiredArgsConstructor
public class BranchService {
private final BranchRepository branchRepository;
private final RailSystemRepository railSystemRepository;
private final SignalRepository signalRepository;
@Transactional
public void deleteBranch(long rsId, long branchId) {
var branch = branchRepository.findByIdAndRailSystemId(branchId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!branch.getSignalConnections().isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Branch should not be connected to any signals.");
}
branchRepository.delete(branch);
}
@Transactional(readOnly = true)
public List<BranchResponse> getAllBranches(long rsId) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return branchRepository.findAllByRailSystemOrderByName(rs).stream()
.map(BranchResponse::new)
.toList();
}
@Transactional(readOnly = true)
public BranchResponse getBranch(long rsId, long branchId) {
var branch = branchRepository.findByIdAndRailSystemId(branchId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new BranchResponse(branch);
}
@Transactional(readOnly = true)
public List<SignalResponse> getConnectedSignals(long rsId, long branchId) {
var branch = branchRepository.findByIdAndRailSystemId(branchId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return signalRepository.findAllConnectedToBranch(branch).stream()
.map(SignalResponse::new)
.toList();
}
}

View File

@ -0,0 +1,166 @@
package nl.andrewl.railsignalapi.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository;
import nl.andrewl.railsignalapi.dao.SwitchConfigurationRepository;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Segment;
import nl.andrewl.railsignalapi.model.component.*;
import nl.andrewl.railsignalapi.rest.dto.PathNodeUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.component.out.ComponentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class ComponentService {
private final ComponentRepository<Component> componentRepository;
private final RailSystemRepository railSystemRepository;
private final SegmentRepository segmentRepository;
private final SwitchConfigurationRepository switchConfigurationRepository;
@Transactional(readOnly = true)
public List<ComponentResponse> getComponents(long rsId) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return componentRepository.findAllByRailSystem(rs).stream().map(ComponentResponse::of).toList();
}
@Transactional(readOnly = true)
public ComponentResponse getComponent(long rsId, long componentId) {
var c = componentRepository.findByIdAndRailSystemId(componentId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return ComponentResponse.of(c);
}
@Transactional
public void removeComponent(long rsId, long componentId) {
var c = componentRepository.findByIdAndRailSystemId(componentId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// If this is a path node, check for and remove any switch configurations that use it.
if (c instanceof PathNode p) {
switchConfigurationRepository.deleteAllByNodesContaining(p);
}
componentRepository.delete(c);
}
@Transactional
public ComponentResponse create(long rsId, ObjectNode data) {
RailSystem rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
String type = data.get("type").asText();
Position pos = new Position();
pos.setX(data.get("position").get("x").asDouble());
pos.setY(data.get("position").get("y").asDouble());
pos.setZ(data.get("position").get("z").asDouble());
String name = data.get("name").asText();
if (name == null || name.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing required name.");
}
if (componentRepository.existsByNameAndRailSystem(name, rs)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Component with that name already exists.");
}
Component c = switch (type) {
case "SIGNAL" -> createSignal(rs, pos, name, data);
case "SWITCH" -> createSwitch(rs, pos, name, data);
case "SEGMENT_BOUNDARY" -> createSegmentBoundary(rs, pos, name, data);
default -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported component type: " + type);
};
c = componentRepository.save(c);
return ComponentResponse.of(c);
}
private Component createSignal(RailSystem rs, Position pos, String name, ObjectNode data) {
long segmentId = data.get("segment").get("id").asLong();
Segment segment = segmentRepository.findById(segmentId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new Signal(rs, pos, name, segment);
}
private Component createSwitch(RailSystem rs, Position pos, String name, ObjectNode data) {
Switch s = new Switch(rs, pos, name, new HashSet<>(), new HashSet<>(), null);
for (JsonNode configJson : data.withArray("possibleConfigurations")) {
Set<PathNode> pathNodes = new HashSet<>();
for (JsonNode pathNodeJson : configJson.withArray("nodes")) {
long pathNodeId = pathNodeJson.get("id").asLong();
Component c = componentRepository.findById(pathNodeId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (c instanceof PathNode pathNode) {
pathNodes.add(pathNode);
s.getConnectedNodes().add(pathNode);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Id " + pathNodeId + " does not refer to a PathNode component.");
}
}
s.getPossibleConfigurations().add(new SwitchConfiguration(s, pathNodes));
}
if (s.getPossibleConfigurations().size() < 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least two switch configurations are needed.");
}
return s;
}
private Component createSegmentBoundary(RailSystem rs, Position pos, String name, ObjectNode data) {
ArrayNode segmentsNode = data.withArray("segments");
Set<Segment> segments = new HashSet<>();
for (JsonNode segmentNode : segmentsNode) {
long segmentId = segmentNode.get("id").asLong();
Segment segment = segmentRepository.findById(segmentId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
segments.add(segment);
}
if (segments.size() < 1 || segments.size() > 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid number of segments.");
}
return new SegmentBoundaryNode(rs, pos, name, new HashSet<>(), segments);
}
@Transactional
public ComponentResponse updatePath(long rsId, long cId, PathNodeUpdatePayload payload) {
var c = componentRepository.findByIdAndRailSystemId(cId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!(c instanceof PathNode p)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Component is not a PathNode.");
Set<PathNode> newNodes = new HashSet<>();
for (var nodeObj : payload.connectedNodes) {
long id = nodeObj.id;
var c1 = componentRepository.findByIdAndRailSystemId(id, rsId);
if (c1.isPresent() && c1.get() instanceof PathNode pn) {
newNodes.add(pn);
} else {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Component with id " + id + " is not a PathNode in the same rail system.");
}
}
Set<PathNode> nodesToRemove = new HashSet<>(p.getConnectedNodes());
nodesToRemove.removeAll(newNodes);
Set<PathNode> nodesToAdd = new HashSet<>(newNodes);
nodesToAdd.removeAll(p.getConnectedNodes());
p.getConnectedNodes().removeAll(nodesToRemove);
p.getConnectedNodes().addAll(nodesToAdd);
for (var node : nodesToRemove) {
node.getConnectedNodes().remove(p);
}
for (var node : nodesToAdd) {
node.getConnectedNodes().add(p);
}
componentRepository.saveAll(nodesToRemove);
componentRepository.saveAll(nodesToAdd);
p = componentRepository.save(p);
return ComponentResponse.of(p);
}
}

View File

@ -1,9 +1,9 @@
package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.BranchRepository;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SignalRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.rest.dto.RailSystemCreationPayload;
import nl.andrewl.railsignalapi.rest.dto.RailSystemResponse;
@ -19,8 +19,8 @@ import java.util.List;
@RequiredArgsConstructor
public class RailSystemService {
private final RailSystemRepository railSystemRepository;
private final SignalRepository signalRepository;
private final BranchRepository branchRepository;
private final SegmentRepository segmentRepository;
private final ComponentRepository<?> componentRepository;
@Transactional
public List<RailSystemResponse> getRailSystems() {
@ -29,10 +29,13 @@ public class RailSystemService {
@Transactional
public RailSystemResponse createRailSystem(RailSystemCreationPayload payload) {
if (railSystemRepository.existsByName(payload.name())) {
if (payload.name() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing required name.");
if (payload.name().isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Name cannot be blank.");
String name = payload.name().trim();
if (railSystemRepository.existsByName(name)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "A rail system with that name already exists.");
}
RailSystem rs = new RailSystem(payload.name());
RailSystem rs = new RailSystem(name);
return new RailSystemResponse(railSystemRepository.save(rs));
}
@ -40,10 +43,8 @@ public class RailSystemService {
public void delete(long rsId) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
var signals = signalRepository.findAllByRailSystemOrderByName(rs);
signalRepository.deleteAll(signals);
var branches = branchRepository.findAllByRailSystemOrderByName(rs);
branchRepository.deleteAll(branches);
componentRepository.deleteAllByRailSystem(rs);
segmentRepository.deleteAllByRailSystem(rs);
railSystemRepository.delete(rs);
}

View File

@ -0,0 +1,58 @@
package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository;
import nl.andrewl.railsignalapi.model.Segment;
import nl.andrewl.railsignalapi.model.component.Component;
import nl.andrewl.railsignalapi.rest.dto.SegmentPayload;
import nl.andrewl.railsignalapi.rest.dto.FullSegmentResponse;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SegmentService {
private final SegmentRepository segmentRepository;
private final RailSystemRepository railSystemRepository;
private final ComponentRepository<Component> componentRepository;
@Transactional(readOnly = true)
public List<SegmentResponse> getSegments(long rsId) {
return segmentRepository.findAllByRailSystemId(rsId).stream().map(SegmentResponse::new).toList();
}
@Transactional(readOnly = true)
public FullSegmentResponse getSegment(long rsId, long segmentId) {
var segment = segmentRepository.findByIdAndRailSystemId(segmentId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new FullSegmentResponse(segment);
}
@Transactional
public FullSegmentResponse create(long rsId, SegmentPayload payload) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
String name = payload.name();
if (segmentRepository.existsByNameAndRailSystem(name, rs)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Segment with that name already exists.");
}
Segment segment = segmentRepository.save(new Segment(rs, name));
return new FullSegmentResponse(segment);
}
@Transactional
public void remove(long rsId, long segmentId) {
var segment = segmentRepository.findByIdAndRailSystemId(segmentId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
componentRepository.deleteAll(segment.getSignals());
componentRepository.deleteAll(segment.getBoundaryNodes());
segmentRepository.delete(segment);
}
}

View File

@ -1,245 +0,0 @@
package nl.andrewl.railsignalapi.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.BranchRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SignalBranchConnectionRepository;
import nl.andrewl.railsignalapi.dao.SignalRepository;
import nl.andrewl.railsignalapi.model.*;
import nl.andrewl.railsignalapi.rest.dto.SignalConnectionsUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.SignalCreationPayload;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import nl.andrewl.railsignalapi.websocket.BranchUpdateMessage;
import nl.andrewl.railsignalapi.websocket.SignalUpdateMessage;
import nl.andrewl.railsignalapi.websocket.SignalUpdateType;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class SignalService {
private final RailSystemRepository railSystemRepository;
private final SignalRepository signalRepository;
private final BranchRepository branchRepository;
private final SignalBranchConnectionRepository signalBranchConnectionRepository;
private final ObjectMapper mapper = new ObjectMapper();
private final Map<WebSocketSession, Set<Long>> signalWebSocketSessions = new ConcurrentHashMap<>();
@Transactional
public SignalResponse createSignal(long rsId, SignalCreationPayload payload) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Rail system not found."));
if (signalRepository.existsByNameAndRailSystem(payload.name(), rs)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Signal " + payload.name() + " already exists.");
}
if (payload.branchConnections().size() != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Exactly two branch connections must be provided.");
}
// Ensure that the directions of the connections are opposite each other.
Direction dir1 = Direction.parse(payload.branchConnections().get(0).direction());
Direction dir2 = Direction.parse(payload.branchConnections().get(1).direction());
if (!dir1.isOpposite(dir2)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Branch connections must be opposite each other.");
Set<SignalBranchConnection> branchConnections = new HashSet<>();
Signal signal = new Signal(rs, payload.name(), payload.position(), branchConnections);
for (var branchData : payload.branchConnections()) {
Branch branch;
if (branchData.id() != null) {
branch = this.branchRepository.findByIdAndRailSystem(branchData.id(), rs)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Branch id " + branchData.id() + " is invalid."));
} else {
branch = this.branchRepository.findByNameAndRailSystem(branchData.name(), rs)
.orElse(new Branch(rs, branchData.name(), BranchStatus.FREE));
}
Direction dir = Direction.parse(branchData.direction());
branchConnections.add(new SignalBranchConnection(signal, branch, dir));
}
signal = signalRepository.save(signal);
return new SignalResponse(signal);
}
@Transactional
public SignalResponse updateSignalBranchConnections(long rsId, long sigId, SignalConnectionsUpdatePayload payload) {
var signal = signalRepository.findByIdAndRailSystemId(sigId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
for (var c : payload.connections()) {
var fromConnection = signalBranchConnectionRepository.findById(c.from())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find signal branch connection: " + c.from()));
if (!fromConnection.getSignal().getId().equals(signal.getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Can only update signal branch connections originating from the specified signal.");
}
var toConnection = signalBranchConnectionRepository.findById(c.to())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find signal branch connection: " + c.to()));
if (!fromConnection.getBranch().getId().equals(toConnection.getBranch().getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Signal branch connections can only path via a mutual branch.");
}
fromConnection.getReachableSignalConnections().add(toConnection);
signalBranchConnectionRepository.save(fromConnection);
}
for (var con : signal.getBranchConnections()) {
Set<SignalBranchConnection> connectionsToRemove = new HashSet<>();
for (var reachableCon : con.getReachableSignalConnections()) {
if (!payload.connections().contains(new SignalConnectionsUpdatePayload.ConnectionData(con.getId(), reachableCon.getId()))) {
connectionsToRemove.add(reachableCon);
}
}
con.getReachableSignalConnections().removeAll(connectionsToRemove);
signalBranchConnectionRepository.save(con);
}
// Reload the signal.
signal = signalRepository.findById(signal.getId()).orElseThrow();
return new SignalResponse(signal);
}
@Transactional
public void registerSignalWebSocketSession(Set<Long> signalIds, WebSocketSession session) {
this.signalWebSocketSessions.put(session, signalIds);
// Instantly send a data packet so that the signals are up-to-date.
RailSystem rs = null;
for (var signalId : signalIds) {
var signal = signalRepository.findById(signalId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid signal id."));
if (rs == null) {
rs = signal.getRailSystem();
} else if (!rs.getId().equals(signal.getRailSystem().getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot open signal websocket session for signals from different rail systems.");
}
for (var branchConnection : signal.getBranchConnections()) {
try {
session.sendMessage(new TextMessage(mapper.writeValueAsString(
new BranchUpdateMessage(
branchConnection.getBranch().getId(),
branchConnection.getBranch().getStatus().name()
)
)));
} catch (IOException e) {
e.printStackTrace();
}
}
signal.setOnline(true);
signalRepository.save(signal);
}
}
@Transactional
public void deregisterSignalWebSocketSession(WebSocketSession session) {
var ids = this.signalWebSocketSessions.remove(session);
if (ids != null) {
for (var signalId : ids) {
signalRepository.findById(signalId).ifPresent(signal -> {
signal.setOnline(false);
signalRepository.save(signal);
});
}
}
}
public WebSocketSession getSignalWebSocketSession(long signalId) {
for (var entry : signalWebSocketSessions.entrySet()) {
if (entry.getValue().contains(signalId)) return entry.getKey();
}
return null;
}
@Transactional
public void handleSignalUpdate(SignalUpdateMessage updateMessage) {
var signal = signalRepository.findById(updateMessage.signalId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Branch fromBranch = null;
Branch toBranch = null;
for (var con : signal.getBranchConnections()) {
if (con.getBranch().getId() == updateMessage.fromBranchId()) {
fromBranch = con.getBranch();
}
if (con.getBranch().getId() == updateMessage.toBranchId()) {
toBranch = con.getBranch();
}
}
if (fromBranch == null || toBranch == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid branches.");
}
SignalUpdateType updateType = SignalUpdateType.valueOf(updateMessage.type().trim().toUpperCase());
if (updateType == SignalUpdateType.BEGIN && toBranch.getStatus() != BranchStatus.FREE) {
log.warn("Warning! Train is entering a non-free branch {}.", toBranch.getName());
}
if (toBranch.getStatus() != BranchStatus.OCCUPIED) {
log.info("Updating branch {} status from {} to {}.", toBranch.getName(), toBranch.getStatus(), BranchStatus.OCCUPIED);
toBranch.setStatus(BranchStatus.OCCUPIED);
branchRepository.save(toBranch);
broadcastToConnectedSignals(toBranch);
}
if (updateType == SignalUpdateType.END) {
if (fromBranch.getStatus() != BranchStatus.FREE) {
log.info("Updating branch {} status from {} to {}.", fromBranch.getName(), fromBranch.getStatus(), BranchStatus.FREE);
fromBranch.setStatus(BranchStatus.FREE);
branchRepository.save(fromBranch);
broadcastToConnectedSignals(fromBranch);
}
} else if (updateType == SignalUpdateType.BEGIN) {
if (fromBranch.getStatus() != BranchStatus.OCCUPIED) {
log.info("Updating branch {} status from {} to {}.", fromBranch.getName(), fromBranch.getStatus(), BranchStatus.OCCUPIED);
fromBranch.setStatus(BranchStatus.OCCUPIED);
branchRepository.save(fromBranch);
broadcastToConnectedSignals(fromBranch);
}
}
}
private void broadcastToConnectedSignals(Branch branch) {
try {
WebSocketMessage<String> msg = new TextMessage(mapper.writeValueAsString(
new BranchUpdateMessage(branch.getId(), branch.getStatus().name())
));
signalRepository.findAllConnectedToBranch(branch).stream()
.map(s -> getSignalWebSocketSession(s.getId()))
.filter(Objects::nonNull).distinct()
.forEach(session -> {
try {
session.sendMessage(msg);
} catch (IOException e) {
e.printStackTrace();
}
});
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
@Transactional(readOnly = true)
public SignalResponse getSignal(long rsId, long sigId) {
var s = signalRepository.findByIdAndRailSystemId(sigId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new SignalResponse(s);
}
@Transactional(readOnly = true)
public List<SignalResponse> getAllSignals(long rsId) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Rail system not found."));
return signalRepository.findAllByRailSystemOrderByName(rs).stream()
.map(SignalResponse::new)
.toList();
}
@Transactional
public void deleteSignal(long rsId, long sigId) {
var s = signalRepository.findByIdAndRailSystemId(sigId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
signalRepository.delete(s);
}
}

View File

@ -3,7 +3,6 @@ package nl.andrewl.railsignalapi.websocket;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.service.SignalService;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
@ -18,7 +17,6 @@ import java.util.Set;
@Slf4j
public class SignalWebSocketHandler extends TextWebSocketHandler {
private final ObjectMapper mapper = new ObjectMapper();
private final SignalService signalService;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
@ -31,19 +29,19 @@ public class SignalWebSocketHandler extends TextWebSocketHandler {
for (var idStr : signalIdHeader.split(",")) {
ids.add(Long.parseLong(idStr.trim()));
}
signalService.registerSignalWebSocketSession(ids, session);
//signalService.registerSignalWebSocketSession(ids, session);
log.info("Connection established with signals {}.", ids);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
var msg = mapper.readValue(message.getPayload(), SignalUpdateMessage.class);
signalService.handleSignalUpdate(msg);
//signalService.handleSignalUpdate(msg);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
signalService.deregisterSignalWebSocketSession(session);
//signalService.deregisterSignalWebSocketSession(session);
log.info("Closed connection {}. Status: {}", session.getId(), status.toString());
}
}

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