Compare commits
10 Commits
Author | SHA1 | Date |
---|---|---|
|
1cf5ed50fc | |
|
9b6eb7b667 | |
|
9c0d588543 | |
|
ad18c1b3d4 | |
|
ac7d040b5e | |
|
e90c267ec2 | |
|
feaff75121 | |
|
ebad42cf99 | |
|
99f438161f | |
|
cb8eed835a |
|
@ -37,3 +37,5 @@ build/
|
|||
|
||||
src/main/resources/app
|
||||
/build_system
|
||||
/log
|
||||
/github_token.properties
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1280"
|
||||
height="640"
|
||||
viewBox="0 0 338.66666 169.33334"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||
sodipodi:docname="banner.svg"
|
||||
inkscape:export-filename="/home/andrew/Pictures/railsignalbanner.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs2">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="-19.654761 : -18.142858 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="338.66666 : 84.66667 : 1"
|
||||
inkscape:persp3d-origin="169.33333 : 56.444447 : 1"
|
||||
id="perspective815" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.7"
|
||||
inkscape:cx="845.17179"
|
||||
inkscape:cy="205.3419"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-127.66666)">
|
||||
<rect
|
||||
style="fill:#659a54;fill-opacity:1;stroke:none;stroke-width:12.70000076;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect870"
|
||||
width="338.66666"
|
||||
height="169.33333"
|
||||
x="0"
|
||||
y="127.66666" />
|
||||
<rect
|
||||
style="fill:#454e44;fill-opacity:1;stroke:none;stroke-width:12.70000076;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect911"
|
||||
width="338.66666"
|
||||
height="111.125"
|
||||
x="-1.110223e-16"
|
||||
y="185.875" />
|
||||
<g
|
||||
id="g852"
|
||||
transform="rotate(90,151.19047,228.96429)">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path835"
|
||||
d="m 122.84226,146.37648 h 92.98215"
|
||||
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 122.84226,212.9003 h 92.98215"
|
||||
id="path837"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path839"
|
||||
d="m 122.84226,246.1622 h 92.98215"
|
||||
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 122.84226,179.63839 h 92.98215"
|
||||
id="path841"
|
||||
inkscape:connector-curvature="0" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path843"
|
||||
d="m 122.84226,279.42411 h 92.98215"
|
||||
style="fill:none;stroke:#4a2b00;stroke-width:12.69999981;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path831"
|
||||
d="M 148.35565,291.70833 V 129.93452"
|
||||
style="fill:none;stroke:#989898;stroke-width:8.46666718;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
style="fill:none;stroke:#989898;stroke-width:8.46666718;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 190.31102,291.70833 V 129.93452"
|
||||
id="path833"
|
||||
inkscape:connector-curvature="0" />
|
||||
</g>
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="flowRoot913"
|
||||
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#ffffff;fill-opacity:1;stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
transform="matrix(0.57599614,0,0,0.57599614,-10.203222,101.60397)"><flowRegion
|
||||
id="flowRegion915"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"><rect
|
||||
id="rect917"
|
||||
width="1102.8572"
|
||||
height="162.85715"
|
||||
x="51.42857"
|
||||
y="34.285713"
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /></flowRegion><flowPara
|
||||
id="flowPara919"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:96px;font-family:'Ubuntu Mono';-inkscape-font-specification:'Ubuntu Mono Bold';stroke:#102f07;stroke-width:4;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1">Rail Signal</flowPara></flowRoot> <g
|
||||
id="g933"
|
||||
transform="translate(123.59822)">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path921"
|
||||
d="m 169.33333,245.97321 v 46.49107"
|
||||
style="fill:none;stroke:#000000;stroke-width:16.93333435;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<rect
|
||||
ry="2.6458333"
|
||||
rx="2.6458333"
|
||||
y="196.08035"
|
||||
x="152.13544"
|
||||
height="51.026794"
|
||||
width="34.395802"
|
||||
id="rect923"
|
||||
style="fill:#000000;fill-opacity:1;stroke:#bababa;stroke-width:4.23333359;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<circle
|
||||
r="6.0476193"
|
||||
cy="212.33333"
|
||||
cx="169.33333"
|
||||
id="path925"
|
||||
style="fill:#1cd500;fill-opacity:1;stroke:none;stroke-width:8.46666718;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<circle
|
||||
style="fill:#d50000;fill-opacity:1;stroke:none;stroke-width:8.46666718;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle927"
|
||||
cx="169.33333"
|
||||
cy="228.5863"
|
||||
r="6.0476193" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 7.2 KiB |
152
build_system.d
152
build_system.d
|
@ -1,6 +1,9 @@
|
|||
#!/usr/bin/env dub
|
||||
/+ dub.sdl:
|
||||
dependency "dsh" version="~>1.6.1"
|
||||
dependency "dxml" version="~>0.4.3"
|
||||
dependency "requests" version="~>2.0.8"
|
||||
dependency "d-properties" version="~>1.0.4"
|
||||
+/
|
||||
|
||||
/**
|
||||
|
@ -11,27 +14,160 @@
|
|||
module build_system;
|
||||
|
||||
import dsh;
|
||||
import dxml.dom;
|
||||
import dxml.util;
|
||||
|
||||
import std.stdio;
|
||||
import std.string;
|
||||
import std.algorithm;
|
||||
import std.uni;
|
||||
|
||||
const DIST = "./src/main/resources/app";
|
||||
const DIST_ORIGIN = "./quasar-app/dist/spa";
|
||||
const APP_DIR = "./quasar-app";
|
||||
const APP_BUILD = "quasar build -m spa";
|
||||
const API_BUILD = "mvn clean package spring-boot:repackage -DskipTests=true";
|
||||
|
||||
void main(string[] args) {
|
||||
print("Building RailSignalAPI");
|
||||
chdir("quasar-app");
|
||||
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) {
|
||||
error("Could not determine version.");
|
||||
return 1;
|
||||
}
|
||||
removeIfExists(LOG_DIR);
|
||||
mkdir(LOG_DIR);
|
||||
print("Building Rail Signal v%s", ver);
|
||||
|
||||
if (args.length >= 2) {
|
||||
string command = args[1].strip.toLower;
|
||||
if (command == "app") {
|
||||
buildApp();
|
||||
} else if (command == "api") {
|
||||
buildApi(ver);
|
||||
} else if (command == "all") {
|
||||
buildApp();
|
||||
buildApi(ver);
|
||||
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") {
|
||||
print("Please enter a short description for this release.");
|
||||
string description = readln().strip;
|
||||
createRelease(ver, description);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buildApp();
|
||||
buildApi(ver);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production version of the frontend app and injects it into the
|
||||
* API's resources to serve statically.
|
||||
*/
|
||||
void buildApp() {
|
||||
chdir(APP_DIR);
|
||||
print("Building app...");
|
||||
runOrQuit(APP_BUILD);
|
||||
runOrQuit(APP_BUILD, "." ~ APP_LOG); // Use an extra dot because we moved into app dir.
|
||||
print("Copying dist from %s to %s", DIST_ORIGIN, DIST);
|
||||
chdir("..");
|
||||
removeIfExists(DIST);
|
||||
mkdir(DIST);
|
||||
copyDir(DIST_ORIGIN, DIST);
|
||||
|
||||
print("Building API...");
|
||||
runOrQuit(API_BUILD);
|
||||
print("Build complete!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production version of the backend API.
|
||||
*/
|
||||
void buildApi(string ver) {
|
||||
print("Building API...");
|
||||
runOrQuit(API_BUILD, API_LOG);
|
||||
string[] jars = findFilesByExtension("target", ".jar", false);
|
||||
string jarFile = jars[0];
|
||||
string finalJarFile = "./target/rail-signal-" ~ ver ~ ".jar";
|
||||
// Clean up the jar file name.
|
||||
copy(jarFile, finalJarFile);
|
||||
print("Build complete. Created %s", finalJarFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the version of the system from the pom file.
|
||||
* Returns: The version string, or null if it couldn't be found.
|
||||
*/
|
||||
string getVersion() {
|
||||
auto data = parseDOM!simpleXML(readText("pom.xml"));
|
||||
auto root = data.children[0];
|
||||
foreach (child; root.children) {
|
||||
if (child.name == "version") {
|
||||
return child.children[0].text;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
print("Creating release...");
|
||||
|
||||
JSONValue data = [
|
||||
"tag_name": "v" ~ ver,
|
||||
"name": "Rail Signal v" ~ ver,
|
||||
"body": description
|
||||
];
|
||||
data.object["prerelease"] = JSONValue(false);
|
||||
data.object["generate_release_notes"] = JSONValue(false);
|
||||
|
||||
auto rq = Request();
|
||||
auto props = Properties(GITHUB_PROPS_FILE);
|
||||
string username = props["username"];
|
||||
string token = props["token"];
|
||||
rq.authenticator = new BasicAuthentication(username, token);
|
||||
auto response = rq.post(
|
||||
"https://api.github.com/repos/andrewlalis/RailSignalAPI/releases",
|
||||
data.toString,
|
||||
"application/json"
|
||||
);
|
||||
if (response.code == 201) {
|
||||
string responseBody = cast(string) response.responseBody;
|
||||
JSONValue responseData = parseJSON(responseBody);
|
||||
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"
|
||||
);
|
||||
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 while creating the release.");
|
||||
writeln(response.responseBody);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
2
pom.xml
2
pom.xml
|
@ -10,7 +10,7 @@
|
|||
</parent>
|
||||
<groupId>nl.andrewl</groupId>
|
||||
<artifactId>rail-signal-api</artifactId>
|
||||
<version>2.1.0</version>
|
||||
<version>2.3.0</version>
|
||||
<name>rail-signal-api</name>
|
||||
<description>A simple API for tracking rail traffic in signalled blocks.</description>
|
||||
<properties>
|
||||
|
|
|
@ -98,6 +98,18 @@ export function removeComponent(rs, id) {
|
|||
});
|
||||
}
|
||||
|
||||
export function updateComponent(rs, component) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios.patch(`${API_URL}/rs/${rs.id}/c/${component.id}`, component)
|
||||
.then(() => {
|
||||
refreshComponents(rs)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSwitchConfiguration(rs, sw, configId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios.post(
|
||||
|
|
|
@ -62,7 +62,7 @@ export function removeRailSystem(rsStore, id) {
|
|||
axios.delete(`${API_URL}/rs/${id}`)
|
||||
.then(() => {
|
||||
if (rsStore.selectedRailSystem !== null && rsStore.selectedRailSystem.id === id) {
|
||||
rsStore.selectRailSystem(null);
|
||||
rsStore.selectedRailSystem = null;
|
||||
}
|
||||
refreshRailSystems(rsStore)
|
||||
.then(resolve)
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,196 +1,40 @@
|
|||
<template>
|
||||
<div class="row">
|
||||
<div class="col-md-8" id="railSystemMapCanvasContainer">
|
||||
<canvas id="railSystemMapCanvas" height="800">
|
||||
<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" :rail-system="railSystem" />
|
||||
<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 {RailSystem} from "src/api/railSystems";
|
||||
import {draw, initMap} from "src/map/mapRenderer";
|
||||
import SelectedComponentView from "components/rs/SelectedComponentView.vue";
|
||||
import { useQuasar } from "quasar";
|
||||
import { createComponent } from "src/api/components";
|
||||
import AddComponentDialog from "components/rs/add_component/AddComponentDialog.vue";
|
||||
import {useQuasar} from "quasar";
|
||||
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: {
|
||||
|
@ -200,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: {
|
||||
|
@ -249,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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,184 +1,37 @@
|
|||
<template>
|
||||
<div class="q-pa-md">
|
||||
<div class="text-h6">{{component.name}}</div>
|
||||
<q-list bordered>
|
||||
<q-item clickable>
|
||||
<q-item-section>
|
||||
<q-item-label>{{component.type}}</q-item-label>
|
||||
<q-item-label caption>Id: {{component.id}}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="component.online === true" top side>
|
||||
<q-chip color="positive" text-color="white">Online</q-chip>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="component.online === false" top side>
|
||||
<q-chip color="negative" text-color="white">Offline</q-chip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable>
|
||||
<q-item-section>
|
||||
<q-item-label>Position</q-item-label>
|
||||
<q-item-label caption>
|
||||
X: {{component.position.x}},
|
||||
Y: {{component.position.y}},
|
||||
Z: {{component.position.z}}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Signal info -->
|
||||
<q-expansion-item
|
||||
id="signalInfo"
|
||||
label="Signal Information"
|
||||
v-if="component.type === 'SIGNAL'"
|
||||
:content-inset-level="0.5"
|
||||
switch-toggle-side
|
||||
expand-separator
|
||||
>
|
||||
<q-list>
|
||||
<segment-list-item v-for="segment in [component.segment]" :key="segment.id" :segment="segment" />
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Path node info -->
|
||||
<q-expansion-item
|
||||
label="Connected Nodes"
|
||||
v-if="component.connectedNodes !== undefined && component.connectedNodes !== null"
|
||||
:content-inset-level="0.5"
|
||||
switch-toggle-side
|
||||
expand-separator
|
||||
class="q-gutter-md"
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="node in component.connectedNodes"
|
||||
:key="node.id"
|
||||
clickable
|
||||
v-ripple
|
||||
@click="select(node)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>{{node.name}}</q-item-label>
|
||||
<q-item-label caption>Id: {{node.id}}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Segment boundary info -->
|
||||
<q-expansion-item
|
||||
label="Connected Segments"
|
||||
v-if="component.type === 'SEGMENT_BOUNDARY'"
|
||||
:content-inset-level="0.5"
|
||||
switch-toggle-side
|
||||
expand-separator
|
||||
>
|
||||
<q-list>
|
||||
<segment-list-item v-for="segment in component.segments" :key="segment.id" :segment="segment"/>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Switch info -->
|
||||
<q-expansion-item
|
||||
label="Switch Information"
|
||||
v-if="component.type === 'SWITCH'"
|
||||
:content-inset-level="0.5"
|
||||
switch-toggle-side
|
||||
expand-separator
|
||||
>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="config in component.possibleConfigurations"
|
||||
:key="config.id"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label class="q-gutter-sm">
|
||||
<q-chip
|
||||
v-for="node in config.nodes"
|
||||
:key="node.id"
|
||||
dense
|
||||
size="sm"
|
||||
:label="node.name"
|
||||
clickable
|
||||
@click="select(node)"
|
||||
/>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="component.activeConfiguration === null || component.activeConfiguration.id !== config.id" side>
|
||||
<q-btn dense size="sm" color="positive" @click="setActiveSwitchConfig(component, config.id)">Set Active</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section side>
|
||||
<q-btn color="warning" label="Remove" size="sm" @click="remove(component)"/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
<label-component-view
|
||||
:label="component"
|
||||
v-if="component.type === 'LABEL'"
|
||||
/>
|
||||
<signal-component-view
|
||||
:signal="component"
|
||||
v-if="component.type === 'SIGNAL'"
|
||||
/>
|
||||
<segment-boundary-component-view
|
||||
:segment-boundary="component"
|
||||
v-if="component.type === 'SEGMENT_BOUNDARY'"
|
||||
/>
|
||||
<switch-component-view
|
||||
:sw="component"
|
||||
v-if="component.type === 'SWITCH'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { RailSystem } from "src/api/railSystems";
|
||||
import { useRailSystemsStore } from "stores/railSystemsStore";
|
||||
import SegmentListItem from "components/rs/SegmentListItem.vue";
|
||||
import { useQuasar } from "quasar";
|
||||
import { removeComponent, updateSwitchConfiguration } from "src/api/components";
|
||||
import SignalComponentView from "components/rs/component_views/SignalComponentView.vue";
|
||||
import SegmentBoundaryComponentView from "components/rs/component_views/SegmentBoundaryComponentView.vue";
|
||||
import LabelComponentView from "components/rs/component_views/LabelComponentView.vue";
|
||||
import SwitchComponentView from "components/rs/component_views/SwitchComponentView.vue";
|
||||
|
||||
export default {
|
||||
name: "SelectedComponentView",
|
||||
components: { SegmentListItem },
|
||||
setup() {
|
||||
const rsStore = useRailSystemsStore();
|
||||
const quasar = useQuasar();
|
||||
return {rsStore, quasar};
|
||||
},
|
||||
components: {
|
||||
SwitchComponentView,
|
||||
LabelComponentView, SegmentBoundaryComponentView, SignalComponentView },
|
||||
props: {
|
||||
component: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
railSystem: {
|
||||
type: RailSystem,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
select(component) {
|
||||
const c = this.rsStore.selectedRailSystem.components.find(cp => cp.id === component.id);
|
||||
if (c) {
|
||||
this.rsStore.selectedRailSystem.selectedComponents.length = 0;
|
||||
this.rsStore.selectedRailSystem.selectedComponents.push(c);
|
||||
}
|
||||
},
|
||||
remove(component) {
|
||||
this.quasar.dialog({
|
||||
title: "Confirm Removal",
|
||||
message: "Are you sure you want to remove this component? This cannot be undone.",
|
||||
cancel: true
|
||||
}).onOk(() => {
|
||||
removeComponent(this.railSystem, component.id)
|
||||
.then(() => {
|
||||
this.quasar.notify({
|
||||
color: "positive",
|
||||
message: "Component has been removed."
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.quasar.notify({
|
||||
color: "negative",
|
||||
message: "An error occurred: " + error.response.data.message
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
setActiveSwitchConfig(sw, configId) {
|
||||
updateSwitchConfiguration(this.railSystem, sw, configId);
|
||||
},
|
||||
isConfigActive(sw, config) {
|
||||
return sw.activeConfiguration !== null && sw.activeConfiguration.id === config.id;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div class="q-pa-md">
|
||||
<div class="text-h6">{{component.name}}</div>
|
||||
<q-list bordered>
|
||||
<q-item clickable>
|
||||
<q-item-section>
|
||||
<q-item-label>{{component.type}}</q-item-label>
|
||||
<q-item-label caption>Id: {{component.id}}</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="component.online === true" top side>
|
||||
<q-chip color="positive" text-color="white">Online</q-chip>
|
||||
</q-item-section>
|
||||
<q-item-section v-if="component.online === false" top side>
|
||||
<q-chip color="negative" text-color="white">Offline</q-chip>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable>
|
||||
<q-item-section>
|
||||
<q-item-label>Position</q-item-label>
|
||||
<q-item-label caption>
|
||||
X: {{component.position.x}},
|
||||
Y: {{component.position.y}},
|
||||
Z: {{component.position.z}}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator/>
|
||||
<slot></slot>
|
||||
|
||||
<q-item>
|
||||
<q-item-section side>
|
||||
<q-btn color="negative" label="Remove" size="sm" @click="remove(component)"/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {removeComponent} from "src/api/components";
|
||||
import {useQuasar} from "quasar";
|
||||
import {useRailSystemsStore} from "stores/railSystemsStore";
|
||||
|
||||
export default {
|
||||
name: "BaseComponentView",
|
||||
props: {
|
||||
component: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const rsStore = useRailSystemsStore();
|
||||
const quasar = useQuasar();
|
||||
return {rsStore, quasar};
|
||||
},
|
||||
methods: {
|
||||
select(component) {
|
||||
const c = this.rsStore.selectedRailSystem.components.find(cp => cp.id === component.id);
|
||||
if (c) {
|
||||
this.rsStore.selectedRailSystem.selectedComponents.length = 0;
|
||||
this.rsStore.selectedRailSystem.selectedComponents.push(c);
|
||||
}
|
||||
},
|
||||
remove(component) {
|
||||
this.quasar.dialog({
|
||||
title: "Confirm Removal",
|
||||
message: "Are you sure you want to remove this component? This cannot be undone.",
|
||||
cancel: true
|
||||
}).onOk(() => {
|
||||
removeComponent(this.rsStore.selectedRailSystem, component.id)
|
||||
.then(() => {
|
||||
this.quasar.notify({
|
||||
color: "positive",
|
||||
message: "Component has been removed."
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.quasar.notify({
|
||||
color: "negative",
|
||||
message: "An error occurred: " + error.response.data.message
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<base-component-view :component="label">
|
||||
<q-item-label header>Label</q-item-label>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label>{{label.text}}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</base-component-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
|
||||
export default {
|
||||
name: "LabelComponentView",
|
||||
components: {BaseComponentView},
|
||||
props: {
|
||||
label: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<q-item-label header>Connected Path Nodes</q-item-label>
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label class="q-gutter-sm">
|
||||
<q-chip
|
||||
v-for="node in pathNode.connectedNodes"
|
||||
:key="node.id"
|
||||
dense
|
||||
size="sm"
|
||||
:label="node.name"
|
||||
/>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item dense v-if="editable">
|
||||
<q-btn size="sm" color="accent" label="Edit Connected Nodes" @click="showDialog"/>
|
||||
<q-dialog v-model="dialog.visible" style="min-width: 400px" @hide="reset">
|
||||
<q-card>
|
||||
<q-form @submit="onSubmit" @reset="reset">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Edit Connected Nodes</div>
|
||||
<p>
|
||||
Update the nodes that this path node is connected to. These
|
||||
connections define how the system understands your rail network,
|
||||
and are used for routing and analytics.
|
||||
</p>
|
||||
<p v-if="pathNode.type ==='SEGMENT_BOUNDARY'">
|
||||
You're editing a <strong>segment boundary</strong> node. This
|
||||
means that you can only have at most <strong>2</strong> nodes
|
||||
connected to it.
|
||||
</p>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-select
|
||||
v-model="dialog.selectedNodes"
|
||||
:options="getEligibleNodes()"
|
||||
multiple
|
||||
:option-value="node => node"
|
||||
:option-label="node => node.name"
|
||||
use-chips
|
||||
stack-label
|
||||
label="Nodes"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useRailSystemsStore} from "stores/railSystemsStore";
|
||||
import {useQuasar} from "quasar";
|
||||
import {updateComponent} from "src/api/components";
|
||||
|
||||
export default {
|
||||
name: "PathNodeItem",
|
||||
props: {
|
||||
pathNode: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const rsStore = useRailSystemsStore();
|
||||
const quasar = useQuasar();
|
||||
return {rsStore, quasar};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialog: {
|
||||
visible: false,
|
||||
selectedNodes: []
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showDialog() {
|
||||
this.dialog.selectedNodes = this.pathNode.connectedNodes.slice();
|
||||
this.dialog.visible = true;
|
||||
},
|
||||
reset() {
|
||||
this.dialog.selectedNodes.length = 0;
|
||||
},
|
||||
onSubmit() {
|
||||
const data = {...this.pathNode};
|
||||
data.connectedNodes = this.dialog.selectedNodes;
|
||||
updateComponent(this.rsStore.selectedRailSystem, data)
|
||||
.then(() => {
|
||||
this.dialog.visible = false;
|
||||
this.quasar.notify({
|
||||
color: "positive",
|
||||
message: "Path node updated."
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
this.quasar.notify({
|
||||
color: "negative",
|
||||
message: "An error occurred: " + error.response.data.message
|
||||
});
|
||||
});
|
||||
},
|
||||
getEligibleNodes() {
|
||||
return this.rsStore.selectedRailSystem.components.filter(c => {
|
||||
return (c.connectedNodes !== null && c.connectedNodes !== undefined) &&
|
||||
this.dialog.selectedNodes.findIndex(node => node.id === c.id) === -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<base-component-view :component="segmentBoundary">
|
||||
<q-item-label header>Connected Segments</q-item-label>
|
||||
<q-item>
|
||||
<q-item-section
|
||||
v-for="segment in segmentBoundary.segments"
|
||||
:key="segment.id"
|
||||
>
|
||||
<q-item-label>{{segment.name}}</q-item-label>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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},
|
||||
props: {
|
||||
segmentBoundary: {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<base-component-view :component="signal">
|
||||
<q-item-label header>Connected to Segment</q-item-label>
|
||||
<segment-list-item :segment="signal.segment"/>
|
||||
</base-component-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
|
||||
import SegmentListItem from "components/rs/SegmentListItem.vue";
|
||||
export default {
|
||||
name: "SignalComponentView",
|
||||
components: {SegmentListItem, BaseComponentView},
|
||||
props: {
|
||||
signal: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -0,0 +1,210 @@
|
|||
<template>
|
||||
<base-component-view :component="sw">
|
||||
<q-item-label header>Switch Configurations</q-item-label>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="config in sw.possibleConfigurations"
|
||||
:key="config.id"
|
||||
dense
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label class="q-gutter-sm">
|
||||
<q-chip
|
||||
v-for="node in config.nodes"
|
||||
:key="node.id"
|
||||
dense
|
||||
size="sm"
|
||||
:label="node.name"
|
||||
/>
|
||||
</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>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item>
|
||||
<q-btn size="sm" color="accent" label="Edit Configurations" @click="showDialog"/>
|
||||
<q-dialog v-model="dialog.visible" style="min-width: 400px" @hide="reset">
|
||||
<q-card>
|
||||
<q-form @submit="onSubmit" @reset="reset">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Edit Switch Configurations</div>
|
||||
<p>
|
||||
Edit the possible configurations for this switch.
|
||||
</p>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="config in dialog.configurations"
|
||||
: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-section side>
|
||||
<q-btn size="12px" flat dense round icon="delete" @click="removeConfig(config)"/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<q-select
|
||||
v-model="dialog.pathNode1"
|
||||
:options="getEligibleSwitchNodes(dialog.pathNode2)"
|
||||
:option-value="node => node"
|
||||
:option-label="node => node.name"
|
||||
label="First Path Node"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<q-select
|
||||
v-model="dialog.pathNode2"
|
||||
:options="getEligibleSwitchNodes(dialog.pathNode1)"
|
||||
:option-value="node => node"
|
||||
:option-label="node => node.name"
|
||||
label="Second Path Node"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<q-btn label="Add Configuration" @click="addConfiguration" v-if="canAddConfig(dialog.pathNode1, dialog.pathNode2)"/>
|
||||
</div>
|
||||
</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-list>
|
||||
<q-separator/>
|
||||
<path-node-item :path-node="sw"/>
|
||||
</base-component-view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseComponentView from "components/rs/component_views/BaseComponentView.vue";
|
||||
import {updateComponent, updateSwitchConfiguration} from "src/api/components";
|
||||
import PathNodeItem from "components/rs/component_views/PathNodeItem.vue";
|
||||
import {useRailSystemsStore} from "stores/railSystemsStore";
|
||||
import {useQuasar} from "quasar";
|
||||
export default {
|
||||
name: "SwitchComponentView",
|
||||
components: {PathNodeItem, BaseComponentView},
|
||||
props: {
|
||||
sw: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const rsStore = useRailSystemsStore();
|
||||
const quasar = useQuasar();
|
||||
return {rsStore, quasar};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dialog: {
|
||||
visible: false,
|
||||
configurations: [],
|
||||
pathNode1: null,
|
||||
pathNode2: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setActiveSwitchConfig(configId) {
|
||||
updateSwitchConfiguration(this.rsStore.selectedRailSystem, this.sw, configId)
|
||||
.then(() => {
|
||||
this.quasar.notify({
|
||||
color: "positive",
|
||||
message: "Sent switch configuration update request."
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
this.quasar.notify({
|
||||
color: "negative",
|
||||
message: "An error occurred: " + error.response.data.message
|
||||
});
|
||||
});
|
||||
},
|
||||
isConfigActive(config) {
|
||||
return this.sw.activeConfiguration !== null && this.sw.activeConfiguration.id === config.id;
|
||||
},
|
||||
showDialog() {
|
||||
this.dialog.configurations = this.sw.possibleConfigurations.slice();
|
||||
this.dialog.configurations.forEach(cfg => {
|
||||
cfg.key = cfg.nodes[0].id + '_' + cfg.nodes[1].id
|
||||
});
|
||||
this.dialog.visible = true;
|
||||
},
|
||||
reset() {
|
||||
this.dialog.pathNode1 = null;
|
||||
this.dialog.pathNode2 = null;
|
||||
this.dialog.configurations.length = 0;
|
||||
},
|
||||
onSubmit() {
|
||||
const data = {...this.sw};
|
||||
data.possibleConfigurations = this.dialog.configurations;
|
||||
data.activeConfiguration = null;
|
||||
updateComponent(this.rsStore.selectedRailSystem, data)
|
||||
.then(() => {
|
||||
this.dialog.visible = false;
|
||||
this.quasar.notify({
|
||||
color: "positive",
|
||||
message: "Switch configurations updated."
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.log(error);
|
||||
this.quasar.notify({
|
||||
color: "negative",
|
||||
message: "An error occurred: " + error.response.data.message
|
||||
});
|
||||
});
|
||||
},
|
||||
getEligibleSwitchNodes(exclude) {
|
||||
return this.rsStore.selectedRailSystem.components.filter(c => {
|
||||
return (c.connectedNodes !== undefined && c.connectedNodes !== null) &&
|
||||
(exclude === null || c.id !== exclude.id);
|
||||
});
|
||||
},
|
||||
canAddConfig(n1, n2) {
|
||||
return this.dialog.configurations.length < 2 &&
|
||||
n1 !== null && n2 !== null &&
|
||||
n1.id !== n2.id &&
|
||||
!this.dialog.configurations.some(config => {
|
||||
return config.nodes.every(node => {
|
||||
return node.id === n1.id || node.id === n2.id;
|
||||
});
|
||||
});
|
||||
},
|
||||
addConfiguration() {
|
||||
this.dialog.configurations.push({
|
||||
nodes: [this.dialog.pathNode1, this.dialog.pathNode2],
|
||||
key: this.dialog.pathNode1.id + '_' + this.dialog.pathNode2.id
|
||||
});
|
||||
},
|
||||
removeConfig(config) {
|
||||
const idx = this.dialog.configurations.findIndex(cfg => cfg.key === config.key);
|
||||
if (idx === -1) return;
|
||||
this.dialog.configurations.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -26,22 +26,12 @@
|
|||
bordered
|
||||
>
|
||||
<rail-systems-list :rail-systems="rsStore.railSystems" />
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="'/'"
|
||||
@click="rsStore.selectRailSystem(null)"
|
||||
>
|
||||
<q-item clickable v-ripple :to="'/'">
|
||||
<q-item-section>
|
||||
<q-item-label>Home</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
clickable
|
||||
v-ripple
|
||||
:to="'/about'"
|
||||
@click="rsStore.selectRailSystem(null)"
|
||||
>
|
||||
<q-item clickable v-ripple :to="'/about'">
|
||||
<q-item-section>
|
||||
<q-item-label>About</q-item-label>
|
||||
</q-item-section>
|
||||
|
@ -68,7 +58,6 @@ export default defineComponent({
|
|||
setup () {
|
||||
const rsStore = useRailSystemsStore()
|
||||
const leftDrawerOpen = ref(false)
|
||||
|
||||
return {
|
||||
rsStore,
|
||||
leftDrawerOpen,
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
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;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x+r, y);
|
||||
ctx.arcTo(x+w, y, x+w, y+h, r);
|
||||
ctx.arcTo(x+w, y+h, x, y+h, r);
|
||||
ctx.arcTo(x, y+h, x, y, r);
|
||||
ctx.arcTo(x, y, x+w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export function circle(ctx, x, y, r) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function sortPoints(points) {
|
||||
points = points.splice(0);
|
||||
const p0 = {};
|
||||
p0.y = Math.min.apply(null, points.map(p=>p.y));
|
||||
p0.x = Math.max.apply(null, points.filter(p=>p.y === p0.y).map(p=>p.x));
|
||||
points.sort((a,b)=>angleCompare(p0, a, b));
|
||||
return points;
|
||||
}
|
||||
|
||||
function angleCompare(p0, a, b) {
|
||||
const left = isLeft(p0, a, b);
|
||||
if (left === 0) return distCompare(p0, a, b);
|
||||
return left;
|
||||
}
|
||||
|
||||
function isLeft(p0, a, b) {
|
||||
return (a.x-p0.x)*(b.y-p0.y) - (b.x-p0.x)*(a.y-p0.y);
|
||||
}
|
||||
|
||||
function distCompare(p0, a, b) {
|
||||
const distA = (p0.x-a.x)*(p0.x-a.x) + (p0.y-a.y)*(p0.y-a.y);
|
||||
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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
export function roundedRect(ctx, x, y, w, h, r) {
|
||||
if (w < 2 * r) r = w / 2;
|
||||
if (h < 2 * r) r = h / 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x+r, y);
|
||||
ctx.arcTo(x+w, y, x+w, y+h, r);
|
||||
ctx.arcTo(x+w, y+h, x, y+h, r);
|
||||
ctx.arcTo(x, y+h, x, y, r);
|
||||
ctx.arcTo(x, y, x+w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
export function circle(ctx, x, y, r) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,244 +0,0 @@
|
|||
/*
|
||||
Helper functions to actually perform rendering of different components.
|
||||
*/
|
||||
|
||||
import { getScaleFactor, getWorldTransform, isComponentHovered, isComponentSelected } from "./mapRenderer";
|
||||
import { circle, roundedRect } 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();
|
||||
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);
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -1,209 +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 elements: ${hoveredElements.length}`
|
||||
]
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i], 10, 20 + (i * 15));
|
||||
}
|
||||
}
|
||||
|
||||
export function getScaleFactor() {
|
||||
return SCALE_VALUES[mapScaleIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
|
@ -1,10 +1,4 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { refreshSegments } from "../api/segments";
|
||||
import { refreshComponents } from "../api/components";
|
||||
import { closeWebsocketConnection, establishWebsocketConnection } from "../api/websocket";
|
||||
import { refreshRailSystems } from "src/api/railSystems";
|
||||
import { refreshLinkTokens } from "src/api/linkTokens";
|
||||
import { refreshSettings } from "src/api/settings";
|
||||
import {defineStore} from "pinia";
|
||||
|
||||
export const useRailSystemsStore = defineStore('RailSystemsStore', {
|
||||
state: () => ({
|
||||
|
@ -16,45 +10,5 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', {
|
|||
* @type {RailSystem | null}
|
||||
*/
|
||||
selectedRailSystem: null
|
||||
}),
|
||||
actions: {
|
||||
/**
|
||||
* Updates the selected rail system.
|
||||
* @param rsId {Number | null} The new rail system id.
|
||||
* @returns {Promise} A promise that resolves when the new rail system is
|
||||
* fully loaded and ready.
|
||||
*/
|
||||
selectRailSystem(rsId) {
|
||||
// Close any existing websocket connections prior to refreshing.
|
||||
const wsClosePromises = [];
|
||||
if (this.selectedRailSystem !== null) {
|
||||
wsClosePromises.push(closeWebsocketConnection(this.selectedRailSystem));
|
||||
}
|
||||
if (rsId === null) return Promise.all(wsClosePromises);
|
||||
return new Promise(resolve => {
|
||||
Promise.all(wsClosePromises).then(() => {
|
||||
refreshRailSystems(this).then(() => {
|
||||
const rs = this.railSystems.find(r => r.id === rsId);
|
||||
console.log(rs);
|
||||
const updatePromises = [];
|
||||
updatePromises.push(refreshSegments(rs));
|
||||
updatePromises.push(refreshComponents(rs));
|
||||
updatePromises.push(refreshLinkTokens(rs));
|
||||
updatePromises.push(refreshSettings(rs));
|
||||
updatePromises.push(establishWebsocketConnection(rs));
|
||||
Promise.all(updatePromises).then(() => {
|
||||
this.selectedRailSystem = rs;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
rsId() {
|
||||
if (this.selectedRailSystem === null) return null;
|
||||
return this.selectedRailSystem.id;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
|
|
@ -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("*");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
package nl.andrewl.railsignalapi.rest.dto.component.in;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* A switch has a set of possible configurations, each of which links two
|
||||
* different nodes. Most conventional switches have two configurations, but for
|
||||
* universal compatibility, as many as 10 may be allowed.
|
||||
*/
|
||||
public class SwitchPayload extends ComponentPayload {
|
||||
@NotEmpty @Size(min = 2, max = 10)
|
||||
@NotNull @Size(max = 10)
|
||||
public SwitchConfigurationPayload[] possibleConfigurations;
|
||||
|
||||
public static class SwitchConfigurationPayload {
|
||||
@NotEmpty @Size(min = 2, max = 10)
|
||||
@NotEmpty @Size(min = 2, max = 2)
|
||||
public NodePayload[] nodes;
|
||||
|
||||
public static class NodePayload {
|
||||
|
|
|
@ -87,9 +87,6 @@ public class ComponentCreationService {
|
|||
}
|
||||
s.getPossibleConfigurations().add(new SwitchConfiguration(s, pathNodes));
|
||||
}
|
||||
if (s.getPossibleConfigurations().size() < 2) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least two switch configurations are needed.");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
|
|
|
@ -173,9 +173,11 @@ public class ComponentService {
|
|||
}
|
||||
|
||||
private void updateConnectedNodes(PathNode owner, Set<PathNode> newNodes) {
|
||||
// The set of all path nodes that will be disconnected from the owner.
|
||||
Set<PathNode> disconnected = new HashSet<>(owner.getConnectedNodes());
|
||||
disconnected.removeAll(newNodes);
|
||||
|
||||
// The set of all path nodes that will be connected to the owner.
|
||||
Set<PathNode> connected = new HashSet<>(newNodes);
|
||||
connected.removeAll(owner.getConnectedNodes());
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import nl.andrewl.railsignalapi.dao.ComponentRepository;
|
|||
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
|
||||
import nl.andrewl.railsignalapi.dao.SegmentRepository;
|
||||
import nl.andrewl.railsignalapi.live.ComponentDownlinkService;
|
||||
import nl.andrewl.railsignalapi.live.dto.ErrorMessage;
|
||||
import nl.andrewl.railsignalapi.live.dto.SegmentBoundaryUpdateMessage;
|
||||
import nl.andrewl.railsignalapi.live.dto.SegmentStatusMessage;
|
||||
import nl.andrewl.railsignalapi.live.websocket.AppUpdateService;
|
||||
|
@ -75,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.
|
||||
|
@ -83,8 +94,21 @@ public class SegmentService {
|
|||
public void onBoundaryUpdate(SegmentBoundaryUpdateMessage msg) {
|
||||
var segmentBoundary = segmentBoundaryRepository.findById(msg.cId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
switch (msg.eventType) {
|
||||
segmentRepository.findByIdAndRailSystemId(msg.toSegmentId, segmentBoundary.getRailSystem().getId())
|
||||
.ifPresentOrElse(
|
||||
segment -> handleSegmentBoundaryMessage(msg.eventType, segmentBoundary, segment),
|
||||
() -> downlinkService.sendMessage(new ErrorMessage(msg.cId, "Invalid toSegmentId."))
|
||||
);
|
||||
}
|
||||
|
||||
private void handleSegmentBoundaryMessage(
|
||||
SegmentBoundaryUpdateMessage.Type type,
|
||||
SegmentBoundaryNode segmentBoundary,
|
||||
Segment toSegment
|
||||
) {
|
||||
switch (type) {
|
||||
case ENTERING -> {
|
||||
log.info("Train entering segment {} in rail system {}.", toSegment.getName(), segmentBoundary.getRailSystem().getName());
|
||||
for (var segment : segmentBoundary.getSegments()) {
|
||||
if (!segment.isOccupied()) {
|
||||
segment.setOccupied(true);
|
||||
|
@ -94,20 +118,17 @@ public class SegmentService {
|
|||
}
|
||||
}
|
||||
case ENTERED -> {
|
||||
log.info("Train has entered segment {} in rail system {}.", toSegment.getName(), segmentBoundary.getRailSystem().getName());
|
||||
List<Segment> otherSegments = new ArrayList<>(segmentBoundary.getSegments());
|
||||
// Set the "to" segment as occupied.
|
||||
segmentRepository.findById(msg.toSegmentId).ifPresent(segment -> {
|
||||
segment.setOccupied(true);
|
||||
segmentRepository.save(segment);
|
||||
sendSegmentOccupiedStatus(segment);
|
||||
otherSegments.remove(segment);
|
||||
});
|
||||
toSegment.setOccupied(true);
|
||||
segmentRepository.save(toSegment);
|
||||
otherSegments.remove(toSegment);
|
||||
// And all others as no longer occupied.
|
||||
for (var segment : otherSegments) {
|
||||
if (segment.isOccupied()) {
|
||||
segment.setOccupied(false);
|
||||
segmentRepository.save(segment);
|
||||
}
|
||||
log.info("Train has left segment {} in rail system {}.", segment.getName(), segmentBoundary.getRailSystem().getName());
|
||||
segment.setOccupied(false);
|
||||
segmentRepository.save(segment);
|
||||
sendSegmentOccupiedStatus(segment);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue