Ursprung
d91b30715d
Commit
0c5f71fc85
@ -207,15 +207,23 @@ endif()
|
||||
|
||||
include(cmake/add-resource.cmake)
|
||||
|
||||
add_resource(resource-www
|
||||
FILES
|
||||
www/css/normalize.css
|
||||
www/css/throttle.css
|
||||
www/js/throttle.js
|
||||
www/throttle.html
|
||||
)
|
||||
|
||||
add_resource(resource-shared
|
||||
BASE_DIR ../
|
||||
FILES
|
||||
shared/gfx/appicon.ico
|
||||
)
|
||||
|
||||
add_dependencies(traintastic-server resource-shared)
|
||||
add_dependencies(traintastic-server resource-www resource-shared)
|
||||
if(BUILD_TESTING)
|
||||
add_dependencies(traintastic-server-test resource-shared)
|
||||
add_dependencies(traintastic-server-test resource-www resource-shared)
|
||||
endif()
|
||||
|
||||
### OPTIONS ###
|
||||
|
||||
@ -37,7 +37,7 @@ guard = '_'.join(namespaces).upper() + '_' + re.sub(r'[\.]+','_', os.path.basena
|
||||
|
||||
is_binary = input_file_ext not in ['html', 'css', 'js']
|
||||
|
||||
with open(input_file, 'rb') as f:
|
||||
with open(input_file, 'rb' if is_binary else 'r') as f:
|
||||
contents = f.read()
|
||||
|
||||
os.makedirs(os.path.dirname(sys.argv[3]), exist_ok=True)
|
||||
@ -64,6 +64,7 @@ constexpr std::array<std::byte, {size}> {variable}{{{{
|
||||
}}}};
|
||||
|
||||
}}
|
||||
|
||||
#endif
|
||||
''')
|
||||
|
||||
@ -79,8 +80,9 @@ else: # text
|
||||
namespace {'::'.join(namespaces)}
|
||||
{{
|
||||
|
||||
constexpr std::string_view {variable} = R"({contents})";
|
||||
constexpr std::string_view {variable} = R"~#!({contents})~#!";
|
||||
|
||||
}}
|
||||
|
||||
#endif
|
||||
''')
|
||||
|
||||
@ -32,6 +32,18 @@
|
||||
#include "../log/log.hpp"
|
||||
#include "../log/logmessageexception.hpp"
|
||||
#include "../utils/setthreadname.hpp"
|
||||
|
||||
//#define SERVE_FROM_FS // Development option, NOT for production!
|
||||
#ifdef SERVE_FROM_FS
|
||||
#include "../utils/readfile.hpp"
|
||||
|
||||
static const auto www = std::filesystem::absolute(std::filesystem::path(__FILE__).parent_path() / ".." / ".." / "www");
|
||||
#else
|
||||
#include <resource/www/throttle.html.hpp>
|
||||
#include <resource/www/css/throttle.css.hpp>
|
||||
#include <resource/www/js/throttle.js.hpp>
|
||||
#endif
|
||||
#include <resource/www/css/normalize.css.hpp>
|
||||
#include <resource/shared/gfx/appicon.ico.hpp>
|
||||
|
||||
#define IS_SERVER_THREAD (std::this_thread::get_id() == m_thread.get_id())
|
||||
@ -45,6 +57,8 @@ namespace
|
||||
static constexpr std::string_view serverHeader{"Traintastic-server/" TRAINTASTIC_VERSION_FULL};
|
||||
static constexpr std::string_view contentTypeTextPlain{"text/plain"};
|
||||
static constexpr std::string_view contentTypeTextHtml{"text/html"};
|
||||
static constexpr std::string_view contentTypeTextCss{"text/css"};
|
||||
static constexpr std::string_view contentTypeTextJavaScript{"text/javascript"};
|
||||
static constexpr std::string_view contentTypeImageXIcon{"image/x-icon"};
|
||||
|
||||
http::message_generator notFound(const http::request<http::string_body>& request)
|
||||
@ -131,6 +145,16 @@ http::message_generator textHtml(const http::request<http::string_body>& request
|
||||
return text(request, contentTypeTextHtml, body);
|
||||
}
|
||||
|
||||
http::message_generator textCss(const http::request<http::string_body>& request, std::string_view body)
|
||||
{
|
||||
return text(request, contentTypeTextCss, body);
|
||||
}
|
||||
|
||||
http::message_generator textJavaScript(const http::request<http::string_body>& request, std::string_view body)
|
||||
{
|
||||
return text(request, contentTypeTextJavaScript, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Server::Server(bool localhostOnly, uint16_t port, bool discoverable)
|
||||
@ -330,6 +354,9 @@ http::message_generator Server::handleHTTPRequest(http::request<http::string_bod
|
||||
"</head>"
|
||||
"<body>"
|
||||
"<h1>Traintastic <small>v" TRAINTASTIC_VERSION_FULL "</small></h1>"
|
||||
"<ul>"
|
||||
"<li><a href=\"/throttle\">Web throttle</a></li>"
|
||||
"</ul>"
|
||||
"</body>"
|
||||
"</html>");
|
||||
}
|
||||
@ -337,6 +364,37 @@ http::message_generator Server::handleHTTPRequest(http::request<http::string_bod
|
||||
{
|
||||
return binary(request, contentTypeImageXIcon, Resource::shared::gfx::appicon_ico);
|
||||
}
|
||||
if(request.target() == "/css/normalize.css")
|
||||
{
|
||||
return textCss(request, Resource::www::css::normalize_css);
|
||||
}
|
||||
if(request.target() == "/css/throttle.css")
|
||||
{
|
||||
#ifdef SERVE_FROM_FS
|
||||
const auto css = readFile(www / "css" / "throttle.css");
|
||||
return css ? textCss(request, *css) : notFound(request);
|
||||
#else
|
||||
return textCss(request, Resource::www::css::throttle_css);
|
||||
#endif
|
||||
}
|
||||
if(request.target() == "/js/throttle.js")
|
||||
{
|
||||
#ifdef SERVE_FROM_FS
|
||||
const auto js = readFile(www / "js" / "throttle.js");
|
||||
return js ? textJavaScript(request, *js) : notFound(request);
|
||||
#else
|
||||
return textJavaScript(request, Resource::www::js::throttle_js);
|
||||
#endif
|
||||
}
|
||||
if(request.target() == "/throttle")
|
||||
{
|
||||
#ifdef SERVE_FROM_FS
|
||||
const auto html = readFile(www / "throttle.html");
|
||||
return html ? textHtml(request, *html) : notFound(request);
|
||||
#else
|
||||
return textHtml(request, Resource::www::throttle_html);
|
||||
#endif
|
||||
}
|
||||
if(target == "/version")
|
||||
{
|
||||
return textPlain(request, TRAINTASTIC_VERSION_FULL);
|
||||
|
||||
@ -22,8 +22,15 @@
|
||||
|
||||
#include "webthrottleconnection.hpp"
|
||||
#include "server.hpp"
|
||||
#include "../traintastic/traintastic.hpp"
|
||||
#include "../core/eventloop.hpp"
|
||||
#include "../core/objectproperty.tpp"
|
||||
#include "../hardware/throttle/webthrottle.hpp"
|
||||
#include "../log/log.hpp"
|
||||
#include "../train/train.hpp"
|
||||
#include "../train/trainerror.hpp"
|
||||
#include "../train/trainlist.hpp"
|
||||
|
||||
WebThrottleConnection::WebThrottleConnection(Server& server, std::shared_ptr<boost::beast::websocket::stream<boost::beast::tcp_stream>> ws)
|
||||
: WebSocketConnection(server, std::move(ws), "webthrottle")
|
||||
{
|
||||
@ -35,6 +42,12 @@ WebThrottleConnection::WebThrottleConnection(Server& server, std::shared_ptr<boo
|
||||
WebThrottleConnection::~WebThrottleConnection()
|
||||
{
|
||||
assert(isEventLoopThread());
|
||||
|
||||
for(auto& it : m_throttles)
|
||||
{
|
||||
it.second->destroy();
|
||||
it.second.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void WebThrottleConnection::doRead()
|
||||
@ -102,7 +115,138 @@ void WebThrottleConnection::doWrite()
|
||||
void WebThrottleConnection::processMessage(const nlohmann::json& message)
|
||||
{
|
||||
assert(isEventLoopThread());
|
||||
(void)message;
|
||||
|
||||
const auto& world = Traintastic::instance->world.value();
|
||||
const auto action = message.value("action", "");
|
||||
const auto throttleId = message.value<uint32_t>("throttle_id", 0);
|
||||
|
||||
if(throttleId == 0)
|
||||
{
|
||||
if(action == "get_train_list")
|
||||
{
|
||||
auto response = nlohmann::json::object();
|
||||
response.emplace("event", "train_list");
|
||||
auto list = nlohmann::json::array();
|
||||
if(world)
|
||||
{
|
||||
for(const auto& train : *world->trains)
|
||||
{
|
||||
auto item = nlohmann::json::object();
|
||||
item.emplace("id", train->id.value());
|
||||
item.emplace("name", train->name.value());
|
||||
list.emplace_back(item);
|
||||
}
|
||||
}
|
||||
response.emplace("list", list);
|
||||
sendMessage(response);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const auto& throttle = getThrottle(throttleId);
|
||||
|
||||
if(action == "acquire")
|
||||
{
|
||||
auto train = std::dynamic_pointer_cast<Train>(world->getObjectById(message.value("train_id", "")));
|
||||
if(train)
|
||||
{
|
||||
nlohmann::json object;
|
||||
|
||||
const auto ec = throttle->acquire(train, message.value("steal", false));
|
||||
if(!ec)
|
||||
{
|
||||
m_trainPropertyChanged.emplace(throttleId, train->propertyChanged.connect(
|
||||
[this, throttleId](BaseProperty& property)
|
||||
{
|
||||
const auto name = property.name();
|
||||
if(name == "direction" || name == "speed" || name == "throttle_speed")
|
||||
{
|
||||
auto event = nlohmann::json::object();
|
||||
event.emplace("event", name);
|
||||
event.emplace("throttle_id", throttleId);
|
||||
if(dynamic_cast<AbstractUnitProperty*>(&property))
|
||||
{
|
||||
event.update(property.toJSON());
|
||||
}
|
||||
else
|
||||
{
|
||||
event.emplace("value", property.toJSON());
|
||||
}
|
||||
sendMessage(event);
|
||||
}
|
||||
}));
|
||||
|
||||
object = nlohmann::json::object();
|
||||
object.emplace("id", train->id.toJSON());
|
||||
object.emplace("name", train->name.toJSON());
|
||||
object.emplace("direction", train->direction.toJSON());
|
||||
object.emplace("speed", train->speed.toJSON());
|
||||
object.emplace("throttle_speed", train->throttleSpeed.toJSON());
|
||||
}
|
||||
else // error
|
||||
{
|
||||
auto error = nlohmann::json::object();
|
||||
error.emplace("event", "message");
|
||||
error.emplace("throttle_id", throttleId);
|
||||
error.emplace("type", "error");
|
||||
if(ec == TrainError::AlreadyAcquired)
|
||||
{
|
||||
error.emplace("tag", "already_acquired");
|
||||
}
|
||||
error.emplace("text", ec.message());
|
||||
sendMessage(error);
|
||||
}
|
||||
auto response = nlohmann::json::object();
|
||||
response.emplace("event", "train");
|
||||
response.emplace("throttle_id", throttleId);
|
||||
response.emplace("train", object);
|
||||
sendMessage(response);
|
||||
}
|
||||
}
|
||||
else if(action == "set_name")
|
||||
{
|
||||
throttle->name = message.value("value", "");
|
||||
}
|
||||
else if(throttle->acquired())
|
||||
{
|
||||
if(action == "estop")
|
||||
{
|
||||
throttle->emergencyStop();
|
||||
}
|
||||
else if(action == "stop")
|
||||
{
|
||||
throttle->stop();
|
||||
}
|
||||
else if(action == "faster")
|
||||
{
|
||||
throttle->faster();
|
||||
}
|
||||
else if(action == "slower")
|
||||
{
|
||||
throttle->slower();
|
||||
}
|
||||
else if(action == "reverse")
|
||||
{
|
||||
throttle->setDirection(Direction::Reverse);
|
||||
}
|
||||
else if(action == "forward")
|
||||
{
|
||||
throttle->setDirection(Direction::Forward);
|
||||
}
|
||||
else if(action == "release")
|
||||
{
|
||||
throttle->release(message.value("stop", true));
|
||||
|
||||
m_trainPropertyChanged.erase(throttleId);
|
||||
|
||||
auto response = nlohmann::json::object();
|
||||
response.emplace("event", "train");
|
||||
response.emplace("throttle_id", throttleId);
|
||||
response.emplace("train", nullptr);
|
||||
sendMessage(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WebThrottleConnection::sendMessage(const nlohmann::json& message)
|
||||
@ -120,3 +264,35 @@ void WebThrottleConnection::sendMessage(const nlohmann::json& message)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const std::shared_ptr<WebThrottle>& WebThrottleConnection::getThrottle(uint32_t throttleId)
|
||||
{
|
||||
assert(isEventLoopThread());
|
||||
|
||||
static const std::shared_ptr<WebThrottle> noThrottle;
|
||||
|
||||
if(auto it = m_throttles.find(throttleId); it != m_throttles.end())
|
||||
{
|
||||
return it->second;
|
||||
}
|
||||
|
||||
if(const auto& world = Traintastic::instance->world.value())
|
||||
{
|
||||
auto [it, inserted] = m_throttles.emplace(throttleId, WebThrottle::create(*world));
|
||||
if(inserted) /*[[likely]]*/
|
||||
{
|
||||
m_throttleReleased.emplace(throttleId, it->second->released.connect(
|
||||
[this, throttleId]()
|
||||
{
|
||||
auto response = nlohmann::json::object();
|
||||
response.emplace("event", "train");
|
||||
response.emplace("throttle_id", throttleId);
|
||||
response.emplace("train", nullptr);
|
||||
sendMessage(response);
|
||||
}));
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
|
||||
return noThrottle;
|
||||
}
|
||||
|
||||
@ -37,6 +37,9 @@ class WebThrottleConnection : public WebSocketConnection
|
||||
protected:
|
||||
boost::beast::flat_buffer m_readBuffer;
|
||||
std::queue<std::string> m_writeQueue;
|
||||
std::map<uint32_t, std::shared_ptr<WebThrottle>> m_throttles;
|
||||
std::map<uint32_t, boost::signals2::scoped_connection> m_throttleReleased;
|
||||
std::map<uint32_t, boost::signals2::scoped_connection> m_trainPropertyChanged;
|
||||
|
||||
void doRead() final;
|
||||
void doWrite() final;
|
||||
@ -44,6 +47,8 @@ protected:
|
||||
void processMessage(const nlohmann::json& message);
|
||||
void sendMessage(const nlohmann::json& message);
|
||||
|
||||
const std::shared_ptr<WebThrottle>& getThrottle(uint32_t throttleId);
|
||||
|
||||
public:
|
||||
const std::string id;
|
||||
|
||||
|
||||
39
server/src/utils/readfile.cpp
Normale Datei
39
server/src/utils/readfile.cpp
Normale Datei
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* server/src/utils/readfile.cpp
|
||||
*
|
||||
* This file is part of the traintastic source code.
|
||||
*
|
||||
* Copyright (C) 2025 Reinder Feenstra
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
#include "readfile.hpp"
|
||||
#include <fstream>
|
||||
|
||||
std::optional<std::string> readFile(const std::filesystem::path& filename)
|
||||
{
|
||||
std::ifstream file(filename, std::ios::in | std::ios::binary | std::ios::ate);
|
||||
if(!file.is_open())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
const size_t size = file.tellg();
|
||||
std::string contents;
|
||||
contents.resize(size);
|
||||
file.seekg(std::ios::beg);
|
||||
file.read(contents.data(), size);
|
||||
return contents;
|
||||
}
|
||||
32
server/src/utils/readfile.hpp
Normale Datei
32
server/src/utils/readfile.hpp
Normale Datei
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* server/src/utils/readfile.hpp
|
||||
*
|
||||
* This file is part of the traintastic source code.
|
||||
*
|
||||
* Copyright (C) 2025 Reinder Feenstra
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
#ifndef TRAINTASTIC_SERVER_UTILS_READFILE_HPP
|
||||
#define TRAINTASTIC_SERVER_UTILS_READFILE_HPP
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
|
||||
std::optional<std::string> readFile(const std::filesystem::path& filename);
|
||||
|
||||
#endif
|
||||
349
server/www/css/normalize.css
vendored
Normale Datei
349
server/www/css/normalize.css
vendored
Normale Datei
@ -0,0 +1,349 @@
|
||||
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
|
||||
|
||||
/* Document
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Correct the line height in all browsers.
|
||||
* 2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
}
|
||||
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the margin in all browsers.
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `main` element consistently in IE.
|
||||
*/
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the font size and margin on `h1` elements within `section` and
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
|
||||
/* Grouping content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in Firefox.
|
||||
* 2. Show the overflow in Edge and IE.
|
||||
*/
|
||||
|
||||
hr {
|
||||
box-sizing: content-box; /* 1 */
|
||||
height: 0; /* 1 */
|
||||
overflow: visible; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
pre {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/* Text-level semantics
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the gray background on active links in IE 10.
|
||||
*/
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Remove the bottom border in Chrome 57-
|
||||
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none; /* 1 */
|
||||
text-decoration: underline; /* 2 */
|
||||
text-decoration: underline dotted; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font weight in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inheritance and scaling of font size in all browsers.
|
||||
* 2. Correct the odd `em` font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: monospace, monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent `sub` and `sup` elements from affecting the line height in
|
||||
* all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/* Embedded content
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Remove the border on images inside links in IE 10.
|
||||
*/
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
/* Forms
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* 1. Change the font styles in all browsers.
|
||||
* 2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the overflow in IE.
|
||||
* 1. Show the overflow in Edge.
|
||||
*/
|
||||
|
||||
button,
|
||||
input { /* 1 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inheritance of text transform in Edge, Firefox, and IE.
|
||||
* 1. Remove the inheritance of text transform in Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select { /* 1 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type="button"],
|
||||
[type="reset"],
|
||||
[type="submit"] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the padding in Firefox.
|
||||
*/
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the text wrapping in Edge and IE.
|
||||
* 2. Correct the color inheritance from `fieldset` elements in IE.
|
||||
* 3. Remove the padding so developers are not caught out when they zero out
|
||||
* `fieldset` elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
box-sizing: border-box; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
display: table; /* 1 */
|
||||
max-width: 100%; /* 1 */
|
||||
padding: 0; /* 3 */
|
||||
white-space: normal; /* 1 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the default vertical scrollbar in IE 10+.
|
||||
*/
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Add the correct box sizing in IE 10.
|
||||
* 2. Remove the padding in IE 10.
|
||||
*/
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box; /* 1 */
|
||||
padding: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct the cursor style of increment and decrement buttons in Chrome.
|
||||
*/
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the odd appearance in Chrome and Safari.
|
||||
* 2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Correct the inability to style clickable types in iOS and Safari.
|
||||
* 2. Change font properties to `inherit` in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/* Interactive
|
||||
========================================================================== */
|
||||
|
||||
/*
|
||||
* Add the correct display in Edge, IE 10+, and Firefox.
|
||||
*/
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/*
|
||||
* Add the correct display in all browsers.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
/* Misc
|
||||
========================================================================== */
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10+.
|
||||
*/
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the correct display in IE 10.
|
||||
*/
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
192
server/www/css/throttle.css
Normale Datei
192
server/www/css/throttle.css
Normale Datei
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* This file is part of Traintastic,
|
||||
* see <https://github.com/traintastic/traintastic>.
|
||||
*
|
||||
* Copyright (C) 2024-2025 Reinder Feenstra
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
.flex-column
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flex-row
|
||||
{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-resize
|
||||
{
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.flex-fixed
|
||||
{
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hide
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stretch
|
||||
{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-right
|
||||
{
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.right
|
||||
{
|
||||
float: right;
|
||||
}
|
||||
|
||||
.p1
|
||||
{
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.pr1
|
||||
{
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
html
|
||||
{
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
background-color: #121212;
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
header
|
||||
{
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
header h1
|
||||
{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#open_settings
|
||||
{
|
||||
font-size: 200%;
|
||||
text-align: center;
|
||||
min-width: 2.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#body
|
||||
{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#settings
|
||||
{
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
background-color: #121212;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#settings label
|
||||
{
|
||||
display: block;
|
||||
}
|
||||
|
||||
button.control,
|
||||
select.control
|
||||
{
|
||||
background-color: #bb86fc;
|
||||
border: none;
|
||||
border-radius: 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.25rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
button.control:disabled
|
||||
{
|
||||
background-color: gray !important;
|
||||
cursor: auto !important;
|
||||
}
|
||||
|
||||
button.control.active
|
||||
{
|
||||
background-color: #03dac6;
|
||||
}
|
||||
|
||||
button.red
|
||||
{
|
||||
background-color: #cf6679;
|
||||
}
|
||||
|
||||
button.square
|
||||
{
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
label
|
||||
{
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input[type=text]
|
||||
{
|
||||
background-color: rgba(255, 255, 255, .07);
|
||||
border: solid 2px #bb86fc;
|
||||
border-radius: 1.5rem;
|
||||
font-size: 150%;
|
||||
color: white;
|
||||
padding: 0 0.5rem;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
}
|
||||
|
||||
div.message
|
||||
{
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.message.error
|
||||
{
|
||||
background-color: #cf6679;
|
||||
}
|
||||
403
server/www/js/throttle.js
Normale Datei
403
server/www/js/throttle.js
Normale Datei
@ -0,0 +1,403 @@
|
||||
/**
|
||||
* This file is part of Traintastic,
|
||||
* see <https://github.com/traintastic/traintastic>.
|
||||
*
|
||||
* Copyright (C) 2024-2025 Reinder Feenstra
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*/
|
||||
|
||||
function Throttle(parent, id)
|
||||
{
|
||||
this.id = id;
|
||||
this.message = null;
|
||||
this.messageTimeout = null;
|
||||
|
||||
var createButton = function (action, innerText, className)
|
||||
{
|
||||
var e = document.createElement('button');
|
||||
e.innerText = innerText;
|
||||
e.className = className;
|
||||
e.setAttribute('throttle-id', id);
|
||||
e.setAttribute('action', action);
|
||||
e.onclick = function ()
|
||||
{
|
||||
tm.send({
|
||||
'throttle_id': parseInt(this.getAttribute('throttle-id')),
|
||||
'action': this.getAttribute('action'),
|
||||
});
|
||||
};
|
||||
return e;
|
||||
};
|
||||
|
||||
var createDiv = function (className, children)
|
||||
{
|
||||
var e = document.createElement('div');
|
||||
e.className = className;
|
||||
children.forEach(function (c, _)
|
||||
{
|
||||
e.appendChild(c);
|
||||
});
|
||||
return e;
|
||||
};
|
||||
|
||||
var createTrainSelect = function (className)
|
||||
{
|
||||
var e = document.createElement('select');
|
||||
e.className = className;
|
||||
e.setAttribute('throttle-id', id);
|
||||
e.setAttribute('name', 'train_select');
|
||||
e.onchange = function ()
|
||||
{
|
||||
if(this.value != '')
|
||||
{
|
||||
tm.send({
|
||||
'throttle_id': parseInt(this.getAttribute('throttle-id')),
|
||||
'action': 'acquire',
|
||||
'train_id': this.value,
|
||||
'steal': false,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
tm.send({
|
||||
'throttle_id': parseInt(this.getAttribute('throttle-id')),
|
||||
'action': 'release',
|
||||
'stop': localStorage.throttleStopOnRelease != 'false',
|
||||
});
|
||||
}
|
||||
};
|
||||
return e;
|
||||
}
|
||||
|
||||
var createSpan = function (id, text = '', className = '')
|
||||
{
|
||||
var e = document.createElement('span');
|
||||
e.id = id;
|
||||
e.className = className;
|
||||
e.innerText = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
var addRemoveClass = function (e, add, className)
|
||||
{
|
||||
if(add && !e.classList.contains(className))
|
||||
{
|
||||
e.classList.add(className);
|
||||
}
|
||||
else if(!add)
|
||||
{
|
||||
e.classList.remove(className);
|
||||
}
|
||||
}
|
||||
|
||||
var formatSpeed = function (value, unit)
|
||||
{
|
||||
if(unit == 'kmph')
|
||||
{
|
||||
return value.toFixed(0) + ' km/h';
|
||||
}
|
||||
if(unit == 'mph')
|
||||
{
|
||||
return value.toFixed(0) + ' mph';
|
||||
}
|
||||
if(unit == 'mps')
|
||||
{
|
||||
return value.toFixed(1) + ' m/s';
|
||||
}
|
||||
return value + ' ' + unit;
|
||||
}
|
||||
|
||||
var throttle = createDiv('p1 stretch', [
|
||||
createTrainSelect('control stretch'),
|
||||
createDiv('flex-row', [
|
||||
createSpan('', 'Speed:', 'text-right pr1'),
|
||||
createSpan('throttle-' + id + '-actual-speed', '', 'stretch'),
|
||||
createSpan('', 'Target:', 'stretch text-right pr1'),
|
||||
createSpan('throttle-' + id + '-target-speed', '', 'stretch'),
|
||||
/* not yet supported:
|
||||
createSpan('', 'Limit:', 'stretch text-right pr1'),
|
||||
createSpan('throttle-' + id + '-target-limit', '∞', 'stretch'),
|
||||
*/
|
||||
]),
|
||||
createDiv('flex-row', [
|
||||
createDiv('flex-resize', [
|
||||
// TODO: function buttons
|
||||
]),
|
||||
createDiv('flex-resize', [
|
||||
createDiv('flex-column stretch', [
|
||||
createButton('faster', '+', 'control stretch'),
|
||||
createButton('slower', '-', 'control stretch'),
|
||||
])
|
||||
])
|
||||
]),
|
||||
createDiv('flex-row', [
|
||||
createDiv('flex-resize', [createButton('reverse', '<', 'control stretch')]),
|
||||
createDiv('flex-resize', [createButton('stop', '0', 'control stretch')]),
|
||||
createDiv('flex-resize', [createButton('forward', '>', 'control stretch')]),
|
||||
]),
|
||||
createButton('estop', 'EStop', 'control stretch red'),
|
||||
]);
|
||||
parent.appendChild(throttle);
|
||||
|
||||
this.setTrainList = function (list)
|
||||
{
|
||||
var train_select = throttle.querySelector('select[name=train_select]');
|
||||
|
||||
train_select.innerHTML = '';
|
||||
train_select.appendChild(document.createElement('option'));
|
||||
list.forEach(function (train, _)
|
||||
{
|
||||
var option = document.createElement('option');
|
||||
option.value = train['id'];
|
||||
option.innerText = train['name'];
|
||||
train_select.appendChild(option);
|
||||
});
|
||||
};
|
||||
|
||||
this.showMessage = function (message)
|
||||
{
|
||||
var layout = [];
|
||||
if(message['tag'] == 'already_acquired')
|
||||
{
|
||||
var e = document.createElement('button');
|
||||
e.innerText = 'Steal';
|
||||
e.className = 'right';
|
||||
e.setAttribute('throttle-id', id);
|
||||
e.setAttribute('train-id', throttle.querySelector('select[name=train_select]').value);
|
||||
e.onclick = function ()
|
||||
{
|
||||
var throttleId = parseInt(this.getAttribute('throttle-id'));
|
||||
tm.send({
|
||||
'throttle_id': throttleId,
|
||||
'action': 'acquire',
|
||||
'train_id': this.getAttribute('train-id'),
|
||||
'steal': true,
|
||||
});
|
||||
tm.throttles[throttleId].clearMessage();
|
||||
};
|
||||
layout.push(e)
|
||||
}
|
||||
text = document.createElement('p');
|
||||
text.innerText = message['text'];
|
||||
layout.push(text);
|
||||
this.clearMessage();
|
||||
this.message = createDiv('message ' + message['type'], layout);
|
||||
throttle.appendChild(this.message);
|
||||
this.messageTimeout = window.setTimeout(function (throttle) { throttle.clearMessage(); }, 5000, this);
|
||||
}
|
||||
|
||||
this.clearMessage = function ()
|
||||
{
|
||||
if(this.message)
|
||||
{
|
||||
throttle.removeChild(this.message);
|
||||
window.clearTimeout(this.messageTimeout);
|
||||
this.message = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.setTrain = function (train)
|
||||
{
|
||||
var buttons = throttle.querySelectorAll('button.control');
|
||||
if(train)
|
||||
{
|
||||
this.setDirection(train.direction);
|
||||
this.setSpeed(train.speed.value, train.speed.unit);
|
||||
this.setThrottleSpeed(train.throttle_speed.value, train.throttle_speed.unit);
|
||||
buttons.forEach(function (button) { button.disabled = false; });
|
||||
throttle.querySelector('select[name=train_select]').value = train.id;
|
||||
}
|
||||
else
|
||||
{
|
||||
buttons.forEach(function (button) { button.disabled = true; });
|
||||
throttle.querySelector('select[name=train_select]').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
this.setDirection = function (direction)
|
||||
{
|
||||
addRemoveClass(
|
||||
throttle.querySelector('button[action=forward]'),
|
||||
direction == 'forward',
|
||||
'active');
|
||||
addRemoveClass(
|
||||
throttle.querySelector('button[action=reverse]'),
|
||||
direction == 'reverse',
|
||||
'active');
|
||||
}
|
||||
|
||||
this.setSpeed = function (value, unit)
|
||||
{
|
||||
document.getElementById('throttle-' + this.id + '-actual-speed').innerText = formatSpeed(value, unit);
|
||||
}
|
||||
|
||||
this.setThrottleSpeed = function (value, unit)
|
||||
{
|
||||
document.getElementById('throttle-' + this.id + '-target-speed').innerText = formatSpeed(value, unit);
|
||||
}
|
||||
|
||||
this.setTrain(null);
|
||||
};
|
||||
|
||||
var tm = new function ()
|
||||
{
|
||||
this.ws = null;
|
||||
this.throttles = []
|
||||
|
||||
this.init = function ()
|
||||
{
|
||||
if(localStorage.throttleName)
|
||||
{
|
||||
document.getElementById('throttle_name').value = localStorage.throttleName;
|
||||
}
|
||||
if(localStorage.throttleStopOnRelease)
|
||||
{
|
||||
document.getElementById('stop_train_on_release').value = localStorage.throttleStopOnRelease;
|
||||
}
|
||||
|
||||
document.getElementById('open_settings').onclick = function ()
|
||||
{
|
||||
var settings = document.getElementById('settings');
|
||||
if(settings.classList.contains('hide'))
|
||||
{
|
||||
settings.classList.remove('hide');
|
||||
}
|
||||
else
|
||||
{
|
||||
document.getElementById('close_settings').onclick();
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('close_settings').onclick = function ()
|
||||
{
|
||||
localStorage.throttleName = document.getElementById('throttle_name').value;
|
||||
localStorage.throttleStopOnRelease = document.getElementById('stop_train_on_release').value == 'on';
|
||||
document.getElementById('settings').classList.add('hide');
|
||||
if(tm.throttles.length == 0)
|
||||
{
|
||||
tm.add();
|
||||
}
|
||||
};
|
||||
|
||||
if(localStorage.throttleName && localStorage.throttleStopOnRelease)
|
||||
{
|
||||
document.getElementById('settings').classList.add('hide');
|
||||
this.add();
|
||||
}
|
||||
}
|
||||
|
||||
this.add = function ()
|
||||
{
|
||||
this.connect();
|
||||
var e = document.getElementById('throttles');
|
||||
var id = 1;
|
||||
while(id in this.throttles) { id++; }
|
||||
this.throttles[id] = new Throttle(e, id);
|
||||
return this.throttles[id];
|
||||
}
|
||||
|
||||
this.connect = function ()
|
||||
{
|
||||
if(!this.ws)
|
||||
{
|
||||
this.ws = new WebSocket((window.location.protocol == 'https' ? 'wss' : 'ws') + '://' + window.location.host + window.location.pathname);
|
||||
this.ws.onopen = function (ev)
|
||||
{
|
||||
tm.send({ 'action': 'get_train_list' });
|
||||
tm.throttles.forEach(function (throttle, _)
|
||||
{
|
||||
tm.send({
|
||||
'throttle_id': throttle.id,
|
||||
'action': 'set_name',
|
||||
'value': localStorage.throttleName + ' #' + throttle.id,
|
||||
});
|
||||
var trainId = localStorage['throttle' + throttle.id + 'TrainId'];
|
||||
if(trainId)
|
||||
{
|
||||
tm.send({
|
||||
'throttle_id': throttle.id,
|
||||
'action': 'acquire',
|
||||
'train_id': trainId,
|
||||
'steal': false,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
this.ws.onmessage = function (ev)
|
||||
{
|
||||
var msg = JSON.parse(ev.data);
|
||||
console.log('RX', msg);
|
||||
if(msg['event'] == 'train_list')
|
||||
{
|
||||
tm.throttles.forEach(function (throttle, _)
|
||||
{
|
||||
throttle.setTrainList(msg['list']);
|
||||
});
|
||||
}
|
||||
else if(msg['event'] == 'message')
|
||||
{
|
||||
var throttleId = msg['throttle_id'];
|
||||
if(throttleId)
|
||||
{
|
||||
tm.throttles[throttleId].showMessage(msg);
|
||||
}
|
||||
}
|
||||
else if(msg['event'] == 'train')
|
||||
{
|
||||
var throttleId = msg['throttle_id'];
|
||||
var train = msg['train'];
|
||||
var item = 'throttle' + throttleId + 'TrainId';
|
||||
if(train)
|
||||
{
|
||||
localStorage.setItem(item, train.id);
|
||||
}
|
||||
else
|
||||
{
|
||||
localStorage.removeItem(item);
|
||||
}
|
||||
tm.throttles[throttleId].setTrain(train);
|
||||
}
|
||||
else if(msg['event'] == 'direction')
|
||||
{
|
||||
tm.throttles[msg['throttle_id']].setDirection(msg['value']);
|
||||
}
|
||||
else if(msg['event'] == 'speed')
|
||||
{
|
||||
tm.throttles[msg['throttle_id']].setSpeed(msg['value'], msg['unit']);
|
||||
}
|
||||
else if(msg['event'] == 'throttle_speed')
|
||||
{
|
||||
tm.throttles[msg['throttle_id']].setThrottleSpeed(msg['value'], msg['unit']);
|
||||
}
|
||||
};
|
||||
this.ws.onclose = function ()
|
||||
{
|
||||
setTimeout(function () { tm.connect(); }, 1000);
|
||||
}
|
||||
this.ws.onerror = function (err)
|
||||
{
|
||||
console.error('WebSocket error: ', err.message);
|
||||
this.ws.close();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.send = function (msg)
|
||||
{
|
||||
console.log('TX', msg);
|
||||
return this.ws.send(JSON.stringify(msg));
|
||||
};
|
||||
}();
|
||||
36
server/www/throttle.html
Normale Datei
36
server/www/throttle.html
Normale Datei
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>Throttle | Traintastic</title>
|
||||
<link rel="stylesheet" href="/css/normalize.css">
|
||||
<link rel="stylesheet" href="/css/throttle.css">
|
||||
</head>
|
||||
|
||||
<body onload="tm.init()">
|
||||
<header>
|
||||
<div id="open_settings" class="right">⋮</div>
|
||||
<h1>Traintastic</h1>
|
||||
</header>
|
||||
<div id="body">
|
||||
<div id="settings">
|
||||
<div>
|
||||
<label for="throttle_name">Name:</label>
|
||||
<input type="text" id="throttle_name">
|
||||
</div>
|
||||
<div>
|
||||
<label for="stop_train_on_release">Stop train on release:</label>
|
||||
<input type="checkbox" id="stop_train_on_release" checked="checked">
|
||||
</div>
|
||||
<div>
|
||||
<button id="close_settings">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="throttles" class="flex-row"></div>
|
||||
</div>
|
||||
<script src="/js/throttle.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Laden…
x
In neuem Issue referenzieren
Einen Benutzer sperren