Cleaned up map rendering code into modules.

This commit is contained in:
Andrew Lalis 2022-06-04 12:28:01 +02:00
parent 9c0d588543
commit 9b6eb7b667
9 changed files with 628 additions and 477 deletions

View File

@ -1,7 +1,7 @@
<template>
<div class="row">
<div class="col-md-8" id="railSystemMapCanvasContainer">
<canvas id="railSystemMapCanvas" height="600">
<canvas id="railSystemMapCanvas" height="700">
Your browser doesn't support canvas.
</canvas>
</div>
@ -21,17 +21,20 @@
<script>
import {RailSystem} from "src/api/railSystems";
import {draw, initMap} from "src/render/mapRenderer";
import {draw, initMap} from "src/map/mapRenderer";
import SelectedComponentView from "components/rs/SelectedComponentView.vue";
import {useQuasar} from "quasar";
import AddComponentForm from "components/rs/add_component/AddComponentForm.vue";
import { useRailSystemsStore } from "stores/railSystemsStore";
import { registerComponentSelectionListener } from "src/map/mapEventListener";
export default {
name: "MapView",
components: { AddComponentForm, SelectedComponentView },
setup() {
const rsStore = useRailSystemsStore();
const quasar = useQuasar();
return {quasar};
return {quasar, rsStore};
},
props: {
railSystem: {
@ -48,9 +51,11 @@ export default {
},
mounted() {
initMap(this.railSystem);
registerComponentSelectionListener("addComponentFormHide", () => this.addComponent.visible = false);
},
updated() {
initMap(this.railSystem);
registerComponentSelectionListener("addComponentFormHide", () => this.addComponent.visible = false);
},
watch: {
railSystem: {
@ -58,6 +63,11 @@ export default {
draw();
},
deep: true
},
'addComponent.visible'(newValue) { // Deselect all components when the user opens the "Add Component" form.
if (newValue === true) {
this.rsStore.selectedRailSystem.selectedComponents.length = 0;
}
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,133 @@
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 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) {
const finishingDrag = camPanNonzero();
camPanFinish();
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 (!finishingDrag) {
MAP_RAIL_SYSTEM.selectedComponents.length = 0;
}
}
/**
* Handle when the mouse moves.
* @param {MouseEvent} event
*/
function onMouseMove(event) {
const p = getMousePoint(event);
LAST_MOUSE_POINT = p;
if (camPanActive()) {
if (event.shiftKey) {
camPanMove({ x: p.x, y: p.y });
} else {
camPanFinish();
}
} else {
MAP_COMPONENTS_HOVERED.length = 0;
// Populate with list of hovered elements.
for (let i = 0; i < MAP_RAIL_SYSTEM.components.length; i++) {
const c = MAP_RAIL_SYSTEM.components[i];
const componentPoint = new DOMPoint(c.position.x, c.position.z, 0, 1);
const mapComponentPoint = camTransformWorldToMap(componentPoint);
const dist2 = (p.x - mapComponentPoint.x) * (p.x - mapComponentPoint.x) + (p.y - mapComponentPoint.y) * (p.y - mapComponentPoint.y);
if (dist2 < HOVER_RADIUS * HOVER_RADIUS) {
MAP_COMPONENTS_HOVERED.push(c);
}
}
}
draw();
}
/**
* @param {KeyboardEvent} event
*/
function onKeyDown(event) {
if (event.ctrlKey && event.code === "Space") {
camResetView();
}
}

View File

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

View File

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

View File

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