Added improved rendering and signal detail panel.

This commit is contained in:
Andrew Lalis 2021-11-24 12:01:36 +01:00
parent 6edb2e4912
commit 4546993f0f
13 changed files with 295 additions and 54 deletions

View File

@ -0,0 +1,9 @@
package nl.andrewl.railsignalapi.dao;
import nl.andrewl.railsignalapi.model.SignalBranchConnection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SignalBranchConnectionRepository extends JpaRepository<SignalBranchConnection, Long> {
}

View File

@ -18,7 +18,8 @@ public interface SignalRepository extends JpaRepository<Signal, Long> {
@Query("SELECT DISTINCT s FROM Signal s " +
"LEFT JOIN s.branchConnections bc " +
"WHERE bc.branch = :branch")
"WHERE bc.branch = :branch " +
"ORDER BY s.name")
List<Signal> findAllConnectedToBranch(Branch branch);
List<Signal> findAllByRailSystemOrderByName(RailSystem railSystem);

View File

@ -8,5 +8,41 @@ public enum Direction {
NORTH_WEST,
NORTH_EAST,
SOUTH_WEST,
SOUTH_EAST
SOUTH_EAST;
public Direction opposite() {
return switch (this) {
case NORTH -> SOUTH;
case SOUTH -> NORTH;
case EAST -> WEST;
case WEST -> EAST;
case NORTH_EAST -> SOUTH_WEST;
case NORTH_WEST -> SOUTH_EAST;
case SOUTH_EAST -> NORTH_WEST;
case SOUTH_WEST -> NORTH_EAST;
};
}
public boolean isOpposite(Direction other) {
return this.opposite().equals(other);
}
public static Direction parse(String s) {
s = s.trim().toUpperCase();
try {
return Direction.valueOf(s);
} catch (IllegalArgumentException e) {
return switch (s) {
case "N" -> NORTH;
case "S" -> SOUTH;
case "E" -> EAST;
case "W" -> WEST;
case "NW" -> NORTH_WEST;
case "NE" -> NORTH_EAST;
case "SW" -> SOUTH_WEST;
case "SE" -> SOUTH_EAST;
default -> throw new IllegalArgumentException("Invalid direction: " + s);
};
}
}
}

View File

@ -5,12 +5,13 @@ import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@Entity
@NoArgsConstructor
@Getter
public class SignalBranchConnection {
public class SignalBranchConnection implements Comparable<SignalBranchConnection> {
@Id
@GeneratedValue
private Long id;
@ -33,4 +34,24 @@ public class SignalBranchConnection {
this.direction = direction;
reachableSignalConnections = new HashSet<>();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
return this.id != null &&
o instanceof SignalBranchConnection sbc && sbc.getId() != null &&
this.id.equals(sbc.getId());
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
@Override
public int compareTo(SignalBranchConnection o) {
int c = Long.compare(this.getSignal().getId(), o.getSignal().getId());
if (c != 0) return c;
return this.direction.compareTo(o.getDirection());
}
}

View File

@ -2,6 +2,7 @@ package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.BranchResponse;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import nl.andrewl.railsignalapi.service.BranchService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -19,6 +20,16 @@ public class BranchesController {
return branchService.getAllBranches(rsId);
}
@GetMapping(path = "/{branchId}")
public BranchResponse getBranch(@PathVariable long rsId, @PathVariable long branchId) {
return branchService.getBranch(rsId, branchId);
}
@GetMapping(path = "/{branchId}/signals")
public List<SignalResponse> getBranchSignals(@PathVariable long rsId, @PathVariable long branchId) {
return branchService.getConnectedSignals(rsId, branchId);
}
@DeleteMapping(path = "/{branchId}")
public ResponseEntity<?> deleteBranch(@PathVariable long rsId, @PathVariable long branchId) {
branchService.deleteBranch(rsId, branchId);

View File

@ -1,6 +1,7 @@
package nl.andrewl.railsignalapi.rest;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.rest.dto.SignalConnectionsUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.SignalCreationPayload;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import nl.andrewl.railsignalapi.service.SignalService;
@ -30,6 +31,11 @@ public class SignalsApiController {
return signalService.getSignal(rsId, sigId);
}
@PostMapping(path = "/{sigId}/signalConnections")
public SignalResponse updateSignalConnections(@PathVariable long rsId, @PathVariable long sigId, @RequestBody SignalConnectionsUpdatePayload payload) {
return signalService.updateSignalBranchConnections(rsId, sigId, payload);
}
@DeleteMapping(path = "/{sigId}")
public ResponseEntity<?> deleteSignal(@PathVariable long rsId, @PathVariable long sigId) {
signalService.deleteSignal(rsId, sigId);

View File

@ -0,0 +1,9 @@
package nl.andrewl.railsignalapi.rest.dto;
import java.util.List;
public record SignalConnectionsUpdatePayload(
List<ConnectionData> connections
) {
public static record ConnectionData(long from, long to) {}
}

View File

@ -41,6 +41,7 @@ public record SignalResponse(
c.getReachableSignalConnections().stream()
.map(cc -> new ReachableConnectionData(
cc.getId(),
cc.getDirection().name(),
cc.getSignal().getId(),
cc.getSignal().getName(),
cc.getSignal().getPosition()
@ -51,6 +52,7 @@ public record SignalResponse(
public static record ReachableConnectionData(
long connectionId,
String direction,
long signalId,
String signalName,
Position signalPosition

View File

@ -3,7 +3,9 @@ package nl.andrewl.railsignalapi.service;
import lombok.RequiredArgsConstructor;
import nl.andrewl.railsignalapi.dao.BranchRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SignalRepository;
import nl.andrewl.railsignalapi.rest.dto.BranchResponse;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -16,6 +18,7 @@ import java.util.List;
public class BranchService {
private final BranchRepository branchRepository;
private final RailSystemRepository railSystemRepository;
private final SignalRepository signalRepository;
@Transactional
public void deleteBranch(long rsId, long branchId) {
@ -35,4 +38,20 @@ public class BranchService {
.map(BranchResponse::new)
.toList();
}
@Transactional(readOnly = true)
public BranchResponse getBranch(long rsId, long branchId) {
var branch = branchRepository.findByIdAndRailSystemId(branchId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new BranchResponse(branch);
}
@Transactional(readOnly = true)
public List<SignalResponse> getConnectedSignals(long rsId, long branchId) {
var branch = branchRepository.findByIdAndRailSystemId(branchId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return signalRepository.findAllConnectedToBranch(branch).stream()
.map(SignalResponse::new)
.toList();
}
}

View File

@ -6,8 +6,10 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import nl.andrewl.railsignalapi.dao.BranchRepository;
import nl.andrewl.railsignalapi.dao.RailSystemRepository;
import nl.andrewl.railsignalapi.dao.SignalBranchConnectionRepository;
import nl.andrewl.railsignalapi.dao.SignalRepository;
import nl.andrewl.railsignalapi.model.*;
import nl.andrewl.railsignalapi.rest.dto.SignalConnectionsUpdatePayload;
import nl.andrewl.railsignalapi.rest.dto.SignalCreationPayload;
import nl.andrewl.railsignalapi.rest.dto.SignalResponse;
import nl.andrewl.railsignalapi.websocket.BranchUpdateMessage;
@ -32,6 +34,7 @@ public class SignalService {
private final RailSystemRepository railSystemRepository;
private final SignalRepository signalRepository;
private final BranchRepository branchRepository;
private final SignalBranchConnectionRepository signalBranchConnectionRepository;
private final ObjectMapper mapper = new ObjectMapper();
private final Map<WebSocketSession, Set<Long>> signalWebSocketSessions = new ConcurrentHashMap<>();
@ -43,6 +46,14 @@ public class SignalService {
if (signalRepository.existsByNameAndRailSystem(payload.name(), rs)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Signal " + payload.name() + " already exists.");
}
if (payload.branchConnections().size() != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Exactly two branch connections must be provided.");
}
// Ensure that the directions of the connections are opposite each other.
Direction dir1 = Direction.parse(payload.branchConnections().get(0).direction());
Direction dir2 = Direction.parse(payload.branchConnections().get(1).direction());
if (!dir1.isOpposite(dir2)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Branch connections must be opposite each other.");
Set<SignalBranchConnection> branchConnections = new HashSet<>();
Signal signal = new Signal(rs, payload.name(), payload.position(), branchConnections);
for (var branchData : payload.branchConnections()) {
@ -61,6 +72,39 @@ public class SignalService {
return new SignalResponse(signal);
}
@Transactional
public SignalResponse updateSignalBranchConnections(long rsId, long sigId, SignalConnectionsUpdatePayload payload) {
var signal = signalRepository.findByIdAndRailSystemId(sigId, rsId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
for (var c : payload.connections()) {
var fromConnection = signalBranchConnectionRepository.findById(c.from())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find signal branch connection: " + c.from()));
if (!fromConnection.getSignal().getId().equals(signal.getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Can only update signal branch connections originating from the specified signal.");
}
var toConnection = signalBranchConnectionRepository.findById(c.to())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Could not find signal branch connection: " + c.to()));
if (!fromConnection.getBranch().getId().equals(toConnection.getBranch().getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Signal branch connections can only path via a mutual branch.");
}
fromConnection.getReachableSignalConnections().add(toConnection);
signalBranchConnectionRepository.save(fromConnection);
}
for (var con : signal.getBranchConnections()) {
Set<SignalBranchConnection> connectionsToRemove = new HashSet<>();
for (var reachableCon : con.getReachableSignalConnections()) {
if (!payload.connections().contains(new SignalConnectionsUpdatePayload.ConnectionData(con.getId(), reachableCon.getId()))) {
connectionsToRemove.add(reachableCon);
}
}
con.getReachableSignalConnections().removeAll(connectionsToRemove);
signalBranchConnectionRepository.save(con);
}
// Reload the signal.
signal = signalRepository.findById(signal.getId()).orElseThrow();
return new SignalResponse(signal);
}
@Transactional
public void registerSignalWebSocketSession(Set<Long> signalIds, WebSocketSession session) {
this.signalWebSocketSessions.put(session, signalIds);

View File

@ -11,26 +11,30 @@ function worldTransform() {
return tx;
}
function mousePointToWorld(event) {
const rect = railMapCanvas[0].getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return worldTransform().invertSelf().transformPoint(new DOMPoint(x, y, 0, 1));
function canvasPointToWorld(p) {
return worldTransform().invertSelf().transformPoint(p);
}
function worldPointToCanvas(p) {
return worldTransform().transformPoint(p);
}
function signalTransform(worldTx, signal) {
let tx = DOMMatrix.fromMatrix(worldTx);
tx.translateSelf(signal.position.x, signal.position.z, 0);
let direction = signal.branchConnections[0].direction;
if (direction === "EAST" || direction === "SOUTH" || direction === "SOUTH_EAST" || direction === "SOUTH_WEST") {
direction = signal.branchConnections[1].direction;
}
if (direction === undefined || direction === null || direction === "") {
direction = "NORTH";
}
let angle = 0;
if (direction === "NORTH" || direction === "SOUTH") {
if (direction === "NORTH") {
angle = 90;
} else if (direction === "NORTH_WEST" || direction === "SOUTH_EAST") {
} else if (direction === "NORTH_WEST") {
angle = 45;
} else if (direction === "NORTH_EAST" || direction === "SOUTH_WEST") {
} else if (direction === "NORTH_EAST") {
angle = 135;
}
tx.rotateSelf(0, 0, angle);
@ -56,8 +60,6 @@ function drawRailSystem() {
ctx.font = '24px Serif';
let textLine = 0;
hoveredElements.forEach(element => {
console.log('printing!')
console.log(element);
ctx.strokeText(element.name, 10, 20 + textLine * 20);
ctx.fillText(element.name, 10, 20 + textLine * 20);
textLine += 1;
@ -72,17 +74,17 @@ function drawSignal(ctx, signal) {
}
ctx.scale(2, 2);
ctx.fillRect(-0.5, -0.5, 1, 1);
let firstCon = signal.branchConnections[0];
let secondCon = signal.branchConnections[1];
if (firstCon === "EAST" || firstCon === "SOUTH" || firstCon === "SOUTH_WEST" || firstCon === "SOUTH_EAST") {
let tmp = firstCon;
firstCon = secondCon;
secondCon = tmp;
let northWesterlyCon = signal.branchConnections[0];
let southEasterlyCon = signal.branchConnections[1];
if (northWesterlyCon.direction === "EAST" || northWesterlyCon.direction === "SOUTH" || northWesterlyCon.direction === "SOUTH_WEST" || northWesterlyCon.direction === "SOUTH_EAST") {
let tmp = northWesterlyCon;
northWesterlyCon = southEasterlyCon;
southEasterlyCon = tmp;
}
ctx.fillStyle = getSignalColor(signal, firstCon.branch.status);
ctx.fillStyle = getSignalColor(signal, southEasterlyCon.branch.status);
ctx.fillRect(-0.75, -0.4, 0.3, 0.8);
ctx.fillStyle = getSignalColor(signal, secondCon.branch.status);
ctx.fillStyle = getSignalColor(signal, northWesterlyCon.branch.status);
ctx.fillRect(0.45, -0.4, 0.3, 0.8);
}
@ -97,15 +99,33 @@ function getSignalColor(signal, branchStatus) {
}
}
// Draws lines indicating reachable paths between this signal and others, with arrows for directionality.
function drawReachableConnections(ctx, signal) {
ctx.strokeStyle = 'black';
ctx.lineWidth = 0.25;
signal.branchConnections.forEach(connection => {
ctx.resetTransform();
connection.reachableSignalConnections.forEach(reachableCon => {
const dx = reachableCon.signalPosition.x - signal.position.x;
const dy = reachableCon.signalPosition.z - signal.position.z;
const dist = Math.sqrt(dx * dx + dy * dy);
let tx = worldTransform();
tx.translateSelf(signal.position.x, signal.position.z, 0);
const angle = Math.atan2(dy, dx) * 180 / Math.PI - 90;
tx.rotateSelf(0, 0, angle);
ctx.setTransform(tx);
ctx.beginPath();
ctx.moveTo(signal.position.x, signal.position.z);
ctx.lineTo(reachableCon.signalPosition.x, reachableCon.signalPosition.z);
ctx.moveTo(0, 0);
ctx.lineTo(0, dist);
const arrowEnd = 5;
const arrowWidth = 0.5;
const arrowLength = 1;
ctx.lineTo(0, arrowEnd);
ctx.lineTo(arrowWidth, arrowEnd - arrowLength);
ctx.lineTo(-arrowWidth, arrowEnd - arrowLength);
ctx.lineTo(0, arrowEnd);
ctx.stroke();
ctx.fill();
});
});
}

View File

@ -9,8 +9,8 @@ let canvasDragOrigin = null;
let canvasDragTranslation = null;
let hoveredElements = [];
const SCALE_VALUES = [0.01, 0.1, 1.0, 1.25, 1.5, 2.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 = 5;
const SCALE_VALUES = [0.01, 0.1, 0.25, 0.5, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 30.0, 45.0, 60.0, 80.0, 100.0];
const SCALE_INDEX_NORMAL = 7;
let canvasScaleIndex = SCALE_INDEX_NORMAL;
$(document).ready(() => {
@ -18,10 +18,10 @@ $(document).ready(() => {
railSystemSelect.change(railSystemChanged);
railMapCanvas = $('#railMapCanvas');
railMapCanvas.on('wheel', onMouseWheel);
railMapCanvas.mousedown(onMouseDown);
railMapCanvas.mouseup(onMouseUp);
railMapCanvas.mousemove(onMouseMove);
railMapCanvas.on('wheel', onCanvasMouseWheel);
railMapCanvas.mousedown(onCanvasMouseDown);
railMapCanvas.mouseup(onCanvasMouseUp);
railMapCanvas.mousemove(onCanvasMouseMove);
$.get("/api/railSystems")
.done(railSystems => {
@ -34,7 +34,8 @@ $(document).ready(() => {
});
});
function onMouseWheel(event) {
// Handle mouse scrolling within the context of the canvas.
function onCanvasMouseWheel(event) {
let s = event.originalEvent.deltaY;
if (s > 0) {
canvasScaleIndex = Math.max(0, canvasScaleIndex - 1);
@ -45,33 +46,40 @@ function onMouseWheel(event) {
event.stopPropagation();
}
function onMouseDown(event) {
const rect = railMapCanvas[0].getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
canvasDragOrigin = {x: x, y: y};
// Handle mouse clicks on the canvas.
function onCanvasMouseDown(event) {
const p = getMousePoint(event);
canvasDragOrigin = {x: p.x, y: p.y};
}
function onMouseUp(event) {
// Handle mouse release on the canvas, which stops dragging or indicates that the user may have clicked on something.
function onCanvasMouseUp(event) {
if (canvasDragTranslation !== null) {
canvasTranslation.x += canvasDragTranslation.x;
canvasTranslation.y += canvasDragTranslation.y;
} else {
const p = mousePointToWorld(event);
const p = getMousePoint(event);
let signalClicked = false;
railSystem.signals.forEach(signal => {
const sp = new DOMPoint(signal.position.x, signal.position.z, 0, 1);
const dist = Math.sqrt(Math.pow(p.x - sp.x, 2) + Math.pow(p.y - sp.y, 2));
if (dist < 1) {
const canvasSp = worldPointToCanvas(sp);
const dist = Math.sqrt(Math.pow(p.x - canvasSp.x, 2) + Math.pow(p.y - canvasSp.y, 2));
if (dist < 5) {
console.log(signal);
$('#testingText').val(JSON.stringify(signal, null, 2));
onSignalSelected(signal);
signalClicked = true;
}
});
if (!signalClicked) {
onSignalSelected(null);
}
}
canvasDragOrigin = null;
canvasDragTranslation = null;
}
function onMouseMove(event) {
// Handle mouse motion over the canvas. This is for dragging and hovering over items.
function onCanvasMouseMove(event) {
const rect = railMapCanvas[0].getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
@ -83,11 +91,12 @@ function onMouseMove(event) {
drawRailSystem();
} else {
hoveredElements = [];
const p = mousePointToWorld(event);
const p = getMousePoint(event);
railSystem.signals.forEach(signal => {
const sp = new DOMPoint(signal.position.x, signal.position.z, 0, 1);
const dist = Math.sqrt(Math.pow(p.x - sp.x, 2) + Math.pow(p.y - sp.y, 2));
if (dist < 1) {
const canvasSp = worldPointToCanvas(sp);
const dist = Math.sqrt(Math.pow(p.x - canvasSp.x, 2) + Math.pow(p.y - canvasSp.y, 2));
if (dist < 5) {
hoveredElements.push(signal);
}
});
@ -95,6 +104,13 @@ function onMouseMove(event) {
}
}
function getMousePoint(event) {
const rect = railMapCanvas[0].getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return new DOMPoint(x, y, 0, 1);
}
function railSystemChanged() {
railSystem = {};
railSystem.id = railSystemSelect.val();
@ -102,14 +118,30 @@ function railSystemChanged() {
.done(signals => {
railSystem.signals = signals;
let bb = getRailSystemBoundingBox();
// canvasTranslation.x = -1 * (bb.x + (bb.width / 2));
// canvasTranslation.y = -1 * (bb.y + (bb.height / 2));
// canvasScaleIndex = SCALE_INDEX_NORMAL;
canvasTranslation.x = -1 * (bb.x + (bb.width / 2));
canvasTranslation.y = -1 * (bb.y + (bb.height / 2));
canvasScaleIndex = SCALE_INDEX_NORMAL;
drawRailSystem();
window.setTimeout(railSystemChanged, 1000);
});
$.get("/api/railSystems/" + railSystem.id + "/branches")
.done(branches => {
railSystem.branches = branches;
});
}
function selectSignalById(id) {
railSystem.signals.forEach(signal => {
if (signal.id === id) {
onSignalSelected(signal);
}
});
}
function onSignalSelected(signal) {
const dp = $('#railMapDetailPanel');
dp.empty();
if (signal !== null) {
const tpl = Handlebars.compile($('#signalTemplate').html());
dp.html(tpl(signal));
}
}

View File

@ -37,21 +37,52 @@
<hr class="my-4"/>
<div class="row">
<div id="railMapCanvasParent" class="col border p-0">
<canvas id="railMapCanvas" width="1000" height="500"></canvas>
<div id="railMapCanvasParent" class="col">
<canvas id="railMapCanvas" width="800" height="500" class="border"></canvas>
</div>
<div id="railMapDetailPanel" class="col p-2 border">
</div>
</div>
</div>
<div class="row">
<textarea id="testingText"></textarea>
</div>
<div style="display: none;">
<script id="signalTemplate" type="text/x-handlebars-template">
<h3>{{name}}</h3>
<hr>
<dl class="row">
<dt class="col-sm-3">ID</dt>
<dd class="col-sm-9">{{id}}</dd>
<dt class="col-sm-3">Position</dt>
<dd class="col-sm-9">{{position.x}}, {{position.y}}, {{position.z}}</dd>
<dt class="col-sm-3">Online</dt>
<dd class="col-sm-9">{{online}}</dd>
</dl>
<h4>Connections</h4>
<hr>
{{#each branchConnections}}
<h5>{{this.direction}} <small class="text-muted">{{this.id}}</small></h5>
<dl class="row">
<dt class="col-sm-3">Branch</dt>
<dd class="col-sm-9">{{this.branch.name}} <small class="text-muted">{{this.branch.id}}</small></dd>
{{#if this.reachableSignalConnections.length}}
<dt class="col-sm-3">Reachable Signals</dt>
<dd class="col-sm-9">
{{#each this.reachableSignalConnections}}
<p><a onclick="selectSignalById({{this.signalId}})" href="#">{{this.signalName}}</a> <small class="text-muted">{{this.signalId}}</small> via {{this.direction}} connection {{this.connectionId}}</p>
{{/each}}
</dd>
{{/if}}
</dl>
{{/each}}
</script>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.7/handlebars.min.js" integrity="sha512-RNLkV3d+aLtfcpEyFG8jRbnWHxUqVZozacROI4J2F1sTaDqo1dPQYs01OMi1t1w9Y2FdbSCDSQ2ZVdAC8bzgAg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/static/js/drawing.js"></script>
<script src="/static/js/main.js"></script>
</body>
</html>