added stuff

This commit is contained in:
Andrew Lalis 2022-05-06 22:43:02 +02:00
parent 2fbc22af0d
commit e608e2ba8c
54 changed files with 4138 additions and 1082 deletions

View File

@ -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,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
```

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>RailSignal</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "railsignal-app",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5050",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"axios": "^0.27.2",
"pinia": "^2.0.14",
"three": "^0.140.0",
"vue": "^3.2.33",
"vue-router": "^4.0.14"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.3.1",
"eslint": "^8.5.0",
"eslint-plugin-vue": "^8.2.0",
"vite": "^2.9.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

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

View File

@ -0,0 +1,31 @@
<template>
<h2>{{railSystem.name}}</h2>
<RsMap :railSystem="railSystem" />
<RsComponent v-if="selectedComponent !== null" :component="selectedComponent" />
</template>
<script>
import RsMap from './railsystem/MapView.vue'
import RsComponent from './railsystem/Component.vue'
export default {
components: {
RsMap,
RsComponent
},
props: {
railSystem: {
type: Object,
required: true
}
},
data() {
return {
selectedComponent: null
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,56 @@
<template>
<select v-model="rsStore.selectedRailSystem">
<option v-for="rs in rsStore.railSystems" :key="rs.id" :value="rs">
{{rs.name}}
</option>
</select>
<p v-if="rsStore.railSystems.length === 0">
There are no rail systems.
</p>
<button v-if="rsStore.selectedRailSystem !== null" @click="rsStore.removeRailSystem(rsStore.selectedRailSystem)">
Remove this Rail System
</button>
<h3>Create a New Rail System</h3>
<form>
<label for="rsNameInput">Name</label>
<input id="rsNameInput" type="text" v-model="formData.rsName"/>
<button type="submit" @click.prevent="formSubmitted">Submit</button>
</form>
</template>
<script>
import {useRailSystemsStore} from "../stores/railSystemsStore";
export default {
name: "RailSystemsManager.vue",
setup() {
const rsStore = useRailSystemsStore();
return {
rsStore
};
},
data() {
return {
formData: {
rsName: ""
}
}
},
methods: {
formSubmitted() {
this.rsStore.createRailSystem(this.formData.rsName)
.then(() => {
this.formData.rsName = "";
});
}
},
mounted() {
this.rsStore.refreshRailSystems();
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,24 @@
<template>
<form>
<label for="addSegmentName">Name</label>
<input type="text" v-model="formData.segmentName" />
<button>Add</button>
</form>
</template>
<script>
export default {
name: "AddSegment",
data() {
return {
formData: {
segmentName: ""
}
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,23 @@
<script>
export default {
props: {
component: {
type: Object,
required: true
}
}
}
</script>
<template>
<div class="rs-component">
</div>
</template>
<style>
.rs-component {
width: 20%;
border: 1px solid black;
}
</style>

View File

@ -0,0 +1,26 @@
<script>
export default {
props: {
railSystem: {
type: Object,
required: true
}
},
data() {
return {}
}
}
</script>
<template>
<canvas>
Your browser doesn't support canvas!
</canvas>
</template>
<style>
canvas {
border: 1px solid black;
width: 70%;
}
</style>

View File

@ -0,0 +1,9 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia();
app.use(pinia);
app.mount('#app')

View File

@ -0,0 +1,48 @@
import { defineStore } from "pinia";
import axios from "axios";
export const useRailSystemsStore = defineStore('RailSystemsStore', {
state: () => ({
railSystems: [],
selectedRailSystem: null,
selectedComponent: null
}),
actions: {
refreshRailSystems() {
return new Promise((resolve, reject) => {
axios.get(import.meta.env.VITE_API_URL + "/rs")
.then(response => {
this.railSystems = response.data;
resolve();
})
.catch(error => {
reject(error);
});
});
},
createRailSystem(name) {
return new Promise((resolve, reject) => {
axios.post(
import.meta.env.VITE_API_URL + "/rs",
{name: name}
)
.then(() => {
this.refreshRailSystems()
.then(() => resolve())
.catch(error => reject(error));
})
.catch(error => reject(error));
});
},
removeRailSystem(rs) {
return new Promise((resolve, reject) => {
axios.delete(import.meta.env.VITE_API_URL + "/rs/" + rs.id)
.then(() => {
this.refreshRailSystems()
.then(() => resolve)
.catch(error => reject(error));
})
})
}
}
});

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

@ -2,10 +2,27 @@ 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 extends JpaRepository<Component, Long> {
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

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

@ -0,0 +1,8 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.component.Signal;
import org.springframework.stereotype.Repository;
@Repository
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

@ -7,6 +7,7 @@ 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;
/**
@ -27,7 +28,7 @@ public class Segment {
/**
* A unique name for this segment.
*/
@Column(unique = true)
@Column
private String name;
/**
@ -39,9 +40,16 @@ public class Segment {
/**
* The set of nodes from which trains can enter and exit this segment.
*/
@ManyToMany(mappedBy = "segments", cascade = CascadeType.ALL)
@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;

View File

@ -37,9 +37,15 @@ public abstract class Component {
/**
* A human-readable name for the component.
*/
@Column(unique = true)
@Column
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.
@ -48,10 +54,11 @@ public abstract class Component {
@Setter
private boolean online = false;
public Component(RailSystem railSystem, Position position, String name) {
public Component(RailSystem railSystem, Position position, String name, ComponentType type) {
this.railSystem = railSystem;
this.position = position;
this.name = name;
this.type = type;
}
@Override
@ -59,4 +66,13 @@ public abstract class Component {
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

@ -19,15 +19,15 @@ import java.util.Set;
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class PathNode extends Component {
public abstract class PathNode extends Component {
/**
* The set of nodes that this one is connected to.
*/
@ManyToMany
private Set<PathNode> connectedNodes;
public PathNode(RailSystem railSystem, Position position, String name, Set<PathNode> connectedNodes) {
super(railSystem, position, name);
public PathNode(RailSystem railSystem, Position position, String name, ComponentType type, Set<PathNode> connectedNodes) {
super(railSystem, position, name, type);
this.connectedNodes = connectedNodes;
}
}

View File

@ -26,7 +26,7 @@ public class SegmentBoundaryNode extends PathNode {
private Set<Segment> segments;
public SegmentBoundaryNode(RailSystem railSystem, Position position, String name, Set<PathNode> connectedNodes, Set<Segment> segments) {
super(railSystem, position, name, connectedNodes);
super(railSystem, position, name, ComponentType.SEGMENT_BOUNDARY, connectedNodes);
this.segments = segments;
}
}

View File

@ -1,12 +1,14 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import nl.andrewl.railsignalapi.model.Direction;
import nl.andrewl.railsignalapi.model.RailSystem;
import nl.andrewl.railsignalapi.model.Segment;
import javax.persistence.*;
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
@ -15,29 +17,16 @@ import javax.persistence.*;
*/
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Signal extends Component {
/**
* The direction this signal is facing. This is the direction in which the
* signal connects to a branch.
* <pre>
* |-segment A-|-segment B-|
* ===================== <- Rail
* ]+ ---> Signal is facing East, and shows status on
* its western side. It is connected to segment B.
* </pre>
*/
@Enumerated(EnumType.STRING)
private Direction direction;
/**
* 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, Direction direction) {
super(railSystem, position, name);
public Signal(RailSystem railSystem, Position position, String name, Segment segment) {
super(railSystem, position, name, ComponentType.SIGNAL);
this.segment = segment;
this.direction = direction;
}
}

View File

@ -3,6 +3,8 @@ 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;
@ -22,8 +24,16 @@ public class Switch extends PathNode {
private Set<SwitchConfiguration> possibleConfigurations;
/**
* The switch configuration that this switch is currently in.
* The switch configuration that this switch is currently in. If null, then
* we don't know what configuration the switch is in.
*/
@OneToOne(optional = false, fetch = FetchType.LAZY)
@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

@ -1,5 +1,7 @@
package nl.andrewl.railsignalapi.model.component;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@ -10,7 +12,8 @@ import java.util.Set;
* as an active configuration in the linked switch component.
*/
@Entity
@NoArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class SwitchConfiguration {
@Id
@GeneratedValue
@ -28,4 +31,17 @@ public class SwitchConfiguration {
*/
@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

@ -0,0 +1,39 @@
package nl.andrewl.railsignalapi.rest;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.component.ComponentResponse;
import nl.andrewl.railsignalapi.rest.dto.component.SimpleComponentResponse;
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<SimpleComponentResponse> 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();
}
}

View File

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

View File

@ -0,0 +1,31 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.FullSegmentResponse;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
import nl.andrewl.railsignalapi.service.SegmentService;
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);
}
}

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;
@ -13,4 +14,13 @@ public class WebConfig implements WebMvcConfigurer {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*");
}
}

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.SegmentBoundaryNodeResponse;
import nl.andrewl.railsignalapi.rest.dto.component.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,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

@ -0,0 +1,27 @@
package nl.andrewl.railsignalapi.rest.dto.component;
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;
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;
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;
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;
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;
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;
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 = 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,129 @@
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.component.ComponentResponse;
import nl.andrewl.railsignalapi.rest.dto.component.SimpleComponentResponse;
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<SimpleComponentResponse> getComponents(long rsId) {
var rs = railSystemRepository.findById(rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return componentRepository.findAllByRailSystem(rs).stream().map(SimpleComponentResponse::new).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 && 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));
}
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 void remove(long rsId, long componentId) {
}
}

View File

@ -20,7 +20,7 @@ import java.util.List;
public class RailSystemService {
private final RailSystemRepository railSystemRepository;
private final SegmentRepository segmentRepository;
private final ComponentRepository componentRepository;
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));
}

View File

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

View File

@ -1,246 +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.model.component.SignalBranchConnection;
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());
}
}

View File

@ -1,144 +0,0 @@
function worldTransform() {
const canvasRect = railMapCanvas[0].getBoundingClientRect();
const scale = SCALE_VALUES[canvasScaleIndex];
let tx = new DOMMatrix();
tx.translateSelf(canvasRect.width / 2, canvasRect.height / 2, 0);
tx.scaleSelf(scale, scale, scale);
tx.translateSelf(canvasTranslation.x, canvasTranslation.y, 0);
if (canvasDragOrigin !== null && canvasDragTranslation !== null) {
tx.translateSelf(canvasDragTranslation.x, canvasDragTranslation.y, 0);
}
return tx;
}
function canvasPointToWorld(p) {
return worldTransform().invertSelf().transformPoint(p);
}
function worldPointToCanvas(p) {
return worldTransform().transformPoint(p);
}
function signalTransform(worldTx, signal) {
let tx = DOMMatrix.fromMatrix(worldTx);
tx.translateSelf(signal.position.x, signal.position.z, 0);
let direction = signal.branchConnections[0].direction;
if (direction === "EAST" || direction === "SOUTH" || direction === "SOUTH_EAST" || direction === "SOUTH_WEST") {
direction = signal.branchConnections[1].direction;
}
if (direction === undefined || direction === null || direction === "") {
direction = "NORTH";
}
let angle = 0;
if (direction === "NORTH") {
angle = 90;
} else if (direction === "NORTH_WEST") {
angle = 45;
} else if (direction === "NORTH_EAST") {
angle = 135;
}
tx.rotateSelf(0, 0, angle);
return tx;
}
function drawRailSystem() {
let ctx = railMapCanvas[0].getContext("2d");
ctx.resetTransform();
ctx.clearRect(0, 0, railMapCanvas.width(), railMapCanvas.height());
const worldTx = worldTransform();
ctx.setTransform(worldTx);
railSystem.signals.forEach(signal => {
drawReachableConnections(ctx, signal);
});
railSystem.signals.forEach(signal => {
ctx.setTransform(signalTransform(worldTx, signal));
drawSignal(ctx, signal);
});
ctx.resetTransform();
ctx.fillStyle = 'black';
ctx.strokeStyle = 'black';
ctx.font = '24px Serif';
let textLine = 0;
hoveredElements.forEach(element => {
ctx.strokeText(element.name, 10, 20 + textLine * 20);
ctx.fillText(element.name, 10, 20 + textLine * 20);
textLine += 1;
});
}
function drawSignal(ctx, signal) {
if (signal.online) {
ctx.fillStyle = 'black';
} else {
ctx.fillStyle = 'gray';
}
ctx.scale(2, 2);
ctx.fillRect(-0.5, -0.5, 1, 1);
let northWesterlyCon = signal.branchConnections[0];
let southEasterlyCon = signal.branchConnections[1];
if (northWesterlyCon.direction === "EAST" || northWesterlyCon.direction === "SOUTH" || northWesterlyCon.direction === "SOUTH_WEST" || northWesterlyCon.direction === "SOUTH_EAST") {
let tmp = northWesterlyCon;
northWesterlyCon = southEasterlyCon;
southEasterlyCon = tmp;
}
ctx.fillStyle = getSignalColor(signal, southEasterlyCon.branch.status);
ctx.fillRect(-0.75, -0.4, 0.3, 0.8);
ctx.fillStyle = getSignalColor(signal, northWesterlyCon.branch.status);
ctx.fillRect(0.45, -0.4, 0.3, 0.8);
}
function getSignalColor(signal, branchStatus) {
if (!signal.online) return 'rgb(0, 0, 255)';
if (branchStatus === "FREE") {
return 'rgb(0, 255, 0)';
} else if (branchStatus === "OCCUPIED") {
return 'rgb(255, 0, 0)';
} else {
return 'rgb(0, 0, 255)';
}
}
// Draws lines indicating reachable paths between this signal and others, with arrows for directionality.
function drawReachableConnections(ctx, signal) {
ctx.strokeStyle = 'black';
ctx.lineWidth = 0.25;
signal.branchConnections.forEach(connection => {
ctx.resetTransform();
connection.reachableSignalConnections.forEach(reachableCon => {
const dx = reachableCon.signalPosition.x - signal.position.x;
const dy = reachableCon.signalPosition.z - signal.position.z;
const dist = Math.sqrt(dx * dx + dy * dy);
let tx = worldTransform();
tx.translateSelf(signal.position.x, signal.position.z, 0);
const angle = Math.atan2(dy, dx) * 180 / Math.PI - 90;
tx.rotateSelf(0, 0, angle);
ctx.setTransform(tx);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, dist);
const arrowEnd = 5;
const arrowWidth = 0.5;
const arrowLength = 1;
ctx.lineTo(0, arrowEnd);
ctx.lineTo(arrowWidth, arrowEnd - arrowLength);
ctx.lineTo(-arrowWidth, arrowEnd - arrowLength);
ctx.lineTo(0, arrowEnd);
ctx.stroke();
ctx.fill();
});
});
}
function getRailSystemBoundingBox() {
let min = {x: Number.MAX_SAFE_INTEGER, z: Number.MAX_SAFE_INTEGER};
let max = {x: Number.MIN_SAFE_INTEGER, z: Number.MIN_SAFE_INTEGER};
railSystem.signals.forEach(signal => {
let p = signal.position;
if (p.x < min.x) min.x = p.x;
if (p.z < min.z) min.z = p.z;
if (p.x > max.x) max.x = p.x;
if (p.z > max.z) max.z = p.z;
});
return {x: min.x, y: min.z, width: Math.abs(max.x - min.x), height: Math.abs(max.z - min.z)};
}

View File

@ -1,388 +0,0 @@
const $ = jQuery;
let railSystemSelect;
let railMapCanvas;
let railSystem = null;
let detailPanel = null;
let canvasTranslation = {x: 0, y: 0};
let canvasDragOrigin = null;
let canvasDragTranslation = null;
let hoveredElements = [];
const SCALE_VALUES = [0.01, 0.1, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 30.0, 45.0, 60.0, 80.0, 100.0];
const SCALE_INDEX_NORMAL = 7;
let canvasScaleIndex = SCALE_INDEX_NORMAL;
$(document).ready(() => {
railSystemSelect = $('#railSystemSelect');
railSystemSelect.change(railSystemChanged);
railMapCanvas = $('#railMapCanvas');
railMapCanvas.on('wheel', onCanvasMouseWheel);
railMapCanvas.mousedown(onCanvasMouseDown);
railMapCanvas.mouseup(onCanvasMouseUp);
railMapCanvas.mousemove(onCanvasMouseMove);
$('#addRailSystemInput').on("input", () => {
$('#addRailSystemButton').prop("disabled", $('#addRailSystemInput').val() === "");
});
$('#addRailSystemButton').click(addRailSystem);
$('#removeRailSystemButton').click(deleteRailSystem);
detailPanel = $('#railMapDetailPanel');
refreshRailSystems(true);
});
// Handle mouse scrolling within the context of the canvas.
function onCanvasMouseWheel(event) {
let s = event.originalEvent.deltaY;
if (s > 0) {
canvasScaleIndex = Math.max(0, canvasScaleIndex - 1);
} else if (s < 0) {
canvasScaleIndex = Math.min(SCALE_VALUES.length - 1, canvasScaleIndex + 1);
}
drawRailSystem();
event.stopPropagation();
}
// Handle mouse clicks on the canvas.
function onCanvasMouseDown(event) {
const p = getMousePoint(event);
canvasDragOrigin = {x: p.x, y: p.y};
}
// Handle mouse release on the canvas, which stops dragging or indicates that the user may have clicked on something.
function onCanvasMouseUp(event) {
if (canvasDragTranslation !== null) {
canvasTranslation.x += canvasDragTranslation.x;
canvasTranslation.y += canvasDragTranslation.y;
} else {
const p = getMousePoint(event);
let signalClicked = false;
railSystem.signals.forEach(signal => {
const sp = new DOMPoint(signal.position.x, signal.position.z, 0, 1);
const canvasSp = worldPointToCanvas(sp);
const dist = Math.sqrt(Math.pow(p.x - canvasSp.x, 2) + Math.pow(p.y - canvasSp.y, 2));
if (dist < 5) {
console.log(signal);
onSignalSelected(signal);
signalClicked = true;
}
});
if (!signalClicked) {
onSignalSelected(null);
}
}
canvasDragOrigin = null;
canvasDragTranslation = null;
}
// Handle mouse motion over the canvas. This is for dragging and hovering over items.
function onCanvasMouseMove(event) {
const rect = railMapCanvas[0].getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (canvasDragOrigin !== null) {
const scale = SCALE_VALUES[canvasScaleIndex];
const dx = x - canvasDragOrigin.x;
const dy = y - canvasDragOrigin.y;
canvasDragTranslation = {x: dx / scale, y: dy / scale};
drawRailSystem();
} else {
hoveredElements = [];
const p = getMousePoint(event);
railSystem.signals.forEach(signal => {
const sp = new DOMPoint(signal.position.x, signal.position.z, 0, 1);
const canvasSp = worldPointToCanvas(sp);
const dist = Math.sqrt(Math.pow(p.x - canvasSp.x, 2) + Math.pow(p.y - canvasSp.y, 2));
if (dist < 5) {
hoveredElements.push(signal);
}
});
drawRailSystem();
}
}
function getMousePoint(event) {
const rect = railMapCanvas[0].getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return new DOMPoint(x, y, 0, 1);
}
function railSystemChanged() {
detailPanel.empty();
railSystem = {};
railSystem.id = railSystemSelect.val();
$.get("/api/railSystems/" + railSystem.id + "/signals")
.done(signals => {
railSystem.signals = signals;
let bb = getRailSystemBoundingBox();
canvasTranslation.x = -1 * (bb.x + (bb.width / 2));
canvasTranslation.y = -1 * (bb.y + (bb.height / 2));
canvasScaleIndex = SCALE_INDEX_NORMAL;
drawRailSystem();
window.setInterval(railSystemUpdated, 1000);
});
$.get("/api/railSystems/" + railSystem.id + "/branches")
.done(branches => {
railSystem.branches = branches;
const branchSelects = $('.js_branch_list');
branchSelects.empty();
railSystem.branches.forEach(branch => {
branchSelects.append($('<option value="' + branch.name + '"></option>'))
});
});
}
// Updates the current rail system's information from the API.
function railSystemUpdated() {
$.get("/api/railSystems/" + railSystem.id + "/signals")
.done(signals => {
railSystem.signals = signals;
drawRailSystem();
});
$.get("/api/railSystems/" + railSystem.id + "/branches")
.done(branches => {
railSystem.branches = branches;
const branchSelects = $('.js_branch_list');
branchSelects.empty();
railSystem.branches.forEach(branch => {
branchSelects.append($('<option value="' + branch.name + '"></option>'))
});
});
}
function selectSignalById(id) {
railSystem.signals.forEach(signal => {
if (signal.id === id) {
onSignalSelected(signal);
}
});
}
function onSignalSelected(signal) {
detailPanel.empty();
if (signal !== null) {
const tpl = Handlebars.compile($('#signalTemplate').html());
detailPanel.html(tpl(signal));
signal.branchConnections.forEach(con => {
const select = $('#signalPotentialConnectionsSelect-' + con.id);
$.get("/api/railSystems/" + railSystem.id + "/branches/" + con.branch.id + "/signals")
.done(signals => {
signals = signals.filter(s => s.id !== signal.id);
let connections = [];
signals.forEach(s => {
s.branchConnections
.filter(c => c.branch.id === con.branch.id && !con.reachableSignalConnections.some(rc => rc.connectionId === c.id))
.forEach(potentialConnection => {
potentialConnection.signalName = s.name;
potentialConnection.signalId = s.id;
connections.push(potentialConnection);
});
});
select.empty();
const row = $('#signalPotentialConnectionsRow-' + con.id);
row.toggle(connections.length > 0);
connections.forEach(c => {
select.append($(`<option value="${c.id}">${c.signalName} via ${c.direction} connection ${c.id}</option>`))
});
});
});
}
}
function addRailSystem() {
let name = $('#addRailSystemInput').val().trim();
$.post({
url: "/api/railSystems",
data: JSON.stringify({name: name}),
contentType: "application/json"
})
.done((response) => {
refreshRailSystems();
})
.always(() => {
$('#addRailSystemInput').val("");
});
}
function deleteRailSystem() {
if (railSystem !== null && railSystem.id) {
confirm("Are you sure you want to permanently remove rail system " + railSystem.id + "?")
.then(() => {
$.ajax({
url: "/api/railSystems/" + railSystem.id,
type: "DELETE"
})
.always(() => {
refreshRailSystems(true);
});
});
}
}
function refreshRailSystems(selectFirst) {
$.get("/api/railSystems")
.done(railSystems => {
railSystemSelect.empty();
railSystems.forEach(railSystem => {
let option = $(`<option value="${railSystem.id}">${railSystem.name} - ID: ${railSystem.id}</option>`)
railSystemSelect.append(option);
});
if (selectFirst) {
railSystemSelect.val(railSystems[0].id);
railSystemSelect.change();
}
});
}
function addNewSignal() {
const modalElement = $('#addSignalModal');
const form = $('#addSignalForm');
form.validate();
if (!form.valid()) return;
const data = {
name: $('#addSignalName').val().trim(),
position: {
x: $('#addSignalPositionX').val(),
y: $('#addSignalPositionY').val(),
z: $('#addSignalPositionZ').val()
},
branchConnections: [
{
direction: $('#addSignalFirstConnectionDirection').val(),
name: $('#addSignalFirstConnectionBranch').val()
},
{
direction: $('#addSignalSecondConnectionDirection').val(),
name: $('#addSignalSecondConnectionBranch').val()
}
]
};
const modal = bootstrap.Modal.getInstance(modalElement[0]);
modal.hide();
modalElement.on("hidden.bs.modal", () => {
confirm("Are you sure you want to add this new signal to the system?")
.then(() => {
$.post({
url: "/api/railSystems/" + railSystem.id + "/signals",
data: JSON.stringify(data),
contentType: "application/json"
})
.done(() => {
form.trigger("reset");
railSystemUpdated();
})
.fail((response) => {
console.error(response);
$('#addSignalAlertsContainer').append($('<div class="alert alert-danger">An error occurred.</div>'));
modal.show();
});
})
.catch(() => {
form.trigger("reset");
});
});
}
function removeSignal(signalId) {
confirm(`Are you sure you want to remove signal ${signalId}? This cannot be undone.`)
.then(() => {
$.ajax({
url: `/api/railSystems/${railSystem.id}/signals/${signalId}`,
type: "DELETE"
})
.always(() => {
railSystemUpdated();
})
.fail((response) => {
console.error(response);
})
})
}
function removeReachableConnection(signalId, fromId, toId) {
confirm(`Are you sure you want to remove the connection from ${fromId} to ${toId} from signal ${signalId}?`)
.then(() => {
$.get(`/api/railSystems/${railSystem.id}/signals/${signalId}`)
.done(signal => {
let connections = [];
signal.branchConnections.forEach(con => {
con.reachableSignalConnections.forEach(reachableCon => {
connections.push({from: con.id, to: reachableCon.connectionId});
});
});
connections = connections.filter(c => !(c.from === fromId && c.to === toId));
$.post({
url: `/api/railSystems/${railSystem.id}/signals/${signal.id}/signalConnections`,
data: JSON.stringify({connections: connections}),
contentType: "application/json"
})
.done(() => {
railSystemUpdated();
})
.fail((response) => {
console.error(response);
});
});
});
}
function addReachableConnectionBtn(signalId, fromId) {
const select = $('#signalPotentialConnectionsSelect-' + fromId);
const toId = select.val();
if (toId) {
addReachableConnection(signalId, fromId, toId);
}
}
function addReachableConnection(signalId, fromId, toId) {
confirm(`Are you sure you want to add a connection from ${fromId} to ${toId} from signal ${signalId}?`)
.then(() => {
$.get(`/api/railSystems/${railSystem.id}/signals/${signalId}`)
.done(signal => {
let connections = [];
signal.branchConnections.forEach(con => {
con.reachableSignalConnections.forEach(reachableCon => {
connections.push({from: con.id, to: reachableCon.connectionId});
});
});
if (!connections.find(c => c.from === fromId && c.to === toId)) {
connections.push({from: fromId, to: toId});
$.post({
url: `/api/railSystems/${railSystem.id}/signals/${signal.id}/signalConnections`,
data: JSON.stringify({connections: connections}),
contentType: "application/json"
})
.done(() => {
railSystemChanged();
})
.fail((response) => {
console.error(response);
});
}
});
});
}
function confirm(message) {
const modalElement = $('#confirmModal');
if (message) {
$('#confirmModalBody').html(message);
} else {
$('#confirmModalBody').html("Are you sure you want to continue?");
}
const modal = new bootstrap.Modal(modalElement[0], {keyboard: false});
modal.show();
return new Promise((resolve, reject) => {
$('#confirmModalOkButton').click(() => {
modalElement.on("hidden.bs.modal", () => resolve());
});
$('#confirmModalCancelButton').click(() => {
modalElement.on("hidden.bs.modal", () => reject());
});
});
}

View File

@ -1,209 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RailSignal</title>
<link href="/static/style/main.css" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
</head>
<body>
<div class="container">
<h1>RailSignal</h1>
<p class="lead">Stay in control of your rails.</p>
<div class="row align-items-center">
<div class="col-auto">
<label for="railSystemSelect">Select a rail system:</label>
</div>
<div class="col-auto">
<select id="railSystemSelect" class="form-select">
</select>
</div>
<div class="col-auto">
<div class="input-group">
<input type="text" class="form-control" placeholder="Or add a new system" id="addRailSystemInput"/>
<button class="btn btn-success" type="button" id="addRailSystemButton" disabled>Add</button>
</div>
</div>
<div class="col-auto">
<button class="btn btn-danger" type="button" id="removeRailSystemButton">Remove Selected System</button>
</div>
</div>
<div class="row">
<div class="col">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addSignalModal">Add Signal</button>
</div>
</div>
<hr class="my-4"/>
<div class="row">
<div id="railMapCanvasParent" class="col">
<canvas id="railMapCanvas" width="800" height="500" class="border"></canvas>
</div>
<div id="railMapDetailPanel" class="col p-2 border">
</div>
</div>
</div>
<div>
<script id="signalTemplate" type="text/x-handlebars-template">
<h3>{{name}}</h3>
<hr>
<dl class="row">
<dt class="col-sm-3">ID</dt>
<dd class="col-sm-9">{{id}}</dd>
<dt class="col-sm-3">Position</dt>
<dd class="col-sm-9">{{position.x}}, {{position.y}}, {{position.z}}</dd>
<dt class="col-sm-3">Online</dt>
<dd class="col-sm-9">{{online}}</dd>
</dl>
<h4>Connections</h4>
<hr>
{{#each branchConnections}}
<h5>{{this.direction}} <small class="text-muted">ID: {{this.id}}</small></h5>
<p>
On branch <span class="badge bg-dark">{{this.branch.name}} <small class="text-muted">ID: {{this.branch.id}}</small></span>
</p>
<h6>Connected to Signals:</h6>
<ul class="list-unstyled">
{{#each this.reachableSignalConnections}}
<li>
<a onclick="selectSignalById({{this.signalId}})" href="#">{{this.signalName}}</a>
via {{this.direction}} <small class="text-muted">ID: {{this.connectionId}}</small>
<span
class="btn btn-sm btn-secondary js_removeSignalConnection"
onclick="removeReachableConnection({{../../id}}, {{../id}}, {{this.connectionId}})"
>Remove</span>
</li>
{{/each}}
</ul>
<div class="row" id="signalPotentialConnectionsRow-{{this.id}}">
<div class="col-auto">
<select id="signalPotentialConnectionsSelect-{{this.id}}" class="form-select form-select-sm">
{{#each this.potentialSignalConnections}}
<option value="{{this.connectionId}}">{{this.signalName}} via {{this.branchName}} on {{this.direction}} connection</option>
{{/each}}
</select>
</div>
<div class="col-auto">
<button type="button" class="btn btn-primary btn-sm" onclick="addReachableConnectionBtn({{../id}}, {{this.id}})">Add Connection</button>
</div>
</div>
{{/each}}
<div class="row">
<div class="col">
<button type="button" class="btn btn-secondary" onclick="removeSignal({{id}})">Remove this Signal</button>
</div>
</div>
</script>
<div class="modal fade" tabindex="-1" id="confirmModal" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="confirmModalHeader">Confirm</h5>
</div>
<div class="modal-body">
<p id="confirmModalBody">Are you sure you want to continue?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="confirmModalCancelButton">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" id="confirmModalOkButton">Ok</button>
</div>
</div>
</div>
</div>
<div class="modal fade" tabindex="-1" id="addSignalModal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Signal</h5>
</div>
<div class="modal-body">
<p>Fill in the following data to add a new signal to the system.</p>
<form id="addSignalForm">
<div class="row mb-3">
<div class="col">
<input id="addSignalName" type="text" class="form-control" placeholder="Name" aria-label="Name" required>
</div>
</div>
<div class="row mb-3">
<h6>Position</h6>
<div class="col">
<input id="addSignalPositionX" class="form-control" placeholder="X" aria-label="X" type="number" step="0.1" required>
</div>
<div class="col">
<input id="addSignalPositionY" class="form-control" placeholder="Y" aria-label="Y" type="number" step="0.1" required>
</div>
<div class="col">
<input id="addSignalPositionZ" class="form-control" placeholder="Z" aria-label="Z" type="number" step="0.1" required>
</div>
</div>
<h6>Connections</h6>
<div class="row mb-3">
<div class="col">
<select id="addSignalFirstConnectionDirection" class="form-select" required>
<option value="N">North</option>
<option value="S">South</option>
<option value="E">East</option>
<option value="W">West</option>
<option value="NW">NorthWest</option>
<option value="NE">NorthEast</option>
<option value="SW">SouthWest</option>
<option value="SE">SouthEast</option>
</select>
</div>
<div class="col">
<input id="addSignalFirstConnectionBranch" type="text" class="form-control" placeholder="Branch Name" list="addSignalFirstConnectionBranchList" required>
<datalist id="addSignalFirstConnectionBranchList" class="js_branch_list">
</datalist>
</div>
</div>
<div class="row mb-3">
<div class="col">
<select id="addSignalSecondConnectionDirection" class="form-select" required>
<option value="N">North</option>
<option value="S">South</option>
<option value="E">East</option>
<option value="W">West</option>
<option value="NW">NorthWest</option>
<option value="NE">NorthEast</option>
<option value="SW">SouthWest</option>
<option value="SE">SouthEast</option>
</select>
</div>
<div class="col">
<input id="addSignalSecondConnectionBranch" type="text" class="form-control" placeholder="Branch Name" list="addSignalSecondConnectionBranchList" required>
<datalist id="addSignalSecondConnectionBranchList" class="js_branch_list">
</datalist>
</div>
</div>
</form>
<div id="addSignalAlertsContainer"></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" onclick="addNewSignal()">Save Changes</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-validation@1.19.3/dist/jquery.validate.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.7/handlebars.min.js" integrity="sha512-RNLkV3d+aLtfcpEyFG8jRbnWHxUqVZozacROI4J2F1sTaDqo1dPQYs01OMi1t1w9Y2FdbSCDSQ2ZVdAC8bzgAg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/static/js/drawing.js"></script>
<script src="/static/js/main.js"></script>
</body>
</html>