tram icon, video-previews, resizing when inactive
All checks were successful
release-tag / release-image (push) Successful in 53s
All checks were successful
release-tag / release-image (push) Successful in 53s
This commit is contained in:
@ -28,6 +28,20 @@ import { Viewport } from "pixi-viewport";
|
||||
import { Text, TextStyle, FillGradient, Assets } from "pixi.js";
|
||||
import { API_URL, GEO_URL } from "../config";
|
||||
import sightIcon from "../icons/sight.svg";
|
||||
import tramRight from "../icons/tram-right.svg";
|
||||
import tramLeft from "../icons/tram-left.svg";
|
||||
import tramTopRight from "../icons/tram-top-right.svg";
|
||||
import tramBottomRight from "../icons/tram-bottom-right.svg";
|
||||
import tramTopLeft from "../icons/tram-top-left.svg";
|
||||
import tramBottomLeft from "../icons/tram-bottom-left.svg";
|
||||
const arrowTransforms = {
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
"bottom-left": [1, 1],
|
||||
"top-left": [1, -1],
|
||||
"bottom-right": [-1, 1],
|
||||
"top-right": [-1, -1],
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "MainMap",
|
||||
@ -38,6 +52,9 @@ export default {
|
||||
viewport: null,
|
||||
routeGraphics: null,
|
||||
videoOverlayVisible: false,
|
||||
lastActivityTime: Date.now(),
|
||||
inactivityInterval: null,
|
||||
lastVideoPreviewId: null,
|
||||
stationContainer: null,
|
||||
sightsContainer: null,
|
||||
toCanvasPoint: null,
|
||||
@ -45,15 +62,63 @@ export default {
|
||||
attemptCount: 0,
|
||||
routeGroup: null,
|
||||
routeLatlngs: [],
|
||||
sights: [],
|
||||
tramPollTimer: null,
|
||||
passedGraphics: null,
|
||||
remainingGraphics: null,
|
||||
tramGraphic: null,
|
||||
tramSprite: null,
|
||||
tramContainer: null,
|
||||
tramArrow: null,
|
||||
currentTramDirection: "right",
|
||||
manualTramDirection: null,
|
||||
manualDirectionUntil: 0,
|
||||
inactivityTimer: null,
|
||||
chooseTramDirection(canvasPt) {
|
||||
// canvasPt: [x, y] in PIXI coords
|
||||
let bestDir = "right";
|
||||
let bestScore = Infinity;
|
||||
const candidates = arrowTransforms;
|
||||
|
||||
Object.entries(candidates).forEach(([dir, vec]) => {
|
||||
const len = Math.hypot(vec[0], vec[1]);
|
||||
const unit = [vec[0] / len, vec[1] / len];
|
||||
let score = 0;
|
||||
[30, 60, 90].forEach((d) => {
|
||||
const probeX = canvasPt[0] + unit[0] * d;
|
||||
const probeY = canvasPt[1] + unit[1] * d;
|
||||
// penalize proximity to stations
|
||||
this.stationContainer.children.forEach((node) => {
|
||||
const dx = probeX - node.x;
|
||||
const dy = probeY - node.y;
|
||||
if (Math.hypot(dx, dy) < 25) score += 3;
|
||||
});
|
||||
// penalize proximity to sights
|
||||
this.sightsContainer.children.forEach((node) => {
|
||||
const dx = probeX - node.x;
|
||||
const dy = probeY - node.y;
|
||||
if (Math.hypot(dx, dy) < 25) score += 3;
|
||||
});
|
||||
// penalize proximity to route segments
|
||||
const pts = this.routeLatlngs.map((pt) => this.toCanvasPoint(pt));
|
||||
pts.forEach((rpt) => {
|
||||
const dx = probeX - rpt.x;
|
||||
const dy = probeY - rpt.y;
|
||||
if (Math.hypot(dx, dy) < 20) score += 1;
|
||||
});
|
||||
});
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestDir = dir;
|
||||
}
|
||||
});
|
||||
return bestDir;
|
||||
},
|
||||
routeRotate: 0,
|
||||
routeCentered: false,
|
||||
viewportAdjusted: false, // авто‑фит сделан?
|
||||
clampApplied: false, // границы перемещения выставлены?
|
||||
stationZoomed: false, // приблизили ли к станции?
|
||||
viewportAdjusted: false,
|
||||
clampApplied: false,
|
||||
stationZoomed: false,
|
||||
centerLatitude: null,
|
||||
centerLongitude: null,
|
||||
};
|
||||
@ -62,9 +127,93 @@ export default {
|
||||
await this.createPixiApp();
|
||||
await this.fetchContext();
|
||||
this.startRoutePolling();
|
||||
this.startInactivityWatcher();
|
||||
this.setupActivityListeners();
|
||||
this.startInactivityCheck();
|
||||
},
|
||||
|
||||
methods: {
|
||||
setupActivityListeners() {
|
||||
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
|
||||
(evt) => window.addEventListener(evt, this.resetInactivity)
|
||||
);
|
||||
},
|
||||
resetInactivity() {
|
||||
this.lastActivityTime = Date.now();
|
||||
if (this.videoOverlayVisible) this.hideVideoOverlay();
|
||||
},
|
||||
startInactivityCheck() {
|
||||
if (this.inactivityInterval) return;
|
||||
this.inactivityInterval = setInterval(() => {
|
||||
if (Date.now() - this.lastActivityTime >= 120000) {
|
||||
this.showNearestSightVideo();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
stopInactivityCheck() {
|
||||
if (this.inactivityInterval) {
|
||||
clearInterval(this.inactivityInterval);
|
||||
this.inactivityInterval = null;
|
||||
}
|
||||
},
|
||||
async showNearestSightVideo() {
|
||||
console.log(
|
||||
"[videoOverlay] Attempting to show nearest sight video at",
|
||||
new Date().toISOString()
|
||||
);
|
||||
if (!this.sights || !this.sights.length) {
|
||||
console.log("[videoOverlay] No sights available to select video.");
|
||||
return;
|
||||
}
|
||||
// select a sight with a video_preview, fallback to first
|
||||
const sight = this.sights.find((s) => s.video_preview) || this.sights[0];
|
||||
const mediaId = sight.video_preview;
|
||||
console.log("[videoOverlay] Selected mediaId:", mediaId);
|
||||
if (!mediaId) {
|
||||
console.log("[videoOverlay] No mediaId found on selected sight.");
|
||||
return;
|
||||
}
|
||||
if (this.lastVideoPreviewId === mediaId) {
|
||||
console.log(
|
||||
"[videoOverlay] mediaId same as last. Showing existing overlay."
|
||||
);
|
||||
this.videoOverlayVisible = true;
|
||||
return;
|
||||
}
|
||||
const src = `${API_URL}/media/${mediaId}/download`;
|
||||
this.replaceVideo(src, mediaId);
|
||||
},
|
||||
replaceVideo(src, previewId) {
|
||||
console.log(
|
||||
"[videoOverlay] replaceVideo called with src:",
|
||||
src,
|
||||
"previewId:",
|
||||
previewId
|
||||
);
|
||||
const overlay = document.getElementById("video-overlay");
|
||||
if (!overlay) return;
|
||||
const vid = document.createElement("video");
|
||||
vid.src = src;
|
||||
vid.autoplay = true;
|
||||
vid.muted = true;
|
||||
vid.loop = true;
|
||||
vid.playsInline = true;
|
||||
vid.style.cssText = "width:100%;height:100%;object-fit:cover;";
|
||||
vid.addEventListener("canplay", () => {
|
||||
[...overlay.children].forEach((child) => {
|
||||
if (child !== vid) overlay.removeChild(child);
|
||||
});
|
||||
this.videoOverlayVisible = true;
|
||||
this.lastVideoPreviewId = previewId;
|
||||
});
|
||||
overlay.appendChild(vid);
|
||||
},
|
||||
hideVideoOverlay() {
|
||||
this.videoOverlayVisible = false;
|
||||
const overlay = document.getElementById("video-overlay");
|
||||
if (overlay) overlay.innerHTML = "";
|
||||
this.lastVideoPreviewId = null;
|
||||
},
|
||||
async createPixiApp() {
|
||||
const container = document.getElementById("map");
|
||||
if (!container || this.pixiApp) return;
|
||||
@ -80,6 +229,14 @@ export default {
|
||||
// add canvas to DOM (missing after last refactor)
|
||||
container.appendChild(this.pixiApp.canvas);
|
||||
await Assets.load(sightIcon);
|
||||
await Assets.load([
|
||||
tramRight,
|
||||
tramLeft,
|
||||
tramTopRight,
|
||||
tramBottomRight,
|
||||
tramTopLeft,
|
||||
tramBottomLeft,
|
||||
]);
|
||||
|
||||
// гарантируем, что канвас всегда принимает pointer‑/touch‑события
|
||||
this.pixiApp.canvas.style.pointerEvents = "auto";
|
||||
@ -381,28 +538,9 @@ export default {
|
||||
// гарантируем, что подписи горизонтальны
|
||||
this.updateLabelOrientation();
|
||||
|
||||
/* ─── Center the view on the first station (once, keep current zoom) ─── */
|
||||
if (!this.stationZoomed && this.stationContainer.children.length) {
|
||||
const firstNode = this.stationContainer.children[0];
|
||||
if (firstNode) {
|
||||
// мировые координаты станции (учитывают поворот/масштаб routeGroup)
|
||||
const b = firstNode.getBounds();
|
||||
const worldPos = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
||||
|
||||
// плавно перемещаем камеру к станции, масштаб НЕ меняем
|
||||
this.viewport.animate({
|
||||
time: 600,
|
||||
position: { x: worldPos.x, y: worldPos.y },
|
||||
easing: "easeInOutSine",
|
||||
});
|
||||
this.stationZoomed = true;
|
||||
console.log(
|
||||
`%c[centerToStation] world=(${worldPos.x.toFixed(
|
||||
1
|
||||
)},${worldPos.y.toFixed(1)})`,
|
||||
"color: plum"
|
||||
);
|
||||
}
|
||||
this.autoFitViewport();
|
||||
this.stationZoomed = true;
|
||||
}
|
||||
},
|
||||
|
||||
@ -447,49 +585,29 @@ export default {
|
||||
const sw = this.viewport.screenWidth;
|
||||
const sh = this.viewport.screenHeight;
|
||||
|
||||
// Подбираем масштаб так, чтобы занять ~90% экрана
|
||||
const fill = 0.9;
|
||||
let scale = Math.min(sw / b.width, sh / b.height) * fill;
|
||||
let scale = Math.min(sw / b.width, sh / b.height);
|
||||
|
||||
// Учитываем scale_min / scale_max, если есть
|
||||
if (this.routeScaleMin !== null)
|
||||
scale = Math.max(scale, this.routeScaleMin / 10);
|
||||
if (this.routeScaleMax !== null)
|
||||
scale = Math.min(scale, this.routeScaleMax / 10);
|
||||
|
||||
if (this.centerLatitude != null && this.centerLongitude != null) {
|
||||
let target;
|
||||
if (this.tramContainer) {
|
||||
// глобальная позиция контейнера с трамваем
|
||||
const worldPos = this.tramContainer.getGlobalPosition();
|
||||
target = worldPos;
|
||||
} else if (this.centerLatitude != null && this.centerLongitude != null) {
|
||||
const local = this.toCanvasPoint([
|
||||
this.centerLatitude,
|
||||
this.centerLongitude,
|
||||
]);
|
||||
const global = this.routeGroup.toGlobal(
|
||||
new PIXI.Point(local.x, local.y)
|
||||
);
|
||||
this.viewport.setZoom(scale, global.x, global.y);
|
||||
target = this.routeGroup.toGlobal(new PIXI.Point(local.x, local.y));
|
||||
} else {
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + b.height / 2;
|
||||
this.viewport.setZoom(scale, cx, cy);
|
||||
target = new PIXI.Point(b.x + b.width / 2, b.y + b.height / 2);
|
||||
}
|
||||
this.viewport.animate({
|
||||
time: 600,
|
||||
scale: scale,
|
||||
position: { x: target.x, y: target.y },
|
||||
easing: "easeInOutSine",
|
||||
});
|
||||
this.viewportAdjusted = true;
|
||||
|
||||
if (this.centerLatitude != null && this.centerLongitude != null) {
|
||||
console.log(
|
||||
`%c[autoFitViewport] scale=${scale.toFixed(2)}, center=(${
|
||||
this.centerLatitude
|
||||
},${this.centerLongitude})`,
|
||||
"color: mediumspringgreen"
|
||||
);
|
||||
} else {
|
||||
const cx = b.x + b.width / 2;
|
||||
const cy = b.y + b.height / 2;
|
||||
console.log(
|
||||
`%c[autoFitViewport] scale=${scale.toFixed(2)}, center=(${cx.toFixed(
|
||||
1
|
||||
)},${cy.toFixed(1)})`,
|
||||
"color: mediumspringgreen"
|
||||
);
|
||||
}
|
||||
},
|
||||
applyPanClamp() {
|
||||
return;
|
||||
@ -564,6 +682,7 @@ export default {
|
||||
),
|
||||
fetch(`${API_URL}/route/${this.routeId}/sight`).then((r) => r.json()),
|
||||
]);
|
||||
this.sights = sights;
|
||||
|
||||
console.log(
|
||||
`%cFetchRoute attempt #${this.attemptCount}`,
|
||||
@ -609,25 +728,10 @@ export default {
|
||||
this.drawSights(sights);
|
||||
// compensate rotation for sight icons
|
||||
this.updateLabelOrientation();
|
||||
// после линии, станций и (возможного) поворота автоматически подгоняем viewport
|
||||
this.autoFitViewport();
|
||||
// сначала обновляем позицию трамвая, чтобы был this.tramContainer
|
||||
this.updateTramPosition();
|
||||
// center view on route-provided center after route load
|
||||
if (this.centerLatitude != null && this.centerLongitude != null) {
|
||||
const localCenter = this.toCanvasPoint([
|
||||
this.centerLatitude,
|
||||
this.centerLongitude,
|
||||
]);
|
||||
const globalCenter = this.routeGroup.toGlobal(
|
||||
new PIXI.Point(localCenter.x, localCenter.y)
|
||||
);
|
||||
// animate viewport to this center (keep current zoom)
|
||||
this.viewport.animate({
|
||||
time: 600,
|
||||
position: { x: globalCenter.x, y: globalCenter.y },
|
||||
easing: "easeInOutSine",
|
||||
});
|
||||
}
|
||||
// затем корректно центрируем и зумим вид
|
||||
this.autoFitViewport();
|
||||
gotPath = true;
|
||||
} else {
|
||||
console.warn(
|
||||
@ -646,6 +750,10 @@ export default {
|
||||
if (this.attemptCount > 1e6) this.attemptCount = 0;
|
||||
}
|
||||
},
|
||||
setTramDirectionOverride(direction, durationMs) {
|
||||
this.manualTramDirection = direction;
|
||||
this.manualDirectionUntil = Date.now() + durationMs;
|
||||
},
|
||||
// ── Update tram position and color route based on progress ──
|
||||
async updateTramPosition() {
|
||||
if (!this.routeLatlngs || !this.routeLatlngs.length) return;
|
||||
@ -694,28 +802,146 @@ export default {
|
||||
// draw or update tram marker
|
||||
const current = this.routeLatlngs[passedCount];
|
||||
const { x, y } = this.toCanvasPoint(current);
|
||||
if (!this.tramGraphic) {
|
||||
this.tramGraphic = new PIXI.Graphics();
|
||||
// set fill style to yellow
|
||||
this.tramGraphic.fill(0xfcd500);
|
||||
// set stroke style for border
|
||||
this.tramGraphic.setStrokeStyle({
|
||||
width: 5,
|
||||
color: 0x000000,
|
||||
alignment: 0.5,
|
||||
alpha: 1,
|
||||
});
|
||||
// draw circle path
|
||||
this.tramGraphic.beginPath();
|
||||
this.tramGraphic.circle(0, 0, 14);
|
||||
// fill the circle
|
||||
this.tramGraphic.fill();
|
||||
// draw the stroke
|
||||
this.tramGraphic.stroke();
|
||||
this.routeGroup.addChild(this.tramGraphic);
|
||||
|
||||
let direction;
|
||||
const now = Date.now();
|
||||
if (this.manualTramDirection && this.manualDirectionUntil > now) {
|
||||
direction = this.manualTramDirection;
|
||||
} else {
|
||||
direction = this.chooseTramDirection([x, y]);
|
||||
// clear expired override
|
||||
if (this.manualDirectionUntil <= now) {
|
||||
this.manualTramDirection = null;
|
||||
}
|
||||
}
|
||||
this.tramGraphic.x = x;
|
||||
this.tramGraphic.y = y;
|
||||
|
||||
// this.setTramDirectionOverride("bottom-right", 60000);
|
||||
|
||||
const svgMap = {
|
||||
right: tramRight,
|
||||
left: tramLeft,
|
||||
"top-right": tramTopRight,
|
||||
"bottom-right": tramBottomRight,
|
||||
"top-left": tramTopLeft,
|
||||
"bottom-left": tramBottomLeft,
|
||||
};
|
||||
const projSrc = svgMap[direction];
|
||||
|
||||
// создаём контейнер с красным кружком и стрелкой, если ещё нет
|
||||
if (!this.tramContainer) {
|
||||
this.tramContainer = new PIXI.Container();
|
||||
|
||||
// 1) базовый кружок красного маркера
|
||||
const circle = new PIXI.Graphics();
|
||||
circle
|
||||
.beginFill("#FCD500")
|
||||
.lineStyle(6, "#000")
|
||||
.drawCircle(0, 0, 14)
|
||||
.endFill();
|
||||
this.tramContainer.addChild(circle);
|
||||
|
||||
// 2) стрелка‑проекция
|
||||
// Horizontal arrows flush to red circle, diagonal by anchor
|
||||
const CIRCLE_D = 28; // diameter of the red circle (2*14)
|
||||
const arrow = PIXI.Sprite.from(projSrc);
|
||||
if (direction === "right") {
|
||||
arrow.width = 112;
|
||||
arrow.height = 82;
|
||||
arrow.anchor.set(0, 0.5);
|
||||
arrow.x = CIRCLE_D / 2 - 12;
|
||||
arrow.y = 0;
|
||||
} else if (direction === "left") {
|
||||
arrow.width = 112;
|
||||
arrow.height = 82;
|
||||
arrow.anchor.set(1, 0.5);
|
||||
arrow.x = -CIRCLE_D / 2 + 12;
|
||||
arrow.y = 0;
|
||||
} else {
|
||||
// diagonal orientations
|
||||
arrow.width = 93;
|
||||
arrow.height = 97;
|
||||
// bottom-right: top-left corner flush to marker
|
||||
if (direction === "bottom-right") {
|
||||
arrow.anchor.set(0, 0);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
}
|
||||
// top-right: bottom-left corner flush
|
||||
else if (direction === "top-right") {
|
||||
arrow.anchor.set(0, 1);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
}
|
||||
// bottom-left: top-right corner flush
|
||||
else if (direction === "bottom-left") {
|
||||
arrow.anchor.set(1, 0);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
}
|
||||
// top-left: bottom-right corner flush
|
||||
else if (direction === "top-left") {
|
||||
arrow.anchor.set(1, 1);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
}
|
||||
// Removed any diagonal rotation or scale flips
|
||||
}
|
||||
// keep arrow upright regardless of route rotation
|
||||
arrow.rotation = -this.routeGroup.rotation;
|
||||
this.tramContainer.addChild(arrow);
|
||||
this.tramArrow = arrow;
|
||||
this.tramDirection = direction;
|
||||
this.routeGroup.addChild(this.tramContainer);
|
||||
}
|
||||
// update existing arrow sprite on direction change
|
||||
if (this.tramArrow && direction !== this.tramDirection) {
|
||||
const arrow = this.tramArrow;
|
||||
const projSrc = svgMap[direction];
|
||||
arrow.texture = PIXI.Texture.from(projSrc);
|
||||
// size & anchor & position logic (same as creation)
|
||||
const CIRCLE_D = 28;
|
||||
if (direction === "right") {
|
||||
arrow.width = 112;
|
||||
arrow.height = 82;
|
||||
arrow.anchor.set(0, 0.5);
|
||||
arrow.x = CIRCLE_D / 2 - 12;
|
||||
arrow.y = 0;
|
||||
} else if (direction === "left") {
|
||||
arrow.width = 112;
|
||||
arrow.height = 82;
|
||||
arrow.anchor.set(1, 0.5);
|
||||
arrow.x = -CIRCLE_D / 2 + 12;
|
||||
arrow.y = 0;
|
||||
} else {
|
||||
arrow.width = 93;
|
||||
arrow.height = 97;
|
||||
if (direction === "bottom-right") {
|
||||
arrow.anchor.set(0, 0);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
} else if (direction === "top-right") {
|
||||
arrow.anchor.set(0, 1);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
} else if (direction === "bottom-left") {
|
||||
arrow.anchor.set(1, 0);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
} else if (direction === "top-left") {
|
||||
arrow.anchor.set(1, 1);
|
||||
arrow.x = 0;
|
||||
arrow.y = 0;
|
||||
}
|
||||
// Removed any diagonal rotation or scale flips
|
||||
}
|
||||
// keep arrow upright regardless of route rotation
|
||||
arrow.rotation = -this.routeGroup.rotation;
|
||||
this.tramDirection = direction;
|
||||
}
|
||||
|
||||
// позиционируем контейнер по маркеру
|
||||
this.tramContainer.x = x;
|
||||
this.tramContainer.y = y;
|
||||
|
||||
// update station marker colors based on tram progress
|
||||
this.stationContainer.children.forEach((node) => {
|
||||
@ -851,6 +1077,43 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
// reset the inactivity timeout
|
||||
resetInactivityTimer() {
|
||||
clearTimeout(this.inactivityTimer);
|
||||
this.inactivityTimer = setTimeout(() => {
|
||||
if (this.tramContainer) {
|
||||
// compute tram position in viewport world coordinates
|
||||
const localPoint = new PIXI.Point(
|
||||
this.tramContainer.x,
|
||||
this.tramContainer.y
|
||||
);
|
||||
const worldCenter = this.viewport.toLocal(
|
||||
localPoint,
|
||||
this.routeGroup
|
||||
);
|
||||
// smooth pan and zoom to tram
|
||||
this.viewport.animate({
|
||||
time: 600,
|
||||
position: { x: worldCenter.x, y: worldCenter.y },
|
||||
scale: 1,
|
||||
easing: "easeInOutSine",
|
||||
});
|
||||
// schedule next recenter
|
||||
this.resetInactivityTimer();
|
||||
}
|
||||
}, 15000);
|
||||
},
|
||||
|
||||
// start watching for user interactions
|
||||
startInactivityWatcher() {
|
||||
if (!this.viewport) return;
|
||||
// reset on any user drag, wheel, or pinch
|
||||
this.viewport.on("drag-start", this.resetInactivityTimer);
|
||||
this.viewport.on("pointerdown", this.resetInactivityTimer);
|
||||
this.viewport.on("wheel", this.resetInactivityTimer);
|
||||
// initialize the timer
|
||||
this.resetInactivityTimer();
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopRoutePolling();
|
||||
@ -858,6 +1121,13 @@ export default {
|
||||
clearInterval(this.tramPollTimer);
|
||||
this.tramPollTimer = null;
|
||||
}
|
||||
if (this.inactivityInterval) {
|
||||
clearInterval(this.inactivityInterval);
|
||||
this.inactivityInterval = null;
|
||||
}
|
||||
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
|
||||
(evt) => window.removeEventListener(evt, this.resetInactivity)
|
||||
);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
30
src/icons/tram-icon.svg
Normal file
30
src/icons/tram-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 62 KiB |
Reference in New Issue
Block a user