Compare commits

...

6 Commits
v2.2.1 ... main

19 changed files with 1159 additions and 726 deletions

View File

@ -32,6 +32,8 @@ const LOG_DIR = "./log";
const API_LOG = LOG_DIR ~ "/api_build.txt";
const APP_LOG = LOG_DIR ~ "/app_build.txt";
const GITHUB_PROPS_FILE = "github_token.properties";
int main(string[] args) {
string ver = getVersion();
if (ver is null) {
@ -54,7 +56,11 @@ int main(string[] args) {
if (args.length >= 3 && args[2].strip.toLower == "release") {
print("Are you sure you want to create a GitHub release for version %s?", ver);
string response = readln().strip.toLower;
if (response == "yes" || response == "y") createRelease(ver);
if (response == "yes" || response == "y") {
print("Please enter a short description for this release.");
string description = readln().strip;
createRelease(ver, description);
}
}
}
} else {
@ -109,7 +115,13 @@ string getVersion() {
return null;
}
void createRelease(string ver) {
/**
* Creates a new GitHub release using the specified version, and uploads the
* JAR file to the release.
* Params:
* ver = The version.
*/
void createRelease(string ver, string description) {
import d_properties;
import requests;
import std.json;
@ -119,15 +131,13 @@ void createRelease(string ver) {
JSONValue data = [
"tag_name": "v" ~ ver,
"name": "Rail Signal v" ~ ver,
"body": "An automated release."
"body": description
];
data.object["prerelease"] = JSONValue(true);
data.object["prerelease"] = JSONValue(false);
data.object["generate_release_notes"] = JSONValue(false);
print("Sending release API request:\n%s", data.toPrettyString);
auto rq = Request();
rq.verbosity = 2;
auto props = Properties("github_token.properties");
auto props = Properties(GITHUB_PROPS_FILE);
string username = props["username"];
string token = props["token"];
rq.authenticator = new BasicAuthentication(username, token);
@ -139,17 +149,24 @@ void createRelease(string ver) {
if (response.code == 201) {
string responseBody = cast(string) response.responseBody;
JSONValue responseData = parseJSON(responseBody);
string assetUrl = responseData["assets_url"].str;
print("Got asset url: %s", assetUrl);
auto f = File("./target/rail-signal-" ~ ver ~ ".jar", "rb");
auto assetResponse = rq.post(
assetUrl,
f.byChunk(4096),
"application/zip"
print("Created release %s", responseData["url"].str);
long releaseId = responseData["id"].integer;
string uploadUrl = format!"https://uploads.github.com/repos/andrewlalis/RailSignalAPI/releases/%d/assets?name=%s"(
releaseId,
"rail-signal-" ~ ver ~ ".jar"
);
writeln(assetResponse);
print("Uploading JAR file to %s", uploadUrl);
auto f = File("./target/rail-signal-" ~ ver ~ ".jar", "rb");
ulong assetSize = f.size();
rq.addHeaders(["Content-Length": format!"%d"(assetSize)]);
auto assetResponse = rq.post(uploadUrl, f.byChunk(4096));
if (assetResponse.code == 201) {
print("JAR file uploaded successfully.");
} else {
error("An error occurred while uploading the JAR file.");
}
} else {
error("An error occurred.");
error("An error occurred while creating the release.");
writeln(response.responseBody);
}
}

View File

@ -10,7 +10,7 @@
</parent>
<groupId>nl.andrewl</groupId>
<artifactId>rail-signal-api</artifactId>
<version>2.2.1</version>
<version>2.3.0</version>
<name>rail-signal-api</name>
<description>A simple API for tracking rail traffic in signalled blocks.</description>
<properties>

View File

@ -48,3 +48,16 @@ export function removeSegment(rs, segmentId) {
.catch(error => reject(error));
});
}
export function toggleOccupied(rs, segmentId) {
return new Promise((resolve, reject) => {
axios.patch(`${API_URL}/rs/${rs.id}/s/${segmentId}/occupied`)
.then(response => {
const updatedSegment = response.data;
const segment = rs.segments.find(s => s.id === updatedSegment.id);
segment.occupied = updatedSegment.occupied;
resolve();
})
.catch(reject);
})
}

View File

@ -1,195 +1,40 @@
<template>
<div class="row">
<div class="col-md-8" id="railSystemMapCanvasContainer">
<canvas id="railSystemMapCanvas" height="600">
<canvas id="railSystemMapCanvas" height="700">
Your browser doesn't support canvas.
</canvas>
</div>
<q-scroll-area class="col-md-4" v-if="railSystem.selectedComponents.length > 0">
<q-scroll-area class="col-md-4">
<div class="row" v-for="component in railSystem.selectedComponents" :key="component.id">
<div class="col full-width">
<selected-component-view :component="component"/>
</div>
</div>
<add-component-form v-if="addComponent.visible" :rail-system="railSystem" @created="addComponent.visible = false"/>
</q-scroll-area>
</div>
<q-page-sticky position="bottom-right" :offset="[25, 25]">
<q-fab icon="add" direction="up" color="accent">
<q-fab-action @click="addSignalData.toggle = true">
<q-icon><img src="~assets/icons/signal_icon.svg"/></q-icon>
<q-tooltip>Add Signal</q-tooltip>
</q-fab-action>
<q-fab-action @click="addSegmentBoundaryData.toggle = true">
<q-icon><img src="~assets/icons/segment-boundary_icon.svg"/></q-icon>
<q-tooltip>Add Segment Boundary</q-tooltip>
</q-fab-action>
<q-fab-action @click="addSwitchData.toggle = true">
<q-icon><img src="~assets/icons/switch_icon.svg"/></q-icon>
<q-tooltip>Add Switch</q-tooltip>
</q-fab-action>
<q-fab-action @click="addLabelData.toggle = true">
<q-icon><img src="~assets/icons/label_icon.svg"/></q-icon>
<q-tooltip>Add Label</q-tooltip>
</q-fab-action>
</q-fab>
<q-fab icon="add" color="accent" v-model="addComponent.visible"/>
</q-page-sticky>
<!-- Add Signal Dialog -->
<add-component-dialog
v-model="addSignalData"
type="SIGNAL"
:rail-system="railSystem"
title="Add Signal"
success-message="Signal added."
>
<template #subtitle>
<p>
Add a signal to the rail system.
</p>
</template>
<template #default>
<q-card-section>
<q-select
v-model="addSignalData.segment"
:options="railSystem.segments"
option-value="id"
option-label="name"
label="Segment"
/>
</q-card-section>
</template>
</add-component-dialog>
<!-- Add Segment boundary -->
<add-component-dialog
title="Add Segment Boundary"
success-message="Segment boundary added."
:rail-system="railSystem"
type="SEGMENT_BOUNDARY"
v-model="addSegmentBoundaryData"
>
<template #subtitle>
<p>
Add a segment boundary to the rail system.
</p>
</template>
<template #default>
<q-card-section>
<q-select
v-model="addSegmentBoundaryData.segments"
:options="railSystem.segments"
multiple
:option-value="segment => segment"
:option-label="segment => segment.name"
label="Segments"
/>
</q-card-section>
</template>
</add-component-dialog>
<!-- Add Switch dialog -->
<add-component-dialog
title="Add Switch"
success-message="Switch added."
:rail-system="railSystem"
type="SWITCH"
v-model="addSwitchData"
>
<template #subtitle>
<p>
Add a switch to the rail system.
</p>
</template>
<template #default>
<q-card-section>
<div class="row">
<q-list>
<q-item
v-for="config in addSwitchData.possibleConfigurations"
:key="config.key"
>
<q-item-section>
<q-item-label>
<q-chip
v-for="node in config.nodes"
:key="node.id"
:label="node.name"
/>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
<div class="row">
<div class="col-sm-6">
<q-select
v-model="addSwitchData.pathNode1"
:options="getEligibleSwitchNodes()"
:option-value="segment => segment"
:option-label="segment => segment.name"
label="First Path Node"
/>
</div>
<div class="col-sm-6">
<q-select
v-model="addSwitchData.pathNode2"
:options="getEligibleSwitchNodes()"
:option-value="segment => segment"
:option-label="segment => segment.name"
label="Second Path Node"
/>
</div>
</div>
<div class="row">
<q-btn label="Add Configuration" color="primary" @click="addSwitchConfiguration"/>
</div>
</q-card-section>
</template>
</add-component-dialog>
<!-- Add Label dialog -->
<add-component-dialog
title="Add Label"
success-message="Added label."
:rail-system="railSystem"
type="LABEL"
v-model="addLabelData"
>
<template #subtitle>
<p>
Add a label to the rail system as a piece of text on the map. Labels
are purely a visual component, and do not interact with the system
in any way, besides being a helpful point of reference for users.
</p>
</template>
<template #default>
<q-card-section>
<q-input
label="Label Text"
type="text"
v-model="addLabelData.text"
/>
</q-card-section>
</template>
</add-component-dialog>
</template>
<script>
import {RailSystem} from "src/api/railSystems";
import {draw, initMap} from "src/render/mapRenderer";
import {draw, initMap} from "src/map/mapRenderer";
import SelectedComponentView from "components/rs/SelectedComponentView.vue";
import {useQuasar} from "quasar";
import AddComponentDialog from "components/rs/add_component/AddComponentDialog.vue";
import AddComponentForm from "components/rs/add_component/AddComponentForm.vue";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { registerComponentSelectionListener } from "src/map/mapEventListener";
export default {
name: "MapView",
components: { AddComponentDialog, SelectedComponentView },
components: { AddComponentForm, SelectedComponentView },
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {quasar};
return {quasar, rsStore};
},
props: {
railSystem: {
@ -199,48 +44,18 @@ export default {
},
data() {
return {
addSignalData: {
name: "",
position: {
x: 0, y: 0, z: 0
},
segment: null,
toggle: false
},
addSegmentBoundaryData: {
name: "",
position: {
x: 0, y: 0, z: 0
},
toggle: false,
segments: [],
connectedNodes: []
},
addSwitchData: {
name: "",
position: {
x: 0, y: 0, z: 0
},
toggle: false,
possibleConfigurations: [],
// Utility properties for the UI for adding configurations.
pathNode1: null,
pathNode2: null
},
addLabelData: {
position: {
x: 0, y: 0, z: 0
},
toggle: false,
text: ""
addComponent: {
visible: false
}
}
},
mounted() {
initMap(this.railSystem);
registerComponentSelectionListener("addComponentFormHide", () => this.addComponent.visible = false);
},
updated() {
initMap(this.railSystem);
registerComponentSelectionListener("addComponentFormHide", () => this.addComponent.visible = false);
},
watch: {
railSystem: {
@ -248,41 +63,11 @@ export default {
draw();
},
deep: true
}
},
methods: {
getEligibleSwitchNodes() {
return this.railSystem.components.filter(component => {
return component.connectedNodes !== undefined && component.connectedNodes !== null;
});
},
addSwitchConfiguration() {
if (
this.addSwitchData.pathNode1 === null ||
this.addSwitchData.pathNode2 === null ||
this.addSwitchData.pathNode1.id === this.addSwitchData.pathNode2.id ||
this.addSwitchData.possibleConfigurations.some(config => {
// Check if there's already a configuration containing both of these nodes.
return config.nodes.every(node =>
node.id === this.addSwitchData.pathNode1.id ||
node.id === this.addSwitchData.pathNode2.id);
})
) {
this.quasar.notify({
color: "warning",
message: "Invalid switch configuration."
});
return;
'addComponent.visible'(newValue) { // Deselect all components when the user opens the "Add Component" form.
if (newValue === true) {
this.rsStore.selectedRailSystem.selectedComponents.length = 0;
}
// All good!
this.addSwitchData.possibleConfigurations.push({
nodes: [
this.addSwitchData.pathNode1,
this.addSwitchData.pathNode2
],
// A unique key, just for the frontend to use. This is not used by the API.
key: this.addSwitchData.pathNode1.id + "_" + this.addSwitchData.pathNode2.id
});
}
}
};

View File

@ -13,9 +13,15 @@
<q-item-label>{{segment.name}}</q-item-label>
<q-item-label caption>Id: {{segment.id}}</q-item-label>
</q-item-section>
<q-item-section v-if="segment.occupied" side>
<q-chip label="Occupied"/>
</q-item-section>
<q-menu touch-position context-menu>
<q-list dense style="min-width: 100px">
<q-item clickable v-close-popup @click="toggleOccupiedInline(segment)">
<q-item-section>Toggle Occupied</q-item-section>
</q-item>
<q-item clickable v-close-popup @click="remove(segment)">
<q-item-section>Delete</q-item-section>
</q-item>
@ -68,7 +74,7 @@
import { useRailSystemsStore } from "stores/railSystemsStore";
import { RailSystem } from "src/api/railSystems";
import { useQuasar } from "quasar";
import { createSegment, removeSegment } from "src/api/segments";
import { createSegment, removeSegment, toggleOccupied } from "src/api/segments";
import { ref } from "vue";
export default {
@ -115,6 +121,9 @@ export default {
onReset() {
this.segmentName = "";
},
toggleOccupiedInline(segment) {
toggleOccupied(this.rsStore.selectedRailSystem, segment.id)
},
remove(segment) {
this.quasar.dialog({
title: "Confirm",

View File

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

View File

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

View File

@ -15,10 +15,8 @@
dense
size="sm"
:label="node.name"
clickable
/>
</q-item-label>
<q-item-label caption>Configuration #{{config.id}}</q-item-label>
</q-item-section>
<q-item-section v-if="!isConfigActive(config)" side top>
<q-btn dense size="sm" color="positive" @click="setActiveSwitchConfig(config.id)">Set Active</q-btn>

View File

@ -1,3 +1,5 @@
import { MAP_CANVAS } from "src/map/mapRenderer";
export function roundedRect(ctx, x, y, w, h, r) {
if (w < 2 * r) r = w / 2;
if (h < 2 * r) r = h / 2;
@ -48,3 +50,15 @@ function distCompare(p0, a, b) {
const distB = (p0.x-b.x)*(p0.x-b.x) + (p0.y-b.y)*(p0.y-b.y);
return distA - distB;
}
/**
* Gets the point at which the user clicked on the map.
* @param {MouseEvent} event
* @returns {DOMPoint}
*/
export function getMousePoint(event) {
const rect = MAP_CANVAS.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return new DOMPoint(x, y, 0, 1);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,14 +3,11 @@ package nl.andrewl.railsignalapi.live.websocket;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistration;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import java.util.Set;
/**
* Configuration for Rail Signal's websockets. This includes both app and
* component connections.
@ -24,7 +21,6 @@ public class WebsocketConfig implements WebSocketConfigurer {
private final ComponentWebsocketHandshakeInterceptor componentInterceptor;
private final AppWebsocketHandler appHandler;
private final AppWebsocketHandshakeInterceptor appInterceptor;
private final Environment env;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
@ -33,12 +29,6 @@ public class WebsocketConfig implements WebSocketConfigurer {
.addInterceptors(componentInterceptor);
WebSocketHandlerRegistration appHandlerReg = registry.addHandler(appHandler, "/api/ws/app/*")
.addInterceptors(appInterceptor);
// appHandlerReg.setAllowedOrigins("*");
// If we're in a development profile, allow any origin to access the app websocket.
// This is so that we can use a standalone JS dev server.
if (Set.of(env.getActiveProfiles()).contains("development")) {
log.info("Allowing all origins to access app websocket because development profile is active.");
appHandlerReg.setAllowedOrigins("*");
}
appHandlerReg.setAllowedOrigins("*");
}
}

View File

@ -36,4 +36,9 @@ public class SegmentsApiController {
segmentService.remove(rsId, sId);
return ResponseEntity.noContent().build();
}
@PatchMapping(path = "/{sId}/occupied")
public FullSegmentResponse toggleOccupied(@PathVariable long rsId, @PathVariable long sId) {
return segmentService.toggleOccupied(rsId, sId);
}
}

View File

@ -76,6 +76,16 @@ public class SegmentService {
}
}
@Transactional
public FullSegmentResponse toggleOccupied(long rsId, long sId) {
var segment = segmentRepository.findByIdAndRailSystemId(sId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
segment.setOccupied(!segment.isOccupied());
segmentRepository.save(segment);
sendSegmentOccupiedStatus(segment);
return new FullSegmentResponse(segment);
}
/**
* Handles updates from segment boundary components.
* @param msg The update message.

64
upload-asset.sh Executable file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Author: Stefan Buck
# License: MIT
# https://gist.github.com/stefanbuck/ce788fee19ab6eb0b4447a85fc99f447
#
#
# This script accepts the following parameters:
#
# * owner
# * repo
# * tag
# * filename
# * github_api_token
#
# Script to upload a release asset using the GitHub API v3.
#
# Example:
#
# upload-github-release-asset.sh github_api_token=TOKEN owner=stefanbuck repo=playground tag=v0.1.0 filename=./build.zip
#
# Check dependencies.
set -e
xargs=$(which gxargs || which xargs)
# Validate settings.
[ "$TRACE" ] && set -x
CONFIG=$@
for line in $CONFIG; do
eval "$line"
done
# Define variables.
GH_API="https://api.github.com"
GH_REPO="$GH_API/repos/$owner/$repo"
GH_TAGS="$GH_REPO/releases/tags/$tag"
AUTH="Authorization: token $github_api_token"
WGET_ARGS="--content-disposition --auth-no-challenge --no-cookie"
CURL_ARGS="-LJO#"
if [[ "$tag" == 'LATEST' ]]; then
GH_TAGS="$GH_REPO/releases/latest"
fi
# Validate token.
curl -o /dev/null -sH "$AUTH" $GH_REPO || { echo "Error: Invalid repo, token or network issue!"; exit 1; }
# Read asset tags.
response=$(curl -sH "$AUTH" $GH_TAGS)
# Get ID of the asset based on given filename.
eval $(echo "$response" | grep -m 1 "id.:" | grep -w id | tr : = | tr -cd '[[:alnum:]]=')
[ "$id" ] || { echo "Error: Failed to get release id for tag: $tag"; echo "$response" | awk 'length($0)<100' >&2; exit 1; }
# Upload asset
echo "Uploading asset... "
# Construct url
GH_ASSET="https://uploads.github.com/repos/$owner/$repo/releases/$id/assets?name=$(basename $filename)"
curl "$GITHUB_OAUTH_BASIC" --data-binary @"$filename" -H "Authorization: token $github_api_token" -H "Content-Type: application/octet-stream" $GH_ASSET