+ Id: {{component.id}} +
++ Position: (x = {{component.position.x}}, y = {{component.position.y}}, z = {{component.position.z}}) +
++ Type: {{component.type}} +
++ Online: {{component.online}} +
++ There are no connected nodes. +
+ + + + + \ No newline at end of file diff --git a/railsignal-app/src/components/railsystem/component/SegmentBoundaryNodeComponentView.vue b/railsignal-app/src/components/railsystem/component/SegmentBoundaryNodeComponentView.vue new file mode 100644 index 0000000..b2f3ba9 --- /dev/null +++ b/railsignal-app/src/components/railsystem/component/SegmentBoundaryNodeComponentView.vue @@ -0,0 +1,24 @@ + ++ Connected segment: {{signal.segment.id}} {{signal.segment.name}} +
+ + + + + \ No newline at end of file diff --git a/railsignal-app/src/components/railsystem/mapRenderer.js b/railsignal-app/src/components/railsystem/mapRenderer.js new file mode 100644 index 0000000..17007f8 --- /dev/null +++ b/railsignal-app/src/components/railsystem/mapRenderer.js @@ -0,0 +1,248 @@ +/* +This component is responsible for the rendering of a RailSystem in a 2d map +view. + */ + +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 mapCanvas = null; +let railSystem = null; + +let mapScaleIndex = SCALE_INDEX_NORMAL; +let mapTranslation = {x: 0, y: 0}; +let mapDragOrigin = null; +let mapDragTranslation = null; +let lastMousePoint = new DOMPoint(0, 0, 0, 0); +const hoveredElements = []; + +export function initMap(rs) { + railSystem = rs; + console.log("Initializing map for rail system: " + rs.name); + hoveredElements.length = 0; + mapCanvas = document.getElementById("railSystemMapCanvas"); + mapCanvas.removeEventListener("wheel", onMouseWheel); + mapCanvas.addEventListener("wheel", onMouseWheel); + mapCanvas.removeEventListener("mousedown", onMouseDown); + mapCanvas.addEventListener("mousedown", onMouseDown); + mapCanvas.removeEventListener("mouseup", onMouseUp); + mapCanvas.addEventListener("mouseup", onMouseUp); + mapCanvas.removeEventListener("mousemove", onMouseMove); + mapCanvas.addEventListener("mousemove", onMouseMove); + + // Do an initial draw. + draw(); +} + +export function draw() { + if (!(mapCanvas && railSystem && railSystem.components)) { + console.warn("Attempted to draw map without canvas or railSystem."); + return; + } + const ctx = mapCanvas.getContext("2d"); + const width = mapCanvas.width; + const height = mapCanvas.height; + ctx.resetTransform(); + ctx.fillStyle = `rgb(240, 240, 240)`; + ctx.fillRect(0, 0, width, height); + const worldTx = getWorldTransform(); + ctx.setTransform(worldTx); + + for (let i = 0; i < railSystem.components.length; i++) { + drawComponent(ctx, worldTx, railSystem.components[i]); + } + + // Draw debug info. + ctx.resetTransform(); + ctx.fillStyle = "black"; + ctx.strokeStyle = "black"; + ctx.font = "10px Sans-Serif"; + const lastWorldPoint = mapPointToWorld(lastMousePoint); + const lines = [ + "Scale factor: " + getScaleFactor(), + `(x = ${lastWorldPoint.x.toFixed(2)}, y = ${lastWorldPoint.y.toFixed(2)}, z = ${lastWorldPoint.z.toFixed(2)})`, + `Components: ${railSystem.components.length}`, + `Hovered elements: ${hoveredElements.length}` + ] + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], 10, 20 + (i * 15)); + } +} + +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(5, 5, 5); + ctx.setTransform(tx); + if (isComponentHovered(component)) { + ctx.fillStyle = `rgba(255, 255, 0, 32)`; + ctx.beginPath(); + ctx.ellipse(0, 0, 1.8, 1.8, 0, 0, Math.PI * 2); + ctx.fill(); + } + if (component.type === "SIGNAL") { + drawSignal(ctx, component); + } else if (component.type === "SEGMENT_BOUNDARY") { + drawSegmentBoundary(ctx, component); + } +} + +function drawSignal(ctx, signal) { + roundedRect(ctx, -0.7, -1, 1.4, 2, 0.25); + ctx.fillStyle = "black"; + ctx.fill(); + + // ctx.fillStyle = "green"; + // ctx.beginPath(); + // ctx.ellipse(0, 0, 0.8, 0.8, 0, 0, Math.PI * 2); + // ctx.fill(); + // + // ctx.strokeStyle = "black"; + // ctx.lineWidth = 0.5; + // ctx.beginPath(); + // ctx.ellipse(0, 0, 1, 1, 0, 0, Math.PI * 2); + // ctx.stroke(); +} + +function drawSegmentBoundary(ctx, segmentBoundary) { + ctx.fillStyle = "blue"; + ctx.beginPath(); + ctx.ellipse(0, 0, 1, 1, 0, 0, Math.PI * 2); + ctx.fill(); +} + +export function getScaleFactor() { + return SCALE_VALUES[mapScaleIndex]; +} + +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; +} + +function isComponentHovered(component) { + for (let i = 0; i < hoveredElements.length; i++) { + if (hoveredElements[i].id === component.id) return true; + } + return false; +} + +/** + * Maps a point on the map coordinates to world coordinates. + * @param {DOMPoint} p + * @returns {DOMPoint} + */ +function mapPointToWorld(p) { + return getWorldTransform().invertSelf().transformPoint(p); +} + +/** + * Maps a point in the world to map coordinates. + * @param {DOMPoint} p + * @returns {DOMPoint} + */ +function worldPointToMap(p) { + return getWorldTransform().transformPoint(p); +} + +function roundedRect(ctx, x, y, w, h, r) { + if (w < 2 * r) r = w / 2; + if (h < 2 * r) r = h / 2; + ctx.beginPath(); + ctx.moveTo(x+r, y); + ctx.arcTo(x+w, y, x+w, y+h, r); + ctx.arcTo(x+w, y+h, x, y+h, r); + ctx.arcTo(x, y+h, x, y, r); + ctx.arcTo(x, y, x+w, y, r); + ctx.closePath(); +} + +/* +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(); +} + +/** + * @param {MouseEvent} event + */ +function onMouseDown(event) { + const p = getMousePoint(event); + mapDragOrigin = {x: p.x, y: p.y}; +} + +function onMouseUp() { + if (mapDragTranslation !== null) { + mapTranslation.x += mapDragTranslation.x; + mapTranslation.y += mapDragTranslation.y; + } + if (hoveredElements.length === 1) { + railSystem.selectedComponent = hoveredElements[0]; + } else { + railSystem.selectedComponent = null; + } + 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); +} diff --git a/railsignal-app/src/stores/railSystemsStore.js b/railsignal-app/src/stores/railSystemsStore.js index d9747b1..ac9e411 100644 --- a/railsignal-app/src/stores/railSystemsStore.js +++ b/railsignal-app/src/stores/railSystemsStore.js @@ -4,13 +4,16 @@ import axios from "axios"; export const useRailSystemsStore = defineStore('RailSystemsStore', { state: () => ({ railSystems: [], + /** + * @type {{segments: [Object], components: [Object], selectedComponent: Object} | null} + */ selectedRailSystem: null, - selectedComponent: null + apiUrl: import.meta.env.VITE_API_URL }), actions: { refreshRailSystems() { return new Promise((resolve, reject) => { - axios.get(import.meta.env.VITE_API_URL + "/rs") + axios.get(this.apiUrl + "/rs") .then(response => { this.railSystems = response.data; resolve(); @@ -22,10 +25,7 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', { }, createRailSystem(name) { return new Promise((resolve, reject) => { - axios.post( - import.meta.env.VITE_API_URL + "/rs", - {name: name} - ) + axios.post(this.apiUrl + "/rs", {name: name}) .then(() => { this.refreshRailSystems() .then(() => resolve()) @@ -36,13 +36,69 @@ export const useRailSystemsStore = defineStore('RailSystemsStore', { }, removeRailSystem(rs) { return new Promise((resolve, reject) => { - axios.delete(import.meta.env.VITE_API_URL + "/rs/" + rs.id) + axios.delete(this.apiUrl + "/rs/" + rs.id) .then(() => { + this.selectedRailSystem = null; this.refreshRailSystems() .then(() => resolve) .catch(error => reject(error)); }) }) + }, + refreshSegments(rs) { + return new Promise(resolve => { + axios.get(`${this.apiUrl}/rs/${rs.id}/s`) + .then(response => { + rs.segments = response.data; + resolve(); + }); + }); + }, + refreshComponents(rs) { + return new Promise(resolve => { + axios.get(`${this.apiUrl}/rs/${rs.id}/c`) + .then(response => { + rs.components = response.data; + resolve(); + }); + }); + }, + fetchSelectedRailSystemData() { + if (!this.selectedRailSystem) return; + this.refreshSegments(this.selectedRailSystem); + this.refreshComponents(this.selectedRailSystem); + }, + addSegment(name) { + const rs = this.selectedRailSystem; + axios.post(`${this.apiUrl}/rs/${rs.id}/s`, {name: name}) + .then(() => this.refreshSegments(rs)) + .catch(error => console.log(error)); + }, + removeSegment(id) { + const rs = this.selectedRailSystem; + axios.delete(`${this.apiUrl}/rs/${rs.id}/s/${id}`) + .then(() => this.refreshSegments(rs)) + .catch(error => console.log(error)); + }, + addComponent(data) { + const rs = this.selectedRailSystem; + axios.post(`${this.apiUrl}/rs/${rs.id}/c`, data) + .then(() => this.refreshComponents(rs)) + .catch(error => console.log(error)); + }, + removeComponent(id) { + const rs = this.selectedRailSystem; + axios.delete(`${this.apiUrl}/rs/${rs.id}/c/${id}`) + .then(() => this.refreshComponents(rs)) + .catch(error => console.log(error)); + }, + fetchComponentData(component) { + return new Promise(resolve => { + const rs = this.selectedRailSystem; + axios.get(`${this.apiUrl}/rs/${rs.id}/c/${component.id}`) + .then(response => resolve(response.data)) + .catch(error => console.log(error)); + }); } } }); \ No newline at end of file diff --git a/src/main/java/nl/andrewl/railsignalapi/rest/ComponentsApiController.java b/src/main/java/nl/andrewl/railsignalapi/rest/ComponentsApiController.java index 1a83b3c..67b2763 100644 --- a/src/main/java/nl/andrewl/railsignalapi/rest/ComponentsApiController.java +++ b/src/main/java/nl/andrewl/railsignalapi/rest/ComponentsApiController.java @@ -2,8 +2,8 @@ package nl.andrewl.railsignalapi.rest; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; -import nl.andrewl.railsignalapi.rest.dto.component.ComponentResponse; -import nl.andrewl.railsignalapi.rest.dto.component.SimpleComponentResponse; +import nl.andrewl.railsignalapi.rest.dto.PathNodeUpdatePayload; +import nl.andrewl.railsignalapi.rest.dto.component.out.ComponentResponse; import nl.andrewl.railsignalapi.service.ComponentService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -17,7 +17,7 @@ public class ComponentsApiController { private final ComponentService componentService; @GetMapping - public List