Fixed switch UI updating, websocket bugs, and context switching.

This commit is contained in:
Andrew Lalis 2022-06-01 15:54:29 +02:00
parent c16627e7d3
commit bd6c87149a
22 changed files with 470 additions and 252 deletions

View File

@ -12,6 +12,7 @@
"axios": "^0.21.1", "axios": "^0.21.1",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"quasar": "^2.6.0", "quasar": "^2.6.0",
"randomcolor": "^0.6.2",
"vue": "^3.0.0", "vue": "^3.0.0",
"vue-router": "^4.0.0" "vue-router": "^4.0.0"
}, },
@ -3666,6 +3667,11 @@
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
}, },
"node_modules/randomcolor": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
"integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A=="
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -7276,6 +7282,11 @@
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
}, },
"randomcolor": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
"integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A=="
},
"range-parser": { "range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",

View File

@ -11,24 +11,25 @@
"test": "echo \"No test specified\" && exit 0" "test": "echo \"No test specified\" && exit 0"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.0.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"pinia": "^2.0.11", "pinia": "^2.0.11",
"@quasar/extras": "^1.0.0",
"quasar": "^2.6.0", "quasar": "^2.6.0",
"randomcolor": "^0.6.2",
"vue": "^3.0.0", "vue": "^3.0.0",
"vue-router": "^4.0.0" "vue-router": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.10.0",
"eslint-plugin-vue": "^8.5.0",
"eslint-config-prettier": "^8.1.0",
"prettier": "^2.5.1",
"@quasar/app-vite": "^1.0.0", "@quasar/app-vite": "^1.0.0",
"autoprefixer": "^10.4.2" "autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-vue": "^8.5.0",
"prettier": "^2.5.1"
}, },
"engines": { "engines": {
"node": "^18 || ^16 || ^14.19", "node": "^18 || ^16 || ^14.19",
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "yarn": ">= 1.21.1"
} }
} }

View File

@ -31,8 +31,7 @@ module.exports = configure(function (ctx) {
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli/boot-files // https://v2.quasar.dev/quasar-cli/boot-files
boot: [ boot: [
'axios'
'axios',
], ],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css

View File

@ -3,7 +3,7 @@
</template> </template>
<script> <script>
import { defineComponent } from 'vue' import { defineComponent } from "vue";
export default defineComponent({ export default defineComponent({
name: 'App' name: 'App'

View File

@ -2,6 +2,9 @@ import axios from "axios";
import {API_URL} from "./constants"; import {API_URL} from "./constants";
import { refreshSegments } from "src/api/segments"; import { refreshSegments } from "src/api/segments";
import { refreshComponents } from "src/api/components"; import { refreshComponents } from "src/api/components";
import { closeWebsocketConnection, establishWebsocketConnection } from "src/api/websocket";
import { refreshLinkTokens } from "src/api/linkTokens";
import { refreshSettings } from "src/api/settings";
export class RailSystem { export class RailSystem {
constructor(data) { constructor(data) {
@ -15,6 +18,7 @@ export class RailSystem {
this.websocket = null; this.websocket = null;
this.selectedComponents = []; this.selectedComponents = [];
this.loaded = false;
} }
} }
@ -57,7 +61,9 @@ export function removeRailSystem(rsStore, id) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${id}`) axios.delete(`${API_URL}/rs/${id}`)
.then(() => { .then(() => {
rsStore.selectedRailSystem = null; if (rsStore.selectedRailSystem !== null && rsStore.selectedRailSystem.id === id) {
rsStore.selectRailSystem(null);
}
refreshRailSystems(rsStore) refreshRailSystems(rsStore)
.then(resolve) .then(resolve)
.catch(reject); .catch(reject);
@ -65,3 +71,41 @@ export function removeRailSystem(rsStore, id) {
.catch(reject); .catch(reject);
}); });
} }
/**
* Loads all data for a rail system. This is generally done when a rail system
* is selected.
* @param {RailSystem} rs
*/
export async function loadData(rs) {
console.log("Loading rail system " + rs.id);
await closeWebsocketConnection(rs);
console.log("Closed websocket connection to " + rs.id);
const updatePromises = [];
updatePromises.push(refreshSegments(rs));
updatePromises.push(refreshComponents(rs));
updatePromises.push(refreshLinkTokens(rs));
updatePromises.push(refreshSettings(rs));
await Promise.all(updatePromises);
await establishWebsocketConnection(rs);
console.log("Finished loading rail system " + rs.id);
rs.loaded = true;
}
/**
* Unloads all data for a rail system. This is generally done when the user
* navigates away from a rail system's page.
* @param {RailSystem} rs
* @returns {Promise}
*/
export async function unloadData(rs) {
console.log("Unloading data for rail system " + rs.id);
await closeWebsocketConnection(rs);
rs.segments = [];
rs.components = [];
rs.linkTokens = [];
rs.selectedComponents = [];
rs.settings = null;
rs.loaded = false;
console.log("Finished unloading data for rail system " + rs.id);
}

View File

@ -1,5 +1,10 @@
import { WS_URL } from "./constants"; import { WS_URL } from "./constants";
/**
* The time to wait before attempting to reconnect if a websocket connection is
* abruptly closed.
* @type {number}
*/
const WS_RECONNECT_TIMEOUT = 3000; const WS_RECONNECT_TIMEOUT = 3000;
/** /**
@ -8,6 +13,9 @@ const WS_RECONNECT_TIMEOUT = 3000;
* @return {Promise} A promise that resolves when a connection is established. * @return {Promise} A promise that resolves when a connection is established.
*/ */
export function establishWebsocketConnection(rs) { export function establishWebsocketConnection(rs) {
if (rs.websocket) {
console.log('rail system ' + rs.id + ' already has websocket')
}
return new Promise(resolve => { return new Promise(resolve => {
rs.websocket = new WebSocket(`${WS_URL}/${rs.id}`); rs.websocket = new WebSocket(`${WS_URL}/${rs.id}`);
rs.websocket.onopen = resolve; rs.websocket.onopen = resolve;
@ -29,7 +37,7 @@ export function establishWebsocketConnection(rs) {
const id = data.cId; const id = data.cId;
const idx = rs.components.findIndex(c => c.id === id); const idx = rs.components.findIndex(c => c.id === id);
if (idx > -1) { if (idx > -1) {
rs.components[idx] = data.data; Object.assign(rs.components[idx], data.data);
} }
} }
}; };
@ -46,12 +54,15 @@ export function establishWebsocketConnection(rs) {
*/ */
export function closeWebsocketConnection(rs) { export function closeWebsocketConnection(rs) {
return new Promise(resolve => { return new Promise(resolve => {
if (rs.websocket) { if (rs.websocket && rs.websocket.readyState !== WebSocket.CLOSED) {
rs.websocket.onclose = resolve; rs.websocket.onclose = () => {
rs.websocket = null;
resolve();
};
rs.websocket.close(); rs.websocket.close();
} else { } else {
rs.websocket = null;
resolve(); resolve();
} }
}); });
} }

View File

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

View File

@ -176,6 +176,9 @@ export default {
}, },
setActiveSwitchConfig(sw, configId) { setActiveSwitchConfig(sw, configId) {
updateSwitchConfiguration(this.railSystem, sw, configId); updateSwitchConfiguration(this.railSystem, sw, configId);
},
isConfigActive(sw, config) {
return sw.activeConfiguration !== null && sw.activeConfiguration.id === config.id;
} }
} }
}; };

View File

@ -30,7 +30,7 @@
clickable clickable
v-ripple v-ripple
:to="'/'" :to="'/'"
@click="rsStore.selectedRailSystem = null" @click="rsStore.selectRailSystem(null)"
> >
<q-item-section> <q-item-section>
<q-item-label>Home</q-item-label> <q-item-label>Home</q-item-label>
@ -40,7 +40,7 @@
clickable clickable
v-ripple v-ripple
:to="'/about'" :to="'/about'"
@click="rsStore.selectedRailSystem = null" @click="rsStore.selectRailSystem(null)"
> >
<q-item-section> <q-item-section>
<q-item-label>About</q-item-label> <q-item-label>About</q-item-label>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template> <template>
<q-page> <q-page>
<div v-if="railSystem"> <div v-if="railSystem && railSystem.loaded">
<q-tabs <q-tabs
v-model="panel" v-model="panel"
align="left" align="left"
@ -25,6 +25,10 @@
<router-view /> <router-view />
</div> </div>
<q-inner-loading
:showing="!railSystem || !railSystem.loaded"
label="Loading rail system..."
/>
</q-page> </q-page>
</template> </template>
@ -33,6 +37,7 @@ import { useRailSystemsStore } from "stores/railSystemsStore";
import MapView from "components/rs/MapView.vue"; import MapView from "components/rs/MapView.vue";
import SegmentsView from "components/rs/SegmentsView.vue"; import SegmentsView from "components/rs/SegmentsView.vue";
import SettingsView from "components/rs/SettingsView.vue"; import SettingsView from "components/rs/SettingsView.vue";
import { loadData, unloadData } from "src/api/railSystems";
export default { export default {
name: "RailSystemPage", name: "RailSystemPage",
@ -41,23 +46,45 @@ export default {
return { return {
panel: "map", panel: "map",
railSystem: null, railSystem: null,
loading: false
linkTokens: []
} }
}, },
beforeRouteEnter(to, from, next) { setup() {
const id = parseInt(to.params.id);
const rsStore = useRailSystemsStore(); const rsStore = useRailSystemsStore();
rsStore.selectRailSystem(id).then(() => { return {rsStore};
next(vm => vm.railSystem = rsStore.selectedRailSystem);
});
}, },
beforeRouteUpdate(to, from) { mounted() {
const id = parseInt(to.params.id); this.updateRailSystem();
const rsStore = useRailSystemsStore(); },
rsStore.selectRailSystem(id).then(() => { created() {
this.railSystem = rsStore.selectedRailSystem; this.$watch(
}); () => this.$route.params,
this.updateRailSystem,
{
immediate: true
}
)
},
methods: {
async updateRailSystem() {
if (this.loading) return;
this.loading = true;
console.log(">>>> updating rail system.")
if (this.railSystem) {
this.rsStore.selectedRailSystem = null;
await unloadData(this.railSystem);
}
if (this.$route.params.id) {
const newRsId = parseInt(this.$route.params.id);
const rs = this.rsStore.railSystems.find(r => r.id === newRsId);
if (rs) {
this.railSystem = rs;
this.rsStore.selectedRailSystem = rs;
await loadData(rs);
}
}
this.loading = false;
}
} }
}; };
</script> </script>

View File

@ -13,4 +13,13 @@ export function roundedRect(ctx, x, y, w, h, r) {
export function circle(ctx, x, y, r) { export function circle(ctx, x, y, r) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2); ctx.arc(x, y, r, 0, Math.PI * 2);
} }
export function mulberry32(a) {
return function() {
let t = a += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}

View File

@ -2,10 +2,89 @@
Helper functions to actually perform rendering of different components. Helper functions to actually perform rendering of different components.
*/ */
import { getScaleFactor, isComponentHovered, isComponentSelected } from "./mapRenderer"; import { getScaleFactor, getWorldTransform, isComponentHovered, isComponentSelected } from "./mapRenderer";
import { circle, roundedRect } from "./canvasUtils"; import { circle, roundedRect } from "./canvasUtils";
import randomColor from "randomcolor";
export function drawComponent(ctx, worldTx, component) { export function drawMap(ctx, rs) {
const worldTx = getWorldTransform();
ctx.setTransform(worldTx);
drawSegments(ctx, rs);
drawNodeConnections(ctx, rs, worldTx);
drawComponents(ctx, rs, worldTx);
}
function drawSegments(ctx, rs) {
const segmentPoints = new Map();
rs.segments.forEach(segment => segmentPoints.set(segment.id, []));
for (let i = 0; i < rs.components.length; i++) {
const c = rs.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});
}
}
}
for (let i = 0; i < rs.segments.length; i++) {
const color = randomColor({ luminosity: 'light', format: 'rgb', seed: rs.segments[i].id });
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.lineWidth = 5;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = "3px Sans-Serif";
const points = segmentPoints.get(rs.segments[i].id);
if (points.length === 0) continue;
const avgPoint = {x: points[0].x, y: points[0].y};
if (points.length === 1) {
circle(ctx, points[0].x, points[0].y, 5);
ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let j = 1; j < points.length; j++) {
ctx.lineTo(points[j].x, points[j].y);
avgPoint.x += points[j].x;
avgPoint.y += points[j].y;
}
avgPoint.x /= points.length;
avgPoint.y /= points.length;
ctx.lineTo(points[0].x, points[0].y);
ctx.fill();
ctx.stroke();
}
// Draw the segment name.
ctx.fillStyle = randomColor({luminosity: 'dark', format: 'rgb', seed: rs.segments[i].id});
ctx.fillText(rs.segments[i].name, avgPoint.x, avgPoint.y);
}
}
function drawNodeConnections(ctx, rs, worldTx) {
for (let i = 0; i < rs.components.length; i++) {
const c = rs.components[i];
if (c.connectedNodes !== undefined && c.connectedNodes !== null) {
drawConnectedNodes(ctx, worldTx, c);
}
}
}
function drawComponents(ctx, rs, worldTx) {
// Draw switch configurations first
for (let i = 0; i < rs.components.length; i++) {
if (rs.components[i].type === "SWITCH") {
drawSwitchConfigurations(ctx, worldTx, rs.components[i]);
}
}
for (let i = 0; i < rs.components.length; i++) {
drawComponent(ctx, worldTx, rs.components[i]);
}
}
function drawComponent(ctx, worldTx, component) {
const tx = DOMMatrix.fromMatrix(worldTx); const tx = DOMMatrix.fromMatrix(worldTx);
tx.translateSelf(component.position.x, component.position.z, 0); tx.translateSelf(component.position.x, component.position.z, 0);
const s = getScaleFactor(); const s = getScaleFactor();
@ -67,48 +146,60 @@ function drawSegmentBoundary(ctx) {
ctx.fill(); ctx.fill();
} }
function drawSwitch(ctx, sw) { function drawSwitchConfigurations(ctx, worldTx, sw) {
const colors = [ const tx = DOMMatrix.fromMatrix(worldTx);
`rgba(61, 148, 66, 0.25)`, tx.translateSelf(sw.position.x, sw.position.z, 0);
`rgba(59, 22, 135, 0.25)`, const s = getScaleFactor();
`rgba(145, 17, 90, 0.25)`, tx.scaleSelf(1/s, 1/s, 1/s);
`rgba(191, 49, 10, 0.25)` tx.scaleSelf(20, 20, 20);
]; ctx.setTransform(tx);
ctx.lineWidth = 1;
for (let i = 0; i < sw.possibleConfigurations.length; i++) { for (let i = 0; i < sw.possibleConfigurations.length; i++) {
const config = sw.possibleConfigurations[i]; const config = sw.possibleConfigurations[i];
ctx.strokeStyle = colors[i]; ctx.strokeStyle = randomColor({
for (let j = 0; j < config.nodes.length; j++) { seed: config.id,
const node = config.nodes[j]; format: 'rgb',
const diff = { luminosity: 'bright'
x: sw.position.x - node.position.x, });
y: sw.position.z - node.position.z, if (sw.activeConfiguration !== null && sw.activeConfiguration.id === config.id) {
}; ctx.lineWidth = 0.6;
const mag = Math.sqrt(Math.pow(diff.x, 2) + Math.pow(diff.y, 2)); } else {
diff.x = 2 * -diff.x / mag; ctx.lineWidth = 0.3;
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)`; for (let j = 0; j < config.nodes.length; j++) {
ctx.strokeStyle = `rgb(245, 188, 66)`; const node = config.nodes[j];
ctx.lineWidth = 0.2; const diff = {
circle(ctx, 0, 0.3, 0.2); x: sw.position.x - node.position.x,
ctx.fill(); y: sw.position.z - node.position.z,
circle(ctx, -0.3, -0.3, 0.2); };
ctx.fill(); const mag = Math.sqrt(Math.pow(diff.x, 2) + Math.pow(diff.y, 2));
circle(ctx, 0.3, -0.3, 0.2); diff.x = 3 * -diff.x / mag;
ctx.fill(); diff.y = 3 * -diff.y / mag;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, 0.3); ctx.moveTo(0, 0);
ctx.lineTo(0, 0); ctx.lineTo(diff.x, diff.y);
ctx.lineTo(0.3, -0.3); ctx.stroke();
ctx.moveTo(0, 0); }
ctx.lineTo(-0.3, -0.3); }
ctx.stroke(); }
function drawSwitch(ctx, sw) {
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 drawLabel(ctx, lbl) { function drawLabel(ctx, lbl) {

View File

@ -3,7 +3,7 @@ This component is responsible for the rendering of a RailSystem in a 2d map
view. view.
*/ */
import {drawComponent, drawConnectedNodes} from "./drawing"; import { drawMap } 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_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 SCALE_INDEX_NORMAL = 7;
@ -56,73 +56,26 @@ export function draw() {
ctx.resetTransform(); ctx.resetTransform();
ctx.fillStyle = `rgb(240, 240, 240)`; ctx.fillStyle = `rgb(240, 240, 240)`;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
const worldTx = getWorldTransform();
ctx.setTransform(worldTx);
// Draw segments! drawMap(ctx, railSystem);
const segmentPoints = new Map(); drawDebugInfo(ctx);
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++) { function drawDebugInfo(ctx) {
const c = railSystem.components[i]; ctx.resetTransform();
if (c.connectedNodes !== undefined && c.connectedNodes !== null) { ctx.fillStyle = "black";
drawConnectedNodes(ctx, worldTx, c); ctx.strokeStyle = "black";
} ctx.font = "10px Sans-Serif";
} const lastWorldPoint = mapPointToWorld(lastMousePoint);
const lines = [
for (let i = 0; i < railSystem.components.length; i++) { "Scale factor: " + getScaleFactor(),
drawComponent(ctx, worldTx, railSystem.components[i]); `(x = ${lastWorldPoint.x.toFixed(2)}, y = ${lastWorldPoint.y.toFixed(2)}, z = ${lastWorldPoint.z.toFixed(2)})`,
} `Components: ${railSystem.components.length}`,
`Hovered elements: ${hoveredElements.length}`
// Draw debug info. ]
ctx.resetTransform(); for (let i = 0; i < lines.length; i++) {
ctx.fillStyle = "black"; ctx.fillText(lines[i], 10, 20 + (i * 15));
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() { export function getScaleFactor() {
@ -133,7 +86,7 @@ export function getScaleFactor() {
* Gets a matrix that transforms world coordinates to canvas. * Gets a matrix that transforms world coordinates to canvas.
* @returns {DOMMatrix} * @returns {DOMMatrix}
*/ */
function getWorldTransform() { export function getWorldTransform() {
const canvasRect = mapCanvas.getBoundingClientRect(); const canvasRect = mapCanvas.getBoundingClientRect();
const scale = getScaleFactor(); const scale = getScaleFactor();
const tx = new DOMMatrix(); const tx = new DOMMatrix();
@ -159,7 +112,7 @@ export function isComponentSelected(component) {
* @param {DOMPoint} p * @param {DOMPoint} p
* @returns {DOMPoint} * @returns {DOMPoint}
*/ */
function mapPointToWorld(p) { export function mapPointToWorld(p) {
return getWorldTransform().invertSelf().transformPoint(p); return getWorldTransform().invertSelf().transformPoint(p);
} }
@ -168,7 +121,7 @@ function mapPointToWorld(p) {
* @param {DOMPoint} p * @param {DOMPoint} p
* @returns {DOMPoint} * @returns {DOMPoint}
*/ */
function worldPointToMap(p) { export function worldPointToMap(p) {
return getWorldTransform().transformPoint(p); return getWorldTransform().transformPoint(p);
} }

View File

@ -20,20 +20,22 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', {
actions: { actions: {
/** /**
* Updates the selected rail system. * Updates the selected rail system.
* @param rsId {Number} The new rail system id. * @param rsId {Number | null} The new rail system id.
* @returns {Promise} A promise that resolves when the new rail system is * @returns {Promise} A promise that resolves when the new rail system is
* fully loaded and ready. * fully loaded and ready.
*/ */
selectRailSystem(rsId) { selectRailSystem(rsId) {
// Close any existing websocket connections prior to refreshing. // Close any existing websocket connections prior to refreshing.
const wsClosePromises = []; const wsClosePromises = [];
if (this.selectedRailSystem) { if (this.selectedRailSystem !== null) {
wsClosePromises.push(closeWebsocketConnection(this.selectedRailSystem)); wsClosePromises.push(closeWebsocketConnection(this.selectedRailSystem));
} }
if (rsId === null) return Promise.all(wsClosePromises);
return new Promise(resolve => { return new Promise(resolve => {
Promise.all(wsClosePromises).then(() => { Promise.all(wsClosePromises).then(() => {
refreshRailSystems(this).then(() => { refreshRailSystems(this).then(() => {
const rs = this.railSystems.find(r => r.id === rsId); const rs = this.railSystems.find(r => r.id === rsId);
console.log(rs);
const updatePromises = []; const updatePromises = [];
updatePromises.push(refreshSegments(rs)); updatePromises.push(refreshSegments(rs));
updatePromises.push(refreshComponents(rs)); updatePromises.push(refreshComponents(rs));

View File

@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
* web app's index page. * web app's index page.
*/ */
@Controller @Controller
@RequestMapping(path = {"/", "/app", "/app/about", "/home", "/index.html", "/index"}) @RequestMapping(path = {"/", "/app", "/app/about", "/app/rail-systems/*", "/home", "/index.html", "/index"})
public class IndexPageController { public class IndexPageController {
@GetMapping @GetMapping
public String getIndex() { public String getIndex() {

View File

@ -2,6 +2,7 @@ package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.PathNode; import nl.andrewl.railsignalapi.model.component.PathNode;
import java.util.Comparator;
import java.util.List; import java.util.List;
public abstract class PathNodeResponse extends ComponentResponse { public abstract class PathNodeResponse extends ComponentResponse {
@ -9,6 +10,9 @@ public abstract class PathNodeResponse extends ComponentResponse {
public PathNodeResponse(PathNode p) { public PathNodeResponse(PathNode p) {
super(p); super(p);
this.connectedNodes = p.getConnectedNodes().stream().map(SimpleComponentResponse::new).toList(); this.connectedNodes = p.getConnectedNodes().stream()
.sorted(Comparator.comparing(PathNode::getName))
.map(SimpleComponentResponse::new)
.toList();
} }
} }

View File

@ -3,6 +3,7 @@ package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.SegmentBoundaryNode; import nl.andrewl.railsignalapi.model.component.SegmentBoundaryNode;
import nl.andrewl.railsignalapi.rest.dto.SegmentResponse; import nl.andrewl.railsignalapi.rest.dto.SegmentResponse;
import java.util.Comparator;
import java.util.List; import java.util.List;
public class SegmentBoundaryNodeResponse extends PathNodeResponse { public class SegmentBoundaryNodeResponse extends PathNodeResponse {
@ -10,6 +11,9 @@ public class SegmentBoundaryNodeResponse extends PathNodeResponse {
public SegmentBoundaryNodeResponse(SegmentBoundaryNode n) { public SegmentBoundaryNodeResponse(SegmentBoundaryNode n) {
super(n); super(n);
this.segments = n.getSegments().stream().map(SegmentResponse::new).toList(); this.segments = n.getSegments().stream()
.map(SegmentResponse::new)
.sorted(Comparator.comparing(sr -> sr.name))
.toList();
} }
} }

View File

@ -2,6 +2,7 @@ package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.SwitchConfiguration; import nl.andrewl.railsignalapi.model.component.SwitchConfiguration;
import java.util.Comparator;
import java.util.List; import java.util.List;
public record SwitchConfigurationResponse ( public record SwitchConfigurationResponse (
@ -11,7 +12,10 @@ public record SwitchConfigurationResponse (
public SwitchConfigurationResponse(SwitchConfiguration sc) { public SwitchConfigurationResponse(SwitchConfiguration sc) {
this( this(
sc.getId(), sc.getId(),
sc.getNodes().stream().map(SimpleComponentResponse::new).toList() sc.getNodes().stream()
.map(SimpleComponentResponse::new)
.sorted(Comparator.comparing(SimpleComponentResponse::name))
.toList()
); );
} }
} }

View File

@ -2,6 +2,7 @@ package nl.andrewl.railsignalapi.rest.dto.component.out;
import nl.andrewl.railsignalapi.model.component.Switch; import nl.andrewl.railsignalapi.model.component.Switch;
import java.util.Comparator;
import java.util.List; import java.util.List;
public class SwitchResponse extends PathNodeResponse { public class SwitchResponse extends PathNodeResponse {
@ -10,7 +11,10 @@ public class SwitchResponse extends PathNodeResponse {
public SwitchResponse(Switch s) { public SwitchResponse(Switch s) {
super(s); super(s);
this.possibleConfigurations = s.getPossibleConfigurations().stream().map(SwitchConfigurationResponse::new).toList(); this.possibleConfigurations = s.getPossibleConfigurations().stream()
.map(SwitchConfigurationResponse::new)
.sorted(Comparator.comparing(SwitchConfigurationResponse::id))
.toList();
this.activeConfiguration = s.getActiveConfiguration() == null ? null : new SwitchConfigurationResponse(s.getActiveConfiguration()); this.activeConfiguration = s.getActiveConfiguration() == null ? null : new SwitchConfigurationResponse(s.getActiveConfiguration());
} }
} }

View File

@ -1,6 +1,7 @@
package nl.andrewl.railsignalapi.service; package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.ComponentRepository; import nl.andrewl.railsignalapi.dao.ComponentRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository; import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SegmentRepository; import nl.andrewl.railsignalapi.dao.SegmentRepository;
@ -24,6 +25,7 @@ import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class SegmentService { public class SegmentService {
private final SegmentRepository segmentRepository; private final SegmentRepository segmentRepository;
private final RailSystemRepository railSystemRepository; private final RailSystemRepository railSystemRepository;